package main import ( "bufio" "bytes" "encoding/json" "errors" "fmt" "go/ast" "go/format" "go/parser" "go/token" "io" "net/url" "os" "os/exec" "path/filepath" "regexp" "runtime" "sort" "strconv" "strings" "unicode" "gotlin/internal/lang" ) const ( textDocumentSyncFull = 1 diagnosticSeverityError = 1 diagnosticSeverityWarning = 2 symbolKindPackage = 4 symbolKindClass = 5 symbolKindMethod = 6 symbolKindField = 8 symbolKindVariable = 13 symbolKindFunction = 12 symbolKindInterface = 11 symbolKindEnum = 10 symbolKindModule = 2 ) var offsetPattern = regexp.MustCompile(` at (\d+)`) func main() { server := server{ in: bufio.NewReader(os.Stdin), out: os.Stdout, docs: map[string]documentState{}, goplsPath: resolveGoplsPath(), } if err := server.run(); err != nil && !errors.Is(err, io.EOF) { fmt.Fprintln(os.Stderr, err) os.Exit(1) } } type server struct { in *bufio.Reader out io.Writer docs map[string]documentState shutdown bool goplsPath string } type documentState struct { text string program *lang.Program diagnostics []diagnostic symbols []symbol packageSymbols []symbol } type symbol struct { Name string Kind int Detail string Range rng Targets []string URI string } type variableDeclInfo struct { Name string Mutable bool Type string } type request struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id,omitempty"` Method string `json:"method"` Params json.RawMessage `json:"params,omitempty"` } type response struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id,omitempty"` Result any `json:"result"` Error *respError `json:"error,omitempty"` } type respError struct { Code int `json:"code"` Message string `json:"message"` } type notification struct { JSONRPC string `json:"jsonrpc"` Method string `json:"method"` Params any `json:"params,omitempty"` } type didOpenParams struct { TextDocument textDocumentItem `json:"textDocument"` } type didChangeParams struct { TextDocument versionedTextDocumentIdentifier `json:"textDocument"` ContentChanges []contentChange `json:"contentChanges"` } type didCloseParams struct { TextDocument textDocumentIdentifier `json:"textDocument"` } type hoverParams struct { TextDocument textDocumentIdentifier `json:"textDocument"` Position position `json:"position"` } type definitionParams struct { TextDocument textDocumentIdentifier `json:"textDocument"` Position position `json:"position"` } type textDocumentItem struct { URI string `json:"uri"` Text string `json:"text"` } type versionedTextDocumentIdentifier struct { URI string `json:"uri"` } type textDocumentIdentifier struct { URI string `json:"uri"` } type contentChange struct { Text string `json:"text"` } type publishDiagnosticsParams struct { URI string `json:"uri"` Diagnostics []diagnostic `json:"diagnostics"` } type diagnostic struct { Range rng `json:"range"` Severity int `json:"severity,omitempty"` Source string `json:"source,omitempty"` Code string `json:"code,omitempty"` Message string `json:"message"` } type rng struct { Start position `json:"start"` End position `json:"end"` } type position struct { Line int `json:"line"` Character int `json:"character"` } type location struct { URI string `json:"uri"` Range rng `json:"range"` } type stdlibTarget struct { PackagePath string SymbolName string } type stdlibSymbol struct { FileName string Start position End position Decl string Doc string } func (s *server) run() error { for { msg, err := readMessage(s.in) if err != nil { return err } var req request if err := json.Unmarshal(msg, &req); err != nil { return err } if err := s.handle(req); err != nil { return err } if s.shutdown && req.Method == "exit" { return nil } } } func (s *server) handle(req request) error { switch req.Method { case "initialize": return s.writeResponse(response{ JSONRPC: "2.0", ID: req.ID, Result: map[string]any{ "capabilities": map[string]any{ "textDocumentSync": textDocumentSyncFull, "hoverProvider": true, "definitionProvider": true, }, "serverInfo": map[string]any{ "name": "gotlin-lsp", "version": "0.2.0", }, }, }) case "initialized": return nil case "shutdown": s.shutdown = true return s.writeResponse(response{JSONRPC: "2.0", ID: req.ID, Result: nil}) case "exit": return nil case "textDocument/didOpen": var params didOpenParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { return err } s.docs[params.TextDocument.URI] = s.buildDocumentState(params.TextDocument.URI, params.TextDocument.Text) return s.publishDiagnostics(params.TextDocument.URI) case "textDocument/didChange": var params didChangeParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { return err } text := "" if len(params.ContentChanges) > 0 { text = params.ContentChanges[len(params.ContentChanges)-1].Text } s.docs[params.TextDocument.URI] = s.buildDocumentState(params.TextDocument.URI, text) return s.publishDiagnostics(params.TextDocument.URI) case "textDocument/didClose": var params didCloseParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { return err } delete(s.docs, params.TextDocument.URI) return s.writeNotification(notification{ JSONRPC: "2.0", Method: "textDocument/publishDiagnostics", Params: publishDiagnosticsParams{URI: params.TextDocument.URI, Diagnostics: []diagnostic{}}, }) case "textDocument/hover": var params hoverParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { return err } return s.writeResponse(response{ JSONRPC: "2.0", ID: req.ID, Result: s.hover(params.TextDocument.URI, params.Position), }) case "textDocument/definition": var params definitionParams if err := json.Unmarshal(req.Params, ¶ms); err != nil { return err } return s.writeResponse(response{ JSONRPC: "2.0", ID: req.ID, Result: s.definition(params.TextDocument.URI, params.Position), }) default: if len(req.ID) == 0 { return nil } return s.writeResponse(response{ JSONRPC: "2.0", ID: req.ID, Error: &respError{Code: -32601, Message: "method not found"}, }) } } func buildDocumentState(text string) documentState { state := documentState{text: text, diagnostics: []diagnostic{}} program, err := lang.Parse(text) if err != nil { state.diagnostics = []diagnostic{diagnosticFromError(text, err)} return state } state.program = program _, semanticDiagnostics := lang.AnalyzeWithContext(program, nil) state.symbols = indexSymbols(text, program) state.diagnostics = diagnosticsFromSemantic(text, semanticDiagnostics) if len(semanticDiagnostics) == 0 { if _, err := lang.GenerateGo(program); err != nil { state.diagnostics = append(state.diagnostics, diagnosticFromError(text, err)) } } return state } func (s *server) buildDocumentState(uri, text string) documentState { state := documentState{text: text, diagnostics: []diagnostic{}} program, err := lang.Parse(text) if err != nil { state.diagnostics = []diagnostic{diagnosticFromError(text, err)} return state } state.program = program programs, symbols := s.packageContext(uri, program.PackagePath, text) _, semanticDiagnostics := lang.AnalyzeWithContext(program, programs) state.symbols = indexSymbols(text, program) state.packageSymbols = symbols state.diagnostics = diagnosticsFromSemantic(text, semanticDiagnostics) return state } func (s *server) packageContext(currentURI, packagePath, currentText string) ([]*lang.Program, []symbol) { path, ok := filePathFromURI(currentURI) if !ok { return nil, nil } root := nearestModuleRoot(filepath.Dir(path)) if root == "" { root = filepath.Dir(path) } var programs []*lang.Program var symbols []symbol _ = filepath.WalkDir(root, func(candidate string, entry os.DirEntry, err error) error { if err != nil || entry.IsDir() || filepath.Ext(candidate) != ".gt" { return nil } candidateURI := fileURI(candidate) text := "" if candidateURI == currentURI { text = currentText } else if open, found := s.docs[candidateURI]; found { text = open.text } else if data, readErr := os.ReadFile(candidate); readErr == nil { text = string(data) } if text == "" { return nil } program, parseErr := lang.Parse(text) if parseErr != nil || program.PackagePath != packagePath { return nil } if candidateURI != currentURI { programs = append(programs, program) } for _, sym := range indexSymbols(text, program) { sym.URI = candidateURI symbols = append(symbols, sym) } return nil }) return programs, symbols } func nearestModuleRoot(directory string) string { for { if _, err := os.Stat(filepath.Join(directory, "go.mod")); err == nil { return directory } parent := filepath.Dir(directory) if parent == directory { return "" } directory = parent } } func filePathFromURI(uri string) (string, bool) { parsed, err := url.Parse(uri) if err != nil || parsed.Scheme != "file" { return "", false } path, err := url.PathUnescape(parsed.Path) if err != nil { return "", false } return filepath.Clean(filepath.FromSlash(path)), true } func fileURI(path string) string { return "file://" + filepath.ToSlash(filepath.Clean(path)) } func (s *server) publishDiagnostics(uri string) error { state, ok := s.docs[uri] if !ok { return nil } diagnostics := state.diagnostics if diagnostics == nil { diagnostics = []diagnostic{} } return s.writeNotification(notification{ JSONRPC: "2.0", Method: "textDocument/publishDiagnostics", Params: publishDiagnosticsParams{ URI: uri, Diagnostics: diagnostics, }, }) } func (s *server) hover(uri string, pos position) any { state, ok := s.docs[uri] if !ok { return nil } word, _ := wordAtPosition(state.text, pos) if word == "" { return nil } if result := stdlibHover(state, pos); result != nil { return result } for _, sym := range state.symbols { if sym.Name != word { continue } if !rangeContains(sym.Range, pos) && !contains(sym.Targets, word) { // allow hover on usages by name match } return map[string]any{ "contents": map[string]any{ "kind": "markdown", "value": "```gotlin\n" + sym.Detail + "\n```", }, } } for _, sym := range state.packageSymbols { if sym.Name == word { return map[string]any{"contents": map[string]any{"kind": "markdown", "value": "```gotlin\n" + sym.Detail + "\n```"}} } } if value, ok := builtinHoverDetail(word); ok { return map[string]any{ "contents": map[string]any{ "kind": "markdown", "value": "```gotlin\n" + value + "\n```", }, } } if result := s.goplsHover(state, pos); result != nil { return result } return nil } func (s *server) definition(uri string, pos position) any { state, ok := s.docs[uri] if !ok { return nil } word, _ := wordAtPosition(state.text, pos) if word == "" { return nil } if result := stdlibDefinition(state, pos); result != nil { return result } for _, sym := range state.symbols { if sym.Name == word { return []location{{URI: uri, Range: sym.Range}} } } for _, sym := range state.packageSymbols { if sym.Name == word && sym.URI != "" { return []location{{URI: sym.URI, Range: sym.Range}} } } if result := s.goplsDefinition(state, pos); result != nil { return result } return nil } func (s *server) goplsHover(state documentState, pos position) any { if s.goplsPath == "" || state.program == nil { return nil } query, tokenStart, ok := goplsQueryAtPosition(state.text, pos) if !ok { return nil } source, targetOffset, cleanup, err := prepareGoplsSource(state.program, state.text, query, tokenStart) if err != nil { return nil } defer cleanup() output, err := exec.Command(s.goplsPath, "hover", fmt.Sprintf("%s:#%d", source, targetOffset)).CombinedOutput() if err != nil || len(bytes.TrimSpace(output)) == 0 { return nil } return map[string]any{ "contents": map[string]any{ "kind": "markdown", "value": "```go\n" + strings.TrimSpace(string(output)) + "\n```", }, } } func stdlibHover(state documentState, pos position) any { target, ok := resolveStdlibTarget(state, pos) if !ok { return nil } sym, ok := findStdlibSymbol(target) if !ok { return nil } var value strings.Builder value.WriteString("```go\n") value.WriteString(sym.Decl) value.WriteString("\n```") if sym.Doc != "" { value.WriteString("\n") value.WriteString(sym.Doc) } return map[string]any{ "contents": map[string]any{ "kind": "markdown", "value": value.String(), }, } } func stdlibDefinition(state documentState, pos position) any { target, ok := resolveStdlibTarget(state, pos) if !ok { return nil } sym, ok := findStdlibSymbol(target) if !ok { return nil } return []location{{ URI: "file://" + filepath.Clean(sym.FileName), Range: rng{ Start: sym.Start, End: sym.End, }, }} } func (s *server) goplsDefinition(state documentState, pos position) any { if s.goplsPath == "" || state.program == nil { return nil } query, tokenStart, ok := goplsQueryAtPosition(state.text, pos) if !ok { return nil } source, targetOffset, cleanup, err := prepareGoplsSource(state.program, state.text, query, tokenStart) if err != nil { return nil } tempDir := filepath.Dir(source) defer cleanup() output, err := exec.Command(s.goplsPath, "definition", fmt.Sprintf("%s:#%d", source, targetOffset)).CombinedOutput() if err != nil || len(bytes.TrimSpace(output)) == 0 { return nil } loc, ok := parseGoplsDefinition(string(output)) if !ok { return nil } if isTempGoplsLocation(loc, tempDir) { return nil } return []location{loc} } func indexSymbols(text string, program *lang.Program) []symbol { var symbols []symbol lines := strings.Split(text, "\n") if program.PackagePath != "" { if r, ok := findLineMatch(lines, regexp.MustCompile(`^\s*package\s+([A-Za-z_][\w.-]*(?:\.[A-Za-z_][\w.-]*)*)`), 1); ok { symbols = append(symbols, symbol{ Name: lastPackageSegment(program.PackagePath), Kind: symbolKindPackage, Detail: "package " + program.PackagePath, Range: r, }) } } importRanges := findAllLineMatches(lines, regexp.MustCompile(`^\s*import\s+((?:[A-Za-z_][\w-]*\.)*[A-Za-z_][\w-]*)(?:\s+((?:[A-Za-z_][\w-]*\.)*[A-Za-z_][\w-]*))?`)) for i, imp := range program.Imports { name := importAlias(imp) if i < len(importRanges) { symbols = append(symbols, symbol{ Name: name, Kind: symbolKindModule, Detail: renderImportDetail(imp), Range: importRanges[i], }) } } interfaceRanges := findAllLineMatches(lines, regexp.MustCompile(`^\s*interface\s+([A-Za-z_][A-Za-z0-9_]*)`)) for i, decl := range program.Interfaces { r := rng{} if i < len(interfaceRanges) { r = interfaceRanges[i] } symbols = append(symbols, symbol{ Name: decl.Name, Kind: symbolKindInterface, Detail: renderInterfaceSignature(decl), Range: r, }) for _, method := range decl.Methods { symbols = append(symbols, symbol{ Name: method.Name, Kind: symbolKindMethod, Detail: renderFunctionSignatureFromParts(method.Name, method.Params, method.ReturnType), Range: r, Targets: []string{decl.Name}, }) } } enumRanges := findAllLineMatches(lines, regexp.MustCompile(`^\s*enum\s+([A-Za-z_][A-Za-z0-9_]*)`)) for i, decl := range program.Enums { r := rng{} if i < len(enumRanges) { r = enumRanges[i] } symbols = append(symbols, symbol{Name: decl.Name, Kind: symbolKindEnum, Detail: "enum " + decl.Name, Range: r}) for _, variant := range decl.Variants { symbols = append(symbols, symbol{Name: variant.Name, Kind: symbolKindVariable, Detail: decl.Name + "." + variant.Name, Range: r, Targets: []string{decl.Name}}) } } classRanges := findAllLineMatches(lines, regexp.MustCompile(`^\s*(?:data\s+)?class\s+([A-Za-z_][A-Za-z0-9_]*)`)) for i, decl := range program.Classes { r := rng{} if i < len(classRanges) { r = classRanges[i] } symbols = append(symbols, symbol{ Name: decl.Name, Kind: symbolKindClass, Detail: renderClassSignature(decl), Range: r, }) for _, field := range decl.Fields { symbols = append(symbols, symbol{ Name: field.Name, Kind: symbolKindField, Detail: renderFieldSignature(decl.Name, field), Range: r, Targets: []string{decl.Name}, }) } for _, method := range decl.Methods { symbols = append(symbols, symbol{ Name: method.Name, Kind: symbolKindMethod, Detail: renderMethodSignature(decl.Name, method), Range: r, Targets: []string{decl.Name}, }) } } funcRanges := findAllLineMatches(lines, regexp.MustCompile(`^\s*fun\s+([A-Za-z_][A-Za-z0-9_]*)`)) for i, fn := range program.Functions { detail := renderFunctionSignature(fn) r := rng{} if i < len(funcRanges) { r = funcRanges[i] } symbols = append(symbols, symbol{ Name: fn.Name, Kind: symbolKindFunction, Detail: detail, Range: r, }) } varRanges := findAllLineNamedMatches(lines, regexp.MustCompile(`^\s*(?:val|var)\s+([A-Za-z_][A-Za-z0-9_]*)`), 1) varInfos := collectVariableDeclInfos(program) varInfoByName := map[string][]variableDeclInfo{} for _, info := range varInfos { varInfoByName[info.Name] = append(varInfoByName[info.Name], info) } for i, decl := range varRanges { mutable := false typ := "" if queue := varInfoByName[decl.Name]; len(queue) > 0 { match := queue[0] varInfoByName[decl.Name] = queue[1:] mutable = match.Mutable typ = match.Type } else if i < len(varInfos) { // Fallback for parser/regex mismatches. mutable = varInfos[i].Mutable typ = varInfos[i].Type } symbols = append(symbols, symbol{ Name: decl.Name, Kind: symbolKindVariable, Detail: renderVariableSignature(decl.Name, mutable, typ), Range: decl.Range, }) } return symbols } func diagnosticsFromSemantic(text string, semantic []lang.SemanticDiagnostic) []diagnostic { diagnostics := make([]diagnostic, 0, len(semantic)) for _, item := range semantic { start := offsetToPosition(text, item.Span.Start) endOffset := item.Span.End if endOffset <= item.Span.Start { endOffset = item.Span.Start + 1 } diagnostics = append(diagnostics, diagnostic{ Range: rng{Start: start, End: offsetToPosition(text, endOffset)}, Severity: int(item.Severity), Source: "gotlin", Code: item.Code, Message: item.Message, }) } return diagnostics } func diagnosticFromError(text string, err error) diagnostic { start := position{} end := position{} if match := offsetPattern.FindStringSubmatch(err.Error()); len(match) == 2 { if offset, convErr := strconv.Atoi(match[1]); convErr == nil { start = offsetToPosition(text, offset) end = start end.Character++ } } return diagnostic{ Range: rng{Start: start, End: end}, Severity: diagnosticSeverityError, Source: "gotlin", Message: err.Error(), } } func offsetToPosition(text string, offset int) position { runes := []rune(text) if offset < 0 { offset = 0 } if offset > len(runes) { offset = len(runes) } line := 0 char := 0 for i := 0; i < offset; i++ { if runes[i] == '\n' { line++ char = 0 continue } char++ } return position{Line: line, Character: char} } func positionToOffset(text string, pos position) int { runes := []rune(text) line := 0 char := 0 for i, r := range runes { if line == pos.Line && char == pos.Character { return i } if r == '\n' { line++ char = 0 if line > pos.Line { return i } continue } char++ } return len(runes) } func wordAtPosition(text string, pos position) (string, rng) { runes := []rune(text) offset := positionToOffset(text, pos) if len(runes) == 0 { return "", rng{} } if offset >= len(runes) { offset = len(runes) - 1 } if !isWordRune(runes[offset]) && offset > 0 && isWordRune(runes[offset-1]) { offset-- } if !isWordRune(runes[offset]) { return "", rng{} } start := offset for start > 0 && isWordRune(runes[start-1]) { start-- } end := offset for end+1 < len(runes) && isWordRune(runes[end+1]) { end++ } return string(runes[start : end+1]), rng{ Start: offsetToPosition(text, start), End: offsetToPosition(text, end+1), } } func findWordRange(text string, word string) rng { runes := []rune(text) target := []rune(word) for i := 0; i+len(target) <= len(runes); i++ { if string(runes[i:i+len(target)]) != word { continue } beforeOk := i == 0 || !isWordRune(runes[i-1]) afterOk := i+len(target) == len(runes) || !isWordRune(runes[i+len(target)]) if beforeOk && afterOk { return rng{ Start: offsetToPosition(text, i), End: offsetToPosition(text, i+len(target)), } } } return rng{} } func isWordRune(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' } func readMessage(r *bufio.Reader) ([]byte, error) { contentLength := -1 for { line, err := r.ReadString('\n') if err != nil { return nil, err } line = strings.TrimRight(line, "\r\n") if line == "" { break } if strings.HasPrefix(strings.ToLower(line), "content-length:") { value := strings.TrimSpace(line[len("content-length:"):]) n, err := strconv.Atoi(value) if err != nil { return nil, err } contentLength = n } } if contentLength < 0 { return nil, fmt.Errorf("missing Content-Length header") } body := make([]byte, contentLength) if _, err := io.ReadFull(r, body); err != nil { return nil, err } return body, nil } func (s *server) writeResponse(resp response) error { data, err := json.Marshal(resp) if err != nil { return err } return writeMessage(s.out, data) } func (s *server) writeNotification(note notification) error { data, err := json.Marshal(note) if err != nil { return err } return writeMessage(s.out, data) } func writeMessage(w io.Writer, payload []byte) error { var buf bytes.Buffer fmt.Fprintf(&buf, "Content-Length: %d\r\n\r\n", len(payload)) buf.Write(payload) _, err := w.Write(buf.Bytes()) return err } func findLineMatch(lines []string, pattern *regexp.Regexp, capture int) (rng, bool) { matches := findAllLineMatches(lines, pattern) if capture == 1 && len(matches) > 0 { return matches[0], true } return rng{}, false } func findAllLineMatches(lines []string, pattern *regexp.Regexp) []rng { var out []rng for i, line := range lines { idx := pattern.FindStringSubmatchIndex(line) if idx == nil || len(idx) < 4 { continue } start := idx[2] end := idx[3] out = append(out, rng{ Start: position{Line: i, Character: utf16Len(line[:start])}, End: position{Line: i, Character: utf16Len(line[:end])}, }) } return out } type namedRange struct { Name string Range rng } func findAllLineNamedMatches(lines []string, pattern *regexp.Regexp, capture int) []namedRange { var out []namedRange for i, line := range lines { matches := pattern.FindAllStringSubmatchIndex(line, -1) for _, idx := range matches { cStart := capture * 2 cEnd := cStart + 1 if cEnd >= len(idx) || idx[cStart] < 0 || idx[cEnd] < 0 { continue } start := idx[cStart] end := idx[cEnd] name := line[start:end] out = append(out, namedRange{ Name: name, Range: rng{ Start: position{Line: i, Character: utf16Len(line[:start])}, End: position{Line: i, Character: utf16Len(line[:end])}, }, }) } } return out } func utf16Len(s string) int { return len([]rune(s)) } func lastPackageSegment(path string) string { parts := strings.Split(path, ".") return parts[len(parts)-1] } func importAlias(imp lang.ImportDecl) string { if imp.Alias != "" { return imp.Alias } path := strings.TrimPrefix(imp.Path, "go.") parts := strings.Split(path, ".") return parts[len(parts)-1] } func renderImportDetail(imp lang.ImportDecl) string { if imp.Alias != "" { return "import " + imp.Alias + " " + imp.Path } return "import " + imp.Path } func renderFunctionSignature(fn lang.FunctionDecl) string { name := fn.Name if len(fn.TypeParams) > 0 { name += "<" + strings.Join(fn.TypeParams, ", ") + ">" } return renderFunctionSignatureFromParts(name, fn.Params, fn.ReturnType) } func renderFunctionSignatureFromParts(name string, fnParams []lang.Param, returnType string) string { var params []string for _, param := range fnParams { params = append(params, param.Name+": "+param.Type) } signature := "fun " + name + "(" + strings.Join(params, ", ") + ")" if returnType != "" && returnType != "Unit" { signature += ": " + returnType } return signature } func renderInterfaceSignature(decl lang.InterfaceDecl) string { return "interface " + decl.Name } func renderClassSignature(decl lang.ClassDecl) string { var b strings.Builder b.WriteString("class ") b.WriteString(decl.Name) if len(decl.TypeParams) > 0 { b.WriteString("<") b.WriteString(strings.Join(decl.TypeParams, ", ")) b.WriteString(">") } var fields []string for _, field := range decl.Fields { keyword := "val" if field.Mutable { keyword = "var" } fields = append(fields, keyword+" "+field.Name+": "+field.Type) } if len(decl.Fields) > 0 { b.WriteString("(") b.WriteString(strings.Join(fields, ", ")) b.WriteString(")") } if len(decl.Parents) > 0 { b.WriteString(": ") b.WriteString(strings.Join(decl.Parents, ", ")) } return b.String() } func renderMethodSignature(className string, fn lang.FunctionDecl) string { return className + "." + renderFunctionSignature(fn) } func renderFieldSignature(className string, field lang.FieldDecl) string { keyword := "val" if field.Mutable { keyword = "var" } return className + "." + keyword + " " + field.Name + ": " + field.Type } func renderVariableSignature(name string, mutable bool, typ string) string { keyword := "val" if mutable { keyword = "var" } if typ != "" { return keyword + " " + name + ": " + typ } return keyword + " " + name } func collectVariableDeclInfos(program *lang.Program) []variableDeclInfo { var result []variableDeclInfo var walk func([]lang.Stmt) walk = func(statements []lang.Stmt) { for _, statement := range statements { switch value := statement.(type) { case lang.VarDecl: typ := value.Type if typ == "" { resolved := lang.ResolvedType(value.Value) if resolved.String() != "" { typ = resolved.String() } } result = append(result, variableDeclInfo{Name: value.Name, Mutable: value.Mutable, Type: typ}) case lang.MultiVarDecl: types := make([]string, len(value.Names)) if fallible, ok := lang.ResolvedType(value.Value).(lang.GenericType); ok && fallible.Base.String() == "Result" && len(fallible.Args) == 2 && len(types) == 2 { types[0] = fallible.Args[0].String() types[1] = fallible.Args[1].String() + "?" } else if tuple, ok := lang.ResolvedType(value.Value).(lang.TupleType); ok && len(tuple.Elements) == len(types) { for index, element := range tuple.Elements { types[index] = element.String() } } for index, name := range value.Names { result = append(result, variableDeclInfo{Name: name, Mutable: value.Mutable, Type: types[index]}) } case lang.IfStmt: walk(value.Then) walk(value.Else) case lang.WhileStmt: walk(value.Body) case lang.ForEachStmt: walk(value.Body) case lang.MatchStmt: for _, matchCase := range value.Cases { walk(matchCase.Body) } case lang.TryCatchStmt: walk(value.TryBody) walk(value.CatchBody) } } } for _, function := range program.Functions { walk(function.Body) } for _, class := range program.Classes { for _, method := range class.Methods { walk(method.Body) } } return result } func resolveStdlibTarget(state documentState, pos position) (stdlibTarget, bool) { if state.program == nil { return stdlibTarget{}, false } imports := map[string]string{} for _, imp := range state.program.Imports { if strings.HasPrefix(imp.Path, `"`) { continue } imports[importAlias(imp)] = importPathToGoPath(imp.Path) } if len(imports) == 0 { return stdlibTarget{}, false } query, _, ok := goplsQueryAtPosition(state.text, pos) if ok { if alias, symbol, found := splitSelectorQuery(query); found { if pkgPath, ok := imports[alias]; ok { return stdlibTarget{ PackagePath: pkgPath, SymbolName: symbol, }, true } if inferredAlias, ok := receiverImportAlias(state.program, alias); ok { if pkgPath, ok := imports[inferredAlias]; ok { return stdlibTarget{ PackagePath: pkgPath, SymbolName: symbol, }, true } } } if pkgPath, ok := imports[query]; ok { return stdlibTarget{ PackagePath: pkgPath, }, true } } word, _ := wordAtPosition(state.text, pos) if word == "" { return stdlibTarget{}, false } if pkgPath, ok := imports[word]; ok { return stdlibTarget{ PackagePath: pkgPath, }, true } if _, r := wordAtPosition(state.text, pos); r != (rng{}) { if rootAlias, ok := inferSelectorRootAlias(state.text, r); ok { if pkgPath, ok := imports[rootAlias]; ok { return stdlibTarget{ PackagePath: pkgPath, SymbolName: word, }, true } if inferredAlias, ok := receiverImportAlias(state.program, rootAlias); ok { if pkgPath, ok := imports[inferredAlias]; ok { return stdlibTarget{ PackagePath: pkgPath, SymbolName: word, }, true } } } } return stdlibTarget{}, false } func inferSelectorRootAlias(text string, tokenRange rng) (string, bool) { runes := []rune(text) start := positionToOffset(text, tokenRange.Start) if start <= 0 || start > len(runes) { return "", false } if runes[start-1] != '.' { return "", false } i := start - 2 parensDepth := 0 for i >= 0 { ch := runes[i] switch ch { case ')': parensDepth++ i-- continue case '(': if parensDepth > 0 { parensDepth-- i-- continue } } if parensDepth > 0 { i-- continue } if unicode.IsSpace(ch) { i-- continue } if isWordRune(ch) { end := i + 1 for i >= 0 && isWordRune(runes[i]) { i-- } startWord := i + 1 ident := string(runes[startWord:end]) j := i for j >= 0 && unicode.IsSpace(runes[j]) { j-- } if j >= 0 && runes[j] == '.' { i = j - 1 continue } return ident, true } return "", false } return "", false } func receiverImportAlias(program *lang.Program, receiver string) (string, bool) { for _, class := range program.Classes { for _, field := range class.Fields { if field.Name != receiver { continue } if alias, ok := firstTypeImportAlias(field.Type); ok { return alias, true } } } return "", false } func firstTypeImportAlias(typ string) (string, bool) { typ = strings.TrimSpace(typ) for strings.HasPrefix(typ, "*") { typ = strings.TrimPrefix(typ, "*") } dot := strings.Index(typ, ".") if dot <= 0 { return "", false } return typ[:dot], true } func splitSelectorQuery(query string) (string, string, bool) { index := strings.Index(query, ".") if index <= 0 || index+1 >= len(query) { return "", "", false } return query[:index], query[index+1:], true } func findStdlibSymbol(target stdlibTarget) (stdlibSymbol, bool) { dir, ok := resolvePackageDir(target.PackagePath) if !ok { return stdlibSymbol{}, false } fset := token.NewFileSet() pkgs, err := parser.ParseDir(fset, dir, func(info os.FileInfo) bool { return !strings.HasSuffix(info.Name(), "_test.go") }, parser.ParseComments) if err != nil || len(pkgs) == 0 { return stdlibSymbol{}, false } pkg := firstASTPackage(pkgs) if pkg == nil { return stdlibSymbol{}, false } files := packageFiles(pkg) if len(files) == 0 { return stdlibSymbol{}, false } if target.SymbolName == "" { file := files[0] start := fset.Position(file.Name.Pos()) end := fset.Position(file.Name.End()) decl := "package " + pkg.Name doc := strings.TrimSpace(commentText(file.Doc)) return stdlibSymbol{ FileName: start.Filename, Start: tokenPosition(start), End: tokenPosition(end), Decl: decl, Doc: doc, }, true } for _, file := range files { for _, decl := range file.Decls { if sym, ok := matchStdlibDecl(fset, decl, target.SymbolName); ok { return sym, true } } } return stdlibSymbol{}, false } func resolvePackageDir(packagePath string) (string, bool) { stdlibDir := filepath.Join(resolveGoRoot(), "src", filepath.FromSlash(packagePath)) if info, err := os.Stat(stdlibDir); err == nil && info.IsDir() { return stdlibDir, true } if listed := goListPackageDir(packagePath); listed != "" { return listed, true } if cached := findInModuleCache(packagePath); cached != "" { return cached, true } return "", false } func goListPackageDir(packagePath string) string { output, err := exec.Command("go", "list", "-f", "{{.Dir}}", packagePath).Output() if err != nil { return "" } dir := strings.TrimSpace(string(output)) if dir == "" { return "" } if info, err := os.Stat(dir); err == nil && info.IsDir() { return dir } return "" } func findInModuleCache(packagePath string) string { modCache := strings.TrimSpace(goEnv("GOMODCACHE")) if modCache == "" { goPath := strings.TrimSpace(goEnv("GOPATH")) if goPath != "" { parts := filepath.SplitList(goPath) if len(parts) > 0 { modCache = filepath.Join(parts[0], "pkg", "mod") } } } if modCache == "" { return "" } parts := strings.Split(packagePath, "/") for i := len(parts); i >= 1; i-- { modulePath := strings.Join(parts[:i], "/") subPath := strings.Join(parts[i:], "/") pattern := filepath.Join(modCache, escapeModulePath(modulePath)+"@*") matches, _ := filepath.Glob(pattern) if len(matches) == 0 { continue } sort.Strings(matches) for j := len(matches) - 1; j >= 0; j-- { candidate := matches[j] if subPath != "" { candidate = filepath.Join(candidate, filepath.FromSlash(subPath)) } if info, err := os.Stat(candidate); err == nil && info.IsDir() { return candidate } } } return "" } func escapeModulePath(path string) string { var b strings.Builder for _, r := range path { if r >= 'A' && r <= 'Z' { b.WriteRune('!') b.WriteRune(r + ('a' - 'A')) continue } b.WriteRune(r) } return b.String() } func importPathToGoPath(path string) string { trimmed := strings.TrimPrefix(path, "go.") parts := strings.Split(trimmed, ".") if len(parts) >= 3 && isDomainTLD(parts[1]) { return parts[0] + "." + parts[1] + "/" + strings.Join(parts[2:], "/") } return strings.ReplaceAll(trimmed, ".", "/") } func isDomainTLD(segment string) bool { switch segment { case "com", "org", "net", "io", "dev", "app", "ai": return true default: return false } } func firstASTPackage(pkgs map[string]*ast.Package) *ast.Package { names := make([]string, 0, len(pkgs)) for name := range pkgs { names = append(names, name) } sort.Strings(names) for _, name := range names { return pkgs[name] } return nil } func packageFiles(pkg *ast.Package) []*ast.File { names := make([]string, 0, len(pkg.Files)) for name := range pkg.Files { names = append(names, name) } sort.Strings(names) files := make([]*ast.File, 0, len(names)) for _, name := range names { files = append(files, pkg.Files[name]) } return files } func matchStdlibDecl(fset *token.FileSet, decl ast.Decl, symbolName string) (stdlibSymbol, bool) { switch d := decl.(type) { case *ast.FuncDecl: if d.Name == nil || d.Name.Name != symbolName { return stdlibSymbol{}, false } return newStdlibSymbol(fset, d.Name.Pos(), d.Name.End(), formatFuncDecl(fset, d), commentText(d.Doc)), true case *ast.GenDecl: for _, spec := range d.Specs { switch s := spec.(type) { case *ast.TypeSpec: if s.Name.Name != symbolName { continue } return newStdlibSymbol(fset, s.Name.Pos(), s.Name.End(), formatGenDecl(fset, d, s), specCommentText(d.Doc, s.Doc, s.Comment)), true case *ast.ValueSpec: for _, name := range s.Names { if name.Name != symbolName { continue } return newStdlibSymbol(fset, name.Pos(), name.End(), formatGenDecl(fset, d, s), specCommentText(d.Doc, s.Doc, s.Comment)), true } } } } return stdlibSymbol{}, false } func newStdlibSymbol(fset *token.FileSet, start token.Pos, end token.Pos, decl string, doc string) stdlibSymbol { startPos := fset.Position(start) endPos := fset.Position(end) return stdlibSymbol{ FileName: startPos.Filename, Start: tokenPosition(startPos), End: tokenPosition(endPos), Decl: strings.TrimSpace(decl), Doc: strings.TrimSpace(doc), } } func formatFuncDecl(fset *token.FileSet, decl *ast.FuncDecl) string { copyDecl := *decl copyDecl.Body = nil return formatNode(fset, ©Decl) } func formatGenDecl(fset *token.FileSet, decl *ast.GenDecl, spec ast.Spec) string { copyDecl := &ast.GenDecl{ Tok: decl.Tok, Specs: []ast.Spec{ spec, }, } return formatNode(fset, copyDecl) } func formatNode(fset *token.FileSet, node any) string { var buf bytes.Buffer if err := format.Node(&buf, fset, node); err != nil { return "" } return buf.String() } func commentText(group *ast.CommentGroup) string { if group == nil { return "" } return strings.TrimSpace(group.Text()) } func specCommentText(groups ...*ast.CommentGroup) string { for _, group := range groups { if text := commentText(group); text != "" { return text } } return "" } func tokenPosition(pos token.Position) position { return position{ Line: pos.Line - 1, Character: pos.Column - 1, } } func resolveGoRoot() string { if value := strings.TrimSpace(os.Getenv("GOROOT")); value != "" { return value } if value := strings.TrimSpace(goEnv("GOROOT")); value != "" { return value } return runtime.GOROOT() } func copyScope(scope map[string]bool) map[string]bool { dup := make(map[string]bool, len(scope)) for k, v := range scope { dup[k] = v } return dup } func isBuiltin(name string) bool { _, ok := builtinDetails[name] return ok } func builtinHoverDetail(name string) (string, bool) { value, ok := builtinDetails[name] return value, ok } var builtinDetails = map[string]string{ "println": "fun println(value: Any): Unit", "runCatching": "fun runCatching(block: () -> Unit): Result", "Channel": "fun Channel(capacity: Int = 0): Channel", "listOf": "fun listOf(values: T...): List", "mutableListOf": "fun mutableListOf(values: T...): MutableList", "mapOf": "fun mapOf(pairs: Any...): Map", "mutableMapOf": "fun mutableMapOf(pairs: Any...): MutableMap", "ByteSlice": "fun ByteSlice(value: String | Int): ByteSlice", "append": "fun append(values: List, value: T): List", "keys": "fun keys(values: Map): List", "goAssert": "fun goAssert(value: Any): T", "len": "fun len(value: Any): Int", "cap": "fun cap(value: Any): Int", "make": "fun make(size: Int): T", "new": "fun new(): *T", "copy": "fun copy(target: Any, source: Any): Int", "delete": "fun delete(map: Any, key: Any): Unit", "close": "fun close(channel: Any): Unit", "panic": "fun panic(value: Any): Unit", "recover": "fun recover(): Any", "string": "fun string(value: Any): String", "int": "fun int(value: Any): Int", "float64": "fun float64(value: Any): Double", "bool": "fun bool(value: Any): Boolean", "sql": "typed PostgreSQL query DSL", "set": "fun set(target: Any, value: Any): Unit", "now": "fun now(): time.Time", "runBlocking": "fun runBlocking(block: suspend () -> Unit): Unit", "coroutineScope": "suspend fun coroutineScope(block: suspend () -> Unit): Unit", "launch": "suspend fun launch(block: suspend () -> Unit): Unit", "async": "suspend fun async(block: suspend () -> T): Deferred", "delay": "suspend fun delay(ms: Int): Unit", "withTimeout": "suspend fun withTimeout(ms: Int, block: suspend () -> Unit): Unit", "isActive": "suspend fun isActive(): Boolean", } func contains(values []string, needle string) bool { for _, value := range values { if value == needle { return true } } return false } func rangeContains(r rng, p position) bool { if p.Line < r.Start.Line || p.Line > r.End.Line { return false } if p.Line == r.Start.Line && p.Character < r.Start.Character { return false } if p.Line == r.End.Line && p.Character > r.End.Character { return false } return true } func uniqueDiagnostics(input []diagnostic) []diagnostic { seen := map[string]bool{} var out []diagnostic for _, d := range input { key := fmt.Sprintf("%d:%d:%d:%d:%s", d.Range.Start.Line, d.Range.Start.Character, d.Range.End.Line, d.Range.End.Character, d.Message) if seen[key] { continue } seen[key] = true out = append(out, d) } return out } func resolveGoplsPath() string { if configured := strings.TrimSpace(os.Getenv("GOTLIN_GOPLS_PATH")); configured != "" { return configured } if path, err := exec.LookPath("gopls"); err == nil { return path } for _, dir := range goBinCandidates() { path := filepath.Join(dir, "gopls") if info, err := os.Stat(path); err == nil && !info.IsDir() { return path } } return "" } func goBinCandidates() []string { var dirs []string seen := map[string]bool{} add := func(dir string) { dir = strings.TrimSpace(dir) if dir == "" || seen[dir] { return } seen[dir] = true dirs = append(dirs, dir) } add(os.Getenv("GOBIN")) for _, part := range filepath.SplitList(os.Getenv("GOPATH")) { add(filepath.Join(part, "bin")) } if home, err := os.UserHomeDir(); err == nil { add(filepath.Join(home, "go", "bin")) } for _, value := range []string{goEnv("GOBIN"), goEnv("GOPATH")} { for _, part := range filepath.SplitList(value) { if filepath.Base(part) == "bin" { add(part) continue } add(filepath.Join(part, "bin")) } } return dirs } func goEnv(name string) string { output, err := exec.Command("go", "env", name).Output() if err != nil { return "" } return strings.TrimSpace(string(output)) } func goplsQueryAtPosition(text string, pos position) (string, int, bool) { word, r := wordAtPosition(text, pos) if word == "" { return "", 0, false } startOffset := positionToOffset(text, r.Start) runes := []rune(text) if startOffset > 0 && runes[startOffset-1] == '.' { leftEnd := startOffset - 1 leftStart := leftEnd - 1 for leftStart >= 0 && isWordRune(runes[leftStart]) { leftStart-- } leftStart++ if leftStart < leftEnd { receiver := string(runes[leftStart:leftEnd]) return receiver + "." + word, startOffset, true } } return word, startOffset, true } func prepareGoplsSource(program *lang.Program, gotlinSource string, query string, tokenStart int) (string, int, func(), error) { goSrc, err := lang.GenerateGo(program) if err != nil { return "", 0, func() {}, err } preferredLine := offsetToPosition(gotlinSource, tokenStart).Line targetOffset := findQueryOffset(string(goSrc), query, preferredLine) if targetOffset < 0 { targetOffset = findQueryOffset(string(goSrc), lastQuerySegment(query), preferredLine) } if targetOffset < 0 { return "", 0, func() {}, fmt.Errorf("query %q not found in generated Go", query) } dir, err := os.MkdirTemp("", "gotlin-gopls-*") if err != nil { return "", 0, func() {}, err } cleanup := func() { _ = os.RemoveAll(dir) } source := filepath.Join(dir, "main.go") if err := os.WriteFile(source, goSrc, 0o644); err != nil { cleanup() return "", 0, func() {}, err } if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module gotlin-gopls-temp\n\ngo 1.25\n"), 0o644); err != nil { cleanup() return "", 0, func() {}, err } return source, targetOffset, cleanup, nil } func findQueryOffset(source string, query string, preferredLine int) int { if query == "" { return -1 } indices := allQueryOffsets(source, query) if len(indices) == 0 { return -1 } bestIndex := indices[0] bestDistance := absInt(byteLineNumber(source, bestIndex) - preferredLine) for _, index := range indices[1:] { distance := absInt(byteLineNumber(source, index) - preferredLine) if distance < bestDistance { bestIndex = index bestDistance = distance } } return bestIndex } func lastQuerySegment(query string) string { if idx := strings.LastIndex(query, "."); idx >= 0 && idx+1 < len(query) { return query[idx+1:] } return query } func parseGoplsDefinition(output string) (location, bool) { line := strings.TrimSpace(output) if line == "" { return location{}, false } first := strings.Split(line, "\n")[0] re := regexp.MustCompile(`^(.*):(\d+):(\d+)-(\d+):`) match := re.FindStringSubmatch(first) if len(match) != 5 { return location{}, false } lineNo, err1 := strconv.Atoi(match[2]) startCol, err2 := strconv.Atoi(match[3]) endCol, err3 := strconv.Atoi(match[4]) if err1 != nil || err2 != nil || err3 != nil { return location{}, false } path := filepath.Clean(match[1]) return location{ URI: "file://" + path, Range: rng{ Start: position{Line: lineNo - 1, Character: startCol - 1}, End: position{Line: lineNo - 1, Character: endCol - 1}, }, }, true } func isTempGoplsLocation(loc location, tempDir string) bool { path := strings.TrimPrefix(loc.URI, "file://") cleanPath := filepath.Clean(path) cleanTempDir := filepath.Clean(tempDir) return cleanPath == cleanTempDir || strings.HasPrefix(cleanPath, cleanTempDir+string(os.PathSeparator)) } func allQueryOffsets(source string, query string) []int { var offsets []int for start := 0; start < len(source); { index := strings.Index(source[start:], query) if index < 0 { break } offset := start + index offsets = append(offsets, offset) start = offset + len(query) } return offsets } func byteLineNumber(source string, offset int) int { if offset < 0 { offset = 0 } if offset > len(source) { offset = len(source) } line := 0 for i := 0; i < offset; i++ { if source[i] == '\n' { line++ } } return line } func absInt(n int) int { if n < 0 { return -n } return n }