feat(lsp): add code actions, signature help, and document highlights

Assisted-by: MiMo V2.5 Pro
This commit is contained in:
2026-08-21 01:20:14 +02:00
parent 1bf95a169e
commit f153be8158
4 changed files with 356 additions and 2 deletions
+196 -1
View File
@@ -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]
+64
View File
@@ -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"`
+19 -1
View File
@@ -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)
+77
View File
@@ -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)