|
| 1 | +--- |
| 2 | +id: 131 |
| 3 | +title: LSP symbol navigation for agents (Claude) |
| 4 | +status: "🔲" |
| 5 | +model: opus |
| 6 | +summary: >- |
| 7 | + Extend `mdsmith lsp` with the document-symbol, |
| 8 | + definition, implementation, references, |
| 9 | + workspace-symbol, and call-hierarchy methods so an |
| 10 | + LSP-aware agent (Claude's LSP tool, Neovim, Helix) |
| 11 | + can navigate Markdown by heading outline, anchor |
| 12 | + and file links, and the include/catalog graph. |
| 13 | +--- |
| 14 | +# LSP symbol navigation for agents (Claude) |
| 15 | + |
| 16 | +## Goal |
| 17 | + |
| 18 | +Let an LSP client navigate Markdown like code: list |
| 19 | +the file outline, jump from links to targets, |
| 20 | +enumerate references to a heading, search by name, |
| 21 | +and walk the include/catalog graph as a call |
| 22 | +hierarchy. The server reuses the existing AST and |
| 23 | +config plumbing. |
| 24 | + |
| 25 | +## Background |
| 26 | + |
| 27 | +Plan 121 shipped `mdsmith lsp` with diagnostics and |
| 28 | +code actions. Plan 122 adds hover for rule and |
| 29 | +directive docs. Neither covers symbol navigation. |
| 30 | + |
| 31 | +Claude's LSP tool exposes nine methods: |
| 32 | + |
| 33 | +| LSP method | Agent intent | |
| 34 | +|-------------------------------------|---------------------------------| |
| 35 | +| `textDocument/documentSymbol` | List symbols in this file | |
| 36 | +| `textDocument/definition` | Where is this defined? | |
| 37 | +| `textDocument/implementation` | Where is the concrete behavior? | |
| 38 | +| `textDocument/hover` | What is this? | |
| 39 | +| `textDocument/references` | Who uses this? | |
| 40 | +| `workspace/symbol` | Find a symbol by name | |
| 41 | +| `textDocument/prepareCallHierarchy` | Anchor a call-graph view | |
| 42 | +| `callHierarchy/incomingCalls` | Who calls this? | |
| 43 | +| `callHierarchy/outgoingCalls` | What does this call? | |
| 44 | + |
| 45 | +mdsmith already understands the edges these methods |
| 46 | +need: headings, anchor and file links, link-ref |
| 47 | +defs, front-matter `kind:`, and directive |
| 48 | +arguments. The |
| 49 | +[MDS027 rule](../internal/rules/MDS027-cross-file-reference-integrity/README.md) |
| 50 | +walks every link target. Catalog expands globs. |
| 51 | + |
| 52 | +## Non-Goals |
| 53 | + |
| 54 | +- New transports. Stdio stays. |
| 55 | +- Rename refactoring. Heading rewrites need anchor |
| 56 | + fixups across the workspace; separate plan. |
| 57 | +- Type hierarchy. No Markdown analogue. |
| 58 | +- Code lens, inlay hints, semantic tokens. |
| 59 | +- Indexing outside the workspace root. The index |
| 60 | + obeys the existing |
| 61 | + [`internal/discovery`](../internal/discovery) |
| 62 | + walk. |
| 63 | + |
| 64 | +## Design |
| 65 | + |
| 66 | +### Symbol model |
| 67 | + |
| 68 | +A symbol is one of four things, with a `SymbolKind` |
| 69 | +chosen so picker UIs bucket each sensibly: |
| 70 | + |
| 71 | +| Concept | `SymbolKind` | Container | |
| 72 | +|---------------------------|----------------|---------------------------| |
| 73 | +| Heading (H1–H6) | `String` (15) | parent heading | |
| 74 | +| Link-reference definition | `Key` (20) | file | |
| 75 | +| Front-matter field | `Property` (7) | file | |
| 76 | +| Directive (`<?name … ?>`) | `Event` (24) | enclosing heading or file | |
| 77 | + |
| 78 | +Headings drive the outline; the others sit flat. |
| 79 | +The cross-document key is `(file, anchor)` for |
| 80 | +headings (slug from |
| 81 | +[`mdtext.CollectTOCItems`](../internal/mdtext/mdtext.go)) |
| 82 | +and `(file, label)` for link refs. |
| 83 | + |
| 84 | +### Workspace index |
| 85 | + |
| 86 | +A new package `internal/lsp/index` holds the |
| 87 | +symbol graph. It stores headings, link-reference |
| 88 | +defs, front-matter top-level keys, directives, and |
| 89 | +both directions of the reference edges (link |
| 90 | +targets, include / catalog / build targets). |
| 91 | + |
| 92 | +Build is lazy on the first symbol request. Update |
| 93 | +is incremental: |
| 94 | + |
| 95 | +- `didOpen` / `didChange` / `didSave` re-parses one |
| 96 | + buffer and swaps its slice. |
| 97 | +- `**/*.md` watcher (added here; today's watcher |
| 98 | + covers only `.mdsmith.yml`) invalidates one file. |
| 99 | +- `.mdsmith.yml` change rebuilds the whole index |
| 100 | + because kind / ignore globs may shift scope. |
| 101 | + |
| 102 | +The index calls |
| 103 | +[`lint.ParseFile`](../internal/lint/file.go) once |
| 104 | +per file. Existing visitors cover headings and |
| 105 | +link targets. A new visitor captures |
| 106 | +`*ast.LinkReferenceDefinition` and front-matter keys. |
| 107 | +Memory at 10 000 files is ~300K entries, well |
| 108 | +under plan 121's 512 MB `GOMEMLIMIT`. |
| 109 | + |
| 110 | +### `textDocument/documentSymbol` |
| 111 | + |
| 112 | +Returns a `DocumentSymbol[]` tree rooted at H1s. |
| 113 | +Each heading carries name, anchor in `detail`, |
| 114 | +range from heading to next sibling, and children. |
| 115 | +Front-matter keys hang off a synthetic top-of-file |
| 116 | +symbol. Directives become children of their |
| 117 | +enclosing heading. |
| 118 | + |
| 119 | +Capability: `documentSymbolProvider = true`. |
| 120 | + |
| 121 | +### `textDocument/definition` and `…/implementation` |
| 122 | + |
| 123 | +Both share one `resolveTarget(uri, position)` core. |
| 124 | +`Implementation` returns multi-target sets where |
| 125 | +`Definition` returns one. |
| 126 | + |
| 127 | +| Cursor on… | `Definition` | `Implementation` adds | |
| 128 | +|--------------------------------|------------------------------|----------------------------| |
| 129 | +| `[text](#anchor)` | heading in this file | — | |
| 130 | +| `[text](./other.md)` | line 1 of `other.md` | — | |
| 131 | +| `[text](./other.md#anchor)` | heading in `other.md` | — | |
| 132 | +| `[text][label]` | matching `[label]: url` | — | |
| 133 | +| `<?include file: "x.md"?>` arg | `x.md` line 1 | — | |
| 134 | +| `<?build source: "x.md"?>` arg | `x.md` line 1 | — | |
| 135 | +| `kind:` value in front matter | kind block in `.mdsmith.yml` | every file with that kind | |
| 136 | +| Heading line | the heading | every link target matching | |
| 137 | + |
| 138 | +A small helper `internal/lsp/index/locate.go` maps |
| 139 | +a position to an AST node and a token tag (heading, |
| 140 | +anchorLink, fileLink, refUse, refDef, directiveArg, |
| 141 | +frontMatterKey, frontMatterValue). One unit test |
| 142 | +per token tag. |
| 143 | + |
| 144 | +Capabilities: `definitionProvider = true`, |
| 145 | +`implementationProvider = true`. |
| 146 | + |
| 147 | +### `textDocument/references` |
| 148 | + |
| 149 | +| Cursor on… | References returned | |
| 150 | +|---------------------------|--------------------------------------------------| |
| 151 | +| Heading | every workspace link to `(file, anchor)` | |
| 152 | +| `[label]: url` definition | every `[text][label]` and shortcut in the file | |
| 153 | +| File line 1 | every link target with this path (no anchor) | |
| 154 | +| `kind:` value | every file with that kind assignment | |
| 155 | +| Directive block | every directive whose `file:` / `source:` = this | |
| 156 | + |
| 157 | +`includeDeclaration: false` excludes the heading or |
| 158 | +definition itself. Capability: |
| 159 | +`referencesProvider = true`. |
| 160 | + |
| 161 | +### `workspace/symbol` |
| 162 | + |
| 163 | +The query is a case-insensitive substring. It |
| 164 | +matches heading text, link-ref labels, kind names, |
| 165 | +and front-matter `title:`. The relative path goes |
| 166 | +in `containerName`. Capability: |
| 167 | +`workspaceSymbolProvider = true`. |
| 168 | + |
| 169 | +### Call hierarchy |
| 170 | + |
| 171 | +A Markdown file is the unit of "function"; an |
| 172 | +outbound reference is a "call". This fits doc |
| 173 | +workflows: `incomingCalls` answers "who depends on |
| 174 | +this runbook?", `outgoingCalls` answers "what does |
| 175 | +this overview embed?". |
| 176 | + |
| 177 | +`prepareCallHierarchy` accepts three cursor |
| 178 | +positions. On line 1, the item is the file. On a |
| 179 | +heading, the item is that heading section and |
| 180 | +calls are scoped to its range. On a directive arg, |
| 181 | +the item is the target file. |
| 182 | + |
| 183 | +`incomingCalls` returns every edge into the item. |
| 184 | +Sources include cross-file links, `<?include?>`, |
| 185 | +`<?catalog?>` matches, and `<?build?>`. Each entry |
| 186 | +carries the source file and the reference line. |
| 187 | +`outgoingCalls` returns every edge out of the |
| 188 | +item. Catalog matches reuse the cached glob |
| 189 | +expansions for MDS019. |
| 190 | + |
| 191 | +Capability: `callHierarchyProvider = true`. |
| 192 | + |
| 193 | +### Position and performance |
| 194 | + |
| 195 | +Ranges follow the UTF-16 column convention plan |
| 196 | +121 set; the |
| 197 | +[`utf16Length`](../internal/lsp/diagnostics.go) |
| 198 | +helper extends unchanged. Budgets: cold build |
| 199 | +under 1 s on 1 000 files, incremental update under |
| 200 | +20 ms per `didChange`. A new |
| 201 | +`internal/lsp/index/bench_test.go` measures both |
| 202 | +on synthetic 100 / 1 000 / 10 000-file workspaces; |
| 203 | +the plan 121 benchmark CI step picks it up. |
| 204 | + |
| 205 | +### Backwards compatibility |
| 206 | + |
| 207 | +Diagnostics and code-action behavior are |
| 208 | +unchanged. New capabilities are additive. A client |
| 209 | +that ignores them sees the post-plan-121 server. |
| 210 | + |
| 211 | +## Tasks |
| 212 | + |
| 213 | +1. Add `internal/lsp/index` with the symbol graph |
| 214 | + types and `Build` / `Update` / `Remove` entry |
| 215 | + points. Cover heading collection, link-ref defs, |
| 216 | + front-matter keys, and directive parsing in unit |
| 217 | + tests. Reuse `mdtext.CollectTOCItems`. |
| 218 | +2. Add the inbound / outbound edge tables. Sources: |
| 219 | + anchor links, file links, `<?include?>`, |
| 220 | + `<?catalog?>`, `<?build?>`. Reuse |
| 221 | + [`lint/pi_parser.go`](../internal/lint/pi_parser.go). |
| 222 | +3. Add `internal/lsp/index/locate.go` mapping a |
| 223 | + document URI plus position to an AST node and |
| 224 | + token tag. One test per tag. |
| 225 | +4. Wire the index into the server. Build lazily on |
| 226 | + first symbol request; update on document |
| 227 | + events; rebuild on `.mdsmith.yml` change; |
| 228 | + invalidate on `**/*.md` watcher events. Extend |
| 229 | + [`registerWatchers`](../internal/lsp/server.go). |
| 230 | +5. Implement `textDocument/documentSymbol` and add |
| 231 | + the capability. Integration test against a |
| 232 | + fixture with H1/H2/H3 headings, directives, and |
| 233 | + link refs. |
| 234 | +6. Implement `textDocument/definition` and |
| 235 | + `textDocument/implementation`. Cover every row |
| 236 | + in the design table. |
| 237 | +7. Implement `textDocument/references`. Cover the |
| 238 | + five rows; verify `includeDeclaration`. |
| 239 | +8. Implement `workspace/symbol`. Cover heading, |
| 240 | + kind, and `title:` matches. |
| 241 | +9. Implement `prepareCallHierarchy`, |
| 242 | + `incomingCalls`, `outgoingCalls`. Cover file / |
| 243 | + heading / directive prepare paths and round- |
| 244 | + trip on a three-file include chain. |
| 245 | +10. Add the bench file and the budget thresholds. |
| 246 | + The CI step from plan 121 covers it. |
| 247 | +11. Extend |
| 248 | + [`docs/reference/cli/lsp.md`](../docs/reference/cli/lsp.md) |
| 249 | + with a "Symbol navigation" section, the |
| 250 | + symbol-kind table, and call-hierarchy |
| 251 | + semantics. |
| 252 | +12. Update |
| 253 | + [the VS Code guide](../docs/guides/editors/vscode.md) |
| 254 | + with a short "Outline and Go to Definition" |
| 255 | + note. |
| 256 | +13. Add an end-to-end test in |
| 257 | + [`cmd/mdsmith`](../cmd/mdsmith) driving |
| 258 | + `initialize` → `didOpen` → `documentSymbol` → |
| 259 | + `definition` → `references` → |
| 260 | + `prepareCallHierarchy` → `incomingCalls` → |
| 261 | + `shutdown` → `exit`. |
| 262 | + |
| 263 | +## Acceptance Criteria |
| 264 | + |
| 265 | +- [ ] `documentSymbol` returns a hierarchical |
| 266 | + outline whose nesting matches heading levels. |
| 267 | +- [ ] `definition` jumps to a heading from an |
| 268 | + anchor link, to line 1 of a file from a |
| 269 | + relative link, and to the matching reference |
| 270 | + def from `[text][ref]`. |
| 271 | +- [ ] `implementation` on a `kind:` value returns |
| 272 | + one location per file assigned that kind. |
| 273 | +- [ ] `references` on a heading returns every |
| 274 | + workspace anchor link to it; |
| 275 | + `includeDeclaration: false` excludes the |
| 276 | + heading itself. |
| 277 | +- [ ] `workspace/symbol` substring queries match |
| 278 | + headings, link-ref labels, front-matter |
| 279 | + titles, and kind names. |
| 280 | +- [ ] `prepareCallHierarchy` on a file returns one |
| 281 | + item; `incomingCalls` lists files that |
| 282 | + include / link to it; `outgoingCalls` lists |
| 283 | + files it includes / links to. |
| 284 | +- [ ] Cold-build benchmark reports under 1 s on |
| 285 | + 1 000 files; incremental-update under 20 ms |
| 286 | + per `didChange`. Invocation: |
| 287 | + `go test -run=^$ -bench=. ./internal/lsp/...` |
| 288 | +- [ ] [`docs/reference/cli/lsp.md`](../docs/reference/cli/lsp.md) |
| 289 | + lists every new capability and the symbol- |
| 290 | + kind table. |
| 291 | +- [ ] All tests pass: `go test ./...`. |
| 292 | +- [ ] `go tool golangci-lint run` reports no |
| 293 | + issues. |
| 294 | +- [ ] `mdsmith check .` passes including the new |
| 295 | + docs and the updated `PLAN.md` catalog. |
| 296 | + |
| 297 | +## Open Questions |
| 298 | + |
| 299 | +- **Catalog glob expansion in `incomingCalls`.** A |
| 300 | + glob like `**/*.md` would inflate result lists. |
| 301 | + The first pass collapses each catalog block to |
| 302 | + one entry. Add an `expandCatalog` flag later if |
| 303 | + needed. |
| 304 | +- **`findReferences` across include boundaries.** |
| 305 | + A heading in an included file is reachable via |
| 306 | + both files. The first pass reports both; flag |
| 307 | + if noisy. |
0 commit comments