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 the Language and Toolchain Reference.
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.
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.
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.
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.
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.