From c4859cfb73cc7f69d68a46f13c641de85c4cc8b9 Mon Sep 17 00:00:00 2001 From: Aofei Sheng Date: Thu, 26 Dec 2024 13:59:45 +0800 Subject: [PATCH] feat(tools/spxls): implement `textDocument/completion` (#1169) Updates #1059 Signed-off-by: Aofei Sheng --- tools/spxls/README.md | 1 - tools/spxls/internal/pkgdata/pkgdata.go | 25 +- tools/spxls/internal/server/command.go | 6 +- tools/spxls/internal/server/command_test.go | 40 ++ tools/spxls/internal/server/compile.go | 39 +- tools/spxls/internal/server/completion.go | 616 ++++++++++++++++++ .../spxls/internal/server/completion_test.go | 129 ++++ .../spxls/internal/server/diagnostic_test.go | 14 +- tools/spxls/internal/server/document_test.go | 28 +- tools/spxls/internal/server/server.go | 10 +- tools/spxls/internal/server/spx_definition.go | 118 +++- tools/spxls/internal/server/util.go | 10 +- 12 files changed, 987 insertions(+), 49 deletions(-) create mode 100644 tools/spxls/internal/server/completion.go create mode 100644 tools/spxls/internal/server/completion_test.go diff --git a/tools/spxls/README.md b/tools/spxls/README.md index 0bc310af..5221e647 100644 --- a/tools/spxls/README.md +++ b/tools/spxls/README.md @@ -44,7 +44,6 @@ For detailed API references, please check the [index.d.ts](index.d.ts) file. | **Code Intelligence** ||| || [`textDocument/hover`](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocument_hover) | Shows types and documentation at cursor position. | || [`textDocument/completion`](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocument_completion) | Generates context-aware code suggestions. | -|| [`completionItem/resolve`](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#completionItem_resolve) | Provides detailed information for selected completion items. | || [`textDocument/signatureHelp`](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocument_signatureHelp) | Shows function/method signature information. | | **Symbols & Navigation** ||| || [`textDocument/declaration`](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocument_declaration) | Finds symbol declarations. | diff --git a/tools/spxls/internal/pkgdata/pkgdata.go b/tools/spxls/internal/pkgdata/pkgdata.go index b413f5d5..1972ae67 100644 --- a/tools/spxls/internal/pkgdata/pkgdata.go +++ b/tools/spxls/internal/pkgdata/pkgdata.go @@ -8,6 +8,7 @@ import ( "fmt" "io" "io/fs" + "strings" "github.com/goplus/builder/tools/spxls/internal/pkgdoc" ) @@ -17,13 +18,33 @@ import ( //go:embed pkgdata.zip var pkgdataZip []byte +const ( + pkgExportSuffix = ".pkgexport" + pkgDocSuffix = ".pkgdoc" +) + +// ListPkgs lists all packages in the pkgdata.zip file. +func ListPkgs() ([]string, error) { + zr, err := zip.NewReader(bytes.NewReader(pkgdataZip), int64(len(pkgdataZip))) + if err != nil { + return nil, fmt.Errorf("failed to create zip reader: %w", err) + } + pkgs := make([]string, 0, len(zr.File)/2) + for _, f := range zr.File { + if strings.HasSuffix(f.Name, pkgExportSuffix) { + pkgs = append(pkgs, strings.TrimSuffix(f.Name, pkgExportSuffix)) + } + } + return pkgs, nil +} + // OpenExport opens a package export file. func OpenExport(pkgPath string) (io.ReadCloser, error) { zr, err := zip.NewReader(bytes.NewReader(pkgdataZip), int64(len(pkgdataZip))) if err != nil { return nil, fmt.Errorf("failed to create zip reader: %w", err) } - pkgExportFile := pkgPath + ".pkgexport" + pkgExportFile := pkgPath + pkgExportSuffix for _, f := range zr.File { if f.Name == pkgExportFile { return f.Open() @@ -38,7 +59,7 @@ func GetPkgDoc(pkgPath string) (*pkgdoc.PkgDoc, error) { if err != nil { return nil, fmt.Errorf("failed to create zip reader: %w", err) } - pkgDocFile := pkgPath + ".pkgdoc" + pkgDocFile := pkgPath + pkgDocSuffix for _, f := range zr.File { if f.Name == pkgDocFile { rc, err := f.Open() diff --git a/tools/spxls/internal/server/command.go b/tools/spxls/internal/server/command.go index bafe1212..ebaf57dd 100644 --- a/tools/spxls/internal/server/command.go +++ b/tools/spxls/internal/server/command.go @@ -113,10 +113,6 @@ func (s *Server) spxGetDefinitions(params []SpxGetDefinitionsParams) ([]SpxDefin return nil, err } if astFile == nil { - diagnostics := result.diagnostics[param.TextDocument.URI] - if len(diagnostics) > 0 { - return nil, fmt.Errorf("failed to compile file: %s", diagnostics[0].Message) - } return nil, nil } astFileScope := result.typeInfo.Scopes[astFile] @@ -233,7 +229,7 @@ func (s *Server) spxGetDefinitions(params []SpxGetDefinitionsParams) ([]SpxDefin }) case *types.Func: var methodNames []string - if methodOverloads := expandGoptOverloadedMethod(member); len(methodOverloads) > 0 { + if methodOverloads := expandGopOverloadedFunc(member); len(methodOverloads) > 0 { methodNames = make([]string, 0, len(methodOverloads)) for _, method := range methodOverloads { _, methodName, _ := util.SplitGoptMethod(method.Name()) diff --git a/tools/spxls/internal/server/command_test.go b/tools/spxls/internal/server/command_test.go index a48668fc..3db59718 100644 --- a/tools/spxls/internal/server/command_test.go +++ b/tools/spxls/internal/server/command_test.go @@ -142,6 +142,46 @@ onStart => { })) }) + t.Run("ParseError", func(t *testing.T) { + s := New(vfs.NewMapFS(func() map[string][]byte { + return map[string][]byte{ + "main.spx": []byte(` +// Invalid syntax +var ( + MySprite Sprite +`), + } + }), nil) + + mainSpxFileScopeParams := []SpxGetDefinitionsParams{ + { + TextDocumentPositionParams: TextDocumentPositionParams{ + TextDocument: TextDocumentIdentifier{URI: "file:///main.spx"}, + Position: Position{Line: 0, Character: 0}, + }, + }, + } + mainSpxFileScopeDefs, err := s.spxGetDefinitions(mainSpxFileScopeParams) + require.NoError(t, err) + require.NotNil(t, mainSpxFileScopeDefs) + assert.True(t, spxDefinitionIdentifierSliceContains(mainSpxFileScopeDefs, SpxDefinitionIdentifier{ + Package: util.ToPtr("builtin"), + Name: util.ToPtr("println"), + })) + assert.True(t, spxDefinitionIdentifierSliceContains(mainSpxFileScopeDefs, SpxDefinitionIdentifier{ + Package: util.ToPtr("main"), + Name: util.ToPtr("MySprite"), + })) + assert.True(t, spxDefinitionIdentifierSliceContains(mainSpxFileScopeDefs, SpxDefinitionIdentifier{ + Package: util.ToPtr(spxPkgPath), + Name: util.ToPtr("Game.onStart"), + })) + assert.False(t, spxDefinitionIdentifierSliceContains(mainSpxFileScopeDefs, SpxDefinitionIdentifier{ + Package: util.ToPtr(spxPkgPath), + Name: util.ToPtr("Sprite.onStart"), + })) + }) + t.Run("TrailingEmptyLinesOfSpriteCode", func(t *testing.T) { s := New(vfs.NewMapFS(func() map[string][]byte { return map[string][]byte{ diff --git a/tools/spxls/internal/server/compile.go b/tools/spxls/internal/server/compile.go index 7aa2fd8e..f6f30a0d 100644 --- a/tools/spxls/internal/server/compile.go +++ b/tools/spxls/internal/server/compile.go @@ -338,9 +338,12 @@ func (s *Server) compile() (*compileResult, error) { Mode: gopparser.AllErrors | gopparser.ParseComments, }) if err != nil { - // Handle parse errors. - var parseErr gopscanner.ErrorList + var ( + parseErr gopscanner.ErrorList + codeErr *gogen.CodeError + ) if errors.As(err, &parseErr) { + // Handle parse errors. for _, e := range parseErr { result.addDiagnostics(documentURI, Diagnostic{ Severity: SeverityError, @@ -351,12 +354,8 @@ func (s *Server) compile() (*compileResult, error) { Message: e.Msg, }) } - continue - } - - // Handle code generation errors. - var codeErr *gogen.CodeError - if errors.As(err, &codeErr) { + } else if errors.As(err, &codeErr) { + // Handle code generation errors. position := codeErr.Fset.Position(codeErr.Pos) result.addDiagnostics(documentURI, Diagnostic{ Severity: SeverityError, @@ -366,21 +365,19 @@ func (s *Server) compile() (*compileResult, error) { }, Message: codeErr.Error(), }) - continue + } else { + // Handle unknown errors. + result.addDiagnostics(documentURI, Diagnostic{ + Severity: SeverityError, + Range: Range{ + Start: Position{Line: 0, Character: 0}, + End: Position{Line: 0, Character: 0}, + }, + Message: fmt.Sprintf("failed to parse spx file: %v", err), + }) } - - // Handle unknown errors. - result.addDiagnostics(documentURI, Diagnostic{ - Severity: SeverityError, - Range: Range{ - Start: Position{Line: 0, Character: 0}, - End: Position{Line: 0, Character: 0}, - }, - Message: fmt.Sprintf("failed to parse spx file: %v", err), - }) - continue } - if astFile.Name.Name == "main" { + if astFile != nil && astFile.Name.Name == "main" { result.mainASTPkg.Files[spxFile] = astFile if spxFileBaseName := path.Base(spxFile); spxFileBaseName == "main.spx" { result.mainSpxFile = spxFile diff --git a/tools/spxls/internal/server/completion.go b/tools/spxls/internal/server/completion.go new file mode 100644 index 00000000..44b3473e --- /dev/null +++ b/tools/spxls/internal/server/completion.go @@ -0,0 +1,616 @@ +package server + +import ( + "errors" + "fmt" + "go/types" + "slices" + "strings" + + "github.com/goplus/builder/tools/spxls/internal/pkgdata" + "github.com/goplus/builder/tools/spxls/internal/util" + gopast "github.com/goplus/gop/ast" + goptoken "github.com/goplus/gop/token" +) + +// See https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocument_completion +func (s *Server) textDocumentCompletion(params *CompletionParams) ([]CompletionItem, error) { + result, _, astFile, err := s.compileAndGetASTFileForDocumentURI(params.TextDocument.URI) + if err != nil { + if errors.Is(err, errNoValidSpxFiles) || errors.Is(err, errNoMainSpxFile) { + return nil, nil + } + return nil, err + } + if astFile == nil { + return nil, nil + } + + tokenFile := result.fset.File(astFile.Pos()) + line := min(int(params.Position.Line)+1, tokenFile.LineCount()) + lineStart := tokenFile.LineStart(line) + pos := tokenFile.Pos(tokenFile.Offset(lineStart) + int(params.Position.Character)) + if !pos.IsValid() { + return nil, nil + } + innermostScope := result.innermostScopeAt(pos) + if innermostScope == nil { + return nil, nil + } + + ctx := &completionContext{ + result: result, + astFile: astFile, + pos: pos, + innermostScope: innermostScope, + fileScope: result.typeInfo.Scopes[astFile], + } + ctx.analyzeCompletionContext() + return ctx.collectCompletionItems() +} + +// completionKind represents different kinds of completion contexts. +type completionKind int + +const ( + completionKindUnknown completionKind = iota + completionKindImport + completionKindDot + completionKindCall + completionKindStructLiteral + completionKindAssignment + completionKindSwitchCase + completionKindSelect +) + +// completionContext represents the context for completion operations. +type completionContext struct { + result *compileResult + astFile *gopast.File + pos goptoken.Pos + innermostScope *types.Scope + fileScope *types.Scope + + kind completionKind + path *gopast.SelectorExpr + enclosing gopast.Node + expectedType types.Type + + inStruct *types.Struct + assignTarget types.Type + + inSwitch *gopast.SwitchStmt + inGo bool + returnIndex int +} + +// analyzeCompletionContext analyzes the completion context to determine the +// kind of completion needed. +func (ctx *completionContext) analyzeCompletionContext() { + path, _ := util.PathEnclosingInterval(ctx.astFile, ctx.pos, ctx.pos) + if len(path) == 0 { + return + } + for i, node := range path { + // TODO: Handle incomplete import statements. Currently the AST parsing may fail + // for incomplete syntax like `import "`. We may need to check the source code + // directly to detect import completion context in such cases. + switch n := node.(type) { + case *gopast.SelectorExpr: + if n.Sel == nil || n.Sel.Pos() >= ctx.pos { + ctx.kind = completionKindDot + ctx.path = n + } + case *gopast.CallExpr: + if n.Lparen.IsValid() && n.Lparen < ctx.pos { + ctx.kind = completionKindCall + } + case *gopast.GoStmt: + ctx.inGo = true + ctx.enclosing = n.Call + ctx.kind = completionKindCall + case *gopast.DeferStmt: + ctx.enclosing = n.Call + ctx.kind = completionKindCall + case *gopast.ReturnStmt: + sig := ctx.enclosingFunction(path[i+1:]) + if sig == nil { + break + } + results := sig.Results() + if results.Len() == 0 { + break + } + ctx.returnIndex = ctx.findReturnValueIndex(n) + if ctx.returnIndex >= 0 && ctx.returnIndex < results.Len() { + ctx.expectedType = results.At(ctx.returnIndex).Type() + } + case *gopast.AssignStmt: + if n.Tok != goptoken.ASSIGN && n.Tok != goptoken.DEFINE { + break + } + for j, rhs := range n.Rhs { + if rhs.Pos() > ctx.pos || ctx.pos > rhs.End() { + continue + } + if j < len(n.Lhs) { + if tv, ok := ctx.result.typeInfo.Types[n.Lhs[j]]; ok { + ctx.kind = completionKindAssignment + ctx.assignTarget = tv.Type + } + break + } + } + case *gopast.CompositeLit: + tv, ok := ctx.result.typeInfo.Types[n] + if !ok { + break + } + typ, ok := tv.Type.Underlying().(*types.Struct) + if !ok { + break + } + ctx.kind = completionKindStructLiteral + ctx.inStruct = typ + case *gopast.SwitchStmt: + ctx.kind = completionKindSwitchCase + ctx.inSwitch = n + case *gopast.SelectStmt: + ctx.kind = completionKindSelect + ctx.expectedType = types.NewChan(types.SendRecv, nil) + } + } +} + +// findReturnValueIndex finds the index of the return value at the current position. +func (ctx *completionContext) findReturnValueIndex(ret *gopast.ReturnStmt) int { + if len(ret.Results) == 0 { + return 0 + } + for i, expr := range ret.Results { + if ctx.pos >= expr.Pos() && ctx.pos <= expr.End() { + return i + } + } + if ctx.pos > ret.Results[len(ret.Results)-1].End() { + return len(ret.Results) + } + return -1 +} + +// enclosingFunction gets the function signature containing the current position. +func (ctx *completionContext) enclosingFunction(path []gopast.Node) *types.Signature { + for _, node := range path { + funcDecl, ok := node.(*gopast.FuncDecl) + if !ok { + continue + } + obj := ctx.result.typeInfo.ObjectOf(funcDecl.Name) + if obj == nil { + continue + } + fun, ok := obj.(*types.Func) + if !ok { + continue + } + return fun.Type().(*types.Signature) + } + return nil +} + +// collectCompletionItems collects completion items based on the completion context. +func (ctx *completionContext) collectCompletionItems() ([]CompletionItem, error) { + var ( + items []CompletionItem + err error + ) + switch ctx.kind { + case completionKindImport: + items, err = ctx.collectImportCompletions() + case completionKindDot: + items, err = ctx.collectDotCompletions() + case completionKindCall: + items, err = ctx.collectCallCompletions() + case completionKindStructLiteral: + items, err = ctx.collectStructLiteralCompletions() + case completionKindAssignment: + items, err = ctx.collectTypeSpecificCompletions() + case completionKindSwitchCase: + items, err = ctx.collectSwitchCaseCompletions() + case completionKindSelect: + items, err = ctx.collectSelectCompletions() + default: + items, err = ctx.collectGeneralCompletions() + } + if err != nil { + return nil, err + } + sortCompletionItems(items) + return items, nil +} + +// collectGeneralCompletions collects general completions. +func (ctx *completionContext) collectGeneralCompletions() ([]CompletionItem, error) { + var items []CompletionItem + seenDefIDs := make(map[string]struct{}) + addItems := func(defs ...SpxDefinition) { + for _, def := range defs { + if _, ok := seenDefIDs[def.ID.String()]; ok { + continue + } + seenDefIDs[def.ID.String()] = struct{}{} + items = append(items, def.CompletionItem()) + } + } + + // Add built-in definitions. + addItems(GetSpxBuiltinDefinitions()...) + + // Add general definitions. + addItems(SpxGeneralDefinitions...) + + // Add file scope definitions if in file scope. + if ctx.innermostScope == ctx.fileScope { + addItems(SpxFileScopeDefinitions...) + } + + // Add all visible objects in the scope. + for scope := ctx.innermostScope; scope != nil; scope = scope.Parent() { + for _, name := range scope.Names() { + obj := scope.Lookup(name) + if obj == nil || !obj.Exported() && obj.Pkg() != ctx.result.mainPkg { + continue + } + + switch obj := obj.(type) { + case *types.Var: + addItems(NewSpxDefinitionForVar(ctx.result, obj, "")) + case *types.Const: + addItems(NewSpxDefinitionForConst(ctx.result, obj)) + case *types.TypeName: + addItems(NewSpxDefinitionForType(ctx.result, obj)) + case *types.Func: + addItems(NewSpxDefinitionsForFunc(ctx.result, obj, "")...) + case *types.PkgName: + addItems(NewSpxDefinitionForPkg(ctx.result, obj)) + } + } + } + + return items, nil +} + +// collectImportCompletions collects import completions. +func (ctx *completionContext) collectImportCompletions() ([]CompletionItem, error) { + var items []CompletionItem + pkgs, err := pkgdata.ListPkgs() + if err != nil { + return nil, fmt.Errorf("failed to list packages: %w", err) + } + for _, pkgPath := range pkgs { + pkgDoc, err := pkgdata.GetPkgDoc(pkgPath) + if err != nil { + continue + } + items = append(items, SpxDefinition{ + ID: SpxDefinitionIdentifier{ + Package: &pkgPath, + }, + Overview: "package " + pkgPath, + Detail: pkgDoc.Doc, + + CompletionItemLabel: pkgPath, + CompletionItemKind: ModuleCompletion, + CompletionItemInsertText: pkgPath, + CompletionItemInsertTextFormat: PlainTextTextFormat, + }.CompletionItem()) + } + return items, nil +} + +// collectDotCompletions collects dot completions for member access. +func (ctx *completionContext) collectDotCompletions() ([]CompletionItem, error) { + if ctx.path == nil { + return nil, nil + } + tv, ok := ctx.result.typeInfo.Types[ctx.path.X] + if !ok { + return nil, nil + } + typ := unwrapPointerType(tv.Type) + if named, ok := typ.(*types.Named); ok && named.Obj().Pkg().Path() == spxPkgPath && named.Obj().Name() == "Sprite" { + typ = ctx.result.spxPkg.Scope().Lookup("SpriteImpl").Type() + } + + var items []CompletionItem + seenDefIDs := make(map[string]struct{}) + addItems := func(defs ...SpxDefinition) { + for _, def := range defs { + if _, ok := seenDefIDs[def.ID.String()]; ok { + continue + } + seenDefIDs[def.ID.String()] = struct{}{} + items = append(items, def.CompletionItem()) + } + } + + if iface, ok := typ.Underlying().(*types.Interface); ok { + for i := 0; i < iface.NumMethods(); i++ { + method := iface.Method(i) + if !method.Exported() && method.Pkg() != ctx.result.mainPkg { + continue + } + + addItems(NewSpxDefinitionsForFunc(ctx.result, method, "")...) + } + return items, nil + } + + if _, ok := typ.Underlying().(*types.Struct); ok { + walkStruct(typ, func(named *types.Named, namedParents []*types.Named, member types.Object) { + if !member.Exported() && member.Pkg() != ctx.result.mainPkg { + return + } + + switch member := member.(type) { + case *types.Var: + addItems(NewSpxDefinitionForVar(ctx.result, member, "")) + case *types.Func: + addItems(NewSpxDefinitionsForFunc(ctx.result, member, named.Obj().Name())...) + } + }) + } + + return items, nil +} + +// collectCallCompletions collects function call completions. +func (ctx *completionContext) collectCallCompletions() ([]CompletionItem, error) { + callExpr, ok := ctx.enclosing.(*gopast.CallExpr) + if !ok { + return nil, nil + } + tv, ok := ctx.result.typeInfo.Types[callExpr.Fun] + if !ok { + return nil, nil + } + sig, ok := tv.Type.(*types.Signature) + if !ok { + return nil, nil + } + argIndex := ctx.getCurrentArgIndex(callExpr) + if argIndex < 0 { + return nil, nil + } + + var expectedType types.Type + if argIndex < sig.Params().Len() { + expectedType = sig.Params().At(argIndex).Type() + } else if sig.Variadic() && argIndex >= sig.Params().Len()-1 { + expectedType = sig.Params().At(sig.Params().Len() - 1).Type().(*types.Slice).Elem() + } + ctx.expectedType = expectedType + return ctx.collectTypeSpecificCompletions() +} + +// getCurrentArgIndex gets the current argument index in a function call. +func (ctx *completionContext) getCurrentArgIndex(callExpr *gopast.CallExpr) int { + if len(callExpr.Args) == 0 { + return 0 + } + for i, arg := range callExpr.Args { + if ctx.pos >= arg.Pos() && ctx.pos <= arg.End() { + return i + } + } + if ctx.pos > callExpr.Args[len(callExpr.Args)-1].End() { + return len(callExpr.Args) + } + return -1 +} + +// collectStructLiteralCompletions collects struct literal completions. +func (ctx *completionContext) collectStructLiteralCompletions() ([]CompletionItem, error) { + if ctx.inStruct == nil { + return nil, nil + } + + var items []CompletionItem + seenFields := make(map[string]struct{}) + + // Collect already used fields. + if composite, ok := ctx.enclosing.(*gopast.CompositeLit); ok { + for _, elem := range composite.Elts { + if kv, ok := elem.(*gopast.KeyValueExpr); ok { + if ident, ok := kv.Key.(*gopast.Ident); ok { + seenFields[ident.Name] = struct{}{} + } + } + } + } + + // Add unused fields. + for i := 0; i < ctx.inStruct.NumFields(); i++ { + field := ctx.inStruct.Field(i) + if !field.Exported() && field.Pkg() != ctx.result.mainPkg { + continue + } + if _, ok := seenFields[field.Name()]; ok { + continue + } + + def := NewSpxDefinitionForVar(ctx.result, field, "") + def.CompletionItemInsertText = field.Name() + ": ${1:}" + def.CompletionItemInsertTextFormat = SnippetTextFormat + items = append(items, def.CompletionItem()) + } + + return items, nil +} + +// collectTypeSpecificCompletions collects type-specific completions. +func (ctx *completionContext) collectTypeSpecificCompletions() ([]CompletionItem, error) { + if ctx.expectedType == nil { + return ctx.collectGeneralCompletions() + } + + var items []CompletionItem + seenDefIDs := make(map[string]struct{}) + addItems := func(defs ...SpxDefinition) { + for _, def := range defs { + if _, ok := seenDefIDs[def.ID.String()]; ok { + continue + } + seenDefIDs[def.ID.String()] = struct{}{} + items = append(items, def.CompletionItem()) + } + } + + for scope := ctx.innermostScope; scope != nil; scope = scope.Parent() { + for _, name := range scope.Names() { + obj := scope.Lookup(name) + if obj == nil || !obj.Exported() && obj.Pkg() != ctx.result.mainPkg { + continue + } + if !isTypeCompatible(obj.Type(), ctx.expectedType) { + continue + } + + switch obj := obj.(type) { + case *types.Var: + addItems(NewSpxDefinitionForVar(ctx.result, obj, "")) + case *types.Const: + addItems(NewSpxDefinitionForConst(ctx.result, obj)) + case *types.Func: + addItems(NewSpxDefinitionsForFunc(ctx.result, obj, "")...) + } + } + } + return items, nil +} + +// isTypeCompatible checks if two types are compatible. +func isTypeCompatible(got, want types.Type) bool { + if got == nil || want == nil { + return false + } + + if types.AssignableTo(got, want) { + return true + } + + switch want := want.(type) { + case *types.Interface: + return types.Implements(got, want) + case *types.Pointer: + if gotPtr, ok := got.(*types.Pointer); ok { + return types.Identical(want.Elem(), gotPtr.Elem()) + } + return types.Identical(got, want.Elem()) + case *types.Slice: + gotSlice, ok := got.(*types.Slice) + return ok && types.Identical(want.Elem(), gotSlice.Elem()) + case *types.Chan: + gotCh, ok := got.(*types.Chan) + return ok && types.Identical(want.Elem(), gotCh.Elem()) && + (want.Dir() == types.SendRecv || want.Dir() == gotCh.Dir()) + } + + if named, ok := got.(*types.Named); ok { + return types.AssignableTo(named.Underlying(), want) + } + + return false +} + +// collectSwitchCaseCompletions collects switch/case completions. +func (ctx *completionContext) collectSwitchCaseCompletions() ([]CompletionItem, error) { + if ctx.inSwitch == nil { + return nil, nil + } + + var items []CompletionItem + seenDefIDs := make(map[string]struct{}) + addItem := func(def SpxDefinition) { + if _, ok := seenDefIDs[def.ID.String()]; ok { + return + } + seenDefIDs[def.ID.String()] = struct{}{} + items = append(items, def.CompletionItem()) + } + + var switchType types.Type + if ctx.inSwitch.Tag != nil { + if tv, ok := ctx.result.typeInfo.Types[ctx.inSwitch.Tag]; ok { + switchType = tv.Type + } + } + + if ctx.inSwitch.Tag == nil { + for _, typ := range []string{"int", "string", "bool", "error"} { + addItem(GetSpxBuiltinDefinition(typ)) + } + return items, nil + } + + if named, ok := switchType.(*types.Named); ok { + if named.Obj().Pkg() != nil { + scope := named.Obj().Pkg().Scope() + for _, name := range scope.Names() { + obj := scope.Lookup(name) + c, ok := obj.(*types.Const) + if !ok { + continue + } + + if types.Identical(c.Type(), switchType) { + addItem(NewSpxDefinitionForConst(ctx.result, c)) + } + } + } + } + + return items, nil +} + +// collectSelectCompletions collects select statement completions. +func (ctx *completionContext) collectSelectCompletions() ([]CompletionItem, error) { + var items []CompletionItem + items = append(items, CompletionItem{ + Label: "case", + Kind: KeywordCompletion, + InsertText: "case ${1:ch} <- ${2:value}:$0", + InsertTextFormat: util.ToPtr(SnippetTextFormat), + }) + items = append(items, CompletionItem{ + Label: "default", + Kind: KeywordCompletion, + InsertText: "default:$0", + InsertTextFormat: util.ToPtr(SnippetTextFormat), + }) + return items, nil +} + +// completionItemKindPriority is the priority order for different completion +// item kinds. +var completionItemKindPriority = map[CompletionItemKind]int{ + VariableCompletion: 1, + FieldCompletion: 2, + MethodCompletion: 3, + FunctionCompletion: 4, + ConstantCompletion: 5, + ClassCompletion: 6, + InterfaceCompletion: 7, + ModuleCompletion: 8, + KeywordCompletion: 9, +} + +// sortCompletionItems sorts completion items. +func sortCompletionItems(items []CompletionItem) { + slices.SortStableFunc(items, func(a, b CompletionItem) int { + if p1, p2 := completionItemKindPriority[a.Kind], completionItemKindPriority[b.Kind]; p1 != p2 { + return p1 - p2 + } + return strings.Compare(a.Label, b.Label) + }) +} diff --git a/tools/spxls/internal/server/completion_test.go b/tools/spxls/internal/server/completion_test.go new file mode 100644 index 00000000..00fa1a31 --- /dev/null +++ b/tools/spxls/internal/server/completion_test.go @@ -0,0 +1,129 @@ +package server + +import ( + "testing" + + "github.com/goplus/builder/tools/spxls/internal/util" + "github.com/goplus/builder/tools/spxls/internal/vfs" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServerTextDocumentCompletion(t *testing.T) { + t.Run("Normal", func(t *testing.T) { + s := New(vfs.NewMapFS(func() map[string][]byte { + return map[string][]byte{ + "main.spx": []byte(` +var ( + MySprite Sprite +) + +MySprite. +run "assets", {Title: "My Game"} +`), + "MySprite.spx": []byte(` +onStart => { + MySprite.turn Right +} +`), + "assets/sprites/MySprite/index.json": []byte(`{}`), + } + }), nil) + + emptyLineItems, err := s.textDocumentCompletion(&CompletionParams{ + TextDocumentPositionParams: TextDocumentPositionParams{ + TextDocument: TextDocumentIdentifier{URI: "file:///main.spx"}, + Position: Position{Line: 4, Character: 0}, + }, + }) + require.NoError(t, err) + require.NotNil(t, emptyLineItems) + assert.Contains(t, emptyLineItems, GetSpxBuiltinDefinition("println").CompletionItem()) + assert.Contains(t, emptyLineItems, SpxDefinition{ + ID: SpxDefinitionIdentifier{ + Package: util.ToPtr("main"), + Name: util.ToPtr("MySprite")}, + Overview: "type MySprite struct{SpriteImpl; *main.Game}", + + CompletionItemLabel: "MySprite", + CompletionItemKind: StructCompletion, + CompletionItemInsertText: "MySprite", + CompletionItemInsertTextFormat: PlainTextTextFormat, + }.CompletionItem()) + + mySpriteDotItems, err := s.textDocumentCompletion(&CompletionParams{ + TextDocumentPositionParams: TextDocumentPositionParams{ + TextDocument: TextDocumentIdentifier{URI: "file:///main.spx"}, + Position: Position{Line: 5, Character: 9}, + }, + }) + require.NoError(t, err) + require.NotNil(t, mySpriteDotItems) + assert.NotContains(t, mySpriteDotItems, GetSpxBuiltinDefinition("println").CompletionItem()) + assert.Contains(t, mySpriteDotItems, SpxDefinition{ + ID: SpxDefinitionIdentifier{ + Package: util.ToPtr("github.com/goplus/spx"), + Name: util.ToPtr("Sprite.turn"), + OverloadID: util.ToPtr("0"), + }, + Overview: "func turn(degree float64)", + + CompletionItemLabel: "turn", + CompletionItemKind: FunctionCompletion, + CompletionItemInsertText: "turn", + CompletionItemInsertTextFormat: PlainTextTextFormat, + }.CompletionItem()) + assert.Contains(t, mySpriteDotItems, SpxDefinition{ + ID: SpxDefinitionIdentifier{ + Package: util.ToPtr("github.com/goplus/spx"), + Name: util.ToPtr("Sprite.turn"), + OverloadID: util.ToPtr("1"), + }, + Overview: "func turn(dir specialDir)", + + CompletionItemLabel: "turn", + CompletionItemKind: FunctionCompletion, + CompletionItemInsertText: "turn", + CompletionItemInsertTextFormat: PlainTextTextFormat, + }.CompletionItem()) + assert.Contains(t, mySpriteDotItems, SpxDefinition{ + ID: SpxDefinitionIdentifier{ + Package: util.ToPtr("github.com/goplus/spx"), + Name: util.ToPtr("Sprite.turn"), + OverloadID: util.ToPtr("2"), + }, + Overview: "func turn(ti *TurningInfo)", + + CompletionItemLabel: "turn", + CompletionItemKind: FunctionCompletion, + CompletionItemInsertText: "turn", + CompletionItemInsertTextFormat: PlainTextTextFormat, + }.CompletionItem()) + assert.Contains(t, mySpriteDotItems, SpxDefinition{ + ID: SpxDefinitionIdentifier{ + Package: util.ToPtr("github.com/goplus/spx"), + Name: util.ToPtr("Sprite.clone"), + OverloadID: util.ToPtr("0"), + }, + Overview: "func clone()", + + CompletionItemLabel: "clone", + CompletionItemKind: FunctionCompletion, + CompletionItemInsertText: "clone", + CompletionItemInsertTextFormat: PlainTextTextFormat, + }.CompletionItem()) + assert.Contains(t, mySpriteDotItems, SpxDefinition{ + ID: SpxDefinitionIdentifier{ + Package: util.ToPtr("github.com/goplus/spx"), + Name: util.ToPtr("Sprite.clone"), + OverloadID: util.ToPtr("1"), + }, + Overview: "func clone(data interface{})", + + CompletionItemLabel: "clone", + CompletionItemKind: FunctionCompletion, + CompletionItemInsertText: "clone", + CompletionItemInsertTextFormat: PlainTextTextFormat, + }.CompletionItem()) + }) +} diff --git a/tools/spxls/internal/server/diagnostic_test.go b/tools/spxls/internal/server/diagnostic_test.go index ba713d64..6250defc 100644 --- a/tools/spxls/internal/server/diagnostic_test.go +++ b/tools/spxls/internal/server/diagnostic_test.go @@ -159,12 +159,12 @@ func TestServerWorkspaceDiagnostic(t *testing.T) { require.NoError(t, err) require.NotNil(t, report) assert.Len(t, report.Items, 3) - foundFiles := make(map[string]bool) + foundFiles := make(map[string]struct{}) for _, item := range report.Items { fullReport := item.Value.(WorkspaceFullDocumentDiagnosticReport) relPath, err := s.fromDocumentURI(fullReport.URI) require.NoError(t, err) - foundFiles[relPath] = true + foundFiles[relPath] = struct{}{} assert.Equal(t, string(DiagnosticFull), fullReport.Kind) assert.Empty(t, fullReport.Items) } @@ -192,7 +192,7 @@ var ( for _, item := range report.Items { fullReport := item.Value.(WorkspaceFullDocumentDiagnosticReport) if fullReport.URI == "file:///main.spx" { - require.Len(t, fullReport.Items, 2) + require.Len(t, fullReport.Items, 3) assert.Contains(t, fullReport.Items, Diagnostic{ Severity: SeverityError, Message: "expected ')', found 'EOF'", @@ -209,6 +209,14 @@ var ( End: Position{Line: 3, Character: 23}, }, }) + assert.Contains(t, fullReport.Items, Diagnostic{ + Severity: SeverityError, + Message: `sprite resource "MyAircraft" not found`, + Range: Range{ + Start: Position{Line: 3, Character: 1}, + End: Position{Line: 3, Character: 11}, + }, + }) } else { assert.Empty(t, fullReport.Items) } diff --git a/tools/spxls/internal/server/document_test.go b/tools/spxls/internal/server/document_test.go index a51a3257..2a02a757 100644 --- a/tools/spxls/internal/server/document_test.go +++ b/tools/spxls/internal/server/document_test.go @@ -231,7 +231,31 @@ var ( } links, err := s.textDocumentDocumentLink(params) - assert.NoError(t, err) - assert.Nil(t, links) + require.NoError(t, err) + require.Len(t, links, 3) + assert.Contains(t, links, DocumentLink{ + Range: Range{ + Start: Position{Line: 3, Character: 1}, + End: Position{Line: 3, Character: 8}, + }, + Target: toURI("spx://resources/sounds/MySound"), + Data: SpxResourceRefDocumentLinkData{ + Kind: SpxResourceRefKindAutoBinding, + }, + }) + assert.Contains(t, links, DocumentLink{ + Range: Range{ + Start: Position{Line: 3, Character: 1}, + End: Position{Line: 3, Character: 8}, + }, + Target: toURI("gop:main?MySound"), + }) + assert.Contains(t, links, DocumentLink{ + Range: Range{ + Start: Position{Line: 3, Character: 9}, + End: Position{Line: 3, Character: 14}, + }, + Target: toURI("gop:github.com/goplus/spx?Sound"), + }) }) } diff --git a/tools/spxls/internal/server/server.go b/tools/spxls/internal/server/server.go index caf6842e..e21b8f7e 100644 --- a/tools/spxls/internal/server/server.go +++ b/tools/spxls/internal/server/server.go @@ -79,13 +79,9 @@ func (s *Server) handleCall(c *jsonrpc2.Call) error { if err := UnmarshalJSON(c.Params(), ¶ms); err != nil { return s.replyParseError(c.ID(), err) } - return errors.New("TODO") - case "completionItem/resolve": - var item CompletionItem - if err := UnmarshalJSON(c.Params(), &item); err != nil { - return s.replyParseError(c.ID(), err) - } - return errors.New("TODO") + s.runWithResponse(c.ID(), func() (any, error) { + return s.textDocumentCompletion(¶ms) + }) case "textDocument/signatureHelp": var params SignatureHelpParams if err := UnmarshalJSON(c.Params(), ¶ms); err != nil { diff --git a/tools/spxls/internal/server/spx_definition.go b/tools/spxls/internal/server/spx_definition.go index 9809193a..7f26bbdd 100644 --- a/tools/spxls/internal/server/spx_definition.go +++ b/tools/spxls/internal/server/spx_definition.go @@ -16,6 +16,11 @@ type SpxDefinition struct { ID SpxDefinitionIdentifier Overview string Detail string + + CompletionItemLabel string + CompletionItemKind CompletionItemKind + CompletionItemInsertText string + CompletionItemInsertTextFormat InsertTextFormat } // HTML returns the HTML representation of the definition. @@ -23,6 +28,17 @@ func (def SpxDefinition) HTML() string { return fmt.Sprintf("\n%s\n", def.ID, def.Overview, def.Detail) } +// CompletionItem constructs a [CompletionItem] from the definition. +func (def SpxDefinition) CompletionItem() CompletionItem { + return CompletionItem{ + Label: def.CompletionItemLabel, + Kind: def.CompletionItemKind, + Documentation: &Or_CompletionItem_documentation{Value: MarkupContent{Kind: Markdown, Value: def.HTML()}}, + InsertText: def.CompletionItemInsertText, + InsertTextFormat: &def.CompletionItemInsertTextFormat, + } +} + var ( // SpxGeneralDefinitions are general spx definitions. SpxGeneralDefinitions = []SpxDefinition{ @@ -30,31 +46,61 @@ var ( ID: SpxDefinitionIdentifier{Name: util.ToPtr("for_iterate")}, Overview: "for i, v <- set { ... }", Detail: "Iterate within given set", + + CompletionItemLabel: "for", + CompletionItemKind: KeywordCompletion, + CompletionItemInsertText: "for ${1:i}, ${2:v} <- ${3:set} {\n\t$0\n}", + CompletionItemInsertTextFormat: SnippetTextFormat, }, { ID: SpxDefinitionIdentifier{Name: util.ToPtr("for_loop_with_condition")}, Overview: "for condition { ... }", Detail: "Loop with condition", + + CompletionItemLabel: "for", + CompletionItemKind: KeywordCompletion, + CompletionItemInsertText: "for ${1:condition} {\n\t$0\n}", + CompletionItemInsertTextFormat: SnippetTextFormat, }, { ID: SpxDefinitionIdentifier{Name: util.ToPtr("for_loop_with_range")}, Overview: "for i <- start:end { ... }", Detail: "Loop with range", + + CompletionItemLabel: "for", + CompletionItemKind: KeywordCompletion, + CompletionItemInsertText: "for ${1:i} <- ${2:start}:${3:end} {\n\t$0\n}", + CompletionItemInsertTextFormat: SnippetTextFormat, }, { ID: SpxDefinitionIdentifier{Name: util.ToPtr("if_statement")}, Overview: "if condition { ... }", Detail: "If statement", + + CompletionItemLabel: "if", + CompletionItemKind: KeywordCompletion, + CompletionItemInsertText: "if ${1:condition} {\n\t$0\n}", + CompletionItemInsertTextFormat: SnippetTextFormat, }, { ID: SpxDefinitionIdentifier{Name: util.ToPtr("if_else_statement")}, Overview: "if condition { ... } else { ... }", Detail: "If else statement", + + CompletionItemLabel: "if", + CompletionItemKind: KeywordCompletion, + CompletionItemInsertText: "if ${1:condition} {\n\t${2:}\n} else {\n\t$0\n}", + CompletionItemInsertTextFormat: SnippetTextFormat, }, { ID: SpxDefinitionIdentifier{Name: util.ToPtr("var_declaration")}, Overview: "var name type", Detail: "Variable declaration, e.g., `var count int`", + + CompletionItemLabel: "var", + CompletionItemKind: KeywordCompletion, + CompletionItemInsertText: "var ${1:name} $0", + CompletionItemInsertTextFormat: SnippetTextFormat, }, } @@ -65,6 +111,11 @@ var ( ID: SpxDefinitionIdentifier{Name: util.ToPtr("func_declaration")}, Overview: "func name(params) { ... }", Detail: "Function declaration, e.g., `func add(a int, b int) int {}`", + + CompletionItemLabel: "func", + CompletionItemKind: KeywordCompletion, + CompletionItemInsertText: "func ${1:name}(${2:params}) ${3:returnType} {\n\t$0\n}", + CompletionItemInsertTextFormat: SnippetTextFormat, }, } @@ -153,6 +204,20 @@ func GetSpxBuiltinDefinition(name string) SpxDefinition { } } + completionItemKind := TextCompletion + if keyword, _, ok := strings.Cut(overview, " "); ok { + switch keyword { + case "var": + completionItemKind = VariableCompletion + case "const": + completionItemKind = ConstantCompletion + case "type": + completionItemKind = StructCompletion + case "func": + completionItemKind = FunctionCompletion + } + } + return SpxDefinition{ ID: SpxDefinitionIdentifier{ Package: util.ToPtr(pkgPath), @@ -160,6 +225,11 @@ func GetSpxBuiltinDefinition(name string) SpxDefinition { }, Overview: overview, Detail: detail, + + CompletionItemLabel: name, + CompletionItemKind: completionItemKind, + CompletionItemInsertText: name, + CompletionItemInsertTextFormat: PlainTextTextFormat, } } @@ -259,6 +329,10 @@ func NewSpxDefinitionForVar(result *compileResult, v *types.Var, selectorTypeNam if selectorTypeName != "" { idName = selectorTypeName + "." + idName } + completionItemKind := VariableCompletion + if strings.HasPrefix(overview.String(), "field ") { + completionItemKind = FieldCompletion + } return SpxDefinition{ ID: SpxDefinitionIdentifier{ Package: &pkgPath, @@ -266,6 +340,11 @@ func NewSpxDefinitionForVar(result *compileResult, v *types.Var, selectorTypeNam }, Overview: overview.String(), Detail: detail, + + CompletionItemLabel: v.Name(), + CompletionItemKind: completionItemKind, + CompletionItemInsertText: v.Name(), + CompletionItemInsertTextFormat: PlainTextTextFormat, } } @@ -307,6 +386,11 @@ func NewSpxDefinitionForConst(result *compileResult, c *types.Const) SpxDefiniti }, Overview: overview.String(), Detail: detail, + + CompletionItemLabel: c.Name(), + CompletionItemKind: ConstantCompletion, + CompletionItemInsertText: c.Name(), + CompletionItemInsertTextFormat: PlainTextTextFormat, } } @@ -355,6 +439,11 @@ func NewSpxDefinitionForType(result *compileResult, typeName *types.TypeName) Sp }, Overview: overview.String(), Detail: detail, + + CompletionItemLabel: typeName.Name(), + CompletionItemKind: StructCompletion, + CompletionItemInsertText: typeName.Name(), + CompletionItemInsertTextFormat: PlainTextTextFormat, } } @@ -362,6 +451,13 @@ func NewSpxDefinitionForType(result *compileResult, typeName *types.TypeName) Sp // function. It returns multiple definitions if the function has overloaded // variants. func NewSpxDefinitionsForFunc(result *compileResult, fun *types.Func, recvTypeNameOverride string) []SpxDefinition { + if funcOverloads := expandGopOverloadedFunc(fun); len(funcOverloads) > 0 { + // When encountering a overload signature like `func(__gop_overload_args__ interface{_()})`, + // we expand it to concrete overloads and use the first one as the default representation. + // All overload variants will still be included in the returned definitions. + fun = funcOverloads[0] + } + pkg := fun.Pkg() pkgPath := pkg.Path() defIdent := result.defIdentOf(fun) @@ -407,6 +503,11 @@ func NewSpxDefinitionsForFunc(result *compileResult, fun *types.Func, recvTypeNa }, Overview: overview, Detail: detail, + + CompletionItemLabel: parsedName, + CompletionItemKind: FunctionCompletion, + CompletionItemInsertText: parsedName, + CompletionItemInsertTextFormat: PlainTextTextFormat, }, } @@ -447,7 +548,7 @@ func NewSpxDefinitionsForFunc(result *compileResult, fun *types.Func, recvTypeNa } methodName := method.Name() - if methodOverloads := expandGoptOverloadedMethod(method); len(methodOverloads) > 0 { + if methodOverloads := expandGopOverloadedFunc(method); len(methodOverloads) > 0 { for i := range methodOverloads { method = methodOverloads[i] methodName = method.Name() @@ -470,6 +571,11 @@ func NewSpxDefinitionsForFunc(result *compileResult, fun *types.Func, recvTypeNa }, Overview: overview, Detail: doc, + + CompletionItemLabel: parsedName, + CompletionItemKind: FunctionCompletion, + CompletionItemInsertText: parsedName, + CompletionItemInsertTextFormat: PlainTextTextFormat, }) } }) @@ -497,6 +603,11 @@ func NewSpxDefinitionsForFunc(result *compileResult, fun *types.Func, recvTypeNa }, Overview: overview, Detail: doc, + + CompletionItemLabel: parsedName, + CompletionItemKind: FunctionCompletion, + CompletionItemInsertText: parsedName, + CompletionItemInsertTextFormat: PlainTextTextFormat, }) } } @@ -616,5 +727,10 @@ func NewSpxDefinitionForPkg(result *compileResult, pkgName *types.PkgName) SpxDe }, Overview: "package " + pkgName.Name(), Detail: detail, + + CompletionItemLabel: pkgName.Name(), + CompletionItemKind: ModuleCompletion, + CompletionItemInsertText: pkgName.Name(), + CompletionItemInsertTextFormat: PlainTextTextFormat, } } diff --git a/tools/spxls/internal/server/util.go b/tools/spxls/internal/server/util.go index 3ca8b801..4ddd2f5a 100644 --- a/tools/spxls/internal/server/util.go +++ b/tools/spxls/internal/server/util.go @@ -144,16 +144,12 @@ func parseGopFuncName(name string) (parsedName string, overloadID *string) { return } -// expandGoptOverloadedMethod expands the given Go+ template method to all -// its overloads. -func expandGoptOverloadedMethod(method *types.Func) []*types.Func { - typ, objs := gogen.CheckSigFuncExObjects(method.Type().(*types.Signature)) +// expandGopOverloadedFunc expands the given Go+ function to all its overloads. +func expandGopOverloadedFunc(fun *types.Func) []*types.Func { + typ, objs := gogen.CheckSigFuncExObjects(fun.Type().(*types.Signature)) if typ == nil { return nil } - if _, ok := typ.(*gogen.TyTemplateRecvMethod); !ok { - return nil - } overloads := make([]*types.Func, 0, len(objs)) for _, obj := range objs { overloads = append(overloads, obj.(*types.Func))