Editor intelligence

Tarn Language Server (tarn-lsp).

tarn-lsp is a standalone LSP 3.17 stdio binary that ships .tarn.yaml language intelligence to every editor. It shares the tarn crate core with the CLI, so there is exactly one parser, one schema, one interpolation engine, and zero feature drift between the terminal and the editor.

Install

$ cargo install tarn-lsp

tarn-lsp is published on crates.io, so a single cargo install is enough. After it finishes, which tarn-lsp should print a path inside ~/.cargo/bin/. That binary is what every LSP client below spawns over stdio.

From a checkout of the tarn repo, use cargo install --path tarn-lsp to install the workspace copy, or cargo build -p tarn-lsp --release for pure local development with no install step at all. The binary lives at ./target/release/tarn-lsp in that case — point your LSP client directly at the absolute path.

Language features

Every feature below is a full LSP request handler that reuses the in-process tarn library, so the server never forks or re-implements parser, schema, or interpolation logic.

Diagnostics

Parse and schema errors are published through textDocument/publishDiagnostics on didOpen, didSave, and debounced didChange (300 ms) notifications. Ranges are range-accurate thanks to the NAZ-260 location metadata the parser emits, so squiggles land on the exact offending token. source is always "tarn" and code is one of yaml_syntax, tarn_parse, tarn_validation, so clients can filter and route on a stable string.

Hover

Context-aware Markdown hovers answer textDocument/hover for every interpolation token class: {{ env.x }} shows the effective value with the winning source layer, file path, active environment, and redaction flag. {{ capture.x }} shows the declaring step, section (setup / flat steps / named test / teardown), and capture source (JSONPath, header, cookie, status, URL, whole body, regex). {{ $builtin }} shows the call signature and docstring. Top-level schema keys pull their description directly from schemas/v1/testfile.json.

A fifth token class — JSONPath literal — fires when the cursor sits on a scalar inside an assert.body key, a capture.*.jsonpath value, or a poll.until.jsonpath value. The hover evaluates the expression in place against the step's last recorded response and appends the result as pretty-printed JSON, capped at 2000 characters with an explicit truncation marker.

Completion

Schema-aware completion answers textDocument/completion with . and $ as trigger characters. Inside {{ env. }} it offers every key from tarn::env::resolve_env_with_sources, sorted by resolution priority and annotated with each key's effective value. Inside {{ capture. }} it offers every capture declared by a strictly earlier step visible from the cursor. Inside {{ $ }} it offers every built-in — $uuid, $uuid_v4, $uuid_v7, $timestamp, $now_iso, $random_hex(n), $random_int(min, max), plus the EN-locale faker corpus ($email, $name, $username, $phone, $word, $words(n), $sentence, $slug, $alpha(n), $alnum(n), $choice(a, b, …), $bool, $ipv4, $ipv6) — with parameter placeholders on parameterized functions.

For blank mapping-key lines, completion descends through the schema tree — properties, items, additionalProperties, local $ref, and oneOf / anyOf / allOf — to find valid children at the cursor's YAML path. request.* offers method / url / headers / body / form / multipart; assert.body."$.id".* offers the body-assertion operator grammar; poll.* offers until / interval / max_attempts; capture.<name>.* offers the extended capture keys. Schema description fields flow through as completion documentation.

Document symbols

textDocument/documentSymbol returns a hierarchical outline: file root (Namespace) → named tests (Module) → steps (Function), with setup, teardown, and top-level flat-step siblings alongside the test groups. Each symbol's range covers the full YAML node, and selection_range covers just the name: value — the same yaml-rust2 second-pass scanner powers both diagnostics and the outline, so the breadcrumb trail always matches the squiggles.

Jump from any {{ capture.x }} or {{ env.x }} token to its declaration site, and list every use site at the click of Find References. Capture references are scoped to the enclosing test (setup captures are visible from every test). Env references walk every .tarn.yaml under the workspace root, cached in a workspace index and bounded at 5000 files as a safety net for pathological monorepos.

