From f153be8158808fb85209f50b959ab9d23b8e409e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Balv=C3=ADn?= Date: Fri, 21 Aug 2026 01:01:58 +0200 Subject: [PATCH] feat(lsp): add code actions, signature help, and document highlights Assisted-by: MiMo V2.5 Pro --- lsp/handlers.go | 197 ++++++++++++++++++++++++++++++++++++++++++++- lsp/protocol.go | 64 +++++++++++++++ lsp/server.go | 20 ++++- lsp/server_test.go | 77 ++++++++++++++++++ 4 files changed, 356 insertions(+), 2 deletions(-) diff --git a/lsp/handlers.go b/lsp/handlers.go index 7cc276b..ae86933 100644 --- a/lsp/handlers.go +++ b/lsp/handlers.go @@ -256,7 +256,202 @@ func (s *Server) inlayHints(p inlayHintParams) []InlayHint { return out } -// formatFile produces a canonical formatting of a parsed file. +// codeActions returns quick fixes for diagnostics in the selected range. +func (s *Server) codeActions(p codeActionParams) []CodeAction { + text := s.docs[p.TextDocument.URI] + if text == "" { + return nil + } + var actions []CodeAction + for _, diag := range p.Context.Diagnostics { + switch diag.Code { + case "missing-ret": + // Offer to add RET at the end of the function. + f, _ := parser.Parse(uriPath(p.TextDocument.URI), text) + if f == nil { + continue + } + for _, d := range f.Decls { + t, ok := d.(*ast.Text) + if !ok { + continue + } + if len(t.Body) == 0 { + continue + } + last := t.Body[len(t.Body)-1] + line := last.Pos().Line + // Insert RET after the last statement. + lines := strings.Split(text, "\n") + if line-1 < len(lines) { + insertLine := line // zero-based index for insertion after last stmt + newLines := make([]string, 0, len(lines)+1) + newLines = append(newLines, lines[:insertLine]...) + newLines = append(newLines, "\tRET") + newLines = append(newLines, lines[insertLine:]...) + newText := strings.Join(newLines, "\n") + endLine := len(lines) - 1 + endChar := len([]rune(lines[endLine])) + actions = append(actions, CodeAction{ + Title: "Add RET", + Kind: "quickfix", + Edit: &WorkspaceEdit{ + Changes: map[string][]TextEdit{p.TextDocument.URI: { + {Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: endLine, Character: endChar}}, NewText: newText}, + }}, + }, + }) + } + } + case "unused-label": + // Offer to remove the unused label. + label := strings.TrimPrefix(diag.Message, "label \"") + label = strings.TrimSuffix(label, "\" is defined but never referenced") + if label != "" { + lines := strings.Split(text, "\n") + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == label+":" { + newLines := make([]string, 0, len(lines)-1) + newLines = append(newLines, lines[:i]...) + newLines = append(newLines, lines[i+1:]...) + newText := strings.Join(newLines, "\n") + endLine := len(lines) - 1 + endChar := len([]rune(lines[endLine])) + actions = append(actions, CodeAction{ + Title: "Remove unused label \"" + label + "\"", + Kind: "quickfix", + Edit: &WorkspaceEdit{ + Changes: map[string][]TextEdit{p.TextDocument.URI: { + {Range: Range{Start: Position{Line: 0, Character: 0}, End: Position{Line: endLine, Character: endChar}}, NewText: newText}, + }}, + }, + }) + break + } + } + } + } + } + return actions +} + +// signatureHelp returns signature information for a CALL instruction. +func (s *Server) signatureHelp(p signatureHelpParams) *SignatureHelp { + text := s.docs[p.TextDocument.URI] + if text == "" { + return nil + } + // Check if the cursor is on a CALL/BL line. + lines := strings.Split(text, "\n") + if p.Position.Line >= len(lines) { + return nil + } + line := lines[p.Position.Line] + trimmed := strings.TrimSpace(line) + upper := strings.ToUpper(trimmed) + if !strings.HasPrefix(upper, "CALL ") && !strings.HasPrefix(upper, "BL ") { + return nil + } + // Extract the function name from the CALL operand. + parts := strings.Fields(trimmed) + if len(parts) < 2 { + return nil + } + funcName := parts[1] + // Strip (SB) suffix. + funcName = strings.TrimSuffix(funcName, "(SB)") + + // Look up the function in the document. + f, _ := parser.Parse(uriPath(p.TextDocument.URI), text) + if f == nil { + return nil + } + // Strip middle-dot prefix if present (Plan 9 package separator). + bareName := strings.TrimPrefix(funcName, "·") + for _, d := range f.Decls { + t, ok := d.(*ast.Text) + if !ok { + continue + } + if t.Name.Name == bareName || t.Name.Name == funcName || strings.HasSuffix(t.Name.Name, "·"+bareName) { + label := "TEXT " + t.Name.Name + "(SB)" + sig := SignatureInformation{ + Label: label, + } + if t.Doc != "" { + sig.Documentation = t.Doc + } + // Add frame and args as parameters. + frame := int64(0) + if t.Frame != nil && t.Frame.Imm.HasVal { + frame = t.Frame.Imm.Val + } + args := int64(0) + if t.Args != nil && t.Args.Imm.HasVal { + args = t.Args.Imm.Val + } + sig.Parameters = []ParameterInformation{ + {Label: fmt.Sprintf("$%d", frame)}, + {Label: fmt.Sprintf("-%d", args)}, + } + return &SignatureHelp{Signatures: []SignatureInformation{sig}} + } + } + return nil +} + +// documentHighlights returns all occurrences of the symbol under the cursor. +func (s *Server) documentHighlights(p documentHighlightParams) []DocumentHighlight { + text := s.docs[p.TextDocument.URI] + word, _ := wordAt(text, p.Position) + if word == "" { + return nil + } + f, errs := parser.Parse(uriPath(p.TextDocument.URI), text) + if f == nil || len(errs) > 0 { + return nil + } + var out []DocumentHighlight + for _, d := range f.Decls { + t, ok := d.(*ast.Text) + if !ok { + continue + } + // Highlight the definition. + if t.Name.Name == word { + out = append(out, DocumentHighlight{ + Range: symRange(t.Name), + Kind: highlightWrite, + }) + } + for _, stmt := range t.Body { + switch st := stmt.(type) { + case *ast.Label: + if st.Name.Text == word { + out = append(out, DocumentHighlight{ + Range: tokenRange(st.Name), + Kind: highlightWrite, + }) + } + case *ast.Instr: + for _, op := range st.Operands { + if op.Addr.Sym != nil && op.Addr.Sym.Name == word { + out = append(out, DocumentHighlight{ + Range: Range{ + Start: Position{Line: op.Pos.Line - 1, Character: op.Pos.Column - 1}, + End: Position{Line: op.Pos.Line - 1, Character: op.Pos.Column - 1 + runeLen(word)}, + }, + Kind: highlightRead, + }) + } + } + } + } + } + return out +} + // documentSymbols returns functions and their labels, plus global symbols. func (s *Server) documentSymbols(p documentSymbolParams) []DocumentSymbol { text := s.docs[p.TextDocument.URI] diff --git a/lsp/protocol.go b/lsp/protocol.go index 4983cdc..0e087a6 100644 --- a/lsp/protocol.go +++ b/lsp/protocol.go @@ -309,11 +309,75 @@ type ServerCapabilities struct { RenameProvider bool `json:"renameProvider,omitempty"` DocumentSymbolProvider bool `json:"documentSymbolProvider,omitempty"` DocumentFormattingProvider bool `json:"documentFormattingProvider,omitempty"` + DocumentHighlightProvider bool `json:"documentHighlightProvider,omitempty"` + CodeActionProvider bool `json:"codeActionProvider,omitempty"` + SignatureHelpProvider map[string]any `json:"signatureHelpProvider,omitempty"` SemanticTokensProvider map[string]any `json:"semanticTokensProvider,omitempty"` InlayHintProvider bool `json:"inlayHintProvider,omitempty"` DiagnosticProvider map[string]any `json:"diagnosticProvider,omitempty"` } +// --- code actions ------------------------------------------------------------- + +type codeActionParams struct { + TextDocument textDocumentIdentifier `json:"textDocument"` + Range Range `json:"range"` + Context codeActionContext `json:"context"` +} + +type codeActionContext struct { + Diagnostics []Diagnostic `json:"diagnostics"` +} + +// CodeAction represents a quick fix or refactoring. +type CodeAction struct { + Title string `json:"title"` + Kind string `json:"kind,omitempty"` + Edit *WorkspaceEdit `json:"edit,omitempty"` +} + +// --- signature help ----------------------------------------------------------- + +type signatureHelpParams struct { + TextDocument textDocumentIdentifier `json:"textDocument"` + Position Position `json:"position"` +} + +// SignatureHelp represents signature information for a function call. +type SignatureHelp struct { + Signatures []SignatureInformation `json:"signatures"` +} + +// SignatureInformation represents a function signature. +type SignatureInformation struct { + Label string `json:"label"` + Documentation string `json:"documentation,omitempty"` + Parameters []ParameterInformation `json:"parameters,omitempty"` +} + +// ParameterInformation represents a parameter in a signature. +type ParameterInformation struct { + Label string `json:"label"` +} + +// --- document highlights ------------------------------------------------------ + +type documentHighlightParams struct { + TextDocument textDocumentIdentifier `json:"textDocument"` + Position Position `json:"position"` +} + +// DocumentHighlight represents a highlight range. +type DocumentHighlight struct { + Range Range `json:"range"` + Kind int `json:"kind,omitempty"` +} + +const ( + highlightRead = 1 + highlightWrite = 2 +) + type initializeResult struct { Capabilities ServerCapabilities `json:"capabilities"` ServerInfo map[string]string `json:"serverInfo,omitempty"` diff --git a/lsp/server.go b/lsp/server.go index 09fa9c5..1b02e02 100644 --- a/lsp/server.go +++ b/lsp/server.go @@ -131,7 +131,10 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) { }, "full": true, }, - InlayHintProvider: true, + InlayHintProvider: true, + DocumentHighlightProvider: true, + CodeActionProvider: true, + SignatureHelpProvider: map[string]any{"triggerCharacters": []string{"("}}, }, ServerInfo: map[string]string{"name": "gasm", "version": "0.31.1"}, }) @@ -205,6 +208,21 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) { json.Unmarshal(msg.Params, &p) s.respond(msg.ID, s.inlayHints(p)) + case "textDocument/codeAction": + var p codeActionParams + json.Unmarshal(msg.Params, &p) + s.respond(msg.ID, s.codeActions(p)) + + case "textDocument/signatureHelp": + var p signatureHelpParams + json.Unmarshal(msg.Params, &p) + s.respond(msg.ID, s.signatureHelp(p)) + + case "textDocument/documentHighlight": + var p documentHighlightParams + json.Unmarshal(msg.Params, &p) + s.respond(msg.ID, s.documentHighlights(p)) + case "textDocument/semanticTokens/full": var p semanticTokensParams json.Unmarshal(msg.Params, &p) diff --git a/lsp/server_test.go b/lsp/server_test.go index bdad5b4..eca0af7 100644 --- a/lsp/server_test.go +++ b/lsp/server_test.go @@ -373,6 +373,83 @@ func TestInlayHints(t *testing.T) { } } +func TestCodeActions(t *testing.T) { + badDoc := "#include \"textflag.h\"\n" + + "TEXT ·foo(SB), NOSPLIT, $0\n" + + "\tMOVQ AX, CX\n" + in := session("file:///f_amd64.s", badDoc) + + frame(10, "textDocument/codeAction", map[string]any{ + "textDocument": map[string]any{"uri": "file:///f_amd64.s"}, + "range": map[string]any{"start": map[string]any{"line": 0, "character": 0}, "end": map[string]any{"line": 2, "character": 0}}, + "context": map[string]any{"diagnostics": []map[string]any{{"code": "missing-ret", "range": map[string]any{"start": map[string]any{"line": 0, "character": 0}, "end": map[string]any{"line": 0, "character": 4}}}}}, + }) + frame(nil, "exit", nil) + msgs := run(t, in) + resp := findByID(msgs, 10) + if resp == nil { + t.Fatal("no codeAction response") + } + var actions []CodeAction + if err := json.Unmarshal(mustResult(t, resp), &actions); err != nil { + t.Fatal(err) + } + if len(actions) == 0 { + t.Fatal("want at least 1 code action for missing-ret") + } + if actions[0].Title != "Add RET" { + t.Errorf("action title = %q, want Add RET", actions[0].Title) + } +} + +func TestSignatureHelp(t *testing.T) { + doc := "#include \"textflag.h\"\n" + + "TEXT ·add(SB), NOSPLIT, $0-24\n" + + "\tMOVQ AX, CX\n" + + "\tRET\n" + + "TEXT ·caller(SB), NOSPLIT, $0\n" + + "\tCALL ·add(SB)\n" + + "\tRET\n" + in := session("file:///f_amd64.s", doc) + + frame(11, "textDocument/signatureHelp", map[string]any{ + "textDocument": map[string]any{"uri": "file:///f_amd64.s"}, + "position": map[string]any{"line": 5, "character": 6}, + }) + frame(nil, "exit", nil) + msgs := run(t, in) + resp := findByID(msgs, 11) + if resp == nil { + t.Fatal("no signatureHelp response") + } + var sh SignatureHelp + if err := json.Unmarshal(mustResult(t, resp), &sh); err != nil { + t.Fatal(err) + } + if len(sh.Signatures) == 0 { + t.Fatal("want at least 1 signature") + } + if !strings.Contains(sh.Signatures[0].Label, "add") { + t.Errorf("signature label = %q, want add", sh.Signatures[0].Label) + } +} + +func TestDocumentHighlights(t *testing.T) { + in := session("file:///f_amd64.s", cleanDoc) + + frame(12, "textDocument/documentHighlight", map[string]any{ + "textDocument": map[string]any{"uri": "file:///f_amd64.s"}, + "position": map[string]any{"line": 4, "character": 5}, + }) + frame(nil, "exit", nil) + msgs := run(t, in) + resp := findByID(msgs, 12) + if resp == nil { + t.Fatal("no documentHighlight response") + } + var highlights []DocumentHighlight + if err := json.Unmarshal(mustResult(t, resp), &highlights); err != nil { + t.Fatal(err) + } + if len(highlights) < 2 { + t.Fatalf("want at least 2 highlights (definition + use), got %d", len(highlights)) + } +} + func TestMethodNotFound(t *testing.T) { in := frame(9, "bogus/method", map[string]any{}) + frame(nil, "exit", nil) msgs := run(t, in)