diff --git a/CHANGELOG.md b/CHANGELOG.md index 21dd790..9924c4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,10 @@ Unreleased changes on the `development` branch. `// func` signature while the cursor is on a `CALL`. - **LSP: document highlights**: every reference to the function or label 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 referenced by any jump (Hint severity). - **Lint: `invalid-textflag` rule.** Flags TEXT/GLOBL flags not in the diff --git a/docs/CLI.md b/docs/CLI.md index a846b6a..1e8aa7f 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -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`, `_loong64.s`). -Provides: completion, hover, document symbols, diagnostics, semantic -tokens, go-to-definition, find references, rename, document formatting, -inlay hints, code actions, signature help, document highlights, and -workspace symbol search. +Provides: completion, hover, document symbols, push and pull +diagnostics, semantic tokens, go-to-definition, find references, rename, +document formatting, inlay hints, code actions, signature help, document +highlights, workspace symbol search, #include document links, and +folding ranges for function bodies. diff --git a/lsp/handlers.go b/lsp/handlers.go index a21a3b5..bb7ed52 100644 --- a/lsp/handlers.go +++ b/lsp/handlers.go @@ -5,6 +5,10 @@ package lsp import ( "fmt" + "os" + "path/filepath" + "regexp" + "runtime" "sort" "strings" "unicode" @@ -13,6 +17,7 @@ import ( "sourcedock.dev/petrbalvin/gasm-devkit/ast" "sourcedock.dev/petrbalvin/gasm-devkit/format" "sourcedock.dev/petrbalvin/gasm-devkit/lexer" + "sourcedock.dev/petrbalvin/gasm-devkit/lint" "sourcedock.dev/petrbalvin/gasm-devkit/parser" "sourcedock.dev/petrbalvin/gasm-devkit/token" ) @@ -784,3 +789,94 @@ func textRange(t *ast.Text) Range { } 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 +} diff --git a/lsp/protocol.go b/lsp/protocol.go index 722b8cc..5fe51f5 100644 --- a/lsp/protocol.go +++ b/lsp/protocol.go @@ -305,6 +305,46 @@ type ServerCapabilities struct { SemanticTokensProvider map[string]any `json:"semanticTokensProvider,omitempty"` InlayHintProvider bool `json:"inlayHintProvider,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 ------------------------------------------------------------- diff --git a/lsp/server.go b/lsp/server.go index 31e5e86..612e1de 100644 --- a/lsp/server.go +++ b/lsp/server.go @@ -12,9 +12,7 @@ import ( "strings" "sync" - "sourcedock.dev/petrbalvin/gasm-devkit/arch" "sourcedock.dev/petrbalvin/gasm-devkit/lint" - "sourcedock.dev/petrbalvin/gasm-devkit/parser" "sourcedock.dev/petrbalvin/gasm-devkit/token" ) @@ -136,6 +134,9 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) { CodeActionProvider: true, SignatureHelpProvider: map[string]any{"triggerCharacters": []string{"("}}, 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"}, }) @@ -229,6 +230,18 @@ func (s *Server) dispatch(msg *rpcMessage) (exit bool) { json.Unmarshal(msg.Params, &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": var p workspaceSymbolParams 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. func (s *Server) publish(uri string) { - text := s.docs[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}) + s.notify("textDocument/publishDiagnostics", publishDiagnosticsParams{URI: uri, Diagnostics: s.diagnosticsFor(uri)}) } // toRange converts one-based line/column plus an optional end position into an diff --git a/lsp/server_test.go b/lsp/server_test.go index 07d70c4..76a5d6d 100644 --- a/lsp/server_test.go +++ b/lsp/server_test.go @@ -493,3 +493,81 @@ func mustResult(t *testing.T, m *rpcMessage) []byte { } 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) + } +}