Rename rewrites every declaration and every use site in a single atomic WorkspaceEdit. textDocument/prepareRename returns the sub-range of the identifier under the cursor so the client can highlight exactly the text the user is about to replace. Capture rename is single-file, single-test. Env rename is workspace-wide: it updates every env source file that declares the old name (inline env: block, tarn.env.yaml, tarn.env.{name}.yaml, tarn.env.local.yaml) and every use site across every .tarn.yaml in the workspace. Collisions in the target scope are rejected with a helpful InvalidParams message; the identifier grammar is the ASCII ^[A-Za-z_][A-Za-z0-9_]*$ the YAML parser and shell expansion agree on.

Code lens

Every named test in a .tarn.yaml file gets a Run test lens on its name: line, and every step inside a named test gets a Run step lens. The commands use two stable, well-known IDs — tarn.runTest and tarn.runStep — whose arguments carry a selector the client passes straight to tarn run --select FILE::TEST::STEP_INDEX. The server does not execute these commands itself; the client dispatches them. See the examples page for selector syntax.

Formatting

textDocument/formatting reformats the whole document by routing through tarn::format::format_document — the same library function the tarn fmt CLI calls. There is exactly one implementation, so a buffer formatted via LSP is byte-identical to tarn fmt output. Range formatting is deliberately not advertised: the formatter re-renders the whole buffer, so a range-only edit cannot be produced without touching surrounding YAML. Format requests against unparseable or schema-invalid buffers are a documented no-op, never an error — the client never sees a toast while the user is still typing.

Code actions

textDocument/codeAction dispatches over a flat list of providers, each returning a fully-resolved CodeAction with the edit: WorkspaceEdit already populated — there is no codeAction/resolve round trip. Four providers ship today:

  • Extract env var (refactor.extract) — lifts a selected string literal inside a request field into a new env key and rewrites the site as "{{ env.<name> }}". Collision detection walks the full env chain and suffixes coined names with a counter.
  • Capture this field (refactor) — lifts a JSONPath literal inside an assert.body: entry into a new capture: block on the same step, deriving the capture name from the last non-wildcard path segment.
  • Scaffold assert.body from last response (refactor) — walks the top-level fields of a recorded response and emits an assert.body block pre-populated with one type: … entry per field.
  • Apply fix (quickfix) — backed by the shared tarn::fix_plan engine that also powers the tarn_fix_plan MCP tool. Both surfaces share one source of truth: CLI agents and editor agents get the same remediation graph.

JSONPath evaluator

workspace/executeCommand handles tarn.evaluateJsonpath. The command accepts two argument shapes through a serde untagged enum — an inline response or a step reference that resolves through the sidecar convention — and returns { matches: [...] } in document order. Single-match paths return a one-element array (never an unwrapped value); not-found paths return [].

// Shape 1: inline response
{
  "path": "$.data[0].id",
  "response": { "data": [{ "id": 42 }] }
}

// Shape 2: step reference — server reads the sidecar
{
  "path": "$.data[0].id",
  "step": {
    "file": "/abs/fixture.tarn.yaml",
    "test": "main",
    "step": "list items"
  }
}

Both the JSONPath hover and the tarn.evaluateJsonpath command share one library primitive — tarn::jsonpath::evaluate_path — a thin canonical wrapper over serde_json_path that the assert.body and capture engines use too. Soft failures return InvalidParams; unknown command IDs return MethodNotFound.

Recorded response sidecar

The JSONPath hover, the "scaffold assert.body from last response" code action, and the step-reference shape of tarn.evaluateJsonpath all read from a sidecar layout next to the test file:

<file>.tarn.yaml
<file>.tarn.yaml.last-run/
  <test-slug>/
    <step-slug>.response.json

<test-slug> and <step-slug> are URL-safe lowercase versions of the respective names. Setup and teardown use the sentinel slugs setup / teardown; top-level flat steps: use flat. The LSP is read-only against this layout — clients (Claude Code, VS Code, any LSP host) write responses there after runs so the hover-JSONPath and scaffold-assert affordances light up.

Editor setup

