A Language Server Protocol implementation for ErgoScript, built from the ErgoScript language specification and validated against a corpus of real contracts.
Zero runtime dependencies — it needs nothing but Node 16+. There is no build step
and no npm install.
node server.js --stdioAn IntelliJ plugin in intellij/plugin adds Scala-like syntax
highlighting and, where the platform's LSP integration is available, drives the
server through IntelliJ's own LSP API. It builds with no downloads — see
Setup.
A contract as the plugin renders it. Several things above are the server's work
rather than the lexer's: the greyed : Boolean, : Coll[Byte], : Box and
: (Coll[Byte], Long) annotations are inferred types shown as inlay hints on
vals that were never annotated, and the colouring distinguishes a value you
declared from a stdlib property, a method call, and a value supplied from outside
the file. The $-prefixed names are
template placeholders; the leading marker on each is a
collapsed name prefix, still present in the file and revealed
on click.
| Feature | Notes |
|---|---|
| Diagnostics | Syntax errors, unresolved names, unknown members, arity, type mismatches, unused vals, contracts that do not evaluate to SigmaProp |
| Completion | Scope-aware. After . it offers only the members that actually exist on the receiver's inferred type |
| Hover | Full specification documentation for every stdlib type, method and predefined function; inferred types for your own vals and defs |
| Signature help | Parameter hints while typing a call, including generic methods such as fold and map |
| Go to definition / Find usages / Rename | For val, def and lambda parameters |
| Document symbols | Structure view of every val and def |
| Inlay hints | Inferred type after every un-annotated val |
| Semantic tokens | Type-aware colouring (a val reads differently from a method, an injected constant, a builtin) |
| Folding | Braces, parentheses and comment blocks |
| Quick fixes | Wrap a Boolean result in sigmaProp(...), add a missing register type argument |
Type inference covers registers (SELF.R4[AvlTree].get → AvlTree), context
variables, tuple projections, collection element types, and generic method
binding through lambdas — so OUTPUTS.fold(0L, { (acc, b) => acc + b.value })
types correctly all the way through.
Two kinds of value arrive from outside the file, and both are identified by a name prefix:
| Prefix | Meaning | Setting |
|---|---|---|
CONST_, INJ_, _ |
A constant injected at compile time by ConstantsBuilder / compileContract |
externalConstantPattern |
CTX_ |
A context variable, read from the transaction's context extension via getVar |
contextVariablePattern |
A name with either prefix is treated as externally supplied rather than as an unresolved-name error, is offered in completion once it appears anywhere in the file, and gets its own colour — consistently at its declaration and at its references, whether or not the file declares it.
Matching is by prefix, not by ALL_CAPS. A screaming-snake name is a naming
convention, not evidence that something external supplies the value, and treating
every one of them as injected suppressed unresolved-name errors for ordinary
typos. PLAIN_CAPS is an ordinary val.
Writing val CTX_SIGNER = and invoking completion offers getVar and
getVarFromInput first, since reading the context extension is the only thing a
context variable is initialised from. For the same reason, a CTX_ value whose
initialiser does not bottom out in one of those is flagged — the prefix is a claim
about where the value comes from, and the editor colours it on that basis, so a
name that makes the claim without reading the context extension misleads every
later reader. .get and .getOrElse(...) chains are seen through. Severity is
configurable via contextVarAccessor.
injectedConstantDeclaration enforces the mirror rule — that a constant-prefixed
name really is supplied by the compiler rather than declared in the file. It is
off by default, because the prefix is just as commonly used for ordinary local
constants: switching it on flags 105 declarations across the Lithos contracts,
which is a house-style disagreement rather than a set of defects. If you do turn
it on, consider narrowing externalConstantPattern to ^(CONST_|INJ_) at the
same time — the bare _ also matches names like _deltaSupplyLP, which is a
local temporary in the Scala sense rather than an injected value.
Some projects store contracts containing $name and have an off-chain build step
substitute the values before compiling, so the file on disk is not quite
ErgoScript:
val minStartHeight = HEIGHT - $epochLength
A $name is read as one identifier and treated as a value supplied from outside
the file — no syntax error, no unresolved name, coloured like an injected
constant, and offered in completion once it appears anywhere in the file. Its type
is unknown, so it silences downstream checks rather than inventing errors.
This is syntax rather than a naming convention, so unlike the prefixes above it is
not configurable: switching it off would only make such files unreadable. A bare
$ not followed by a name is still an error.
Hidden prefixes
Because those prefixes are pure convention and repeat on every use, the editor
hides them: CONST_PERIOD_LENGTH renders as PERIOD_LENGTH and CTX_SIGNER as
SIGNER, each still in its own colour.
This is ordinary code folding, which is what makes it safe — the prefix is still
in the document, so copy and paste yields the real identifier, Find (Ctrl+F)
matches the prefix, and rename and go-to-definition are unaffected. Only the
on-screen width changes.
Revealing a prefix is a transient peek. Click the · (or hover it, or press
Ctrl+NumPad +) to see the full name; move the caret away and it hides again. A
prefix you are typing inside stays visible until you leave it, so nothing
disappears mid-keystroke.
The marker character is configurable in the same place, as is turning the whole
thing off. It cannot be empty: a zero-width fold region has nothing to click and
nothing to hover, so a hidden prefix could never be revealed — a blank value falls
back to ·.
2026.2 ships Ultimate and Community as a single distribution, with the tier
expressed as a module: without a subscription, com.intellij.modules.ultimate
appears in disabled_plugins.txt and any plugin hard-depending on it refuses to
load. So the plugin is built in two halves:
| Half | Requires | Gives you |
|---|---|---|
| Language and colours | nothing | Highlighting, brace matching, Ctrl+/, colour scheme page |
| Language server | com.intellij.modules.lsp |
Diagnostics, completion, hover, navigation, inlay hints, quick fixes |
The second is declared as an optional dependency on
com.intellij.modules.lsp — the capability actually used, rather than the tier.
If it is unavailable the plugin still installs and still highlights; you just
don't get the server — see Other editors for running it through
a different LSP client instead.
To tell which you got: after installing, look for an ErgoScript page
under Settings → Languages & Frameworks. Present means the LSP half loaded;
absent means only highlighting is active.
cd intellij\plugin
.\build.ps1No Gradle, no wrapper download, no IntelliJ Platform Gradle Plugin. IDEA already
ships everything needed: kotlinc comes with the bundled Kotlin plugin and the
JBR provides the JDK, so the plugin is compiled directly against the jars of the
IDE it will run in. The script finds your newest IntelliJ IDEA* install
automatically; override with -IdeaHome or ERGOSCRIPT_IDEA_HOME.
It prints the path to build\ErgoScript-plugin.zip.
Settings → Plugins → ⚙ → Install Plugin from Disk… → pick the zip → restart.
Settings → Languages & Frameworks → ErgoScript.
Both paths can be left empty, in which case the plugin discovers them:
- Node — checks the setting, then
ERGOSCRIPT_NODE, thenPATH, then the usual install locations. It refuses a.cmd/.batlauncher with an explanation rather than failing later withEINVAL. - Server — checks the setting, then
ERGOSCRIPT_SERVER, then looks forserver.jsin the project root or anErgoScriptLSPdirectory beside it, confirmingsrc/analyzer.jssits next to it so an unrelatedserver.jscannot be picked up by mistake.
If you keep the server outside the project you are editing, set the path once and it is remembered globally.
Open any contract from test/corpus/. You should see:
- an annotated
valwhose register type disagrees with it flagged — for exampleval totalScore: Long = SELF.R6[BigInt].get, where the register holds aBigInt; - hover on an AVL tree value reporting its inferred type;
.after it offeringinsert,getMany,digest, … and nothing fromBox.
The language services widget in the status bar shows whether the server started and offers Restart; it is the first place to look if something misbehaves.
Check Settings → Editor → File Types for whether ErgoScript actually owns
*.ergo. If it does not, the plugin is loaded but idle: the server only starts
for files whose type is ErgoScript, so an unclaimed .ergo gets neither
highlighting nor diagnostics.
This happens when something else already claimed the extension — a TextMate
bundle, or a manual mapping to Text from a pre-plugin setup. IntelliJ resolves
the conflict by asking, and records your answer in filetypes.xml as:
<removed_mapping ext="ergo" approved="true" type="ErgoScript" />approved="true" means "the user removed this; do not restore it", so the
plugin's own registration is ignored from then on and reinstalling does not
help — the entry survives in IDE config. To fix it:
- Remove whatever else claims
.ergo— for a TextMate bundle,Settings → Editor → TextMate Bundles. Settings → Editor → File Types→ select ErgoScript → add*.ergounder File name patterns.
Two highlighters competing for one extension is exactly what produces this, so
do not register a TextMate grammar for .ergo alongside the plugin.
The plugin claims *.ergo only. Ultimate's JavaScript support already owns
*.es (ECMAScript), and claiming it too would be a file type conflict. If you
work with .es contracts, add the pattern yourself under
Settings → Editor → File Types → ErgoScript.
When the parameter you are filling in has a known type there is usually one obviously right thing to type next, so it is offered first:
| After typing | Suggestion |
|---|---|
allOf(, anyOf(, xorOf(, atLeast(k, |
Coll() |
OUTPUTS.fold(0L, |
{ (acc: Long, x: Box) => } |
OUTPUTS.fold(0L, { |
(acc: Long, x: Box) => |
OUTPUTS.forall( |
{ (x: Box) => } |
val CTX_SIGNER = |
getVar, getVarFromInput |
The lambda parameter types are inferred, not templated — acc: Long comes from
the seed and x: Box from the receiver, so changing either changes the
suggestion. Whether a { has already been typed is taken into account, so the
braces are never doubled.
Both a single-line and a multiline shape are always offered. The multiline one is laid out the way the contracts are written — brace ending the call line, parameters on a line of their own, body one step further in, closing brace lined up with the statement — so it needs no reformatting:
val correctNValues = shareMappings.forall{
(hN: (Int, Int)) =>
val shareHeight = hN._1
}
Indentation is taken from the line the call starts on, so a nested lambda indents from its own position rather than the outermost one.
lambdaStyle decides which of the two leads. The inline grey text always shows a
single-line shape, because a grey text element is one line: previewing a
multiline lambda collapsed and then inserting something else would be worse than
offering the compact form. With lambdaStyle: multiline the popup leads with the
multiline lambda and the inline preview offers the single-line one.
(, , and { are completion trigger characters, but only for these
suggestions: invoked by one of them the server returns nothing else, so the popup
opens where there is something worth showing and stays shut on every other brace
and comma in the file. Ctrl+Space still offers everything.
The same suggestion also appears as grey inline text, accepted with Tab. The
plugin does not compute it — it asks the server the same question the popup asks,
with the same trigger character, and renders the top answer, so the two cannot
drift apart.
Highlighting arrives in two layers. A lexer in the plugin colours the file the instant it opens — before the server has connected, and still if it fails to start. Semantic tokens from the server then refine that with type-aware information the lexer cannot know, and win where the two disagree.
Every colour key is declared with a fallback to a platform default and carries no hardcoded colour of its own, so ErgoScript follows whichever editor scheme you use rather than fighting it. What makes a file read like Scala is not a copied palette but which distinctions get their own key:
| ErgoScript | Reads as | Example |
|---|---|---|
a val you declared |
local variable | nispTree |
| a constant, by prefix | static field, like a Scala stable id | CONST_PERIOD_LENGTH, INJ_RATE, _FIXED_RATE |
| a context variable, by prefix | global variable | CTX_SIGNER, CTX_NISP_PROOF |
| a builtin context value | predefined symbol | SELF, HEIGHT, OUTPUTS |
| a lambda parameter | reassigned parameter | the acc, b of fold(0L, { (acc, b) => … }) |
a def you declared |
function declaration | getScore |
| a predefined function | predefined symbol | blake2b256, sigmaProp |
| a stdlib method | instance method | .insert, .fold |
| a stdlib property | instance field | .digest, .value |
| a stdlib type | class reference | Box, AvlTree |
Constants and context variables read identically whether or not the file declares them, and identically at a declaration and at its references — the prefix is what carries the meaning, not where the name happens to be bound.
Override any of them individually under
Settings → Editor → Color Scheme → ErgoScript:
Every key the plugin defines appears here, grouped, with a live preview beneath — so a change can be judged against real ErgoScript rather than guessed at. Because the keys only carry fallbacks, the page starts out showing whatever the active scheme provides; the screenshot is Solarized Dark, and nothing about it was configured for this plugin.
Editable under Settings → Languages & Frameworks → ErgoScript, and sent to the
server as initialization options. Changes take effect when the server restarts —
use the status bar widget.
| Setting | Default | Meaning |
|---|---|---|
externalConstantPattern |
^(_|CONST_|INJ_) |
Identifiers treated as compiler-injected constants |
contextVariablePattern |
^CTX_ |
Identifiers treated as context extension variables |
contextVarAccessor |
warning |
error, warning, hint or off for a CTX_ value not read via getVar / getVarFromInput |
injectedConstantDeclaration |
off |
Same values, for a constant-prefixed name declared in the file rather than injected |
lambdaStyle |
single |
single or multiline — which lambda shape is offered first |
typeChecking |
warning |
error, warning or off for type-mismatch diagnostics |
literalNarrowing |
allow |
allow lets an integer literal that fits stand in for a narrower numeric parameter, as in getVarFromInput(1, 0). Set to warn if your compiler rejects those |
unusedValues |
hint |
warning, hint or off |
requireSigmaPropResult |
true |
Warn when a contract does not evaluate to SigmaProp |
maxNumberOfProblems |
300 |
Diagnostic cap per file |
The server speaks plain LSP over stdio and does not care who its client is. Point
any LSP client at an absolute path to your Node executable and to server.js:
/usr/bin/node /path/to/server.js --stdio
Map *.ergo (and *.es if you use it) to language id ergoscript.
Two things are worth knowing if the server will not start, both learned the hard way on Windows:
- Point the client at the Node executable, not at a shell script. LSP clients
exec their command without a shell, and Windows refuses to exec
.bat/.cmdthat way — you getEINVALor "Cannot run program". - Use an absolute path rather than bare
node. An IDE inherits thePATHof whatever launched it, which often predates a Node install; this bites nvm and fnm users especially. The IntelliJ plugin resolves this for you and will refuse a.cmdlauncher with an explanation rather than failing opaquely.
To run the server by hand:
node server.js --stdioIt will sit there waiting for LSP messages on stdin — that is correct behaviour,
not a hang. Ctrl+C to exit.
npm testtest/smoke-lsp.jsdrives the server over stdio the way an editor does and asserts on every feature's replies.test/check-corpus.jsruns the parser and analyzer across every contract intest/corpus/and prints the diagnostics, which is how false positives get caught. It reports rather than asserts a count: a contract that produces findings is information, not a failure. Only a crash fails the run.test/check-globals.jsasserts that every member ofGlobalresolves without theGlobal.prefix, which the language allows. It enumerates the type rather than naming members, so one added later cannot quietly become unresolvable.test/check-classification.jsasserts what the analyzer classifies identifiers as, which is what drives editor colours. Classification has no effect on whether a contract analyses cleanly, so these bugs are invisible to the diagnostics tests above: it checks that context values are distinguishable from yourvals, and that a constant reads the same at its declaration as at its references.test/check-name-prefixes.jsasserts that both prefix claims hold: whichCTX_initialiser forms are accepted (including through.get/.getOrElsechains and braced blocks) and which are rejected, and the mirror rule for constants — that it is off by default, covers all three prefixes, and stops matching the bare_once the pattern is narrowed.test/check-completion.jsasserts the context-sensitive rankings — thatgetVaris promoted afterval CTX_X =and only there, is not duplicated, and does not displace member completion after a dot.test/legend-parity.jsasserts the plugin's semantic tokens legend matches the server's. Tokens are encoded as indices into that legend, so a mismatch does not error — it silently mis-colours everything. It also asserts thatshouldAskServerForSemanticTokensis overridden and that no key shown on the colour settings page is left unapplied, the two other ways a colour can look broken while previewing correctly.
The corpus is a deliberate mix of contracts by different authors — see
test/corpus/SOURCES.md for provenance and licensing.
Contracts written elsewhere are what stop the analyzer from quietly encoding one
codebase's habits as language rules; adding them immediately surfaced the
$name placeholder dialect, which is now supported.
The plugin's lexer is checked against the server's tokenizer over the whole corpus, which is what keeps pre-server and post-server colours consistent:
cd intellij\plugin
.\check-lexer.ps1It verifies the IntelliJ lexer invariants (tokens tile the buffer, none are
zero-length) and that every token boundary agrees with src/lexer.js.
server.js LSP transport, dispatch, capabilities
src/lexer.js Tokenizer (comments, hex and Long literals, operators)
src/parser.js Recursive-descent parser with error recovery
src/types.js Structural type model, unification, numeric widening
src/stdlib.js The ErgoScript standard library, from the language spec
src/analyzer.js Scopes, type inference, diagnostics
src/features.js Completion, hover, navigation, semantic tokens, …
src/document.js Text document with offset/position index and caching
intellij/plugin/ IntelliJ plugin: LSP wiring, lexer, colours
test/corpus/ Contracts the analyzer is checked against
The plugin itself:
build.ps1 Compile and package, no downloads
check-lexer.ps1 Cross-check the lexer against the server
tools/LexerCheck.kt The checker
src/main/resources/META-INF/ plugin.xml
src/main/kotlin/…/lexer/ Port of src/lexer.js
src/main/kotlin/…/highlight/ Colour keys, syntax highlighter, settings page
src/main/kotlin/…/lsp/ LSP integration, semantic tokens, toolchain, settings
minandmaxare supported even though the language specification omits them: they are predefined functions in sigmastate and appear in deployed contracts.- Inference is deliberately lenient. Anything it cannot resolve becomes an unknown type, which silences downstream checks rather than inventing errors.
- The server does not invoke the real sigmastate compiler, so it cannot report ErgoTree serialisation or cost-limit errors.
- The plugin builds no PSI beyond a flat token tree. Everything structural comes from the server, so IDE features that need real PSI (structural search, the Scala-style code formatter) are out of scope.
com.intellij.modules.lspresolves without an Ultimate subscription on 2026.2 — verified onIU-262.8665.337withcom.intellij.modules.ultimatedisabled. That is not documented by JetBrains and could change, which is why the dependency stays optional.
CC0 1.0 Universal — dedicated to the public domain. Use it for anything, with or without attribution.
That dedication covers the contracts in test/corpus/ as well as
the code. The external contracts there were already CC0; the rest are released on
the same terms — see test/corpus/SOURCES.md.