Skip to content

Commit 320c497

Browse files
author
merge-queue-bot
committed
Merge PR #241: Implement plan 131: LSP symbol navigation for agents
2 parents 6ad63c4 + 73e9a0f commit 320c497

22 files changed

Lines changed: 8077 additions & 43 deletions

PLAN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ footer: |
4242
| 128 || sonnet | [Reject undefined reference-link labels](plan/128_no-undefined-reference-labels.md) |
4343
| 129 || sonnet | [Flag unused or duplicate link reference definitions](plan/129_no-unused-link-definitions.md) |
4444
| 130 | 🔳 | opus | [Distribute mdsmith binaries via npm, PyPI, asdf, mise, and the VS Code marketplaces](plan/130_binary-distribution-and-versioning.md) |
45-
| 131 | 🔲 | opus | [LSP symbol navigation for agents (Claude)](plan/131_lsp-symbol-navigation.md) |
45+
| 131 | | opus | [LSP symbol navigation for agents (Claude)](plan/131_lsp-symbol-navigation.md) |
4646
| 145 | 🔲 | opus | [Publish mdsmith via asdf and mise registry submissions](plan/145_asdf-mise-registry-submissions.md) |
4747
| 52 || | [Archetype / Template Library for Agentic Patterns](plan/52_archetype-template-library.md) |
4848
| 61 || | [Required Structure Rule Hardening](plan/61_required-structure-hardening.md) |

