aontu

aontu Language Server (LSP)

Rendered from docs/lsp.md in the engine repository, where a correction belongs, and where the test suite executes every example on this page.

Both implementations ship a Language Server Protocol server that reports aontu unification problems as editor diagnostics while you type. As required by the project’s parity rule, the TypeScript and Go servers are built the same way, advertise the same capabilities, and produce identical diagnostic text for the same source.

This document is a reference: it is exhaustive about the architecture, the library API in both languages, the supported protocol surface, and how to run the server. Wiring it into an editor is a task, and lives in the wire your editor guide.

What it does

The server provides these features:

  • Diagnostics: unification problems published as you edit.
  • Hover: the resolved value and kind under the cursor, or the declaration of the alias name it is on.
  • Completion: the built-in functions, scalar-kind keywords and literals, and the alias names the document binds.
  • Go to definition: from an alias name to where the file binds it.
  • Signature help: the parameters of the function being called.

Diagnostics: open or edit an aontu document and it publishes a list of problems, each with a precise source range, severity, an engine error code (code), and a human-readable message.

It reports genuine errors only:

SourceDiagnostic?
a:1 a:2yes: scalar_value conflict
a:1 & stringyes: no_scalar_unify
x:foo(1)yes: unknown_function
a:$.missingyes: no_path
b: refer() & path("$.nope")yes: refer_unresolved (a rel() member naming no entity: rel_unresolved)
a:stringno: a non-concrete schema is valid
a:{b:string, c:1}no: partial/constraint documents are valid
port:*8080 | integerno: defaults and disjunctions are valid
a: $.ano: a recursive reference is a valid schema

This distinction is deliberate: aontu documents are frequently schemas or partial fragments, which are not concrete but are not errors. The server flags only contradictions and unresolved/unknown constructs. See How diagnostics are computed.

Verdicts that land at generation stay out of the editor. A required recursive position that no data expanded (recursion_unexpanded) and a relation finding from acyclic() (relation_cycle) are generate-time refusals, so computeDiagnostics publishes nothing for either.

One diagnostic is not an error: a value carrying deprecate() is published with code deprecated at Hint severity (4), tagged with the native Deprecated tag (2), so editors strike it through without shouting.

Hover reads the unified tree, so hovering a value shows what it resolves to: hovering 8080 in port: 8080 shows 8080 with kind integer; hovering string in a schema shows kind type. Hover targets concrete values (scalars, kinds, references), not containers.

Completion offers a context-free list (clients filter by the typed prefix): the built-in functions (the engine’s full roster, 41 today, the constraint atoms (min, re, length, …) and the entity and relation atoms (id, refer, rel, acyclic, inverse) included) the scalar-kind keywords (string, number, integer, float, biginteger, bigdecimal, boolean) and the literals (_, true, false, null, top).

Architecture: library vs. server

Per the requirement to expose the LSP logic as a library separate from serving it, each implementation is split into three layers. Only the outermost layer touches stdin/stdout, so the analysis and the protocol state machine are both unit-testable with no I/O.

┌─────────────────────────────────────────────────────────────┐
│ 3. Server (transport)        ts/src/lsp-server.ts             │
│    stdio Content-Length      go/lsp/serve.go                  │
│    JSON-RPC framing only                                      │
└───────────────┬─────────────────────────────────────────────┘
                │ decoded message objects
┌───────────────▼─────────────────────────────────────────────┐
│ 2. Handler (protocol)        LspHandler  (ts/src/lsp.ts)      │
│    document sync, dispatch,  lsp.Handler (go/lsp/handler.go)  │
│    initialize/shutdown/exit  — no I/O, returns reply objects  │
└───────────────┬─────────────────────────────────────────────┘
                │ document text
┌───────────────▼─────────────────────────────────────────────┐
│ 1. Analysis (pure)           computeDiagnostics (ts/src/lsp.ts)│
│    source text -> Diagnostic[]  lsp.Diagnostics (go/lsp/lsp.go)│
└─────────────────────────────────────────────────────────────┘
LayerTypeScriptGo
1. AnalysiscomputeDiagnostics(src) in ts/src/lsp.tslsp.Diagnostics(src) in go/lsp/lsp.go (over aontu.Check)
2. HandlerLspHandler in ts/src/lsp.tslsp.Handler in go/lsp/handler.go
3. Serverts/src/lsp-server.ts → aontu lsp (and the aontu-lsp bin)go/lsp/serve.go → aontu lsp (and the aontu-lsp binary)

