ry at a glance
ry is one toolchain for R: a type checker, a linter, a formatter, a language server, a package
loader and a runtime, configured by one ry.toml. Plain R stays valid, and ry adds static types,
records, tagged unions, a typed table and a standard library on top. This page is also served as
Markdown at /ry.md.
Commands
Section titled “Commands”| Command | What it does |
|---|---|
ry check [PATHS] |
Report problems; exit status 1 when there are any. --fix applies safe fixes, --unsafe-fixes the rest, --output json for tools |
ry fmt [PATHS] |
Format in place; --check lists unformatted files, --range 2-3 formats some lines |
ry run [FILE] |
Run a script on ry’s runtime, or open its console, which reloads your package before each command |
ry R [FILE] |
The same on the GNU R installed on your machine, with inline annotations erased |
ry load -e EXPR |
Load the package, evaluate EXPR (or -f FILE), exit |
ry test |
testthat tests against the loaded package; --filter, --watch |
ry document |
roxygen2 against the loaded package, byte-identical output |
ry migrate data-table |
Rewrite data.table code as DataFrame code |
ry server |
The language server, which your editor starts |
ry debug-adapter |
A Debug Adapter Protocol debugger |
Checking, formatting and the editor never run R. Only the commands that run code need GNU R installed, and ry’s runtime still reads base R from it. Install the VS Code extension or a binary (Installation).
Configuration
Section titled “Configuration”ry.toml at the project root (the first one found walking up). Every key is optional:
[check]typing = false # report type errorsstrict = false # also reject values with unknown types and untyped parametersinline-annotations = true # false reports every inline annotation, for code Rscript must runobject-systems = true # false makes each S4, R6 or Reference Class definition an errorunused = true # report values that are never readexclude = [] # gitignore-style patterns
[lint]single-quotes = "warn" # every lint takes "off", "warn" or "error"
[format]indent-width = 2A # typing: off, on or strict comment overrides the project for one file, and
# ry: allow(code) silences one finding on its line and the next. lintr’s # nolint comments and
a .lintr file’s linters field are honored. Configuration lists
every key.
What ry check finds without configuration
Section titled “What ry check finds without configuration”- Names, resolved by R’s scoping rules: a misspelled variable or function,
pkg::namethat the package does not export, an unused assignment, a top-level name defined twice, aNAMESPACEentry that does not exist. Data-masking calls (dplyr verbs, data.table’s[,with()) are understood. - Syntax errors, named precisely (
missing , between these arguments), with analysis continuing after them. - Eleven default lints, such as
x == NA,1:length(x),&in anif,TforTRUE,=for assignment, and asprintf()format with the wrong number of values. - Projects as R sees them:
R/is one package namespace, scripts follow theirsource()calls, tests see helpers, and.Rmd,.qmdand.Rnwchunks form one script.
unresolved
! I could not resolve `ratee` in this package, its imports, or builtins. Did you mean `rate`? --[R/discount.R:2:11] 1 | apply_discount <- function(price, rate) { 2 | price * ratee | ^^^^^Annotations
Section titled “Annotations”Types are inferred from use, so unannotated code is checked once typing = true. An annotation
pins a contract. Each form has a comment spelling, which keeps the file plain R for every tool, and
an inline spelling, which is shorter and is also checked at run time by ry run:
| What | #: comment |
Inline |
|---|---|---|
| A value | #: double above rate <- readRDS(f) |
rate: double <- readRDS(f) |
| A function | #: fn(price: double, rate: double) -> double |
function(price: double, rate: double) -> double |
| An optional parameter | #: fn(x: integer, [label]: character) |
function(x: integer, label: character = "a") |
| A generic function | #: <T> fn(x: T) -> T | NULL |
function<T>(x: T) -> T | NULL |
| A nominal type | #: @type Money {list{amount: double}} |
type Money = list{amount: double} |
| A structural alias | #: @alias Row {list{id: integer}} |
alias Row = list{id: integer} |
| Creating a nominal value | #: @new Money above the value |
@Money(value) |
Typing an Unknown value |
#: @if-unknown double |
amount: @if-unknown double <- df$amount |
| Overriding inference | #: @trust double |
x: @trust double <- f() |
| One parameter per line | #: @param count {integer}, #: @return {integer} |
(on the parameter list) |
When a comment and an inline annotation disagree, ry uses the inline one and warns
(annotation-mismatch). Inline annotations are on by default; with
inline-annotations = false, ry warns at each one.
| Type | Meaning |
|---|---|
integer, double, character, logical, complex |
Exactly one element; an if condition needs one |
integer[], double[named] |
A vector of any length, optionally with names |
list{integer, character}, list{name: character} |
A list with a fixed shape, positional or named; $ on a missing field is an error |
list[integer], list[named: integer] |
A list of any length, or looked up by key ([[key]] gives integer | NULL) |
character | NULL |
A union; is.null() and the other is.* tests narrow it |
fn(x: double, ...: character) -> double |
A function type; argument names are part of it |
<T: numeric> fn(x: T) -> T |
A generic function, bounded to integer or double |
Record{x: double}, Tuple{integer, character} |
ry’s records and tuples |
:Ok(integer) | :Err(character) | .. |
Tags; a trailing .. leaves the union open |
Array<double> |
ry’s strict vector: one item type, no NA, no names, no recycling |
DataFrame{region: character[], units: integer[]} |
ry’s typed table |
Result<T, E>, Dict<K, V>, Set<T> |
Standard library types |
Any |
A deliberate opt-out; compatible with everything, and strict mode accepts it |
Unknown |
What ry could not infer (data frame columns, S4 and R6 objects); compatible with everything, and strict mode reports it |
A double accepts an integer or logical, but a number never passes for a character. Each of
your functions has one signature, and there is no inheritance, so inference stays fast enough for
every keystroke. Type checking and the
type system reference have the rules.
Records, tags and match
Section titled “Records, tags and match”ry’s own data types have one spelling and run only on ry’s runtime:
point <- (x = 1, y = 2) # Record{x: double, y: double}; point$x reads a fieldpair <- (1L, "a") # Tuple{integer, character}one <- (x = 1,) # a one-field record needs the trailing commashape <- :Rect(width = 2, height = 3) # a tag with a payloadxs <- [1.5, 2, 3] # Array<double>
alias Shape = | :Circle(radius: double) | :Rect(width: double, height: double)
area <- function(s: Shape) -> double { match (s) { :Circle(radius = r) => pi * r^2 :Rect(width = w, height = h) => w * h }}area(shape)[1] 6A match that misses a case is a non-exhaustive-match error, and an arm the earlier ones already
cover is an unreachable-pattern warning. Patterns are _, a name, a literal, a tag with payload
patterns, (p, q), (x = p, ..), p | q, and a guard p if (cond). Records have value
semantics, and passing one to an R function is an error until you convert it with as.list().
Records and tags and Arrays have the
details.
Standard library
Section titled “Standard library”Seven modules modeled on Roc’s builtins, called by plain qualified names: Result, List, Str,
Num, Bool, Dict and Set. Every function is typed, never meets NA, and reports failure as
a tag: List.first(xs) is :Ok(x) or :Err(:ListWasEmpty), and Str.to_integer("x") is
:Err(:InvalidNumStr). A selection: List.map, List.walk, List.map_try, List.sort_with,
Str.split_on, Str.trim, Num.div_checked, Result.map, Result.with_default, Dict.update,
Dict.from_list. Dict and Set have value semantics and keep insertion order.
Standard library lists every function.
DataFrame
Section titled “DataFrame”ry’s built-in table, queried with Polars’ verbs and run on the Polars engine. The checker follows
every column through every step, so a misspelled column or an unaggregated column in agg is an
error before the query runs:
sales <- DataFrame(region = ["eu", "us", "eu"], units = [3L, 5L, 2L], price = [1.5, 2, 3])sales |> DataFrame.with_columns(revenue = col("units") * col("price")) |> DataFrame.group_by("region") |> GroupBy.agg(total = col("revenue") |> Expr.sum())Verbs: select, with_columns, filter, group_by and agg, sort, join, join_asof,
head, and lazy queries through LazyFrame. Existing data.table code runs unchanged, most of its
queries on the same engine, and data.table’s own test suite passes under ry.
DataFrames has the API.
Strict mode
Section titled “Strict mode”strict = true makes every Unknown value and every unconstrained parameter an error, so a clean
run means every value has a known type. ry run, ry R, ry load and the console then refuse code
with a strict-mode error. Combined with object-systems = false, it also rejects new S4, R6 and
Reference Classes, which otherwise get an object-system warning recommending records and tags.
Strict mode lists what it reports.
Running and loading packages
Section titled “Running and loading packages”ry run inside a package reloads exactly the definitions you changed before each command, with no
load_all() and no restart. In RStudio, source(pipe("ry load --r-entry")) in ~/.Rprofile gives
you a fast load_all(). On a 326,000-line package, a cold load takes 1.3 to 1.7 s against
devtools::load_all()’s 9.6 s, and a reload after an edit 79 to 83 ms against 8.6 s.
Running and reloading has the details.
Editor
Section titled “Editor”Diagnostics as you type, hover with inferred types, completion ranked by the expected type,
go-to-definition, references, rename, call hierarchy, signature help, inlay hints, quick fixes,
refactorings (extract variable or function, inline, convert between #: and inline types), an
outline, folding, semantic highlighting, formatting, and code lenses to run and debug tests. The VS
Code extension adds a debugger, a console with live values, a data viewer and a plot panel. Any
LSP editor can run ry server. Editors lists everything.
Performance
Section titled “Performance”On ry’s runtime, untyped and typed in strict mode, against GNU R with its bytecode compiler. Peak memory is the peak resident set size of the whole process, R’s startup included:
| program | time | ry vs GNU R | peak memory | ||||
|---|---|---|---|---|---|---|---|
| GNU R, untyped | ry, untyped | ry, typed and strict | GNU R, untyped | ry, untyped | ry, typed and strict | ||
array_dot | 0.278 s | 0.043 s | 0.047 s | 6.5x | 81 MiB | 103 MiB | 124 MiB |
array_filter | 0.246 s | 0.043 s | 0.038 s | 5.7x | 81 MiB | 102 MiB | 123 MiB |
array_map | 0.385 s | 0.054 s | 0.067 s | 7.1x | 94 MiB | 123 MiB | 141 MiB |
array_sum | 0.175 s | 0.033 s | 0.033 s | 5.3x | 81 MiB | 102 MiB | 123 MiB |
closures | 1.100 s | 0.570 s | 0.607 s | 1.9x | 99 MiB | 145 MiB | 148 MiB |
drawdown | 0.335 s | 0.074 s | 0.079 s | 4.5x | 73 MiB | 133 MiB | 141 MiB |
fib | 1.312 s | 0.010 s | 0.010 s | 131.2x | 54 MiB | 43 MiB | 43 MiB |
log_rank | 0.128 s | 0.137 s | 0.136 s | 0.9x | 107 MiB | 56 MiB | 58 MiB |
loop_sum | 0.312 s | 0.069 s | 0.068 s | 4.5x | 50 MiB | 82 MiB | 106 MiB |
vasicek | 0.055 s | 0.040 s | 0.040 s | 1.4x | 106 MiB | 64 MiB | 70 MiB |
vector_arith | 0.290 s | 0.242 s | 0.249 s | 1.2x | 109 MiB | 157 MiB | 164 MiB |
DataFrames answer db-benchmark’s group-by and join questions at ten million rows in about a quarter of data.table’s time. Benchmarks has the programs and the method.
Status
Section titled “Status”ry is in beta. The checker, language server, formatter and loader are ready for daily use, and
diagnostic codes, ry.toml keys and the JSON output are stable. ry’s own runtime is still
incomplete: a script that needs something it lacks stops with an error that names it, and ry R
runs plain and typed R on GNU R meanwhile. Records, tags, Arrays, DataFrame and the standard
library run only on ry’s runtime.