Testing
Most of ry is tested with fixtures: plain-text files that pair a piece of R with the output ry should produce for it. A fixture reads well in a diff, and a new case takes seconds to add. Reach for an ordinary Rust test only when the behavior is awkward to express as rendered output.
A fixture suite describes the desired semantics, not what the implementation happens to pass.
Record a known gap explicitly, in .agents/memory/backlog.md or as a case pinned to the
wrong-but-current output with a comment that says so, because an unrecorded gap looks intentional to
the next reader.
Fixture format
Section titled “Fixture format”Every suite in the shipping crates runs through one harness, syntax::testing::run_fixture_suite,
which syntax, semantics, format, and ide all share. A .test file holds groups of cases:
#==== group_name#---- case_name<input source>#++++<expected rendered output>group__caseis the case’s stable identity. It must be unique across the suite, and a duplicate is rejected rather than allowed to shadow the other case silently.- What the expected output shows (schemes, diagnostics, a tree dump, hover text) is up to the suite’s runner. Behavior specific to one suite belongs in its runner, never in the fixture syntax.
- A typing or IDE case may hold several files. Each
#~~~~ <path>line starts one (seesyntax::testing::split_files), and a path underR/is a package file while anything else is a script. A typing case renders each file’s output under an== <path>line. - The runner loads only
.testfiles, so anything else in a suite directory is ignored.
Running one case
Section titled “Running one case”To run a single case, set FIXTURE_FILTER and pick the suite’s test target:
FIXTURE_FILTER=group__case cargo test -p semantics --test test_typing_fixtures -- --nocaptureFIXTURE_FILTER=group__case cargo test -p format --test test_format_fixtures -- --nocaptureFIXTURE_FILTER=group__case cargo test -p ide --test test_ide_fixtures -- --nocaptureOne test target can drive several suites, so a suite that does not hold the case skips it quietly. But a filter that matches no case in any suite fails: that is almost certainly a typo, and a run of zero cases would otherwise report a pass.
While iterating, cargo test -p semantics is the default command, since it covers analysis
behavior. just gate runs every test plus clippy and a formatting check. CI runs the same recipes,
but only when started by hand: the orchestrating agent starts it on main at its hourly check when
main has new commits, so a push or a pull request gets no CI run of its own.
Blessing expectations
Section titled “Blessing expectations”With RY_BLESS=1, a mismatch does not fail. Instead, the harness rewrites the #++++ blocks in the
.test files in place from the runner’s actual output:
RY_BLESS=1 cargo test -p semantics --test test_typing_fixturesOnly the body of each #++++ block is rewritten. Directive lines and the spacing around them are
kept, so a blessed file is byte-identical to what a person would have written, and a second run
without bless passes. FIXTURE_FILTER still applies, so you can bless a single case, and blessing a
suite that is already correct changes no bytes.
Review every blessed change before you commit it. Bless records whatever the runner produces, wrong outcomes included, so accept a changed expectation only when the behavior or the wording improved on purpose.
The fixture suites
Section titled “The fixture suites”crates/syntax/tests/syntax: golden lossless trees and syntax errors, rendered bydebug_dump.crates/syntax/tests/tsr: tree-sitter-r’s parser corpus, converted to the same format.crates/syntax/tests/errors: golden Elm-style error messages, organized by area (the lexer, delimiters, control flow, functions and calls, the#:type and directive grammar, and recovery locality). Its coverage contract has three parts: every distinct error message the lexer or parser can emit has at least one case here, valid but tricky shapes are pinned asno errors, and recovery cases assert that one broken construct reports once and leaves the rest of the file clean.crates/semantics/tests/naming: the name-resolution suite. It records which binding every name in an item resolves to, rendered flat in source order. This is the only suite that tests naming directly. Everywhere else naming is tested through its consequences, a type that comes out right or a diagnostic that does or does not fire, and consequences cannot see a read that resolves to the wrong binding while still producing the same type.crates/semantics/tests/typing: the typing suite. Each case runs the full semantic pipeline on one package file, or on several, with the shipped stubs installed. It renders the exported scheme of every top-level definition (name: <T: numeric> fn(x: T) -> T), the type of every other top-level statement as a bare line, and then the file’s diagnostics (start..end severity[code] message, in byte offsets). Each test thread reuses one database across cases (semantics::testing::with_fixture_project), because parsing the stub corpus costs far more than checking a case. Cases are grouped by feature, with one exception:programs.R.testholds whole small programs written the way real projects are written. A case earns its place there by exercising several features at once, and a clean case with an empty diagnostics block is a promise of zero false positives for that style of code.crates/semantics/tests/lowering: the HIR each item lowers to, dumped as an indented tree under the item’s name (or under# <statement>for an unnamed item). Argument tags are rendered inline, because matching arguments is what a call’s lowering has to get right. The suite exists because rewrites, such as the native pipe becoming a call, a replacement form, orlocal, would otherwise be tested only through the type that falls out at the far end, which cannot tell “lowered correctly” from “lowered to the wrong shape but typed the same”. A case whose comment saysWRONG-BUT-CURRENTpins a known defect next to the shape it should have, so fixing the defect turns the case red.crates/semantics/tests/typing-scripts: the same pipeline over script documents, which have one sequential, top-down scope. Besides script semantics, this suite holds the rules for single expressions (literals, operators, indexing, lists, and calls to stubbed functions), rendered one line per statement.crates/semantics/tests/annotations: one#:block per case, where the case’s lines are the block. It renders what the block lowers to (the kind of coercion, the declared type, the@newtarget, and each@typeor@aliasdefinition), followed by its grammar and lowering errors. This is the compact contract for the type notation, while thesyntaxsuite pins the trees and theerrorssuite the wording.crates/semantics/tests/stubs:.Rtypesdeclarations. Each case renders the declarations it loads, with overload candidates in order, and the problems that make the loader drop a line, in the wording the editor and the override report show.crates/semantics/tests/typing-inline-off: the ordinary rendering with[check] inline-annotations = false, which pins thery-syntaxwarning on records, tuples, tags andmatch, and shows that literals are still typed whilematchcoverage is not checked.crates/semantics/tests/typing-strict: the strict stream. After the ordinary rendering, each case appends the file’s typing mode and itsstrictdiagnostics.crates/semantics/tests/top-level: what a package file’s top level runs under strict mode. A case lists files with#~~~~separators, soR/files are library files andtests/or root files are entries. Each case loads the shipped stubs and renders, per file, every top-level item asdefinition,constantoreffect, with(pure)when the item declares its function pure, followed by the file’s top-levelstrictdiagnostics. Run it withcargo test -p semantics --test test_typing_fixtures top_level_fixtures.crates/semantics/tests/typing-imports: package metadata. A leading#namespaceline supplies the case’sNAMESPACEsource and a#descriptionline itsDESCRIPTIONsource, and both are installed as thePackageMetadatainput before rendering. The directive lines stay in the analyzed text as ordinary comments, so expected offsets are honest. Attaching a package needs no directive: alibrary(data.table)statement in the source activates the conditional stub namespace through the same scan the hosts run.crates/semantics/tests/lints,lints-styleandlints-object-systems-off:lints::lint_file, first under the default configuration, then undernaming-style = "snake_case",unused-parameter = "warn",shadows-builtin = "warn", andshadows-namespace = "warn", all of which are off by default, and last under[check] object-systems = false. Each case renders every finding asstart..end severity[code] message, orclean. Run all three withcargo test -p semantics --test test_lint_fixtures.crates/semantics/tests/load_index: the package-load index. A case listsR/files, and optionallyNAMESPACEandDESCRIPTION, with#~~~~separators, and renders the package’s name, theNAMESPACEdirectives a load honors, and, per file, each item’s position and whether it is bound lazily (as a function or, in a strict file, a constant) or runs eagerly, with what it writes, what it reads while running, and the registrations it makes. Run it withcargo test -p semantics --test test_load_index_fixtures.crates/loader/tests/reload: reload plans. A file headed#~~~~ before <path>or#~~~~ after <path>exists on that side only, so the same path on both sides is an edit and one side alone is an addition or a deletion, while a plain#~~~~ <path>is unchanged. The case loads thebeforeside, then renders the plan that takes it to theafterside: what is bound, run, removed, unregistered, re-registered, and which datasets are read again.R/files run in the orderDESCRIPTION’s collation field gives them, and adata/file is a dataset whose text stands in for its size and modification time. Run it withcargo test -p loader --test test_reload_fixtures.crates/format/tests/format: the formatter’s golden suite. Each case’s source must format to the expected block, and the runner formats that output again to assert idempotence on every case. When the expected result is a refusal, the case renders the structuredFormatError.crates/format/tests/format-range: range formatting. The source marks the selection with$0markers (two for a selection, one for a bare caret, none for the whole file), and the expectation lists the edits by one-based position and shows the document they leave behind, with each replacement between«and». Run it withcargo test -p format --test test_format_range_fixtures.crates/ide/tests/ide: the editor features. The source carries one$0cursor marker, which is stripped before analysis, and the expectation renders each feature’s answer at that position: the hover line with its absolute range, the definition’s target range, and the reference ranges. A target inside the stub corpus renders the token its range covers, as indeclared in stub source 0 at `print`, rather than a byte offset, because an offset shifts whenever an unrelated declaration is added, while the token proves the range is right.crates/ide/tests/structure: whole-file editor features. Each case renders the outline (nested by indentation), the folds as zero-based inclusive line ranges, the semantic tokens for R names, the quick fixes the diagnostics and default lints offer, and, when the source has a$0marker, every step of selection expansion from it. Run it withcargo test -p ide --test test_ide_fixtures structure.crates/ide/tests/migrate: the data.table to DataFrame rewrites behind the code actions andry migrate data-table. Each case is one script whose attached packages are the ones it attaches, and the expectation lists every rewrite as its title, the original lines (-) and the replacement (+), orno rewrite. A$0marker also lists the code actions offered there. Run it withcargo test -p ide --test test_ide_fixtures migrate.
Fuzzing and property tests
Section titled “Fuzzing and property tests”Fixtures check the answers someone wrote down. The harnesses in this section check properties that must hold for every input, over a great many inputs. Every pipeline stage gets fuzz coverage from its first commit.
The batteries of invariants are exported, so that the in-tree harnesses and the coverage-guided
targets share exactly the same contracts: syntax::testing::check_parse_invariants for the parser,
format::check_format_invariants for the formatter, and semantics::testing::check_semantics_input
for the semantic pipeline. The last one folds every lint, under a configuration that turns them all
on, into its rendering, so the lint layer inherits the same invariants. The format and semantics
harnesses, and those of syntax and ide, also carry a fuzz_regressions_hold_invariants battery
that pins every input the coverage-guided targets have ever broken.
The semantic pipeline
Section titled “The semantic pipeline”crates/semantics/tests/test_fuzz.rs runs generated programs, token soup, and two-file projects
through the full semantic pipeline, with the generator biased toward reference cycles, closures,
annotations, and typing-mode directives. On every input it checks four properties:
- Nothing panics, which among other things means every salsa fixpoint converges.
- The output is deterministic across fresh databases.
- Diagnostic ranges are well formed.
- Incremental equals fresh. Editing a file through the salsa setter gives the same output as a fresh database on the edited text, and editing it back restores the original.
FUZZ_ITERS scales the budgets. A bounded default runs inside cargo test -p semantics, the
ignored fuzz_deep test carries the long runs, and on a panic the harness prints the inputs that
produced it.
The formatter
Section titled “The formatter”crates/format/tests/test_fuzz.rs feeds the formatter valid-program seeds, byte-level mutations of
those seeds, token soup, random bytes, every fixture case source in the repository, and real corpus
files once they are fetched. It checks four properties: nothing panics (the formatter either
succeeds or refuses with a structured error), the output is deterministic, the code is preserved,
and formatting is idempotent, meaning that formatting a successful output again succeeds and
reproduces it byte for byte.
The refusal path is part of the contract. A file with a syntax error in its R is refused, while an
error in a #: annotation, which the parser marks in_annotation, only sends the affected block
down the verbatim path.
Preservation is the one property that can catch the formatter changing your code. Determinism, idempotence, and “the output formats again” all hold for a formatter that silently drops a statement or misspells a token. Preservation compares the sequence of non-trivia token kinds and spellings between input and output, with two allowances:
- Four kinds are ignored entirely (
{,},;, and#:), because the formatter may add or move them: it braces single-statement bodies, splits;chains onto separate lines, and re-lays out annotation blocks. - A string is compared by its content rather than its text, because the formatter chooses the quote character. Raw strings are copied byte for byte, so they keep their full spelling.
Comparing kinds alone would miss a miscompile: a stale or off-by-one source range makes the formatter emit the wrong bytes for an identifier, which renames a variable while preserving every kind. An injected bug of exactly that shape passed a kind-only comparison and fails the spelling comparison.
Every generated input also runs the range battery, check_range_format_invariants, over a bounded
spread of selections (the whole file, carets, whole lines, part-line spans, a range past the end).
Range edits must be deterministic, refuse exactly when the whole file refuses, cover whole lines in
order without overlapping, preserve the code, leave a document that still formats to the same
result, be a subset of the edits formatting the whole file in place makes, reproduce that result
when the selection is everything, and be stable when re-run on what they produced. On inputs of up
to 24 lines it also checks additivity (a span of lines makes exactly the union of its lines’ edits)
and composition (formatting line by line until nothing changes reaches the whole-file result, and
every step still formats to it). Composition is the strongest of these: it found whole-file
idempotence bugs that no single-pass check sees. crates/format/tests/test_format_range.rs runs the
battery over every fixture source, the program corpus, and re-indented copies of the fixtures (four
spaces, tabs, mixed steps), and checks every byte selection of a few short sources against an
independent line oracle.
The editor features
Section titled “The editor features”crates/ide/tests/test_fuzz.rs sweeps every feature over a sample of byte offsets in each input:
every token boundary plus a coarse stride, so positions off token boundaries and in the middle of
multi-byte characters are covered too. At each offset nothing may panic, and every range handed to
the editor must lie inside the text it indexes.
Containment alone is a weak check, though: it says a range is somewhere in the file, not that it is on the right thing. Wherever the cursor sits on a name, two stronger properties hold as well:
- Name identity: every rename edit, every reference, and the definition target cover text that
spells the same name (with backtick quoting normalized, since
`x`andxare one binding). - Round trip: if the cursor navigates to a declaration, asking for references from that declaration leads back to the cursor’s own token.
An off-by-one injected where item-relative ranges convert back to absolute offsets passes the containment check and fails name identity on the first seed.
The data.table rewrites are checked once per input: they never overlap, every range lies inside the text, and when the input parses cleanly, so does the text with every rewrite applied.
Completion is sampled once per completion context (the kind of token the cursor sits in or after) rather than at every offset, because it costs far more per call than any other feature and its answer depends on the syntactic context rather than the exact byte.
The program corpus
Section titled “The program corpus”crates/syntax/tests/programs/*.R.corpus holds 1,967 distinct R programs, read through
syntax::testing::program_corpus_sources. The syntax, format, and semantics batteries each
run the whole set. The corpus holds inputs only, with no expected output.
The inputs are usable on their own because the invariants need no expected output: nothing
panics, syntax checks the lossless reprint and the tree geometry, format checks preservation,
and semantics checks that every diagnostic range lies inside its file. The semantics arm shares
one database across the whole corpus instead of using the per-input battery, because a fresh
database re-parses the stubs every time. It therefore asserts only what a shared database can, and
leaves determinism and incremental equivalence to the generated inputs. Every arm also asserts that
the corpus is non-empty, so deleting it fails loudly instead of passing on zero cases.
The annotation round trip
Section titled “The annotation round trip”crates/semantics/tests/test_roundtrip.rs asserts that every type the checker prints is a type you
could write back. A #: TYPE annotation says the annotated value is compatible with TYPE, and the
checker has just proved the value has the type it rendered, so pasting an inferred scheme above its
own definition must add no finding.
No other test compares the renderer with the annotation grammar: every other suite reads a rendered type as a string, so a type that prints well but parses back as something else is invisible to them.
The test runs over every fixture case source. It skips cases that do not already check clean,
because “re-declaring adds no finding” needs a baseline with no findings, and it skips definitions
that already carry a #: block, because those render the user’s own text rather than a type the
checker chose. It re-points one database with the salsa setter instead of building a new one per
probe, because a fresh database re-parses the whole stub corpus.
KNOWN_UNWRITABLE lists schemes that are allowed not to round-trip. It is empty, and worth keeping
that way. Its entries name case ids rather than skipping by shape, so a new unwritable rendering of
any kind fails the test instead of blending into a known category.
Tree-sitter as a second opinion
Section titled “Tree-sitter as a second opinion”crates/syntax/tests/test_corpus.rs compares our verdict on “does this file have errors?” with
tree-sitter-r’s, in both directions and over two sets of inputs:
corpus_acceptanceruns over the fetchedcorpus/and gates the direction where we reject what tree-sitter accepts, which means a gap in our grammar.in_tree_acceptanceruns over inputs that are always present (the program corpus plus every fixture case source) and gates the other direction, where we accept what tree-sitter rejects.
That second direction is the only check that bounds the number of parse errors from below. Every other parser invariant bounds it from above, so without it an over-suppressing parser, the regression most likely to slip in given how much deduplication the parser does, would pass every fuzz battery while broken files went silently clean.
Each direction is gated only where it means something. Rejecting what tree-sitter accepts is noise
on in-tree input, which is full of #: annotations (comments, to tree-sitter) and of deliberately
broken sources. Accepting what tree-sitter rejects is gated there against
crates/syntax/tests/in-tree-acceptance-allowlist.txt. Each allowlist entry is adjudicated by
running R itself, and must say which of two things it is: tree-sitter being wrong, or a known gap in
this parser.
Inline type annotations are ry syntax that R rejects by design, so tree-sitter judges the plain R
program a source erases to (syntax::erase_inline_annotations), not the source itself. That makes
the test a check on erasure too: an annotated fixture whose erased text tree-sitter rejects fails it.
check_parse_invariants pins erasure from the other side, for every fuzz input that parses cleanly:
the erased text parses cleanly, holds no inline annotation, and erases to itself.
The runtime
Section titled “The runtime”The runtime is tested against models and against GNU R rather than with fixtures. The oracle’s
cases (crates/oracle/cases/, Corpus and oracle) hold the
R-level behavior, and crates/interp/tests/test_fuzz.rs fuzzes lowering and checks that the
tier-0 bytecode agrees with the evaluator. crates/jit/tests/test_tiers.rs does the same one tier
up: it runs random loop, branch and vector programs as closure bodies under tier 0 and under the JIT
forced on (jit::Mode::Force, which compiles a body on its second call and a loop on its first
backward jump), and every call must give the same value or error. Most generated programs must run
to a value, so a generator that drifts into erroring early fails the test instead of passing
without comparing anything. The test interpreter has no base R closures (numeric, as.list,
withCallingHandlers), so programs use builtins, and each hand-written seed states whether it is
meant to fail. Behavior that needs base R, such as a warning handler watching a compiled loop,
goes in the oracle case bytecode__hot_loops.R. crates/interp/tests/test_linalg.rs pins base R’s linear
algebra to GNU R’s results bit for bit. The matrix products are ry’s own kernels, so their bits hold
on every machine; the LAPACK cases hold only on the reference libraries, so on another BLAS (such as
CI’s OpenBLAS) the test compares them with a GNU R on the same libraries, and skips them without one. crates/table/tests/test_table.rs checks the table engine
against values read from data.table, test_lazy.rs there checks the Polars-style verbs, and
crates/libr/tests/test_frame_altrep.rs checks the columns as ALTREP vectors. crates/interp/tests/test_records.rs, test_arrays.rs and test_standard_library.rs cover ry’s
own values at run time, the last one also holding the runtime’s function table against the
declarations in types/std.Rtypes. crates/libr/tests/test_rmath.rs, test_numerics.rs and
test_linpack.rs compare the C API’s numerics with answers recorded from GNU R, so they run
without R, and test_fuzz_rmath.rs checks properties of the distribution functions on random and
extreme arguments. crates/heap/tests/test_gc.rs builds random
object graphs (vectors, lists, interned strings, environments), drops roots, stores young objects
into old ones and collects at random points, with and without collection stress, and after every
collection checks each object the model still reaches. It also re-interns every string ever made,
so an intern-table entry that outlived its string shows up. A disabled write barrier, a frame whose
cells are not traced, and a minor collection that keeps dead strings in the table each turn it red.
Tests that load base R take it from the GNU R installation R_HOME names, and the runtime then
behaves as that version; without R_HOME they use ry’s own base R (just base-r), and the
scenarios skip when it is not built. The oracle’s expectations are GNU R 4.3.3 output, so CI sets
R_HOME to 4.3.3.
Run them once per supported version, for example R_HOME=/opt/R/4.6.1/lib/R cargo test -p interp, and the oracle the same way with ORACLE_R (Corpus and oracle).
crates/libr/tests/test_fuzz_native.rs fuzzes the native boundary itself. It nests every
non-local exit native code can set up (Rf_eval, R_tryEval, R_ToplevelExec, R_tryCatchError,
R_UnwindProtect, R_ExecWithCleanup and a cpp11-style C++ unwind) around values and errors, and
checks the outcome, which cleanups ran, and that every C context, protection and R_alloc block was
released. A second battery drives random API sequences under collection stress against a model.
crates/oracle/tests/test_differential.rs runs whole generated R programs (oracle::fuzz::program)
under GNU R, ry’s tier 0 and ry’s JIT, and under collection stress against ry unstressed, and every
engine must print the same output, warnings and errors. The generator covers loops shaped for the
JIT, closures, S3 dispatch, conditions and on.exit, environments, attributes, NA and overflow,
and about one program in eight ends on a deliberate error. cargo run -p oracle -- fuzz --seed N --count K --engines ry-tier0,ry-jit,ry-gctorture runs any range, prints each divergent program and
its replay command, and just fuzz-differential and just gc-torture are the long runs.
crates/oracle/tests/test_gallery.rs renders a seeded sample of the plot gallery under both
engines and compares the images (Corpus and oracle).
just gc-torture runs every oracle case on every core and takes about ten minutes on four cores
in a debug build. RY_GC_STRESS=1 stresses from the script’s start and RY_GC_STRESS=startup
from the first allocation, which takes about seven seconds on an empty script in a debug build.
crates/interp/tests/test_debugger.rs drives the debug adapter (interp::debugger) in-process.
Scripted sessions pin breakpoints, stepping, the stack, variables, evaluate, browser() and
uncaught errors as transcripts, and a seeded simulator plays a client that sends random, sometimes
invalid, requests at every stop. Every request must get exactly one response, the session must end,
and the interpreter must be left clean. FUZZ_SEED=<seed> replays a failing session.
The table engine
Section titled “The table engine”crates/table/tests/test_table.rs drives the engine from Rust. Every expected value that encodes
data.table behavior was read from data.table under GNU R. Its differential fuzz generates random
tables and queries and checks the native kernels against the Polars plans
(query::execute_with_plans_only), which serve as the reference. FUZZ_ITERS raises the
iteration count; run it in release, as FUZZ_ITERS=3000 cargo test-release -p table fuzz. The
fuzz tables are small, so the kernels’ parallel paths get large-table tests of their own.
just bench-table <dir> [--rows 5e7 --groupby-only] runs the db-benchmark questions under
data.table, as R code under ry run and on the engine from Rust, fails if any checksum differs from
data.table’s, and rewrites the matching results table in development/design/r-runtime.md.
just bench-base [--rows 1e5,1e6 --only REGEX --memory] times base R’s own kernels (sorting,
string formatting and regex, grouping, merge, match, matrix margins, cumulative sums, xts and
zoo rolling operations) under ry run and GNU R, and prints the ratio ry / GNU R per kernel. A
kernel whose checksum differs from GNU R’s is marked MISMATCH. With --memory every kernel
runs in its own process, and the table adds both engines’ peak RSS. zoo and xts load from
RY_BENCH_RLIB (default ~/R/library).
just bench-stdlib [filter] [n] times every List, Str, Dict and Result function at
n = 1e6 by default against its fastest GNU R equivalent, and prints one row per function with
the ratio ry / GNU R. The cases live in tools/bench/stdlib: ry.R holds ry’s side and gnu.R
the R alternatives in base R, purrr, stringr and stringi under the same case names, and the
fastest alternative sets the bar. A callback function is compared with R’s callback idioms
(vapply, Filter, Reduce, a for loop, purrr), and a function on one string with R’s
vectorized string functions. Where ry’s input is a vector, the case also runs it on an Array,
ry’s unboxed vector, and the faster of the two counts. Each case runs in its own process, so a case slower than
--timeout shows as timeout without stopping the rest, and a check column compares
checksums of both results. Packages load from R_LIBS_USER, and an alternative whose package is
missing is skipped and named in the row. --sort ratio puts the slowest functions first, and
--write saves the table, as in tools/bench/stdlib/results.md.
Coverage-guided fuzzing
Section titled “Coverage-guided fuzzing”fuzz/ is a cargo-fuzz crate, in its own workspace and excluded from the main one. Each target is a
thin wrapper around a battery its crate exports, which the crate’s in-tree harness runs too:
| Target | Battery |
|---|---|
parse |
syntax::testing::check_parse_invariants |
format |
format::check_format_invariants |
semantics |
semantics::testing::check_semantics_input |
ide |
ide::testing::check_ide_invariants |
position |
ry::position::check_position_invariants |
config |
ry::config::check_config_invariants |
lsp |
the language-server simulator, run_session, reading its choices from the input |
load_index, reload |
loader::testing::check_load_index_input, check_random_reload |
sniff |
sniff::check_sniff_invariants |
csv |
table::io::check_csv_input |
interp_lower, interp_unserialize, bytecode |
interp::testing::check_lowering_input, check_unserialize_input, check_bytecode_agreement |
unserialize |
R::serialize::check_unserialize_input |
The semantics target derives its document kind and an incremental edit from the input bytes, so a
single byte stream drives the full battery, the splice cache included. The generator-driven targets
(lsp, reload, bytecode) read the generator’s choices from the input through
syntax::testing::ByteStream, so a byte mutation is a mutation of the program or session. The
runtime targets link polars, so cargo fuzz build --no-default-features builds only the analysis
targets on a machine with little memory.
It needs nightly, which the repository’s pinned toolchain already is. Run cargo install cargo-fuzz, then just fuzz-run format runs one target, and just fuzz-all 120 runs every target
for two minutes each after seeding the corpora. Without just, from fuzz/:
cargo -Zscript ../scripts/seed-fuzz-corpus.rs # seed from all fixture case sourcescargo fuzz run format -- -max_total_time=600 -print_final_stats=1cargo fuzz run parse -- -max_total_time=600The seeder copies every fixture case’s source in the repository into
fuzz/corpus/{parse,format,semantics}/. Files are content-addressed, so running it again only adds.
The corpus and the artifacts are gitignored, because a durable regression belongs in the
REGRESSIONS battery or in a fixture case, not in the corpus.
When the fuzzer finds something, minimize it with cargo fuzz tmin format <artifact>, fix
the bug, replay the artifact, and add the minimized input to REGRESSIONS, plus a golden fixture
case when the shape deserves a pinned rendering. To judge corpus quality, you can get a coverage
report (the pinned toolchain includes the llvm-tools component it needs) with
cargo fuzz coverage format && cargo cov -- show, as the
cargo-fuzz coverage guide describes.
Deep runs are manual or scheduled work; the bounded in-tree batteries remain the gate in the default
suite.
Reloading
Section titled “Reloading”crates/loader/tests/test_reload_fuzz.rs generates packages from statements that define, wrap,
read and rewrite a small pool of names, edits them at random, and checks that applying the reload
plan to the loaded package gives what a fresh load of the edited package gives. A model executes
both sides: a lazy binding’s value is its definition’s text, and a statement stores its text plus
the values it read, so a statement that should have rerun shows up as a stale value. The same test
checks that the incrementally derived index equals one built from scratch, and a second test feeds
the index arbitrary bytes and asserts its positions stay inside the text. Removing either of the
diff’s ordering rules turns it red within a few hundred seeds.
End-to-end suites
Section titled “End-to-end suites”The CLI
Section titled “The CLI”crates/ry/tests/test_cli.rs drives the real ry binary end to end. It covers:
- diagnostic rendering, with 1-based character positions, snippet windows, and underlines;
- JSON Lines output;
- the exit-code contract;
- finding the configuration, and failing to;
- per-file
# typing:directives and suppression comments; NAMESPACEvalidation, and project stub overrides including their loader-problem reports;- data-masked evaluation;
- the formatter’s
--check,--diff, and in-place modes; ry runandry Rwithout R: help output, usage errors, a missing script, and strict mode refusing a script before any R starts.
crates/ry/tests/test_command_line.rs property-tests and fuzzes argument parsing for ry run,
ry R and the ry repl alias against a model: everything after the script reaches it verbatim,
options resolve to the documented runtime and mode, and no argument vector panics the parser.
test_repl_e2e.rs runs every entry point on both runtimes: script arguments, -i, piped consoles,
and the earlier spellings (--runtime, RY_RUNTIME, ry repl).
Docs examples
Section titled “Docs examples”crates/ry/tests/test_docs.rs runs the examples in the docs through the real ry binary, so a page
that shows stale output fails the suite. A block opts in with a marker: the word ry after the
fence’s language, then the command and its options. Expressive Code ignores the extra words, so the
site shows a plain block, and the pages’ Markdown versions drop the marker. Here an output block
follows a tab group:
<Tabs syncKey="annotation-style"><TabItem label="Inline">
```rgreet <- function(name: character, greeting: character = "hello") -> character { paste(greeting, name)}greet("Ada", greting = "hi")```
</TabItem><TabItem label="Comments">
```r#: fn(name: character, [greeting]: character) -> charactergreet <- function(name, greeting = "hello") { paste(greeting, name)}greet("Ada", greting = "hi")```
</TabItem></Tabs>
```text ry check typingtype-mismatch
x this function has no parameter `greting`. Did you mean `greeting`?```A marked text block is the output of the nearest r block above it, or of every tab of the
nearest tab group above it, and each tab must produce it on its own. Inside a <TabItem>, after the
tab’s r block, it is the output of that tab alone, and a tab of the same group with no output
block of its own must report no errors. The code runs in a throwaway
project of one ry.toml and one file, main.R unless the output’s snippet names another file, as
--[R/both.R:2:31] does. A tab group that no output block follows must give the same findings in
every tab, with typing on. An unmarked output block is not tested.
| Word | Meaning |
|---|---|
check / run |
Run ry check, or ry run main.R. |
typing, strict |
Set that [check] key to true. Type checking is off unless a block says typing. |
KEY=VALUE |
Any other [check] key, written as in ry.toml, such as object-systems=false. |
runtime=ry / runtime=gnu-r |
Which runtime run uses; GNU R by default, as on the command line. |
exact |
Compare the whole output instead of an excerpt. |
continue |
Run the page’s earlier r blocks first, as one session, and compare only this block’s output. A check takes every earlier block; a run takes only those whose output the page checks, so an illustration that needs a package the machine lacks does not stop it. |
tab=LABEL |
Only that tab produces this output, for the rare example whose spellings legitimately differ. |
dput |
On an r block with #> output, print each result with dput() instead of print(). |
An r block can also hold its own output, as #> lines after the code that prints it, with a marker
such as ```r ry run runtime=ry dput. Each run of code before a #> group shows its last
value, and the comparison is always exact. A page’s #> blocks share one R process, each in its
own local() environment, because a runtime start costs seconds and the standard library reference
has over a hundred of them.
An excerpt matches when every non-blank line of it equals a line of the real output, both trimmed of surrounding whitespace, in the page’s order. Lines the page leaves out, such as the summary, a source snippet, or other findings, are free. A message the page wraps by hand matches one output line when its lines are joined with single spaces. This rule lets a page show the part of the output that matters while still pinning every word it does show. An exact block matches when the whole output agrees, ignoring trailing whitespace and the project’s temporary directory. Both compare standard output and standard error as one stream, interleaved as a terminal shows them, with color off.
just docs-test [PAGE] runs the pages whose path contains PAGE, and just docs-bless [PAGE]
rewrites the output of exact and #> blocks from the real tool. An excerpt is curated by hand, so
bless leaves it alone: the failure prints the full output to choose the lines from. A run block
is skipped with a note when GNU R is missing, or for runtime=ry, when libR.so is not built
beside ry. The landing page’s samples in docs/src/pages/index.astro are checked as excerpts with
typing on, without markers.
Package loading
Section titled “Package loading”crates/ry/tests/test_load.rs runs ry load and plain Rscript against a real GNU R and checks
the loaded result: Collate order, imports, S3 and S4 registration, Depends, sysdata.rda,
source references, reloads that rebind, rerun and remove, undoing deleted S4 definitions, the error
locations, and the ry load --r-entry entry point. test_repl_e2e.rs checks that ry R loads
the package it starts in and reloads an edit before the next command. Both skip without R.
Native packages
Section titled “Native packages”The libr tests load libR.so the way an R host does, build packages from source with the system C
and C++ compilers, load them with dyn.load (which runs R_init_<pkg>), and call their routines
through .Call, all through the exported C API. The shared harness is crates/libr/tests/harness.rs.
The packages are brio from the benchmark closure, vendored in tests/packages/brio, and
tests/packages/rytest, written for these tests.
test_native.rsbuilds both against ry’s headers and checks the results, collection stress, the handle scope,PROTECTimbalance, errors unwinding through C and C++ frames,R_allocrelease, finalizers, coercion, and the random number generator against GNU R’s output.test_interrupts.rschecks that an interrupt raised while interrupts are suspended, from R or from C, stays pending until the next interrupt check. It loads base R, which changes how a C callable resolves for every later test in the session, so it runs in a process of its own.test_gnu_r_binaries.rsbuilds both with GNU R’sR CMD SHLIB, against GNU R’s headers and linked to GNU R’slibR.so, and runs them under ry’s. It runs in a process of its own, so the two builds of one package never share a session.test_headers.rscompiles every header as C11 and C++17 with warnings as errors, checks with the compiler that every declared name is exported, runs BLAS and LAPACK calls through the headers, and compares the graphics structs’ layout with GNU R’s. It also requires every name intests/headers/r_api_names.txt(GNU R’s public header names that itsR.dllexports, plus the internals packages link) to be declared by ry’s headers and exported bylibR. WithRY_GNU_R_INCLUDEandRY_GNU_R_EXPORTSpointing at a GNU Rinclude/and a list of itsR.dllexports,api_names_cover_gnu_rchecks the list against that release, andRY_UPDATE_API_NAMES=1regenerates it.test_altrep.rsandtest_altrep_api.rsdrive thealtreptestpackage’s classes of every kind and base R’s own classes, against a report recorded from GNU R.test_serialize.rsandtest_serialize_native.rsread and write GNU R’s recorded streams byte for byte in every format and version, read installed lazy-load databases, and fuzz the reader.test_connections.rsruns one connection scenario list in GNU R and in ry and compares the transcripts;test_connections_gnu_binary.rsrepeats it with theryconnpackage built by GNU R.test_install.rsassembles ry’s R home and installs packages withR CMD INSTALLsemantics. WithRY_SOURCE_PACKAGESset to a directory of source tarballs, it builds each one against ry’s headers and reports the outcome.test_corpus_binaries.rsloads every compiled package of the installed corpus (RY_CORPUS_LIBRARY, default.corpus/library) under ry, each in a child process with its dependencies, and writestarget/tmp/corpus-binaries.tsv.test_corpus_calls.rsreplays native routine calls thattests/data/corpus_calls/record.Rrecorded under GNU R, reading arguments and results with ry’s unserializer, and requires every routine whose library loads to return what it returned under GNU R.
The two corpus suites pass without a corpus and report that they skipped.
These suites need a C and C++ toolchain, and GNU R for the second suite and the generator check.
The parts that need GNU R find it through the R on PATH, taking its headers from R CMD config --cppflags and its Rscript from PATH, so Debian’s split layout and a source build both work. When no R is on PATH, or RY_SKIP_GNU_R=1 is set, those parts print why and skip.
The language server
Section titled “The language server”crates/ry/tests/test_lsp.rs spawns the real ry server process and drives it over stdio with an
async-lsp test client. It covers:
- capability negotiation: position encodings, pull diagnostics, refresh, label offsets, and snippets;
- document sync, both incremental and full-text, including untitled and non-
file:buffers and re-reading from disk on close; - the two-wave push contract, its settled-superset invariant, and how a burst of edits settles under latest-edit-wins cancellation;
- pull diagnostics: result ids, unchanged reports, retryable cancellation, and push suppression;
- diagnostic refresh on save and on a configuration change;
- configuration: live reload, a configuration in an ancestor directory, a broken configuration keeping the previous one in force, and the diagnostic on the configuration file itself;
- every feature endpoint, including correct UTF-16 and UTF-8 ranges over BMP and non-BMP text, and safety for out-of-bounds positions;
- semantic tokens for a
#:body, and serving.RtypesandNAMESPACEbuffers.
The cancelled-pull test is made deterministic by a fault-injection seam. When the
RY_TEST_DELAY_PULL_MS environment variable is set, the server announces each diagnostics pull by
creating the RY_TEST_PULL_MARKER file, then holds the pull (for at most the delay) until the
cancellation token flips. The test sends its didChange only after the marker appears, so the
cancellation provably lands while the pull is in flight, and the retryable SERVER_CANCELLED
response and the successful retry after it can be asserted without sleeping and hoping. Nothing
else sets these variables.
crates/ry/tests/test_simulation.rs runs the in-process language-server simulator: random editor
sessions under a seeded schedule, checked at random points against a server started fresh on the same
disk and buffers. crates/ry/tests/test_scenarios.rs runs the scripted user journeys in
crates/ry/tests/scenarios/. Robustness documents both, including the
scenario format and the replay variables.
Incrementality
Section titled “Incrementality”A fixture checks what an edit produces, but not how much work it took to get there.
crates/semantics/tests/test_incremental.rs checks the work: semantics::testing::ProbeDatabase
logs every query execution, and checked_since names the items whose item_check ran. The tests
pin these contracts:
- A body edit that keeps the item’s scheme re-checks the edited item alone.
- A body edit that changes the scheme also re-checks the items that read it.
- A new definition re-checks only the items that read its name.
- An inserted statement re-checks only the reads it intercepts.
- A body edit re-derives one item’s load facts and its own file’s index layer, a
NAMESPACEedit re-derives no item, and a statement that only moved keeps its load facts. - A one-shot check (
ProbeDatabase::one_shot) runs nopackage_name,test_nameorinterface_groupand oneproject_interface_groups, reports exactly what the incremental check reports, and agrees with the per-name and per-item answers under invariants (checked in a child process, because invariants are process-wide and would perturb the counting tests). The incremental database still goes through the per-name and per-item firewalls.
The same file checks that a chain of re-exports resolves to its base, that a cycle of them pins to
Unknown, and that a chain of 4,096 links resolves without overflowing the analysis thread’s stack.
In the IDE harness, point_queries_recheck_only_what_an_edit_changed holds the features that run on
every keystroke to the same rule.
Performance and memory
Section titled “Performance and memory”crates/semantics/tests/test_perf.rs asserts performance budgets over the real-file corpus. It is
ignored by default, because it needs the fetched corpus and a release build, and CI does not fetch
the corpus:
cargo test-release -p semantics --test test_perf -- --ignored --nocapturejust perf runs the same command. Its budgets are measured numbers plus headroom, and cover the
cold-pass wall time per line, resolve steps per line (the tripwire for a regression in resolve
memoization), resident bytes per line, and the p50 and p95 latency from a keystroke to fresh
diagnostics on the corpus’s largest package. It prints what it measured, so it doubles as the
corpus-wide measurement, while ry debug analysis-stats diagnoses one workspace phase by phase.
Run optimized tests with cargo test-release, an alias for cargo test --profile bench, which is
what just perf and cargo bench use. The bench profile keeps opt-level = 3 but uses thin
local LTO and 16 codegen units. Plain cargo test --release builds with the release profile’s fat
LTO: building libr’s test_linpack that way peaks at 11.1 GiB of RSS and takes 30 minutes on
four cores (it was OOM-killed once on a 16 GB machine), against 3.6 GiB and 11 minutes under
cargo test-release. Shipped binaries and every benchmark number in
the docs come from cargo build --release, so a budget measured under cargo test-release can be
slightly slower than a release binary. Debug builds keep line tables only ([profile.dev] debug = "line-tables-only"), which keeps backtraces symbolized; set CARGO_PROFILE_DEV_DEBUG=true to
inspect locals in a debugger.
crates/ry/tests/test_perf.rs holds the memory budget of a whole cold ry check --no-cache with
type errors on. It generates the three 300,000-line benchmark code bases with
tools/bench/generate.py, runs the release binary on each, and fails when one peaks above
256 MiB resident. It reads the child’s own peak with wait4, so it measures the process the user
would run, allocator included. It is ignored by default for the same reasons:
cargo test-release -p ry-lang --test test_perf -- --ignored --nocapturejust perf runs both suites.
Guidelines
Section titled “Guidelines”- Add or tighten a fixture before writing a parser-local or crate-local unit test.
- A new phase or module gets a fixture suite before it relies on ad hoc unit tests.
- Use the lightest fixture change that captures the failing shape.
- Change a fixture expectation deliberately, never as a side effect.