carve-lsp

August 18, 2026 · View on GitHub

Language server (LSP) for Carve markup documents. Provides editor intelligence for .crv files via the Language Server Protocol.

Install

npm install -g @markup-carve/carve-lsp

Or run without installing:

npx @markup-carve/carve-lsp --stdio

The server communicates over stdio (--stdio flag).

Supported capabilities

CapabilityDetails
DiagnosticsPush and LSP 3.17 pull diagnostics for syntax, migration hazards, silent failures, include failures, and invalid table metadata
SymbolsNested document outline plus queryable workspace symbols, including included files
HoverMarkup, cross-reference, include, citation, and table-marker explanations
CompletionAdmonitions, attributes, semantic spans, citations, references, workspace anchors, and contained include paths/fragments
NavigationDocument links, go-to-definition, highlights, and references for anchors, captions, footnotes, citations, and link labels
RenameWorkspace-wide, namespace-aware rename; generated heading ids become explicit when renamed
Code actionsMigration and lint quick-fixes, table marker repair, and creation of missing definitions
Code lensReference counts for anchors, captions, citations, footnotes, and link labels
Selection and foldingSyntax-aware selection expansion and folding, with tolerant fallbacks during incomplete edits
FormattingConservative document/range formatting, on-type continuation, or explicit migration formatting
Semantic tokensFull, ranged, and delta token updates, including table structure and alignment metadata
Inlay hintsGenerated heading ids (configurable)
Commandscarve.previewHtml and carve.showAst return an open document's rendered HTML or AST JSON
File inclusionResolves {{ path }} directives, watches dependencies, and reports failures as diagnostics - off by default, see below

Workspace navigation is backed by a versioned .crv index. The initial scan ignores .git and node_modules and is bounded at 10,000 files / 64 MiB; open buffers replace their disk snapshot and closing a buffer restores it.

Language settings

Clients can send these under carve in initialization options or workspace/didChangeConfiguration. If the client does not supply a carve section, the server reads the first .carverc.json in the workspace roots and watches it for changes.

SettingDefaultMeaning
platforms[]Extra platform lint profiles. Currently supports "github".
extensions[]Enabled extension names, for example "semantic-span".
inlayHintstrueShow generated heading identifiers.
formatter"conservative"Use whitespace-only conservative formatting; "migration" opts into canonical whole-document conversion.
severities{}Override a diagnostic code with "error", "warning", "information", "hint", or "off".

Example .carverc.json:

{
  "carve": {
    "platforms": ["github"],
    "extensions": ["semantic-span"],
    "inlayHints": true,
    "formatter": "conservative",
    "severities": { "table-width-total": "error" }
  }
}

File inclusion

Carve's {{ path }} directive is a processor-level feature (PART 9 §19 of the grammar), not part of the parser. This server resolves it so that the thin editor clients - helix-carve, emacs-carve, vim-carve, zed-carve, sublime-carve - get inclusion support without each implementing it.

Resolution reads files from disk, so it is opt-in and off by default. The server enables it only when it is asked to, and only inside a containment root.

Include settings

Pass these under carve.includes, either in initializationOptions at initialize time or through workspace/didChangeConfiguration. Changing them re-publishes diagnostics for every open document.

SettingDefaultMeaning
enabled"auto""auto" enables inclusion only for a workspace the client reports as trusted; "on" always; "off" never. A client that reports no trust gets inclusion off.
includeRootworkspace rootContainment root override. With no workspace, the document's own directory is used - never the server's working directory.
allowAbsolutefalseAllow absolute include paths. They still have to canonicalize inside the root.
allowedRemoteHosts[]Hosts a remote include may name. This server has no fetcher, so a remote target is refused either way; the list exists so the gate is explicit.
maxDepth16Maximum transitive include depth.
maxBytesmax(1 MiB, 8x document)Total byte budget across the whole include graph, charged per occurrence.

Client trust is read from initializationOptions.workspaceTrusted.

{
  "carve": {
    "includes": {
      "enabled": "auto",
      "maxDepth": 8
    }
  },
  "workspaceTrusted": true
}

What is enforced

Every target has to canonicalize (symlinks resolved) to a file inside the containment root. A symlink pointing out of the root, a .. path that leaves it, and an absolute path outside it are all refused. A .. path whose real target stays inside the root is fine - a chapter reaching a shared glossary is ordinary layout. Remote URLs are never fetched. Recursion depth and total expanded bytes are both bounded, so a file that includes another many times cannot amplify without limit.

A refused target produces an include-unresolved diagnostic on the directive, and the directive stays literal. The diagnostic deliberately does not say WHICH check refused it: a distinguishable denial is a way to probe the layout of the machine the server runs on.

The server watches every legal include target, including a missing target that may be created later. A child change invalidates its cached source and parsed tree, then revalidates every open document that includes it. Go to definition on a directive opens the resolved child, and child headings appear in the including document's symbol result with locations in the child file. All three features use the same contained resolver as diagnostics; refused paths are never registered as watchers or navigation targets.

Editor setup

VS Code

Install the vscode-carve extension, which bundles and auto-starts this server.

For a generic LSP client (e.g. vscode-languageclient), add to .vscode/settings.json:

{
  "languageServerExample.serverCommand": "carve-lsp",
  "languageServerExample.serverArgs": ["--stdio"]
}

Or wire it up in a custom extension:

const serverOptions: ServerOptions = {
  command: 'carve-lsp',
  args: ['--stdio'],
};
const clientOptions: LanguageClientOptions = {
  documentSelector: [{ scheme: 'file', language: 'carve' }],
};
new LanguageClient('carve-lsp', 'Carve Language Server', serverOptions, clientOptions).start();

Neovim (nvim-lspconfig)

local lspconfig = require('lspconfig')
local configs = require('lspconfig.configs')

if not configs.carve_lsp then
  configs.carve_lsp = {
    default_config = {
      cmd = { 'carve-lsp', '--stdio' },
      filetypes = { 'carve', 'crv' },
      root_dir = lspconfig.util.root_pattern('.git', '.'),
      single_file_support = true,
    },
  }
end

lspconfig.carve_lsp.setup({})

Add a filetype detection entry if your Neovim does not already recognize .crv:

vim.filetype.add({
  extension = {
    crv = 'carve',
  },
})

Other editors

Any editor with LSP support can start the server as an external process:

  • Command: carve-lsp --stdio
  • File extension: .crv (language ID: carve)
  • Root pattern: .git or the project root

Development

npm install
npm run build
npm test

Run the server directly over stdio:

node dist/server.js --stdio