
Testing Hashql
- 2 installs
- 1.6k repo stars
- Updated August 5, 2026
- hashintel/hash
Provides HashQL testing strategies including compiletest UI tests, unit tests, snapshot tests, //~ annotations, and --bless.
About
Explains the three HashQL testing approaches, with compiletest as the default for testing compiler behavior via //~ annotations and snapshot tests. A developer uses it when writing or debugging tests for HashQL compiler code.
- compiletest is the default for compiler behavior
- Covers insta snapshot tests and --bless
Testing Hashql by the numbers
- 2 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #1,683 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/hashintel/hash --skill testing-hashqlAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2 |
|---|---|
| repo stars | ★ 1.6k |
| Last updated | August 5, 2026 |
| Repository | hashintel/hash ↗ |
What it does
Provides HashQL testing strategies including compiletest UI tests, unit tests, snapshot tests, //~ annotations, and --bless.
Files
HashQL Testing Strategies
HashQL uses three testing approaches. compiletest is the default for testing compiler behavior.
Quick Reference
| Scenario | Test Type | Location |
|---|---|---|
| Diagnostics/error messages | compiletest | tests/ui/ |
| Compiler pipeline phases | compiletest | tests/ui/ |
| MIR/HIR/AST pass integration | compiletest | tests/ui/ |
| MIR/HIR/AST pass edge cases | insta | tests/ui/<category>/ |
| MIR pass unit tests | MIR builder | src/**/tests.rs |
| Core crate (where needed) | insta | src/**/snapshots/ |
| Parser fragments (syntax-jexpr) | insta | src/*/snapshots/ |
| Internal functions/logic | Unit tests | src/*.rs |
compiletest (UI Tests)
Test parsing, type checking, and error reporting using J-Expr files with diagnostic annotations.
Structure:
package/tests/ui/
category/
.spec.toml # Suite specification (required)
test.jsonc # Test input
test.stdout # Expected output (run: pass)
test.stderr # Expected errors (run: fail)
test.aux.svg # Auxiliary output (some suites)Commands:
cargo run -p hashql-compiletest run # Run all
cargo run -p hashql-compiletest run --filter "test(name)" # Filter
cargo run -p hashql-compiletest run --bless # Update expectedTest file example:
//@ run: fail
//@ description: Tests duplicate field detection
["type", "Bad", {"#struct": {"x": "Int", "x": "String"}}, "_"]
//~^ ERROR Field `x` first defined hereDirectives (//@ at file start):
run: pass/run: fail(default) /run: skipdescription: ...(encouraged)name: custom_name
Annotations (//~ for expected diagnostics):
//~ ERROR msg- current line//~^ ERROR msg- previous line//~v ERROR msg- next line//~| ERROR msg- same as previous annotation
📖 Full Guide: references/compiletest-guide.md
Unit Tests
Standard Rust #[test] functions for testing internal logic.
Location: #[cfg(test)] modules in source files
Example from hashql-syntax-jexpr/src/parser/state.rs:
#[test]
fn peek_returns_token_without_consuming() {
bind_context!(let context = "42");
bind_state!(let mut state from context);
let token = state.peek().expect("should not fail").expect("should have token");
assert_eq!(token.kind, number("42"));
}Commands:
cargo nextest run --package hashql-<package>
cargo test --package hashql-<package> --doc # Doc testsinsta Snapshot Tests
Use insta crate for snapshot-based output when compiletest (the preferred method) is infeasible. Three categories exist:
| Category | Crates | Snapshot Location | Rationale |
|---|---|---|---|
| Pipeline Crates | mir, hir, ast | tests/ui/<category>/*.snap | Colocate with compiletest tests |
| Core | hashql-core | Default insta (src/**/snapshots/) | Separate from pipeline; prefer unit tests |
| Syntax | syntax-jexpr | src/*/snapshots/ | Macro-based for parser fragments |
Pipeline Crates (mir, hir, ast)
Snapshots colocate with compiletest UI tests. Test code lives in src/**/tests.rs, snapshots go in the appropriate tests/ui/<category>/ directory.
// Example: hashql-mir/src/pass/transform/ssa_repair/tests.rs
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut settings = Settings::clone_current();
settings.set_snapshot_path(dir.join("tests/ui/pass/ssa_repair")); // matches test category
settings.set_prepend_module_to_snapshot(false);
let _drop = settings.bind_to_scope();
assert_snapshot!(name, value);Categories vary: reify/, lower/, pass/ssa_repair/, etc.
Core
hashql-core is separate from the compilation pipeline, so it uses default insta directories. Prefer unit tests; only use snapshots where necessary.
Syntax (syntax-jexpr)
Syntax crates predate compiletest and use macro-based test harnesses for testing parser fragments directly.
// hashql-syntax-jexpr/src/parser/string/test.rs
pub(crate) macro test_cases($parser:ident; $($name:ident($source:expr) => $description:expr,)*) {
$(
#[test]
fn $name() {
assert_parse!($parser, $source, $description);
}
)*
}Snapshots: hashql-syntax-jexpr/src/parser/*/snapshots/*.snap
Commands
cargo insta test --package hashql-<package>
cargo insta review # Interactive review
cargo insta accept # Accept all pendingMIR Builder Tests
For testing MIR transformation and analysis passes directly with programmatically constructed MIR bodies.
Location: hashql-mir/src/pass/**/tests.rs
When to use:
- Testing MIR passes in isolation with precise CFG control
- Edge cases requiring specific MIR structures hard to produce from source
- Benchmarking pass performance
Key features:
- Transform passes return
Changedenum (Yes,No,Unknown) to indicate modifications - Test harness captures and includes
Changedvalue in snapshots for verification - Snapshot format: before MIR →
Changed: Yes/No/Unknownseparator → after MIR
Important: Missing Macro Features
The body! macro does not support all MIR constructs. If you need a feature that is not supported, do not work around it manually - instead, stop and request that the feature be added to the macro.
Quick Example (using body! macro)
use hashql_core::{heap::Heap, r#type::environment::Environment};
use hashql_mir::{builder::body, intern::Interner};
let heap = Heap::new();
let interner = Interner::new(&heap);
let env = Environment::new(&heap);
let body = body!(interner, env; fn@0/1 -> Int {
decl x: Int, cond: Bool;
bb0() {
cond = load true;
if cond then bb1() else bb2();
},
bb1() {
goto bb3(1);
},
bb2() {
goto bb3(2);
},
bb3(x) {
return x;
}
});📖 Full Guide: references/mir-builder-guide.md
References
- compiletest Guide - Detailed UI test documentation
- Testing Strategies - Choosing the right approach
- MIR Builder Guide -
body!macro for MIR construction in tests - MIR Fluent Builder - Programmatic builder API (for advanced cases)
HashQL compiletest Guide
A comprehensive test harness for HashQL that executes test cases and verifies their behavior against expected outputs. This tool helps ensure that the HashQL language implementation behaves correctly by testing parsing, type checking, execution, and error reporting.
Heavily influenced by the Rust compiler's compiletest tool.
Table of Contents
- Running Tests
- Directory Structure
- Test Directives
- Diagnostic Annotations
- Discovering Test Suites
- Adding New Tests
- Updating Expected Output
- Debugging Failures
- Best Practices
---
Running Tests
Basic Commands
# Run all UI tests
cargo run -p hashql-compiletest run
# List all available tests without running
cargo run -p hashql-compiletest listFiltering Tests
Uses nextest filter syntax:
# Run tests matching a name pattern
cargo run -p hashql-compiletest run --filter "test(some_test_name)"
# Run all tests in a package
cargo run -p hashql-compiletest run --filter "package(some_package)"
# Combine filters with &
cargo run -p hashql-compiletest run --filter "package(some_package) & test(error_handling)"Updating Expected Outputs
When you make intentional changes that affect test outputs:
# Update all tests
cargo run -p hashql-compiletest run --bless
# Update specific test
cargo run -p hashql-compiletest run --filter "test(name)" --blessThis updates .stdout and .stderr files to match actual outputs.
---
Directory Structure
Tests are organized under tests/ui/ in each HashQL crate. The directory must be in a workspace member crate.
package_name/
tests/
ui/
namespace1/
.spec.toml # Test suite specification
test_case1.jsonc # J-Expr test file
test_case1.stdout # Expected output
test_case1.stderr # Expected diagnostics
namespace2/
.spec.toml
another_test.jsonc
another_test.stdout
another_test.stderrTest Components
| File | Purpose | Required |
|---|---|---|
.jsonc | J-Expr test code | ✅ Yes |
.spec.toml | Test suite specification | ✅ Yes (in dir or parent) |
.stdout | Expected standard output | Optional (empty if none) |
.stderr | Expected diagnostics | Optional (empty if none) |
.aux.<ext> | Auxiliary/secondary output | Suite-dependent |
The harness searches upward from the test file to find .spec.toml, stopping at tests/ui/. This allows shared specs at directory roots with overrides for specific subdirectories.
Auxiliary Files
Some test suites produce auxiliary outputs beyond stdout/stderr. These are stored with the pattern test_name.aux.<extension>.
Example: The mir/reify suite generates SVG diagrams:
mir/tests/ui/reify/
nested-if.jsonc
nested-if.stdout
nested-if.aux.svg # CFG diagramSuites declare their auxiliary extensions via the secondary_file_extensions() method:
impl Suite for MirReifySuite {
fn secondary_file_extensions(&self) -> &[&str] {
&["svg"]
}
// ...
}When running --bless, auxiliary files are also updated alongside stdout/stderr.
---
Test Directives
Directives control test behavior. They must be at the start of the file, before any test code.
Supported Directives
//@ run: pass // Test should pass (no errors expected)
//@ run: fail // Test should fail with errors (DEFAULT)
//@ run: skip // Skip this test
//@ run: skip reason=Not implemented yet
//@ name: custom_test_name // Override the default test name
//@ description: Tests that... // Describe test purpose (ENCOURAGED)
//@ suite#key: value // Suite-specific directive (TOML value)Run Modes
| Mode | Behavior |
|---|---|
pass | Test must succeed with no errors |
fail | Test must produce errors (default if omitted) |
skip | Test is skipped entirely |
Important: If you don't specify //@ run:, the test defaults to fail mode. Always use //@ run: pass explicitly for tests that should succeed.
Suite-Specific Directives
The //@ suite#key: value syntax passes configuration to specific suites. Values are parsed as TOML:
//@ suite#timeout: 30
//@ suite#features: ["experimental"]---
Diagnostic Annotations
Annotations verify that specific diagnostics appear at expected locations.
Basic Syntax
"undefined_variable" //~ ERROR unknown variableComponents
//~- Annotation marker- Line reference (optional):
^,v,|,? - Severity:
ERROR,WARNING,NOTE,DEBUG, orCRITICAL - Optional error code:
[category::subcategory] - Message fragment to match
Line Reference Types
| Syntax | Meaning | Example |
|---|---|---|
//~ ERROR msg | Current line | Error on this exact line |
//~^ ERROR msg | Previous line (1 up) | Error on line above |
//~^^ ERROR msg | 2 lines above | |
//~^^^ ERROR msg | 3 lines above | |
//~v ERROR msg | Next line (1 down) | Error on line below |
//~vv ERROR msg | 2 lines below | |
//~vvv ERROR msg | 3 lines below | |
| `//~\ | ERROR msg` | Same line as previous |
//~? ERROR msg | Unknown/any line | Use sparingly |
Error Codes
Include optional error codes in brackets:
["+", "string", 42] //~ ERROR[category::subcategory] cannot addMulti-Annotation Example
["let", "x", //~^ ERROR first error on the let line
["invalid"] //~ ERROR error on this line
] //~| ERROR another error on same line as previous
//~| NOTE additional contextSeverity Levels
| Level | Use Case |
|---|---|
CRITICAL | Unrecoverable errors |
ERROR | Standard errors |
WARNING | Non-fatal warnings |
NOTE | Informational notes |
DEBUG | Debug output |
---
Discovering Test Suites
List all available suites and their descriptions:
# Human-readable list with descriptions
cargo run -p hashql-compiletest suites
# Machine-readable NDJSON output
cargo run -p hashql-compiletest suites --jsonSuite Categories
parse/*- Parsing tests (e.g.,parse/syntax-dump)ast/lowering/*- AST lowering phaseshir/lower/*- HIR lowering phaseshir/reify- HIR generation from ASTmir/*- MIR passes and generationeval/*- Evaluation tests
Specifying a Suite
In .spec.toml:
suite = "parse/syntax-dump"---
Adding New Tests
Step 1: Create the Test File
Create a .jsonc file with your test code and directives:
//@ description: Verifies that undefined variables produce an error
//@ run: fail
["let", "x", {"#literal": 42},
"undefined_var" //~ ERROR unknown variable
]Step 2: Create or Verify .spec.toml
Ensure there's a .spec.toml file in the directory or a parent:
suite = "parse/syntax-dump"Step 3: Generate Expected Outputs
Run with --bless to generate initial reference files:
cargo run -p hashql-compiletest run --filter "test(your_test)" --blessStep 4: Review Generated Files
Check the generated .stdout and .stderr files to ensure they contain expected output.
---
Updating Expected Output
When intentional changes affect test outputs, use --bless:
# Update all failing tests
cargo run -p hashql-compiletest run --bless
# Update specific test
cargo run -p hashql-compiletest run --filter "test(name)" --blessWhen to use --bless:
- After intentionally changing error messages
- After adding new diagnostic information
- When output format changes
- When adding new test cases
When NOT to use --bless:
- When debugging unexpected failures (investigate first)
- Without reviewing the diff
---
Debugging Failures
When a test fails, the harness shows:
1. Test name and location 2. Expected vs. actual output diff 3. Unfulfilled annotations (expected errors that didn't appear) 4. Unexpected diagnostics (errors that appeared but weren't expected)
Common Failure Types
Output mismatch:
- Compare diff between expected and actual
- Check if the change is intentional → use
--bless - Check if it's a bug → fix the code
Unfulfilled annotation:
- Expected error didn't appear at the specified location
- Check line references (
^,v,|) are correct - Verify the error message fragment matches
Unexpected diagnostic:
- An error appeared that wasn't annotated
- Add missing
//~annotation - Or fix the code if the error is a bug
Resolution Steps
1. Real bug: Fix the implementation code 2. Intentional change: Run --bless to update expected outputs 3. Annotation mismatch: Update //~ annotations to match new messages/locations 4. Missing annotation: Add //~ for legitimate new diagnostics
---
Best Practices
1. Always include `//@ description:` - Document what behavior is being tested 2. Default is `fail` mode - Explicitly use //@ run: pass for passing tests 3. Keep tests focused - Each test should verify a specific behavior 4. Use descriptive file names - Names should indicate what's being tested 5. Group related tests - Use directories to organize by feature 6. Use annotations precisely - Verify specific error messages, not just failure 7. Avoid `//~?` - Unknown line annotations make tests brittle 8. Review --bless changes - Don't blindly accept new outputs 9. Structure specs wisely - Place common .spec.toml at roots, override where needed
---
Quick Command Reference
# Run all tests
cargo run -p hashql-compiletest run
# List tests
cargo run -p hashql-compiletest list
# List available suites
cargo run -p hashql-compiletest suites
# Filter by test name
cargo run -p hashql-compiletest run --filter "test(name)"
# Filter by package
cargo run -p hashql-compiletest run --filter "package(pkg)"
# Combined filter
cargo run -p hashql-compiletest run --filter "package(pkg) & test(name)"
# Update expected outputs
cargo run -p hashql-compiletest run --bless
# Update specific test
cargo run -p hashql-compiletest run --filter "test(name)" --blessMIR Builder Guide
Ergonomic API for constructing MIR bodies in tests. Use for testing and benchmarking MIR passes without manual structure boilerplate.
Source: libs/@local/hashql/mir/src/builder/
Important
The body! macro does not support all MIR constructs. If you need a feature that is not supported, do not work around it manually - instead, stop and request that the feature be added to the macro.
For advanced cases not supported by the macro, see mir-fluent-builder.md.
Quick Start
use hashql_core::{heap::Heap, r#type::environment::Environment};
use hashql_mir::{builder::body, intern::Interner};
let heap = Heap::new();
let interner = Interner::new(&heap);
let env = Environment::new(&heap);
let body = body!(interner, env; fn@0/1 -> Int {
decl x: Int, cond: Bool;
bb0() {
cond = load true;
if cond then bb1() else bb2();
},
bb1() {
goto bb3(1);
},
bb2() {
goto bb3(2);
},
bb3(x) {
return x;
}
});body! Macro Syntax
body!(interner, env; <source> @ <id> / <arity> -> <return_type> {
decl <local>: <type>, ...;
<block>(<params>...) {
<statements>...
},
...
})Important: Only a single decl statement is supported. Declare all locals in one comma-separated list:
// ✅ Correct - single decl with all locals
decl env: (), vertex: Entity, x: Int, y: Int, result: Bool;
// ❌ Wrong - multiple decl statements will not compile
decl env: (), vertex: Entity;
decl x: Int, y: Int;
decl result: Bool;Header
| Component | Description | Example |
|---|---|---|
<source> | Body source type | fn, thunk, [ctor expr], intrinsic |
<id> | DefId (literal or variable) | 0, 42, my_def_id |
<arity> | Number of function arguments | 0, 1, 2 |
<return_type> | Return type | Int, Bool, (Int, Bool) |
The <id> can be a numeric literal (0, 1, 42) or a variable identifier (callee_id, my_def_id). When using a variable, it must be a DefId in scope.
Source types:
| Syntax | Maps to | Use case |
|---|---|---|
fn | Source::Closure | Regular closures/functions |
thunk | Source::Thunk | Thunk bodies (zero-arg delayed computations) |
[ctor sym::path] | Source::Ctor(sym) | Constructor bodies (always inlined) |
[graph::read::filter] | Source::GraphReadFilter | Graph read filter bodies (never inlined) |
intrinsic | Source::Intrinsic | Intrinsic bodies (never inlined) |
Types
| Syntax | Description | Example |
|---|---|---|
Int | Integer type | Int |
Num | Number (float) type | Num |
Bool | Boolean type | Bool |
Null | Null type | Null |
? | Unknown type (dynamic) | ? |
(T1, T2, ...) | Tuple types | (Int, Bool, Int) |
(T,) | Single-element tuple | (Int,) |
(a: T1, b: T2) | Struct types | (a: Int, b: Bool) |
[List T] | List type (intrinsic) | [List Int], [List (Int, Bool)] |
[fn(T1, T2) -> R] | Closure types | [fn(Int) -> Int], [fn() -> Bool] |
[Opaque path; T] | Opaque type with symbol path | [Opaque sym::path::Entity; ?] |
| `\ | types\ | types.custom()` |
Projections (Optional)
Declare field projections after decl to access struct/tuple fields as places:
@proj <name> = <base>.<field>: <type>, ...;Field access modes:
- Numeric index (e.g.,
tup.0) →ProjectionKind::Field - Named field (e.g.,
entity.metadata) →ProjectionKind::FieldByName
Each @proj declaration supports only ONE field after the base. For deeper paths, chain through intermediate declarations:
let body = body!(interner, env; fn@0/0 -> Int {
decl tup: ((Int, Int), Int), result: Int;
// inner uses tup as base, inner_1 uses inner as base
@proj inner = tup.0: (Int, Int), inner_1 = inner.1: Int;
bb0() {
result = load inner_1;
return result;
}
});Named field projections for opaque types:
use hashql_core::symbol::sym;
let body = body!(interner, env; [graph::read::filter]@0/2 -> Bool {
decl env: (), vertex: [Opaque sym::path::Entity; ?];
// Chain: vertex -> metadata -> archived
@proj metadata = vertex.metadata: ?, archived = metadata.archived: Bool;
bb0() {
return archived;
}
});Statements
| Syntax | Description | MIR Equivalent |
|---|---|---|
let x; | Mark storage live | StorageLive(x) |
drop x; | Mark storage dead | StorageDead(x) |
x = load <operand>; | Load value | Assign(x, Load(operand)) |
x = apply <func>; | Call with no args | Assign(x, Apply(func, [])) |
x = apply <func>, <a1>, <a2>; | Call with args | Assign(x, Apply(func, [a1, a2])) |
x = tuple <a>, <b>; | Create tuple | Assign(x, Aggregate(Tuple, [a, b])) |
x = struct a: <v1>, b: <v2>; | Create struct | Assign(x, Aggregate(Struct, [v1, v2])) |
x = closure <def> <env>; | Create closure | Assign(x, Aggregate(Closure, [def, env])) |
x = bin.<op> <lhs> <rhs>; | Binary operation | Assign(x, Binary(lhs, op, rhs)) |
x = un.<op> <operand>; | Unary operation | Assign(x, Unary(op, operand)) |
x = input.load! "name"; | Load required input | Assign(x, Input(Load { required: true }, "name")) |
x = input.load "name"; | Load optional input | Assign(x, Input(Load { required: false }, "name")) |
x = input.exists "name"; | Check if input exists | Assign(x, Input(Exists, "name")) |
Terminators
| Syntax | Description |
|---|---|
return <operand>; | Return from function |
goto <block>(<args>...); | Unconditional jump with args |
if <cond> then <tb>(<ta>) else <eb>(<ea>); | Conditional branch |
switch <discr> [<val> => <block>(<args>), ...]; | Switch (no otherwise) |
switch <discr> [<val> => <block>(), _ => <block>()]; | Switch with otherwise |
unreachable; | Mark block as unreachable |
Operands
| Syntax | Description |
|---|---|
x, cond | Place (local variable or projection) |
42, -5 | Integer literal (i64) |
3.14 | Float literal (f64) |
true, false | Boolean literal |
() | Unit |
null | Null |
def_id | DefId variable (for function pointers) |
Operators
Binary (bin.<op>): ==, !=, <, <=, >, >=, &, |, +, -, *, /
Unary (un.<op>): !, neg
Common Patterns
Diamond CFG (Branch and Merge)
let body = body!(interner, env; fn@0/0 -> Int {
decl x: Int, cond: Bool;
bb0() {
cond = load true;
if cond then bb1() else bb2();
},
bb1() {
goto bb3(1);
},
bb2() {
goto bb3(2);
},
bb3(x) {
return x;
}
});Loop
let body = body!(interner, env; fn@0/0 -> Int {
decl x: Int, cond: Bool;
bb0() {
x = load 0;
goto bb1();
},
bb1() {
cond = bin.< x 10;
x = bin.+ x 1;
if cond then bb1() else bb2();
},
bb2() {
return x;
}
});Switch Statement
let body = body!(interner, env; fn@0/0 -> Null {
decl selector: Int;
bb0() {
selector = load 0;
switch selector [0 => bb1(), 1 => bb2(), _ => bb3()];
},
bb1() {
return null;
},
bb2() {
return null;
},
bb3() {
return null;
}
});Graph Read Filter
Filter bodies for graph traversal. The first two declared locals become the function arguments (_0 = env tuple, _1 = vertex):
let body = body!(interner, env; [graph::read::filter]@0/2 -> Bool {
decl env: (Int,), vertex: (Int, Int), result: Bool;
@proj vertex_field = vertex.0: Int;
bb0() {
result = bin.== vertex_field 42;
return result;
}
});Direct Function Calls
Use a DefId variable directly:
let callee_id = DefId::new(1);
let body = body!(interner, env; fn@0/0 -> Int {
decl result: Int;
bb0() {
result = apply callee_id;
return result;
}
});Indirect Function Calls (via local)
Load a DefId into a local, then apply the local:
let callee_id = DefId::new(1);
let body = body!(interner, env; fn@0/0 -> Int {
decl func: [fn(Int) -> Int], result: Int;
bb0() {
func = load callee_id;
result = apply func, 1;
return result;
}
});Multiple Bodies with DefId Variables
When creating multiple bodies that reference each other:
let callee_id = DefId::new(1);
let caller_id = DefId::new(0);
let caller = body!(interner, env; fn@caller_id/0 -> Int {
decl result: Int;
bb0() {
result = apply callee_id;
return result;
}
});
let callee = body!(interner, env; fn@callee_id/0 -> Int {
decl ret: Int;
bb0() {
return ret;
}
});Struct Aggregate
let body = body!(interner, env; fn@0/0 -> (a: Int, b: Bool) {
decl result: (a: Int, b: Bool);
bb0() {
result = struct a: 42, b: true;
return result;
}
});Closure with Projections
// body0: function that takes captured env and returns it
let body0 = body!(interner, env; fn@0/1 -> Int {
decl env_arg: Int, result: Int;
bb0() {
result = load env_arg;
return result;
}
});
// body1: creates closure, calls it via projections
let body1 = body!(interner, env; fn@1/0 -> Int {
decl captured: Int, closure: [fn(Int) -> Int], result: Int;
@proj closure_fn = closure.0: [fn(Int) -> Int], closure_env = closure.1: Int;
bb0() {
captured = load 55;
closure = closure (body0.id) captured;
result = apply closure_fn, closure_env;
return result;
}
});Projections in Terminators
Projected places can be used as operands in terminators:
let body = body!(interner, env; fn@0/0 -> Int {
decl tup: (Int, Int), result: Int;
@proj tup_0 = tup.0: Int, tup_1 = tup.1: Int;
bb0() {
tup = tuple 1, 2;
if tup_0 then bb1(tup_0) else bb2(tup_1);
},
bb1(result) {
return result;
},
bb2(result) {
return result;
}
});Test Harness Pattern
Standard pattern used across transform pass tests:
use std::{io::Write as _, path::PathBuf};
use bstr::ByteVec as _;
use hashql_core::{
heap::Heap,
pretty::Formatter,
r#type::{TypeFormatter, TypeFormatterOptions, environment::Environment},
};
use hashql_diagnostics::DiagnosticIssues;
use insta::{Settings, assert_snapshot};
use crate::{
builder::body,
context::MirContext,
def::DefIdSlice,
intern::Interner,
pass::TransformPass as _,
pretty::TextFormat,
};
#[track_caller]
fn assert_pass<'heap>(
name: &'static str,
body: Body<'heap>,
context: &mut MirContext<'_, 'heap>,
) {
let formatter = Formatter::new(context.heap);
let mut formatter = TypeFormatter::new(
&formatter,
context.env,
TypeFormatterOptions::terse().with_qualified_opaque_names(true),
);
let mut text_format = TextFormat {
writer: Vec::new(),
indent: 4,
sources: (),
types: &mut formatter,
};
let mut bodies = [body];
// Format before
text_format
.format(DefIdSlice::from_raw(&bodies), &[])
.expect("should be able to write bodies");
// Run the pass and capture change status
let changed = YourPass::new().run(context, &mut bodies[0]);
// Include Changed value in snapshot
write!(
text_format.writer,
"\n\n{:=^50}\n\n",
format!(" Changed: {changed:?} ")
).expect("infallible");
// Format after
text_format
.format(DefIdSlice::from_raw(&bodies), &[])
.expect("should be able to write bodies");
// Snapshot configuration
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut settings = Settings::clone_current();
settings.set_snapshot_path(dir.join("tests/ui/pass/your_pass"));
settings.set_prepend_module_to_snapshot(false);
let _drop = settings.bind_to_scope();
let value = text_format.writer.into_string_lossy();
assert_snapshot!(name, value);
}
#[test]
fn test_case() {
let heap = Heap::new();
let interner = Interner::new(&heap);
let env = Environment::new(&heap);
let body = body!(interner, env; fn@0/0 -> Int {
decl x: Int;
bb0() {
x = load 42;
return x;
}
});
assert_pass(
"test_case",
body,
&mut MirContext {
heap: &heap,
env: &env,
interner: &interner,
diagnostics: DiagnosticIssues::new(),
},
);
}Examples in Codebase
Real test examples in libs/@local/hashql/mir/src/pass/:
Transform passes (transform/):
administrative_reduction/tests.rsdse/tests.rs- Dead Store Eliminationssa_repair/tests.rs- SSA Repaircfg_simplify/tests.rs- CFG Simplificationdbe/tests.rs- Dead Block Eliminationcp/tests.rs- Constant Propagationdle/tests.rs- Dead Local Eliminationinst_simplify/tests.rs- Instruction Simplification
Analysis passes (analysis/):
callgraph/tests.rs- Call graph analysisdata_dependency/tests.rs- Data dependency analysisdataflow/liveness/tests.rs- Liveness analysis
MIR Fluent Builder API
The fluent builder API provides programmatic MIR construction. Prefer the `body!` macro for most cases - use this API only when the macro doesn't support a required feature.
Source: libs/@local/hashql/mir/src/builder/
Setup
use hashql_core::{heap::Heap, r#type::{TypeBuilder, environment::Environment}};
use hashql_mir::{builder::BodyBuilder, intern::Interner};
let heap = Heap::new();
let interner = Interner::new(&heap);
let builder = BodyBuilder::new(&interner);
let env = Environment::new(&heap);op! Macro
Creates operators for binary/unary operations:
use hashql_mir::builder::op;
// Binary: ==, !=, <, <=, >, >=, &, |, +, -, *, /
rv.binary(x, op![==], y)
// Unary: !, neg
rv.unary(op![!], cond)
rv.unary(op![neg], value)Locals and Types
let env = Environment::new(&heap);
// Common types
let int_ty = TypeBuilder::synthetic(&env).integer();
let bool_ty = TypeBuilder::synthetic(&env).boolean();
let null_ty = TypeBuilder::synthetic(&env).null();
// Declare locals
let x = builder.local("x", int_ty); // Returns Place<'heap>Constants
let const_42 = builder.const_int(42);
let const_true = builder.const_bool(true);
let const_unit = builder.const_unit();
let const_null = builder.const_null();
let const_fn = builder.const_fn(def_id);Basic Blocks
// Reserve without parameters
let bb0 = builder.reserve_block([]);
// Reserve with block parameters (for SSA phi-like merging)
let bb1 = builder.reserve_block([x.local, y.local]);Building Blocks
builder
.build_block(bb0)
.assign_place(x, |rv| rv.load(const_1))
.assign_place(y, |rv| rv.binary(x, op![==], x))
.storage_live(local)
.storage_dead(local)
.nop()
.ret(result); // Must end with terminatorTerminators
// Return
builder.build_block(bb).ret(value);
// Goto
builder.build_block(bb0).goto(bb1, []);
builder.build_block(bb0).goto(bb1, [x.into(), y.into()]);
// If-else
builder.build_block(bb0).if_else(cond, bb_then, [], bb_else, []);
// Switch
builder.build_block(bb0).switch(selector, |switch| {
switch.case(0, bb1, []).case(1, bb2, []).otherwise(bb3, [])
});
// Unreachable
builder.build_block(bb).unreachable();RValue Methods
| Method | Creates | Example |
|---|---|---|
load(operand) | Copy/move | rv.load(x) |
binary(l, op, r) | Binary op | rv.binary(x, op![+], y) |
unary(op, val) | Unary op | rv.unary(op![!], cond) |
tuple([...]) | Tuple | rv.tuple([x, y, z]) |
list([...]) | List | rv.list([a, b, c]) |
struct([...]) | Struct | rv.r#struct([("x", val)]) |
closure(def, env) | Closure | rv.closure(def_id, env_place) |
dict([...]) | Dict | rv.dict([(k, v)]) |
apply(fn, args) | Call | rv.apply(func, [arg1]) |
call(fn) | Call (no args) | rv.call(func) |
input(op, name) | Input | rv.input(InputOp::Load { required: true }, "x") |
Places with Projections
let tup = builder.local("tup", tuple_ty);
let tup_field_0 = builder.place(|place| place.from(tup).field(0, int_ty));
// Nested projections
let nested = builder.place(|place| {
place.from(outer).field(0, inner_ty).field(1, int_ty)
});Complete Example
use hashql_core::{heap::Heap, r#type::{TypeBuilder, environment::Environment}};
use hashql_mir::{builder::BodyBuilder, intern::Interner, op};
let heap = Heap::new();
let interner = Interner::new(&heap);
let builder = BodyBuilder::new(&interner);
let env = Environment::new(&heap);
let int_ty = TypeBuilder::synthetic(&env).integer();
let x = builder.local("x", int_ty);
let const_1 = builder.const_int(1);
let bb0 = builder.reserve_block([]);
builder
.build_block(bb0)
.assign_place(x, |rv| rv.load(const_1))
.ret(x);
let body = builder.finish(0, int_ty);When to Use Fluent Builder
Use the fluent builder API when the body! macro doesn't support your use case:
GraphReadterminator- Index projections (e.g.,
list[idx]) - Complex dynamic DefId manipulation
- Other advanced MIR constructs not yet in the macro
For all other cases, prefer the body! macro for clarity and maintainability.
HashQL Testing Strategies Guide
This guide helps you choose the right testing approach for HashQL code.
---
Decision Matrix
| Question | compiletest | Unit Tests | insta Snapshots |
|---|---|---|---|
| Testing error messages/diagnostics? | ✅ Best | ❌ | ⚠️ Possible |
| Testing compiler pipeline stages? | ✅ Best | ❌ | ⚠️ Possible |
| Testing internal function logic? | ❌ | ✅ Best | ❌ |
| MIR/HIR pass integration (end-to-end)? | ✅ Best | ❌ | ❌ |
| MIR/HIR pass edge cases (isolated)? | ⚠️ Noisy | ❌ | ✅ Best |
| Testing parser output structure? | ⚠️ Possible | ⚠️ Possible | ✅ Best |
| Need to verify exact output format? | ✅ Best | ❌ | ✅ Best |
| Testing edge cases in isolation? | ❌ | ✅ Best | ⚠️ Possible |
---
1. compiletest (UI Tests)
The default for HashQL. Use for testing the complete compiler pipeline with emphasis on diagnostics.
When to Use
- Testing error messages and diagnostic formatting
- Testing multi-stage compilation (parsing → lowering → type checking → evaluation)
- Verifying user-facing compiler output
- Testing error recovery and multiple errors in one file
Structure
crate/tests/ui/
category/
.spec.toml # Suite specification
test-name.jsonc # Test input (J-Expr)
test-name.stderr # Expected errors (if run: fail)
test-name.stdout # Expected output (if run: pass)
test-name.aux.svg # Auxiliary output (some suites)Example: Error Message Test
From libs/@local/hashql/ast/tests/ui/lowering/type-extractor/definition/duplicate-fields.jsonc:
//@ run: fail
//@ description: Tests error handling for structs with multiple duplicate fields
[
"type",
"BadRecord",
{
"#struct": {
"field": "Number",
//~^ ERROR Field `field` first defined here
"field": "String",
"another": "Boolean",
//~^ ERROR Field `another` first defined here
"another": "Number",
"unique": "String"
}
},
"_"
]The //~^ annotations verify that specific errors appear at specific locations.
Example: Pipeline Output Test
From libs/@local/hashql/hir/tests/ui/lower/graph-hoisting/hoist.jsonc:
//@ run: pass
//@ description: TODO
[
"let", "a", { "#literal": true },
["let", "b", { "#literal": true },
["::graph::tail::collect",
["::graph::body::filter",
["::graph::head::entities", ["::graph::tmp::decision_time_now"]],
["fn", { "#tuple": [] }, { "#struct": { "vertex": "_" } }, "_",
["==", "a", "b"]]]]]
]The corresponding .stdout file captures the HIR before and after transformations.
Commands
cargo run -p hashql-compiletest run # Run all
cargo run -p hashql-compiletest run --filter "test(duplicate-fields)" # Filter
cargo run -p hashql-compiletest run --bless # Update expected---
2. Unit Tests
Standard Rust #[test] functions for testing isolated components.
When to Use
- Testing internal functions and helper utilities
- Testing state transitions and edge cases
- Testing error handling logic (not error messages)
- Testing data structure operations
- When you need fine-grained control over test setup
Structure
Unit tests live alongside the code in #[cfg(test)] modules:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn my_test() {
// ...
}
}Example: State Machine Testing
From libs/@local/hashql/syntax-jexpr/src/parser/state.rs:
#[test]
fn peek_returns_token_without_consuming() {
bind_context!(let context = "42");
bind_state!(let mut state from context);
let token = state
.peek()
.expect("should not fail")
.expect("should have token");
assert_eq!(token.kind, number("42"));
// Token should not be consumed
let token2 = state
.peek()
.expect("should not fail")
.expect("should have token");
assert_eq!(token2.kind, number("42"));
}
#[test]
fn advance_consumes_token() {
bind_context!(let context = "42 true");
bind_state!(let mut state from context);
let token = state.advance(SyntaxKind::Number).expect("should not fail");
assert_eq!(token.kind, number("42"));
// Next token should be available
let token2 = state.peek().expect("should not fail").expect("should have token");
assert_eq!(token2.kind, TokenKind::Bool(true));
}Commands
cargo nextest run --package hashql-syntax-jexpr
cargo nextest run --package hashql-syntax-jexpr -- state::tests::peek_returns_token
cargo test --package hashql-syntax-jexpr --doc---
3. insta Snapshot Tests
Uses the insta crate for snapshot-based output when compiletest is infeasible. Three categories exist:
| Category | Crates | Snapshot Location | Rationale |
|---|---|---|---|
| Pipeline Crates | mir, hir, ast | tests/ui/<category>/*.snap | Colocate with compiletest tests |
| Core | hashql-core | Default insta (src/**/snapshots/) | Separate from pipeline; prefer unit tests |
| Syntax | syntax-jexpr | src/*/snapshots/ | Macro-based for parser fragments |
Pipeline Crates (mir, hir, ast)
Snapshots colocate with compiletest UI tests. Test code lives in src/**/tests.rs, snapshots go in the appropriate tests/ui/<category>/ directory.
Example from libs/@local/hashql/mir/src/pass/transform/ssa_repair/tests.rs:
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let mut settings = Settings::clone_current();
settings.set_snapshot_path(dir.join("tests/ui/pass/ssa_repair")); // matches test category
settings.set_prepend_module_to_snapshot(false);
let _drop = settings.bind_to_scope();
assert_snapshot!(name, value);Categories vary: reify/, lower/, pass/ssa_repair/, etc.
Core
hashql-core is separate from the compilation pipeline, so it uses default insta directories. Prefer unit tests; only use snapshots where necessary.
Syntax (syntax-jexpr)
Syntax crates predate compiletest and use macro-based test harnesses for testing parser fragments directly.
Example from libs/@local/hashql/syntax-jexpr/src/parser/string/test.rs:
pub(crate) macro test_cases($parser:ident; $($name:ident($source:expr) => $description:expr,)*) {
$(
#[test]
fn $name() {
assert_parse!($parser, $source, $description);
}
)*
}Snapshots stored at: hashql-syntax-jexpr/src/parser/*/snapshots/*.snap
Usage in libs/@local/hashql/syntax-jexpr/src/parser/string/type.rs:
#[cfg(test)]
mod tests {
bind_parser!(SyntaxDump; fn parse_type_test(parse_type));
test_cases!(parse_type_test;
empty_tuple("()") => "Empty tuple",
single_element_tuple("(Int,)") => "Single-element tuple with trailing comma",
single_field_struct("(name: String)") => "Single-field struct",
unclosed_tuple("(Int, String") => "Unclosed tuple",
);
}Commands
cargo insta test --package hashql-mir
cargo insta review # Interactive review
cargo insta accept # Accept all pending
cargo insta reject # Reject all pending---
Choosing the Right Approach: Examples
Scenario 1: New error message for invalid syntax
Use compiletest. You want to verify:
- The error message is user-friendly
- The span points to the correct location
- Help text is appropriate
//@ run: fail
//@ description: Error when using reserved keyword as identifier
["let", "type", {"#literal": 1}, "type"]
//~^ ERROR `type` is a reserved keywordScenario 2: Testing a utility function
Use Unit Tests. You're testing internal logic:
#[test]
fn symbol_table_lookup_returns_none_for_undefined() {
let table = SymbolTable::new();
assert!(table.lookup("undefined").is_none());
}Scenario 3: New MIR transformation pass
Use compiletest for pipeline integration — verifying the pass works end-to-end:
//@ run: pass
//@ description: Tests new optimization pass integrates correctly
["let", "x", {"#literal": 1}, ["add", "x", "x"]]Use insta for isolated edge cases — exercising specific scenarios that are rarely hit in normal pipeline tests, or where compiletest would create too much noise:
#[test]
fn edge_case_irreducible_cfg() {
scaffold!(heap, interner, builder);
// ... construct specific edge case MIR ...
assert_pass("irreducible_cfg", body, context);
}Scenario 4: New parser production rule
Use insta snapshots (syntax-jexpr pattern). You want to verify AST structure:
test_cases!(parse_new_syntax;
basic_case("new-syntax-here") => "Basic new syntax",
with_options("new-syntax option1 option2") => "With options",
);---
Summary
| Approach | Test Location | Snapshot Location | Update Command | Best For |
|---|---|---|---|---|
| compiletest | tests/ui/*.jsonc | tests/ui/*.stdout/stderr | --bless | Diagnostics, pipeline, pass integration |
| Unit tests | src/*.rs | N/A | N/A | Isolated logic |
| insta (pipeline) | src/**/tests.rs | tests/ui/<category>/ | cargo insta accept | Pass edge cases |
| insta (core) | src/**/tests | src/**/snapshots/ | cargo insta accept | Core crate |
| insta (syntax-jexpr) | src/*/tests | src/*/snapshots/ | cargo insta accept | Parser fragments |
Default choice: compiletest for end-to-end pipeline testing; insta for isolated edge cases where compiletest would be noisy.