| id | 121 |
|---|---|
| title | Expose mdsmith to VS Code via Language Server Protocol |
| status | ✅ |
| model | opus |
| summary | Ship an `mdsmith lsp` subcommand that speaks the Language Server Protocol over stdio plus a thin VS Code extension that spawns it, so Markdown diagnostics appear inline as the user types and `mdsmith fix` is exposed as code actions. |
Surface mdsmith diagnostics and auto-fixes inside VS
Code as the user edits a Markdown file, without
requiring the user to run mdsmith check from a
terminal. The same server speaks LSP, so any
LSP-aware editor (Neovim, Helix, JetBrains via the
LSP plugin) gets the same experience for free.
docs/background/markdown-linters.md
flags VS Code support as a gap. Every peer linter
ships an extension. mdsmith does not. The
CLI JSON schema is
already labelled "stable for LSP consumption". Plan
95 designed kinds resolve --json and check --explain for the same audience.
Two surfaces are needed: a server that speaks LSP over stdio and reuses the lint pipeline, and a VS Code extension that knows how to find the binary and start it. The Node runtime never touches the lint engine.
- A standalone Visual Studio (Windows IDE) extension — follow-up plan if demand surfaces.
- New rules or new fix logic. The plan wires the existing pipeline through LSP unchanged.
- Marketplace publication — gated on release planning (see Open Questions).
A new subcommand mdsmith lsp runs an LSP server on
stdio. Implementation lives in internal/lsp/ and
hand-rolls the JSON-RPC 2.0 framing plus the subset
of LSP message types the VS Code extension needs.
The server has no third-party dependencies beyond
the Go standard library; the go.lsp.dev/* packages
were considered and rejected once the surface area
turned out to be small enough that pulling in two
modules was net-negative.
LSP capabilities the server advertises:
| Capability | Behavior |
|---|---|
textDocumentSync = Full |
Re-lint on every change; debounced |
publishDiagnostics |
One push after each lint |
codeActionProvider |
Per-diagnostic quick fixes (see below) |
workspace.configuration |
Pull mdsmith.config, mdsmith.run, trace |
workspace.didChangeWatchedFiles |
Re-lint open buffers when .mdsmith.yml changes |
The server maps mdsmith JSON diagnostics to LSP
Diagnostic:
| mdsmith field | LSP field |
|---|---|
rule + name |
code = rule (e.g. MDS001); source = mdsmith |
message |
message |
severity |
severity (error → 1, warning → 2) |
line, column |
range.start; end column derived per-rule |
explanation |
data (preserved for code-action handlers) |
The server reuses
internal/engine and
internal/lint by feeding the
in-memory document text through the same pipeline
check uses, with a Source other than the file
on disk. This avoids forking the lint logic.
Two action kinds:
- Quick fix per diagnostic —
quickfix: each diagnostic that came from a fixable rule produces aWorkspaceEditthat applies just that rule's fix to the affected range. Runs the existinginternal/fixpass scoped to one diagnostic. - Whole-file fix —
source.fixAll.mdsmith: runsmdsmith fixon the buffer and returns the diff as aWorkspaceEdit. This matches the contract VS Code's "Fix all" command expects, and lets users bindeditor.codeActionsOnSaveto it.
Rules whose fix touches multiple non-contiguous
ranges (e.g. catalog, toc) only surface as
whole-file actions; the per-diagnostic action is
omitted to avoid partial regenerations.
Lives in a new top-level directory editors/vscode/
to keep the Go module clean. TypeScript, built and
tested with Bun (bun run build.ts
to bundle, bun test for the unit tests),
packaged with vsce. The entry point uses
Microsoft's vscode-languageclient package to speak
to mdsmith lsp over stdio.
Settings the extension contributes:
| Setting | Default | Purpose |
|---|---|---|
mdsmith.path |
"mdsmith" |
Binary path; resolved against $PATH |
mdsmith.config |
"" |
Override -c config path |
mdsmith.run |
"onType" |
onType, onSave, or off |
mdsmith.fixOnSave |
false |
Wires source.fixAll.mdsmith on save |
mdsmith.trace.server |
"off" |
LSP trace verbosity |
mdsmith.path is client-only. The extension reads
it to spawn the server. The server pulls only
mdsmith.config, mdsmith.run, and
mdsmith.trace.server. The mdsmith.run default
is onType; onSave and off are opt-in.
Document selector: markdown and *.markdown files.
Activation event: onLanguage:markdown. The
extension does not bundle the binary; it surfaces a
clear error and a "Download mdsmith" link when the
binary is missing. The link points to the
GitHub releases page.
The server resolves .mdsmith.yml the same way the
CLI does (walk up from the document URI). When
mdsmith.config is set, that path wins. A
workspace/didChangeWatchedFiles subscription on
**/.mdsmith.yml triggers a re-lint of every open
buffer so config edits take effect immediately.
- Per-document lint runs on the document goroutine
and is debounced 200 ms after the last
didChange. - The server is single-process, multi-document. One client equals one server. No daemon mode.
- Memory budget: same
GOMEMLIMIT(512 MB) the CLI sets incmd/mdsmith/main.go. - Latency budget: p95 squiggle-update under
150 ms on a 1 000-line file and under 500 ms on a
5 000-line file, measured end-to-end (
didChangetopublishDiagnostics). The benchmark drives the perf task and guards the latency budget for theonTypedefault.
CI packages the extension as a .vsix on each
release. The artifact ships next to the Go
binaries. Marketplace publication waits for a
publisher token. Users install with code --install-extension mdsmith-<version>.vsix until
then.
- Add
internal/lsppackage: server skeleton, stdio transport, capability advertisement,initialize/shutdownhandlers. Unit-test the handshake against an in-memory jsonrpc2 pair. - Wire
textDocument/didOpen,didChange, anddidClose: maintain an in-memory document store keyed by URI, run the lint pipeline against the buffer text, publish diagnostics. Reuseinternal/engineby exposing aLintBuffer(uri, text) []Diagnosticentry point oninternal/lint. - Add diagnostic-to-LSP mapping with end-column
computation; round-trip test against
internal/output/json.go's shape so the two surfaces stay in sync. - Add
textDocument/codeActionreturning per-diagnostic quick fixes for rules whose fix is range-local. Add thesource.fixAll.mdsmithwhole-file action. - Add
workspace/didChangeWatchedFilesfor**/.mdsmith.yml; invalidate the cached config and re-lint open documents. - Register the
lspsubcommand incmd/mdsmith/main.goand add it tousageText. - Add an end-to-end test in
cmd/mdsmith/exercisingmdsmith lspover a pipe: sendinitialize, open a buffer with a known violation, assert the published diagnostic shape. - Create
editors/vscode/:package.json,tsconfig.json, a Bunbuild.tsbundler, theLanguageClientbootstrap, the settings contributions, and a README that documents install / config. - Add a CI job that runs
bun install --frozen-lockfile && bun test && bun run build.ts --production && bunx @vscode/vsce packageineditors/vscode/and uploads the.vsixas a release artifact. - Update the Commands table in
docs/reference/cli.md
to include
lsp. Update the integration table in the linter comparison to flip "VS Code: no" to "yes". Add a newdocs/guides/editors/vscode.mdcovering install, settings, and troubleshooting. - Add a benchmark
internal/lsp/bench_test.gothat measures end-to-enddidChange→publishDiagnosticslatency on synthetic 1k and 5k line documents. The benchmark uses*testing.B; wire the budgets above asb.Fatalfthresholds. Add a step to.github/workflows/that runs the benchmark explicitly (go test -run=^$ -bench=. ./internal/lsp/...);go test ./...does not invoke benchmarks by default. Document the invocation indocs/guides/editors/vscode.md.
-
mdsmith lspruns an LSP server on stdio and survives a fullinitialize→didOpen→didChange→shutdownround trip in an integration test. - Opening a Markdown file with a
MDS001violation in VS Code shows the squiggle inline within 500 ms of save (manual smoke test documented in the new guide). - CI runs the latency benchmark and reports
p95 latency under the 150 ms / 500 ms budgets
on the 1k / 5k-line fixtures. Invocation:
go test -run=^$ -bench=. ./internal/lsp/... - Quick-fix code actions appear for fixable rules and apply only the corresponding range; the file's other diagnostics are unaffected.
-
source.fixAll.mdsmithproduces the same output asmdsmith fixon the same buffer (integration test compares the two). - Editing
.mdsmith.ymlre-lints open documents without restarting the editor. -
mdsmith lsp --helpdocuments the subcommand;usageTextlists it. -
editors/vscode/builds withbun run build.ts --productionand packages withbunx @vscode/vsce packagein CI; the.vsixis attached to release artifacts. -
docs/reference/cli.mdCommands table includeslsp;docs/background/markdown-linters.mdno longer reports "VS Code: no". - All tests pass:
go test ./... -
go tool golangci-lint runreports no issues. -
mdsmith check .passes including the new docs and the updatedPLAN.mdcatalog.
- Markdown docs under
editors/vscode/. Resolved:editors/**was added todirectory-structure.allowedand the extension README was authored to pass the repo's existing rules as-is. No subtree-specific overrides were needed. - Marketplace publication. Publishing requires
an Azure DevOps publisher account and
VSCE_PAT. Decide as part of release planning; this plan ships the.vsixonly. - Visual Studio (Windows) parity. Visual
Studio supports LSP via the
Microsoft.VisualStudio.LanguageServerpackage but needs a separate.vsixhost. Track in a follow-up plan if user demand surfaces. - Notebook Markdown. VS Code Markdown cells in
.ipynbnotebooks reach the language server through a different document URI scheme. Out of scope for v1; revisit if requested.