A Zed extension for OpenAPI specs.
extension.toml lives at the repo root, so Zed compiles the root crate to WASM and uses it to
start openapi-lsp over LSP.
None of the language logic lives in the WASM module (heavy parsing has no business running under WASI). It is split into two ordinary crates instead:
| Path | Role |
|---|---|
extension.toml + src/lib.rs |
Zed extension: locate and launch openapi-lsp |
openapi-core/ |
Document model: OAS detection, JSON Pointer, $ref resolution, neighbouring-file discovery, settings, offset/position math, JSON Schema lookup (no LSP) |
openapi-core/schemas/ |
Bundled OpenAPI 2.0 / 3.0 / 3.1 JSON Schemas (provenance in SOURCES.md) |
openapi-lsp/ |
Language server: server.rs implements LanguageServer, and completion/hover/definition/diagnostics each own one capability |
openapi-lsp/src
├── main.rs tokio + LspService/Server, served over stdio
├── server.rs capabilities, document sync, request dispatch (impl LanguageServer)
├── completion.rs $ref target completion (local, cross-file, file paths) + schema-driven key/enum completion
├── hover.rs $ref hover preview, documentHighlight
├── definition.rs $ref go-to-definition, documentLink
└── diagnostics.rs parse errors, unresolvable $ref/Pointer, JSON Schema validation
- Go to Definition and document links on
$ref, including cross-file refs - Hover preview of the node a
$refpoints at - Completion for
$reftargets — components in this file, files around it, and the pointers inside those files — plus schema-driven keys and enum values ($refincluded) - Diagnostics for parse errors, unresolvable
$ref/JSON Pointer, and JSON Schema violations - YAML and JSON specs, OpenAPI 2.0 / 3.0 / 3.1
-
Install the extension in Zed:
- Command Palette →
zed: install dev extension - Select the repo root (the directory containing
extension.toml)
- Command Palette →
-
Open an OpenAPI YAML/JSON file and try Go to Definition on a
$ref.
That is the whole setup — the language server installs itself. On first start the extension
downloads the release archive matching your platform from
Releases, unpacks it into its own version-keyed
directory and marks the binary executable; Zed reports the progress as Downloading. Later
starts reuse that binary, a new extension version downloads afresh rather than overwriting, and
older versions are pruned. Nothing to install by hand, no PATH entry to set up.
Two things can interrupt that. If openapi-lsp is already on your PATH, that binary wins and
no download happens — which is how you work on the server itself (see Development). And only the
five targets in Releasing the language server are published; on
anything else the extension tells you to build the server yourself.
Zed builds the extension with cargo build --target wasm32-wasip2, so the workspace's
default-members contains only the root crate — openapi-core and openapi-lsp are never pulled
into the WASM build. If your Rust toolchain does not come from rustup, install that target
yourself.
# Only the two ordinary crates are testable (the root crate is the wasm extension)
cargo test -p openapi-core -p openapi-lsp
# The extension itself holds almost no logic; Zed compiles this WASM on dev-extension install
cargo build --target wasm32-wasip2To run Zed against your own server instead of the downloaded one, put it on PATH — a PATH
binary always takes precedence over the download:
cargo install --path openapi-lsp
# verify
which openapi-lsp.github/workflows/lsp-release.yml builds the openapi-lsp release artifacts:
git tag lsp-v0.2.0 && git push origin lsp-v0.2.0On an lsp-v* tag (or a manual workflow_dispatch against an existing tag) CI first runs clippy
(-D warnings) and the tests, then builds five targets and packages each one as
openapi-lsp-<target>.tar.gz (.zip on Windows) with a sibling .sha256, and finally creates or
updates the GitHub release for that tag. The version lives in the release, not the file name, so
the archives are simply openapi-lsp-aarch64-apple-darwin.tar.gz and friends, each holding the
binary at its root with no wrapper directory:
| Target | Runner |
|---|---|
x86_64-unknown-linux-gnu |
ubuntu-latest |
aarch64-unknown-linux-gnu |
ubuntu-24.04-arm |
x86_64-apple-darwin |
macos-latest |
aarch64-apple-darwin |
macos-latest |
x86_64-pc-windows-msvc |
windows-latest |
src/lib.rs downloads from these same assets: the platform alone determines the name, so the
extension needs no version table, and adding a target here is all it takes for that platform's
automatic install to start working.
Completions come from two independent sources:
-
$refvalues come from the document itself (which components exist) and from the files around it. Completion only fires when the container under the cursor matches the table inopenapi-core/src/keys.rs— no match means no suggestions, rather than guessing/components/schemas. Accepting a completion replaces the whole$ref: "..."entry and keeps whichever quote style you already typed. What you get depends on what you have typed:Typed Offered nothing yet both lists below, this file's own components first #…components in this file: #/components/schemas/Pet./pets.yaml#…components in that file — its component map, or its top level when it is a standalone schema file with no /components/schemasanything else components in the files around this one, as the whole reference: ./schemas/pets.yaml#/components/schemas/Pet. Bare file names are never offered, and a neighbour that has no component map contributes nothing — naming it explicitly is what falls back to its top levelThe list reads name-first —
Pet, then a dimpet.yaml, with the whole../../shared/schemas/pet.yaml#/components/schemas/Petas the item's description. Leading with the reference would bury the name behind a row of../..at the popup's width. Filtering still runs against the full text, so either the name or the path narrows the list.Keys are filtered on the server when the word under the cursor starts with
$, so$roffers$refalone. Clients fuzzy-match on word characters and would otherwise still rankreadOnlyandrequiredinto that list. -
Keys and enum values come from the bundled JSON Schemas. That is why schema keywords no longer pop up under user-named maps such as
properties,paths, orcomponents/schemas, while positions liketype:andin:do offer enum values.$refitself is offered wherever a Reference Object fits, which takes two workarounds: the 3.0 schema declares it throughpatternProperties: {"^\\$ref$": …}, and the 3.1 schema hides it behindif/then(openapi-core/src/jsonschema.rshandles both).
Which files $ref completion offers is configurable. In Zed, put them under
lsp.openapi-lsp.initialization_options in settings.json (a workspace/didChangeConfiguration
payload with the same shape works too):
{
"lsp": {
"openapi-lsp": {
"initialization_options": {
"openapi": {
"refFiles": {
"scanUp": 6,
"scanDown": 6,
"maxFiles": 500,
"extensions": [],
"skipDirs": ["node_modules", "target", "dist", "build", "vendor"]
}
}
}
}
}
}| Key | Default | Meaning |
|---|---|---|
scanUp |
6 |
Directory levels above the document to include |
scanDown |
6 |
Directory levels below the document's own directory to descend into |
maxFiles |
500 |
Upper bound on offered files |
extensions |
[] |
Extensions to scan; empty means "the same suffix as the file being edited", so a YAML spec never suggests its own JSON build output |
skipDirs |
see above | Directory names never scanned, whatever their depth |
The directories Zed has open are a hard ceiling: whatever scanUp says, the scan never leaves the
worktree. With no workspace folder to go on (a single file opened on its own) it stops at the
enclosing .git/.hg checkout instead. Dotted directories and symlinks are never followed, and a
scan is reused for two seconds so a burst of keystrokes costs one walk. Each neighbour is parsed
once and remembered until its size or modification time changes; open files are read from the
editor's buffer instead, so unsaved components show up.
Diagnostics have the same source: unknown keys, missing required keys, enum/type mismatches. Nodes
whose value is null (i.e. you are still typing) are skipped so the file does not light up mid-keystroke.
All three schemas (2.0 / 3.0 / 3.1) are bundled under openapi-core/schemas/. The 3.1 Schema
Object delegates to the JSON Schema 2020-12 dialect via $dynamicRef: "#meta", so that dialect and
its vocabularies are bundled too. if/then/else steers completion but is not enforced by
validation, and unevaluatedProperties is ignored entirely, which makes the "unknown key" check
slightly more permissive on 3.1 than on 3.0.
To debug, check zed: open log, or run zed --foreground to watch the server's
window/logMessage output.