You can consume any layer directly:

  • embed layer 1 to lint aontu source in your own tool;
  • embed layer 2 to run the server over a non-stdio transport (for example a socket or an in-process channel) by feeding it decoded JSON-RPC objects;
  • run layer 3 as a ready-made stdio server for an editor.

Bring your own server

The library (layers 1–2) does not depend on the bundled stdio server, so a third party can build a server on a different transport while reusing all the analysis and protocol logic:

  • Go: import github.com/aontu-lang/aontu/go/lsp. The package has no dependency on cmd/aontu-lsp (verifiable with go list -deps ./lsp | grep cmd → empty). Drive lsp.NewHandler() with decoded lsp.Message values and write back the returned lsp.Outs over whatever transport you like.
  • TypeScript: import aontu/dist/lsp (or ts/src/lsp.ts). It does not import lsp-server.ts; the dependency is one-way (server → library). Drive new LspHandler() with message objects.

In both, computeDiagnostics/Diagnostics, computeHover/Hover, and computeCompletions/Completions take the document’s text and are usable standalone, with no JSON-RPC at all.

Running the server

The server is the lsp verb of the CLI in both builds. It reads LSP/JSON-RPC from stdin and writes to stdout; diagnostic logging (if any) goes to stderr. Editors launch it with no arguments beyond the verb:

aontu lsp

The standalone binaries still ship and run the same server, for configurations that name them: aontu-lsp from the npm package (node ts/bin/aontu-lsp.js from a checkout), and cmd/aontu-lsp in the Go module (go run ./cmd/aontu-lsp inside go/). The two builds are byte-for-byte interchangeable from a client’s point of view.

Editor configuration

Ready-made plugins for VS Code, Emacs and Vim/Neovim live in editors/, and the manual wiring recipes for VS Code, Neovim and any other LSP client have moved to the wire your editor guide. The facts a client needs: command aontu with the argument lsp, transport stdio, document selector the aontu language (.aontu is the extension, and the only one; .jsonic is retired), and no configuration options.

Library API

TypeScript library

Import from the built package (ts/dist/lsp) or from source (ts/src/lsp.ts).

import {
  computeDiagnostics,
  LspHandler,
  type Diagnostic,
  type Message,
  type OutMessage,
  SEVERITY_ERROR,
} from 'aontu/dist/lsp' // or relative path in this repo

computeDiagnostics(src, opts?) => Diagnostic[]

Analyse one document of aontu source and return its diagnostics. A valid document (including a non-concrete schema) returns [].

  • src: string: the document text.
  • opts?: { vars?: Record<string, Val> }: optional $name variable bindings, the same map accepted by the engine’s runner context.

Diagnostic is LSP-shaped:

type Position = { line: number; character: number }   // 0-based; UTF-16 chars
type Range    = { start: Position; end: Position }
type Diagnostic = {
  range: Range
  severity: number       // SEVERITY_ERROR (1); SEVERITY_HINT (4) for "deprecated"
  code?: string          // engine error code, e.g. "scalar_value"
  source: string         // always "aontu"
  message: string
  tags?: number[]        // LSP DiagnosticTag values; [2] marks "deprecated"
}
computeDiagnostics('a:1\na:2')
// [{ range: { start: { line: 1, character: 2 }, end: { line: 1, character: 3 } },
//    severity: 1, code: 'scalar_value', source: 'aontu',
//    message: '[aontu/scalar_value]: Cannot unify values at path $.a\n...' }]

computeDiagnostics('a:string') // []  (valid schema)

computeHover(src, position) => Hover | null

Resolve the value at a 0-based { line, character } position and describe it, or null if the position is not over a concrete value.

computeHover('port: 8080', { line: 0, character: 7 })
// { contents: { kind: 'markdown', value: '```aontu\n8080\n```\n\n*integer*' },
//   range: { start: { line: 0, character: 6 }, end: { line: 0, character: 10 } } }

computeCompletions(src) => CompletionItem[]

Return the completion list: the built-in functions, scalar-kind keywords and literals, which do not depend on src, and the alias names src binds, which do. CompletionItem is { label, kind?, detail? }. The exported BUILTIN_FUNCS is the function-name list.

computeDefinition(src, position, uri) => Location | null

