Development
For deterministic compiler property testing, campaign replay, shrinking, and corpus promotion, see Fuzz testing.
This guide covers everything needed to build, test, and develop Ashes locally.
Prerequisites
| Tool | Version | Notes |
|---|---|---|
| .NET SDK | 10.0+ | Specified in global.json |
| Node.js | 20.19+, 22.13+, or 24+ | VS Code extension only |
| pnpm | (managed via corepack) | VS Code extension only |
| bash | any | Scripts and LLVM provisioning |
Repository Layout
Ashes.slnx Solution file
src/
Ashes.Frontend/ Lexer, parser, AST
Ashes.Semantics/ Binding, type inference, IR, optimizer
Ashes.Backend/ LLVM codegen and native linker
Ashes.Formatter/ Canonical source formatting
Ashes.Cli/ CLI orchestration (compile, run, repl, test, fmt)
Ashes.Lsp/ Language server
Ashes.Dap/ Debug adapter
Ashes.TestRunner/ End-to-end .ash test execution
Ashes.Tests/ Compiler unit tests
Ashes.Lsp.Tests/ LSP unit tests
docs/ Specifications and references
examples/ Example .ash programs
tests/ End-to-end .ash test suite
scripts/ Development and CI helper scripts
vscode-extension/ VS Code extension sourceBuilding
Build the entire solution:
dotnet build Ashes.slnxRelease build:
dotnet build Ashes.slnx --configuration ReleaseNative Runtime Libraries
The native backend requires LLVM libraries, and HTTPS/TLS workloads also require the Mbed TLS bitcode payload. LLVM must be provisioned before running backend or end-to-end tests; the Mbed TLS payloads are vendored under runtimes/{linux-x64,linux-arm64,win-x64,win-arm64}/ and only need to be refreshed when bumping MbedTlsVersion:
# LLVM: all host RIDs (linux-x64, linux-arm64, win-x64, win-arm64):
bash scripts/download-llvm-native.sh --all
# LLVM: current architecture only:
bash scripts/download-llvm-native.sh
# LLVM: specific architecture:
bash scripts/download-llvm-native.sh 22 arm64
# Mbed TLS: refresh all vendored payloads (linux-x64, linux-arm64, win-x64, win-arm64):
bash scripts/download-mbedtls.sh --all
# Mbed TLS: refresh selected vendored payloads:
bash scripts/download-mbedtls.sh --linux-x64 --win-x64Mbed TLS is compiled to LLVM bitcode by the clang frontend (needs clang, llvm-link, opt), so every target's payload builds on one host with no cross toolchain. The default Mbed TLS version is read from Directory.Build.props.
On Windows, run the bash scripts from WSL. A normal checkout already includes the Mbed TLS payloads; only LLVM must be downloaded for test setup unless you are intentionally refreshing the vendored bitcode:
# Download all supported LLVM payloads:
bash scripts/download-llvm-native.sh --all
# Optional: refresh the vendored Mbed TLS payloads:
bash scripts/download-mbedtls.sh --allThe scripts stage LLVM payloads and refreshed Mbed TLS payloads under runtimes/{linux-x64,linux-arm64,win-x64,win-arm64}/. Ashes.Backend.csproj validates the vendored Mbed TLS version and copies both LLVM and Mbed TLS assets into build output.
Optional linux-arm64 execution from linux-x64 hosts
The repo can also execute linux-arm64 backend outputs from an x64 Linux host when all of the following are available:
qemu-aarch64orqemu-aarch64-static- an arm64 sysroot containing
lib/ld-linux-aarch64.so.1(for example/usr/aarch64-linux-gnu) - arm64 runtime support libraries including
libgcc_s.so.1andlibstdc++.so.6
src/Ashes.Tests/LinuxArm64BackendCoverageTests.cs auto-detects emulator binaries from both PATH and the rootless Arch-style unpack location ~/.local/share/ashes-tools/qemu-user-static/root/usr/bin.
For manual runs outside the test helper, add the rootless install to PATH if needed:
export PATH="$HOME/.local/share/ashes-tools/qemu-user-static/root/usr/bin:$PATH"
qemu-aarch64-static -L /usr/aarch64-linux-gnu ./hello-arm64Optional win-x64 execution from linux-x64 hosts
win-x64 backend outputs can also be executed from an x64 Linux host when a Wine launcher is available, such as wine64, wine, or wine-stable in PATH. On Ubuntu 24.04, installing the wine package provides wine-stable, while wine64 alone lives at /usr/lib/wine/wine64.
src/Ashes.Tests/TestProcessHelper.cs auto-detects Wine for test-time PE execution, so EndToEndWindowsBackendTests and WindowsBackendCoverageTests can run from Linux hosts once Wine is installed.
For loopback TLS tests and other controlled overrides, the coverage helper passes SSL_CERT_FILE to the compiled PE program using a Wine-visible path so the embedded TLS runtime can load PEM roots without touching a host Windows certificate store.
win-arm64: host + target, but not executable on x64 hosts
win-arm64 (Windows on ARM64) is both a compile target and a host RID: the release ships a win-arm64 compiler/LSP/DAP bundle (with an aarch64-windows libLLVM.dll, provisioned by download-llvm-native.sh --win-arm64 / --all), so a Windows-on-ARM machine can both run the Ashes compiler and be targeted by it. Everything is built and structurally validated on an x64 host, but neither the emitted PE nor the WoA compiler executes there — Wine on x64 cannot load ARM64 PEs, and qemu-aarch64 runs ELF, not PE. Chaining them (x64 → qemu-aarch64 → an aarch64 Wine with the aarch64-windows PE builtins) is capable but impractical: under single-core TCG emulation, Wine's first-boot (wineboot) does not complete in reasonable time.
win-arm64 is therefore validated structurally on x64: WindowsArm64BackendTests parses the emitted PE (machine 0xAA64, imports, resolved relocations); scripts/verify.sh / ci/jobs.sh cross-compile a program and assert the machine field; and the host bundle is produced with dotnet publish --runtime win-arm64 and checked for an ARM64 ashes.exe + libLLVM.dll. Execution validation requires a native aarch64 host — a real Windows-on-ARM machine, or a native ARM64 Linux box / cloud ARM runner with Wine ≥ 10 (which ships the aarch64-windows builtins), where wine app.exe runs the PE at native speed with no qemu tax.
Running the Compiler
# Compile and run a program:
dotnet run --project src/Ashes.Cli -- run hello.ash
# Run an inline expression:
dotnet run --project src/Ashes.Cli -- run --expr "Ashes.IO.print(40 + 2)"
# Compile to a native executable:
dotnet run --project src/Ashes.Cli -- compile hello.ash -o hello
# Cross-compile:
dotnet run --project src/Ashes.Cli -- compile --target linux-arm64 hello.ash -o hello
# REPL:
dotnet run --project src/Ashes.Cli -- replTesting
Compiler Unit Tests
dotnet run --project src/Ashes.Tests -- --no-progressFilter by test class:
dotnet run --project src/Ashes.Tests -- --no-progress --treenode-filter "/*/*/ClassName/**"RC Perceus work normally starts with the ownership, reuse, and arena-boundary classes before the native memory profiles:
dotnet run --project src/Ashes.Tests -- --no-progress \
--treenode-filter "/*/*/PerceusLifetimePlacementTests/**"
dotnet run --project src/Ashes.Tests -- --no-progress \
--treenode-filter "/*/*/OwnershipTests/**"
dotnet run --project src/Ashes.Tests -- --no-progress \
--treenode-filter "/*/*/ReuseTokenTests/**"
dotnet run --project src/Ashes.Tests -- --no-progress \
--treenode-filter "/*/*/ArenaDeallocationTests/**"For allocator/lifetime behavior, add or run the multi-scale native RSS tests in LinuxBackendCoverageTests; see Compiler Memory Regressions. Correct output alone is insufficient for a memory-management change.
To inspect what the compiler decided about a program, use --explain. It takes ownership, rc, reuse, or memory, may be repeated, and accepts an optional :selector restricting the report to matching functions:
dotnet run --project src/Ashes.Cli -- compile hello.ash -o hello --explain ownership
dotnet run --project src/Ashes.Cli -- run program.ash --explain rc:fold --explain reuseownership reports the inferred contracts — per-parameter borrowed/consumed, move safety, uniqueness, result aliasing and freshness. rc counts the Perceus operations in the final semantic IR, the code actually handed to LLVM rather than what lowering first emitted. reuse reports specialization decisions and why a candidate was rejected. memory correlates all three with the physical representation each value received.
rc counts exactly the instructions --emit-ir final prints, so reach for the report when you want the magnitude and the dump when you want to know which operations on which values.
To read the IR itself rather than a report about it, use --emit-ir lowered or --emit-ir final. Requesting both and diffing them shows exactly what the Ashes-level optimizer changed, which is usually the fastest way to understand a placement or reuse decision:
dotnet run --project src/Ashes.Cli -- compile program.ash -o program \
--emit-ir lowered:loop --emit-ir final:loopReports go to stderr, so run --explain leaves the program's own stdout intact, and they are static: they describe compile-time decisions, not how often anything executed at runtime. Requesting a report cannot change generated code — the same source compiles to identical bytes with and without the option. See CLI Reference for the full surface.
LSP Unit Tests
dotnet run --project src/Ashes.Lsp.Tests -- --no-progressEnd-to-End Tests
dotnet run --project src/Ashes.Cli -- test testsFull Verification
The scripts/verify.sh script runs the complete validation pipeline: build, format check, unit tests, publish, format examples/tests, run examples, and run end-to-end tests:
bash scripts/verify.shMeasuring an Optimizer Change
An optimizer or lowering change is not done when its unit tests pass; it is done when a real compiled .ash program shows the effect. Hand-built raw-IR fixtures routinely encode a shape that never occurs in lowered output (a LoadLocal/LoadMemOffset pair where real closures use LoadEnv; raw temp reuse across a label where real joins always go through a local slot), so a pass can pass every test it was written against and fire on nothing. The routine that has caught every such case:
- Read the real shape first.
ashes compile --emit-ir finalon a small program written in the source pattern the pass targets, before writing the pass. Isolate one function withsed -n '/^function <label> /,/^function <next>/p'and grep-count the instructions the pass is supposed to remove — that count is the "did it fully fire" oracle afterwards. - Build a baseline. Commit first, then swap in the pre-change file with
git show origin/main:<path> > <path>, rebuild, compile the probe programs to separate binaries, restore withgit checkout HEAD -- <path>, rebuild, compile again. Never run the swap over uncommitted work. - Measure at both levels. The CLI defaults to
-O2, where LLVM already subsumes classical scalar and control-flow cleanups — many passes are identical there and only show at-O0. A change that removes an allocation, an RC operation, or an arena bracket shows at both. Usehyperfine -Nwith warmup and 10+ runs, one binary per invocation (batching hides a segfault behind a timing line), and/usr/bin/time -f "%es %MKB"for peak RSS. - Pick the signal that matches the claim:
--explain memory/--explain reusefor allocation and reuse counts,--explain rcfor dup/drop counts, an--emit-ir finaldiff for instruction shape, RSS for anything touching lifetimes, andls -laon the binary for layout changes. - Soak before merging anything RC-, reuse-, or closure-adjacent. Run the full
ashes test testssuite (--pipeline bothmirrors CI) and the compute-bound programs underchallenges/with the baseline and the new compiler, diffing outputs byte-for-byte; the C# suite has repeatedly stayed green through a crash that only the end-to-end suite or a real program exposed.
Record the before/after numbers and the exact program shape in the Compiler Changelog; a pass whose only evidence is its own unit tests has not been shown to do anything.
Self-hosting phase benchmarks
selfhost/bench/run.sh times the .NET compiler's phases and the self-hosted compiler's phases (import header, lex, parse, format, inference) over the same corpus and prints them side by side; selfhost/bench/README.md documents the phase mapping, how to add a phase, and keeps the refreshed results table. It is the compile-time counterpart of the challenges/ runtime benchmarks: refresh it when a self-hosting milestone lands or when a change touches the self-hosted packages' hot paths.
Formatting
.ash Files
All .ash source files must be canonically formatted. After creating or modifying any .ash file:
# Format a file or directory (prints formatted output):
dotnet run --project src/Ashes.Cli -- fmt path/to/file.ash
# Format in place:
dotnet run --project src/Ashes.Cli -- fmt examples -wC# Code
dotnet format Ashes.slnxVS Code Extension
Local Development
Build and install the extension locally:
bash scripts/install-vscode-extension-local.shBy default this publishes the compiler, language server, and debug adapter for the current RID only, then packages and installs the VSIX. Use --skip-install to package without installing:
bash scripts/install-vscode-extension-local.sh --skip-installUseful options:
# Publish all supported bundled RIDs (win-x64, win-arm64, linux-x64, linux-arm64):
bash scripts/install-vscode-extension-local.sh --all-rids
# Force a clean pnpm dependency reinstall before building:
bash scripts/install-vscode-extension-local.sh --force-install-dependencies
# Override the VS Code CLI to use for install:
bash scripts/install-vscode-extension-local.sh --code-command code-insiders
# When running from bash on Windows/WSL and targeting the Windows VS Code build:
bash scripts/install-vscode-extension-local.sh --target-rid win-x64On Windows, run the same script from WSL. Use --target-rid win-x64 when you need to bundle binaries for the Windows VS Code build.
Extension Only (No Publish)
To work on the extension TypeScript code without re-publishing .NET projects:
cd vscode-extension
pnpm install --frozen-lockfile
pnpm run compile
pnpm run lint
pnpm run format:checkPublishing
Build self-contained executables for distribution:
bash scripts/publish.shOn Windows, run the same command from WSL.
Development Rules
- Spec first. Update the Language Reference before implementing any new syntax or semantic rule.
- Layer discipline. Respect the project dependency graph (Frontend → Semantics → Backend). Runtime behaviour never goes in Frontend; the LSP must not depend on Backend.
- Test every invariant. Ship tests that prove new guarantees.
- Format
.ashfiles. Runashes fmtafter creating or modifying any.ashsource. - No
[NotInParallel]in tests. This attribute is banned. Fix the root cause instead.
See Architecture for the full dependency graph and compiler internals.