cmd/mdsmith/lsp_navigation_test.go

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
package main_test
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/url"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
"testing"
11+
"time"
12+
13+
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
15+
)
16+
17+
// TestLSPNavigationE2E spawns the shared mdsmith binary, drives a
18+
// full symbol-navigation round-trip
19+
// (initialize → didOpen → documentSymbol → definition → references
20+
// → prepareCallHierarchy → incomingCalls → shutdown → exit) over
21+
// stdio, and asserts the headline plan-131 acceptance criteria
22+
// against a real workspace.
23+
func TestLSPNavigationE2E(t *testing.T) {
24+
if testing.Short() {
25+
t.Skip("skipping LSP navigation subprocess test in -short mode")
26+
}
27+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
28+
defer cancel()
29+
30+
tmp, srcA, srcB := writeNavigationCorpus(t)
31+
pipe := startLSPSubprocess(t, ctx, binaryPath)
32+
rootURI := pathToFileURIE2E(t, tmp)
33+
uriA, uriB := rootURI+"/a.md", rootURI+"/b.md"
34+
35+
initE2ENavigation(t, pipe, rootURI)
36+
pipe.openDocument(uriA, srcA)
37+
_ = pipe.awaitDiagnostics(t, uriA, time.Now().Add(15*time.Second))
38+
pipe.openDocument(uriB, srcB)
39+
_ = pipe.awaitDiagnostics(t, uriB, time.Now().Add(15*time.Second))
40+
41+
assertDocumentSymbolOutline(t, pipe, uriA)
42+
assertDefinitionJumpsToHeading(t, pipe, uriA, uriB)
43+
assertReferencesFromB(t, pipe, uriA, uriB)
44+
assertCallHierarchyIncoming(t, pipe, uriA)
45+
46+
pipe.shutdown(t)
47+
}
48+
49+
// writeNavigationCorpus writes a two-file workspace where a.md is
50+
// the navigation target and b.md links into it.
51+
func writeNavigationCorpus(t *testing.T) (root, srcA, srcB string) {
52+
t.Helper()
53+
root = t.TempDir()
54+
srcA = "# Alpha\n\n## Inner\n\nbody\n"
55+
srcB = "# Beta\n\n[link](./a.md#inner)\n"
56+
require.NoError(t, os.WriteFile(filepath.Join(root, "a.md"), []byte(srcA), 0o644))
57+
require.NoError(t, os.WriteFile(filepath.Join(root, "b.md"), []byte(srcB), 0o644))
58+
return root, srcA, srcB
59+
}
60+
61+
func initE2ENavigation(t *testing.T, pipe *lspPipe, rootURI string) {
62+
t.Helper()
63+
resp := pipe.request("initialize", 1, map[string]any{
64+
"rootUri": rootURI,
65+
"capabilities": fullClientCapabilities(),
66+
})
67+
require.Equal(t, float64(1), resp["id"])
68+
res, ok := resp["result"].(map[string]any)
69+
require.True(t, ok)
70+
caps, ok := res["capabilities"].(map[string]any)
71+
require.True(t, ok)
72+
for _, want := range []string{
73+
"documentSymbolProvider",
74+
"definitionProvider",
75+
"referencesProvider",
76+
"callHierarchyProvider",
77+
} {
78+
assert.Contains(t, caps, want)
79+
}
80+
pipe.notify("initialized", map[string]any{})
81+
}
82+
83+
func assertDocumentSymbolOutline(t *testing.T, pipe *lspPipe, uriA string) {
84+
t.Helper()
85+
syms := pipe.requestPickResult(t, "textDocument/documentSymbol", 100, map[string]any{
86+
"textDocument": map[string]any{"uri": uriA},
87+
}).([]any)
88+
require.NotEmpty(t, syms)
89+
root := syms[0].(map[string]any)
90+
assert.Equal(t, "Alpha", root["name"])
91+
require.Contains(t, root, "children")
92+
}
93+
94+
func assertDefinitionJumpsToHeading(t *testing.T, pipe *lspPipe, uriA, uriB string) {
95+
t.Helper()
96+
defLoc := pipe.requestPickResult(t, "textDocument/definition", 101, map[string]any{
97+
"textDocument": map[string]any{"uri": uriB},
98+
"position": map[string]any{"line": 2, "character": 12},
99+
})
100+
defObj, ok := defLoc.(map[string]any)
101+
require.True(t, ok, "definition result: %v", defLoc)
102+
assert.Equal(t, uriA, defObj["uri"])
103+
}
104+
105+
func assertReferencesFromB(t *testing.T, pipe *lspPipe, uriA, uriB string) {
106+
t.Helper()
107+
refsRaw := pipe.requestPickResult(t, "textDocument/references", 102, map[string]any{
108+
"textDocument": map[string]any{"uri": uriA},
109+
"position": map[string]any{"line": 2, "character": 3},
110+
"context": map[string]any{"includeDeclaration": false},
111+
})
112+
refs, ok := refsRaw.([]any)
113+
require.True(t, ok, "references result: %v", refsRaw)
114+
require.Len(t, refs, 1)
115+
first := refs[0].(map[string]any)
116+
assert.Equal(t, uriB, first["uri"])
117+
}
118+
119+
func assertCallHierarchyIncoming(t *testing.T, pipe *lspPipe, uriA string) {
120+
t.Helper()
121+
preparedRaw := pipe.requestPickResult(t, "textDocument/prepareCallHierarchy", 103, map[string]any{
122+
"textDocument": map[string]any{"uri": uriA},
123+
"position": map[string]any{"line": 0, "character": 0},
124+
})
125+
prepared, ok := preparedRaw.([]any)
126+
require.True(t, ok)
127+
require.Len(t, prepared, 1)
128+
129+
incomingRaw := pipe.requestPickResult(t, "callHierarchy/incomingCalls", 104, map[string]any{
130+
"item": prepared[0],
131+
})
132+
incoming, ok := incomingRaw.([]any)
133+
require.True(t, ok)
134+
require.Len(t, incoming, 1, "expected one incoming call from b.md")
135+
}
136+
137+
// requestPickResult issues a request and returns the value at
138+
// `result`. It also handles server-initiated requests interleaved
139+
// with the response, so workspace/configuration replies don't stall
140+
// the dispatch loop on the other side.
141+
func (p *lspPipe) requestPickResult(t *testing.T, method string, id int, params any) any {
142+
t.Helper()
143+
p.writeFrame(map[string]any{
144+
"jsonrpc": "2.0", "id": id, "method": method, "params": params,
145+
})
146+
deadline := time.Now().Add(15 * time.Second)
147+
for time.Now().Before(deadline) {
148+
m := p.readFrame()
149+
if mid, ok := m["id"].(float64); ok && int(mid) == id {
150+
return m["result"]
151+
}
152+
// Server-initiated request? Auto-ack.
153+
if method, _ := m["method"].(string); method != "" {
154+
if mid, ok := m["id"]; ok && mid != nil {
155+
p.writeFrame(map[string]any{
156+
"jsonrpc": "2.0", "id": mid, "result": nil,
157+
})
158+
}
159+
}
160+
}
161+
t.Fatalf("timed out waiting for response to %s", method)
162+
return nil
163+
}
164+
165+
// pathToFileURIE2E mirrors the production server's pathToURI: it
166+
// emits RFC 8089-compliant file URIs so the helper produces the
167+
// same shape on every host OS (including Windows drive letters and
168+
// UNC paths). Without that the E2E test would send a non-standard
169+
// `file://C:/...` rootUri on Windows that the server's URI parser
170+
// would treat as a UNC host.
171+
func pathToFileURIE2E(t *testing.T, p string) string {
172+
t.Helper()
173+
abs, err := filepath.Abs(p)
174+
require.NoError(t, err)
175+
if isWindowsDrivePathE2E(abs) {
176+
u := url.URL{Scheme: "file", Path: "/" + filepath.ToSlash(abs)}
177+
return u.String()
178+
}
179+
if strings.HasPrefix(abs, `\\`) {
180+
rest := strings.TrimPrefix(filepath.ToSlash(abs), "//")
181+
host, tail, _ := strings.Cut(rest, "/")
182+
u := url.URL{Scheme: "file", Host: host, Path: "/" + tail}
183+
return u.String()
184+
}
185+
u := url.URL{Scheme: "file", Path: filepath.ToSlash(abs)}
186+
return u.String()
187+
}
188+
189+
func isWindowsDrivePathE2E(p string) bool {
190+
if len(p) < 2 || p[1] != ':' {
191+
return false
192+
}
193+
c := p[0]
194+
return (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z')
195+
}
196+
197+
// silence unused import in some build paths
198+
var _ = json.Marshal

docs/guides/editors/vscode.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,32 @@ command expects. Bind it to save by setting:
114114
Or set `mdsmith.fixOnSave` to `true`, which wires the
115115
same behavior without touching `editor.codeActionsOnSave`.
116116

117+
## Outline and Go to Definition
118+
119+
The server publishes a hierarchical outline of each
120+
open Markdown file. It also resolves cross-document
121+
jumps. VS Code surfaces these as the Outline pane,
122+
"Go to Definition" (F12), "Find All References"
123+
(Shift-F12), the `Ctrl-T`/`Cmd-T` symbol picker, and
124+
the call-hierarchy view.
125+
126+
The relevant LSP methods are:
127+
128+
- `textDocument/documentSymbol`
129+
- `textDocument/definition`
130+
- `textDocument/implementation`
131+
- `textDocument/references`
132+
- `workspace/symbol`
133+
- `textDocument/prepareCallHierarchy`
134+
135+
Headings nest by level. Front-matter keys hang off a
136+
synthetic "front matter" entry. Directives
137+
(`<?include?>`, `<?catalog?>`, `<?build?>`) attach to
138+
their enclosing heading or to the file root. See the
139+
[`mdsmith lsp` reference](../../reference/cli/lsp.md#symbol-navigation)
140+
for the symbol-kind table and the cursor → target
141+
matrix.
142+
117143
## Configuration discovery
118144

119145
The server starts at the workspace root supplied at

docs/reference/cli/lsp.md

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,18 @@ uses stdio either way.
2525

2626
## Capabilities advertised
2727

28-
| Capability | Behavior |
29-
|-----------------------------------|---------------------------------------------------------------|
30-
| `textDocumentSync = Full` | Full-document sync; lint trigger gated by `mdsmith.run` |
31-
| `publishDiagnostics` | One push after each lint |
32-
| `codeActionProvider` | `quickfix` per fixable diagnostic, `source.fixAll.mdsmith` |
33-
| `workspace/didChangeWatchedFiles` | Immediate re-lint of open buffers when `.mdsmith.yml` changes |
28+
| Capability | Behavior |
29+
|-----------------------------------|------------------------------------------------------------------------------------|
30+
| `textDocumentSync = Full` | Full-document sync; lint trigger gated by `mdsmith.run` |
31+
| `publishDiagnostics` | One push after each lint |
32+
| `codeActionProvider` | `quickfix` per fixable diagnostic, `source.fixAll.mdsmith` |
33+
| `documentSymbolProvider` | Hierarchical outline (headings, link refs, front matter, directives) |
34+
| `definitionProvider` | Jump-to-definition for anchor / file / ref-style links and directive arguments |
35+
| `implementationProvider` | Multi-target jump for `kind:` values and headings (every link target) |
36+
| `referencesProvider` | Workspace links pointing at the symbol under the cursor |
37+
| `workspaceSymbolProvider` | Substring search across headings, link refs, front-matter `title:`, and kind names |
38+
| `callHierarchyProvider` | File-level call graph over `<?include?>`, `<?catalog?>`, `<?build?>`, and links |
39+
| `workspace/didChangeWatchedFiles` | Re-lint open buffers on `.mdsmith.yml` change; index refresh on Markdown changes |
3440

3541
`mdsmith.run` controls when the server actually re-lints:
3642

@@ -74,6 +80,91 @@ prints:
7480
current buffer; produces the same bytes the on-disk fixer
7581
would write.
7682

83+
## Symbol navigation
84+
85+
The server indexes the workspace into a symbol graph. The
86+
graph is built lazily on the first symbol-navigation
87+
request and is kept in sync via:
88+
89+
- `didOpen` / `didChange` re-parse the open buffer
90+
and swap its slice of the index.
91+
- `**/*.md` watcher events refresh one file from disk
92+
when it changes outside any open buffer.
93+
- `.mdsmith.yml` changes invalidate the whole index
94+
because `ignore:`, `kind-assignment:`, and
95+
`follow-symlinks:` all shift what the index sees.
96+
Open buffers bypass `ignore:` (the user editing a
97+
file always wants it visible).
98+
99+
### Symbol kinds
100+
101+
| Concept | LSP `SymbolKind` | Container |
102+
|---------------------------|------------------|---------------------------|
103+
| Heading (H1–H6) | `String` (15) | parent heading |
104+
| Link-reference definition | `Key` (20) | file |
105+
| Front-matter field | `Property` (7) | file |
106+
| Directive (`<?name … ?>`) | `Event` (24) | enclosing heading or file |
107+
108+
Headings drive the outline; the others hang off the
109+
synthetic file-root entry. The cross-document key is
110+
`(file, anchor)` for headings (slug from
111+
`mdtext.CollectTOCItems`) and `(file, label)` for link
112+
refs.
113+
114+
### Definition and implementation
115+
116+
| Cursor on… | `Definition` | `Implementation` adds |
117+
|--------------------------------|------------------------------|----------------------------|
118+
| `[text](#anchor)` | heading in this file ||
119+
| `[text](./other.md)` | line 1 of `other.md` ||
120+
| `[text](./other.md#anchor)` | heading in `other.md` ||
121+
| `[text][label]` | matching `[label]: url` ||
122+
| `<?include file: "x.md"?>` arg | `x.md` line 1 ||
123+
| `<?build source: "x.md"?>` arg | `x.md` line 1 ||
124+
| `kind:` value in front matter | kind block in `.mdsmith.yml` | every file with that kind |
125+
| Heading line | the heading | every link target matching |
126+
127+
### References
128+
129+
| Cursor on… | References returned |
130+
|-------------------------------------|--------------------------------------------------|
131+
| Heading | every workspace link to `(file, anchor)` |
132+
| `[label]: url` definition | every `[text][label]` and shortcut in the file |
133+
| File line 1 | every link target with this path (no anchor) |
134+
| `kind:` value | every file with that kind assignment |
135+
| Directive arg (`file:` / `source:`) | every directive whose `file:` / `source:` = this |
136+
137+
`includeDeclaration: false` excludes the heading or
138+
definition itself.
139+
140+
### Workspace symbol
141+
142+
The query is a case-insensitive substring. It matches
143+
heading text, link-ref labels, front-matter `title:`,
144+
and kind names. The relative path goes in
145+
`containerName`.
146+
147+
### Call hierarchy
148+
149+
A Markdown file is the unit of "function"; an outbound
150+
reference is a "call". `incomingCalls` answers "who
151+
depends on this runbook?", `outgoingCalls` answers
152+
"what does this overview embed?".
153+
154+
`prepareCallHierarchy` accepts three cursor positions:
155+
156+
- File root → the item is the file.
157+
- Heading line → the item is that heading section.
158+
- Directive arg → the item is the target file.
159+
160+
`incomingCalls` returns every edge into the item, with
161+
sources from cross-file links, `<?include?>`,
162+
`<?catalog?>` matches, and `<?build?>`. Each entry
163+
carries the source file and the reference line.
164+
`outgoingCalls` returns every edge out of the item;
165+
catalog matches collapse to one entry per directive
166+
(expansion would inflate large globs into noise).
167+
77168
## Configuration discovery
78169

79170
The server uses workspace-wide discovery. It starts

internal/config/merge.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,31 @@ func mergeCategories(base, override map[string]bool) map[string]bool {
208208
return result
209209
}
210210

211+
// EffectiveKinds returns the same list resolveEffectiveKinds computes
212+
// internally — the front-matter kinds plus every config-driven
213+
// kind-assignment match in order, deduplicated. Exposed for callers
214+
// outside the config package (e.g. the LSP symbol index) that need
215+
// effective-kind resolution without re-implementing the merge rules.
216+
//
217+
// When cfg is nil there are no kind-assignment globs to apply, so
218+
// the result is just fmKinds with duplicates dropped — preserving
219+
// the dedup contract callers rely on.
220+
func EffectiveKinds(cfg *Config, filePath string, fmKinds []string) []string {
221+
if cfg == nil {
222+
seen := make(map[string]bool, len(fmKinds))
223+
out := make([]string, 0, len(fmKinds))
224+
for _, k := range fmKinds {
225+
if seen[k] {
226+
continue
227+
}
228+
seen[k] = true
229+
out = append(out, k)
230+
}
231+
return out
232+
}
233+
return resolveEffectiveKinds(cfg, filePath, fmKinds)
234+
}
235+
211236
// resolveEffectiveKinds builds the ordered, deduplicated effective kind list
212237
// for a file. fmKinds are the kinds declared in the file's front matter;
213238
// they come first. kind-assignment matches are appended in config order.

0 commit comments

Comments
 (0)