Build with cargo build. Run tests with cargo test. Production build with cargo build --release.
Before committing, run what CI runs. .github/workflows/ci.yml is the authority on that and nothing else is: it is four jobs and seven failable steps, on every pull request and every push to master. Judge each by its exit code, and run it in this spelling:
test—cargo fmt --all --check;cargo clippy --workspace --all-targets -- -D warnings;cargo test --workspace.grammar, witheditors/tree-sitter-scarletas the working directory —bun install;bun run generate && git diff --exit-code src grammar.js;bun run corpus.hawk—cargo hawk check -D warnings.mordant—cargo dylint --all, withDYLINT_RUSTFLAGS=-D warningsin the environment.
Three of those need a tool this repo does not install for you: grammar needs bun, hawk needs cargo-hawk (below), and mordant needs cargo-dylint and dylint-link (cargo install cargo-dylint dylint-link --locked). All three run on an Apple host — none is Linux-only — so a missing one is worth installing rather than skipping. A host that still cannot run a gate reports it unrun, with the reason; a green you inferred from "my change does not touch that" is not a measurement.
Only these spellings report what CI reports. cargo clippy --all-targets without -D warnings exits 0 while printing the warnings, so it reports success on a tree CI will reject, and bare cargo fmt rewrites the files instead of reporting on them, so it cannot fail on formatting at all. --workspace is the one part that is not load-bearing today: the root manifest is virtual and sets no default-members, so every crate is already in scope, and cargo test and cargo test --workspace build the same 43 test executables. Keep writing it, so the scope cannot narrow in silence if a default-members ever appears.
bun run corpus parses every .scrl in the repo with the committed grammar. On a host with no tree-sitter configuration it prints Warning: You have not configured any parser directories! and then resolves this directory's grammar and runs anyway — the warning is noise, not a skipped gate, and a malformed .scrl still takes the step to rc=1.
hawk is a dead-code check that fails on pub items nothing reachable from the scarlet binary uses. It is not preinstalled, but it does run on an Apple host, so there is no reason to push blind on it — release 0.1.12 publishes cargo-hawk-aarch64-apple-darwin.tar.gz (and an x86_64-apple-darwin one) on the same GitHub release ci.yml installs from, and the tarball unpacks to a cargo-hawk-aarch64-apple-darwin/ directory holding cargo-hawk and cargo-hawk-driver. Put that directory on PATH and run cargo hawk check -D warnings --target-dir "$CARGO_TARGET_DIR/hawk". The release has to match rust-toolchain.toml's channel because hawk links against rustc's internals — 0.1.12 goes with 1.97.1, and they are bumped together.
Two things a local hawk run does not tell you. It computes reachability for the host target, and says so on its summary line, so an Apple host has analysed aarch64-apple-darwin while CI analysed Linux: for an item carrying no cfg(target_os)/cfg(unix) on itself or its callers the two agree, but over cfg-gated code they need not, and a local green is then not a CI green. And its exit code alone is not the verdict, because the instrumented build needs a scratch target directory and a disk-full there also exits 1 while printing no findings at all — read the hawk: N finding(s) line the run ends with, and pass --target-dir as above so that build lands somewhere you control instead of the default temp location.
That host-target gap is closeable, but not for free. cargo hawk check -D warnings --target x86_64-unknown-linux-gnu --target-dir "$CARGO_TARGET_DIR/hawk-cross" analyses exactly what CI analyses — measured agreeing with CI's 0 findings on 7010ee8, and measured with discriminating power: a planted pair of pub items, one unconditional and one #[cfg(target_os = "linux")], is caught in full (2 findings) by the cross run and only the unconditional one (1 finding) by a host-target run. It needs more than rustup target add, though — ring, aws-lc-sys and libmimalloc-sys compile C, so a stock Darwin toolchain dies in ring's build script with failed to find tool "x86_64-linux-gnu-gcc" before it reaches analysis at all, rc=1 with no hawk: line. A zig cc shim on PATH named x86_64-linux-gnu-gcc works, provided it strips cc-rs's --target=/-m64 flags before forwarding to zig cc -target x86_64-linux-gnu.
.github/workflows/build.yml is a separate Release workflow, not a pull-request gate: it triggers only on push to master, a nightly cron, and manual dispatch. Its build job runs cargo build --release --target <T> and cargo test --release --target <T> over aarch64-apple-darwin, x86_64-apple-darwin, x86_64-unknown-linux-gnu and aarch64-unknown-linux-gnu. So a break that is specific to a target or to the release profile passes CI, lands, and goes red only afterwards — and x86_64-apple-darwin is built there and nowhere else.
scarlet lint is the .scrl illegal-state census: it reads a corpus with the compiler's own scanner and parser and reports constructors carrying several Bool fields, and catch-all arms over a locally-declared sum type. It lives in crates/scarlet_core/src/lint/ and is reached from the driver's lint subcommand; it began as cargo xtask scrl-census, and that xtask command is gone rather than delegated to. It is deliberately a command you run, not a default-on compiler warning and not a CI gate. Measured on this repo when it moved (97 files, 177 type declarations, 524 matches): shape 1 is 2 findings, both in tests/programs/decoders.scrl and both fixtures whose width is the property under test; shape 2 is 39, of which 15 are extractors and the remaining 24 ordinary dispatches with a default arm, including Method in the stdlib's own http.scrl. So a default-on warning's precision here would be 0/2 and at best 0/24. Diagnostics print to stderr and the golden harness asserts a clean run writes none, so turning either shape on by default also takes golden_examples red on correct code. lint/mod.rs's module doc carries the full measurement; T-148's opt-in @exhaustive remains the language-level mechanism for the class this census declines to gate.
Be sparse when adding comments in the code. Do not add unnecessary comments. Do add comments when explaining larger, more complicated code paths. Especially in things like the parser and compiler or vm.
Crate layout: crates/scarlet_vm is the language-agnostic runtime (bytecode ISA, NaN-boxed values, heap, frozen area, interpreter/schedulers/JIT under scarlet_vm::vm) and must never depend on any language crate — Cargo enforces this, keep it that way. crates/scarlet_syntax is the syntax layer (span, token, scanner, parser, AST, formatter, diagnostics, module identity) and depends on nothing in the workspace. crates/scarlet_types is the type layer (HM inference, type_def, exhaustiveness) over scarlet_syntax + scarlet_vm. crates/scarlet_core is the compiler (typed_ir, core_ir, bytecode emission, module resolution, LSP session) and re-exports the lower layers at their historical paths (scarlet_core::parser, scarlet_core::types, scarlet_core::heap, ...). crates/scarlet is the driver (CLI, REPL, LSP); its scarlet::vm module wires the generated stdlib template table (STDLIB_TEMPLATES) into scarlet_vm::vm — the VM constructs stdlib values (Ok/Err, NetError, HTTP types) only through that injected table.
The AST is defined in crates/scarlet_syntax/src/ast/mod.rs. When changing AST shape, also update scarlet_syntax's parser/mod.rs (construction) and formatter/mod.rs (rendering — a field the formatter drops silently rewrites the user's program), plus scarlet_core's bytecode/compiler/ (typecheck) and typed_ir/elaborate*.rs (which lowers it).
The HM type inferencer lives in crates/scarlet_types/src/types/infer.rs. Type definitions are in type_def/mod.rs. Exhaustiveness checking is in types/exhaustiveness.rs.
Editor extensions live in editors/. For the VSCode extension in editors/vscode/, use Bun for package management and running scripts (e.g., bun install, bun run compile). The Zed extension in editors/zed/ is a standalone Rust crate (not a workspace member — Zed builds it for wasm32-wasip2 itself).
I have aliased cat to be bat, which when piping with STDIN will add a "STDIN" string on the first line. For this reason, use /bin/cat explicitly for catting when piping
Stdlib convention: fallible operations return Result(_, Nil), not Option, even when the error carries no data — Result signals "operation failed" and chains via result.then; Option is for "value may be absent" (e.g. index_of, map lookup).
When deciding between n+1 implementations of a feature or fix, prefer the one that is more idiomatic and correct. Working on a programming language is something that has a lot of prior art. Generally consider what is more idiomatic and correct over what is more clever, "fun", or "efficient" unless efficiency is the main concern of the code path. As a rule of thumb, "effort" to achieve an implementation is not a worry here. Do not worry about things "getting complicated" or "big" - if you find yourself adding TODO comments, consider removing them and continuing with the full, correct implementation.
Never add Claude or Anthropic branding to commit messages, issue bodies, PR titles, PR descriptions, etc.
A map key must be the canonical identity of the thing it names — never a span (no file identity), a written import path (not the resolved file), a pointer's bits (not the value), or a position from another file's coordinate space. Five bugs shared this shape: HashMap<Span, Ty> let the REPL retype an earlier entry, path_key of the written import merged two different modules, and constant-pool dedup keyed on to_bits() (a pointer for boxed values) duplicated every big-int constant. When adding a map, name what the key is the identity of; if two distinct things can collide on it, it is the wrong key.
A fact must be carried by a value the code path that made it true produced — never re-derived later from mutable state. The REPL once ran a Program whose toplevel the compiler had skipped emitting (the module had an error), because "is there something to run" was re-read from a Vec<Diagnostic> a caller had filtered; the entry frame's pre-filled locals then made the missing result look like a computed 0. The fix is the shape CleanModule already had: mint a witness (EntryToplevel) at the site that does the work, and make it the only door to the result (CompileResult::into_runnable). When a boolean or an Option says something happened elsewhere, ask which line of code sets it, and whether anything between there and the reader can make it lie.