Codebase / Codebase development standards
Good compiler work should be easy to read, easy to test and hard to misinterpret. These references cover implementation style, test design and the validation gates that show a change is ready.
This document owns implementation style for new and refactored compiler code.
For test strategy and test construction, read docs/src/docs/codebase/style-guide/testing.mtf.
For final technical gates and completion checks, read docs/src/docs/codebase/style-guide/validation.mtf.
Priorities:
panic!, todo! or user-data-driven .unwrap().CompilerDiagnostic for normal user-facing source, config, import, syntax, type, rule, borrow and deferred-feature diagnostics.CompilerError only for internal compiler, filesystem, backend, dev-server and tooling infrastructure failures.SourceLocation and source labels wherever the compiler can point to useful source context.TypeIds and render them through DiagnosticRenderContext, not cloned DataType values or formatted type names.DiagnosticBag for stage-local accumulation and CompilerMessages only at build/render boundaries.For test assertion policy, see testing.mtf.
i and j.build_ast, generate_hir and emit_wasm.Avoid vague names when they cross more than a few lines or function boundaries:
function_signature over sig.expression over expr outside tiny scopes.data_type or the concrete type name over ty.scope_context or module_environment over ctx or env when several contexts/environments exist.format! and printingsay!() for stdout when creating user-facing messages that may need colour styling in the future.Use variables directly in format! strings whenever possible.
Avoid inline imports. They are only acceptable when they are one level deep and help local reasoning.
// ACCEPTABLE: builtin_type_ids is imported, but kept inline because it's terse and clear.
let type_id = builtin_type_ids::BOOL
// BAD: large inline import, even if this is only used once, it's too noisy inline.
let type_id = crate::compiler_frontend::datatypes::ids::builtin_type_ids::BOOL
// BAD: inline imports with long paths should be imported at the top, even if only used once.
fn lookup_constant(
&self,
) -> Option<(
crate::compiler_frontend::external_packages::ExternalConstantId,
&crate::compiler_frontend::external_packages::ExternalConstantDef,
)> {
...
}
Avoid aliasing unless it clearly improves readability.
mod.rs should be the module entry point and structural map. It exposes the public surface, shows the flow of the module and points to the files that contain the real implementation.mod.rs focused on orchestration, re-exports and documentation rather than core implementation.mod.rs first and quickly understand what the module does, which stages or responsibilities it contains and where to find the important functions and types.mod.rs to explain the module's structure, data flow and why the pieces are arranged that way..unwrap() unless it's blatantly safe and tied to an internal invariant..to_owned() over .clone() when copying owned string-like data..clone() when a general copy is genuinely required and clearer.For test-file placement, see testing.mtf.
Compiler code should optimise for fast human review. Dense code is harder to scan and easier to extend badly.
Prefer code that reads as a sequence of named steps over dense expression chains, clever iterator nesting or large inline matches. The best code in this compiler should make the data flow obvious before the reader understands every detail.
Good compiler code has:
Avoid code that compresses too much logic into one expression. Brevity is less important than clarity.
Use vertical spacing to show structure.
Required:
Prefer this:
let source_file = header.source_file.clone();
let visibility = import_environment.visibility_for(&source_file)?;
// Resolve type aliases before constants because constant folding may depend on alias-expanded types.
self.resolve_type_aliases(&headers, visibility, string_table)?;
self.resolve_constant_headers(&headers, visibility, string_table)?;
Avoid this:
let source_file = header.source_file.clone();
let visibility = import_environment.visibility_for(&source_file)?;
self.resolve_type_aliases(&headers, visibility, string_table)?;
self.resolve_constant_headers(&headers, visibility, string_table)?;
Comments are encouraged when they make code faster to understand. They should be terse and focus on wider context rather than re-describing the code. Write comments aimed at programmers reading the codebase for the first time, trying to navigate around and understand how everything flows and is connected together in the wider picture.
Always:
WHAT comments should mostly provide context about what something does from the perspective of its role in the wider module / compiler. They should only help condense complexity or provide wider codebase/non-local context concisely.
Comments should help a reader understand code locally without reading large parts of the codebase first. They should not restate syntax, especially for functions with already descriptive names and signatures.
Be comment-positive, but not noisy.
They should explain:
Use terse comments to mark:
Good comments are grammatical, readable and explain intent:
// Register same-file declarations before imports so aliases cannot shadow local names.
for declaration in same_file_declarations {
visible_names.register_same_file_declaration(declaration)?;
}
// Prelude names are registered as reserved names first, then inserted only if not shadowed.
for symbol in external_package_registry.prelude_symbols() {
visible_names.reserve_prelude_symbol(symbol)?;
}
When moving code across compiler stages, don't copy the old module shape unchanged. Use the move to correct ownership, names, APIs, comments and data flow.
Moved code must:
Don't preserve a bad API shape just because it already exists.
Order types in a file from higher-level abstractions to lower-level supporting types.
pub struct HirModule { ... }
pub struct HirBlock { ... }
pub struct HirNode { ... }
pub struct HirExpression { ... }
let mut processed_nodes = Vec::new();
for node in ast_nodes {
if let Some(ir_node) = convert_to_ir(&node)? {
if ir_node.is_optimizable() {
processed_nodes.push(optimize_node(ir_node));
}
}
}
Functions should usually stay under ~200 lines. Longer functions are fine when they still represent one coherent operation, such as a compiler transformation, state machine or tightly coupled sequential process.
Split functions when they mix unrelated responsibilities, are hard to test or no longer match their name.
Large matches should be grouped by meaning.
Use blank lines and short comments to make each group obvious. If a match grows too large, extract branch handling into named helpers.
Prefer:
match resolution {
// Constants create dependency edges.
ConstantReference::SourceConstant { path, source_file } => {
add_constant_dependency(header, path, source_file)?;
}
// Type names are valid to resolve but do not create value dependency edges.
ConstantReference::SourceTypeAlias { .. } => {}
// These are structurally invalid in constant initializers.
ConstantReference::SourceNonConstant { path }
| ConstantReference::ExternalNonConstant { .. } => {
errors.push(non_constant_reference_error(path, location.clone()));
}
// Unknown names may produce better diagnostics during AST expression parsing.
ConstantReference::Unknown => {}
}
Avoid long ungrouped matches where every branch looks equally important.
Don't use advanced Rust features just to make code shorter.
Avoid:
Option / Result combinator pipelines for validation logicPrefer named types and straightforward flow:
pub(crate) struct ImportResolution {
pub(crate) target: ResolvedImportTarget,
pub(crate) local_name: StringId,
pub(crate) location: SourceLocation,
}
Instead of:
Result<(ResolvedImportTarget, StringId, SourceLocation), CompilerError>
A few more lines are fine when the result is easier to inspect and harder to misuse.
clippy.allow(dead_code) attributes only with clear justification. Dead code must have a comment stating that it's a todo or used only in tests.Use section banners only in long functions, large files or complex orchestration code where phase boundaries help a reader jump into the process.
Use exactly this format:
// ------------------------
// Resolve template slots
// ------------------------
Rules:
// followed by dashes// and uses one extra leading space after the comment markerGood uses:
The diagnostic system has two paths:
CompilerDiagnostic is the current user-facing diagnostic type. Use typed payloads (DiagnosticPayload) and stable diagnostic codes. This is the correct path for all source-language diagnostics: syntax, type, rule, import, borrow and config.CompilerError is for internal/tooling failures only: compiler bugs, HIR lowering failures, filesystem errors and backend failures. Don't route user-facing diagnostics through CompilerError.CompilerDiagnostic with typed constructors in compiler_diagnostic.rs. Don't add new CompilerError for syntax, type, rule, import or borrow diagnostics.return_compiler_error! only for internal compiler bugs or broken invariants.DiagnosticBag for stage-local accumulation of multiple diagnostics.CompilerMessages only at clear build/render boundaries.Result error boundary carries CompilerDiagnostic or CompilerError and Clippy reports result_large_err, box the payload inside the local boundary enum or use a stage-local boxed diagnostic result alias.DiagnosticBag and CompilerMessages owning plain CompilerDiagnostic values at accumulation and render/build boundaries.Option<CompilerDiagnostic> over Result<(), CompilerDiagnostic> for predicate-only validation helpers that only need to report one diagnostic or continue.Result<_, CompilerDiagnostic>.SourceLocation for every user-facing diagnostic.PathBufs.TypeIds and context enums, not rendered type strings or cloned DataType payloads.
// Typed diagnostic constructor.
return Err(CompilerDiagnostic::invalid_assignment_target(
InvalidAssignmentTargetReason::ImmutableVariable,
variable_name,
location,
));
// Internal bug path.
return_compiler_error!(
"Unsupported AST node type: {:?}",
node_type; {
CompilationStage => "AST Processing",
PrimarySuggestion => "This is a compiler bug - please report it"
}
);
Codebase pages and Moth template references have different jobs.
@page.moth owns the website page, title, introduction, navigation and layoutoverview.mtf provides a compact routing and contract reference and must not be imported by @page.moth.mtf files own detailed design content.mtf files.mtf files should remain useful when read directlyWriting rules:
must and must not language for deliberate stern emphasis.' apostrophes, never U+2019.Before considering an implementation slice structurally complete, check:
This document owns test selection, test structure, assertions, fixture design and test pruning for compiler work.
It does not define the final validation command. For completion gates, read docs/src/docs/codebase/style-guide/validation.mtf.
The primary goal is end-to-end language and artifact correctness. Prefer real usage patterns and complete Moth snippets over narrow implementation-shaped tests.
Integration tests written in Moth are the default for user-visible language behaviour. They exercise the real compiler path and usually provide the strongest regression coverage.
Behavior | Owner |
|---|---|
Pure data or invariant behavior | Focused unit test near the owning subsystem |
Compiler-stage invariant | Stage-local unit test naming the invariant |
Stage-boundary orchestration | Minimal pipeline/build smoke test |
User-facing language behavior | Integration case under |
Backend artifact behavior | Backend-specific artifact assertions or contractual goldens |
Side-table or hidden compiler fact | Focused unit test if external output cannot expose it |
Cross-backend semantic parity | One integration input with backend-specific assertions |
Each behavior should have one primary owner. Secondary coverage is justified only when it protects a distinct boundary, target, diagnostic lane or internal invariant.
src/compiler_frontend/hir/tests/
src/compiler_tests/
tests/cases/
Retain unit tests for:
Prefer integration tests for user-visible behavior. Once a subsystem is stable, prune superseded unit tests. Rewrite obsolete tests rather than preserving old API shapes. A unit test should name the invariant it protects. Do not use unit coverage as a substitute for an end-to-end case when user behavior changed.
moth tests runs the integration test runner.input/ directory.io.line(...) unless console behavior is itself under test.Every successful HTML and HTML-Wasm backend run applies its universal backend baseline. The baseline protects harness and target invariants. It is not the case's authored semantic contract.
Use success_contract = "acceptance_only" only when that backend has no stronger case-specific semantic, artifact, golden, absence or expected-warning assertion.
[backends.html]
mode = "success"
warnings = "forbid"
success_contract = "acceptance_only"
Acceptance-only intent does not disable the baseline. The audit records both baseline_applied = true and acceptance_only = true, with backend_baseline and acceptance_only in assertion_kinds.
Do not combine acceptance-only intent with artifact assertions, golden mode, rendered-output assertions, artifact-absence assertions or an authored expected-warning contract. A whole case whose backends are all acceptance-only uses role = "smoke". A mixed-backend case may use a boundary or backend role when another backend owns a stronger contract.
A successful backend with only the universal baseline is invalid. Typed fixture loading rejects it before policy evaluation, listing, audit, or execution.
For new type-system syntax, include:
Canonical fixture shape:
tests/cases/
├── manifest.toml
└── case_name/
├── input/
│ ├── main.moth
│ └── helper.moth
├── expect.toml
└── golden/
├── html/
└── html_wasm/
manifest.toml is the authoritative case order. Every case declares a unique canonical id, unique fixture path and at least one non-empty tag.tests/cases/, including through symlinks.role. Supported roles are primary, boundary, backend, adversarial and smoke.contract. A whole-case acceptance-only smoke may remain contractless because it has no case-specific semantic owner. Do not invent a contract for it.primary case requires a contract, and one contract may have at most one primary case.Manifest entry shape:
[[case]]
id = "borrow_conflict_resolved_by_reordering"
path = "borrow_conflict_resolved_by_reordering"
tags = ["integration", "borrows"]
contract = "language.bindings.alias_final_use_allows_mutable_rebinding"
role = "primary"
Use the canonical case ID and retained metadata for local iteration:
cargo run --quiet -- tests --case arithmetic_operator_precedence --backend html
cargo run --quiet -- tests --tag borrows --tag diagnostics --backend html
cargo run --quiet -- tests --contract
cargo run --quiet -- tests --list --tag borrows
--case and --contract are exact matches.--tag filters compose as logical AND.--list accepts selection filters and reports canonical ID, selected backend blocks, tags, contract and role without compiling cases.Use the audit mode to validate and inventory the complete canonical suite without compiling cases:
cargo run --quiet -- tests --audit
Audit writes its JSON inventory before returning hard-policy failure. Normal listing and execution use the same policy evaluator and reject hard findings before compiling a case. Missing roles, missing contracts on non-smoke cases, primary cases without contracts and duplicate primary owners are hard findings. Contractless whole-case acceptance-only smoke cases are valid.
Audit cannot be combined with filters or --list. It writes the reporting-owned JSON inventory to target/test-reports/integration_suite_inventory.json, including canonical metadata, backend expectation facts, hard-policy violations and primary-less contract-family advisories. The report is review input only; it never deletes, merges or rewrites fixtures.
The schema 6 inventory reports acceptance-only, rendered-output, artifact, golden, absence and expected-warning backend counts, plus the zero baseline-only invariant. It reports exact, ordered and exact-once runtime contracts separately. Per-backend fields keep baseline_applied, acceptance_only, assertion_kinds, golden_present, golden_mode, every rendered-output form, exact warning codes, diagnostic match mode and any contains-mode reason separate so the report matches what ran.
Hard policy has one suite-policy owner. Missing roles, missing non-smoke contracts, duplicate primary contracts, a primary without a contract and a whole-case acceptance-only fixture without role = "smoke" are hard findings. Normal list and execution modes reject hard findings before selection or compilation. Audit still writes the complete JSON report, then returns failure. Primary-less contract-family advisories remain non-fatal.
Runtime assertions use one Node harness. It records console output and HTML fragment inserts in one event array when each event happens, then joins their text with newlines in that same order. Channel views are derived from the event array. They never reconstruct chronology after execution.
The harness runs only when a backend declares a rendered-output assertion. It executes the emitted page scripts and waits for one documented microtask tick so scheduled reactive updates can flush. Do not make extra scheduler turns or incidental microtask counts contractual.
Choose the narrowest field that owns the behaviour:
rendered_output_exact protects the complete combined output. It is mutually exclusive with the other rendered-output fields. Exact comparison normalizes line endings only and preserves every other whitespace difference.rendered_output_contains_in_order protects the relative order of two or more fragments.rendered_output_contains_exactly_once protects activation, mounting and helper output from duplication.rendered_output_contains and rendered_output_not_contains protect required and forbidden fragments when complete output isn't the contract.
[backends.html]
mode = "success"
warnings = "forbid"
rendered_output_contains = ["active-root"]
rendered_output_not_contains = ["imported-root"]
rendered_output_contains_in_order = ["before-loop", "after-loop"]
rendered_output_contains_exactly_once = ["active-root", "mounted"]
Use exact output for small deterministic behaviours such as loop or map operation results. Use ordered and exact-once fragments when unrelated output may coexist. Prefer these runtime contracts over generated JavaScript text or a whole-page golden when execution is the real owner.
diagnostic_codes.diagnostic_codes is an exact unordered multiset by default. Repeat a code when the diagnostic must occur more than once.ErrorType.
[backends.html]
mode = "failure"
warnings = "forbid"
diagnostic_codes = ["MOTH-RULE-0033", "MOTH-RULE-0033"]
Use diagnostic_assertions when a broad code needs a compiler-owned reason or when source remapping is part of the contract. Each table selects one diagnostic by stable code and one-based occurrence. A unique code may omit occurrence. Repeat the code in diagnostic_codes to preserve its exact multiplicity.
[[backends.html.diagnostic_assertions]]
code = "MOTH-RULE-0057"
reason = "invalid_generic_instantiation.recursive_function_instantiation"
path = "input/@page.moth"
line = 3
count = 1
[[backends.html.diagnostic_assertions.secondary_labels]]
occurrence = 1
path = "input/helpers.moth"
line = 2
message_contains only when rendered wording or label prose is itself contractual.Use diagnostic_match = "contains" only when intentional independent recovery or cascades make additional diagnostics part of the accepted current behaviour. Every contains-mode block needs a substantive diagnostic_match_reason. The suite audit reports the reason and treats a missing or blank reason as a hard policy finding.
diagnostic_match = "contains"
diagnostic_match_reason = "Independent recovery may emit additional diagnostics from another source unit."
Warnings use a separate backend-local contract:
warnings = "forbid" rejects every warning and accepts no warning_codes field.warnings = "ignore" deliberately makes warnings non-contractual and accepts no warning_codes field.warnings = "exact" requires warning_codes as an exact unordered multiset. Repeat duplicate codes to preserve their required count.
[backends.html]
mode = "success"
warnings = "exact"
warning_codes = ["MOTH-RULE-0022", "MOTH-RULE-0022"]
rendered_output_contains = ["warning-contract result=one"]
Use one expect.toml per case with backend-specific assertion blocks.
entry = "."
[backends.html]
mode = "success"
warnings = "forbid"
artifacts_must_not_exist = ["unexpected.html"]
[backends.html_wasm]
mode = "success"
warnings = "forbid"
[[backends.html_wasm.artifact_assertions]]
path = "page.wasm"
kind = "wasm"
validate_wasm = true
must_export = ["memory", "moth_str_ptr", "moth_str_len", "moth_release"]
entry = "." is the one supported current-directory sentinel and selects the case's input/ directory. Every other entry is a relative contained path with no absolute, platform-prefix, parent or current-directory component. Its canonical path must remain inside the owning input/ directory, including through symlinks.
golden//
artifacts_must_not_exist for success cases whose contract requires an exact normalized output path to be absent. Paths reported as NotBuilt are ignored because they were never emitted.golden_mode without at least one discovered file is invalid. When files exist, strict is the default if no mode is authored. When no files exist, audit reports golden_present = false and golden_mode = null.tempfile::tempdir() for temporary directories and files.dev/ or release/ outputs from test or benchmark projects.Benchmark fixtures are performance evidence, not correctness coverage.
Do not use benchmark coverage as a substitute for unit or integration tests. Negative diagnostics belong in tests/cases, not benchmark fixtures.
Use bench-check to detect broad performance regressions after correctness has already been established by ordinary tests.
Before considering test coverage complete, check:
This document defines the technical gates a completed compiler or documentation slice must pass.
The required final gate depends on whether the slice changes implementation or is strictly documentation-only.
Use an up-to-date release build of the compiler when one is available. The moth command in PATH is the usual direct form:
moth check docs --terse
moth build docs --release
If a suitable release build is not available, run the same operations through Cargo from the repository root:
cargo run --quiet -- check docs --terse
cargo run --quiet -- build docs --release
Slice | Required final gate |
|---|---|
Code-bearing or mixed change | |
Strictly documentation-only change | |
The justfile is the executable authority for the full code-bearing gate.
A slice is code-bearing if it changes Rust, compiler or build-system sources, libraries, tests, benchmarks, manifests, scripts, configuration or any other non-documentation implementation file.
Before declaring a non-trivial code-bearing slice complete, run:
just validate
Gate | Current command | Purpose |
|---|---|---|
Cross-target Clippy | | Native, Linux x64 and Windows x64 linting with warnings denied |
Rust unit tests | | Rust unit and subsystem tests across the complete workspace |
Compiler integration tests | | Canonical suite policy followed by end-to-end compiler cases |
Documentation check | | Moth docs-source checking inside the full gate |
Benchmark sanity check | | Complete benchmark preflight followed by bounded non-recording measurements |
ci-clippy uses the pinned Rust toolchain configured in the justfile.xtask packages.bench-ci preflights every benchmark manifest case once, then measures only the quick CLI and frontend subsets with three iterations.bench-ci does not update local history or tracked benchmark summaries.The integration command loads the complete canonical suite and applies the shared hard-policy evaluator before selection or compilation. This makes missing ownership, duplicate primaries and other hard suite-policy findings part of the normal validation gate. Run the audit command when the JSON inventory under target/test-reports/ is also required:
cargo run --quiet -- tests --audit
A slice is documentation-only only when every modified file is documentation source or generated documentation.
Allowed examples include:
docs/**README.md, CONTRIBUTING.md or AGENTS.mddocs/release/**A documentation-only slice must not change Rust, tests, libraries, build configuration, manifests, scripts, benchmark code or fixtures, compiler configuration or other executable project sources.
For a strictly documentation-only slice, run one documentation release build using a current compiler.
The direct release-binary form is:
moth build docs --release
If no suitable release build is available, use cargo run --quiet -- build docs --release instead.
Then:
Do not additionally run just validate, Clippy, unit tests, integration tests or benchmark checks for a documentation-only slice. A Cargo invocation of the compiler is an alternative way to run the same documentation gate, not an extra gate.
If any non-documentation file changed, the documentation-only exception does not apply. Use the full code-bearing gate.
Task | Command |
|---|---|
Unit-test iteration | |
Integration iteration | |
Clippy iteration | |
Docs-source iteration | |
Bounded benchmark sanity | |
Benchmark-only preflight | |
Full CLI benchmark check | |
Full frontend benchmark check | |
Documentation release-build iteration | |
Complete code-bearing gate | |
Passing one targeted command does not imply a code-bearing slice passes the complete gate.
cargo fmt when Rust files changed.just validate does not currently format source automatically.The ship recipe formats before validation, while validate itself does not.
Do not edit files under docs/release/** directly.
The documentation release build is:
moth build docs --release
A generated diff must result from documentation source changes, not manual HTML edits.
When a code-bearing slice changes stage ownership, frontend boundaries, HIR, diagnostics, types or backend handoff, perform a manual architecture audit in addition to the automated gate.
Audit that:
TypeEnvironment and TypeIdCompilerDiagnosticCompilerErrorThis full architecture audit is not required for a prose-only documentation change.
Review the progress matrix whenever behaviour, support, rejection, backend coverage or test coverage changes.
Update it when current status changed. Do not make a meaningless edit for a pure refactor or prose-only correction.
just validate uses non-recording bench-ci.bench-ci validates every benchmark case before it measures the quick subset. It replaces the full ten-iteration CLI and frontend checks inside the normal validation gate.just bench-check and just bench-frontend-check for deliberate full-suite performance evidence. Both commands remain non-recording.Before declaring a non-trivial code-bearing slice complete, check:
style-guide.mtf.testing.mtf.just validate passes.Before declaring a documentation-only slice complete, check: