---
title: "aontu Language Server (LSP)"
description: "The aontu-lsp language server: diagnostics, hover and completion, how to wire it into an editor, and the reusable library API."
source: "https://aontu.dev/docs/lsp/"
---

# aontu Language Server (LSP)

Rendered from [`docs/lsp.md`](https://github.com/aontu-lang/aontu/blob/main/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](https://microsoft.github.io/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](https://aontu.dev/how-to/wire-your-editor) guide.

-   [What it does](#what-it-does)
-   [Architecture: library vs. server](#architecture-library-vs-server)
-   [Running the server](#running-the-server)
-   [Editor configuration](#editor-configuration)
-   [Library API](#library-api)
    -   [TypeScript](#typescript-library)
    -   [Go](#go-library)
-   [Protocol surface](#protocol-surface)
-   [How diagnostics are computed](#how-diagnostics-are-computed)
-   [Cross-implementation parity](#cross-implementation-parity)
-   [Limitations and extension points](#limitations-and-extension-points)

## 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:

| Source | Diagnostic? |
| --- | --- |
| `a:1 a:2` | yes: `scalar_value` conflict |
| `a:1 & string` | yes: `no_scalar_unify` |
| `x:foo(1)` | yes: `unknown_function` |
| `a:$.missing` | yes: `no_path` |
| `b: refer() & path("$.nope")` | yes: `refer_unresolved` (a `rel()` member naming no entity: `rel_unresolved`) |
| `a:string` | **no**: a non-concrete schema is valid |
| `a:{b:string, c:1}` | **no**: partial/constraint documents are valid |
| `port:*8080 | integer` | **no**: defaults and disjunctions are valid |
| `a: $.a` | **no**: 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](#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.

```plaintext
┌─────────────────────────────────────────────────────────────┐
│ 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)│
└─────────────────────────────────────────────────────────────┘
```

| Layer | TypeScript | Go |
| --- | --- | --- |
| 1\. Analysis | `computeDiagnostics(src)` in `ts/src/lsp.ts` | `lsp.Diagnostics(src)` in `go/lsp/lsp.go` (over `aontu.Check`) |
| 2\. Handler | `LspHandler` in `ts/src/lsp.ts` | `lsp.Handler` in `go/lsp/handler.go` |
| 3\. Server | `ts/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.Out`s 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:

```sh
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/`](https://github.com/aontu-lang/aontu/blob/main/editors/), and the manual wiring recipes for VS Code, Neovim and any other LSP client have moved to the [wire your editor](https://aontu.dev/how-to/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`).

```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:

```ts
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"
}
```

```ts
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.

````ts
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.

```ts
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`.

```go
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.

```go
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"
}
```

```go
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 `,`.

| Method | Kind | Behaviour |
| --- | --- | --- |
| `initialize` | request | Replies with the advertised capabilities and `serverInfo: { name: "aontu-lsp", version }`. |
| `initialized` | notification | Ignored. |
| `textDocument/didOpen` | notification | Stores the document, publishes diagnostics. |
| `textDocument/didChange` | notification | Replaces the document with the last content change (Full sync), publishes diagnostics. |
| `textDocument/didClose` | notification | Drops the document, publishes an empty diagnostic list (clears markers). |
| `textDocument/hover` | request | Replies with a hover for the alias name at the position, else for the value there, else `null`. |
| `textDocument/completion` | request | Replies with the completion item list for the open document. |
| `textDocument/definition` | request | Replies with the `Location` where the document binds the alias name at the position, or `null`. |
| `textDocument/signatureHelp` | request | Replies with the signature of the call the position is inside, or `null`. |
| `textDocument/publishDiagnostics` | notification (server→client) | Carries `{ uri, diagnostics }`. |
| `shutdown` | request | Replies `result: null`, arms a clean exit. |
| `exit` | notification | Stops the server. Exit code `0` if `shutdown` came first, else `1`. |
| any other request | request | Replies with JSON-RPC error `-32601` (method not found). |
| any other notification | notification | Ignored. |

### Message flow

```plaintext
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 `NilVal`s.** 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** `NilVal`s, 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](https://aontu.dev/docs/reference-language#canonical-form) for the decimal subset that pins it.
