feat(lsp): add code actions, signature help, and document highlights
Assisted-by: MiMo V2.5 Pro
This commit is contained in:
+196
-1
@@ -256,7 +256,202 @@ func (s *Server) inlayHints(p inlayHintParams) []InlayHint {
|
|||||||
return out
|
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.
|
// documentSymbols returns functions and their labels, plus global symbols.
|
||||||
func (s *Server) documentSymbols(p documentSymbolParams) []DocumentSymbol {
|
func (s *Server) documentSymbols(p documentSymbolParams) []DocumentSymbol {
|
||||||
text := s.docs[p.TextDocument.URI]
|
text := s.docs[p.TextDocument.URI]
|
||||||
|
|||||||
@@ -309,11 +309,75 @@ type ServerCapabilities struct {
|
|||||||
RenameProvider bool `json:"renameProvider,omitempty"`
|
RenameProvider bool `json:"renameProvider,omitempty"`
|
||||||
DocumentSymbolProvider bool `json:"documentSymbolProvider,omitempty"`
|
DocumentSymbolProvider bool `json:"documentSymbolProvider,omitempty"`
|
||||||
DocumentFormattingProvider bool `json:"documentFormattingProvider,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"`
|
SemanticTokensProvider map[string]any `json:"semanticTokensProvider,omitempty"`
|
||||||
InlayHintProvider bool `json:"inlayHintProvider,omitempty"`
|
InlayHintProvider bool `json:"inlayHintProvider,omitempty"`
|
||||||
DiagnosticProvider map[string]any `json:"diagnosticProvider,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 {
|
type initializeResult struct {
|
||||||
Capabilities ServerCapabilities `json:"capabilities"`
|
Capabilities ServerCapabilities `json:"capabilities"`
|
||||||
ServerInfo map[string]string `json:"serverInfo,omitempty"`
|
ServerInfo map[string]string `json:"serverInfo,omitempty"`
|
||||||
|
|||||||
@@ -132,6 +132,9 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) {
|
|||||||
"full": true,
|
"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"},
|
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)
|
json.Unmarshal(msg.Params, &p)
|
||||||
s.respond(msg.ID, s.inlayHints(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":
|
case "textDocument/semanticTokens/full":
|
||||||
var p semanticTokensParams
|
var p semanticTokensParams
|
||||||
json.Unmarshal(msg.Params, &p)
|
json.Unmarshal(msg.Params, &p)
|
||||||
|
|||||||
@@ -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) {
|
func TestMethodNotFound(t *testing.T) {
|
||||||
in := frame(9, "bogus/method", map[string]any{}) + frame(nil, "exit", nil)
|
in := frame(9, "bogus/method", map[string]any{}) + frame(nil, "exit", nil)
|
||||||
msgs := run(t, in)
|
msgs := run(t, in)
|
||||||
|
|||||||
Reference in New Issue
Block a user