mere lspSpeaks LSP over stdin and stdout. What it does today is diagnostics — every syntax error in the buffer, republished on each keystroke, and the first type error once the file parses — hover, which reports the type inference gave whatever is under the cursor, go to definition, completion, an outline, formatting, semantic highlighting, find references, rename, code actions, document highlight, go to type definition and signature help.
It is the same check the compiler runs — Pipeline.check, which the CLI also
goes through. A language server that agrees with the compiler on good days
is worse than none, because it teaches you to distrust the underline.
Three of these were added by writing a handler and nothing else. That is worth saying plainly, because it is the same discovery the code actions were:
| request | the answer it uses |
|---|---|
| document highlight | Query.references_at — find-references' list, drawn in the file. Every occurrence is kind Text: Mere has no assignment, so there is no read/write distinction to claim |
| go to type definition | the node's inferred type, and Parser.declared_types for where that type was declared. Prelude types are excluded — list is declared in a text nobody can open |
| signature help | Ast.rv_spine, which splits f a b into a head and its arguments. In a curried language "which parameter" is just how many arguments sit before the cursor |
⚠ Signature help contains one guess. A Loc.t is a start and a width, so "the
call the cursor is inside" is not a question the tree can answer; it answers
about the nearest call head at or before the cursor on that line, preferring
a call that is still unfinished. That is what keeps add3 (pick Red) from
reporting about pick. A call spanning lines gets no help rather than the
wrong help.
foldingRange is deliberately absent: the same missing extent means nothing
knows where a declaration ends.
Two things, both since v0.1.504:
- It answers on a definition, not only on a use.
Query.node_atlooks for an expression, and the name inlet inc = ...is a pattern, so for a long time hovering the very place a name is introduced returned nothing. - It shows the comment above the definition. Contiguous
//lines ending on the line before the definition, found through the binding — so hovering a use shows what was written at the definition. There is no///marker: Gleam has one because its docs generator publishes them, and Mere has no generator for a third slash to feed.
Three, and none of them computes anything new — each is an answer the compiler already had, in the shape an editor can apply:
| action | where it comes from |
|---|---|
| Add the missing arm(s) | the exhaustiveness checker writes the arm when it finds the hole; it used to print it on the help: line and nothing else could reach it |
Declare f : (shape -> int) |
the type inference put on the definition. Mere has no annotation on a let, so this writes the forward declaration — let fn f: (shape -> int); — on the line above. Accepting it cannot change what the program means; what it changes is where the next edit to that definition fails |
Prefix x with _ |
the unused-binding warning, which is the other thing a person does about one |
They arrive as quickfixes on the diagnostic they answer (the annotation one is
a refactor.rewrite, offered on the line the definition starts on). There is
nothing to resolve later: the server builds the whole edit when asked.
scripts/lsp_smoke.sh drives the round trip over the real wire format —
request, apply the edit that comes back, compile the result — and
--poison checks that it can go red by applying nothing, and by applying the
same text at the wrong position.
Neovim (built-in client, no plugin):
vim.api.nvim_create_autocmd("FileType", {
pattern = "mere",
callback = function(args)
vim.lsp.start({
name = "mere",
cmd = { "mere", "lsp" },
root_dir = vim.fs.dirname(vim.fs.find({ "mere.toml", ".git" }, { upward = true })[1]),
})
end,
})
vim.filetype.add({ extension = { mere = "mere" } })VS Code: install the Mere extension,
which starts this server and speaks to it. It finds mere on PATH; mere.path
points it at a local build.
Anything else: the command is mere lsp, there are no arguments and no
configuration. It reads Content-Length-framed JSON-RPC on stdin and writes the
same on stdout, so a client that can start a process can talk to it.
| method | behaviour |
|---|---|
initialize |
capabilities: full-text sync (textDocumentSync: 1), hoverProvider, definitionProvider, completionProvider, documentSymbolProvider, documentFormattingProvider, semanticTokensProvider |
textDocument/hover |
the inferred type of the narrowest node under the cursor, as name : type when it is a name |
textDocument/definition |
where the name under the cursor was bound, when that is somewhere in this file |
textDocument/completion |
every name visible at the cursor, innermost first, with its type |
textDocument/documentSymbol |
the file's top-level declarations, for the outline and symbol search |
textDocument/formatting |
the whole document, formatted — the same function mere fmt runs |
textDocument/semanticTokens/full |
which names are parameters, functions, constructors |
textDocument/references |
every occurrence of the binding under the cursor |
textDocument/rename / prepareRename |
the same occurrences, rewritten; refuses what this file does not own |
textDocument/didOpen / didChange / didSave |
check the buffer, publish diagnostics |
textDocument/didClose |
publish an empty list, clearing the underlines |
shutdown / exit |
as specified |
| anything else with an id | -32601 method not found, rather than silence |
Diagnostics carry a range with a width, so the underline covers the token rather than one character of it, and positions are converted from Mere's 1-based lines and columns to the protocol's 0-based ones.
The buffer is what gets checked, not the file on disk — an editor owns a file
while it is open. But import still resolves against the file's directory,
taken from the document URI, so imports work in an unsaved buffer.
Warnings are diagnostics too (severity 2): a top-level name that collides
with a C keyword, a match over a scalar with no wildcard arm. They used to be
printed to stderr from inside the compiler, which is fine for a terminal and
useless to anything else — an editor cannot underline a line written to a stream
it is not reading. The pipeline hands them over as data now and the CLI does its
own printing.
A match missing a named case is severity 1, not 2 (v0.1.468): the compiler
refuses to build it, and an editor that draws it as a warning is describing a
program that will not compile. The help: lines carrying the arm to add travel
in the message, after the first newline.
An error inside an import — of any kind — is published against that
file's URI,
where its line numbers mean something, rather than against the buffer. A position
carries the file it came from (the lexer stamps every token from an imported
file), so this works for a type error raised long after the parse, not just for a
syntax error. The server remembers which other files it has spoken about and
clears them when the import is fixed — a diagnostic stays on screen until the
server says otherwise, and "never mind" is exactly the message nobody thinks to
send.
A Loc.t in this compiler is a line, a column and a width — the token a node
was built from, not a span over its subtree. So "the node at this position" means
the narrowest node whose own token contains the cursor, which is what makes
hovering inside a call answer about the piece under the cursor rather than about
the whole application.
The type comes from the typer having written it onto the node (e.ty <- Some t)
during the check that produced the diagnostics. There is no second inference pass
and no separate index: the hover is reading what the compiler already concluded.
The server keeps the last tree that type-checked. While a line is half typed the file does not check, and an answer from a moment ago beats no answer at all — so hover keeps working through an edit, and catches up when the file is valid again.
Scope is recomputed by walking down to the position, not kept in an index. The walk descends one path rather than the whole tree, it cannot go stale, and it has no invalidation to get wrong — the same reason hover reads the typer's annotations instead of building a table beside them.
A binder is in scope for the parts of itself where it is really visible: a let
binds its body but not its own value expression, a fn binds its body, a
let rec binds both, a match arm's pattern binds that arm. Getting that wrong is
how a server sends you to the wrong x, so each case is a test.
Two things it deliberately declines to answer:
- A prelude name (
print_int,str_len) is genuinely in scope, but its position is a line in the prelude's own text. Jumping there would send the editor to an arbitrary line of the user's file, so it answers nothing at all. The count of prelude declarations is recorded when the program is parsed, which is what makes them distinguishable. - A parameter points at the
fnthat introduced it rather than at the parameter name, becauseFuncarries the name but not the name's own position.
Every name visible at the position, innermost first, one entry per name — an
inner binding shadows an outer one, and offering both would offer a name that
cannot be reached. Each carries its inferred type as the detail line, and a
kind so the editor draws a function icon for a function.
Prelude names are offered (str_len is exactly what you want in the list) but
sortText puts them after the file's own names, and the prelude's internal
helpers — the ones it names with a leading underscore — are left out.
There are no trigger characters: this language has no . member access to
complete after, so the list arrives when the editor asks for it. The response is
marked complete, so an editor filters it as you keep typing rather than asking
again.
Formatting is mere fmt's own function, so format-on-save and the command
line cannot come to different conclusions about what formatted means. Two
refusals worth knowing: a file that does not parse is left alone (replacing a
buffer with the best guess of a parser that failed is how somebody loses work),
and an already-formatted file produces no edit at all rather than an edit that
changes nothing.
The outline lists the file's value declarations, telling a function from a
value by its type. A type declaration is missing from it, and honestly so:
Top_type carries a name and its variants and no position at all, so it cannot
be pointed at without guessing which line it is on.
Semantic tokens are the compiler saying which names are parameters, which are functions, which are constructors — the distinctions a regular expression cannot make, and the reason an editor's grammar is only half the story. The grammar still handles keywords, strings and numbers, which it is perfectly good at. A constructor in a pattern is not tokenised: patterns are not expressions, and the walk that produces these follows expressions.
The same question, and the difficulty in both is shadowing: two xes in one
file may be two different things, and treating them as one is a rename that
breaks the program. So the walk resolves every occurrence to the binding it
actually refers to, and the answer is the occurrences that resolved to the same
one.
let x = 1; // renaming this one touches
let f = fn (n: int) ->
let x = n + 1 in // ... not this one, nor
x + x; // ... these
let _ = print_int (f x + x); // ... but these two
Binder positions are included, so the cursor may be on the definition rather than on a use.
Rename refuses what this file does not own. A prelude name or a builtin has
its definition somewhere the edit cannot reach, and renaming the uses while
leaving the definition is worse than refusing. The refusal comes back from
prepareRename, which is where an editor asks before offering a box to type in.
-
The rarer type errors still stop a declaration. A mismatch and an unknown name are collected — those are nearly all of them — and inference carries on with a fresh variable. The other twenty or so
raisesites in the typer still end that declaration's check, and the recovery at the declaration boundary picks up from there. So the worst case is one error for a declaration rather than all of them, never one error for the file. -
Incremental sync. The whole buffer arrives on every change. The check re-reads all of it anyway, so incremental sync would buy nothing yet and cost a class of desynchronisation bugs.
Re-checking the whole document on every change is only reasonable if checking is cheap, and this is the first thing that ever made the compiler's own cost visible — a batch compiler that takes two seconds is fine, an editor that takes two seconds per character is not.
Measured end to end, driving the server the way an editor does (didChange, then
wait for publishDiagnostics):
| document | per keystroke |
|---|---|
| 4 000 lines | 32ms |
| 8 000 lines | 65ms |
| 16 000 lines | 135ms |
22 466 lines (mere-ruby's main.mere) |
1251ms |
Those numbers are from v0.1.220. Before it they were 524ms, 1834ms, 7741ms and
5261ms: generalize scanned the whole type environment per binding, so checking
was quadratic in the number of bindings. scripts/infer_scaling.sh is the guard
that keeps it linear.
The largest file is still over a second, and the remaining cost is spread evenly across parsing, inference and the move checker rather than concentrated in one place. Getting below it means not re-checking everything — which is what incremental sync above is really for, and neither is worth doing on a guess about which documents people actually edit.
sh scripts/lsp_smoke.sh drives the real process through its wire format: a
canned session that opens a file with three syntax errors, edits it into one with
a type error, then into a clean one, and checks the framing and the three
publishDiagnostics that come back.
The handler itself is a function — message and state in, messages out — so the
suite tests it directly, without a process. That split is deliberate: the only
untested part is the three lines of IO in Lsp.serve.
sh scripts/infer_scaling.sh guards the latency above, by measuring rather than
asserting: two files eight times apart, failing if the larger takes more than 20x
the smaller. Linear predicts 8x and quadratic predicts 64x, so the bound survives
a busy machine and still fails the moment an environment scan comes back.