A pure-Lua Neovim plugin for navigating OpenAPI/Swagger specification files — implemented as a standalone LSP server so navigation works seamlessly alongside any other LSP client setup.
Uses your existing gd / K / gr mappings without fighting over them.
Complements yaml-language-server (validation, completion) with $ref
navigation and Laravel route navigation that LSP alone doesn't provide.
Supports OpenAPI 3.0 and 3.1, YAML and JSON, single-file and multi-file specs.
No external binary dependencies. No Treesitter parsers required.
- Neovim >= 0.9
- yaml-language-server (optional, recommended for validation/completion)
- Internet access on first browser preview load (RapiDoc loads from unpkg CDN; cached by the browser after that)
{
"akyrey/openapi-navigator.nvim",
ft = { "yaml", "json" },
opts = {},
}use {
"akyrey/openapi-navigator.nvim",
config = function()
require("openapi-navigator").setup()
end,
}:OpenAPIPreview opens the current spec in a browser tab rendered by
RapiDoc. The browser auto-refreshes whenever you
save any file in the spec workspace — no manual reload needed.
:OpenAPIPreview " start preview (opens browser automatically)
:OpenAPIPreviewStop " stop the preview server
The preview server:
- Binds to
127.0.0.1on a random free port (configurable). - Serves the main spec at
/specand all other spec files as static assets, so cross-file$refvalues resolve correctly in the browser. - Pushes reload events via Server-Sent Events on
BufWritePost. - Shuts down cleanly when Neovim exits.
gd has two modes depending on where the cursor is:
On a $ref value — jumps to the referenced definition. Works via the
standard LSP textDocument/definition request — FzfLua, Telescope, and
vim.lsp.buf.definition all route through the server automatically.
Supports all $ref formats:
| Format | Example |
|---|---|
| Same-file JSON pointer | $ref: '#/components/schemas/User' |
| Cross-file, no pointer | $ref: './schemas/User.yaml' |
| Cross-file with pointer | $ref: './schemas/User.yaml#/properties/email' |
| Relative from subdirectory | $ref: '../openapi.yaml#/components/schemas/UserId' |
Path-item $ref (OpenAPI 3.1) |
$ref: './paths/users.yaml' |
On an HTTP method or path key inside paths: — jumps to the Laravel
controller method that implements the route (see Laravel integration below).
paths:
/users/{id}: # ← gd here → picker with all methods for this path
get: # ← gd here → jumps directly to UserController@showWhen typing a $ref value, the plugin offers completions via the standard LSP
completion request (Ctrl+Space in nvim-cmp, or your existing completion keymap):
- Local pointer completions —
#/components/schemas/…entries defined in the current file - File path completions — relative paths to other spec files in the workspace (e.g.
./schemas/User.yaml)
Completions are triggered automatically when you type # or / inside a $ref value.
Pressing K on a $ref value shows the target schema's content — type,
properties, required fields, description — formatted as YAML in a hover popup.
Nested $ref values inside the preview are recursively expanded up to
hover.max_depth levels (default: 2).
When the cursor is not on a $ref, the server returns nothing and Neovim
automatically falls through to yaml-language-server hover — no configuration
needed.
Find every $ref pointing to the definition under the cursor. Works in two
modes:
- Cursor on a
$ref— finds all other refs pointing to the same target. - Cursor on a definition key — finds all refs that point to that definition.
Results are returned as standard LSP locations, so your existing gr mapping
(whether it calls vim.lsp.buf.references(), opens a Telescope picker, or a
quickfix list) works unchanged.
Searches all .yaml, .yml, and .json files in the spec root directory.
The plugin activates automatically for files it detects as OpenAPI specs:
- Files containing a top-level
openapi:orswagger:key. - Files matching the configured
patterns(e.g.openapi*.yaml). - YAML/JSON files inside a directory tree that contains a root marker file
(e.g.
openapi.yaml) — covers split multi-file specs.
No plugin-specific keymaps are registered. Navigation uses your existing LSP
keymaps — whatever you have bound to gd, K, and gr will just work.
Open an OpenAPI file, then:
:LspInfo— confirmopenapi-navigatoris attached to the buffer.:LspLog— see the server's diagnostic output if something isn't working.
require("openapi-navigator").setup({
-- File patterns to treat as OpenAPI specs
patterns = {
"openapi*.yaml",
"openapi*.yml",
"openapi*.json",
"swagger*.yaml",
"swagger*.json",
"**/api-docs/**/*.yaml",
"**/api-docs/**/*.yml",
},
-- Root markers used to find the spec root directory (for multi-file specs).
-- When none of these are found, the current file's directory is used as root.
root_markers = {
"openapi.yaml",
"openapi.yml",
"openapi.json",
"swagger.yaml",
"swagger.json",
},
-- Hover preview options
hover = {
max_width = 80,
max_height = 30,
max_depth = 2, -- max nested $ref expansion levels
},
-- Browser preview options
preview = {
port = 0, -- 0 = OS-assigned free port; set a fixed port if preferred
theme = "dark", -- "dark" | "light"
open_browser = true, -- auto-open the browser on :OpenAPIPreview
},
-- Laravel route navigation (see below)
laravel = {
enabled = true,
cmd = { "php", "artisan", "route:list", "--json" },
path_prefix = "",
},
})When your OpenAPI spec lives alongside a Laravel project, gd on a path or
operation key jumps directly to the controller method that implements it.
- Walks up from the spec file looking for an
artisanfile to locate the Laravel root. - Runs
php artisan route:list --json(or your configured command) and caches the result. - Matches the spec path and HTTP method to a Laravel route — parameter names (
{id}vs{user}) are ignored; only the pattern matters. - Reads
composer.jsonto resolve the controller FQN to a file via PSR-4, then scans for the method declaration. - Returns an LSP
Locationso yourgdkeymap jumps there directly.
require("openapi-navigator").setup({
laravel = {
-- Set to false to disable entirely
enabled = true,
-- Override for Docker, Sail, or custom wrappers:
cmd = { "./xenv", "artisan", "route:list", "--json" },
-- Or for Laravel Sail:
-- cmd = { "./vendor/bin/sail", "artisan", "route:list", "--json" },
-- Prepend to spec paths before matching Laravel URIs.
-- Use "api" when your spec has "/users/{id}" but Laravel registers "api/users/{id}".
path_prefix = "api",
},
})The route list is cached per session and refreshed automatically whenever a
routes/*.php file is saved.
The plugin resolves $ref paths relative to the file that contains them, so
deeply nested directory structures work correctly:
api/
├── openapi.yaml ← root (contains openapi: 3.0.3)
├── paths/
│ └── users.yaml ← $ref: '../schemas/User.yaml'
└── schemas/
├── User.yaml ← $ref: './Address.yaml#/properties/city'
└── Address.yaml
The ref index is built lazily on first use and invalidated on file save. When no root marker file is present, the current file's directory is scanned, so single-file specs work without configuration.
The plugin handles OpenAPI 3.1-specific constructs transparently:
- Path-item
$ref—pathsentries that are$refvalues are indexed and navigable. - Webhook
$ref—webhooksentries are scanned for$refvalues. - Nullable type arrays —
type: ["string", "null"]does not confuse the schema block extractor. prefixItems,const,$schema— treated as regular keys; no special handling needed.
The plugin runs as a standalone Lua LSP server launched via
nvim --headless -l server/main.lua (Neovim's built-in LuaJIT — no extra
runtime needed on PATH).
openapi-navigator.nvim/
├── plugin/
│ └── openapi-navigator.vim # Double-load guard
├── lua/openapi-navigator/
│ ├── init.lua # OpenAPI detection + vim.lsp.start()
│ ├── config.lua # User options with defaults
│ └── preview/
│ ├── init.lua # Preview orchestrator (commands, autocmds)
│ ├── http.lua # vim.loop TCP HTTP server
│ ├── sse.lua # Server-Sent Events subscriber manager
│ └── html.lua # RapiDoc HTML page template
└── server/ # Standalone LSP server (no vim.* deps)
├── main.lua # Entry point: stdio → dispatcher loop
├── rpc.lua # JSON-RPC 2.0 framing
├── dispatcher.lua # LSP method router
├── resolver.lua # $ref parsing + JSON pointer resolution
├── index.lua # Bidirectional ref index
├── hover.lua # Hover response builder
├── references.lua # Find-references response builder
├── completion.lua # $ref value completion items
├── laravel.lua # Laravel route navigation adapter
├── document_store.lua # In-memory open file contents
├── workspace.lua # Root detection + file globbing
└── fs.lua # Pure-Lua file ops (no vim.fn)
Data flow: LSP request → dispatcher → resolver extracts $ref + walks
JSON pointer → index provides reverse lookups → Location / Hover / Location[]
returned to client. When the cursor is not on a $ref, the definition
handler falls through to the Laravel adapter which maps paths/operations to
controller methods via artisan route:list.