| id | 131 |
|---|---|
| title | LSP symbol navigation for agents (Claude) |
| status | ✅ |
| model | opus |
| summary | Extend `mdsmith lsp` with the document-symbol, definition, implementation, references, workspace-symbol, and call-hierarchy methods so an LSP-aware agent (Claude's LSP tool, Neovim, Helix) can navigate Markdown by heading outline, anchor and file links, and the include/catalog graph. |
Let an LSP client navigate Markdown like code: list the file outline, jump from links to targets, enumerate references to a heading, search by name, and walk the include/catalog graph as a call hierarchy. The server reuses the existing AST and config plumbing.
Plan 121 shipped mdsmith lsp with diagnostics and
code actions. Plan 122 adds hover for rule and
directive docs. Neither covers symbol navigation.
Claude's LSP tool exposes nine methods:
| LSP method | Agent intent |
|---|---|
textDocument/documentSymbol |
List symbols in this file |
textDocument/definition |
Where is this defined? |
textDocument/implementation |
Where is the concrete behavior? |
textDocument/hover |
What is this? |
textDocument/references |
Who uses this? |
workspace/symbol |
Find a symbol by name |
textDocument/prepareCallHierarchy |
Anchor a call-graph view |
callHierarchy/incomingCalls |
Who calls this? |
callHierarchy/outgoingCalls |
What does this call? |
mdsmith already understands the edges these methods
need: headings, anchor and file links, link-ref
defs, front-matter kind:, and directive
arguments. The
MDS027 rule
walks every link target. Catalog expands globs.
- New transports. Stdio stays.
- Rename refactoring. Heading rewrites need anchor fixups across the workspace; separate plan.
- Type hierarchy. No Markdown analogue.
- Code lens, inlay hints, semantic tokens.
- Indexing outside the workspace root. The index
obeys the existing
internal/discoverywalk.
A symbol is one of four things, with a SymbolKind
chosen so picker UIs bucket each sensibly:
| Concept | SymbolKind |
Container |
|---|---|---|
| Heading (H1–H6) | String (15) |
parent heading |
| Link-reference definition | Key (20) |
file |
| Front-matter field | Property (7) |
file |
Directive (<?name … ?>) |
Event (24) |
enclosing heading or file |
Headings drive the outline; the others sit flat.
The cross-document key is (file, anchor) for
headings (slug from
mdtext.CollectTOCItems)
and (file, label) for link refs.
A new package internal/lsp/index holds the
symbol graph. It stores headings, link-reference
defs, front-matter top-level keys, directives, and
both directions of the reference edges (link
targets, include / catalog / build targets).
Build is lazy on the first symbol request. Update is incremental:
didOpen/didChange/didSavere-parses one buffer and swaps its slice.**/*.mdwatcher (added here; today's watcher covers only.mdsmith.yml) invalidates one file..mdsmith.ymlchange rebuilds the whole index because kind / ignore globs may shift scope.
The index calls
lint.ParseFile once
per file. Existing visitors cover headings and
link targets. A new visitor captures
*ast.LinkReferenceDefinition and front-matter keys.
Memory at 10 000 files is ~300K entries, well
under plan 121's 512 MB GOMEMLIMIT.
Returns a DocumentSymbol[] tree rooted at H1s.
Each heading carries name, anchor in detail,
range from heading to next sibling, and children.
Front-matter keys hang off a synthetic top-of-file
symbol. Directives become children of their
enclosing heading.
Capability: documentSymbolProvider = true.
Both share one resolveTarget(uri, position) core.
Implementation returns multi-target sets where
Definition returns one.
| Cursor on… | Definition |
Implementation adds |
|---|---|---|
[text](#anchor) |
heading in this file | — |
[text](./other.md) |
line 1 of other.md |
— |
[text](./other.md#anchor) |
heading in other.md |
— |
[text][label] |
matching [label]: url |
— |
<?include file: "x.md"?> arg |
x.md line 1 |
— |
<?build source: "x.md"?> arg |
x.md line 1 |
— |
kind: value in front matter |
kind block in .mdsmith.yml |
every file with that kind |
| Heading line | the heading | every link target matching |
A small helper internal/lsp/index/locate.go maps
a position to an AST node and a token tag (heading,
anchorLink, fileLink, refUse, refDef, directiveArg,
frontMatterKey, frontMatterValue). One unit test
per token tag.
Capabilities: definitionProvider = true,
implementationProvider = true.
| Cursor on… | References returned |
|---|---|
| Heading | every workspace link to (file, anchor) |
[label]: url definition |
every [text][label] and shortcut in the file |
| File line 1 | every link target with this path (no anchor) |
kind: value |
every file with that kind assignment |
| Directive block | every directive whose file: / source: = this |
includeDeclaration: false excludes the heading or
definition itself. Capability:
referencesProvider = true.
The query is a case-insensitive substring. It
matches heading text, link-ref labels, kind names,
and front-matter title:. The relative path goes
in containerName. Capability:
workspaceSymbolProvider = true.
A Markdown file is the unit of "function"; an
outbound reference is a "call". This fits doc
workflows: incomingCalls answers "who depends on
this runbook?", outgoingCalls answers "what does
this overview embed?".
prepareCallHierarchy accepts three cursor
positions. On line 1, the item is the file. On a
heading, the item is that heading section and
calls are scoped to its range. On a directive arg,
the item is the target file.
incomingCalls returns every edge into the item.
Sources include cross-file links, <?include?>,
<?catalog?> matches, and <?build?>. Each entry
carries the source file and the reference line.
outgoingCalls returns every edge out of the
item. Catalog matches reuse the cached glob
expansions for MDS019.
Capability: callHierarchyProvider = true.
Ranges follow the UTF-16 column convention plan
121 set; the
utf16Length
helper extends unchanged. Budgets: cold build
under 1 s on 1 000 files, incremental update under
20 ms per didChange. A new
internal/lsp/index/bench_test.go measures both
on synthetic 100 / 1 000 / 10 000-file workspaces;
the plan 121 benchmark CI step picks it up.
Diagnostics and code-action behavior are unchanged. New capabilities are additive. A client that ignores them sees the post-plan-121 server.
- Add
internal/lsp/indexwith the symbol graph types andBuild/Update/Removeentry points. Cover heading collection, link-ref defs, front-matter keys, and directive parsing in unit tests. Reusemdtext.CollectTOCItems. - Add the inbound / outbound edge tables. Sources:
anchor links, file links,
<?include?>,<?catalog?>,<?build?>. Reuselint/pi_parser.go. - Add
internal/lsp/index/locate.gomapping a document URI plus position to an AST node and token tag. One test per tag. - Wire the index into the server. Build lazily on
first symbol request; update on document
events; rebuild on
.mdsmith.ymlchange; invalidate on**/*.mdwatcher events. ExtendregisterWatchers. - Implement
textDocument/documentSymboland add the capability. Integration test against a fixture with H1/H2/H3 headings, directives, and link refs. - Implement
textDocument/definitionandtextDocument/implementation. Cover every row in the design table. - Implement
textDocument/references. Cover the five rows; verifyincludeDeclaration. - Implement
workspace/symbol. Cover heading, kind, andtitle:matches. - Implement
prepareCallHierarchy,incomingCalls,outgoingCalls. Cover file / heading / directive prepare paths and round- trip on a three-file include chain. - Add the bench file and the budget thresholds. The CI step from plan 121 covers it.
- Extend
docs/reference/cli/lsp.mdwith a "Symbol navigation" section, the symbol-kind table, and call-hierarchy semantics. - Update the VS Code guide with a short "Outline and Go to Definition" note.
- Add an end-to-end test in
cmd/mdsmithdrivinginitialize→didOpen→documentSymbol→definition→references→prepareCallHierarchy→incomingCalls→shutdown→exit.
-
documentSymbolreturns a hierarchical outline whose nesting matches heading levels. -
definitionjumps to a heading from an anchor link, to line 1 of a file from a relative link, and to the matching reference def from[text][ref]. -
implementationon akind:value returns one location per file assigned that kind. -
referenceson a heading returns every workspace anchor link to it;includeDeclaration: falseexcludes the heading itself. -
workspace/symbolsubstring queries match headings, link-ref labels, front-matter titles, and kind names. -
prepareCallHierarchyon a file returns one item;incomingCallslists files that include / link to it;outgoingCallslists files it includes / links to. - Cold-build benchmark reports under 1 s on
1 000 files; incremental-update under 20 ms
per
didChange. Invocation:go test -run=^$ -bench=. ./internal/lsp/... -
docs/reference/cli/lsp.mdlists every new capability and the symbol- kind table. - All tests pass:
go test ./.... -
go tool golangci-lint runreports no issues. -
mdsmith check .passes including the new docs and the updatedPLAN.mdcatalog.
- Catalog glob expansion in
incomingCalls. A glob like**/*.mdwould inflate result lists. The first pass collapses each catalog block to one entry. Add anexpandCatalogflag later if needed. findReferencesacross include boundaries. A heading in an included file is reachable via both files. The first pass reports both; flag if noisy.