feat(lsp): pull diagnostics, include links and folding ranges
Assisted-by: GLM 5.3 Flash
This commit is contained in:
@@ -80,6 +80,10 @@ Unreleased changes on the `development` branch.
|
|||||||
`// func` signature while the cursor is on a `CALL`.
|
`// func` signature while the cursor is on a `CALL`.
|
||||||
- **LSP: document highlights**: every reference to the function or label
|
- **LSP: document highlights**: every reference to the function or label
|
||||||
under the cursor is highlighted.
|
under the cursor is highlighted.
|
||||||
|
- **LSP: pull diagnostics** (`textDocument/diagnostic`), **#include
|
||||||
|
document links** (resolved against the document directory, then
|
||||||
|
`$GOROOT/pkg/include`, so `textflag.h` opens) and **folding ranges**
|
||||||
|
(one collapsible region per TEXT function body).
|
||||||
- **Lint: `unused-label` rule.** Flags labels that are defined but never
|
- **Lint: `unused-label` rule.** Flags labels that are defined but never
|
||||||
referenced by any jump (Hint severity).
|
referenced by any jump (Hint severity).
|
||||||
- **Lint: `invalid-textflag` rule.** Flags TEXT/GLOBL flags not in the
|
- **Lint: `invalid-textflag` rule.** Flags TEXT/GLOBL flags not in the
|
||||||
|
|||||||
+5
-4
@@ -191,7 +191,8 @@ associate it with `.s` files. The target architecture is inferred from
|
|||||||
the file-name suffix (`_amd64.s`, `_arm64.s`, `_riscv64.s`,
|
the file-name suffix (`_amd64.s`, `_arm64.s`, `_riscv64.s`,
|
||||||
`_loong64.s`).
|
`_loong64.s`).
|
||||||
|
|
||||||
Provides: completion, hover, document symbols, diagnostics, semantic
|
Provides: completion, hover, document symbols, push and pull
|
||||||
tokens, go-to-definition, find references, rename, document formatting,
|
diagnostics, semantic tokens, go-to-definition, find references, rename,
|
||||||
inlay hints, code actions, signature help, document highlights, and
|
document formatting, inlay hints, code actions, signature help, document
|
||||||
workspace symbol search.
|
highlights, workspace symbol search, #include document links, and
|
||||||
|
folding ranges for function bodies.
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ package lsp
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"runtime"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
@@ -13,6 +17,7 @@ import (
|
|||||||
"sourcedock.dev/petrbalvin/gasm-devkit/ast"
|
"sourcedock.dev/petrbalvin/gasm-devkit/ast"
|
||||||
"sourcedock.dev/petrbalvin/gasm-devkit/format"
|
"sourcedock.dev/petrbalvin/gasm-devkit/format"
|
||||||
"sourcedock.dev/petrbalvin/gasm-devkit/lexer"
|
"sourcedock.dev/petrbalvin/gasm-devkit/lexer"
|
||||||
|
"sourcedock.dev/petrbalvin/gasm-devkit/lint"
|
||||||
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
|
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
|
||||||
"sourcedock.dev/petrbalvin/gasm-devkit/token"
|
"sourcedock.dev/petrbalvin/gasm-devkit/token"
|
||||||
)
|
)
|
||||||
@@ -784,3 +789,94 @@ func textRange(t *ast.Text) Range {
|
|||||||
}
|
}
|
||||||
return Range{Start: start, End: end}
|
return Range{Start: start, End: end}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// diagnosticsFor computes the LSP diagnostics of one document; the push
|
||||||
|
// (publishDiagnostics) and pull (textDocument/diagnostic) paths share it.
|
||||||
|
func (s *Server) diagnosticsFor(uri string) []Diagnostic {
|
||||||
|
text := s.docs[uri]
|
||||||
|
f, _ := parser.Parse(uriPath(uri), text)
|
||||||
|
cfg := lint.Config{Arch: arch.FromFilename(uriPath(uri))}
|
||||||
|
diags := lint.File(f, cfg)
|
||||||
|
|
||||||
|
out := make([]Diagnostic, 0, len(diags))
|
||||||
|
for _, d := range diags {
|
||||||
|
out = append(out, Diagnostic{
|
||||||
|
Range: toRange(d.Pos.Line, d.Pos.Column, d.End),
|
||||||
|
Severity: lintSeverity(d.Severity),
|
||||||
|
Code: d.Code,
|
||||||
|
Source: "gasm",
|
||||||
|
Message: d.Message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// includeRe matches a #include directive's quoted path.
|
||||||
|
var includeRe = regexp.MustCompile(`^\s*#include\s+"([^"]+)"`)
|
||||||
|
|
||||||
|
// documentLinks links each #include path to the header file it resolves to:
|
||||||
|
// first relative to the document's directory, then in $GOROOT/pkg/include
|
||||||
|
// (where textflag.h and friends live). Unresolvable paths get no link.
|
||||||
|
func (s *Server) documentLinks(uri string) []DocumentLink {
|
||||||
|
text := s.docs[uri]
|
||||||
|
if text == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
docDir := filepath.Dir(uriPath(uri))
|
||||||
|
goroot := runtime.GOROOT()
|
||||||
|
|
||||||
|
var out []DocumentLink
|
||||||
|
for i, line := range strings.Split(text, "\n") {
|
||||||
|
m := includeRe.FindStringSubmatch(line)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target := resolveInclude(docDir, goroot, m[1])
|
||||||
|
if target == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start := strings.Index(line, "\"")
|
||||||
|
out = append(out, DocumentLink{
|
||||||
|
Range: Range{
|
||||||
|
Start: Position{Line: i, Character: start},
|
||||||
|
End: Position{Line: i, Character: start + len(m[1]) + 2},
|
||||||
|
},
|
||||||
|
Target: "file://" + target,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveInclude returns the existing file a #include path refers to, or "".
|
||||||
|
func resolveInclude(docDir, goroot, path string) string {
|
||||||
|
for _, dir := range []string{docDir, filepath.Join(goroot, "pkg", "include")} {
|
||||||
|
cand := filepath.Join(dir, path)
|
||||||
|
if _, err := os.Stat(cand); err == nil {
|
||||||
|
return cand
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// foldingRanges marks each TEXT function's body as a collapsible region.
|
||||||
|
func (s *Server) foldingRanges(uri string) []FoldingRange {
|
||||||
|
text := s.docs[uri]
|
||||||
|
f, _ := parser.Parse(uriPath(uri), text)
|
||||||
|
if f == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var out []FoldingRange
|
||||||
|
for _, d := range f.Decls {
|
||||||
|
t, ok := d.(*ast.Text)
|
||||||
|
if !ok || len(t.Body) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start := t.Keyword.Pos.Line - 1
|
||||||
|
end := t.Body[len(t.Body)-1].Pos().Line - 1
|
||||||
|
if end <= start {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, FoldingRange{StartLine: start, EndLine: end, Kind: "region"})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -305,6 +305,46 @@ type ServerCapabilities struct {
|
|||||||
SemanticTokensProvider map[string]any `json:"semanticTokensProvider,omitempty"`
|
SemanticTokensProvider map[string]any `json:"semanticTokensProvider,omitempty"`
|
||||||
InlayHintProvider bool `json:"inlayHintProvider,omitempty"`
|
InlayHintProvider bool `json:"inlayHintProvider,omitempty"`
|
||||||
DiagnosticProvider map[string]any `json:"diagnosticProvider,omitempty"`
|
DiagnosticProvider map[string]any `json:"diagnosticProvider,omitempty"`
|
||||||
|
DocumentLinkProvider map[string]any `json:"documentLinkProvider,omitempty"`
|
||||||
|
FoldingRangeProvider bool `json:"foldingRangeProvider,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- pull diagnostics ---------------------------------------------------------
|
||||||
|
|
||||||
|
type diagnosticParams struct {
|
||||||
|
TextDocument textDocumentIdentifier `json:"textDocument"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// documentDiagnosticReport is the pull-diagnostics result: a full report for
|
||||||
|
// one document.
|
||||||
|
type documentDiagnosticReport struct {
|
||||||
|
Kind string `json:"kind"` // always "full"
|
||||||
|
Items []Diagnostic `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- document links -----------------------------------------------------------
|
||||||
|
|
||||||
|
type documentLinkParams struct {
|
||||||
|
TextDocument textDocumentIdentifier `json:"textDocument"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DocumentLink links a #include path to the header file it resolves to.
|
||||||
|
type DocumentLink struct {
|
||||||
|
Range Range `json:"range"`
|
||||||
|
Target string `json:"target"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- folding ranges -----------------------------------------------------------
|
||||||
|
|
||||||
|
type foldingRangeParams struct {
|
||||||
|
TextDocument textDocumentIdentifier `json:"textDocument"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// FoldingRange marks a collapsible region: a TEXT function's body.
|
||||||
|
type FoldingRange struct {
|
||||||
|
StartLine int `json:"startLine"`
|
||||||
|
EndLine int `json:"endLine"`
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- code actions -------------------------------------------------------------
|
// --- code actions -------------------------------------------------------------
|
||||||
|
|||||||
+16
-18
@@ -12,9 +12,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"sourcedock.dev/petrbalvin/gasm-devkit/arch"
|
|
||||||
"sourcedock.dev/petrbalvin/gasm-devkit/lint"
|
"sourcedock.dev/petrbalvin/gasm-devkit/lint"
|
||||||
"sourcedock.dev/petrbalvin/gasm-devkit/parser"
|
|
||||||
"sourcedock.dev/petrbalvin/gasm-devkit/token"
|
"sourcedock.dev/petrbalvin/gasm-devkit/token"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -136,6 +134,9 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) {
|
|||||||
CodeActionProvider: true,
|
CodeActionProvider: true,
|
||||||
SignatureHelpProvider: map[string]any{"triggerCharacters": []string{"("}},
|
SignatureHelpProvider: map[string]any{"triggerCharacters": []string{"("}},
|
||||||
WorkspaceSymbolProvider: true,
|
WorkspaceSymbolProvider: true,
|
||||||
|
DiagnosticProvider: map[string]any{"interFileDependencies": false, "workspaceDiagnostics": false},
|
||||||
|
DocumentLinkProvider: map[string]any{},
|
||||||
|
FoldingRangeProvider: true,
|
||||||
},
|
},
|
||||||
ServerInfo: map[string]string{"name": "gasm", "version": "0.31.1"},
|
ServerInfo: map[string]string{"name": "gasm", "version": "0.31.1"},
|
||||||
})
|
})
|
||||||
@@ -229,6 +230,18 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) {
|
|||||||
json.Unmarshal(msg.Params, &p)
|
json.Unmarshal(msg.Params, &p)
|
||||||
s.respond(msg.ID, s.semanticTokens(p))
|
s.respond(msg.ID, s.semanticTokens(p))
|
||||||
|
|
||||||
|
case "textDocument/diagnostic":
|
||||||
|
var p diagnosticParams
|
||||||
|
json.Unmarshal(msg.Params, &p)
|
||||||
|
s.respond(msg.ID, documentDiagnosticReport{Kind: "full", Items: s.diagnosticsFor(p.TextDocument.URI)})
|
||||||
|
case "textDocument/documentLink":
|
||||||
|
var p documentLinkParams
|
||||||
|
json.Unmarshal(msg.Params, &p)
|
||||||
|
s.respond(msg.ID, s.documentLinks(p.TextDocument.URI))
|
||||||
|
case "textDocument/foldingRange":
|
||||||
|
var p foldingRangeParams
|
||||||
|
json.Unmarshal(msg.Params, &p)
|
||||||
|
s.respond(msg.ID, s.foldingRanges(p.TextDocument.URI))
|
||||||
case "workspace/symbol":
|
case "workspace/symbol":
|
||||||
var p workspaceSymbolParams
|
var p workspaceSymbolParams
|
||||||
json.Unmarshal(msg.Params, &p)
|
json.Unmarshal(msg.Params, &p)
|
||||||
@@ -250,22 +263,7 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) {
|
|||||||
|
|
||||||
// publish parses and lints a document and pushes the diagnostics to the client.
|
// publish parses and lints a document and pushes the diagnostics to the client.
|
||||||
func (s *Server) publish(uri string) {
|
func (s *Server) publish(uri string) {
|
||||||
text := s.docs[uri]
|
s.notify("textDocument/publishDiagnostics", publishDiagnosticsParams{URI: uri, Diagnostics: s.diagnosticsFor(uri)})
|
||||||
f, _ := parser.Parse(uri, text)
|
|
||||||
cfg := lint.Config{Arch: arch.FromFilename(uriPath(uri))}
|
|
||||||
diags := lint.File(f, cfg)
|
|
||||||
|
|
||||||
out := make([]Diagnostic, 0, len(diags))
|
|
||||||
for _, d := range diags {
|
|
||||||
out = append(out, Diagnostic{
|
|
||||||
Range: toRange(d.Pos.Line, d.Pos.Column, d.End),
|
|
||||||
Severity: lintSeverity(d.Severity),
|
|
||||||
Code: d.Code,
|
|
||||||
Source: "gasm",
|
|
||||||
Message: d.Message,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
s.notify("textDocument/publishDiagnostics", publishDiagnosticsParams{URI: uri, Diagnostics: out})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// toRange converts one-based line/column plus an optional end position into an
|
// toRange converts one-based line/column plus an optional end position into an
|
||||||
|
|||||||
@@ -493,3 +493,81 @@ func mustResult(t *testing.T, m *rpcMessage) []byte {
|
|||||||
}
|
}
|
||||||
return b
|
return b
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPullDiagnostics(t *testing.T) {
|
||||||
|
in := session("file:///f_amd64.s", badDoc) +
|
||||||
|
frame(20, "textDocument/diagnostic", map[string]any{
|
||||||
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
||||||
|
}) + frame(nil, "exit", nil)
|
||||||
|
msgs := run(t, in)
|
||||||
|
resp := findByID(msgs, 20)
|
||||||
|
if resp == nil {
|
||||||
|
t.Fatal("no diagnostic response")
|
||||||
|
}
|
||||||
|
var report documentDiagnosticReport
|
||||||
|
if err := json.Unmarshal(mustResult(t, resp), &report); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if report.Kind != "full" {
|
||||||
|
t.Errorf("report kind = %q, want full", report.Kind)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, d := range report.Items {
|
||||||
|
if d.Code == "unknown-instruction" {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("want an unknown-instruction diagnostic, got %+v", report.Items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocumentLinks(t *testing.T) {
|
||||||
|
in := session("file:///f_amd64.s", cleanDoc) +
|
||||||
|
frame(21, "textDocument/documentLink", map[string]any{
|
||||||
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
||||||
|
}) + frame(nil, "exit", nil)
|
||||||
|
msgs := run(t, in)
|
||||||
|
resp := findByID(msgs, 21)
|
||||||
|
if resp == nil {
|
||||||
|
t.Fatal("no documentLink response")
|
||||||
|
}
|
||||||
|
var links []DocumentLink
|
||||||
|
if err := json.Unmarshal(mustResult(t, resp), &links); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(links) != 1 {
|
||||||
|
t.Fatalf("want 1 document link, got %d", len(links))
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(links[0].Target, "pkg/include/textflag.h") {
|
||||||
|
t.Errorf("link target = %q, want the GOROOT pkg/include header", links[0].Target)
|
||||||
|
}
|
||||||
|
if links[0].Range.Start.Line != 0 || links[0].Range.Start.Character != 9 {
|
||||||
|
t.Errorf("link range start = %+v, want line 0 character 9", links[0].Range.Start)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFoldingRanges(t *testing.T) {
|
||||||
|
in := session("file:///f_amd64.s", cleanDoc) +
|
||||||
|
frame(22, "textDocument/foldingRange", map[string]any{
|
||||||
|
"textDocument": map[string]any{"uri": "file:///f_amd64.s"},
|
||||||
|
}) + frame(nil, "exit", nil)
|
||||||
|
msgs := run(t, in)
|
||||||
|
resp := findByID(msgs, 22)
|
||||||
|
if resp == nil {
|
||||||
|
t.Fatal("no foldingRange response")
|
||||||
|
}
|
||||||
|
var ranges []FoldingRange
|
||||||
|
if err := json.Unmarshal(mustResult(t, resp), &ranges); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(ranges) != 1 {
|
||||||
|
t.Fatalf("want 1 folding range, got %d", len(ranges))
|
||||||
|
}
|
||||||
|
if ranges[0].StartLine != 1 || ranges[0].EndLine != 5 {
|
||||||
|
t.Errorf("folding range = %d-%d, want 1-5", ranges[0].StartLine, ranges[0].EndLine)
|
||||||
|
}
|
||||||
|
if ranges[0].Kind != "region" {
|
||||||
|
t.Errorf("folding kind = %q, want region", ranges[0].Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user