Language Server (LSP)
March ships march-lsp, a Language Server Protocol server that gives any
LSP-capable editor rich, always-accurate support. It is built directly on the
compiler’s parse → desugar → typecheck pipeline — types, diagnostics, and
completions come from the real type checker (full Hindley–Milner inference), not
a re-implementation, so what the editor shows is exactly what the compiler sees.
There is also a Debug Adapter Protocol server (march dap) for breakpoint
debugging, and a standalone query CLI for scripts and LLMs. Both are covered
below.
Install
march-lsp speaks LSP over stdio. Build it and put it on your PATH:
dune build lsp/bin/main.exe # produces _build/default/lsp/bin/main.exe
dune install march-lsp # installs it as `march-lsp` on PATH
Then point your editor at the march-lsp command. The server reports positions
in UTF-16 (the LSP default; it advertises positionEncoding: utf-16) and
uses forge.toml as the project root marker, which is how it discovers the
other files in your project for cross-file features.
Editor setup
Neovim (built-in LSP, 0.11+)
vim.lsp.config.march = {
cmd = { "march-lsp" },
filetypes = { "march" },
root_markers = { "forge.toml" },
}
vim.lsp.enable("march")
vim.filetype.add({ extension = { march = "march" } })
On Neovim 0.10, start it per-buffer:
vim.api.nvim_create_autocmd("FileType", {
pattern = "march",
callback = function()
vim.lsp.start({ name = "march", cmd = { "march-lsp" },
root_dir = vim.fs.root(0, { "forge.toml" }) })
end,
})
Helix (~/.config/helix/languages.toml)
[[language]]
name = "march"
scope = "source.march"
file-types = ["march"]
roots = ["forge.toml"]
language-servers = ["march-lsp"]
[language-server.march-lsp]
command = "march-lsp"
Zed (~/.config/zed/settings.json)
{ "lsp": { "march-lsp": { "binary": { "path": "march-lsp" } } } }
The Zed extension in zed-march/ also wires up the tree-sitter grammar
(highlighting, brackets) and the DAP debugger.
Emacs (eglot)
(add-to-list 'eglot-server-programs '(march-mode . ("march-lsp")))
VS Code
Use a generic LSP client (a small vscode-languageclient wrapper, or an existing
“generic LSP” extension) with:
serverOptions:{ command: "march-lsp", transport: stdio }documentSelector:[{ language: "march" }]
A matching DAP debug extension lives in editors/vscode-march-debug/.
Features
Diagnostics & navigation
| Feature | What it does |
|---|---|
| Diagnostics | Parse + type errors, warnings, hints with precise source spans — the same checks as forge build |
| Hover | Inferred type at the cursor (with linear/affine qualifiers), the docstring if present, and actor state/messages on an actor name |
| Go to definition | Jump to a function, type, constructor, or module — across files |
| Go to implementation / type definition | Jump from an interface to its impls, or from a value to its type |
| Find references | Every use of a name, across the project |
| Document & workspace symbols | File outline, and fuzzy project-wide symbol search (Cmd/Ctrl+T) |
| Call hierarchy | Incoming/outgoing calls for a function |
| Rename | Rename a symbol across all its use sites |
Editing intelligence
| Feature | What it does |
|---|---|
| Completions | Context-aware: module members after ., constructors, interfaces, in-scope names, keywords; with snippet placeholders for function/constructor arguments |
| Auto-import | Completing an un-imported stdlib/dep member inserts the needed use |
| Signature help | Parameter hints at call sites |
| Inlay hints | Inferred types inline, plus FBIP annotations (♻ reused / ⧉ copied) |
| Semantic tokens | Type-accurate highlighting, including ownership (linear/affine) |
| Folding, selection range, linked editing | Block folding, expand/shrink selection by AST node, live multi-cursor rename |
Cross-file / project intelligence
The server discovers the rest of your project from forge.toml, so go-to-def,
find-references, workspace symbols, and whole-project diagnostics all work
across files — including resolving an interface declared in one file from an
impl in another.
Typestate and capability intelligence
When your code uses the typestate system — always_linear type handles with transitions blocks — the LSP provides extra guidance directly in the editor.
Typestate hover
Hovering any expression whose type is a typestate handle shows:
- An always-linear badge if the handle was declared with
always_linear type(the compiler requires it to be consumed, never dropped) - The current state the handle is in at this point in the code
- Every declared transition from that state, showing which
viafunction moves it to which next state
always-linear — must be consumed, not dropped
Typestate: resource `ConnTag`, state **`Closed`**
Transitions from `Closed`:
- `Closed` → `Open` via `connect`
This pairs naturally with the existing type hover — the hover card first shows
the inferred type (Handle(ConnTag, Closed)), then the typestate block below the
separator.
via completions
Inside a transitions block, after typing via , the completion menu filters to
functions in scope whose type matches the transition shape:
transitions Handle do
ConnTag: Closed -> Open via con<cursor>
-- ↑ only `connect`, `reconnect`, etc. offered
end
Only functions with the right type are shown — Handle(R, S1) -> Handle(R, S2) where R and the handle name match the enclosing transitions declaration. This prevents accidentally wiring a transition to an unrelated function.
Code actions
The lightbulb / quick-fix menu offers a large set of refactors and fixes, type-directed where possible:
- Quick fixes — add missing match arm(s) (
Add all N missing cases), prefix/remove an unused binding, remove an unused import, fix a non-exhaustive match across the whole file. - Refactors — introduce / remove pipe (
f(g(x))⇄x |> g() |> f()), extract variable, extract function, inline variable / function, convertif→match, expand / collapse a function capture (f⇄fn x -> f(x)). - Type-directed — destruct / case-split a variant value into an exhaustive
match, fill a typed hole (?) ranked by the expected type, add a type or return-type annotation, organize imports. - Generators — generate a doc comment, scaffold the missing methods of an
impl, generate an actor client module, scaffold a session-type handler. - Auto-alias — collapse a repeated module prefix into an
alias.
Project-wide refactors that rewrite every call site (rename, move, codemod, and introduce parameter object) live in the build tool as
forge refactor …— see Tooling. Cursor-driven actions above are the LSP’s; project-wide rewrites are forge’s.
Performance insights
The LSP runs the full TIR optimization pipeline asynchronously and reports the results as inlay hints and per-function code lens:
⚡ stack-allocated— value promoted to the stack (no heap allocation)♻ in-place— FBIP: value reused in place without allocating⚠ non-tail call— a recursive call that isn’t in tail position📦 closure captures N values— a closure capturing several variables- allocation inside a recursive arm, actor message copies, indirect calls
A per-function lens summarizes them, e.g. ⚡ 2 stack-allocated · ♻ 1 in-place.
Toggle the inlay annotations with the march.inlayHints.performanceAnnotations
client setting.
Standalone query CLI
The same analysis engine is reachable as one-shot queries that print a single JSON object and exit — no LSP handshake, no persistent process. Handy for scripts, CI, and LLM tooling:
march-lsp query hover file.march --line 10 --col 4
march-lsp query type file.march --line 10 --col 4 # inferred type only
march-lsp query definition file.march --line 10 --col 4
march-lsp query references file.march --line 10 --col 4
march-lsp query completions file.march --line 10 --col 4
march-lsp query diagnostics file.march
march-lsp query symbols file.march # top-level symbols
march-lsp query format file.march # formatted source (not JSON)
# Analyse an unsaved buffer piped on stdin (the path is only used for messages):
cat buffer.march | march-lsp query diagnostics buffer.march --stdin
--line/--col are 1-based on the command line (human-friendly), counted in
UTF-16 code units, and converted to the 0-based LSP convention internally; ranges
in the JSON output are 0-based UTF-16 (LSP). Exit status is 0 on success and
2 on a usage error (the JSON then has an "error" field).
$ march-lsp query type good.march --line 3 --col 6
{"type":"Int"}
$ march-lsp query diagnostics bad.march
{"diagnostics":[{"message":"I expected `Bool` but found `Int`.","range":{"start":{"line":1,"character":18},"end":{"line":1,"character":22}}}]}
symbols reports every symbol visible to the analysis (open file plus loaded
stdlib/workspace modules), matching the LSP documentSymbol request.
Debugger (DAP)
March also ships a Debug Adapter Protocol server for breakpoint debugging, launched as a subcommand of the compiler:
march dap # speaks DAP over stdio
It supports line breakpoints, step in / over / out, a call stack, and local variable inspection, by pausing the interpreter at each breakpoint. Editor clients:
- VS Code — the extension in
editors/vscode-march-debug/ - Zed — the
[debug_adapters.march]entry inzed-march/
This is the protocol-based debugger; for a quick inline pause you can also drop a
dbg() call to open the REPL at that point (see Tooling).
Why it’s accurate
march-lsp and march dap use the compiler’s own libraries — the same lexer,
parser, type checker, and IR passes that build your program. There is no separate
“editor model” to drift out of sync: a diagnostic in the editor is a diagnostic
in the build, and a hover type is the real inferred type.