ry is a fast static checker for the R language, written in Rust and
inspired by astral-sh/ty. It parses R
source with tree-sitter-r,
infers types across your whole project, and reports likely bugs before
you run the code: calling a non-function, arithmetic on incompatible
types, misspelled data frame columns, unbound variables, malformed if
conditions, and more.
R will happily coerce, recycle, and partially match its way past most of these mistakes at runtime and hand you a statistically wrong answer instead of an error. ry’s job is to catch the mistake while it is still cheap.
ry is not a formatter (pair it with air) and not a
replacement for lintr’s style rules. It focuses on type- and
scope-driven diagnostics that need a whole-program view.
On Linux and macOS, install the latest release with:
curl --proto '=https' --tlsv1.2 -LsSf \
https://github.com/sims1253/ry/releases/latest/download/ry-cli-installer.sh | shOn Windows PowerShell:
powershell -ExecutionPolicy Bypass -c "irm https://github.com/sims1253/ry/releases/latest/download/ry-cli-installer.ps1 | iex"Or build from source with Rust 1.88 or newer:
git clone https://github.com/sims1253/ry
cd ry
cargo build --release
# binary at target/release/ryPer-platform archives are also attached to GitHub releases. See CHANGELOG.md for release highlights and upgrade notes.
Point ry check at files, directories, or a project root (.R and .r
files are collected recursively):
cat > demo.R <<'EOF'
nums <- 1:3
y <- "a" + 1L
if ("x") print(nums)
if (c(TRUE, FALSE)) print(1)
z <- undefined_thing
EOF
ry check demo.R
#> demo.R:2:6: error: [RY040] cannot apply arithmetic op to `character` and `integer`
#> y <- "a" + 1L
#> ^~~~~~~~
#> demo.R:3:5: warning: [RY001] `if` condition is `character` (not logical); will be silently coerced
#> if ("x") print(nums)
#> ^~~
#> demo.R:4:5: warning: [RY002] `if` condition has length 2; R requires a length-1 condition
#> if (c(TRUE, FALSE)) print(1)
#> ^~~~~~~~~~~~~~
#> demo.R:5:6: warning: [RY010] variable `undefined_thing` is not bound in this scope
#> z <- undefined_thing
#> ^~~~~~~~~~~~~~~
#> ry: checked 1 file(s), 1 error(s), 3 warning(s)Diagnostics use the full format by default – the offending line with
the span underlined – and messages carry the context you need to act,
e.g. a column miss lists what IS there:
ry check /tmp/ry-readme/analysis.R
#> /tmp/ry-readme/analysis.R:2:11: error: [RY060] column `dispp` not found in data frame schema; available columns: mpg, disp
#> m <- mean(d$dispp)
#> ^~~~~~~
#> ry: checked 1 file(s), 1 error(s), 0 warning(s)Exit codes are CI-friendly: non-zero when any error-level diagnostic
fires (--exit-zero overrides; --error-on-warning promotes warnings).
Human-readable diagnostics use ANSI color on terminals; select the policy
explicitly with --color auto|always|never. Automatic color respects
NO_COLOR, and machine-readable formats never contain ANSI escapes.
ry tracks library() / require() calls and resolves functions against
per-package type stubs, so the same name means the right thing in
context: filter() is stats::filter until dplyr is loaded, and
dplyr::filter(df, x > 0) resolves the column x against df’s schema
either way. For packages being checked, importFrom(pkg, name) directives
in NAMESPACE preserve exact binding provenance, falling back to opaque when
ry has no stub for the dependency. Whole-package imports and library() / require() calls also
use installed packages' static NAMESPACE exports without executing R or
loading package code. requireNamespace() deliberately does not introduce
unqualified names.
Stubs are maintained in the standalone
r-typeshed repository and
vendored into the binary. They currently cover base R (with a
mechanically generated symbol inventory for the default packages),
the tidyverse core (dplyr, tidyr, tidyselect, dbplyr, purrr), the
Bayesian stack (brms, posterior, loo, bayesplot, cmdstanr), and
testthat, tinytest, withr, R6, S7, Rcpp, foreach, shiny, survival,
recipes, mirai, and others. Packages attached outside the checked
sources can be declared in ry.toml.
When checking a package source tree, files are checked in their
evaluation context: tests/testthat/ and inst/tinytest/ files see
the package’s own namespace, the test framework, DESCRIPTION
Depends / Suggests, and bindings plus library() calls from
helper* / setup* files; data-raw/, demo/, and vignettes/
attach Depends. revdep/, src/, snapshot data, and
.Rbuildignore matches (never R/ or tests/) are skipped.
Parallel purrr code checks like sequential code, and the typed map family is checked against its callback:
ry check /tmp/ry-readme/parallel.R
#> /tmp/ry-readme/parallel.R:5:8: warning: [RY080] `map_dbl` expects `double` returns but the callback returns `character`; R will coerce silently
#> bad <- map_dbl(1:4, function(i) as.character(i))
#> ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
#> ry: checked 1 file(s), 0 error(s), 1 warning(s)in_parallel() is type-transparent to ry: a map_dbl whose callback
returns character is a diagnostic before the run, not a surprise halfway
through your simulation study.
Stubs declare which parameters are data-masked, tidy-selected, or
quoted (for tidyverse packages this metadata is generated from the
<data-masking> / <tidy-select> markers in their documentation).
Columns inside a masked argument resolve against the data frame's
schema instead of the lexical scope:
ry check nse.R
#> nse.R:4:23: warning: [RY010] variable `mgp` is not bound in this scope
#> summarise(d, m = mean(mgp))
#> ^~~
#> ry: checked 1 file(s), 0 error(s), 1 warning(s)where nse.R is
library(dplyr)
d <- data.frame(mpg = c(21, 22.8), cyl = c(6, 4))
summarise(d, m = mean(mpg)) # resolves
summarise(d, m = mean(mgp)) # typo, caught
my_mean <- function(df, var) summarise(df, m = mean({{ var }})) # silentrlang’s {{ }} embrace, the .data / .env pronouns, !! / !!!,
and functions that defuse their own arguments (a parameter whose first
use is enquo() / substitute() / …) are recognized, so wrapper
functions do not produce false unbound-variable reports. When the
masked data’s schema is unknown, column candidates stay silent rather
than guessed at.
Discovered by walking up from the checked path. All keys optional:
# Promote / demote / disable rules by code (RY040), name
# (invalid-arithmetic), or "all".
error = ["RY040"]
warn = ["RY070"]
ignore = ["RY033"]
# Packages attached outside the checked sources.
packages = ["dplyr"]
# Names created dynamically by the host application or an unresolvable
# load(). Only these names are treated as opaque globals.
globals = ["runtime_data", "generated_lookup"]
# Additional package stubs. Paths are relative to this ry.toml.
typeshed = ["stubs", "../shared-r-stubs"]
# Accepted findings from `ry check --write-baseline`; new findings still fail.
baseline = "ry-baseline.json"
error-on-warning = false
exit-zero = false
output-format = "full" # full | concise | json | github | gitlab | junit
# gitignore-style patterns, relative to this ry.toml's directory.
exclude = ["renv", "tests/snaps/**"]CLI flags override the config only when passed explicitly. When multiple paths are checked, the first path anchors config discovery; that one configuration applies to the complete invocation.
Custom stub directories let a project add package signatures or replace ry's
vendored signatures without recompiling. Both stubs/foo.json and
stubs/foo/foo.json layouts are accepted. The optional package header names
the package; legacy files fall back to the JSON file stem.
Directories are layered in declaration order and --typeshed <DIR> may be
repeated to append CLI directories. Later directories win, so CLI stubs replace
same-named config stubs. A custom package replaces the embedded package as a
whole; function-by-function merging is intentionally not performed. A
base.json stub likewise replaces the embedded base typeshed for that run.
Malformed files produce a warning naming the file while valid siblings remain
active.
Run ry explain typeshed to see the vendored snapshot, embedded packages, and
the custom directories active from the current workspace's ry.toml.
x <- bad # ry: ignore # suppress all rules on this line
x <- bad # ry: ignore[RY010, RY040] # suppress specific rules
x <- bad # noqa: RY010 # flake8/ruff-compatible alias
# ry: ignore # standalone: suppresses the next line
# ry: ignore-file # file-level, anywhere in the filePrefer a rule-specific inline suppression or globals entry for dynamic
workspaces. ry intentionally does not suppress diagnostics merely because an
expression appears inside expect_error(): the setup expression is ordinary R
code and can contain a real defect before the expected error is reached.
Every diagnostic carries a confidence tier. Structurally exact rules
(RY093, RY094, RY096, RY030, RY033, …) are high; RY010 is medium;
diagnostics from tests/, data-raw/, demo/, vignettes/, and
inst/ are demoted one tier. Output is sorted by tier, non-medium
tiers are tagged in the message, and --min-confidence high|medium|low
filters both output and exit code.
To adopt ry on an existing codebase, snapshot the current findings and fail only on new ones:
ry check --write-baseline ry-baseline.json .
ry check --baseline ry-baseline.json . # or `baseline` in ry.tomlBaseline entries match on path, rule, and message – not line numbers – so unrelated edits do not invalidate them. Fixed findings can be removed by regenerating the baseline.
ry server speaks the Language Server Protocol over stdio: diagnostics
as you type (debounced, cached parses), hover with inferred types,
go-to-definition, references, rename, completion, signature help, inlay
hints, folding, and quick-fix actions that insert suppression comments.
Connect it from any LSP-aware editor (VS Code, Positron, Neovim, Helix,
…).
--output-format github emits workflow-command annotations; gitlab
and junit cover the other major CI systems. --statistics prints
per-rule counts after a run – useful for corpus work. A minimal GitHub
Actions step:
- run: ry check --output-format github .Defaults can be overridden per-project; ry explain rule RY040 prints the
explanation for one rule.
| code | name | severity | summary |
|---|---|---|---|
| RY000 | syntax-error | error | Unparseable input. tree-sitter could not recover this region; subsequent diagnostics may be unreliable. |
| RY001 | invalid-condition | warning | if / while condition is not a length-1 logical. |
| RY002 | condition-length | warning | if condition length is known to be greater than 1; only the first element is used. |
| RY010 | unbound-variable | warning | Reference to a variable with no binding in scope. |
| RY020 | unary-minus-type | error | Unary - applied to a non-numeric type. |
| RY021 | unary-not-type | error | Unary ! applied to a non-coercible-to-logical type. |
| RY030 | invalid-comparison | error | Comparison between types with no defined ordering. |
| RY031 | invalid-logical-op | error | & / | / && / || applied to non-coercible types. |
| RY032 | scalar-logical-length | warning | && and || only use the first element of their operands; using them with vectors of length > 1 is almost always a bug. Use &/| for vectorized operations. |
| RY033 | comparison-mode-mismatch | warning | Comparing a character value with a numeric value is valid R but almost always unintended. R compares byte values, not semantic equality. |
| RY034 | compare-na | warning | Comparing with NA using == or != always produces NA. Use is.na() instead. |
| RY040 | invalid-arithmetic | error | Arithmetic operator between incompatible types. |
| RY041 | non-divisible-recycling | warning | Vector lengths do not divide evenly, so R recycles values with a warning and may produce unintended results. |
| RY042 | factor-arithmetic | warning | Arithmetic on factors produces missing values. Operate on levels or convert explicitly. |
| RY050 | missing-s3-method | warning | S3 generic called on a value with no defined method for its class. |
| RY060 | undefined-column | error | Column access on a value whose schema does not contain that column. |
| RY061 | dollar-on-atomic | error | The $ operator is invalid for atomic vectors (integer, double, character, logical). It only works on list-like types (lists, data frames, environments). |
| RY070 | call-non-function | error | A non-function value (a variable bound to a non-function, or a literal like 42()) is being called as a function. R will error at runtime (‘attempt to apply non-function’ / ‘could not find function’). |
| RY080 | map-return-type-mismatch | warning | A purrr typed-map (map_dbl, map_int, …) callback returns a value whose mode is incompatible with the target vector type. R coerces at runtime, but the mismatch is almost always unintended. |
| RY090 | unknown-argument | warning | A named call argument does not match any formal parameter after R’s exact and partial argument matching. |
| RY091 | missing-required-argument | warning | A required formal parameter is not bound by name or position. |
| RY092 | argument-type-mismatch | error | A call argument has a known mode incompatible with the parameter type declared by the resolved signature. |
| RY093 | comparison-inside-length | warning | A comparison directly inside length() (also nchar(), abs()) is usually a parenthesization mistake. |
| RY094 | printf-argument-count | warning | A literal printf-family format string has more conversions than supplied value arguments. |
| RY096 | hasarg-non-formal | warning | hasArg() names a parameter that is not a formal of an enclosing function without .... |
| RY097 | not-r-source | info | File does not appear to be R source (e.g. Ratfor); its diagnostics are suppressed. |
| RY098 | default-forced-before-assignment | warning | A parameter default references a body-local that may not be assigned yet on some execution path. |
Known gaps: S4 modeling covers in-package setClass / setGeneric /
setMethod and @ slot access but not full method resolution order;
R6 modeling covers self / private / super in method bodies, not
field types. No expansion of dynamic exportPattern() directives and no
NA tracking yet. Cross-package names without stubs resolve to opaque
values when static package metadata proves that they exist.
See CONTRIBUTING.md for the build gate, fixture conventions, and the false-positive bar every new rule must clear.