feat(lsp): document directives on hover and widen completion
Assisted-by: GLM 5.3
This commit is contained in:
+100
-6
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user