Architecture¶
Tua is organized around one reusable language engine. The CLI and language
server provide different transports and lifecycles, but both read the same
project model, semantic data, diagnostics, and generated Lua from tua-core.
This page documents the current implementation boundaries for contributors. For language behavior, see Language and Typing In Tua.
Keep semantics in shared analysis
A CLI command, LSP handler, or VS Code feature must not recreate parser,
type, module, or domain knowledge. Add the fact or query to tua-core, then
adapt its result at the edge.
At A Glance¶
flowchart LR
Editor[VS Code] --> Extension[editors/vscode]
Extension -->|starts tua lsp| CLI[tua executable]
CLI --> LSP[tua-lsp]
CLI --> Core[tua-core]
LSP --> Core
Core --> Lua[Lua emission and provenance]
CLI --> Output[Lua files and assets]
The implementation follows four rules:
- One semantic authority. Parsing, types, module resolution, diagnostics,
and editor facts live in
tua-core. - Revisioned queries. Mutable project inputs belong to
AnalysisHost; requests read immutableAnalysisSnapshotvalues. - Side effects stay at the edges. The CLI owns disk output and watch events. The LSP owns JSON-RPC and protocol conversion. The extension owns VS Code integration.
- Dynamic Lua stays conservative. Static analysis reports what source and configured catalogs establish. It does not execute project code or invent identities for dynamic values.
Shader compilation follows the same boundary: tua-core owns the request
protocol and diagnostics, the CLI supplies a bounded LÖVE process adapter, and
the LSP owns idle scheduling and stale-result rejection. The executable injects
the adapter through run_stdio_with_shader_validator; core analysis never
starts a process. Headless fallback headers are generated from the vendored
LÖVE 11.5 wrapper by scripts/generate-shader-headers.py; core wraps stages and
maps user diagnostics, while CLI owns bounded glslang processes. Header failures
remain tool failures instead of being attributed to user source. Editor shader overlays reuse the shared asset inventory.
Uniform metadata lives in the asset index as immutable shared models, reused
by the checker and editor queries. Disk refreshes compare shader content and
reuse unchanged models; synchronized overlays replace only the current
snapshot's model. Completion, hover, and definition do not read shader files.
Models retain GLSL types, array bounds, original declaration spans, entrypoints,
and read status. Shader-local resolution uses the shared lexical resolver.
Repository Boundaries¶
| Path | Owns | Does not own |
|---|---|---|
crates/tua-core |
Source models, parser, HIR, scopes, types, checking, linting, diagnostics, project model, VFS, module graph, IDE queries, domain analysis, lowering, and Lua emission | Filesystem watching, JSON-RPC, terminal presentation, or editor UI |
crates/tua-cli |
Arguments, command orchestration, project discovery, builds, output files, asset copying, watch mode, source maps, traceback mapping, and terminal diagnostics | Independent language or editor semantics |
crates/tua-lsp |
Stdio transport, scheduling, cancellation, document synchronization, settings, capability advertising, URI and position conversion, and LSP request routing | Parsing or inferring source in handlers |
editors/vscode |
Language registration, grammar, settings, tasks, extension activation, server discovery, custom views, packaging, and extension tests | Type inference or project analysis in TypeScript |
The Rust dependency direction is intentionally small:
flowchart TD
CLI[tua-cli] --> Core[tua-core]
CLI --> LSP[tua-lsp]
LSP --> Core
tua-core must remain usable without an editor or command-line process. This
keeps semantic tests fast and lets future integrations reuse the same engine.
Core Analysis Model¶
flowchart TD
Inputs[Config, roots, catalogs, and files] --> Host[AnalysisHost]
Overlays[Open document overlays] --> Host
Host --> Snapshot[AnalysisSnapshot at revision N]
Snapshot --> FileFacts[Per-file semantic facts]
Snapshot --> ProjectFacts[Project indexes and graphs]
FileFacts --> Queries[Diagnostics, IDE, and compiler queries]
ProjectFacts --> Queries
Inputs And Identity¶
ProjectModel interprets workspace configuration: source and output roots,
declaration-only type-library roots and module-prefix mappings, asset roots,
ignored paths, file associations, and module paths. Vfs stores the
corresponding analysis files. An open document overlay takes precedence over
the disk copy until the document closes, so editor requests always analyze the
text the user can see.
Each path's effective visible source and source kind has a monotonic VFS version. A VFS lineage shares its allocator across worker and ephemeral host clones, while snapshots freeze the version they observed. Identical updates retain the version; changing the visible overlay or disk value receives a new one. Do not detach this allocator when resetting clone-local analysis caches.
Paths are normalized before they become analysis identities. Module, symbol, asset, and reverse-reference indexes should use those normalized identities instead of retaining ad hoc path strings.
AnalysisHost owns mutable inputs and the current revision. Updating a file,
document overlay, project option, or builtin catalog invalidates the affected
cached products and advances that revision.
AnalysisSnapshot is an immutable view of one revision. It combines cached
per-file facts with project-wide indexes and exposes stable query APIs to the
CLI and LSP.
Per-file analysis records are immutable and shared by reference between the host cache and snapshot copies. Snapshot clones also share their immutable project-wide indexes. Updating a file creates new records and indexes; in-flight requests retain the previous revision without copying its AST and semantic data. Identical builtin alias and local catalogs also share storage across files; project-specific additions use copy-on-write storage.
Module-path completion follows the same boundary. Workspace indexing and file
events provide normalized module paths and workspace-scoped require roots to
AnalysisHost; each snapshot combines that inventory with live VFS overlays
in a ModulePathIndex. require(...) and import(...) completion query that
index without walking the live filesystem, so output roots, associated Lua
files, unsaved documents, and the rest of the query observe one consistent
revision. Normal require(...) and non-relative static imports consume the same
ordered workspace module map, while child-relative imports use paths near the
current file. Require-root order remains project-model order so source modules
keep precedence over duplicate generated modules. The index has its own
revision and is reused across source-content edits until its roots, separator,
or path inventory changes. Configured roots are indexed recursively; a
configless workspace indexes direct siblings at its root and around opened or
watched files instead of recursively walking an unbounded workspace folder.
Closing a document that has no file on disk removes its VFS entry and module
path, preventing unsaved modules from leaking into later snapshots.
Declaration libraries enter the VFS as Lua analysis inputs, but remain outside
source roots, require search roots, asset copying, and emission. During snapshot
construction, ---@meta identities are mapped to configured runtime
require(...) names and added as declaration overlays in the shared module
graph. The type database and editor queries consume that same resolved module
instead of maintaining a separate external-library path.
| Use | API |
|---|---|
| Update configuration, roots, catalogs, disk files, or open documents | AnalysisHost |
| Serve a fast query against the current file during typing | Current-file AnalysisHost query |
| Resolve modules, symbols, references, assets, or project types | AnalysisSnapshot query |
| Keep several related reads internally consistent | One shared AnalysisSnapshot |
Snapshot once per logical operation
A handler or command that needs several project facts should take one snapshot and reuse it. Taking snapshots between related reads can mix revisions and duplicate project work.
Per-File Products¶
The per-file pipeline produces reusable semantic data rather than a separate parse for every feature:
flowchart LR
Source --> Normalize[Normalize syntax]
Normalize --> Parse[Parsed source]
Parse --> HIR[HIR and scopes]
HIR --> Types[Type environment]
Types --> Summary[Exports, usages, assets, and domain summaries]
Types --> Diagnostics[Check and lint diagnostics]
A successfully parsed file has one semantic bundle containing its HIR, scope map, and base type environment. Other per-file products include module exports and require edges, symbols, member usages, asset references, and compact summaries for Signals, ECS, and object pools.
Temporarily invalid text follows the shared recovery path. The lossy parse and degraded environment are cached for that snapshot so hover, completion, and other typing features can keep working without polluting the valid semantic cache.
Do not add feature-specific fallback parsing
If an editor feature fails on incomplete source, improve the shared parser recovery or degraded semantic facts. A private fallback environment inside completion, hover, or an LSP handler will drift from the checker.
Project Products¶
Snapshots aggregate per-file summaries into project-aware structures:
ModuleGraphconnects imports and exports.ProjectTypeDatabaseresolves exported and cross-file types.- Symbol and usage indexes support workspace symbols, references, rename, call hierarchy, and ranking.
- Asset indexes support validation, completion, hover metadata, and rename.
- Signal, ECS, and object-pool graphs support diagnostics and structured inspector views.
Project indexes retain compact facts rather than duplicating all project source text. Reverse relationships are derived from indexed files and reused by navigation queries.
Query Surface¶
The public analysis API is the integration boundary. A minimal core consumer looks like this:
use std::path::PathBuf;
use tua_core::{AnalysisHost, AnalysisSourceKind, SourcePosition};
let path = PathBuf::from("src/main.tua");
let mut host = AnalysisHost::new();
host.set_source_roots([PathBuf::from("src")]);
host.set_file_with_kind(
path.clone(),
"local score: number = 0\n",
AnalysisSourceKind::Tua,
);
let snapshot = host.snapshot();
let hover = snapshot.hover(&path, SourcePosition { line: 1, column: 8 });
Snapshot queries cover diagnostics, completion, hover, signature help, inlay hints, semantic tokens, symbols, links, code actions, navigation, references, rename, call hierarchy, module graphs, assets, type explanations, and domain inspectors. Prefer extending that surface over exposing internal parser or cache types to another crate.
Query Contexts And Semantic Domains¶
Recursive semantic work carries an explicit query context. Type inference, checking, and completion should construct that context once and pass it through the entire operation instead of repeatedly threading parsed source, type environments, local bindings, configuration, and ranking inputs through long procedural argument lists. Besides keeping entry points small, one context ensures every nested walk observes the same semantic inputs.
Built-in language domains are registered through the internal
BuiltinDomainRegistry. The registry owns their stable ordering and shared
hooks for type names, aliases, constructors, typed members, inferred method
returns, and diagnostics. Completion-only presentation hooks live behind the
CompletionDomainRegistry, which converts domain results into protocol-neutral
completion items. Adding a built-in domain should extend these registries and
the domain's own module instead of adding another parallel chain to the type
environment, checker, and completion dispatcher.
These are closed compile-time integration points, not a runtime plugin API. Third-party Lua libraries continue to enter through declaration-only type libraries and normal module resolution; semantic queries never load executable plugins or fetch code from the network.
Editor Request Flow¶
sequenceDiagram
participant E as Editor
participant L as tua-lsp
participant H as AnalysisHost
participant S as AnalysisSnapshot
participant C as tua-core query
E->>L: didOpen / didChange
L->>H: update document overlay
H-->>L: new revision
E->>L: completion / hover / definition
alt file-local request
L->>H: run current-file query
H->>C: read cached file facts
C-->>L: core result
else project-aware request
L->>H: request snapshot
H-->>S: freeze revision
S->>C: read semantic and project facts
C-->>L: core result
end
L-->>E: LSP result
The server separates transport and scheduling from language work. Interactive requests use the responsive worker path, while indexing and diagnostics can be debounced or warmed in the background. Current-file diagnostics can appear quickly during typing; project-aware diagnostics follow when the relevant snapshot is ready.
Results are associated with document versions and analysis revisions. The server discards stale work instead of publishing it over newer text. Cancellation is cooperative at the scheduling boundary: a core query that already started may finish, but its stale result must not be applied.
LSP handlers should have a narrow shape:
- Convert URI, position, and settings into core inputs.
- Select a current-file query or take one snapshot.
- Call a typed
tua-corequery. - Convert the core result into an LSP response.
The server classifies request and notification method strings once through its typed protocol registry before dispatch. Handlers match those request kinds; they do not maintain independent string-based routing or domain semantics.
If a handler starts parsing text, resolving a module, or constructing a type, the behavior belongs lower in the stack.
Build And Watch Flow¶
flowchart LR
Config[Project config] --> Discover[CLI source discovery]
Discover --> Host[AnalysisHost]
Host --> Check[Core diagnostics]
Check --> Emit[Core lowering and LuaEmission]
Emit --> Write[CLI output and source maps]
Assets[Asset roots] --> Copy[CLI asset copy]
Copy --> Write
The build path is split by responsibility:
- The CLI loads configuration, discovers project files, and updates one
AnalysisHost. - Core analysis checks the same sources and dependencies used by editor features.
- Core lowering returns
LuaEmission: generated Lua plus source provenance. - The CLI writes Lua, helper runtimes that are actually required, assets, Tua provenance maps, and standard debugger-compatible source maps.
- Runtime traceback mapping consumes compiler provenance instead of guessing line offsets from generated text.
The VS Code LOVE debug command consumes those build artifacts rather than adding debugger semantics to the compiler or language server. It launches an external Lua debug adapter against generated Lua and lets the adapter resolve Tua breakpoints and stack frames through the standard maps.
Watch mode keeps a long-lived host while project configuration remains stable.
File events update the VFS, invalidate affected importers, and rebuild from the
new snapshot. Filesystem watching and output suppression stay in tua-cli;
semantic invalidation stays in tua-core.
Semantic Ownership¶
Use this map when deciding where a change belongs:
| Concern | Primary modules |
|---|---|
| Surface syntax, normalization, comments, and spans | parser, parser source models |
| Declarations, expressions, scopes, and semantic identity | hir, scope, semantic_index |
| Type representation, inference, assignability, and project exports | types, type_database |
| User-facing errors, warnings, and inline ignores | check, diagnostic, inline_ignore |
| Canonical source layout | formatter |
| Code-quality and standard lint rules | linter |
| Roots, files, module paths, imports, and project identity | project_model, vfs, module_graph, module_index, module_link |
| Revisioned inputs, cached products, recovery, and public queries | analysis |
| Completion, hover, navigation, symbols, refactors, and editor data | Corresponding tua-core feature module, exposed through analysis |
| Lowering, helper selection, generated Lua, and provenance | emit |
| LOVE, assets, sprites, Signals, stores, input, ECS, pools, and vectors | Shared domain modules and analysis queries |
Domain support follows the same architecture as language support. LOVE and Lua catalogs contribute typed facts; asset and gameplay helpers contribute indexed summaries; editor features consume those facts through shared queries. Custom VS Code views render structured results but do not derive them.
Architecture Invariants¶
These are review requirements, not future goals:
- Core IDE queries do not read files directly from disk.
- LSP handlers and extension code do not duplicate semantic analysis.
- Diagnostics use the shared diagnostic model and stable Tua codes.
- A snapshot never observes half of a newer revision.
- Invalid-source support uses shared recovery rather than one-off parsers.
- Cross-file features use normalized project identities and indexes.
- Dynamic project code is never executed to answer an editor query.
- Generated output remains portable Lua 5.1-compatible code with source provenance.
- Optional helper runtimes are emitted only when lowering requires them.
The explicitly started tua api --stdio session exposes these snapshot queries
as read-only newline-delimited JSON-RPC. tua mcp --stdio maps twelve tools
onto the same session. Neither starts from LSP, opens a socket, calls a model,
or applies edits. See External API And MCP.
Model completion follows the same ownership boundary. Core constructs bounded, fingerprinted FIM context and validates candidates in an ephemeral cloned analysis host. Core also classifies local versus function-body completion and owns the corresponding candidate limits. LSP only converts protocol positions and versions. The VS Code extension owns loopback Ollama transport and ghost-text lifecycle, so ordinary compiler completion remains deterministic and model-independent.
Deterministic inline completion uses a separate host-level query. Core owns
eligibility, source-only statement snippets, current-file recall, ranking, and
parse-only validation. The Phase 1 query reads the current VFS document and may
peek at an exactly matching cached file, but it never constructs a snapshot or
runs project analysis. The LSP supplies configuration, a fixed deadline, and a
cancellation flag, converts protocol ranges, and schedules requests as
non-interactive newest-wins work on a single speculative worker. That lane
keeps at most one queued request per document and replaces it in place, so
cancelled keystroke bursts cannot grow an unbounded queue or occupy the menu
completion workers. The coordinator prepares an owned source query without
parsing, then gives the worker a bounded priority window for returning it. An
already-completed workspace warm-up may wait for that window, but edits,
cancellation, menu completion, and ordinary completed requests do not. VS Code
owns the single debounced inline provider and tries deterministic results before
the optional Ollama path. After its 65 ms debounce, the provider bounds the
local request to 250 ms with a linked cancellation token; timeout is a transient
abstention rather than an Ollama fallback trigger.
Disabled or malformed local requests never enter the speculative queue, and a
panic in candidate generation is contained to that request so the worker can
serve the next keystroke.
The hidden Phase 2 project-wide exact-retrieval experiment uses an immutable,
host-owned corpus that is not part of AnalysisSnapshot. Background workers
receive owned source/version inputs and a caller-supplied loaded project model,
then return immutable per-file tables. The coordinator adopts each table only
while its effective VFS version and eligibility generation still match. A
merged base plus a small overlay of replacements and tombstones lets edits
become stale immediately without mutating shared state; request-time queries
read only that corpus, the active VFS source, and effective VFS versions.
Module targets are resolved and declared LÖVE callbacks are collected during
background construction, never by loading a model or reading disk on the
request path. Callback generation remains deferred pending evaluation; the
collected declarations are foundation for later cross-file duplicate
suppression.
The LSP keeps this source disabled unless its internal experimental source flag
is explicitly enabled; the extension does not contribute that setting.
didChange only advances the VFS version and moves a per-file deadline; after
300 ms of idle time, the background lane builds at most 16 files per batch.
Base remerges use the same prepare/build/adopt boundary: construction stays on
the worker, while the coordinator accepts the result only if the corpus
identity, VFS lineage, generation, and every input version are still current.
Corpus queue, adoption, memory, cancellation, and remerge counters are
available through tua/cacheStats and tua/health.
The bounded request path uses the currently tuned policy only after semantic
and current-file sources abstain: comma, operator, and other contexts, at least
six non-whitespace prefix characters, and at least two supporting matches from
a directly required module. Unrelated project, same-directory, and
bundled-corpus evidence are not used. This integration has not passed held-out,
external, or aggregate phase gates and is not a public completion capability.
The Phase 3 statistical experiment reuses the same immutable corpus and background build/adopt lifecycle rather than creating another index or worker. It adds bounded token-count tables for Tua files when the statistical source is explicitly enabled. When project exact retrieval is disabled, statistical-only builds omit its original-line tables entirely; the corpus then retains token counts and provenance only. Query-side identifier slots use bounded scope facts captured by normal analysis warm-up. The scope cache may retain the last analyzed source for small active Tua files so normalized parser positions can be mapped back to original coordinates; cold or changed suffixes fall back to conservative slots instead of preventing forward typing. Each generated token needs evidence from at least two distinct files at the context order that chose it, and decoding is bounded by file, byte, candidate, beam, token, deadline, and cancellation limits before entering the same suffix and parse-delta validator as exact sources. The extension exposes this source as an explicit default-off experiment because tuning evidence is too sparse for quality approval.
The custom and standard inline-completion protocols are adapters over this one
core path. tua/localInlineCompletion carries Tua source metadata for the VS
Code extension's local counters; textDocument/inlineCompletion returns the
portable LSP shape. Both are routed through the same speculative newest-wins
queue. During initialization, the Tua VS Code extension declares its combined
provider and the server omits inlineCompletionProvider for that client;
standalone clients still receive the standard capability.
Developer menu-state replay follows the same boundary. When two explicit environment variables configure a local capture and deterministic inline completion is enabled, the extension records normalized edited-document hashes, dataset-relative paths, UTF-16 positions, document versions, edit delays, menu state, and the selected completion text/range when present. The relative path exposes part of the dataset layout, and selected text can retain source-derived identifiers or literals, so the JSONL is potentially sensitive local evaluation data even though it omits the document body and absolute URI. The extension normalizes CRLF to LF before hashing, matching the evaluator's canonical scenario text. Core generates the evaluator's known-truth scenario documents and applies a record only when its hash and position match one exactly. This allows real unsaved edits without storing the document body while preventing menu state from being applied to a different document. No production request path reads a capture file, and no capture is uploaded.
Change Workflows¶
Add Or Change Language Behavior¶
- Update parser normalization and source models where syntax changes.
- Represent declarations and expressions in shared HIR and scope data.
- Implement type behavior in the shared environment and type database.
- Add checker diagnostics and lowering from the same semantics.
- Expose reusable facts through analysis queries for editor features.
- Add focused parser, checker, emitter, and analysis tests, then update the language documentation and examples.
Add An IDE Or LSP Feature¶
- Define a protocol-neutral result type and query in
tua-core. - Reuse semantic files, project indexes, and shared resolvers.
- Add focused core tests for valid, incomplete, and cross-file source.
- Add a thin
tua-lspconversion and handler with protocol tests. - Change
editors/vscodeonly when VS Code needs custom UI or configuration.
Add A Domain Inspector¶
- Extract a compact summary while analyzing each file.
- Aggregate summaries in a revisioned snapshot graph.
- Expose structured graph data through a core query.
- Route a custom LSP request only if standard LSP cannot represent it.
- Keep rendering and interaction in the extension.
Change Build Or Watch Behavior¶
Put language lowering and provenance in tua-core. Put source discovery,
filesystem events, generated-file writes, asset copying, and terminal reporting
in tua-cli. Test invalidation with an existing host rather than rebuilding a
new analysis world for every event.
Verification¶
Test at the layer that owns the behavior, then run the integration boundary it affects:
| Change | Focused verification |
|---|---|
| Parser, types, diagnostics, IDE facts, or emission | cargo test -p tua-core |
| LSP routing, freshness, protocol conversion, or scheduling | cargo test -p tua-lsp |
| CLI commands, builds, source maps, or watch mode | cargo test -p tua-cli |
| Real editor and closed-file behavior | make editor-regression-smoke |
| Documentation structure and examples | make docs-build |
| Main repository checks | make verify |
Use the smallest focused test while iterating. Before merging a cross-layer
change, include the relevant integration test and run make diff-check.
Non-Goals¶
- Tua does not run user code to improve static analysis.
- Tua does not make the VS Code extension a second language engine.
- Tua does not require an LSP process to compile a project.
- Tua does not optimize architecture around giant-workspace machinery before measurements justify the complexity.
- Tua does not hide generated output behind an opaque runtime; emitted Lua is a readable, debuggable build artifact.