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 systemalways_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 via function 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, convert ifmatch, expand / collapse a function capture (ffn 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 in zed-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.