diff --git a/docs/man/gasm-lsp.1 b/docs/man/gasm-lsp.1 index 71791f0..fac8627 100644 --- a/docs/man/gasm-lsp.1 +++ b/docs/man/gasm-lsp.1 @@ -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 gasm-lsp \- run the Plan 9 assembly language server .SH SYNOPSIS @@ -11,14 +11,30 @@ associate it with files; the target architecture is inferred from the file suffix (_amd64.s, _arm64.s, _riscv64.s, _loong64.s). .PP -Provides completion, hover, document symbols, push and pull -diagnostics, semantic-token highlighting, go-to-definition, find -references, rename, formatting, inlay hints, code actions, signature -help, document highlights, workspace symbol search, include document -links and folding ranges; definition, references and rename work across -every open document. Syntax highlighting is delivered as LSP semantic -tokens, so no editor-specific grammar is required. +Provides completion, hover, document symbols, push and pull diagnostics, +semantic-token highlighting, go-to-definition, find references, rename, +formatting, inlay hints, code actions, signature help, document +highlights, workspace symbol search, include document links and folding +ranges. Completion offers the architecture's registers and +instructions, the TEXT, DATA and GLOBL directives, the annotation and +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 Runs until the client closes the session; exits 0 on a clean shutdown. .SH SEE ALSO -.BR gasm (1) +.BR gasm (1), +.BR gasm\-lint (1) diff --git a/lsp/handlers.go b/lsp/handlers.go index dd11c06..9c5fcda 100644 --- a/lsp/handlers.go +++ b/lsp/handlers.go @@ -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 diff --git a/lsp/server_test.go b/lsp/server_test.go index 7b57f06..ec4d06b 100644 --- a/lsp/server_test.go +++ b/lsp/server_test.go @@ -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()