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
+25 -9
View File
@@ -1,4 +1,4 @@
.TH GASM-LSP 1 "2026-09-19" "gasm" "User Commands" .TH GASM-LSP 1 "2026-10-01" "gasm" "User Commands"
.SH NAME .SH NAME
gasm-lsp \- run the Plan 9 assembly language server gasm-lsp \- run the Plan 9 assembly language server
.SH SYNOPSIS .SH SYNOPSIS
@@ -11,14 +11,30 @@ associate it with
files; the target architecture is inferred from the file suffix files; the target architecture is inferred from the file suffix
(_amd64.s, _arm64.s, _riscv64.s, _loong64.s). (_amd64.s, _arm64.s, _riscv64.s, _loong64.s).
.PP .PP
Provides completion, hover, document symbols, push and pull Provides completion, hover, document symbols, push and pull diagnostics,
diagnostics, semantic-token highlighting, go-to-definition, find semantic-token highlighting, go-to-definition, find references, rename,
references, rename, formatting, inlay hints, code actions, signature formatting, inlay hints, code actions, signature help, document
help, document highlights, workspace symbol search, include document highlights, workspace symbol search, include document links and folding
links and folding ranges; definition, references and rename work across ranges. Completion offers the architecture's registers and
every open document. Syntax highlighting is delivered as LSP semantic instructions, the TEXT, DATA and GLOBL directives, the annotation and
tokens, so no editor-specific grammar is required. padding pseudo-ops (PCALIGN, FUNCDATA, PCDATA, the BYTE family) and the
textflag constants; hover documents mnemonics, registers,
pseudo-registers and the directives. Syntax highlighting is delivered
as LSP semantic tokens, so no editor-specific grammar is required.
.PP
Definition, references and rename work across every open document and
the wider workspace on disk: the server indexes the .s files under the
workspace root that the editor has never opened, an open buffer always
shadows its disk copy, and watched-file events together with a per-query
freshness check keep the index current.
.PP
The quick fixes add the missing textflag.h include, set the TEXT
argument area to the size the
.B //\ func
signature implies, declare that area when it is omitted entirely, add a
missing RET, and remove an unused label.
.SH EXIT STATUS .SH EXIT STATUS
Runs until the client closes the session; exits 0 on a clean shutdown. Runs until the client closes the session; exits 0 on a clean shutdown.
.SH SEE ALSO .SH SEE ALSO
.BR gasm (1) .BR gasm (1),
.BR gasm\-lint (1)
+100 -6
View File
@@ -35,6 +35,46 @@ var textflagMacros = map[string]bool{
"NOFRAME": true, "REFLECTMETHOD": true, "TOPFRAME": true, "ABIWRAPPER": true, "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. // completion builds the completion list for a document.
func (s *Server) completion(p completionParams) []CompletionItem { func (s *Server) completion(p completionParams) []CompletionItem {
a := arch.ForArch(arch.FromFilename(uriPath(p.TextDocument.URI))) 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: "DATA", Kind: ciKeyword, Detail: "initialise a data symbol"},
{Label: "GLOBL", Kind: ciKeyword, Detail: "declare a global 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 { for name := range textflagMacros {
items = append(items, CompletionItem{Label: name, Kind: ciKeyword, Detail: "textflag.h flag"}) 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))) a := arch.ForArch(arch.FromFilename(uriPath(p.TextDocument.URI)))
var md string 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 md = "**" + in.Name + "**: " + in.Summary
} else if r, ok := a.Register(word); ok { } else if r, ok := a.Register(word); ok {
md = "**" + r.Name + "**: " + r.Class.String() + " register. " + r.Desc md = "**" + r.Name + "**: " + r.Class.String() + " register. " + r.Desc
@@ -316,10 +371,12 @@ func (s *Server) codeActions(p codeActionParams) []CodeAction {
var actions []CodeAction var actions []CodeAction
for _, diag := range p.Context.Diagnostics { for _, diag := range p.Context.Diagnostics {
switch diag.Code { switch diag.Code {
case "missing-ret": case lint.CodeMissingRet:
// Offer to add RET at the end of the flagged function only: the // Offer to add RET at the end of the flagged function only: the
// diagnostic's range covers the TEXT keyword, so a line match // 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) f, _ := parser.Parse(uriPath(p.TextDocument.URI), text)
if f == nil { if f == nil {
continue continue
@@ -359,7 +416,7 @@ func (s *Server) codeActions(p codeActionParams) []CodeAction {
}) })
} }
} }
case "unused-label": case lint.CodeUnusedLabel:
// Offer to remove the unused label. // Offer to remove the unused label.
label := strings.TrimPrefix(diag.Message, "label \"") label := strings.TrimPrefix(diag.Message, "label \"")
label = strings.TrimSuffix(label, "\" is defined but never referenced") 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: // Offer to #include the header that defines the flag macros:
// after the last existing include, or at the top of the file // after the last existing include, or at the top of the file
// when there is none. // 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 // Offer to set the TEXT argument area to the size the // func
// signature implies. The diagnostic's range covers the TEXT // signature implies. The diagnostic's range covers the TEXT
// keyword, so a line match picks the function it belongs to. // 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 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. // mustResult re-marshals a response result into raw JSON for typed decoding.
func mustResult(t *testing.T, m *rpcMessage) []byte { func mustResult(t *testing.T, m *rpcMessage) []byte {
t.Helper() t.Helper()