Skip to content

About

ErgoScript LSP and IntelliJ 2026.2+ Plugin

Resources

Stars

3 stars

Watchers

1 watching

Forks

Latest commit

 

History

3 Commits

Folders and files

Repository files navigation

ErgoScript Language Server

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 --stdio

An 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.

An ErgoScript contract open in IntelliJ

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.

What it does

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.

Name prefixes

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.

Template placeholders

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 ·.

Setup in IntelliJ IDEA 2026.2

Which half you get

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.

1. Build the plugin

cd intellij\plugin
.\build.ps1

No 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.

2. Install it

Settings → Plugins → ⚙ → Install Plugin from Disk… → pick the zip → restart.

3. Point it at the server

Settings → Languages & Frameworks → ErgoScript.

Both paths can be left empty, in which case the plugin discovers them:

  • Node — checks the setting, then ERGOSCRIPT_NODE, then PATH, then the usual install locations. It refuses a .cmd/.bat launcher with an explanation rather than failing later with EINVAL.
  • Server — checks the setting, then ERGOSCRIPT_SERVER, then looks for server.js in the project root or an ErgoScriptLSP directory beside it, confirming src/analyzer.js sits next to it so an unrelated server.js cannot 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.

Verifying it works

Open any contract from test/corpus/. You should see:

  • an annotated val whose register type disagrees with it flagged — for example val totalScore: Long = SELF.R6[BigInt].get, where the register holds a BigInt;
  • hover on an AVL tree value reporting its inferred type;
  • . after it offering insert, getMany, digest, … and nothing from Box.

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.

If nothing highlights and the server never starts

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:

  1. Remove whatever else claims .ergo — for a TextMate bundle, Settings → Editor → TextMate Bundles.
  2. Settings → Editor → File Types → select ErgoScript → add *.ergo under 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.

A note on .es

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.

Suggestions shaped by the expected type

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.

Colours

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:

The ErgoScript colour scheme settings page

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.

Settings

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

Other editors

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/.cmd that way — you get EINVAL or "Cannot run program".
  • Use an absolute path rather than bare node. An IDE inherits the PATH of 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 .cmd launcher with an explanation rather than failing opaquely.

To run the server by hand:

node server.js --stdio

It will sit there waiting for LSP messages on stdin — that is correct behaviour, not a hang. Ctrl+C to exit.

Tests

npm test
  • test/smoke-lsp.js drives the server over stdio the way an editor does and asserts on every feature's replies.
  • test/check-corpus.js runs the parser and analyzer across every contract in test/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.js asserts that every member of Global resolves without the Global. prefix, which the language allows. It enumerates the type rather than naming members, so one added later cannot quietly become unresolvable.
  • test/check-classification.js asserts 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 your vals, and that a constant reads the same at its declaration as at its references.
  • test/check-name-prefixes.js asserts that both prefix claims hold: which CTX_ initialiser forms are accepted (including through .get / .getOrElse chains 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.js asserts the context-sensitive rankings — that getVar is promoted after val CTX_X = and only there, is not duplicated, and does not displace member completion after a dot.
  • test/legend-parity.js asserts 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 that shouldAskServerForSemanticTokens is 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.ps1

It verifies the IntelliJ lexer invariants (tokens tile the buffer, none are zero-length) and that every token boundary agrees with src/lexer.js.

Layout

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

Known limits

  • min and max are 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.lsp resolves without an Ultimate subscription on 2026.2 — verified on IU-262.8665.337 with com.intellij.modules.ultimate disabled. That is not documented by JetBrains and could change, which is why the dependency stays optional.

Licence

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.

About

ErgoScript LSP and IntelliJ 2026.2+ Plugin

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages