-
Notifications
You must be signed in to change notification settings - Fork 1
Embedding
Mirror of
docs/EMBEDDING.md, the canonical copy for the published 0.5.0 release.
How to run the px-lsp language server (@px-lsp/server) inside a desktop
application that already knows where the game, the logs and the mod live.
The audience is the person wiring the server into a host application, not an editor user. If you are configuring neovim or another editor, read server README instead: it covers filetypes, root markers and the setup failure modes. This document covers the process contract, the initialization options an application should send, the wire surface beyond standard LSP, and what deliberately does not exist for a non-VS Code client.
The wire types are the contract. TypeScript hosts should import them from
@px-lsp/protocol/protocol; everyone else reads Protocol Reference, which
mirrors that file method by method.
Send each unsaved script or localization change through textDocument/didChange with a new version before requesting language features. The server refreshes definitions and script references from that buffer; closing it restores the saved file. File watcher events update closed files and invalidate cached dependency searches. They do not replace an open buffer.
Definition kinds have independent names and override chains. A localization key and a trait may share a name, as may a scripted GUI and a scripted trigger. Navigation uses the type required at the cursor and still returns the mod, parent and vanilla declarations of that type.
Declare workspace.workspaceEdit.documentChanges: true in standard LSP capabilities if the host can enforce document versions when applying rename. The server supplies versions for open files and null for closed files. Without that capability, rename requires open sources to match disk and returns plain changes. Show rename errors to the user; do not apply a partial edit after an error. See the rename contract for unsupported and ambiguous cases.
The server is one Node process speaking JSON-RPC 2.0 with LSP framing. It picks
its transport from process.argv, so the transport is a command-line argument,
not an API call:
| Argument | Transport |
|---|---|
--stdio |
stdin/stdout. What an embedding application uses. |
--node-ipc |
Node's parent/child IPC channel. Requires child_process.fork. |
--socket=<port> (or --socket <port>) |
TCP: the server connects out to 127.0.0.1:<port>, so the host listens. |
--pipe=<name> (or --pipe <name>) |
Named pipe / unix domain socket, same direction. |
With none of them, the server defaults to --stdio (unless it was forked over
node IPC, which it detects from process.send), so a bare px-lsp behaves
like px-lsp --stdio. px-lsp --version prints the server version and exits
without a handshake, for install scripts and health checks.
node /path/to/px-lsp-server-<version>/dist/server.js --stdio
The Windows zip ships px-lsp.cmd, which is exactly that line against the
bundled node.exe with every path resolved from %~dp0, and forwards any
extra arguments. Point your host at the .cmd and pass nothing.
Heap ceiling. The definition index is the dominant allocation and it is
large by design: measured at roughly 924 bytes per definition, about 408 MB for
a full vanilla scan, with peak during the scan around 1.5x that, plus the whole
definition set of every extra root (workspace mods and the parentPaths submod
chain). Node sizes its default old space from system RAM, which lands near 2 GB
on an 8 GB machine, inside crash range for a total conversion on top of a
framework parent. The VS Code client therefore forks the server with
--max-old-space-size: 4 GB, cut toward half of physical RAM on a small
machine but never below 2 GB (packages/vscode/src/serverHeap.ts). Do the
same:
- spawning
nodeyourself: pass--max-old-space-size=4096before the script path; - launching
px-lsp.cmd: setNODE_OPTIONS=--max-old-space-size=4096in the child environment, since the launcher's forwarded arguments reach the script, not the runtime.
vscode-languageserver installs a watchdog for you, but only if you tell it
which process to watch. Send your own pid as processId in the initialize
params (or pass --clientProcessId <pid> on the command line). The server then
polls that pid every 3 seconds with a null signal and exits as soon as it
disappears, with code 0 if shutdown had been received and 1 otherwise.
Send it. A host that crashes without it leaves an orphaned server holding its index, and the next launch adds another one.
Over --stdio there is a second safety net: an end or close on stdin exits
the process the same way. It fires when the pipe is actually torn down, which a
hard-killed parent on Windows does not always do, so it is a backstop and not a
replacement for processId.
Over --stdio, stdout carries protocol frames and nothing else. The server
source writes to neither console.log nor process.stdout; its own logging
goes through connection.console.log, which is a window/logMessage
notification. On top of that, vscode-languageserver replaces the whole
console.* family with connection-routed logging when the transport is stdio,
so even a stray console.log from a dependency arrives as a log notification
rather than as corruption in the middle of a JSON-RPC frame.
stderr is not part of the contract. Node's own warnings and an unhandled exception trace land there. Capture it into your host's log, do not parse it.
Everything the server knows about its own health is mirrored to
window/logMessage: a startup line naming the resolved bundled-data folder (or
its absence) and status: lines with token and definition counts on indexing
transitions. Surface that channel somewhere reachable. "Completion is empty" is
answered by those lines and by almost nothing else.
The standard LSP sequence, and it is worth following exactly:
-
shutdownrequest, await the response; -
exitnotification; - the process exits 0.
Skipping the shutdown request and sending only exit also terminates the
server, but with exit code 1, because an exit that was never announced is
indistinguishable from a crash. If your host treats a non-zero exit as an error
worth reporting, that is where the spurious report comes from.
An editor plugin has to discover the mod root from the workspace. An application usually knows it already, and can say so:
That is the whole useful minimum. Every field is optional and the server has fail-soft fallbacks for all of them, but each one you omit costs something concrete:
-
gameIdpicks the game profile ("ck3"default,"vic3","eu5"). There is no auto-detection outside VS Code, so set it explicitly for anything but CK3. One server instance serves one game; changing it later throughparadox/configChangedtriggers a full reload. -
gamePathis the game'sgame/folder, the source of vanilla definitions, asset paths and override detection. Without it the index knows only the mod. -
logsPathis the folder holding the user'sscript_docsdumps. CK3 and Vic3 ship bundled fallbacks (wiki tables, dump snapshots), so there it is an exact-version upgrade; EU5 ships only a data-type snapshot so far, so for engine tokens it is the difference between working completion and a thin index of the user's own definitions. Vic3/EU5 write script_docs toDocuments/.../docs; the data-type dump lands underlogs/and the server probes the siblinglogs/folder of a docs-stylelogsPathautomatically. -
modPathis the mod root. A nonempty value and anyworkspaceModstake precedence over the workspace fallback. When both are empty, the server uses the first initialization workspace folder, orrootUriwhen no folder is supplied. The fallback survives later settings updates. SendingmodPath: nullwithworkspaceMods: []restores it. Without any root, features that require a known mod stay silent. -
workspaceModsare the roots being edited. Listing a root here is what upgrades it from a plain definition scan to reference indexing plus reference diagnostics.modPathitself always gets that treatment, so a single-mod host can sendmodPathalone (settingworkspaceModsto[modPath]is equivalent) — which is also why a bare editor client whose workspace root becomes the fallbackmodPathstill gets reference diagnostics. Read-only dependency mods go inparentPathsinstead, base first, in load order. -
locLanguageselects the localization language for inlay previews and coverage.
Set indexAssets: false to skip graphics .asset definition and reference indexing in all roots. It defaults to true for profiles that support .asset files. Send the changed settings through paradox/configChanged to rebuild the index without a restart. This does not disable open-file syntax features or on-demand texture previews.
Set texturePreviewBackground to checkerboard (the default), dark, light, or a six-digit hex color to choose the background behind texture hover images. Invalid values use checkerboard. The background is part of the generated thumbnail, so it works with ordinary Markdown image rendering. A settings update changes subsequent hovers without an index rebuild. VS Code uses the same workspace choice for its DDS editors; source files and exported PNGs retain their transparency.
Declare the supported completion documentation formats in textDocument.completion.completionItem.documentationFormat, in preference order. Omit it or send ["plaintext"] for plain insertion previews and value hints. The exported snippet catalogue keeps its explicitly documented Markdown format regardless of these capabilities.
Set completionMode to minimal (the default), examples, or names to control ordinary script keyword insertion. Minimal adds the documented operator and blank values and the fields from valid documented examples or scripted parameters, omitting fields marked optional. Examples restores the documented example values and scripted-call parameters. Names inserts only the keyword. Explicit definition templates and the paradox/snippets catalogue retain their full templates in every mode. The standard snippetSupport capability still decides whether inserts carry tabstops or plain text. This setting applies through paradox/configChanged without an index rebuild. Resolving a completion adds an insertion preview and expected-value descriptions from the token documentation or the definition's @param tags. Example values are not treated as confirmed datatypes. These hints are documentation only; insertText is unchanged.
The remaining settings (parentPaths, scopeInlayHints, diagnosticsIgnore, diagnosticsIgnorePatterns, diagnosticsVanilla) are documented in Protocol Reference. Existing hosts can continue to send the whole settings object through paradox/configChanged; omitted fields return to defaults.
Event localization value completion can propose a key before its localization entry exists. CK3 uses <event ID>.t, .desc, and successive option suffixes .a through .z; Victoria 3 uses .t, .d, .f, and the same option suffixes. The current unsaved document supplies the event ID and option order. Proposed keys are labelled as new, existing keys keep their definition information, and accepting a suggestion inserts only the reference. The user still supplies its localization text. No new-key convention is assumed for EU5.
For standard LSP updates, send a partial settings object under pxLsp. This example changes hover detail without clearing roots, changing the selected game, or rebuilding the index:
{ "jsonrpc": "2.0", "method": "workspace/didChangeConfiguration", "params": { "settings": { "pxLsp": { "hoverDetail": "compact" } } } }Standard updates retain omitted fields and replace supplied arrays in full. gamePath, logsPath, and modPath accept null to clear a configured path before root fallback is applied. calendar: null clears the configured calendar. Invalid texturePreviewBackground values use checkerboard. Unrelated sections and other malformed updates are ignored. The pxLsp shape is Partial<ParadoxSettings>, separate from the VS Code extension's native px configuration.
If the host advertises capabilities.workspace.configuration: true, answer workspace/configuration requests for section pxLsp with [partialSettings]. The server requests it after initialized, before its first build, using the initialization workspace URI as scopeUri when available. Returned fields patch initialization settings; absent/null results and request failures preserve them. Send an empty or null didChangeConfiguration.settings to request another pull. Late responses cannot overwrite a newer push, custom update, or pull. The server dynamically registers configuration notifications only when workspace.didChangeConfiguration.dynamicRegistration is true. Hosts without these capabilities can continue to use initialization options and pushed updates.
Include <mod>/<configDir>/schema.json and playset.json in a host-owned file watcher, including the profile's legacy config directory where supported. Send creation, change, and deletion events through workspace/didChangeWatchedFiles or paradox/modFileChanged. The server reloads the schema, dependency roots, and dependent data in one debounced rebuild for the burst. Its dynamically registered file watcher includes these files automatically.
A playset reload does not extend the client's watched roots. Hosts must also watch dependency files outside their existing watched folders to report later edits in those dependencies.
storageDir is where the server caches parsed script_docs and the harvested
data-function usage tables, per game (the filenames carry a per-profile
suffix, so several games can share one directory).
Unset, it defaults to <os tmpdir>/px-lsp. That works and it is what a bare
editor client gets, but it is the wrong choice for an application: temp
directories get swept, so the first launch after a cleanup pays the full parse
again for no reason. Point it at your host's own per-user data directory (the
VS Code client uses the extension's global storage path).
Create that directory yourself. The server only creates the tmpdir default, and every cache write swallows its own failure, so a path that does not exist costs you the cache silently instead of raising anything.
The initialize result carries standard LSP serverInfo:
{ "name": "px-lsp", "version": "0.3.0" }version is the @px-lsp/server package version, read from the manifest that
ships with the bundle, so it cannot drift from the artifact you unpacked. Log
it, and gate any feature you added against a version check on it rather than
against the presence of a method.
What the server emits is tailored per capability, and a client declares exactly what it implements:
interface ParadoxClientCapabilities {
hoverHtml?: boolean; // renders the sanitized <span style="color:var(--vscode-*)"> hover markup
commands?: string[]; // the px.* command ids this client actually registers
ownFileWatcher?: boolean; // client watches the mod tree and pushes paradox/modFileChanged
fileLinks?: boolean; // hover renderer navigates file: links
hoverIcons?: boolean; // client sets supportThemeIcons, so hover badges may use $(codicon) glyphs
}Every field is independent and defaults to off, which is the honest default
for an embedder: send only what you have built. The combinations are real, not
theoretical. A host with a custom hover renderer can take hoverHtml and list
zero commands. A host that registers px.showReferences but not the
localization commands lists just that one, and the localization quick fix
arrives as a real WorkspaceEdit instead of a command it could not run.
clientCommands: boolean is the deprecated predecessor and should not be
used in new code. It conflated three unrelated questions behind one "is this VS
Code" switch. It still works: true means
{ hoverHtml: true, commands: <every id>, ownFileWatcher: true, fileLinks: true } plus snippet support, false or absent means all-off, and client
wins when both are sent.
One more capability matters here and is NOT part of this object, because
standard LSP already carries it: snippet support. Declare
textDocument.completion.completionItem.snippetSupport: true in the
initialize capabilities if your editor expands ${1:…} tabstops.
The full set an embedder should consider, and what each one buys:
| Declare | If your client | Without it |
|---|---|---|
snippetSupport (standard LSP) |
expands ${1:…} tabstops in completion inserts |
completion inserts are plain-text skeletons: same block shape, no tabstops, never a literal ${
|
client.hoverHtml |
renders the sanitized <span style="color:var(--vscode-*)"> hover markup |
hover cards are plain markdown; the span content is self-sufficient text, so nothing is lost but color |
client.commands |
registers some/all px.* commands |
command-link affordances degrade per id: the localization quick fix becomes a plain WorkspaceEdit, the hover reference-count line is dropped |
client.fileLinks |
navigates file: links from hover markdown |
every hover location line (provenance, set sites, define/gui/format sources, datafunction examples, texture paths) renders as plain text instead of a dead link |
client.ownFileWatcher |
watches the mod tree and pushes paradox/modFileChanged
|
the server registers its own didChangeWatchedFiles watcher (needs dynamic registration) |
The concrete per-capability behavior (what the hover looks like without
hoverHtml, which quick fix replaces which) is the "Degraded modes" section of
Protocol Reference.
Two bundled assets are read from disk at runtime rather than compiled into the
bundle: the wiki token mirror (wikidocs/, CK3 only) and the completion
frequency table (freqs.json). By default the server looks for them in
data/<gameId>/ next to its own dist/server.js, which is the layout both
release artifacts ship.
If your installer puts the data somewhere else, send dataDir: the directory
that contains the per-game folders, not one of them.
"initializationOptions": {
"dataDir": "C:/Program Files/YourApp/resources/px-lsp-data"
// the server reads <dataDir>/ck3/wikidocs/ and <dataDir>/ck3/freqs.json
}Both files resolve independently under <dataDir>/<gameId>/, and the whole
path is re-derived when paradox/configChanged switches the game, so the
override stays profile-correct. A <gameId>/ folder holding only one of the
two assets, or missing entirely, is a supported state and not an error.
CK3 and Victoria 3 ship script-doc snapshots, and all three games ship data-type dump snapshots. User-generated dumps take priority; a missing bundle remains a supported state.
wikidocsDir is the deprecated predecessor. It overrides the wikidocs/
folder alone, leaves freqs.json on the bundle root, and does not follow a
gameId change. Send dataDir.
Send file: URIs. The server converts them with
vscode-uri, and on Windows that
lowercases the drive letter: file:///F:/mods/my_mod/events/a.txt becomes
f:\mods\my_mod\events\a.txt.
Two consequences worth building in from the start:
- compare paths case-insensitively on Windows whenever you match a server answer against a path of your own (the server does this internally for its own root matching);
- treat the URI string, not the derived path, as the document identity. Echo back exactly the URI the server sent you in a location or diagnostic, and exactly the URI you opened the document with in every subsequent request. A URI that differs only in drive-letter case is a different open document, and the request will find nothing.
The server declares TextDocumentSyncKind.Incremental, but that is the maximum
it accepts, not a requirement. A textDocument/didChange whose content change
carries no range is a full-document replacement, and it is legal under
Incremental sync. The document store applies both forms.
An embedder is often better off sending the full text. The host's editor buffer rarely produces LSP-shaped incremental deltas for free, and hand-computing ranges is the classic source of client/server desync, which shows up much later as offsets that drift by a few characters. The server re-parses the whole document per version either way: every position feature shares one cached parse per document version rather than re-scanning per request, so the saving from an incremental delta is the text splice alone.
Bump version on every change, monotonically. The parse cache is keyed by
URI plus version. Reusing a version with different text serves the previous
parse, and the symptom is completion and diagnostics answering for text the
user no longer has.
Also send textDocument/didSave. The server declares save: true and uses it
for more than re-validation (see below).
The server reports missing-bom as an Error for localization files and a Warning for mod script .txt files. The script warning applies only inside an editable workspace mod. It does not look for the BOM in your buffer text: editors routinely strip U+FEFF when they read a file. The server reads the first three bytes on disk on didOpen and again on didSave.
Encoding fixes belong to the host editor. VS Code supplies a local quick fix that opens its native encoding picker for the affected document; other hosts should offer their own save-encoding control. A BOM-only save clears the diagnostic even when the document version has not changed.
A host whose buffers have the BOM stripped therefore needs to do nothing special, which is the point. Two things follow:
- an unsaved or unreadable file yields "unknown", and the check is skipped rather than guessed at, so a brand new buffer never gets a false BOM error;
- if your host writes files itself, send
didSaveafter the write. Otherwise the server keeps the BOM state it read at open time and the diagnostic disagrees with what is now on disk.
Beyond standard LSP the server answers a set of custom requests. A plain client
can ignore all of them. Full payload shapes are in Protocol Reference and
packages/protocol/src/protocol.ts.
| If you are building | Wire these |
|---|---|
| Anything at all |
paradox/configChanged (push settings without a restart), paradox/status and paradox/indexChanged (server to client; index health and a re-query signal) |
| A status bar or an index panel |
paradox/status, paradox/indexStats, paradox/reloadDocs (reload both script docs and data types after the user generates them) |
| A scope indicator |
paradox/scopeAt (see below) |
| An “insert a definition” command or palette |
paradox/snippets (see below) |
| Localization tooling |
paradox/lookupLoc, paradox/locCoverage
|
| Mod-wide reports |
paradox/modOverview (content inventory), paradox/overrides (what shadows vanilla, with the LIOS/FIOS winner) |
| An impact view for one definition |
paradox/dependencies (dependents and dependencies, by cursor or by name) |
| An event browser or graph |
paradox/eventGraph, paradox/eventDetail
|
A .gui designer |
paradox/guiTree, paradox/guiLayout, paradox/guiSourceEdit (paradox/guiWidgetEdit is the deprecated position/size half of the last one) |
| Your own file watcher | declare client.ownFileWatcher and push paradox/modFileChanged per changed file |
The mod-scoped requests (modOverview, locCoverage, overrides) take
{ modRoot?: string | null }: one workspace mod by absolute root path, or
absent for all of them.
For data health, show the script-doc and data-type sources separately. tokensFromScriptDocs && !tokensFromBundledDumps means a generated script dump is loaded. StatusPayload.dataTypesSource reports generated, bundled or none; absent means the server does not report it. The optional status in paradox/reloadDocs is the refreshed status, also sent through paradox/status. Recommend generating both dumps after game patches, then reloading. The status identifies their source, not their age.
When opening a specific indexed definition, pass its source as file (absolute path or file URI) to paradox/eventDetail alongside id, or to paradox/definitionForm alongside kind and name. This avoids selecting another mod's definition with the same ID. An exact source that cannot be loaded returns null for event detail or leaves the form's current absent. Omitting file preserves the existing lookup behavior.
For a localization row in a specific language, pass language with key to paradox/lookupLoc. Omitting it uses the configured completion language. An explicit identifier uses lowercase letters and underscores; missing translations and invalid identifiers return an empty list. The request includes open unsaved text and does not change the completion language.
The server never writes a file. A designer gesture goes out as an op and comes back as offsets into the text you sent, which YOU apply: your editor keeps undo, dirty state and the live preview loop. Send the buffer's current text with every request and apply the edits to that same text, end-first.
// -> paradox/guiSourceEdit
{ "uri": "file:///d%3A/mods/my_mod/gui/window_my.gui",
"text": "window = {\n\tname = \"my_window\"\n}\n",
"op": { "kind": "setProperties", "line": 0,
"properties": [{ "key": "size", "value": "{ 320 200 }" }] } }
// <- { "edits": [{ "start": 31, "end": 31, "newText": "\tsize = { 320 200 }\n" }] }The other ops are reorder, insert, insertRaw (paste), delete,
duplicate, wrap and blockText (read-only, for a clipboard); a widget is
addressed by the 0-based line of its own statement, the line that
paradox/guiLayout reports for it.
Expect { "refused": "…" } instead of edits and show the string: it is the
server saying the gesture would not do what it looks like it does (a box owns
its children's slots, a content-sized container ignores an explicit size, a
type definition is used by other files). A write that lands but is only half
honoured returns edits plus a warning.
Everything the server can offer to insert at a cursor, so a host can build an "Insert Snippet" command without shipping a snippet table of its own. Request a position in an open script document:
// -> paradox/snippets
{ "uri": "file:///d%3A/mods/my_mod/events/my_events.txt",
"position": { "line": 0, "character": 0 } }// <- result (abbreviated; the strings carry real newlines and tabs)
{
"snippets": [
{ "id": "event", "label": "new event", "form": "definition",
"detail": "skeleton measured over 9,791 vanilla definitions",
"snippet": "namespace = ${1:my_namespace}\n\n${1:my_namespace}.${2:1} = {\n\ttype = ${3|character_event,activity_event,letter_event,court_event|}\n\t…\n}",
"plain": "namespace = my_namespace\n\nmy_namespace.1 = {\n\ttype = character_event\n\t…\n}" },
{ "id": "event.option", "label": "option block", "form": "block", "…": "…" },
{ "id": "if", "label": "if", "form": "token", "…": "…" }
]
}Notes worth reading before you draw it:
-
Both insert forms always ship. Use
snippetonly if you expand${1:…}tabstops;plainis the same shape with the placeholder text written out and is guaranteed free of${. This mirrors thesnippetSupportgate on completion inserts. -
formgroups the list: onedefinition(the document folder's own kind), then itsblockchildren, thentokenentries for the engine triggers/effects legal in the block the cursor sits in, frequency-ordered and capped at 60. -
The definition entry already knows about the file it lands in. It writes
the
namespace =header only when the document declares none, and reuses the namespace the document already has when it does. - An empty list is a normal answer, not an error: the document is not an open script document, or its folder maps to no definition kind, or nobody has measured that game's vanilla files yet.
The scope inference that ranks completion and annotates hovers, exposed structurally so a host can render it. Request a position in an open script document:
// -> paradox/scopeAt
{ "uri": "file:///d%3A/mods/my_mod/events/my_events.txt",
"position": { "line": 6, "character": 4 } }// <- result, for a cursor inside liege = { capital_province = { … } }
{
"scopes": ["province"],
"chain": [
{ "scopes": ["character"] },
{ "entryKeyword": "liege", "scopes": ["character"] },
{ "entryKeyword": "capital_province", "scopes": ["province"] }
],
"savedScopes": [{ "name": "the_actor", "scopes": ["character"] }]
}Rendering notes that will save you a redesign:
-
scopesis an array, never one name. A link or iterator with several documented output scopes stays ambiguous instead of guessing. Render several asa|b. - An empty array means unknown, and it is a first-class answer, not an error. Render it as "unknown". The server annotates and ranks, it never diagnoses on scope grounds and never asserts more than the derived link tables actually say.
-
chainis outermost first, one entry per scope-changing step. The first step carries noentryKeyword: it is the enclosing definition's root scope and comes from no key. -
savedScopesis file-wide, not flow-sensitive. Everysave_scope_as/save_scope_value_assite in the document, plus the engine-provided ambient scopes of its definition kind, including saves below the cursor. That is what completion and hover already offer, so a panel built on it cannot disagree with the popup. -
nullmeans the document is not an open script document. Render nothing.
Everything above assumes a host that can spawn a process. A web page cannot,
and @px-lsp/server/browser is the answer to that: the same parser, schema,
token tables and scope engine, assembled as a plain library against a single
in-memory document. No child process, no JSON-RPC, no workspace scan, no
filesystem.
import { createBrowserLanguageService } from "@px-lsp/server/browser";
const tokens = await (await fetch("/px/tokens.json")).json();
const freqs = await (await fetch("/px/freqs.json")).json();
const service = createBrowserLanguageService({ tokens, freqs });
const doc = service.openDocument("events/tutorial.txt", text);
doc.diagnostics();
doc.completions(offset);
doc.hover(offset);
doc.scopeAt(offset);
doc.update(newText); // keep the handle; it holds the parse across editsopenDocument takes a mod-relative path. Nothing opens it, but the schema
classifies a file by its folder, so events/tutorial.txt gets the event
grammar and root scope while common/scripted_effects/00_x.txt gets that one.
doc.kind reports which schema entry matched, or null when the folder is not
one the schema knows, which is worth showing rather than silently defaulting.
On node the server parses data/<gameId>/script_docs/*.log at startup. That is
1.1 MB of text a browser should not download or parse, so
scripts/bake-browser-data.ts runs the same parsers at build time and splits
the result by how often it is needed:
| Artifact | Raw | Brotli | Needed for |
|---|---|---|---|
dist/browser.js |
838 KB | 163 KB | everything (parser, schema, features) |
browser-data/<gameId>/tokens.json |
527 KB | 36 KB | completion, diagnostics, scope inference |
browser-data/<gameId>/freqs.json |
104 KB | 26 KB | completion ranking (optional) |
browser-data/<gameId>/docs.json |
608 KB | 72 KB | hover prose only |
So a page is answering completions and diagnostics after 225 KB brotli, and
docs.json can wait until the first hover:
service.attachDocs(await (await fetch("/px/docs.json")).json());Hover works before that call; it just has names and scopes instead of prose.
capabilities.hoverDocs says which state you are in.
Regenerate the payloads with pnpm run bake:browser, which bakes every game
that ships script_docs (add -- --game <id> for one). They carry
a version that createBrowserLanguageService checks, so a payload baked by a
different server version fails loudly at startup instead of producing subtly
wrong answers.
Browser hovers use plain Markdown without VS Code command links, HTML spans, theme variables or codicons. Completion items retain snippet tabstops for the host to expand. Each synchronous browser feature call restores the surrounding host's output capabilities before it returns.
The service has exactly one file: the one you opened. capabilities states
this field by field, and a host should surface it rather than imply the
fidelity of the editor:
-
workspaceIndex: false. No vanilla scan and no other mod files, so a reference to a trait, decision or scripted effect defined elsewhere does not resolve. Definitions in the open document do, which is why hover on ascripted_effectyou just wrote above still works. -
referenceDiagnostics: false. The unknown-reference checks need that index. They are omitted rather than approximated, because a false "unknown trait" on a trait that exists is worse than no check. -
guiAndAssets: false..guilayout, DDS decoding,[ ... ]datafunctions and the tiger runner are node-only or need a game install.
Diagnostics are therefore the structural and file-layout class only: unbalanced
braces, encoding traps, and folder traps like common/on_actions/ (plural,
which CK3 silently ignores).
The feature modules import fs, path and os on paths a browser never
reaches (loadSchema(null) takes no filesystem path, and the token tables
arrive as JSON). The browser bundle aliases all three to
src/browser/shims/, plus vscode-languageserver/node to
vscode-languageserver-types, which drops the JSON-RPC transport the library
form has no use for.
The fs shim is an empty filesystem: existsSync is false and the readers
throw ENOENT, so anything that ever did slip onto a disk-backed path fails
the way a missing file fails on node. The path shim is POSIX-only and is
pinned against node's own path.posix in
packages/server/test/browserPath.test.ts, because classifyFile picks a
schema entry with path.relative and a shim that disagrees by one segment
would produce a wrong diagnostic rather than a visible failure.
Consumers do not need any of these aliases: @px-lsp/server/browser resolves
to the prebuilt dist/browser.js with the shims already linked in.
It is not the VS Code editor in a page, and it is not an LSP server in a Web
Worker. It is the language knowledge as a library. A worker-hosted LSP would
wrap this module with BrowserMessageReader/BrowserMessageWriter and the
transport alias above changed back to vscode-languageserver/browser; nothing
in the service would need to move.
These VS Code extension features are implemented in the client rather than the language server.
- No tiger diagnostics. Deep validation (unknown effects, unknown traits, wrong argument types) is ck3-tiger / vic3-tiger's job by design, not this server's, and the download-and-run integration lives in the client. The server's own diagnostics stay in the class it can decide with certainty: structural damage, encoding and file-layout traps, missing required localization, and references to events no declared namespace contains. Run tiger from your host and map its output into your own diagnostics if you want it, exactly as the extension does. There is no EU5 tiger build at all.
-
No overview UIs. The event graph, GUI preview, mod report and coverage
views are VS Code webviews. The data behind every one of them is on the
wire (
paradox/eventGraph,paradox/guiLayout,paradox/modOverview,paradox/locCoverage), which is the split on purpose: the server computes, the client draws. -
No
.ddsrendering. Hovering a texture path still produces a hover, but the image is adata:URI in the markdown. A client that does not render images in hover markdown shows the link text instead. The DDS decoder itself is vscode-free (packages/server/src/dds/) if you need to build your own viewer.
Three of them, all runnable, all kept honest by CI or by the release checklist.
-
packages/server/test/lspSmoke.test.tsis the closest thing to a worked example of a rich embedder. It forks the packaged bundle over node IPC and drives the real protocol end to end:initializewithprocessIdand fullParadoxInitOptions, theserverInfoassertion,didOpen, completion and resolve, hover, definition, semantic tokens,paradox/scopeAt,paradox/guiTree, thenshutdown. It also forks a second server declaringhoverHtmlwith zero commands, which is the capability combination the old boolean could not express. -
packages/server/test/stdioSmoke.test.tsis the same flow over--stdiowith noinitializationOptionsat all, so it is the executable statement of what the fallbacks do on their own.PX_LSP_SERVERpoints it at another bundle, which is how CI smokes the extracted release tarball. -
scripts/nvim-parity/drives headless neovim through the plain-client setup against a real mod. Beyond feature presence it checks that hovers carry no VS Code markup or deadcommand:links, that external edits are picked up without a restart, and that the status mirror reaches the log. It needs neovim, a game install and a real mod, so it is run by hand before a release rather than in CI. Its README has the invocation.
Both are attached to every GitHub release
and stage the identical server payload, defined once in
scripts/server-package.mjs so the two cannot drift apart.
px-lsp-server-<version>.tar.gz, the portable one. Needs Node 18+ on the
target machine.
px-lsp-server-<version>/
dist/server.js
data/ck3/ data/vic3/ # bundled fallback data, found automatically
README.md LICENSE THIRD-PARTY-NOTICES.md
px-lsp-win-x64-<version>.zip, the one to embed on Windows. Same payload
plus an unmodified official nodejs.org build, so nothing has to be installed
first.
px-lsp-win-x64-<version>/
px-lsp.cmd # runs the bundled node against dist/server.js --stdio
node.exe # official win-x64 build, unmodified
NODE-LICENSE # Node's own license (the GPL LICENSE keeps the plain name)
dist/ data/ README.md LICENSE THIRD-PARTY-NOTICES.md
The Node build is pinned to an Active LTS release, downloaded from nodejs.org
and verified against that release's own SHASUMS256.txt at build time.
Do not flatten either archive. The server finds its bundled data at
../data/<gameId>/ relative to dist/server.js, so dist/ and data/ must
stay siblings, or dataDir must name the new root. Flattened, the server still
starts and still answers requests, it just silently loses the bundled wiki
tokens and the frequency tables. The startup window/logMessage line names the
directory it resolved, which is how you tell the two apart.
Redistribution: the server is GPL-3.0-or-later, node.exe keeps its own
license as NODE-LICENSE, the bundled CK3 wiki token lists are CC BY-SA 3.0
(data/ck3/wikidocs/ATTRIBUTION.md) and the EU5 schema table derives from
MIT-licensed community CWT rules. THIRD-PARTY-NOTICES.md ships in both
archives with the full texts.
Call paradox/snippetCatalogue with {} for the full active-game catalogue, without requiring an open document or applying the cursor picker's cap. The response includes engine templates, definition and child-block skeletons, and effective indexed scripted calls. Every available variant includes snippet syntax, plain insertion text and completion-preview Markdown. An indexing: true response has no entries; request again after indexing finishes. VS Code uses this request for px.exportSnippets, which saves a self-contained, searchable HTML file with copy and print controls. See the Protocol Reference for the response fields.
Wiki notice: This wiki is mainly AI-generated, with limited human review and moderation. Pages primarily describe the latest preview version of the toolkit and may contain errors or differ from stable and older releases.
Repository · Releases · Changelog · Report a bug · Credits
Extension id JDeffner.px-toolkit. Licensed GPL-3.0-or-later; bundled third-party data keeps its own terms (notices).
{ "processId": 12345, "rootUri": null, "capabilities": { /* your LSP client capabilities */ }, "initializationOptions": { "storageDir": "C:/Users/you/AppData/Local/YourApp/px-lsp", "settings": { "gameId": "ck3", "gamePath": "D:/Steam/steamapps/common/Crusader Kings III/game", "logsPath": "C:/Users/you/Documents/Paradox Interactive/Crusader Kings III/logs", "modPath": "D:/mods/my_mod", "workspaceMods": ["D:/mods/my_mod"], "locLanguage": "english" } } }