Architecture
The crate boundaries, the analysis database, and the language server’s scheduling described here are the contract for the implementation. What the type checker means is specified separately, by the typing reference.
Crate graph
Section titled “Crate graph”Layering is enforced by the crate graph. A crate boundary is the compiler-checked way of making an illegal dependency unrepresentable.
-
syntaxis a hand-written lexer and a recursive-descent parser with Pratt parsing for expressions, emitting a flat syntax tree (see The syntax tree). Three properties matter downstream:- The trees are lossless: every byte, trivia included, reprints exactly.
- Parsing is error-resilient: every parse yields a tree, and an error stays local to the place where the code broke.
- Items are position-independent: an item’s syntax is a window onto the file tree with offsets counted from the item’s start, so an untouched statement’s tree stays equal when something elsewhere in the file changes.
A
#:annotation is lexed as structured trivia and parsed into first-class nodes with real spans, so type syntax is grammar rather than comment text scraped after the fact. Inline annotations run the same type grammar on R’s token stream, inTYPE_ASCRIPTION,RETURN_TYPE, and a function’sTYPE_BINDER_LISTnodes, anderase_inline_annotationsgives the plain R program an annotated one means. The crate depends on nothing semantic. -
semanticsholds the salsa database and every analysis query: the per-item item tree and HIR, naming, interned types, Hindley-Milner inference, the stub corpus, the lints, and the point where diagnostics leave the analysis. It depends onsyntaxand salsa. -
ideimplements the editor features (hover, navigation, rename, call hierarchy, completion, inlay hints, signature help, code actions and fixes, symbols and the outline, folding, selection ranges, and semantic tokens) as pure reads ofsemanticsqueries at byte offsets. Anything to do with the editor protocol lives in the server, never here. Reading an installed package’s R code is I/O, so the server passes it in as a callback (completion_with,signature_help_with) rather than the analysis reading the R library itself. Completion ranks names by how often the project uses them, summed over tracked counts per chunk of 32 files, so a keystroke recounts one chunk instead of the whole project. -
formatis the formatter. It depends onsyntaxalone, and because the compiler enforces that, it can never come to depend on an analysis result. -
loaderis the package-development loader. It reads a package into the analysis, diffs the load index against what a session has loaded, and produces a backend-neutral plan. Its GNU R backend hands the plan to R code as an RDS file, and its backend for ry’s own runtime (behind theruntimefeature, which linksheapandinterp) builds the plan on the heap and binds each lazy definition as a promise over the analysis’s own syntax tree. -
oracleis the differential test harness for the R runtime: it runs one R file under GNU R and under another engine and compares the observable results. It also generates the programs for differential fuzzing and runs the corpus packages’ own tests and installs. It depends only onsyntax. -
replis the R console behindry runandry R. It loads R at run time, so no crate,replincluded, needs R to build. Forry runit loads ry’slibR.soinstead of GNU R’s, andryitself does not linkinterporlibr. -
heapis the R runtime’s object model and garbage collector: theSEXPobject layout, allocation in bitmap-managed pages, the non-moving generational collector and its roots,CHARSXPinterning, symbols, environment frames and theR_allocarena. It depends on nothing else in the workspace. -
libris R’s C API, built as the shared librarylibR.sothat compiled packages link against, with the C half of the native boundary and an R 4.6-compatible header set. It depends onheap,interp,jit,tableandloader. Its design is the draft atdevelopment/design/r-runtime.md. Evaluation belongs to the interpreter, which plugs into it through theEvaluatorhook ineval.rs. -
interpis the R interpreter: the evaluator, the tier-0 bytecode, conditions, S3 and S4 dispatch, and base R’s primitives and internals in Rust, plus the check of inline annotations at function boundaries. It depends onheap,syntaxandtable, andlibrinstalls it through theEvaluatorhook, so it knows nothing about C. The values that are ry’s own live here too: records and tags,Array,DataFrameand the standard library. -
jitis the runtime’s optimizing tier: it compiles hot tier-0 bytecode to machine code with Cranelift. Tier 0 calls it through theTierhooks ininterp’sbytecode.rswhen a body has been entered often or a loop has jumped back often, and the compiled code runs on tier 0’s own register window, so a failed speculation hands the activation back to the interpreter mid-body. It depends onheapandinterp, andlibrinstalls it after the interpreter.RY_JITpicks the mode:on(the default),off, orforce, which the tier tests and thery-jitoracle engine use. -
tableis ry’s built-in typed table: an engine over Polars with a query IR that lowerstbl[i, j, by]and theDataFrameverbs to a lazy plan, plus the readers, writers and data.table helpers around it.interpowns theDataFramevalue and calls the engine, andlibrexposes a column to native code as an ALTREP vector, so the table holds only Arrow data. -
sniffreads the layout of a delimited text file: separator, header and each column’s R type. The table engine’s reader and the checker both use it, so the checker types a literalfread("prices.csv")as exactly the columns the engine reads. It depends onsyntaxalone. -
rcmdis ry’s R home and theR CMDcommands that build and install packages against ry’s headers andlibR. It depends on nothing else in the workspace;libr’s tests use it. -
ryis the product: the LSP server and the CLI, with thecheck,fmt,server,run,R,load, anddebugcommands. It owns configuration, conversion between position encodings, assembling and publishing diagnostics, and suppression comments.
The semantics database
Section titled “The semantics database”Analysis runs on salsa and is incremental at the granularity of an item: one top-level statement of a file, such as a function definition, a value binding, or a bare call.
Inputs
Section titled “Inputs”The analysis of a project has five inputs:
SourceFilecarries one file’s text and document kind.ProjectFilesis a singleton listing the project’s files: package documents first, in theDESCRIPTIONCollateorder when one is declared and by path otherwise, then the rest by path. The last-writer-wins symbol index and the CLI both rely on that order.PackageMetadatais a singleton carrying the imports fromNAMESPACEand the dependency universe fromDESCRIPTION.StubSourcesis a set-once singleton carrying the.Rtypescorpus.AnalysisSettingsis a singleton carrying the configuration that changes what analysis means rather than which findings a host publishes:[check] inline-annotationsand[check] strict. The first is read only where an inline annotation exists, so toggling it re-checks only the items that have one. The second is the project default behindsemantics::file_is_strict, which decides a file’s mode for the check (its# typing:directive wins). The package loader keeps its ownload_index::file_is_strict, which reads the loaded package’s default instead. A check records the origins strict mode reports only for an item in a strict file, so a project that is not strict never builds that table.
The package loader adds a sixth, PackageSources, described below.
The syntax tree
Section titled “The syntax tree”A file’s tree is one syntax::Tree: its text and four parallel arrays over its elements (nodes and
tokens alike) in preorder, holding each element’s kind, start offset, the index one past its
subtree, and its parent’s index. Kinds are numbered tokens first, so a comparison tells a node from
a token, and an element’s end is where the next element after its subtree starts. A subtree is
therefore a contiguous index range and a node’s text a contiguous slice of the file text, so a
whole-tree walk is a linear scan and reading a node’s text never allocates.
An item’s tree is a window onto its file’s tree, not a copy. A Tree holds the file’s arrays
behind an Arc plus the window’s first index, length and base offset, and every accessor
subtracts those, so an item tree reports indices from zero and offsets relative to the item.
Equality compares kinds, subtree ends and those relative offsets, which is why moving an item
within its file does not invalidate what was computed from it. Copying item ranges instead would
duplicate every file’s arrays, about 114 MiB on the finance bench.
Cursors are indices. An owned SyntaxNode or SyntaxToken is the tree’s Arc plus a u32, so
moving one is arithmetic and a clone is one reference-count increment. A borrowed NodeRef is the
tree reference plus the index and costs nothing to copy, which is what the whole-file passes and
HIR lowering use. Lookups that a nested tree answers child by child use the layout instead: starts
never decrease in preorder, so node_at, token_at_offset and child_or_token_at_range binary
search the starts, and a previous sibling is found by climbing parents from the element before.
The parser emits elements in postorder, because it only knows that a node wraps the elements after
a checkpoint when it opens the node there. TreeBuilder::finish lays them out in preorder in one
backward pass: an element’s preorder index is its parent’s plus one plus the size of its earlier
siblings’ subtrees, which postorder gives as a difference of first-element indices. The parse
invariants (syntax::testing::check_parse_invariants) check every shortcut against the
child-by-child definition on every parsed program, and with enable_invariants() every splice
reparse is compared with a parse from scratch.
Parsing, and why it is not where incrementality happens
Section titled “Parsing, and why it is not where incrementality happens”parse(file) is a per-file query with a splice cache beside it. The cache remembers the file’s
previous text and tree (shared across clones of the storage handle), works out the current edit as
the longest common prefix and suffix, and lets syntax::reparse reparse only the statements the
edit touched and copying the untouched top-level runs of the old tree around them.
The spliced tree is byte-identical and error-identical to a parse from scratch. The edit-stream fuzzer pins that equivalence, and the splice refuses and falls back to a full parse whenever the equivalence is not provable, so the cache is invisible to every query downstream. Invalidation below the level of a whole file stays out of salsa on purpose.
The real incrementality happens one level down, in the item tree. An item’s identity interns its kind, its name, its parent, and a disambiguator, which makes it stable when other items are inserted around it. An edit inside one item therefore leaves the derived values of its siblings equal, and salsa’s early cutoff prunes all the work downstream of them.
Syntax trees stay resident in the server, and ry check holds one at a time
Section titled “Syntax trees stay resident in the server, and ry check holds one at a time”The server keeps every syntax tree, because an edit reparses one file and every other tree stays
valid. ry check reads each tree in its first pass only, so it does not memoize them. Each worker of
the syntax pass wraps a file’s work in semantics::with_held_tree, which parses the file once and
parks the tree in a thread-local slot that parse serves for the rest of that file’s work; the
tree is freed when the worker moves on. In a one-shot database item_syntax and
item_annotation_syntax skip their per-item memos as well, because a memo would keep its file’s
tree alive past the syntax pass and buys nothing in a run that never edits. A tree read outside a held
tree still works: it goes through the parse_memo query, which then keeps that file’s tree for the
rest of the run. A full cold check never does this, but a partial cached run, the cache planner’s
surface comparison and --annotate read some trees that way. Annotation memos (file_item_annotations) store
detached copies of their subtrees (syntax::SyntaxNode::detached_subtree), so on any host an
annotation never keeps the whole file’s tree alive.
Every query that reads a tree is a memoized query whose result is small: the item HIR and
annotations, parse_stage_diagnostics, file_error_ranges, the annotation references and
agreement candidates in diagnostics.rs, item_name_range, item_syntax_flags (whether
an item binds only its own name, and whether it is declared pure), and lints::file_syntax_lints. So a check never walks another file’s tree, and revalidating an
untouched file after an edit elsewhere never walks its own. A finding that also needs naming or
the project’s declarations is split in two: the syntax-only half is memoized, and a cheap step
joins it with the rest (a bare setClass call is reported only when the file does not bind
setClass itself, and two annotations disagree only when their types differ once aliases
expand).
The CLI’s cold check runs three passes over the files, largest first, spread over the cores.
The first computes every tree-derived fact (diagnostics::derive_from_syntax), including each
file’s share of the project-wide maps (warm_project_folds), so that no later check derives
another file’s facts on its own thread. The second names every item, so that two threads
checking files that reach the same item do not block on one claim, and the third assembles the
diagnostics. RootDatabase::one_shot turns off the splice cache, which would otherwise keep the
previous tree of every file. test_incremental.rs pins that a cold check parses each file once
and that a one-shot cold check memoizes no tree.
Most tree readers look for a handful of node kinds in a large file, so they walk borrowed cursors
(syntax::NodeRef), which touch no reference count: syntax::matching_nodes takes a predicate
over a node and its parent and makes owned nodes only for the matches. The per-file site collectors
share one pass over the nodes, file_syntax_sites, which tests each role only on the kinds that
can carry it. The syntax lints scan the kind column for the element kinds a lint or an annotation region
can start at (NodeRef::elements_of_kind),
track the enclosing annotation region by position (NodeRef::encloses), and make an owned node
(syntax::node_at) only where a cheap test says a lint may fire. A borrowed TokenRef reads its
kind, range and text only when asked, because a child walk mostly checks kinds.
The cold check is CPU-bound and scales about 3 times on 4 cores, so per-file constant costs set its wall time. Several of them are worth knowing:
- Every salsa read of a tracked function with several arguments interns its key and verifies a
memo. So in the server one item check resolves all its frame binders in one query
(
item_frame_binders), and every item check caches the package names and schemes it looks up inSalsaGlobals.package_nameandtest_nametake only the name and readProjectFilesthemselves. TyandNameare not salsa structs. ATyis a pointer to a hash-consed entry in a global, 64-way sharded table (crates/semantics/src/types.rs), so equality and hashing are a pointer compare and reading the kind is a field load. A direct-mapped per-thread cache sits in front of the shards, because checkers intern the same few types over and over and those land on the same shard mutexes. The entry stores the type’s size, which the inference-size cap reads without a walk. ANameis aSymbol. Neither is ever freed: a one-shot check loses nothing, and the server’s table grows only with distinct types, which repeat heavily because types are structural. A query keyed by one name takes aQueryName, the one salsa-interned wrapper left.Symbol::newchecks a thread-local table before the shared one.- The checker matches call arguments in a
bumpaloarena that lives for one item check, and it borrows the item’s checks and the stub schemes instead of cloning them. - A
NAMESPACEover 64 KiB parses in chunks on all cores (metadata::parse_namespace). Chunks split only before a line that opens a call and follows a line that is exactly one complete top-level call, so no directive straddles a boundary. - The CLI creates every input at high durability except the attached namespaces, which it sets after the first pass. Memos that never read that set skip verification in the new revision.
- Every item keeps its HIR, naming and check memos for the whole run, so each 8-byte field on
those values costs about half a megabyte across a 300K-line bench.
ItemNaming::extrasandItemCheck::editorkeep the rarely populated and the editor-only fields behind one optional box, and a one-shot check skips the per-item binder memo (item_frame_binders) and reads the file’s binders, a sorted array searched by name, directly. - mimalloc’s
reallockeeps the old block when the new size is at least half of it, so aVecshrunk withinto_boxed_sliceorshrink_to_fitcan still hold twice its length. A buffer that stays memoized is allocated at its exact size up front instead. - mimalloc commits its arenas lazily (
crates/ry/src/main.rs): eager commit made the kernel zero huge pages that a short-lived process never touched. What allocation still costs is mostly setting up fresh pages, which scales with the memory the analysis keeps, and mimalloc’s purge and huge-page options measured no difference.
The check cache
Section titled “The check cache”The cold check already uses about 3.4 of 4 cores in every pass, so more parallelism cannot bring
it much lower. What a repeat run can skip is work whose result is already known, so ry check
records each file’s findings in .ry_cache/check.json (crates/ry/src/check_cache.rs). The salsa
memos themselves are not persisted. Salsa 0.28 exposes no dependency graph to save, and its
persistence feature would need every memo to serialize, including the process-wide Ty table.
The record has a key fingerprint and, for each file, four facts:
- Key: the ry version and build, the configuration, the project root, the data files,
NAMESPACE,DESCRIPTION, and the project’s stubs. A different key checks every file. - Content fingerprint: the file’s text.
- Surface fingerprint: two parts. The first is the text with the body of each top-level
function definition left out, unless the body contains
<<-,->>, or a call that declares or attaches something (assign,library,setClassand the rest ofDECLARING_CALLS). The second is the file’s share of every project-wide fold, listed bydiagnostics::project_fold_shares: its definitions, conditional writers, type declarations,globalVariablesnames, S3 generics and attached packages. A body can feed a fold, as aUseMethodcall or a#: @typeinside it does, so the shares come from the queries rather than from the text. A new fold over the project’s files must add its share there. - Dependencies: the files whose items the check read through interface edges, transitively
(
semantics::file_dependency_files), plus the files that its findings point into. - Imports used: the
NAMESPACEimports the file’s code uses, which theNAMESPACEfindings depend on.
A run with no changed file replays every finding without building a tree. A run whose changed
files all keep their surface checks the changed files and the files whose dependencies include
one of them. Every surface then matches the record, so the attached set comes from the record as
well, and the NAMESPACE findings are recomputed from the imports used, which costs a few
milliseconds. Any other change checks every file.
A package-only run, such as ry check R or the strict check of ry load, analyses only the
package files. Tests and scripts read the package namespace but add nothing to it, so leaving
them unparsed changes no package finding. They stay in the record: a file the run left unanalysed
keeps its recorded content, surface, findings and imports used while its text is unchanged. When
its text did change, the record keeps no surface for it and marks every unanalysed file
unchecked, so the next run that analyses them checks them all. The NAMESPACE findings read only
the imports the analysed files use, so a package-only run and a full run can disagree on them
(an import only the tests use is unused to the first and used to the second). A replay therefore
recomputes them when the package files’ imports differ from all files’ imports. On the benches, a file’s dependency closure has a median size of 0 and a maximum of 1, so
an edit inside a body rechecks one or two files.
A partial run still parses every file, because the project-wide maps fold every file’s items. So
it warms those folds across all cores, and that parse, about 0.3 s of the 0.45 s total, is its
floor. Removing that floor would mean persisting the item trees. When invariants are on
(syntax::testing::enable_invariants), every run that reuses findings is compared with a fresh
run and panics on any difference. The seeded edit simulation in cli.rs
(cached_checks_match_fresh_checks_across_seeded_edits) drives that comparison.
From item to scheme
Section titled “From item to scheme”An item’s subtree lowers to HIR, with spans relative to the item. Its annotations lower
separately: a #: block and an inline annotation go through the same type lowering into the same
Annotation, and a statement annotated both ways gets one merged annotation
(annotations::merge_annotations), so the checker never knows which spelling it was given. Names
in HIR and naming are interned Symbols (a 4-byte Copy id). Literal text is not interned,
because the interner never frees and an edit inside a string literal would otherwise keep every
spelling it passed through; it lives in the module’s own text buffer, addressed by a Copy
Text span. The HIR module is
struct-of-arrays: an ExprId indexes separate kind and range arrays, and every list (arguments,
parameters, statements, record fields, match arms, pattern lists) lives in one side array per
element type that the expression addresses with a Span, so every HIR kind is Copy and a scan
over kinds never touches ranges. A name written at a site with its own range (an argument name, a
field after $ or @, a loop variable, a tag) is a NameId into a NameSite side array that
holds the symbol and its range together, and every id is a NonZeroU32, so an optional child costs
no extra word. An expression kind is 16 bytes and an argument 8. Lowering reads the tree through
borrowed syntax::NodeRef cursors into a per-thread scratch (hir::Parts) that keeps its capacity
across items. Sealing copies every array and the text into one allocation of exactly the needed
size, a word header of section starts followed by the sections, so the memoized Module costs one
allocation and no spare capacity. When every range in the item ends below 64 KiB, as almost all
do, a range packs into one word. Naming
then runs the variable model, in which a variable is a mutable slot with reaching-write flow.
Finally, the inference walk, item_check, produces the expression types, the type errors, the
strict-mode origins, and the scheme the item exports.
An inference variable must never leave the item that created it. A variable is an index into one
item’s inference table, so nothing that outlives that table may hold one. An exported scheme is
closed at the item boundary by close_scheme, and a value cached across a rollback of the table has
its variables erased first. A leaked id is not merely a crash risk: in a reader’s table it either
points past the end, which panics, or it silently names some unrelated variable of the reader’s own,
which is worse.
To keep that from happening by accident, every transformation that rebuilds a type (resolution,
substitution, generalization, erasure) goes through one structural map, map_child_types. That way
no walk can handle a member, such as a parameter’s default, that another walk forgets. Reads go
through the matching for_each_child_type and Parameter::types.
Cyclic definitions
Section titled “Cyclic definitions”When definitions refer to each other in a cycle, the package interface resolves the whole group through one canonical fixpoint, so the result never depends on which member happened to be queried first:
interface_groupfinds the cyclic group an item belongs to.item_interface_edgesgives each item, definition or top-level statement alike, the items its reads resolve to, following exactly the resolution a check performs: the frame binder in the item’s own file, then the test and package layers, and every conditional writer of a name that has no unconditional winner. An iterative Tarjan walk from the item visits only what it reaches, which is enough, because a cycle through the item lies inside that reachable set. Members come back in canonical project order, and the first one keys the group.scc_exportsstarts every member atUnknownand runs Jacobi rounds until the table of exports (each member’s scheme and the schemes of the top-level names it binds) stops changing. If the round cap is reached, every member is pinned.item_checkadopts the canonical exports as the one exported truth.
A member check inside the fixpoint never re-enters item_check, so no salsa cycle forms. Salsa’s own
cycle recovery remains only as a backstop, for a reference the static graph cannot see.
The graph must cover every edge a check can follow, not just the common ones. Salsa never backdates the memo of a query that took part in a cycle, so when an edit breaks a cycle the former members re-run with no cycle heads and salsa’s backdating check panics on any member whose value did not change. A missing edge turns into exactly that panic on the edit that removes and restores it.
That backstop has a trap of its own: a cycle recovery must not pin a value that depends on the
round that produced it. Salsa stops iterating when a recovery returns the same value as last time,
so the value pinned at the round cap has to come back unchanged. A pin computed from the freshly
recomputed value does not: the parts it leaves alone keep moving with the cycle, every round
differs, and salsa runs into its own MAX_ITERATIONS and panics. Pin a constant, or re-pin what was
already returned, and when the pinned type is a composite, cut every surface it exports, not just
the obvious one.
Keeping project-wide work cheap
Section titled “Keeping project-wide work cheap”A check never names the whole project. The interface graph is walked per item, from the items a
check reaches, and item_interface_reads, the set of items and names an item’s reads resolve to, is
a tiny tracked query whose value survives an edit that only shifts ranges.
The one map that does cover every package item is the set of conditional writers, the items that
write a top-level name other than their own, which a read with no unconditional winner joins.
file_conditional_writers builds a file’s share by screening each item’s syntax first: only an
assignment operator, for, a destructuring %<-%, or a by-reference setnames outside a nested
function can write a top-level name, so an item with no such token besides its own binding needs
no naming pass. Only the rest go through item_top_level_names. The screen may pass too many items
but never too few, and the fuzz battery checks it against naming on every generated program.
The project-wide maps reach per-file data through one tracked memo per file (file_definitions,
file_conditional_writers, file_type_definitions and the others warm_project_folds lists),
never through per-item queries. After a keystroke salsa must
confirm that a project-wide map is still valid, and it does so by checking the map’s dependencies.
Read per file, that is about 500 memos on a 300,000-line project, and every one outside the edited
file passes on durability alone (see “The language server”). Read per item, it was about 56,000
memos, which cost every editor request about 35 ms right after a keystroke.
A check never reads a project-wide map either. Each lookup it makes across items goes through a
firewall keyed by one name or one item: package_name gives a name’s winner and its conditional
writers, interface_group gives an item’s cyclic group, and frame_binder gives the file-local
binder that an immediate or script read sees. The interface edges an item contributes to the group
walk (item_interface_edges) go through package_name too. A structural edit re-runs each firewall
that a check read, and an unchanged answer is backdated, so a new definition re-checks only the
items that read its name. crates/semantics/tests/test_incremental.rs pins these contracts.
ry check --no-cache never edits, so it has no use for those firewalls, and recording them costs a
cold check hundreds of thousands of salsa dependency edges it never replays. It runs on
RootDatabase::one_shot, whose Db::one_shot answer switches three lookups to a coarser shape.
A NameLookup reads the project-wide winner and conditional-writer maps once per reader and then
answers each name with a hash probe, instead of interning a name and going through package_name
or test_name. A frame binder comes from the file’s binders, read once per reader, instead of
the per-item item_frame_binders memo and a frame_binder memo per name it does not list. And
item_group finds every cyclic group in one walk over the project (project_interface_groups)
instead of one interface_group walk per item; ry check runs that walk on a cloned handle
beside the parallel edge pass. Every read still goes through salsa, so the answers are the same
facts; only the granularity of the recorded dependencies changes. Under
syntax::testing::enable_invariants() each one-shot lookup is compared against the per-name and
per-item answer. Every ry check run is one-shot, cached or not; only the language server keeps the firewalls. A one-shot host also answers Db::editor_facts with false, so its item checks leave out expression and pattern types, literal expectations, loop variables, selected overloads and callee targets, which only editor features and rewrites read. ry migrate reads expression types for its rewrites, so it analyses on RootDatabase::one_shot_with_editor_facts.
A name being typed is a new top-level definition on every keystroke, so the folds that a new name
changes are kept to one pass: package_definition_index folds each package file’s definitions
into the winners and, in the same loop, the names defined more than once. The duplicate check reads
those through duplicate_binding_map, a copy that is empty in a healthy project and so backdates.
Names in these maps are interned Symbols whose hash was computed once at interning, so a fold
hashes four bytes per name and never rereads the spelling.
Types are hash-consed, so type equality is pointer equality and nothing is ever deep-cloned. Deep resolution
over the interned type graph is memoized per binding epoch and cuts any cycle to Unknown; the
decision log records that design.
The package-load index
Section titled “The package-load index”ry load reads a package through its own input, PackageSources: the R/ files in collation
order plus the NAMESPACE and DESCRIPTION text, as separate fields so a NAMESPACE edit never
re-derives an item. The index has three layers, and each depends only on the one below:
file_load_indexerases one file’s inline annotations, keeping every line break so line numbers match the file on disk, and records each item’s position in that text.file_itemsthen gives each item its content hash (of its erased text), whether it is a function definition, the names it writes and reads while it runs (item_immediate_reads, which excludes reads inside the functions it defines), and the S4, R6 and S3 registrations it makes. Both read one file only.file_bindsreduces one file to the names it binds and how, andpackage_bindingsjoins those into which names are bound lazily: a name whose one writer is a function definition. A body edit leaves a file’s binds equal, so salsa backdates them and the join, which is the firewall that keeps an edit from reaching every file’s bindings.package_indexjoins the files and the bindings into what a plan diffs.
A reload depends on all of a package’s files, so salsa would revisit every file’s memos after any
edit. The loader avoids that with durability: a file starts at high durability and drops to low on
its first edit, and salsa skips verifying memos that depend only on inputs at a durability nothing
changed at. The diff then looks inside only the files whose hash changed, plus the eager code of
every file, which is all an edit can reach. Lowering a file’s durability makes its first edit verify
every memo once (about 20 ms at 326,000 lines), and later edits of that file skip the check.
test_incremental.rs pins the per-file contract.
Before the console or ry load starts R for a package, it calls the loader’s prepare, which reads
the sources and derives this whole index on its own threads while base R loads. The first
load_all() then waits for that to finish and only diffs.
Deriving the index means parsing every file, which is most of a cold start, so the first plan of a
package in a process caches the index across processes. The entry lives in the user’s cache
directory ($XDG_CACHE_HOME/ry/load-index, one file per package root), and its key hashes the
DESCRIPTION, NAMESPACE and R/ texts, the strict setting, and a fingerprint of the parser, the
analysis, the shipped stubs and the loader that crates/loader/build.rs computes. On a hit the
loader syncs the texts into the database without parsing them and hands the backend each file’s text
instead of its tree; ry’s runtime then parses only the code it lowers, one top-level item at a time,
which gives the same tree as the whole file would. Meanwhile a detached thread parses every file on
a database handle of its own, so the first reload finds the parses memoized, and an edit that reaches
the database first cancels it.
A fresh load on ry’s runtime may also hold back the package’s S4 definitions until first use. The
loader decides which (DeferredS4 in crates/loader/src/loader.rs states the rules), and the
methods package’s class and generic lookups in crates/interp/src/methods.rs force them on a miss;
package loading has the contract. When the definitions
read only S4 constructors and their own names, the loader gives the unit a cache key
(DeferredS4::cache_key), and .ry_force_s4 in runtime.R restores the bindings a previous process
saved instead of running the definitions.
Running the definitions and using the classes goes through the methods package’s native core
(crates/interp/src/methods_core.rs). The methods package keeps GNU R’s R code and its metadata
objects (class representations, the .__C__ and .__T__ tables, the method tables), which stay the
only source of truth; the core reads and writes those same objects. When the methods namespace
loads, install_fast_paths puts a native prologue from methods_core.R in front of the body of each
function it covers, but only if the function’s hash matches the GNU R version the prologue was
written against. A prologue either does the function’s whole work, with the same result and
effects, or returns without observable effect and lets GNU R’s body run, so a case the core does
not handle stays exact. When the methods package’s version is the bundled one, every prologue must
install, so a mismatch stops start-up with an error; on any other version a mismatch only leaves
GNU R’s body in place.
The covered functions are the object operations (new, the default initialize, validObject,
is, extends) and the definitions (setGeneric, setMethod, setClass).
Diagnostics
Section titled “Diagnostics”Diagnostics come in two layers over one source of truth:
parse_stage_diagnostics(file)holds the syntax errors, the typing-directive errors, the refusals of malformed#:blocks and inline annotations, and, while inline annotations are turned off, a warning at each one. All of them are pure functions of the parse andAnalysisSettings.file_diagnostics(file)adds the naming findings (unresolved and unused names) and the type errors. The strict-mode findings are rendered separately bystrict_diagnostics(file), which is empty unlessfile_is_strictholds ([check] strictor the file’s own directive).
The host, meaning the server or the CLI, then gates diagnostic classes by configuration, applies
per-file # typing: overrides, escalates unresolved names to errors under strict mode, appends the
lints, and honors # ry: allow(...) suppression comments. The server and the CLI share this
assembly in crates/ry, so they cannot disagree.
The language server
Section titled “The language server”The server runs on two threads. The async-lsp frontend, on the tokio thread, turns every request and notification into a job for a single dedicated worker thread. The worker owns the salsa database and all the documents, and the database handle deliberately never leaves it.
- The latest edit wins. A notification that changes an input first cancels the worker’s salsa
cancellation token and then enqueues its job, so a query in flight unwinds cooperatively. Every
later job starts on a fresh, cheaply cloned storage handle, because a cancellation is consumed by
whichever query it killed. A best-effort feature answers with nothing when cancelled, but an
authoritative answer never degrades: pull diagnostics return a retryable
SERVER_CANCELLEDwithretriggerRequest: true, and rename returnsCONTENT_MODIFIED. - Diagnostics are pushed in two waves. On every sync the server immediately publishes the parse-stage classes and the lints, tagged with the document version. Until the startup warm-up (below) ends, the first wave holds only the parse-stage classes, because the lints read the project-wide name maps. At idle time it publishes the settled full set, without a version. Without a check record, the settled set is a faithful superset of the first, so the second wave only ever adds findings.
- The first wave can come from the check record. At initialize the worker loads
.ry_cache/check.json, computes the record’s key exactly asry checkdoes (check_cache::key, with the server’s own binary), and runscheck_cache::planover the project’s files, reading the scripts it does not load from disk. A changed file the server did not load has no surface there, which plans a fresh run and restores nothing. Every loaded file the plan reuses gets its recorded findings, and the first wave shows them instead of the parse stage until the settled wave replaces them. The server analyses only the package files and the open documents whilery checkanalyses every file, so the two can differ in a finding that depends on a script, and the settled wave corrects it. Any input change (an edit, a watched-file event, a stub orNAMESPACEbuffer, a file opened with other text) drops every restored finding, because a change to one file can change another’s findings. Restoring the record for a 300,000-line package costs about what a replayingry checkdoes, about 0.2 s. A client that supports pull diagnostics gets no pushes at all, and instead receives result ids derived from a content hash, with unchanged reports. - Idle work has an order. At startup the worker first runs the warm-up: across all cores, it
scans every file’s
library()andrequire()calls and applies the attached set once, then derives the per-file folds behind the project-wide maps (warm_project_folds). Every semantic wave reads those maps, and one thread would parse the whole project to derive them, so settled publishes wait for it. The warm-up names no item: a file’s check names only the items its interface reaches, so a file opened at startup settles in about 200 ms on a 300,000-line package. After the warm-up the worker rescans thelibrary()calls of any file whose scan a cancellation interrupted, because the edit that cancelled it may belong to another file and nothing else would rescan this one. Then it serves any settled publish it owes, most recently edited file first. Then it re-derivesproject_global_names, the set goto-definition and references consult on every library call, whenever the warm-up or a durability drop left it stale: salsa never backdates across a durability drop, so the fold re-runs once per opened file (about 20 ms at 300,000 lines) and would otherwise land on the first request. Next it lowers the durability of documents opened since (see below). With nothing else to do, it checks the rest of the workspace in parallel batches and never publishes that work. Idle work that gets cancelled is requeued. - Parallel idle work stops between files. It runs on one cloned database handle per core, and each thread checks before every file whether a job is queued. Cancelling the threads’ salsa tokens is not enough on its own, because salsa clears a handle’s cancellation flag whenever a top-level query returns, so a flip that lands at the end of a query is lost.
- A request preempts idle work. A request is queued first and then cancels the idle unit in flight, if any, so a hover never waits for a background file to finish. A request never cancels another request or a sync.
- Only package files stay loaded while closed. At startup the server loads the files under
R/, and a closed or disk-changed file stays in the project only if it is one of them. Test, helper and script files are analyzed while they are open, so a session never holds a file a fresh start would not. - The worker is deterministic and steppable. Everything it sends the client goes through an
Outbox, which is the socket in production and a recorder in the simulator, andrun_jobis one step of its loop. Collections whose order reaches the client are ordered. - A coherence failure panics. A failed document sync is unrecoverable, and a panic that escapes a worker job terminates the process deterministically rather than going on to serve corrupted state. An IDE feature lookup, by contrast, must never panic.
- Only edited files change at low durability. A workspace file enters the database at
Durability::MEDIUM, an open document is lowered toLOW, and closing it restoresMEDIUM. The stubs, settings, package metadata and data-file inputs areMEDIUMtoo, because a memo is only as durable as its least durable input and nearly every memo reads the stubs. An edit then revalidates every memo that reads only other files by durability alone, without walking its dependencies.test_incremental.rspins that an edit never revisits the other files’ trees or items. - The project file list is
LOWfrom the start. Salsa never backdates a memo whose durability drops, so if the project-wide maps started atMEDIUM, a file’s firstLOWwrite would change them, then everypackage_name, then every item check in the project (about 2 s on a 300,000-line project). They readProjectFiles, so aLOWlist keeps themLOWthroughout, and the first edit re-derives only the edited file’s own memos.didOpenleaves text that matches the disk untouched, which would otherwise be aMEDIUMwrite that invalidates the whole project, and idle time rewrites it atLOWso that the first keystroke does not pay for that file’s share. A setter that names no durability keeps the field’s current one, so every write to a source file names it. - Positions convert only at the protocol edge, against the target document’s own text, in the negotiated encoding. That is UTF-16 by default, and UTF-8 when the client offers it. Signature-help label offsets are always UTF-16, as the LSP specification requires.
- Stub buffers and
NAMESPACEfiles are served standalone and never enter the database. An.Rtypesbuffer gets loader-problem diagnostics, semantic tokens, and goto-definition for type names, and aNAMESPACEfile gets import validation.
Correctness and performance instruments
Section titled “Correctness and performance instruments”The fixture suites are the correctness contract. crates/semantics/tests/test_incremental.rs pins
the incrementality contracts by counting query executions after an edit, and
crates/semantics/tests/test_perf.rs asserts wall-time, memory, and keystroke-latency budgets
against a corpus of real files. Testing documents all of them, and
Robustness documents the simulator, the fuzzers and the scenario tests
that check the whole toolchain against from-scratch runs and GNU R.