feat(lsp): document directives on hover and widen completion

Assisted-by: GLM 5.3
This commit is contained in:
2026-10-02 00:40:54 +02:00
parent 69dcbec8ef
commit 42bc1669d7
3 changed files with 290 additions and 15 deletions
+100 -6
View File
@@ -35,6 +35,46 @@ var textflagMacros = map[string]bool{
"NOFRAME": true, "REFLECTMETHOD": true, "TOPFRAME": true, "ABIWRAPPER": true,
}
// directiveDocs documents the directives and pseudo-operations for hover and
// completion, condensed from the language reference in docs/asm/.
var directiveDocs = map[string]string{
"TEXT": "TEXT symbol(SB), [flags,] $framesize-argsize: define a function. The frame size " +
"counts the local frame in bytes, the argument size the caller's argument area. End the " +
"body with RET: execution never falls through into the next TEXT.",
"DATA": "DATA symbol+offset(SB)/width, value: initialise one field of a data symbol. The " +
"width is 1, 2, 4 or 8 for an integer (any width for a string); bytes no DATA line " +
"names stay zero.",
"GLOBL": "GLOBL symbol(SB), [flags,] $size: declare a global symbol with its total size in " +
"bytes. Every DATA initialiser needs its GLOBL; uninitialised bytes are zero.",
"PCALIGN": "PCALIGN $n: pad the code so the next instruction lands on an n-byte boundary, " +
"a power of two at least the target's instruction alignment.",
"FUNCDATA": "FUNCDATA $index, symbol(SB): a garbage-collector annotation indexed by the " +
"funcdata.h ids; assembly normally reaches it through the GO_ARGS and " +
"NO_LOCAL_POINTERS macros.",
"PCDATA": "PCDATA $index, $value: a pc-value table annotation for the collector and " +
"traceback. The compiler writes these; hand-written values are meaningless outside " +
"the funcdata.h macros.",
"BYTE": "BYTE $imm: emit literal bytes into the instruction stream, the escape hatch for " +
"an encoding the assembler does not know.",
"WORD": "WORD $imm: emit a literal 16-bit word into the instruction stream.",
"LONG": "LONG $imm: emit a literal 32-bit word into the instruction stream.",
"QUAD": "QUAD $imm: emit a literal 64-bit word into the instruction stream.",
"FLOAT": "FLOAT $imm: emit a literal floating-point constant into the data section.",
}
// docSummary reduces a directive description to the sentence after its
// grammar, the short form a completion entry shows.
func docSummary(desc string) string {
_, rest, ok := strings.Cut(desc, ":")
if !ok {
return desc
}
if i := strings.Index(rest, "."); i >= 0 {
rest = rest[:i]
}
return strings.TrimSpace(rest)
}
// completion builds the completion list for a document.
func (s *Server) completion(p completionParams) []CompletionItem {
a := arch.ForArch(arch.FromFilename(uriPath(p.TextDocument.URI)))
@@ -44,6 +84,15 @@ func (s *Server) completion(p completionParams) []CompletionItem {
{Label: "DATA", Kind: ciKeyword, Detail: "initialise a data symbol"},
{Label: "GLOBL", Kind: ciKeyword, Detail: "declare a global symbol"},
}
for name, desc := range directiveDocs {
if name == "TEXT" || name == "DATA" || name == "GLOBL" {
continue
}
items = append(items, CompletionItem{
Label: name, Kind: ciKeyword, Detail: docSummary(desc),
Documentation: desc,
})
}
for name := range textflagMacros {
items = append(items, CompletionItem{Label: name, Kind: ciKeyword, Detail: "textflag.h flag"})
}
@@ -85,7 +134,13 @@ func (s *Server) hover(p hoverParams) *Hover {
a := arch.ForArch(arch.FromFilename(uriPath(p.TextDocument.URI)))
var md string
if in, ok := a.Lookup(word); ok {
// Directives first: several of their names (TEXT, PCALIGN, FUNCDATA,
// the BYTE family) also sit in the generated instruction tables with
// empty summaries, and the directive documentation is the answer a
// reader wants over them.
if doc, ok := directiveDocs[strings.ToUpper(word)]; ok {
md = doc
} else if in, ok := a.Lookup(word); ok {
md = "**" + in.Name + "**: " + in.Summary
} else if r, ok := a.Register(word); ok {
md = "**" + r.Name + "**: " + r.Class.String() + " register. " + r.Desc
@@ -316,10 +371,12 @@ func (s *Server) codeActions(p codeActionParams) []CodeAction {
var actions []CodeAction
for _, diag := range p.Context.Diagnostics {
switch diag.Code {
case "missing-ret":
case lint.CodeMissingRet:
// Offer to add RET at the end of the flagged function only: the
// diagnostic's range covers the TEXT keyword, so a line match
// picks the function the diagnostic belongs to.
// picks the function the diagnostic belongs to. Both shapes of
// the rule are served: a function with no RET at all, and one
// whose tail can fall off the end.
f, _ := parser.Parse(uriPath(p.TextDocument.URI), text)
if f == nil {
continue
@@ -359,7 +416,7 @@ func (s *Server) codeActions(p codeActionParams) []CodeAction {
})
}
}
case "unused-label":
case lint.CodeUnusedLabel:
// Offer to remove the unused label.
label := strings.TrimPrefix(diag.Message, "label \"")
label = strings.TrimSuffix(label, "\" is defined but never referenced")
@@ -387,7 +444,7 @@ func (s *Server) codeActions(p codeActionParams) []CodeAction {
}
}
}
case "missing-textflag-include":
case lint.CodeMissingTextflag:
// Offer to #include the header that defines the flag macros:
// after the last existing include, or at the top of the file
// when there is none.
@@ -408,7 +465,7 @@ func (s *Server) codeActions(p codeActionParams) []CodeAction {
}}},
},
})
case "abi-argsize":
case lint.CodeABIArgSize:
// Offer to set the TEXT argument area to the size the // func
// signature implies. The diagnostic's range covers the TEXT
// keyword, so a line match picks the function it belongs to.
@@ -442,6 +499,43 @@ func (s *Server) codeActions(p codeActionParams) []CodeAction {
},
})
}
case lint.CodeMissingArgSize:
// Offer to declare the argument area the // func signature
// implies, by appending it to the frame operand ($0 becomes
// $0-16). The diagnostic's range covers the TEXT keyword, so a
// line match picks the function it belongs to.
f, _ := parser.Parse(uriPath(p.TextDocument.URI), text)
if f == nil {
continue
}
for _, d := range f.Decls {
t, ok := d.(*ast.Text)
if !ok || t.Keyword.Pos.Line-1 != int(diag.Range.Start.Line) {
continue
}
want, ok := lint.ExpectedArgSize(t.Doc)
if !ok || t.Args != nil || t.Frame == nil || !t.Frame.Imm.HasVal {
continue
}
// The frame operand as written in the document; the
// operand's Raw joins its tokens with a space and is not
// the source spelling.
spell := "$" + strconv.FormatInt(t.Frame.Imm.Val, 10)
rng := clientRange(text, Range{
Start: Position{Line: t.Frame.Pos.Line - 1, Character: t.Frame.Pos.Column - 1},
End: Position{Line: t.Frame.Pos.Line - 1, Character: t.Frame.Pos.Column - 1 + runeLen(spell)},
})
actions = append(actions, CodeAction{
Title: fmt.Sprintf("Declare argument area -%d", want),
Kind: "quickfix",
Edit: &WorkspaceEdit{
Changes: map[string][]TextEdit{p.TextDocument.URI: {{
Range: rng,
NewText: spell + "-" + strconv.FormatInt(want, 10),
}}},
},
})
}
}
}
return actions
+165
View File
@@ -806,6 +806,171 @@ func TestSemanticTokensPipeOperator(t *testing.T) {
}
}
// TestCodeActionDeclaresArgSize pins the missing-argsize quick fix: the
// frame operand $0 grows to $0-16, the area the // func signature implies.
func TestCodeActionDeclaresArgSize(t *testing.T) {
doc := "// func f(a int) int\n" +
"TEXT \u00b7f(SB), $0\n" +
"\tMOVQ a+0(FP), AX\n" +
"\tMOVQ AX, ret+8(FP)\n" +
"\tRET\n"
in := session("file:///f_amd64.s", doc) +
frame(34, "textDocument/codeAction", map[string]any{
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
"range": map[string]any{"start": map[string]any{"line": 1, "character": 0}, "end": map[string]any{"line": 1, "character": 4}},
"context": map[string]any{"diagnostics": []map[string]any{
{"code": "missing-argsize", "range": map[string]any{"start": map[string]any{"line": 1, "character": 0}, "end": map[string]any{"line": 1, "character": 4}}},
}},
}) + frame(nil, "exit", nil)
msgs := run(t, in)
resp := findByID(msgs, 34)
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) != 1 {
t.Fatalf("actions = %d, want 1", len(actions))
}
if actions[0].Title != "Declare argument area -16" {
t.Errorf("action title = %q, want Declare argument area -16", actions[0].Title)
}
edits := actions[0].Edit.Changes["file:///f_amd64.s"]
if len(edits) != 1 || edits[0].NewText != "$0-16" {
t.Fatalf("edit = %+v, want the frame operand rewritten to $0-16", edits)
}
if got := applyEdits(doc, edits); !strings.Contains(got, "TEXT \u00b7f(SB), $0-16") {
t.Errorf("edited document = %q, want $0-16 on the TEXT line", got)
}
}
// TestNewLintRulesPublished checks that the new rule families reach the
// editor through publishDiagnostics: the DATA structure errors and the
// addressing edges.
func TestNewLintRulesPublished(t *testing.T) {
doc := "#include \"textflag.h\"\n" +
"DATA ·tab+0(SB)/3, $7\n" +
"GLOBL ·tab(SB), RODATA, $8\n" +
"TEXT ·f(SB), NOSPLIT, $0-8\n" +
"\tMOVQ 0(FP), AX\n" +
"\tRET\n"
msgs := run(t, session("file:///f_amd64.s", doc)+frame(nil, "exit", nil))
pub := findMethod(msgs, "textDocument/publishDiagnostics")
if pub == nil {
t.Fatal("no publishDiagnostics notification")
}
var p publishDiagnosticsParams
json.Unmarshal(pub.Params, &p)
found := map[string]bool{}
for _, d := range p.Diagnostics {
found[d.Code] = true
}
for _, want := range []string{"data-width", "unnamed-fp-reference"} {
if !found[want] {
t.Errorf("want a %s diagnostic, got %+v", want, p.Diagnostics)
}
}
}
// TestCompletionDirectives checks the directive completions: the annotation
// and padding pseudo-ops appear beside TEXT, DATA and GLOBL.
func TestCompletionDirectives(t *testing.T) {
in := session("file:///f_amd64.s", cleanDoc) +
frame(35, "textDocument/completion", map[string]any{
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
"position": map[string]any{"line": 2, "character": 1},
}) + frame(nil, "exit", nil)
msgs := run(t, in)
resp := findByID(msgs, 35)
if resp == nil {
t.Fatal("no completion response")
}
var items []CompletionItem
if err := json.Unmarshal(mustResult(t, resp), &items); err != nil {
t.Fatal(err)
}
labels := map[string]bool{}
for _, it := range items {
labels[it.Label] = true
}
for _, want := range []string{"PCALIGN", "FUNCDATA", "PCDATA", "BYTE", "QUAD"} {
if !labels[want] {
t.Errorf("completion missing %q", want)
}
}
}
// TestHoverDirectives checks hover over the directives the language defines.
func TestHoverDirectives(t *testing.T) {
doc := "#include \"textflag.h\"\n" +
"TEXT ·f(SB), NOSPLIT, $8\n" +
"\tPCALIGN $32\n" +
"\tMOVQ $1, x-8(SP)\n" +
"\tRET\n"
in := session("file:///f_amd64.s", doc) +
frame(36, "textDocument/hover", map[string]any{
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
"position": map[string]any{"line": 2, "character": 2}, // on PCALIGN
}) +
frame(37, "textDocument/hover", map[string]any{
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
"position": map[string]any{"line": 1, "character": 2}, // on TEXT
}) + frame(nil, "exit", nil)
msgs := run(t, in)
for id, want := range map[int]string{36: "boundary", 37: "framesize-argsize"} {
resp := findByID(msgs, id)
if resp == nil {
t.Fatalf("no hover response %d", id)
}
var h Hover
if err := json.Unmarshal(mustResult(t, resp), &h); err != nil {
t.Fatal(err)
}
if !strings.Contains(h.Contents.Value, want) {
t.Errorf("hover %d = %q, want it to mention %q", id, h.Contents.Value, want)
}
}
}
// TestCodeActionFallthroughAddsRet pins the quick fix for the second
// missing-ret shape: a function that has a RET but can fall off its end
// gets the RET appended after its last statement.
func TestCodeActionFallthroughAddsRet(t *testing.T) {
doc := "#include \"textflag.h\"\n" +
"TEXT \u00b7f(SB), NOSPLIT, $0\n" +
"\tCMPQ AX, $0\n" +
"\tJNE done\n" +
"\tRET\n" +
"done:\n" +
"\tMOVQ $2, BX\n"
in := session("file:///f_amd64.s", doc) +
frame(38, "textDocument/codeAction", map[string]any{
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
"range": map[string]any{"start": map[string]any{"line": 1, "character": 0}, "end": map[string]any{"line": 6, "character": 0}},
"context": map[string]any{"diagnostics": []map[string]any{
{"code": "missing-ret", "range": map[string]any{"start": map[string]any{"line": 1, "character": 0}, "end": map[string]any{"line": 1, "character": 4}}},
}},
}) + frame(nil, "exit", nil)
msgs := run(t, in)
resp := findByID(msgs, 38)
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) != 1 {
t.Fatalf("actions = %d, want 1", len(actions))
}
newText := actions[0].Edit.Changes["file:///f_amd64.s"][0].NewText
if !strings.HasSuffix(newText, "MOVQ $2, BX\n\tRET\n") {
t.Errorf("edit does not append RET after the last statement:\n%s", newText)
}
}
// mustResult re-marshals a response result into raw JSON for typed decoding.
func mustResult(t *testing.T, m *rpcMessage) []byte {
t.Helper()