Any LSP 3.17 client that can spawn a stdio server and speak JSON-RPC works out of the box. The recipes below are the officially supported ones.

Claude Code plugin

This repo ships a ready-to-use plugin at editors/claude-code/tarn-lsp-plugin, surfaced from the repo-root marketplace alongside the tarn MCP + skill plugin. Installation is a three-command sequence inside a Claude Code session:

$ /plugin marketplace add NazarKalytiuk/tarn
$ /plugin install tarn-lsp@tarn --scope project
$ /reload-plugins

Claude Code's LSP plugin format registers servers by simple file extension (.yaml), and .tarn.yaml is a compound extension, so the plugin necessarily claims every .yaml and .yml file in any project where it is installed. Install with --scope project in Tarn-focused repositories so it does not shadow yaml-language-server for Kubernetes manifests, Compose files, or CI configs elsewhere. The plugin README covers prerequisites (Claude Code 2.0.74 or newer, tarn-lsp on $PATH) and verification steps.

opencode

opencode registers LSP servers directly from its native config — no plugin wrapper, no marketplace. This repo commits an opencode.jsonc at its root so agents running opencode inside it pick up tarn-lsp automatically. Mirror the same snippet in your own repo:

{
  "$schema": "https://opencode.ai/config.json",
  "lsp": {
    "tarn": {
      "command": ["tarn-lsp"],
      "extensions": [".yaml", ".yml"]
    }
  }
}

opencode's LSP matcher uses path.parse(file).ext, so the tarn entry claims every .yaml / .yml file in the workspace (not just .tarn.yaml) — the same limitation that applies to the Claude Code plugin. Commit it at project level in Tarn-focused repos; do not put it in your global ~/.config/opencode/config.json. The full install flow (MCP + LSP + skill) is in editors/opencode/README.md.

VS Code extension

The Tarn VS Code extension (publisher nazarkalytiuk) is on the VS Marketplace and Open VSX. It gives agents a test-explorer surface: discovery, CodeLens, live streaming via --ndjson, unified-diff failure peek, environment picker, status bar, trusted/untrusted workspace handling, and Remote Development across Dev Container, Codespaces, WSL, and Remote SSH. A window-scoped tarn.experimentalLspClient setting (off by default) spawns tarn-lsp alongside the existing direct providers — Phase V scaffolding for migrating onto the LSP with no feature drift in flight. See docs/LSP_MIGRATION.md for the migration plan.

Neovim

Register tarn-lsp through the built-in vim.lsp client (0.10+) and add a filetype rule so .tarn.yaml buffers get the tarn filetype:

vim.filetype.add({
  pattern = { [".*%.tarn%.yaml"] = "tarn" },
})

vim.lsp.start({
  name      = "tarn",
  cmd       = { "tarn-lsp" },
  filetypes = { "tarn" },
  root_dir  = vim.fs.dirname(
    vim.fs.find({ "tarn.config.yaml", ".git" }, { upward = true })[1]
  ),
})

Helix

Add a [[language]] entry to ~/.config/helix/languages.toml pointing at tarn-lsp:

[[language]]
name = "tarn"
scope = "source.tarn"
file-types = [{ glob = "*.tarn.yaml" }]
language-servers = ["tarn-lsp"]

[language-server.tarn-lsp]
command = "tarn-lsp"

Zed

Zed registers language servers through its lsp block in settings.json or through a language extension. Point the server command at tarn-lsp and the file pattern at *.tarn.yaml; Zed's LSP config convention handles the rest.

Emacs

eglot and lsp-mode both accept tarn-lsp as a plain stdio server command. Register it against a tarn-mode (or a yaml-mode hook filtered to .tarn.yaml buffers) and the standard LSP affordances light up.

Schema validation

Schema enforcement inside tarn-lsp uses exactly the same JSON Schema at schemas/v1/testfile.json that the tarn validate CLI and the redhat.vscode-yaml extension consume — one schema, three consumers, zero drift. See Writing Test Files for the DSL surface the schema describes and the inline yaml-language-server: $schema=… comment that wires it up for YAML-aware editors without a Tarn-specific extension.

Reference