See also: CONTRIBUTING.md | server/INTERNALS.md | scripts/README.md
High-level architecture of the BGforge MLS extension. For server-specific details (provider registry, symbol system, data flow), see server/INTERNALS.md.
- System Overview
- Repository Layout
- Build System
- Client Architecture
- Server Architecture
- CLI Tools
- Grammar Architecture
- Data Pipeline
- Test Architecture
- Extension Packaging
- Latency Budgets
- Key Design Decisions
+-------------------+ IPC +-------------------+
| VSCode Client | <--------------> | LSP Server |
| (extension.ts) | | (server.ts) |
+-------------------+ +-------------------+
| |
| TS Language Service v
| (tsserver process) +-------------------+
v | ProviderRegistry |
+-------------------+ +-------------------+
| bgforge-tssl- | |
| plugin/index.js | +--------+--------+--+--------+---------+
| bgforge-td- | | | | | |
| plugin/index.js | v v v v v
+-------------------+ Fallout WeiDU WeiDU WeiDU Fallout
SSL BAF D TP2 Worldmap
+-------------------+
| CLI Tools | Standalone, reuse server modules
| fgfmt (cli.js) | No VSCode dependency
| transpile.js |
| fgbin (cli.js) |
+-------------------+
Three runtime processes:
- VSCode Client -- extension activation, commands, webview panels, binary editor
- LSP Server -- language features (completion, hover, definition, format, etc.)
- tsserver -- TypeScript Language Service plugins for
.tssland.tdfiles
CLI tools run independently, reusing server modules directly.
vscode-mls/
|
+-- client/ VSCode extension client
| +-- src/
| | +-- extension.ts Entry point (activate/deactivate, LSP client)
| | +-- (TS plugins moved to plugins/ directory)
| | +-- indicator.ts Server initialization progress indicator
| | +-- dialog-tree/ Dialog tree preview (webview panels)
| | +-- editors/ Binary .pro/.map editor (custom editor)
| | +-- parsers/ Binary file parsers (.pro/.map)
| | +-- test/ E2E tests (mocha + vscode test runner)
| +-- out/ esbuild output
|
+-- server/ LSP server (see server/INTERNALS.md for details)
| +-- src/
| | +-- server.ts LSP entry point: connection setup, debouncer wiring, handler registration
| | +-- provider-registry.ts Routes requests to language providers
| | +-- language-provider.ts Provider interface
| | +-- compile.ts Compilation dispatch
| | +-- translation.ts .tra/.msg inlay hints, hover, definition, and find references
| | +-- user-messages.ts User message wrappers (auto-decode file:// URIs)
| | +-- safe-eval.ts Safe expression evaluator (no eval())
| | +-- common.ts Logging, file utilities
| | +-- settings.ts User settings
| | +-- handlers/ Per-feature LSP request handlers (HandlerContext shared)
| | +-- core/ Symbol system, URI normalization, patterns, debouncer, file index, compile-tmp helper
| | +-- shared/ Cross-provider utilities
| | +-- fallout-ssl/ Fallout SSL provider (full IDE support)
| | +-- fallout-worldmap/ Worldmap provider (completion + hover)
| | +-- weidu-baf/ WeiDU BAF provider (format + compile)
| | +-- weidu-d/ WeiDU D provider (symbols, definition, rename, JSDoc hover)
| | +-- weidu-log/ WeiDU log provider (go-to-definition for mod paths)
| | +-- weidu-tp2/ WeiDU TP2 provider (full IDE support)
| | +-- tssl/ TSSL dialog bridge (depends on tree-sitter + LSP)
| | +-- td/ TD dialog bridge (depends on tree-sitter + LSP)
| +-- data/ YAML data files (game engine definitions)
| +-- test/ Unit tests (vitest)
| +-- out/ esbuild output + WASM files + JSON data
|
+-- cli/ Standalone CLI tools
| +-- bin/ Binary parser CLI (.pro/.map -> JSON)
| +-- test/ CLI tests
|
+-- format/ @bgforge/format package: formatters library + fgfmt CLI bin
| +-- src/ index.ts (library) + cli.ts (fgfmt bin)
| +-- out/ tsup output + WASM files
|
+-- shared/ Pure TypeScript helpers shared across workspaces
| +-- cli/ Shared CLI utilities (used by format, transpile, bin)
|
+-- plugins/ TypeScript Language Service Plugins
| +-- tssl-plugin/ TSSL plugin (TS6133 suppression, engine proc hover)
| +-- td-plugin/ TD plugin (runtime injection, completion filtering)
|
+-- grammars/ Tree-sitter grammars (6 languages)
| +-- fallout-ssl/ grammar.js, corpus tests, WASM output
| +-- weidu-baf/
| +-- weidu-d/
| +-- weidu-tp2/
| +-- fallout-msg/ Highlight-only (external editors)
| +-- weidu-tra/ Highlight-only (external editors)
|
+-- syntaxes/ TextMate grammars (YAML source -> JSON)
| +-- {lang}.tmLanguage.yml Primary syntax highlighting
| +-- {lang}-tooltip.tmLanguage.yml Hover tooltip syntax
| +-- bgforge-mls-*.tmLanguage.yml Comment/string/docstring injection
|
+-- language-configurations/ VSCode language settings (brackets, comments, indent)
+-- themes/ Color theme (BGforge Monokai) + icon theme
+-- snippets/ Code snippets (SSL, BAF, TP2)
+-- scripts/ Build, test, data generation scripts
+-- transpilers/ Transpiler implementations + user documentation
| +-- common/ Shared utilities (no package.json)
| +-- tssl/ @bgforge/tssl: TypeScript to Fallout SSL
| +-- tbaf/ @bgforge/tbaf: TypeScript to WeiDU BAF
| +-- td/ @bgforge/td: TypeScript to WeiDU D
| +-- src/ @bgforge/transpile: library entry (index.ts) + fgtp bin (cli.ts)
+-- external/ Game data (Fallout, Infinity Engine)
+-- resources/ Extension icon
+-- docs/ Documentation
All bundles use esbuild (not tsc). The monorepo uses pnpm workspaces.
| Target | Input | Output | Notes |
|---|---|---|---|
| Client | client/src/extension.ts |
client/out/extension.js |
CJS, vscode external |
| Server | server/src/server.ts |
server/out/server.js |
CJS, patches import_meta for WASM |
| TSSL Plugin | plugins/tssl-plugin/src/index.ts |
node_modules/bgforge-tssl-plugin/index.js |
CJS, standalone |
| TD Plugin | plugins/td-plugin/src/index.ts |
node_modules/bgforge-td-plugin/index.js |
CJS, standalone |
| Webviews | client/src/{dialog,binary}-webview.ts |
client/out/*.js |
Browser context |
| Format lib | format/src/{index,cli}.ts |
format/out/{index,cli}.js |
ESM, tsup-bundled; cli.js is the fgfmt bin |
| Transpile lib | transpilers/src/{index,cli}.ts |
transpilers/out/{index,cli}.js |
ESM, tsup-bundled; cli.js is the fgtp bin |
| Binary lib | binary/src/{index,cli}.ts |
binary/out/{index,cli}.js |
ESM, tsup-bundled; cli.js is the fgbin bin |
| Grammars | grammars/*/grammar.js |
grammars/*/*.wasm -> server/out/ |
tree-sitter build --wasm |
| TextMate | syntaxes/*.tmLanguage.yml |
syntaxes/*.tmLanguage.json |
YAML -> JSON conversion |
pnpm build
|
+-> build:client esbuild client + TS plugins
+-> build:binary @bgforge/binary library + fgbin CLI
+-> build:server esbuild server + copy WASM to server/out/
+-> build:test esbuild E2E test bundles
+-> build:webviews esbuild webview bundles
pnpm build:all Full build: build:grammar + build + build:editors
pnpm build:dev Minimal build for F5 development (skips CLIs)
pnpm build is the default repo-wide build, not the full build. Use pnpm build:all
when you need grammars and editor bundles too.
- WASM URL resolution: web-tree-sitter uses
import.meta.urlfor WASM loading. esbuild's CJS output shimsimport.metaas an empty object. Build scripts use--banner:jsto define a__imuvariable with the correct file URL, and--define:import.meta.url=__imuto replace references. This works reliably with--minify(the previoussedapproach broke when esbuild mangled variable names). - TS plugins: Must be standalone CJS bundles in
node_modules/directories. tsserver loads them by package name fromtypescriptServerPluginsin package.json. - Externalized .d.ts imports: Transpiler libraries (ielib, folib) use
.d.tsfor engine declarations. esbuild externalizes these; they pass through as bare identifiers. Libraries must use named re-exports, notexport *. - Library bundlers (tsup):
@bgforge/binary,@bgforge/format, and@bgforge/transpilebundle via tsup. tsdown — the named successor to tsup, Rolldown-based — was evaluated and rejected for now: the format package imports the runtimeSyntaxTypeenum from auto-generatedtree-sitter.d.tsdeclaration files (from "../../../server/src/<lang>/tree-sitter.d"). esbuild (under tsup) inlines those enum members as the literal string at each use site (child.type === "value" /* Value */); Rolldown (under tsdown) follows tsc's strict ".d.tsis types-only" rule, so the imported references resolve to placeholder objects at runtime and everychild.type === SyntaxType.Xcomparison silently returns false — dropping content during formatting. (Reproducer: Ascension.tp2'sBEGIN @104001 DESIGNATED 0 ... INCLUDEblock collapses toBEGIN INCLUDEwhen the format CLI is built with tsdown.) Switching off tsup requires either making the generated tree-sitter types a runtime.tsfile (project-wide rename across ~50 import sites plus thedts-tree-sittergeneration pipeline and gitignore patterns) or hand-maintaining a runtime SyntaxType shim per grammar; neither is justified by tsup's current state alone. Revisit when tsdown matures (an esbuild-style enum-inlining option would unblock the swap), when tsup actually breaks, or when one of those refactors lands for an unrelated reason.
esbuild emits all production code; tsc is used for type-checking only (noEmit: true).
tsconfig.base.json reflects this with module: ESNext, moduleResolution: bundler,
verbatimModuleSyntax: true, and noEmit: true. Two exceptions override the base:
scripts run via tsx/Node and need module: NodeNext; TS Language Service plugins require
module: commonjs and verbatimModuleSyntax: false because tsserver loads them as CJS
and they use export = init syntax.
The extension activates on language open or when the workspace contains transpiler files
(.tssl, .tbaf, .td). See activationEvents in package.json.
activate()
|
+-> Create LanguageClient (IPC transport to server)
+-> Register commands (compile, dialog preview)
+-> Register binary editor provider (.pro/.map files)
+-> Register dialog tree webview panels
+-> Start server (server/out/server.js)
VSCode engine floor (1.73): The extension requires VSCode 1.73 or later. The
binding constraint is vscode.l10n.t() in client/src/indicator.ts, which was
introduced in VSCode 1.73 (November 2022). Earlier APIs used by the extension —
vscode.CustomEditorProvider (1.46) and the semanticTokenTypes contribution
point (1.43) — are all satisfied by 1.73. The floor should be raised if a feature
requiring a later release is added.
Plugins intercept tsserver calls for transpiler files. They run inside the tsserver process, not the extension host.
- TSSL Plugin — suppresses TS6133 for engine procedures, adds hover docs. See plugins/tssl-plugin/README.md.
- TD Plugin — injects
td-runtime.d.ts, filters completions per file type. See plugins/td-plugin/README.md.
Two webview-based features, each with a host-side and browser-side module:
| Feature | Host Module | Webview Module | Trigger |
|---|---|---|---|
| Dialog Tree (SSL) | dialog-tree/dialogTree.ts |
dialogTree-webview.ts |
Ctrl+Shift+V in SSL |
| Dialog Tree (D/TD) | dialog-tree/dialogTree-d.ts |
dialogTree-webview.ts |
Ctrl+Shift+V in D/TD |
| Dialog Tree (TSSL) | dialog-tree/dialogTree.ts |
dialogTree-webview.ts |
Ctrl+Shift+V in TSSL |
| Binary Editor | editors/binaryEditor.ts |
binaryEditor-webview.ts |
Open .pro or .map file |
For the binary library internals — spec system, primitives, derivation, format-adapter pattern, adding a new format — see binary/INTERNALS.md.
Binary editor design choice:
.mapfiles are parsed strictly in the custom editor. If strict parsing fails, the editor shows the parse errors instead of silently falling back to heuristic recovery.- Graceful MAP fallback remains available in non-editor workflows such as the binary CLI via
--graceful-map, where corpus parsing and opaque-byte round-tripping are more useful than an editable strict tree. - The editor includes
Dump to JSONandLoad from JSONsidebar actions. Snapshots use extension-preserving sidecars such asfile.pro.jsonandfile.map.json. - Binary JSON snapshots are canonical
schemaVersion: 1documents for bothproandmap. They are validated on dump and load. Legacy editor-tree snapshots are no longer supported. - Both binary parsers now separate canonical data from presentation. Parser results still include a tree for the editor, but
ParseResult.documentis the canonical machine model and is the source of truth for JSON dump/load and binary serialization. - Canonical rebuild during save and JSON export is strict about output validity. If a parsed PRO or MAP field is outside a supported domain range, the serializer clamps it to the nearest valid value before writing bytes or snapshots.
- Presentation metadata such as labels, enum/flag option tables, numeric formatting, and editability is defined separately in
binary/src/presentation-schema.ts, so external tools can consume the canonical data contract without inheriting the editor tree. - Presentation lookups are keyed by stable semantic IDs such as
pro.header.objectTypeandmap.scripts[].extents[].slots[].flags. The old escaped tree-path lookup form is no longer part of the contract. - MAP JSON snapshots remain fidelity snapshots. Any MAP region the editor intentionally omits from the visible tree, such as tiles or opaque tails, is still carried in the canonical snapshot so JSON round-trips remain byte-preserving.
- That byte preservation applies to omitted MAP regions and preserved fixed-width source bytes such as filename slots. Once a field is modeled and changed through the canonical document, JSON load/save treats the parsed value as authoritative and rewrites that field in canonical form.
- MAP snapshots are semantic documents, not field-layout dumps. Regular decoded MAP fields do not persist
offset,size,valueType, ornodeType; those remain internal codec concerns. Offsets and sizes are only preserved inopaqueRangesfor undecoded or intentionally omitted byte spans. - MAP snapshots use a single persisted tile encoding: tile bytes must be stored in the
opaqueRangesentry labeledtiles. Decoded tile snapshots are intentionally unsupported. - JSON load in the custom editor intentionally stays strict for MAP files even when a snapshot was originally produced from a graceful parse. This is on purpose: ambiguous MAP bytes should not spread through normal editor workflows. Users who explicitly want to reload those ambiguous snapshots must use the binary CLI with
--graceful-map. - The custom editor intentionally omits MAP tile data. Tiles are large, mostly low-signal bulk data for editor workflows, so the editor skips materializing them entirely and preserves their bytes only for round-trip save/revert.
- The MAP editor hides a few script-entry struct slots that Fallout 2 CE still leaves as legacy or unknown internals. It keeps meaningful fields visible, renames them to match CE semantics where possible, and leaves the persisted program pointer slot read-only because the engine treats the saved pointer value as non-semantic.
- The editor sends a lazy tree model to the webview rather than one large pre-expanded JSON payload. Enum/flag choices are attached per field node, and MAP projection now lives in the tree builder instead of a separate compacted parse-result layer.
- Format-specific behaviour (snapshots, canonical rebuild, semantic key mapping, editor projection, structural edits) is encapsulated in
BinaryFormatAdapterimplementations registered inbinary/src/format-adapter.ts. Adding a new binary format requires implementing this interface alongside the parser. - Dialog tree preview and binary editor now share the same inline webview asset-cache helper (
client/src/webview-assets.ts) for HTML/CSS/JS shell loading.
See server/INTERNALS.md for comprehensive documentation covering:
- Provider registry pattern and request routing
- Symbol system (IndexedSymbol, scope hierarchy, pre-computed responses)
- Include graph (workspace-wide rename via transitive dependant tracking)
- Data flow (initialization, hover fallthrough, file change propagation)
- Tree-sitter integration (sequential init, SyntaxType enum, parse caching)
- Translation service (.tra/.msg inlay hints)
- Adding a new provider
Two recent behavior points are easy to miss:
- Provider indexing is registry-driven via
indexExtensions, not provider-specific startup scans. - VS Code workspace-symbol search is scoped to the active document language for
fallout-ssl,weidu-d, andweidu-tp2, so Ctrl+T does not mix symbols across languages.
Each provider implements ProviderBase plus relevant capability interfaces from core/capabilities.ts
(e.g., FormattingCapability, CompletionCapability). The LanguageProvider type is a
Partial<> intersection of all capabilities — providers declare explicit implements clauses
for compile-time enforcement:
| Provider | Completion | Hover | Signature | Definition | References | Format | Symbols | Workspace Symbols | Rename | Inlay | Folding | Diagnostics | JSDoc |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| fallout-ssl | x | x | x | x | x | x | x | x | x | .msg | x | sslc | x |
| fallout-worldmap | x | x | |||||||||||
| weidu-baf | x | x | x | .tra | x | weidu | |||||||
| weidu-d | x | x | x | x | x | x | x | x | .tra | x | weidu | x | |
| weidu-log | x | ||||||||||||
| weidu-tp2 | x | x | x | x | x | x | x | x | .tra | x | weidu | x |
Three TypeScript-to-scripting-language transpilers share a common pipeline:
Source (.tssl/.tbaf/.td)
|
+-> Extract @tra tag (esbuild strips comments)
+-> Bundle imports (esbuild, shared bundler)
+-> Parse AST (ts-morph)
+-> Transform to IR (language-specific)
+-> Emit target language text
+-> Write output file
+-> Optional: chain native compilation
The four internal packages (common, tssl, tbaf, td) stay private. The
publishable library lives at the transpilers/ root as @bgforge/transpile
and bundles all four into a single ESM artifact via tsup. Internal consumers
(LSP server, TS plugins) keep importing the per-language packages directly;
external consumers use the bundled library. esbuild-wasm is the only runtime
dependency — it cannot be inlined because it detects bundling at load time.
Shared pipeline (transpilers/common/transpiler-pipeline.ts): createTranspiler() factory
handles the common orchestration — extension validation, @tra tag extraction, file I/O,
and structured compile events such as output_written. The shared pipeline does not
write to stdout; host-specific callers decide whether to surface those events as LSP
messages, CLI stderr output, or ignore them. TBAF and TD use this factory; TSSL has
custom entry points due to its batch state and non-standard output path.
Shared utilities (transpilers/common/transpiler-utils.ts): variable substitution, loop unrolling
(max 1000 iterations), array spread/destructuring, helper fixups (obj/tra/tlk),
point tuple conversion ([x, y] -> [x.y]), @tra tag extraction.
Shared bundler (transpilers/tbaf/src/bundle.ts): esbuild-wasm with externalized .d.ts imports,
enum transformation plugin, extensionless import resolution. Used by TBAF and TD
directly; TSSL calls bundleWithEsbuild() directly with preserved-function tracking.
TBAF/TD skip bundling for import-free files (hasImports() guard); TSSL always bundles
because enums are a first-class feature, inline function extraction depends on bundling,
and enum property expansion needs all bundled enum names.
Architecture differences: TSSL emits directly from AST (no IR). TBAF uses a
structured IR (BAFBlock/Condition/Action) with condition algebra (boolean to
CNF conversion for BAF OR groups). TD has the richest IR (20+ construct types)
with state machines, method chain parsing, and dual-pass orphan detection.
| Transpiler | Input | Output | Key Features |
|---|---|---|---|
| TSSL | .tssl |
.ssl |
const/let, loops, functions, enum pre-transform |
| TBAF | .tbaf |
.baf |
for/for-of, arrays, spread, destructuring, function inlining, point tuples |
| TD | .td |
.d |
All TBAF features + conditionals, method chains, transitive state collection, orphan warnings, dialog preview |
TD module structure (transpilers/td/src/):
| Module | Purpose |
|---|---|
index.ts |
Entry point, bundling, orphan detection on original source |
parse.ts |
AST walker: ts-morph AST -> IR |
parse-helpers.ts |
Utility functions (evaluate, resolve, validate) |
expression-eval.ts |
Expression -> trigger/action/text conversion |
chain-parsing.ts |
Method chain transition parsing |
chain-processing.ts |
Chain body processing (from/fromWhen/say) |
state-transitions.ts |
State/transition processing, loop unrolling |
state-resolution.ts |
Post-parse BFS transitive collection, orphan detection |
patch-operations.ts |
Patch operation transforms (ALTER_TRANS, etc.) |
emit.ts |
IR -> D text serialization |
types.ts |
IR types (TDScript, TDConstruct, TDState, TDSay, etc.) |
td-runtime.d.ts |
TypeScript declarations for TD API |
dialog.ts (dialog tree preview, parseTDDialog) lives in server/src/td/ — it depends on tree-sitter parsers and LSP infrastructure and was not extracted.
Standalone command-line tools that reuse server modules without VSCode dependency.
fgfmt <file|dir> [--save] [--check] [--save-and-check] [-r] [-q]
Formats Fallout SSL, WeiDU BAF/D/TP2, WeiDU TRA, Fallout MSG, Infinity Engine 2DA, and
Fallout scripts.lst files. Parser-based formats (SSL/BAF/D/TP2) use tree-sitter and
respect .editorconfig. String-based formats (TRA/MSG/2DA/scripts.lst) require no parser.
Includes WASM parser modules. Ships as the fgfmt bin entry in @bgforge/format; the
library entry exposes the formatters for use in custom build pipelines.
fgtp <file|dir> [--save] [--check] [-r] [-q]Transpiles .tssl, .tbaf, .td files to their target formats. Uses ts-morph
and native esbuild in the standalone CLI build (--alias:esbuild-wasm=esbuild).
Reports orphan warnings for TD files.
fgbin <file.pro|file.map|dir> [--save] [--check] [--load] [--graceful-map] [-r] [-q]
Ships as the fgbin bin entry of @bgforge/binary (built via tsup to binary/out/cli.js).
Parses Fallout .pro and .map binary files and outputs structured JSON. --load writes JSON back using the parser's native extension, and --graceful-map allows ambiguous MAP object boundaries to fall back to opaque bytes for corpus and round-trip workflows.
Snapshots are saved as extension-preserving sidecars such as file.pro.json and file.map.json.
Snapshot contract:
- Snapshots are canonical
schemaVersion: 1JSON documents, not editor-tree dumps. proandmapboth dump/load through format-specific canonical schemas.- Dump and load both validate snapshots, then reload bytes through the native parser as a round-trip safety check.
mapsnapshots are semantic and do not expose normal field layout metadata; byte-preservation metadata lives inopaqueRangesonly.- Ambiguous MAP snapshots intentionally require
--graceful-mapagain on load.
shared/cli/cli-utils.ts provides:
- Argument parsing (
--save,--check,-r,-q) - File discovery (single file or recursive directory scan)
- Diff reporting (colorized, for
--checkfailures) - Error handling wrapper
Six tree-sitter grammars compiled to WASM (4 LSP + 2 highlight-only for external editors). See grammars/README.md for the full list, build commands, WASM rationale, and type generation details.
TextMate grammars (in syntaxes/) provide syntax highlighting. Source is YAML,
converted to JSON at build time. Includes:
- 11 primary language grammars
- 4 tooltip grammars (hover rendering)
- 3 injection grammars (comments, strings, docstrings)
Game engine definitions flow from YAML sources to runtime. See data-pipeline.md for the full diagram.
Summary:
External Sources (IESDP, sfall, game files)
|
v
server/data/*.yml Version-controlled YAML
|
v
generate-data.sh Build-time conversion
|
v
server/out/*.json Bundled JSON (completion, hover, signature)
syntaxes/*.tmLanguage.yml Updated highlight stanzas
|
v
core/static-loader.ts Runtime loading into Symbols index
YAML data files (~1.7 MB total):
| File | Contents |
|---|---|
fallout-ssl-base.yml |
Fallout SSL functions, variables, constants |
fallout-ssl-sfall.yml |
Sfall extension library |
weidu-baf-base.yml |
BAF triggers and actions |
weidu-baf-ids.yml |
IDS file entries (auto-generated) |
weidu-baf-iesdp.yml |
IESDP triggers and actions |
weidu-d-base.yml |
D file functions |
weidu-tp2-base.yml |
TP2 functions and macros |
fallout-worldmap-txt.yml |
Worldmap key-value pairs |
See scripts/README.md for all test commands.
Four test layers:
- Server unit tests (
server/test/, vitest) -- a couple thousand tests covering providers, transpilers, core symbol system, shared utilities. Runpnpm exec vitest --run --reporter=verbose 2>/dev/null | tailfor the current count.
- Integration tests -- grammar corpus, TD/TBAF sample transpilation, format comparison, CLI exit codes
- E2E tests (
client/src/test/, mocha + vscode) -- completion, hover in a real VSCode instance - Grammar tests (
grammars/*/test/corpus/) -- tree-sitter corpus tests per grammar
Per-package vitest coverage thresholds reflect the slice of behaviour each package's unit tests are responsible for, not the package's full execution surface. Two packages run intentionally low floors because their broader behaviour is verified by other layers:
@bgforge/format(format/vitest.config.ts): 27/17/12/27 (lines/functions/branches/statements). The formatter surface is exercised end-to-end by grammar-corpus fixtures undergrammars/*/test/corpus/and by the directory-mode--save-and-checkinvocation inscripts/test.sh. The vitest project here covers only the standalone unit slice (utilities, helpers, dispatch); the broader surface is covered but in a different layer.@bgforge/transpile(transpilers/vitest.config.ts): 15/25/8/15. The bulk of transpiler correctness is enforced by Stryker mutation testing (stryker.conf.json, breaks at 60% mutation score) plus the TD/TBAF fixture-driven integration suites inscripts/test.sh. The vitest project here covers the public API and shared helpers; the per-language transformer surface is covered through mutation and integration.
The other workspaces — server, client, binary, shared, scripts, and the
two TypeScript plugins — run at 90/80/90/90 (or higher for the plugins) because
their unit suites are responsible for the bulk of their own behaviour. server
additionally excludes src/fallout-ssl/provider.ts and src/weidu-tp2/provider.ts
(LSP dispatcher glue verified by the integration tests under server/test/integration/)
and src/**/format/**/*.ts (per-language tree-sitter formatters covered by
grammar-corpus tests).
Ratchet upward when the unit slice in any package widens.
.vscodeignore uses a blocklist strategy (exclude dev files, keep runtime files by default). See docs/ignore-files.md for the full list
and rationale.
Packaging pipeline (scripts/package.sh):
- Run prepublish build (with full pnpm deps available)
- Deref pnpm symlinks for server runtime deps, strip all other symlinks
- Run
pnpm vsce package --no-dependencies(skips vsce's npm list check) - Inject TS plugins into VSIX via
zip -g(vsce excludes rootnode_modules/with--no-dependencies) - Restore
server/node_modules/viapnpm install(EXIT trap)
Runtime dependencies that must ship in the VSIX:
| Dependency | Location | Why not bundled |
|---|---|---|
| sslc-emscripten-noderawfs | server/node_modules/ |
Loaded via fork(), separate process |
| esbuild-wasm | server/node_modules/ |
esbuild --external, WASM binary |
| bgforge-tssl-plugin | node_modules/ |
Loaded by tsserver by package name |
| bgforge-td-plugin | node_modules/ |
Loaded by tsserver by package name |
See docs/ignore-files.md for .vscodeignore details.
The server wraps hot LSP handlers with timeHandler (in server/src/shared/time-handler.ts).
When a handler exceeds the threshold it logs a [lsp-timing] warning to the LSP console.
The threshold is DEFAULT_THRESHOLD_MS = 50 ms and can be overridden at startup via the
BGFORGE_LSP_SLOW_MS environment variable.
The threshold is a per-request, per-call budget — not an aggregate. A single request that takes longer than 50 ms triggers a warning regardless of prior request history.
Handlers currently wrapped: onCompletion, onHover, onDefinition, onReferences,
onDocumentSymbol, semanticTokens, onWorkspaceSymbol.
No measured baselines exist in the repo yet. The targets below are initial values derived from the default threshold; refine them once real baselines are captured in CI or profiling sessions. If a new provider is added, re-measure all wrapped handlers with the new language loaded and update this table.
| Operation | Budget | Notes |
|---|---|---|
Completion (onCompletion) |
50 ms | Responses are pre-computed O(1) lookups; budget is the threshold. |
Hover (onHover) |
50 ms | Same O(1) lookup model. |
Definition / References (onDefinition, onReferences) |
50 ms | May walk include graph; budget is the threshold. |
Workspace symbol (onWorkspaceSymbol) |
50 ms | Iterates all indexed symbols; cancellation checked periodically. |
Document symbol (onDocumentSymbol) |
50 ms | Per-file tree traversal; budget is the threshold. |
| Server startup (provider initialization) | Not wrapped | Sequential WASM loads; not currently measured by timeHandler. |
The startup path is not wrapped because providers initialize sequentially by design (see Sequential Provider Initialization) and the initialization latency is dominated by WASM load time, which is not actionable per request.
- A new provider is added: re-measure all hot paths with the new language loaded.
- A new data file or YAML source is significantly larger than existing ones: re-check
onCompletionandonHoverbudgets. - A stricter SLO is adopted project-wide: update
DEFAULT_THRESHOLD_MSand this table together so the code and docs stay in sync.
All language features route through a single LSP server with a provider registry. Providers implement an optional interface -- each language only implements what it supports. This avoids separate servers per language while keeping providers decoupled.
Tree-sitter (WASM) handles the niche scripting languages -- it's fast, incremental, and grammar-driven. ts-morph handles transpiler input (TypeScript subset) -- it provides a full TypeScript AST with type information.
LSP responses (completion items, hover markdown, signature help) are computed once at
parse/index time and stored in IndexedSymbol. Requests are O(1) lookups. This trades
memory for latency.
web-tree-sitter uses a shared TRANSFER_BUFFER for JS/WASM communication. Concurrent
Language.load() calls corrupt parser state. Providers initialize sequentially.
Format and transpile CLIs import server modules directly. No code duplication. CLIs are esbuild-bundled to single files with no VSCode dependency.
.tssl and .td files are valid TypeScript subsets. TS plugins intercept tsserver to
suppress false errors and inject engine documentation, giving users full TypeScript
tooling (type checking, refactoring, go-to-definition) alongside transpiler features.
Dependency bumps stay within the current major version. pnpm update -r is run
periodically to pick up minor and patch releases across the workspace; major bumps
(including 0.x → 0.y where y > current, which npm treats as breaking) are deferred
until an explicit, motivated upgrade pass. This trades currency for stability: strict
mode, verbatimModuleSyntax, and the custom TS config make major-bump churn expensive,
and the extension's user surface is small enough that we gain little from being on the
absolute newest release of every library.
Symbol resolution respects language visibility rules automatically (SSL procedure scope, TP2 first-assignment-wins, D dialog-scoped composite keys). Lookups never cross scope boundaries, so features don't need to post-filter results.
Edits update only the changed file's entry in the symbol and references indexes, not the whole workspace. Keeps incremental updates cheap on large mod repos.
Features try resolution sources in order: local AST -> static data index -> translation
service. Each step returns undefined to continue to the next source or null to stop.
This keeps language-specific precedence rules explicit at call sites rather than baked
into shared helpers.
Several features have separate implementations per provider that may look like duplication
but are intentionally language-specific. Shared infrastructure lives in server/src/shared/;
the per-language bodies encode genuinely different semantics:
| Feature | Why per-language |
|---|---|
| Definition finders | Different scoping models (SSL procedures vs TP2 functions vs D state labels) |
| Document symbol extraction | Different construct types and scoping: SSL has explicit variable declarations, TP2 uses first-assignment-wins deduplication. Both show params/vars as children. TP2 uses hasError guard to skip error-recovery artifacts; icon assignment uses shared looksLikeConstant() heuristic (cross-linked: symbol.ts, hover.ts, tree-utils.ts, tmLanguage.yml) |
| Rename | SSL is workspace-wide via ReferencesIndex; TP2 is single-file with %var% handling |
| Reference finders | SSL has procedure scope shadows; TP2 has synthetic string nodes; D uses dialog-scoped composite keys |
| Call-site extractors | SSL indexes all identifiers; TP2 indexes only function/macro names (case-sensitive); D uses dialog:label composite keys |
| Folding block type sets | Language-specific node types, passed as parameters to shared getFoldingRanges() |
| Comment stripping | stripCommentsWeidu() handles ~string~ delimiters; stripCommentsFalloutSsl() does not |
Tree-sitter error recovery can fabricate structurally valid nodes from broken input.
When the user is mid-typing a keyword (e.g. COPY_EXISTN instead of COPY_EXISTING),
error recovery may produce a patch_assignment node with a phantom zero-width =
operator. Without protection, this creates spurious variable completions with wrong
types.
Two defense layers prevent this:
isPhantomAssignment()(tree-utils.ts) -- rejects assignment nodes where the operator has zero width (inserted by error recovery, not present in source). Applied in bothlocalCompletion()andextractVariables().excludeWord(provider.ts) -- excludes the word at cursor from local completions in all paths, not just declaration sites. Prevents self-referencing completion even if layer 1 is bypassed by future error recovery changes.
Design limitation: layer 1 relies on observed tree-sitter behavior (zero-width phantom
operators), not a documented guarantee. Layer 2 provides backup. Both must fail for a
regression to occur. See isPhantomAssignment() JSDoc for alternatives considered.
Only TP2 is affected because it has bare assignment syntax (foo = 5 without a keyword).
Other providers (SSL, BAF, D) don't have bare assignment grammar rules, so error recovery
cannot produce phantom assignment nodes for them.
Document symbols (symbol.ts) use a separate defense: node.hasError guards on all
variable-extracting code paths (extractFileLevelVars, collectBodyVars). This skips
nodes where tree-sitter's error recovery inserted phantom tokens. The guard still recurses
into children, so valid variables inside an ACTION_IF with partial errors are still
collected.
On Windows, VSCode and Node's pathToFileURL() produce different percent-encodings for
the same file (e.g., %21 vs !, %3A vs :). Using raw URI strings as Map/Set keys
causes silent mismatches when the same file enters via different paths (LSP at runtime
vs pathToUri at startup).
The NormalizedUri branded type (core/normalized-uri.ts) canonicalizes file:// URIs
via a fileURLToPath -> pathToFileURL round-trip. ProviderRegistry normalizes all
URIs at the gateway before passing to providers.
The branded type is enforced at storage boundaries: Symbols.files,
ReferencesIndex.files, FileIndex methods, the UriDebouncer instances
in core/uri-debouncer.ts, and activeCompiles maps in compilers all use
Map<NormalizedUri, ...>. pathToUri()
returns NormalizedUri since it produces canonical encoding. Providers cast at the
boundary where they pass URIs to storage (uri as NormalizedUri), documented with a
comment explaining the gateway guarantee.
All user-visible messages (showInformationMessage, showWarningMessage,
showErrorMessage) go through wrappers in user-messages.ts that auto-decode
file:// URIs to human-readable paths. A custom oxlint rule
(.oxlint/oxlint-plugin-no-showmessage.ts) enforces this -- direct
connection.window.show*Message() calls in server code produce lint errors.
Debug logs intentionally keep raw URIs to preserve diagnostic ability.
Cases where apparent duplication is intentional. Each subsection explains why the components stay separate.
format/ (fgfmt bin), transpilers/ (fgtp bin), and binary/ (fgbin bin) stay as separate bundles.
Shared scaffolding (argument parsing, file discovery, output modes) is already extracted to
shared/cli/cli-utils.ts; further consolidation was evaluated and costs more than it saves.
- The transpile bundle is ~12 MB (owns
esbuild+ts-morph+ transform passes). Format and binary bundles are small. A unified binary would load the transpile toolchain on everyformatorbinaryinvocation -- cold-start and install-size regression for the two use cases that don't need it. - The three tools do semantically different jobs: text round-trip, source-to-source
compilation, binary parsing. The shared surface is already shared at the right layer
(
shared/cli/cli-utils.ts); the per-tool bodies are not duplicated. - The format CLI ships as the
fgfmtbin entry within@bgforge/format, the transpile CLI ships as thefgtpbin entry within@bgforge/transpile, and the binary CLI ships as thefgbinbin entry within@bgforge/binary-- each library and its CLI share one package, one version, and one tarball without coupling to the other tools.
The plugins stay in separate packages. They intercept different tsserver methods and have different initialization side effects; merging is mechanically feasible but not worthwhile.
- tssl-plugin proxies
getSemanticDiagnostics,getSuggestionDiagnostics, andgetQuickInfoAtPosition; scopes by.tsslfilename. Purely read-side filtering. - td-plugin proxies
getCompletionsAtPositionand also callsoverrideHost()to inject the TD runtime into the language service host. That host mutation runs once per plugin load; a merged plugin would run it for every TypeScript project, even those with no.tdfiles, widening the blast radius of that side effect. - The two plugins are loaded side-by-side by tsserver via
contributes.typescriptServerPlugins-- having them separate costs one extra plugin registration entry, nothing else at runtime. The build pipeline already calls the samescripts/build-ts-plugin.shfor both with different args.
The feature matrix appears in two forms serving different audiences; both are maintained.
- README — user-facing languages ("Fallout SSL", "WeiDU TP2"), ✓ marks, includes a "Dialog preview" row. Optimized for someone deciding whether the extension supports their workflow.
server/INTERNALS.md#feature-matrix— provider names (fallout-ssl,weidu-tp2),Y/n/a/blank distinction, covers extra providers (weidu-log,worldmap,weidu-tra,fallout-msg,infinity-2da,scripts-lst) that are internals relevant only to implementers.
Collapsing to either form would hide information the other audience needs. Both must be updated when a user-visible feature ships.