feat(lsp): quick fixes for the textflag include and the argument area
Test / test (push) Successful in 2m56s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-23 20:23:35 +02:00
parent 4be16dcdf5
commit 8eed54b3da
7 changed files with 208 additions and 3 deletions
+5
View File
@@ -15,6 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
reach files that were never opened. An open buffer always shadows its reach files that were never opened. An open buffer always shadows its
disk copy, and watched-file events together with a per-query freshness disk copy, and watched-file events together with a per-query freshness
check keep the index current. check keep the index current.
- **Quick fixes for the textflag include and the argument area.** The
`missing-textflag-include` warning offers to add the include after the
last one in the file, and the `abi-argsize` warning offers to set the
TEXT argument area to the size the `// func` signature implies, computed
by the new `lint.ExpectedArgSize`.
### Fixed ### Fixed
+2 -1
View File
@@ -101,7 +101,8 @@ to give that syntax the tooling it deserves.
help, document highlights, workspace symbol search, #include document help, document highlights, workspace symbol search, #include document
links and folding ranges over stdio; definition, references and rename links and folding ranges over stdio; definition, references and rename
work across every open document and the indexed workspace files beyond work across every open document and the indexed workspace files beyond
them. them, and the quick fixes add a missing textflag.h include and set the
argument area from the // func signature.
- **Comparators and audits.** `gasm diff` compares the machine code of two - **Comparators and audits.** `gasm diff` compares the machine code of two
assembly files byte-for-byte, `gasm profile` shows basic-block structure, assembly files byte-for-byte, `gasm profile` shows basic-block structure,
`gasm audit-instructions` diffs the encoder against the installed toolchain, `gasm audit-instructions` diffs the encoder against the installed toolchain,
+4 -1
View File
@@ -254,7 +254,10 @@ document store, republishes diagnostics on every change, and provides:
- **assists**: document formatting through the `format` package, inlay hints - **assists**: document formatting through the `format` package, inlay hints
(the frame size after the TEXT argument area), signature help (the callee's (the frame size after the TEXT argument area), signature help (the callee's
`// func` signature while the cursor is on a `CALL`), and code actions `// func` signature while the cursor is on a `CALL`), and code actions
offering quick fixes for the `missing-ret` and `unused-label` diagnostics. offering quick fixes for the `missing-ret` and `unused-label` diagnostics,
the `missing-textflag-include` warning (the include after the last one in
the file) and the `abi-argsize` warning (the argument area set to the size
the `// func` signature implies).
- **document information**: pull diagnostics (`textDocument/diagnostic`), - **document information**: pull diagnostics (`textDocument/diagnostic`),
#include document links (resolved against the document directory, then #include document links (resolved against the document directory, then
`$GOROOT/pkg/include`) and folding ranges (one collapsible region per `$GOROOT/pkg/include`) and folding ranges (one collapsible region per
+4 -1
View File
@@ -458,7 +458,10 @@ function bodies. Definition, references and rename work across every open
document and the wider workspace on disk: the server indexes the `.s` files 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 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 always shadows its disk copy, and watched-file events together with a
per-query freshness check keep the index current. per-query freshness check keep the index current. The quick fixes add the
missing `#include "textflag.h"`, set the TEXT argument area to the size the
`// func` signature implies, add a missing `RET`, and remove an unused
label.
## version ## version
+9
View File
@@ -11,6 +11,15 @@ import (
"strings" "strings"
) )
// ExpectedArgSize computes the argument-area size (parameters plus results,
// laid out with Go's alignment rules on a 64-bit target) implied by the
// `// func …` signature in a doc comment. It returns ok=false when there is
// no parseable signature or it uses a type whose size cannot be determined
// (a named type), so the caller can skip the fix rather than guess.
func ExpectedArgSize(doc string) (int64, bool) {
return abiExpectedArgSize(doc)
}
// abiExpectedArgSize computes the argument-area size (parameters plus results, // abiExpectedArgSize computes the argument-area size (parameters plus results,
// laid out with Go's alignment rules on a 64-bit target) from the `// func …` // laid out with Go's alignment rules on a 64-bit target) from the `// func …`
// signature in a TEXT function's doc comment. It returns ok=false when there // signature in a TEXT function's doc comment. It returns ok=false when there
+56
View File
@@ -11,6 +11,7 @@ import (
"regexp" "regexp"
"runtime" "runtime"
"slices" "slices"
"strconv"
"strings" "strings"
"unicode" "unicode"
"unicode/utf16" "unicode/utf16"
@@ -386,6 +387,61 @@ func (s *Server) codeActions(p codeActionParams) []CodeAction {
} }
} }
} }
case "missing-textflag-include":
// 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.
insertLine := 0
lines := strings.Split(text, "\n")
for i, line := range lines {
if includeRe.MatchString(line) {
insertLine = i + 1
}
}
actions = append(actions, CodeAction{
Title: `Add #include "textflag.h"`,
Kind: "quickfix",
Edit: &WorkspaceEdit{
Changes: map[string][]TextEdit{p.TextDocument.URI: {{
Range: Range{Start: Position{Line: insertLine, Character: 0}, End: Position{Line: insertLine, Character: 0}},
NewText: "#include \"textflag.h\"\n",
}}},
},
})
case "abi-argsize":
// 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.
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.Args.Imm.HasVal || t.Args.Imm.Val == want {
continue
}
// The parser records the argument area with its leading
// minus, so the replacement spans the whole `-16` shape.
rng := clientRange(text, Range{
Start: Position{Line: t.Args.Pos.Line - 1, Character: t.Args.Pos.Column - 1},
End: Position{Line: t.Args.Pos.Line - 1, Character: t.Args.Pos.Column - 1 + runeLen(t.Args.Raw)},
})
actions = append(actions, CodeAction{
Title: fmt.Sprintf("Set arg size to %d", want),
Kind: "quickfix",
Edit: &WorkspaceEdit{
Changes: map[string][]TextEdit{p.TextDocument.URI: {{
Range: rng,
NewText: "-" + strconv.FormatInt(want, 10),
}}},
},
})
}
} }
} }
return actions return actions
+128
View File
@@ -1014,6 +1014,134 @@ func TestCodeActionsTargetsFlaggedFunctionOnly(t *testing.T) {
} }
} }
// TestCodeActionInsertsTextflagInclude offers the include a file must carry
// when it uses flag macros: at the top of the file when there is no include
// yet, after the last one otherwise.
func TestCodeActionInsertsTextflagInclude(t *testing.T) {
doc := "TEXT \u00b7f(SB), NOSPLIT, $0\n\tRET\n"
withInclude := "#include \"go_asm.h\"\n\nTEXT \u00b7f(SB), NOSPLIT, $0\n\tRET\n"
in := session("file:///f_amd64.s", doc) +
frame(30, "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": 1, "character": 0}},
"context": map[string]any{"diagnostics": []map[string]any{
{"code": "missing-textflag-include", "range": map[string]any{"start": map[string]any{"line": 0, "character": 0}, "end": map[string]any{"line": 0, "character": 4}}},
}},
}) +
frame(nil, "textDocument/didChange", map[string]any{
"textDocument": map[string]any{"uri": "file:///f_amd64.s", "version": 2},
"contentChanges": []map[string]any{{"text": withInclude}},
}) +
frame(31, "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": 3, "character": 0}},
"context": map[string]any{"diagnostics": []map[string]any{
{"code": "missing-textflag-include", "range": map[string]any{"start": map[string]any{"line": 2, "character": 0}, "end": map[string]any{"line": 2, "character": 4}}},
}},
}) +
frame(nil, "exit", nil)
msgs := run(t, in)
resp := findByID(msgs, 30)
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))
}
edits := actions[0].Edit.Changes["file:///f_amd64.s"]
if len(edits) != 1 || edits[0].Range.Start.Line != 0 || edits[0].Range.Start.Character != 0 {
t.Fatalf("edit = %+v, want an insertion at line 0 character 0", edits)
}
if got := applyEdits(doc, edits); !strings.HasPrefix(got, "#include \"textflag.h\"\nTEXT \u00b7f") {
t.Errorf("edited document = %q, want the include as the first line", got)
}
resp = findByID(msgs, 31)
if resp == nil {
t.Fatal("no codeAction response for the with-include variant")
}
actions = nil
if err := json.Unmarshal(mustResult(t, resp), &actions); err != nil {
t.Fatal(err)
}
if len(actions) != 1 || actions[0].Edit.Changes["file:///f_amd64.s"][0].Range.Start.Line != 1 {
t.Fatalf("actions = %+v, want one insertion after the last include (line 1)", actions)
}
}
// TestCodeActionFixesArgSize offers the argument area the // func signature
// implies; without a parseable signature there is nothing to offer.
func TestCodeActionFixesArgSize(t *testing.T) {
doc := "// func Add(x int64) int64\n" +
"TEXT \u00b7Add(SB), NOSPLIT, $0-8\n" +
"\tMOVQ x+0(FP), AX\n" +
"\tADDQ y+8(FP), AX\n" +
"\tMOVQ AX, ret+16(FP)\n" +
"\tRET\n"
bare := "TEXT \u00b7Bare(SB), NOSPLIT, $0-8\n\tRET\n"
in := session("file:///f_amd64.s", doc) +
frame(32, "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": "abi-argsize", "range": map[string]any{"start": map[string]any{"line": 1, "character": 0}, "end": map[string]any{"line": 1, "character": 4}}},
}},
}) +
frame(nil, "textDocument/didChange", map[string]any{
"textDocument": map[string]any{"uri": "file:///f_amd64.s", "version": 2},
"contentChanges": []map[string]any{{"text": bare}},
}) +
frame(33, "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": 0, "character": 4}},
"context": map[string]any{"diagnostics": []map[string]any{
{"code": "abi-argsize", "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, 32)
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 != "Set arg size to 16" {
t.Errorf("action title = %q, want Set arg size to 16", actions[0].Title)
}
edits := actions[0].Edit.Changes["file:///f_amd64.s"]
if len(edits) != 1 || edits[0].NewText != "-16" || edits[0].Range.Start.Line != 1 {
t.Fatalf("edit = %+v, want -16 on line 1", edits)
}
if got := applyEdits(doc, edits); !strings.Contains(got, "$0-16") {
t.Errorf("edited document = %q, want $0-16", got)
}
// No // func signature, no offer: the expected size cannot be computed.
resp = findByID(msgs, 33)
if resp == nil {
t.Fatal("no codeAction response for the bare variant")
}
actions = nil
if err := json.Unmarshal(mustResult(t, resp), &actions); err != nil {
t.Fatal(err)
}
if len(actions) != 0 {
t.Fatalf("actions = %+v, want none without a // func signature", actions)
}
}
// TestCrossFileDefinitionAndReferences opens two documents: docA calls // TestCrossFileDefinitionAndReferences opens two documents: docA calls
// ·helper(SB), docB defines it. Definition must jump to docB and references // ·helper(SB), docB defines it. Definition must jump to docB and references
// must collect the call site in docA plus the definition in docB. // must collect the call site in docA plus the definition in docB.