| command | lsp |
|---|---|
| summary | Run a Language Server Protocol server on stdio for editor integrations. |
Run an LSP server that speaks the Language Server Protocol over
stdio. The server reuses the same lint and fix pipelines as
check and fix, surfaces diagnostics, and exposes per-rule
quick fixes plus a whole-file source.fixAll.mdsmith action.
mdsmith lsp [--stdio]
The subcommand is designed to be spawned by an LSP client (VS Code, Neovim, Helix, JetBrains LSP plugin), not run interactively. It reads JSON-RPC frames on stdin and writes responses and notifications on stdout.
--stdio is accepted as a no-op for clients (notably
vscode-languageclient) that always append it when selecting
stdio transport. The server uses stdio either way.
| Capability | Behavior |
|---|---|
textDocumentSync = Full |
Full-document sync; lint trigger gated by mdsmith.run |
publishDiagnostics |
One push after each lint |
codeActionProvider |
quickfix per fixable diagnostic, source.fixAll.mdsmith |
hoverProvider |
Rule docs on hover over a diagnostic; directive docs on hover inside <?…?> |
documentSymbolProvider |
Hierarchical outline (headings, link refs, front matter, directives) |
definitionProvider |
Jump-to-definition for anchor / file / ref-style links and directive arguments |
implementationProvider |
Multi-target jump for kind: values and headings (every link target) |
referencesProvider |
Workspace links pointing at the symbol under the cursor |
workspaceSymbolProvider |
Substring search across headings, link refs, front-matter title:, and kind names |
callHierarchyProvider |
File-level call graph over <?include?>, <?catalog?>, <?build?>, and links |
completionProvider |
Heading anchors, link-ref labels, kind names, and directive file paths |
renameProvider |
Heading + link-reference label renames, with prepareProvider: true |
workspace/didChangeWatchedFiles |
Re-lint open buffers on .mdsmith.yml change; index refresh on Markdown changes |
mdsmith.run controls when the server actually re-lints:
onType(default): lint on everydidChange(debounced 200 ms) plusdidOpen,didSave, and config changes.onSave: lint ondidOpen,didSave, and config changes only.didChangeevents update the buffer but do not trigger a lint pass.off: never lint automatically. Code actions still work when invoked explicitly.
textDocument/hover resolves in two passes:
-
Diagnostic-first. If the cursor falls inside an active diagnostic range, the server returns a
MarkupContent(kindmarkdown). The body begins with the diagnostic message followed by the rule's full help text — the same textmdsmith help rule <id>prints. -
Directive fallback. If no diagnostic covers the cursor, the server checks whether the cursor is inside a
<?directive …?>block. If so, it returns the directive's guide page fromdocs/guides/directives/. The documented directives arecatalog,include,build,allow-empty-section, andrequire.
If neither pass finds a match, the server returns null (no hover).
Each hover response includes a range field set to the matched span
— the diagnostic range or the full directive block range — so clients
can anchor the popup to the right span.
mdsmith/rulePatterns returns rule maintainability metadata; hover
adds "Suggested remediation" only when for-diagnostic: true.
LSP Diagnostic fields map from the same JSON shape check
prints:
| mdsmith | LSP |
|---|---|
rule + name |
code (e.g. MDS001); source = mdsmith |
severity |
severity (error → 1, warning → 2) |
line, column |
range.start; end is the line's UTF-16 length (squiggle → end-of-line) |
message |
message |
| rule name | data.rule (echoed back on codeAction) |
quickfix— one per fixable diagnostic. Each edit replaces the whole document with the output of running the single rule, so it covers every occurrence of that rule. The action title is the rule's own quick-fix label (e.g. "Remove trailing whitespace"); a rule that supplies none falls back to "Fix all<rule>with mdsmith". Within one request all quick-fix actions for the same rule share oneWorkspaceEdit; the fix is run once regardless of how many diagnostics carry that rule. Quick fixes apply immediately (see Fix preview below). Generated-section rules (catalog, toc, include) regenerate the section in their fix; the action surfaces normally.source.fixAll.mdsmith— runsmdsmith fixon the current buffer; produces the same bytes the on-disk fixer would write.
Set mdsmith.previewFix: true to preview the
source.fixAll.mdsmith edit before it writes. That action
is what fix-on-save runs. The preview is scoped to it:
quick fixes apply right away. Both capabilities below must
appear in initialize:
workspace.workspaceEdit.documentChangesworkspace.workspaceEdit.changeAnnotationSupport
When both are present, the source.fixAll.mdsmith edit
uses AnnotatedTextEdit (LSP 3.16) with
needsConfirmation: true. The edits slice carries one
entry per line-aligned diff hunk, not one whole-file
replacement (which Refactor Preview would render as "old
file → new file" with no visible delta). All hunks share
the mdsmith-fix-all annotationId. Drop either
capability and the server emits the legacy changes map.
A warning goes to window/logMessage once per session.
The server indexes the workspace into a symbol graph. The graph is built lazily on the first symbol-navigation request and is kept in sync via:
didOpen/didChangere-parse the open buffer and swap its slice of the index.**/*.mdwatcher events refresh one file from disk when it changes outside any open buffer..mdsmith.ymlchanges invalidate the whole index becauseignore:,kind-assignment:, andfollow-symlinks:all shift what the index sees. Open buffers bypassignore:(the user editing a file always wants it visible).
| Concept | LSP 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 hang off the
synthetic file-root entry. The cross-document key is
(file, anchor) for headings (slug from
mdtext.CollectTOCItems) and (file, label) for link
refs.
| 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?> inputs: list item |
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 |
| 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 arg (file: / source:) |
every directive whose file: / source: = this |
includeDeclaration: false excludes the heading or
definition itself.
The query is a case-insensitive substring. It matches
heading text, link-ref labels, front-matter title:,
and kind names. The relative path goes in
containerName.
A Markdown file is the unit of "function"; an outbound
reference is a "call". incomingCalls answers "who
depends on this runbook?", outgoingCalls answers
"what does this overview embed?".
prepareCallHierarchy accepts three cursor positions:
- File root → the item is the file.
- Heading line → the item is that heading section.
- Directive arg → the item is the target file.
incomingCalls returns every edge into the item, with
sources from 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 collapse to one entry per directive
(expansion would inflate large globs into noise).
The server handles textDocument/completion and advertises:
Completion items are fully computed in one pass from the workspace symbol
index (resolveProvider: false). Items are returned sorted with same-file
matches first for anchor completion.
| Cursor on… | Items returned | kind |
|---|---|---|
[text](#prefix |
Heading anchors in current file | Reference |
[text](./other.md#prefix |
Heading anchors in other.md |
Reference |
[text][prefix |
Link-ref labels in current file | Reference |
Front-matter kind: prefix |
Kind names from .mdsmith.yml |
EnumMember |
Front-matter kinds: list item |
Kind names from .mdsmith.yml |
EnumMember |
<?include file: "prefix"?> arg |
Workspace Markdown paths | File |
<?build?> inputs: list item |
Workspace Markdown paths | File |
<?catalog glob: "prefix"?> entry |
Workspace Markdown paths | File |
| Any other position | Empty list (no error) | — |
The detail field carries the source file path for headings and
link-ref labels, and .mdsmith.yml for kind names.
Duplicate-slug anchors (foo, foo-1, foo-2, …) are each returned
as separate items.
Directive-arg paths are relative to the open buffer's directory.
This matches how ResolveRelTarget resolves them at lint time.
Both .md and .markdown files appear as candidates.
Image links ( do not trigger anchor completion.
Completion inside fenced or indented code blocks returns an empty list.
prepareRename returns the text range for ATX heading text
(without #s), setext heading text lines, [label]: url
defs, the trailing […] of a full reference, and the
leading […] of a shortcut or collapsed reference. The
placeholder pre-fills the popup; other positions return
null.
Heading rename rewrites the heading and every workspace
anchor link to its slug. Duplicate-name disambiguator shifts
emit follow-up edits. Link-ref rename rewrites the
[label]: url def plus every same-file use. InvalidParams
fires on a new duplicate base slug, a colliding def, an
empty slug, or a [ / ] / newline in a label. The
error's data.conflict names the colliding symbol.
Discovery is workspace-wide. Starting at the workspace root
from initialize, the server walks up until it finds a
.mdsmith.yml or hits .git. Every open buffer shares the
resolved config. Set mdsmith.config to override the walk.
Edits to .mdsmith.yml re-lint all open documents immediately.
Run go test -run=^$ -bench=. ./internal/lsp/... to reproduce
the p95 latency benchmarks (150 ms on 1 000-line, 500 ms on
5 000-line buffers).
| Code | Meaning |
|---|---|
| 0 | Server exited cleanly |
| 2 | Runtime or transport error |
mdsmith check— the CLI surface that the server reusesmdsmith fix— the fix pipeline behind both code actions- VS Code guide — install, settings, troubleshooting