| id | 151 |
|---|---|
| title | LSP rename for headings and link-reference labels |
| status | ✅ |
| model | opus |
| summary | Add `textDocument/prepareRename` and `textDocument/rename` to `mdsmith lsp` so an editor or agent can rename a heading and have every workspace anchor link rewritten in one `WorkspaceEdit`. Also covers link-reference label rename within the current file. |
Let an LSP client rename a heading from one
buffer. Every workspace anchor link that points
at it ([text](file.md#anchor),
[text](#anchor)) updates in one atomic
WorkspaceEdit. The same flow handles link-ref
label renames. Each shortcut and full reference
use in the file updates with the def.
Without this, an agent that restructures Markdown breaks anchors silently. MDS027 (cross-file-reference-integrity) catches the breakage on the next lint pass, but only after the fact.
Plan 131 shipped the workspace symbol index plus
definition, references, implementation,
and call hierarchy. Rename was a non-goal there.
Heading rewrites need anchor fixups across the
workspace. The team wanted that work in its own
plan.
The index already knows every edge it needs:
- Headings keyed by
(file, anchor). - Anchor links keyed by
(file, anchor). - Link-ref defs and uses keyed by
(file, label).
Slug computation uses
mdtext.CollectTOCItems.
The same function disambiguates duplicate slugs by
appending -1, -2, …; rename has to recompute
the full slug map for the file to know whether the
new heading text collides with an existing one.
- File rename (
workspace/willRename/didRename). Separate concern; rewrites the link path, not the anchor. - Renaming a
kind:value or akinds:list entry. Kinds live in .mdsmith.yml; editing the config from an LSP rename would conflict with the user-consent rule in CLAUDE.md. - Renaming a directive name. Few directives, low value.
- Renaming front-matter keys. Schema work owns that.
- Markdown reflow after rename. The edit replaces
the old heading text with the new text only;
surrounding paragraph wrapping is left to the
user or to
mdsmith fix.
prepareProvider is true because the rename range
for a heading excludes the leading #s and the
trailing closing #s (if any). The client needs
the explicit range so the rename popup highlights
only the heading text.
Returns the rename range for the symbol under the cursor:
| Cursor on… | Range returned |
|---|---|
| Heading text | The text run between leading and trailing markers |
[label]: url definition |
The label text inside […] |
[text][label] reference use |
The label text inside […][label] |
| Anywhere else | null (rename not supported here) |
Setext headings (===== / -----) are also in
scope: the range is the heading line, the
underline stays as-is.
Computes a WorkspaceEdit per symbol kind.
- Recompute the file's full slug map under the
new heading text using
mdtext.CollectTOCItems. - Map old slug → new slug. If the new slug
collides with another heading in the file
(because the disambiguator now resolves
differently), return an LSP error
(
InvalidParamswith adatafield naming the colliding heading) rather than silently shifting numbered suffixes. Slug collisions are rare and surprising; failing loud is better than corrupting cross-file links. - Build the
WorkspaceEdit:
- One
TextEditon the heading line in the current file. - One
TextEditper workspace anchor link pointing at(file, oldSlug). The match uses the same edge table that powerstextDocument/referencesfor headings. - Same-file anchor links (
[text](#oldSlug)) are included. - Setext heading renames touch only the text line; the underline keeps its byte length even if the text length changes (CommonMark does not require the underline width to match the text).
- The rename is file-local — link-ref defs and their uses do not cross files in CommonMark.
- Build the
WorkspaceEdit:
- One edit on the
[label]: urldefinition. - One edit per
[text][label]and shortcut[label]use in the same file.
- Collisions: if the new label matches another
defined label, return
InvalidParams. MDS029 (no-unused-link-definitions) and MDS028 (no-undefined-reference-labels) would surface the breakage anyway, but the LSP error catches it before the edit applies.
mdtext.CollectTOCItems appends -1, -2, …
when two headings share a base slug. A rename can
shift those numbers:
## Setup <!-- slug "setup" -->
## Setup <!-- slug "setup-1" -->Renaming the first to "Configuration" shifts the
second's slug from setup-1 to setup. Any
anchor pointing at #setup-1 silently breaks.
The plan addresses this two ways:
- The slug-collision check in step 2 of heading rename catches the case where the rename makes the new heading's slug collide with another.
- A separate "shift detection" pass walks the
slug map after the rename and finds any heading
whose slug changed even though it wasn't
renamed. Each shifted slug becomes another
TextEditrewriting links pointing at the old slug to the new one. This keeps the workspace consistent.
The integration test covers a three-heading file with a duplicate-name pair to verify shift detection.
Rename reuses the index from plan 131. Cost:
prepareRename: O(1) — single position lookup.rename(heading): O(headings in file) for slug recompute, plus O(workspace anchor links to this file) for the edge walk. The 1 000-file benchmark ininternal/lsp/index/bench_test.goupper-bounds this at well under 100 ms.rename(link-ref): O(uses in current file) only.
renameProvider is additive. Clients that ignore
the capability see the post-plan-134 server.
- Add
prepareRenameto the server (internal/lsp/server.go) dispatching on the position-tag frominternal/lsp/index/locate.go. Return null for unsupported positions. - Implement heading rename. Use
mdtext.CollectTOCItemsfor slug recomputation. Surface slug-collision errors viaInvalidParams. Cover same-file and cross-file anchor edges. - Implement shift detection for the disambiguator case. Add a unit test with two headings sharing a base slug.
- Implement link-ref label rename. Cover full
[text][label]and shortcut[label]uses. - Advertise
renameProvider.prepareProvider = truein theinitializecapabilities. - Add an end-to-end integration test in
cmd/mdsmithdrivinginitialize→didOpen(across three files) →prepareRename→rename→ assert the resultingWorkspaceEditcovers every expected edge. - Add a "Rename" section to
docs/reference/cli/lsp.mdwith the prepareRename range table and the collision-error contract.
-
renameProvider.prepareProvider = trueappears in theinitializecapabilities. -
prepareRenameon a heading returns the heading text range (excluding leading#s). -
prepareRenameon plain prose returnsnull. -
renameon a heading rewrites the heading text in the current file and every anchor link in the workspace pointing at the old slug. -
renametriggers shift detection: when a duplicate-name disambiguator changes, affected anchors update too. -
renameon a link-ref definition rewrites every[text][label]and shortcut[label]use in the same file. - A heading rename whose new slug collides
with an existing heading in the same file
fails with an LSP
InvalidParamserror naming the colliding heading. -
docs/reference/cli/lsp.mddocumentsrenameProviderand the collision contract. - All tests pass:
go test ./.... -
go tool golangci-lint runreports no issues. -
mdsmith check .passes.
- Cross-file link-ref defs. Some Markdown flavors (not CommonMark) allow shared link-ref files. The plan keeps link-ref rename file-local; revisit if a future flavor profile introduces cross-file refs.
- Annotation behavior. A
WorkspaceEditcan includechangeAnnotationsso the client shows a confirmation dialog ("rename heading and 47 anchor links?"). The first pass returns a flat edit; add annotations later if reviewer feedback requests them.