gotlin/cmd/gotlin-lsp/main.go

2469 lines
62 KiB
Go

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"`
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, &params); 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, &params); 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, &params); 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, &params); 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, &params); 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
state.symbols = indexSymbols(text, program)
state.diagnostics = semanticDiagnostics(text, program, state.symbols)
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
state.symbols = indexSymbols(text, program)
programs, symbols := s.packageContext(uri, program.PackagePath, text)
state.packageSymbols = symbols
state.diagnostics = semanticDiagnosticsWithPackage(text, program, state.symbols, programs)
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
}
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},
})
}
}
workerRanges := findAllLineMatches(lines, regexp.MustCompile(`^\s*worker\s+([A-Za-z_][A-Za-z0-9_]*)`))
for i, decl := range program.Workers {
r := rng{}
if i < len(workerRanges) {
r = workerRanges[i]
}
symbols = append(symbols, symbol{
Name: decl.Name,
Kind: symbolKindClass,
Detail: renderWorkerSignature(decl),
Range: r,
})
for _, field := range decl.Fields {
symbols = append(symbols, symbol{
Name: field.Name,
Kind: symbolKindField,
Detail: renderWorkerFieldSignature(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 semanticDiagnostics(text string, program *lang.Program, symbols []symbol) []diagnostic {
return semanticDiagnosticsWithPackage(text, program, symbols, nil)
}
func semanticDiagnosticsWithPackage(text string, program *lang.Program, symbols []symbol, packagePrograms []*lang.Program) []diagnostic {
var diagnostics []diagnostic
funcSymbols := map[string]symbol{}
importSymbols := map[string]symbol{}
typeSymbols := map[string]symbol{}
for _, sym := range symbols {
switch sym.Kind {
case symbolKindFunction:
if prev, ok := funcSymbols[sym.Name]; ok {
diagnostics = append(diagnostics, duplicateDiagnostic(sym.Range, "duplicate function "+sym.Name))
diagnostics = append(diagnostics, duplicateDiagnostic(prev.Range, "duplicate function "+sym.Name))
} else {
funcSymbols[sym.Name] = sym
}
case symbolKindModule:
if prev, ok := importSymbols[sym.Name]; ok {
diagnostics = append(diagnostics, duplicateDiagnostic(sym.Range, "duplicate import alias "+sym.Name))
diagnostics = append(diagnostics, duplicateDiagnostic(prev.Range, "duplicate import alias "+sym.Name))
} else {
importSymbols[sym.Name] = sym
}
case symbolKindClass, symbolKindInterface, symbolKindEnum:
if prev, ok := typeSymbols[sym.Name]; ok {
diagnostics = append(diagnostics, duplicateDiagnostic(sym.Range, "duplicate type "+sym.Name))
diagnostics = append(diagnostics, duplicateDiagnostic(prev.Range, "duplicate type "+sym.Name))
} else {
typeSymbols[sym.Name] = sym
}
}
}
imports := map[string]bool{}
for _, imp := range program.Imports {
imports[importAlias(imp)] = true
}
functions := map[string]lang.FunctionDecl{}
for _, fn := range program.Functions {
functions[fn.Name] = fn
}
types := map[string]bool{}
for _, decl := range program.Interfaces {
types[decl.Name] = true
}
for _, decl := range program.Classes {
types[decl.Name] = true
}
for _, decl := range program.Workers {
types[decl.Name] = true
}
for _, decl := range program.Enums {
types[decl.Name] = true
}
for _, sibling := range packagePrograms {
for _, fn := range sibling.Functions {
functions[fn.Name] = fn
}
for _, decl := range sibling.Interfaces {
types[decl.Name] = true
}
for _, decl := range sibling.Classes {
types[decl.Name] = true
}
for _, decl := range sibling.Workers {
types[decl.Name] = true
}
for _, decl := range sibling.Enums {
types[decl.Name] = true
}
}
for _, fn := range program.Functions {
diagnostics = append(diagnostics, functionSemanticDiagnostics(text, fn, functions, imports, types, nil)...)
}
for _, class := range program.Classes {
fields := map[string]bool{
"this": true,
}
for _, field := range class.Fields {
fields[field.Name] = true
}
for _, method := range class.Methods {
diagnostics = append(diagnostics, functionSemanticDiagnostics(text, method, functions, imports, types, fields)...)
}
}
for _, worker := range program.Workers {
fields := map[string]bool{
"this": true,
}
for _, field := range worker.Fields {
fields[field.Name] = true
}
for _, method := range worker.Methods {
diagnostics = append(diagnostics, functionSemanticDiagnostics(text, method, functions, imports, types, fields)...)
}
}
sort.SliceStable(diagnostics, func(i, j int) bool {
if diagnostics[i].Range.Start.Line != diagnostics[j].Range.Start.Line {
return diagnostics[i].Range.Start.Line < diagnostics[j].Range.Start.Line
}
return diagnostics[i].Range.Start.Character < diagnostics[j].Range.Start.Character
})
return uniqueDiagnostics(diagnostics)
}
func functionSemanticDiagnostics(text string, fn lang.FunctionDecl, functions map[string]lang.FunctionDecl, imports map[string]bool, types map[string]bool, fields map[string]bool) []diagnostic {
scope := map[string]bool{}
for _, param := range fn.Params {
scope[param.Name] = true
}
for name := range fields {
scope[name] = true
}
var diagnostics []diagnostic
var walkExpr func(expr lang.Expr, scope map[string]bool)
var walkStmts func(stmts []lang.Stmt, scope map[string]bool)
walkStmts = func(stmts []lang.Stmt, scope map[string]bool) {
for _, stmt := range stmts {
switch s := stmt.(type) {
case lang.VarDecl:
walkExpr(s.Value, scope)
scope[s.Name] = true
case lang.MultiVarDecl:
walkExpr(s.Value, scope)
for _, name := range s.Names {
scope[name] = true
}
case lang.AssignStmt:
if !scope[s.Name] {
diagnostics = append(diagnostics, undefinedNameDiagnostic(text, s.Name, "undefined variable "+s.Name))
}
walkExpr(s.Value, scope)
case lang.AddAssignStmt:
if !scope[s.Name] {
diagnostics = append(diagnostics, undefinedNameDiagnostic(text, s.Name, "undefined variable "+s.Name))
}
walkExpr(s.Value, scope)
case lang.MultiAssignStmt:
for _, name := range s.Names {
if !scope[name] {
diagnostics = append(diagnostics, undefinedNameDiagnostic(text, name, "undefined variable "+name))
}
}
walkExpr(s.Value, scope)
case lang.ReturnStmt:
if s.Value != nil {
walkExpr(s.Value, scope)
}
case lang.ThrowStmt:
walkExpr(s.Value, scope)
case lang.GoStmt:
walkExpr(s.Value, scope)
case lang.DeferStmt:
walkExpr(s.Value, scope)
case lang.ExprStmt:
walkExpr(s.Value, scope)
case lang.IfStmt:
walkExpr(s.Cond, scope)
thenScope := copyScope(scope)
elseScope := copyScope(scope)
walkStmts(s.Then, thenScope)
walkStmts(s.Else, elseScope)
case lang.WhileStmt:
walkExpr(s.Cond, scope)
bodyScope := copyScope(scope)
walkStmts(s.Body, bodyScope)
case lang.ForEachStmt:
walkExpr(s.Source, scope)
bodyScope := copyScope(scope)
bodyScope[s.Name] = true
walkStmts(s.Body, bodyScope)
case lang.SelectStmt:
for _, c := range s.Cases {
walkExpr(c.Source, scope)
caseScope := copyScope(scope)
caseScope["it"] = true
walkStmts(c.Body, caseScope)
}
case lang.MatchStmt:
walkExpr(s.Value, scope)
for _, matchCase := range s.Cases {
caseScope := copyScope(scope)
for _, binding := range matchCase.Bindings {
caseScope[binding] = true
}
walkStmts(matchCase.Body, caseScope)
}
case lang.TryCatchStmt:
tryScope := copyScope(scope)
walkStmts(s.TryBody, tryScope)
catchScope := copyScope(scope)
catchScope[s.CatchName] = true
walkStmts(s.CatchBody, catchScope)
}
}
}
walkExpr = func(expr lang.Expr, scope map[string]bool) {
switch e := expr.(type) {
case lang.IdentExpr:
if isBuiltin(e.Name) || scope[e.Name] || functions[e.Name].Name != "" || imports[e.Name] || types[e.Name] {
return
}
diagnostics = append(diagnostics, undefinedNameDiagnostic(text, e.Name, "undefined identifier "+e.Name))
case lang.UnaryExpr:
walkExpr(e.Value, scope)
case lang.BinaryExpr:
walkExpr(e.Left, scope)
walkExpr(e.Right, scope)
case lang.CallExpr:
if ident, ok := e.Callee.(lang.IdentExpr); ok {
switch ident.Name {
case "after", "every":
if len(e.Args) != 1 {
diagnostics = append(diagnostics, undefinedNameDiagnostic(text, ident.Name, ident.Name+"(ms) expects exactly one Int argument"))
}
if len(e.Args) == 1 {
switch e.Args[0].(type) {
case lang.StringExpr, lang.BoolExpr, lang.NullExpr:
diagnostics = append(diagnostics, undefinedNameDiagnostic(text, ident.Name, ident.Name+"(ms) expects an Int argument"))
}
}
if ident.Name == "every" && len(e.Args) == 1 {
if value, ok := staticIntExprValue(e.Args[0]); ok && value <= 0 {
diagnostics = append(diagnostics, undefinedNameDiagnostic(text, ident.Name, "every(ms) requires ms > 0"))
}
}
}
}
walkExpr(e.Callee, scope)
for _, arg := range e.Args {
walkExpr(arg, scope)
}
case lang.SelectorExpr:
walkExpr(e.Receiver, scope)
case lang.IndexExpr:
walkExpr(e.Receiver, scope)
walkExpr(e.Index, scope)
case lang.EnumVariantExpr:
for _, value := range e.Values {
walkExpr(value, scope)
}
case lang.LambdaExpr:
lambdaScope := copyScope(scope)
if e.ImplicitIt {
lambdaScope["it"] = true
}
for _, param := range e.Params {
lambdaScope[param.Name] = true
}
walkStmts(e.Body, lambdaScope)
case lang.NullExpr:
return
}
}
walkStmts(fn.Body, scope)
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 duplicateDiagnostic(r rng, message string) diagnostic {
return diagnostic{
Range: r,
Severity: diagnosticSeverityWarning,
Source: "gotlin",
Message: message,
}
}
func undefinedNameDiagnostic(text string, name string, message string) diagnostic {
r := findWordRange(text, name)
return diagnostic{
Range: r,
Severity: diagnosticSeverityWarning,
Source: "gotlin",
Message: message,
}
}
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 {
return renderFunctionSignatureFromParts(fn.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)
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 renderWorkerSignature(decl lang.WorkerDecl) string {
return "worker " + decl.Name
}
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 renderWorkerFieldSignature(workerName string, field lang.WorkerFieldDecl) string {
keyword := "val"
if field.Mutable {
keyword = "var"
}
typ := field.Type
if typ == "" {
typ = inferWorkerFieldType(field)
}
if typ != "" {
return workerName + "." + keyword + " " + field.Name + ": " + typ
}
return workerName + "." + keyword + " " + field.Name
}
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 {
functions := map[string]lang.FunctionDecl{}
for _, fn := range program.Functions {
functions[fn.Name] = fn
}
classes := map[string]lang.ClassDecl{}
for _, class := range program.Classes {
classes[class.Name] = class
}
workers := map[string]lang.WorkerDecl{}
for _, worker := range program.Workers {
workers[worker.Name] = worker
}
var out []variableDeclInfo
var walkStmts func(stmts []lang.Stmt, scope map[string]string)
var inferExprType func(expr lang.Expr, scope map[string]string) string
var inferCallReturnTypes func(call lang.CallExpr, scope map[string]string) []string
inferCallReturnTypes = func(call lang.CallExpr, scope map[string]string) []string {
switch callee := call.Callee.(type) {
case lang.IdentExpr:
if fn, ok := functions[callee.Name]; ok {
return []string{fn.ReturnType}
}
if _, ok := classes[callee.Name]; ok {
return []string{callee.Name}
}
if _, ok := workers[callee.Name]; ok {
return []string{callee.Name}
}
switch callee.Name {
case "println":
return []string{"Unit"}
case "after", "every":
return []string{"Channel<time.Time>"}
case "Channel":
if len(call.TypeArgs) == 1 {
return []string{"Channel<" + call.TypeArgs[0] + ">"}
}
return []string{"Channel<any>"}
case "listOf", "mutableListOf":
if len(call.TypeArgs) == 1 {
return []string{"List<" + call.TypeArgs[0] + ">"}
}
if len(call.Args) > 0 {
elemType := inferExprType(call.Args[0], scope)
for i := 1; i < len(call.Args); i++ {
argType := inferExprType(call.Args[i], scope)
if argType == "" || elemType == "" || argType != elemType {
elemType = ""
break
}
}
if elemType != "" {
return []string{"List<" + elemType + ">"}
}
}
return []string{"List<any>"}
case "mapOf", "mutableMapOf":
if len(call.TypeArgs) == 2 {
return []string{"Map<" + call.TypeArgs[0] + ", " + call.TypeArgs[1] + ">"}
}
if len(call.Args) > 0 && len(call.Args)%2 == 0 {
keyType := inferExprType(call.Args[0], scope)
valType := inferExprType(call.Args[1], scope)
for i := 2; i < len(call.Args); i += 2 {
nextKeyType := inferExprType(call.Args[i], scope)
nextValType := inferExprType(call.Args[i+1], scope)
if keyType == "" || nextKeyType == "" || keyType != nextKeyType {
keyType = ""
}
if valType == "" || nextValType == "" || valType != nextValType {
valType = ""
}
}
if keyType != "" && valType != "" {
return []string{"Map<" + keyType + ", " + valType + ">"}
}
}
return []string{"Map<any, any>"}
default:
return nil
}
case lang.SelectorExpr:
receiverType := inferExprType(callee.Receiver, scope)
if callee.Name == "read" {
if elemType, ok := channelElementType(receiverType); ok {
return []string{elemType}
}
return nil
}
if callee.Name == "send" {
return []string{"Unit"}
}
switch receiverType {
case "*sql.DB", "sql.DB":
switch callee.Name {
case "Ping":
return []string{"error"}
}
case "*bun.DB", "bun.DB":
switch callee.Name {
case "NewSelect":
return []string{"*bun.SelectQuery"}
case "NewCreateTable":
return []string{"*bun.CreateTableQuery"}
case "NewInsert":
return []string{"*bun.InsertQuery"}
}
case "*bun.SelectQuery", "bun.SelectQuery":
switch callee.Name {
case "ColumnExpr", "Model":
return []string{"*bun.SelectQuery"}
case "Scan":
return []string{"error"}
case "Count":
return []string{"Int", "error"}
}
case "*bun.CreateTableQuery", "bun.CreateTableQuery":
switch callee.Name {
case "Model", "IfNotExists":
return []string{"*bun.CreateTableQuery"}
case "Exec":
return []string{"sql.Result", "error"}
}
case "*bun.InsertQuery", "bun.InsertQuery":
switch callee.Name {
case "Model":
return []string{"*bun.InsertQuery"}
case "Exec":
return []string{"sql.Result", "error"}
}
}
if path, ok := selectorPathLang(call.Callee); ok {
switch path {
case "context.Background":
return []string{"context.Context"}
case "sql.OpenDB":
return []string{"*sql.DB"}
case "bun.NewDB":
return []string{"*bun.DB"}
case "pgdriver.NewConnector":
return []string{"pgdriver.Connector"}
}
}
}
return nil
}
inferExprType = func(expr lang.Expr, scope map[string]string) string {
switch e := expr.(type) {
case lang.IntExpr:
return "Int"
case lang.StringExpr:
return "String"
case lang.BoolExpr:
return "Boolean"
case lang.NullExpr:
return "null"
case lang.IdentExpr:
return scope[e.Name]
case lang.CallExpr:
types := inferCallReturnTypes(e, scope)
if len(types) > 0 {
return types[0]
}
return ""
case lang.SelectorExpr:
receiverType := inferExprType(e.Receiver, scope)
if receiverType == "" {
return ""
}
trimmed := strings.TrimPrefix(receiverType, "*")
if class, ok := classes[trimmed]; ok {
for _, field := range class.Fields {
if field.Name == e.Name {
return field.Type
}
}
}
if worker, ok := workers[trimmed]; ok {
for _, field := range worker.Fields {
if field.Name == e.Name {
if field.Type != "" {
return field.Type
}
return inferWorkerFieldType(field)
}
}
}
return ""
default:
return ""
}
}
walkStmts = func(stmts []lang.Stmt, scope map[string]string) {
for _, stmt := range stmts {
switch s := stmt.(type) {
case lang.VarDecl:
typ := s.Type
if typ == "" {
typ = inferExprType(s.Value, scope)
}
out = append(out, variableDeclInfo{Name: s.Name, Mutable: s.Mutable, Type: typ})
scope[s.Name] = typ
case lang.MultiVarDecl:
types := []string(nil)
if call, ok := s.Value.(lang.CallExpr); ok {
types = inferCallReturnTypes(call, scope)
}
for i, name := range s.Names {
typ := ""
if i < len(types) {
typ = types[i]
}
out = append(out, variableDeclInfo{Name: name, Mutable: s.Mutable, Type: typ})
scope[name] = typ
}
case lang.AddAssignStmt:
if _, ok := scope[s.Name]; !ok {
scope[s.Name] = ""
}
case lang.IfStmt:
thenScope := copyTypeScope(scope)
elseScope := copyTypeScope(scope)
walkStmts(s.Then, thenScope)
walkStmts(s.Else, elseScope)
case lang.WhileStmt:
bodyScope := copyTypeScope(scope)
walkStmts(s.Body, bodyScope)
case lang.SelectStmt:
for _, c := range s.Cases {
caseScope := copyTypeScope(scope)
if elemType, ok := channelElementType(inferExprType(c.Source, scope)); ok {
caseScope["it"] = elemType
} else {
caseScope["it"] = "any"
}
walkStmts(c.Body, caseScope)
}
case lang.TryCatchStmt:
tryScope := copyTypeScope(scope)
walkStmts(s.TryBody, tryScope)
catchScope := copyTypeScope(scope)
catchScope[s.CatchName] = s.CatchType
walkStmts(s.CatchBody, catchScope)
}
}
}
for _, fn := range program.Functions {
scope := map[string]string{}
for _, param := range fn.Params {
scope[param.Name] = param.Type
}
walkStmts(fn.Body, scope)
}
for _, class := range program.Classes {
fieldScope := map[string]string{}
for _, field := range class.Fields {
fieldScope[field.Name] = field.Type
}
for _, method := range class.Methods {
scope := copyTypeScope(fieldScope)
for _, param := range method.Params {
scope[param.Name] = param.Type
}
walkStmts(method.Body, scope)
}
}
for _, worker := range program.Workers {
fieldScope := map[string]string{}
for _, field := range worker.Fields {
typ := field.Type
if typ == "" {
typ = inferWorkerFieldType(field)
}
fieldScope[field.Name] = typ
}
for _, method := range worker.Methods {
scope := copyTypeScope(fieldScope)
for _, param := range method.Params {
scope[param.Name] = param.Type
}
walkStmts(method.Body, scope)
}
}
return out
}
func copyTypeScope(scope map[string]string) map[string]string {
dup := make(map[string]string, len(scope))
for k, v := range scope {
dup[k] = v
}
return dup
}
func channelElementType(typ string) (string, bool) {
typ = strings.TrimSpace(typ)
if !strings.HasPrefix(typ, "Channel<") || !strings.HasSuffix(typ, ">") {
return "", false
}
inner := strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(typ, "Channel<"), ">"))
if inner == "" {
return "", false
}
return inner, true
}
func staticIntExprValue(expr lang.Expr) (int, bool) {
switch e := expr.(type) {
case lang.IntExpr:
v, err := strconv.Atoi(e.Value)
if err != nil {
return 0, false
}
return v, true
case lang.UnaryExpr:
if e.Op != "-" {
return 0, false
}
v, ok := staticIntExprValue(e.Value)
if !ok {
return 0, false
}
return -v, true
default:
return 0, false
}
}
func inferWorkerFieldType(field lang.WorkerFieldDecl) string {
if field.Type != "" {
return field.Type
}
switch field.Value.(type) {
case lang.IntExpr:
return "Int"
case lang.StringExpr:
return "String"
case lang.BoolExpr:
return "Boolean"
default:
return ""
}
}
func selectorPathLang(expr lang.Expr) (string, bool) {
switch e := expr.(type) {
case lang.IdentExpr:
return e.Name, true
case lang.SelectorExpr:
left, ok := selectorPathLang(e.Receiver)
if !ok {
return "", false
}
return left + "." + e.Name, true
default:
return "", false
}
}
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, &copyDecl)
}
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<T>(capacity: Int = 0): Channel<T>",
"after": "fun after(ms: Int): Channel<time.Time>",
"every": "fun every(ms: Int): Channel<time.Time>",
"listOf": "fun listOf<T>(values: T...): List<T>",
"mutableListOf": "fun mutableListOf<T>(values: T...): MutableList<T>",
"mapOf": "fun mapOf<K, V>(pairs: Any...): Map<K, V>",
"mutableMapOf": "fun mutableMapOf<K, V>(pairs: Any...): MutableMap<K, V>",
"ByteSlice": "fun ByteSlice(value: String): ByteSlice",
"append": "fun append<T>(values: List<T>, value: T): List<T>",
"len": "fun len(value: Any): Int",
"cap": "fun cap(value: Any): Int",
"make": "fun make<T>(size: Int): T",
"new": "fun new<T>(): *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",
}
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
}