Where the document binds the alias name at a 0-based position, or null where the position is not on one, or names nothing the file binds. Location is { uri, range }.

class LspHandler

The transport-agnostic protocol state machine. Construct one per session and feed it decoded JSON-RPC message objects.

const handler = new LspHandler()
const replies: OutMessage[] = handler.handle({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} })
  • handle(msg: Message): OutMessage[]: process one message; returns the messages to send back (a response for a request, notifications such as textDocument/publishDiagnostics for document events, or []).
  • get shouldExit(): boolean: true once an exit notification arrives.
  • get exitCode(): number: 0 if shutdown preceded exit, else 1.
  • doc(uri: string): string | undefined: current text of an open document (handy in tests).

class FrameCodec (server helper)

Exported from ts/src/lsp-server.ts. A byte-level Content-Length codec that drives an LspHandler; injectable write/onExit make it testable without real stdio. Most users want aontu lsp instead.

Go library

Import github.com/aontu-lang/aontu/go/lsp.

import "github.com/aontu-lang/aontu/go/lsp"

func Diagnostics(src string) []Diagnostic

Analyse one document and return its diagnostics (empty for valid documents). DiagnosticsVars(src, vars) adds $name bindings.

type Position struct { Line int `json:"line"`; Character int `json:"character"` }
type Range    struct { Start Position `json:"start"`; End Position `json:"end"` }
type Diagnostic struct {
    Range    Range  `json:"range"`
    Severity int    `json:"severity"` // SeverityError (1); SeverityHint (4) for "deprecated"
    Code     string `json:"code,omitempty"`
    Source   string `json:"source"`   // "aontu"
    Message  string `json:"message"`
    Tags     []int  `json:"tags,omitempty"` // LSP DiagnosticTag values; [2] marks "deprecated"
}
d := lsp.Diagnostics("a:1\na:2")
// d[0].Code == "scalar_value", d[0].Range.Start == {Line:1, Character:2}
lsp.Diagnostics("a:string") // len 0 (valid schema)

func Hover(src string, line, character int) *HoverResult

Resolve the value at a 0-based position, or nil. HoverResult is { Contents MarkupContent; Range *Range }. Built on the core (*aontu.Aontu).Spans(src) []aontu.ValueSpan, which lists positioned non-container values.

func Completions(src string) []CompletionItem

The completion list: the built-in functions, scalar-kind keywords and literals, which do not depend on src, and the alias names src binds, which do. CompletionItem is { Label string; Kind int; Detail string }. The function names come from the engine via aontu.BuiltinFuncNames(), and the alias names via aontu.AliasScope().

func Definition(src string, line, character int, uri string) *Location

Where the document binds the alias name at a 0-based position, or nil where the position is not on one, or names nothing the file binds. Location is { URI string; Range Range }.

type Handler

The transport-agnostic protocol state machine (mirrors LspHandler).

  • NewHandler() *Handler
  • (*Handler) Handle(m Message) []Out: process one message, return messages to send.
  • (*Handler) ShouldExit() bool, (*Handler) ExitCode() int, (*Handler) Doc(uri string) (string, bool).

Message/Out are JSON-RPC envelopes; Out marshals to a well-formed response (including an explicit result: null for shutdown).

Core support: func (*aontu.Aontu) Check(src string) []aontu.Problem

lsp.Diagnostics is built on aontu.Check, which lives in package aontu because it needs the engine’s internal error positions. Check parses and unifies src and returns every problem (it does not stop at the first, and does not treat non-concrete values as errors). Each Problem carries a source byte offset (Pos, or -1), the byte Len of the offending value’s canon, the error code (Why), and the Message. The lsp package converts byte offsets to LSP line/UTF-16 positions.

Protocol surface

