Move LSP semantics into typed analysis

This commit is contained in:
pavel 2026-08-27 21:28:11 +02:00
commit bac1183593
26 changed files with 1232 additions and 637 deletions

View file

@ -13,7 +13,7 @@ Current support:
- publish diagnostics from the existing Gotlin parser/code generator
- `textDocument/hover` for packages, imports, and top-level functions
- `textDocument/definition` for imported namespaces and top-level functions
- simple semantic diagnostics for duplicate imports/functions and undefined names
- structured resolver/type diagnostics from `lang.Analyze`, including package-context symbols
- optional fallback to `gopls` for hover/definition on Go-imported symbols
Build:

View file

@ -160,6 +160,7 @@ 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"`
}
@ -306,13 +307,13 @@ func buildDocumentState(text string) documentState {
return state
}
state.program = program
if _, err := lang.Analyze(program); err != nil {
state.diagnostics = append(state.diagnostics, diagnosticFromError(text, err))
}
_, semanticDiagnostics := lang.AnalyzeWithContext(program, nil)
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))
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
}
@ -325,10 +326,11 @@ func (s *server) buildDocumentState(uri, text string) documentState {
return state
}
state.program = program
state.symbols = indexSymbols(text, program)
programs, symbols := s.packageContext(uri, program.PackagePath, text)
_, semanticDiagnostics := lang.AnalyzeWithContext(program, programs)
state.symbols = indexSymbols(text, program)
state.packageSymbols = symbols
state.diagnostics = semanticDiagnosticsWithPackage(text, program, state.symbols, programs)
state.diagnostics = diagnosticsFromSemantic(text, semanticDiagnostics)
return state
}
@ -363,7 +365,9 @@ func (s *server) packageContext(currentURI, packagePath, currentText string) ([]
if parseErr != nil || program.PackagePath != packagePath {
return nil
}
programs = append(programs, program)
if candidateURI != currentURI {
programs = append(programs, program)
}
for _, sym := range indexSymbols(text, program) {
sym.URI = candidateURI
symbols = append(symbols, sym)
@ -729,227 +733,22 @@ func indexSymbols(text string, program *lang.Program) []symbol {
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
}
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,
})
}
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.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.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)...)
}
}
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.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.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:
walkExpr(e.Callee, scope)
for _, arg := range e.Args {
walkExpr(arg, scope)
}
case lang.SelectorExpr:
walkExpr(e.Receiver, scope)
case lang.SafeSelectorExpr:
walkExpr(e.Receiver, scope)
case lang.NonNullExpr:
walkExpr(e.Value, 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
}
@ -973,25 +772,6 @@ func diagnosticFromError(text string, err error) diagnostic {
}
}
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 {
@ -1221,7 +1001,11 @@ func renderImportDetail(imp lang.ImportDecl) string {
}
func renderFunctionSignature(fn lang.FunctionDecl) string {
return renderFunctionSignatureFromParts(fn.Name, fn.Params, fn.ReturnType)
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 {
@ -1244,6 +1028,11 @@ 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 {
@ -1289,289 +1078,59 @@ func renderVariableSignature(name string, mutable bool, typ string) string {
}
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
}
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}
}
switch callee.Name {
case "println":
return []string{"Unit"}
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
}
}
}
return ""
default:
return ""
}
}
walkStmts = func(stmts []lang.Stmt, scope map[string]string) {
for _, stmt := range stmts {
switch s := stmt.(type) {
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 := s.Type
typ := value.Type
if typ == "" {
resolved := lang.ResolvedType(s.Value)
resolved := lang.ResolvedType(value.Value)
if resolved.String() != "<unknown>" {
typ = resolved.String()
} else {
typ = inferExprType(s.Value, scope)
}
}
out = append(out, variableDeclInfo{Name: s.Name, Mutable: s.Mutable, Type: typ})
scope[s.Name] = typ
result = append(result, variableDeclInfo{Name: value.Name, Mutable: value.Mutable, Type: 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]
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()
}
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] = ""
for index, name := range value.Names {
result = append(result, variableDeclInfo{Name: name, Mutable: value.Mutable, Type: types[index]})
}
case lang.IfStmt:
thenScope := copyTypeScope(scope)
elseScope := copyTypeScope(scope)
walkStmts(s.Then, thenScope)
walkStmts(s.Else, elseScope)
walk(value.Then)
walk(value.Else)
case lang.WhileStmt:
bodyScope := copyTypeScope(scope)
walkStmts(s.Body, bodyScope)
walk(value.Body)
case lang.ForEachStmt:
walk(value.Body)
case lang.MatchStmt:
for _, matchCase := range value.Cases {
walk(matchCase.Body)
}
case lang.TryCatchStmt:
tryScope := copyTypeScope(scope)
walkStmts(s.TryBody, tryScope)
catchScope := copyTypeScope(scope)
catchScope[s.CatchName] = s.CatchType
walkStmts(s.CatchBody, catchScope)
walk(value.TryBody)
walk(value.CatchBody)
}
}
}
for _, fn := range program.Functions {
scope := map[string]string{}
for _, param := range fn.Params {
scope[param.Name] = param.Type
}
walkStmts(fn.Body, scope)
for _, function := range program.Functions {
walk(function.Body)
}
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)
walk(method.Body)
}
}
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 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
}
return result
}
func resolveStdlibTarget(state documentState, pos position) (stdlibTarget, bool) {

View file

@ -450,6 +450,21 @@ fun main() {
}
}
func TestBuildDocumentStateUsesStructuredSemanticDiagnostics(t *testing.T) {
text := "package demo\nfun main() { println(missing) }"
state := buildDocumentState(text)
if len(state.diagnostics) != 1 {
t.Fatalf("diagnostics = %#v", state.diagnostics)
}
diagnostic := state.diagnostics[0]
if diagnostic.Code != "undefined-symbol" || diagnostic.Severity != diagnosticSeverityError || diagnostic.Message != "undefined identifier missing" {
t.Fatalf("unexpected diagnostic: %#v", diagnostic)
}
if diagnostic.Range.Start.Line != 1 || diagnostic.Range.Start.Character != 21 {
t.Fatalf("unexpected diagnostic range: %#v", diagnostic.Range)
}
}
func TestBuildDocumentStateInfersHttpServerVariableTypes(t *testing.T) {
text := strings.TrimSpace(`
package demo
@ -460,7 +475,7 @@ import github.com.uptrace.bun
class C(val db: *bun.DB) {
fun run() {
val ctx = context.Background()
val total = db.NewSelect().ColumnExpr("1").Count(ctx)
val total = db.NewSelect().ColumnExpr("1").Count(ctx).unwrap()
println(total)
}
}