@nx-lang/language-protocol
The contract between an editor and the NX language service: the shape of a language query and its
answer, the position encoding both sides use, and the NxLanguageService interface every client
implementation satisfies. Types, a few constants and guards, and the error classes; no runtime
dependency.
Implementations of the interface live elsewhere:
@nx-lang/language-client— an HTTP implementation for browsers, talking to@nx-lang/language-http— a mountable Node handler that answers queries through@nx-lang/sdk-node.
An editor integration such as @nx-lang/monaco depends on this package alone, so an HTTP-backed,
worker-backed, or in-process service is interchangeable.
The document-set model
Every query carries the complete set of documents it is to be answered from, plus the URI of the document the query is about:
interface LanguageQuery {
documents: { uri: string; source: string; version?: number; identity?: string }[];
uri: string; // one of documents[].uri
}
The service keeps nothing between queries. Two identical queries produce identical answers whatever was asked in between, which is what lets several server instances answer without session affinity. A single-document host sends its one document; a host editing one file of a set that import each other sends the whole set, with the edited document carrying the editor's unsaved text, and completions include what the edited document imports from its siblings.
version is whatever the editor counts; it is echoed back in the answer so a client can discard an
answer that arrives after a newer version was sent. identity is the workspace identity NX resolves
imports by (tenant/form.nx); it is derived from the URI when omitted.
Positions are UTF-16 code units
A TextPosition is a zero-based line and a zero-based character counted in UTF-16 code units
from the line start, the way String.prototype.length counts and the way Monaco, CodeMirror and the
Language Server Protocol default count. An emoji earlier on the line is two units. A range carries
its start and end positions and, alongside them, startByte and endByte offsets into the
document's UTF-8 text, which is what the language service measured.
// let s = "😀" + name
// ^ name starts at character 15 (the emoji is 2 units) and byte 18 (it is 4 bytes)
{ start: { line: 0, character: 15 }, end: { line: 0, character: 19 }, startByte: 18, endByte: 22 }
Queries
Five queries are defined; each has a request and an answer type that round-trip through JSON. The
examples below are compiled in test/examples.test.ts, so they cannot drift from the types.
hover
const request: HoverRequest = {
documents,
uri: "nx://tenant/form.nx",
position: { line: 1, character: 3 },
};
const answer: Hover | null = {
uri: "nx://tenant/form.nx",
identity: "tenant/form.nx",
version: 7,
range: { start: { line: 1, character: 1 }, end: { line: 1, character: 7 }, startByte: 34, endByte: 40 },
contents: "```nx\n<Button label:string />\n```",
};
contents is markdown with NX fragments in ```nx fences. When the service has nothing to
say the answer is null, not an error.
completions
const request: CompletionsRequest = {
documents,
uri: "nx://tenant/form.nx",
position: { line: 1, character: 23 },
};
const answer: CompletionList = {
uri: "nx://tenant/form.nx",
identity: "tenant/form.nx",
version: 7,
items: [{ label: "label", kind: "Property", detail: "label:string", documentation: "The caption." }],
};
kind is one of Keyword, Type, Declaration, Component, Property, Member.
documentation is the /// documentation of what the item names, as markdown, and is absent when
it has none.
diagnostics
const request: DiagnosticsRequest = { documents, uri: "nx://tenant/form.nx" };
const answer: DiagnosticReport = {
documents: [
{ uri: "nx://tenant/ui.nx", identity: "tenant/ui.nx", version: 3, diagnostics: [] },
{
uri: "nx://tenant/form.nx",
identity: "tenant/form.nx",
version: 7,
diagnostics: [
{
range: { start: { line: 1, character: 8 }, end: { line: 1, character: 13 }, startByte: 41, endByte: 46 },
severity: "Error",
code: "type-mismatch",
message: "Expected string, found int",
related: [],
},
],
},
],
workspace: [],
};
The report covers every document in the set, one entry each, so a multi-document host can set
markers on all of them from one answer. workspace holds diagnostics that belong to no document,
such as an import of a module that is in neither the set nor a library. severity is one of
Error, Warning, Info, Hint.
documentSymbols
const request: DocumentSymbolsRequest = { documents, uri: "nx://tenant/ui.nx" };
const answer: DocumentSymbol[] = [
{
name: "Button",
kind: "Component",
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 47 }, startByte: 0, endByte: 47 },
selectionRange: { start: { line: 0, character: 12 }, end: { line: 0, character: 18 }, startByte: 12, endByte: 18 },
},
];
kind is one of Function, Value, TypeAlias, Record, Action, Union, Component,
Element.
sourceTree (unstable)
Unstable: the answer's shape may change in any release until a later one commits to it. It is published for the NX viewer and a structural change view to build on.
const request: SourceTreeRequest = { documents, uri: "nx://tenant/ui.nx" };
const answer: SourceTree = {
uri: "nx://tenant/ui.nx",
identity: "tenant/ui.nx",
version: 3,
nodes: [
{ role: "declaration", range: range(0, 47), key: "Button", name: "Button", declaration: 0, flags: ["export"] },
{ role: "parameter", range: range(19, 31), parent: 0, key: "Button.label", name: "label" },
{ role: "typeReference", range: range(25, 31), parent: 1, key: "Button.label.type", value: "string" },
{ role: "element", range: range(37, 47), parent: 0, key: "Button.body", name: "button", type: "button" },
],
declarations: [
{ module: "tenant/ui.nx", name: "Button", kind: "function", range: range(0, 47), properties: [{ name: "label", type: "string" }] },
],
};
(range(start, end) stands for the EditorRange of those bytes on line 0.)
Every piece of the document is a node, comments and regions that did not parse included, and every
token belongs to exactly one node: the smallest whose range holds it. Nodes are listed in order of
start offset, each parent before its children, with the index of the parent. A node has a role
(element, attribute, literal, case, operator, condition, comment, unparsed and the
rest of SourceRole), and carries what it says as written in name, value and textType, its
type spelled in NX in type when the type checker gave it one, and in declaration the index of
the declaration it refers to. A node's type and declaration are what hover reports inside it.
key is the node's path within the document, such as roleQuestion.value.choices[2].label: the
top-level declaration's name, then the attributes, members, arms and slots on the way, and positions
for items. An edit to another declaration or another attribute does not change it, so two versions
of a document can be compared by key. A key is an identifier to compare for equality, not a path to
split: an arm is named by its patterns or test as written (is Mode.dark, when x > 1) and an
import by its path, and those may hold dots and spaces. Read the tree's structure from parent.
declarations lists once each declaration a node refers to, from this document, another document
of the set or a library: its module identity, name and kind, its range when this document declares
it, its doc comment, its properties in declaration order with inherited ones first (each with its
type, its default as written, and whether it is optional, content or inherited), its bases, a
component's state, a union's cases, an alias's type, and a value's type and the range of its value.
Optional properties of a node or a declaration are absent rather than null when they do not apply.
A request with visualizers: {} (or visualizers: { modules: [...] }, naming further visualizer
modules the host supplies) also resolves visualizer declarations: each declaration that has one
carries a visualizer, with its title, id and icon, optional preview: { renderer: string }, its properties in the order to show them, and,
for each property or alias, a format and an editor. editor holds the editor hints that fit the
type: kind (field, dropdown or segmented), min, max, step, multiline,
placeholder and choices. Each choice's value is typed as a sourceEdit setValue value, so a
client passes it on as it is to write the choice:
const sizeProperty: SourcePropertyVisualizer = {
name: "size",
editor: {
max: 96,
choices: [
{ value: { kind: "number", text: "12" }, label: "sm" },
{ value: { kind: "number", text: "16" }, label: "base" },
],
},
};
sourceEdit (unstable)
Unstable: the request's and the answer's shapes may change in any release until a later one commits to them. It is published for the NX viewer's Visual editing to build on.
const request: SourceEditRequest = {
documents,
uri: "nx://tenant/ui.nx",
key: "Button.label.type",
edit: { kind: "setValue", value: { kind: "string", value: "Save" } },
};
const answer: SourceEditAnswer = { edits: [{ range: range(25, 31), newText: '"Save"' }] };
const refused: SourceEditAnswer = { edits: [], refused: "notEditable" };
The request names a node of the document's source tree by its key and one change: setValue
with a typed value (string, number as typed, boolean, or case by name) for a literal or a
bare union case that is a whole value, setText with one line of text for a run of text content,
remove for an attribute or for an element that is an item of a list, addProperty with a
property's name and a typed value for an element, which writes the attribute after the element's
last one, in its layout, or move with a position to for an element that is an item of a list. An
item of a list is an element in a braced sequence or in an element's content, not in an if branch
or a for body, in a list that holds no text; to is the position it takes once moved, counted as
the list's keys count its items. In a list written an item to a line, an item moves or goes with its
whole lines, the comments directly above it, and the blank lines that separate it from the next item
(or the one before, for the last); in a list on one line, with the space beside it. Other layouts are
refused notEditable, and a to outside the list invalidValue. The answer's edits make
the change: every range is in the document as sent and no two overlap, so a host applies them together; the service writes the spelling (quotes, escapes, kept braces, an
attribute's whole line). A change it cannot make answers no edits and refused: unknownNode,
notEditable or invalidValue. A change that would leave the text as it is answers no edits and no
refusal.
Reserved names
definition, references, rename, signatureHelp, inlayHints and semanticTokens are
reserved. An implementation that receives one answers with the unsupported-query error rather
than a transport failure or a malformed answer, so a client can tell "not yet" from "broken".
isLanguageQueryName and isReservedLanguageQueryName classify a name.
The service interface
interface NxLanguageService {
hover(request: HoverRequest, signal?: AbortSignal): Promise<Hover | null>;
completions(request: CompletionsRequest, signal?: AbortSignal): Promise<CompletionList>;
diagnostics(request: DiagnosticsRequest, signal?: AbortSignal): Promise<DiagnosticReport>;
documentSymbols(request: DocumentSymbolsRequest, signal?: AbortSignal): Promise<DocumentSymbol[]>;
sourceTree(request: SourceTreeRequest, signal?: AbortSignal): Promise<SourceTree>; // unstable
sourceEdit(request: SourceEditRequest, signal?: AbortSignal): Promise<SourceEditAnswer>; // unstable
}
Cancelling signal before the answer arrives rejects the promise with an abort error, which
isAbortError recognizes, and the promise never resolves afterwards. An editor integration is
tested against an in-process fake of this interface and behaves the same with the HTTP client for
identical answers.
Errors
| Class | When |
|---|---|
UnsupportedQueryError | The service recognized the query name but does not support it. Carries query. |
LanguageServiceHttpError | An HTTP implementation received a non-success status. Carries status, url, a bodyExcerpt, and the body's code when it was an error body. |
LanguageServiceTimeoutError | The request did not answer within the client's timeout; the request was aborted. Carries timeoutMs. |
An HTTP implementation answers a failed query with the JSON body
{ error: { code: LanguageServiceErrorCode; message: string; query?: string } }
where code is one of unsupported-query, unknown-query, method-not-allowed,
invalid-request, payload-too-large, internal-error. isLanguageServiceErrorBody recognizes
the shape.
Source-tree callers can set visualizers: { includeUnreferencedDeclarations: true } to include
loaded visualizer targets and inherited presentation metadata even when the entry source does not
reference them directly. The default keeps the declaration table bounded to the source tree.
Unknown preview renderer IDs remain metadata; hosts activate only their supported implementations.