textDocumentSync is Full (the client sends the whole document on each change). Advertised capabilities: textDocumentSync: 1, hoverProvider: true, definitionProvider: true, completionProvider: {}, signatureHelpProvider with the trigger characters ( and ,.

MethodKindBehaviour
initializerequestReplies with the advertised capabilities and serverInfo: { name: "aontu-lsp", version }.
initializednotificationIgnored.
textDocument/didOpennotificationStores the document, publishes diagnostics.
textDocument/didChangenotificationReplaces the document with the last content change (Full sync), publishes diagnostics.
textDocument/didClosenotificationDrops the document, publishes an empty diagnostic list (clears markers).
textDocument/hoverrequestReplies with a hover for the alias name at the position, else for the value there, else null.
textDocument/completionrequestReplies with the completion item list for the open document.
textDocument/definitionrequestReplies with the Location where the document binds the alias name at the position, or null.
textDocument/signatureHelprequestReplies with the signature of the call the position is inside, or null.
textDocument/publishDiagnosticsnotification (server→client)Carries { uri, diagnostics }.
shutdownrequestReplies result: null, arms a clean exit.
exitnotificationStops the server. Exit code 0 if shutdown came first, else 1.
any other requestrequestReplies with JSON-RPC error -32601 (method not found).
any other notificationnotificationIgnored.

Message flow

client → initialize                     server → result(capabilities)
client → initialized
client → didOpen(uri, text)             server → publishDiagnostics(uri, [...])
client → didChange(uri, newText)        server → publishDiagnostics(uri, [...])
client → didClose(uri)                  server → publishDiagnostics(uri, [])
client → shutdown                       server → result(null)
client → exit                           (process exits 0)

How diagnostics are computed

The analysis layer turns source into diagnostics in three steps:

  1. Unify. Parse and run the fixpoint unification over the whole document (in error-collecting mode; it never throws on conflicts).
  2. Walk for NilVals. Traverse the unified result tree and collect every NilVal node. The rule: in aontu a NilVal in the result is always a real error (a conflict, an unresolved reference, an unknown function, …). Valid-but-non-concrete values (scalar kinds like string, unresolved references, conjuncts) are not NilVals, so schemas and partial documents produce no diagnostics. Nodes are de-duplicated by identity.
  3. Map to LSP. Each NilVal carries a source position (1-based row/col in TS; a byte offset in Go) and the offending value’s canon. These become a 0-based LSP range whose end extends across the canon length. The message is the engine’s full error text (the [aontu/<code>] marker line, the hint, and the located source frames) identical in both languages.

A hard syntax error (which prevents producing a tree) is reported as a single diagnostic with code parse, positioned where the parser failed if that information is available, otherwise at the document start.

Positions use the LSP default encoding (UTF-16 code units per line): JavaScript strings are already UTF-16, and the Go library counts UTF-16 units explicitly, so a multi-byte character before an error does not shift the reported column.

Cross-implementation parity

The two servers are kept in lock-step:

  • Same capabilities and serverInfo.name (aontu-lsp).
  • Same diagnostics: the analysis is driven by the same engine, the NilVal-walk is identical, and the message text is constructed the same way (go/val.go NilVal.Message and ts/src/lsp.ts nilMessage), so for any given source both servers emit the same code, range, and message.
  • Same protocol behaviour, including result: null for shutdown and the 0/1 exit-code rule.

Both libraries are unit-tested (ts/test/lsp.test.ts, go/lsp/lsp_test.go) and each server has a transport round-trip test (go/lsp/serve_test.go, and the FrameCodec test in ts/test/lsp.test.ts).

Limitations and extension points

Current scope is diagnostics, hover, completion, go-to-definition on an alias name, and signature help. The layered design makes additions localised: most new features are implemented once in the analysis layer (layer 1) and advertised in initialize (layer 2):

  • Hover targets concrete values (scalars, kinds, references), not containers, and uses canon length to size the hit span on a single line; a value resolved from a reference is shown at its definition site. Hovering a multi-line container or the cursor exactly on a { brace may not resolve.
  • Completion has no cursor-to-path awareness: it offers the built-ins and the names the document binds, but not sibling keys. Adding key completion needs a position→path mapping in the analysis layer.
  • The alias features read the document’s text, not its tree, so they answer while a document is half-written and would not parse. The cost is that they know only what the text says: a name arriving through a destructure resolves to the pattern that takes it, which is where the file binds the name, rather than to the declaration in the other file. Following it across the boundary would mean the server reading that file itself, outside the include capability that governs every other read.
  • Go-to-definition on a key or path, cancellation, rename: not implemented; unknown requests get a -32601 reply.
  • Incremental sync: the server uses Full document sync for simplicity; range-based incremental edits could be added in the handler without touching the analysis layer.
  • Warnings/info severities: engine problems are all published as Error; the one exception is deprecated, at Hint severity with the Deprecated tag.
  • Number-canon edge cases: diagnostic ranges are sized by the offending value’s canon length; see Canonical form for the decimal subset that pins it.