Arity
Arity is a language server, formatter, and linter for the R language. It is built in Rust on a lossless, incremental parser, providing a fast, deterministic development experience that integrates with popular code editors and IDEs.
Quick Start
Install with Cargo:
cargo install arity
Format your first document:
arity format file.R
Run lint checks:
arity lint file.R
For full installation options (prebuilt binaries, package managers, and source builds), see Getting Started.
Where to Go Next
- Getting Started: complete installation and first-run walkthrough.
- Editor setup: connect the language server to your editor.
- Configuration: every
arity.tomlkey. - CLI Reference: every command and option.
- Lint rules: the rule reference, generated by running the linter on worked examples.
arity v0.16.0
Getting Started
Installation
Cargo
The simplest way to install Arity is from crates.io with Cargo:
cargo install arity
From Source
Clone the repository and build a release binary:
git clone https://github.com/jolars/arity
cd arity
cargo build --release
The binary is written to target/release/arity.
First Run
Format a file in place:
arity format file.R
Check formatting without writing changes:
arity format --check file.R
Lint a file (or pipe from stdin):
arity lint file.R
Run the language server over stdio (for editor integration):
arity lsp
See the CLI Reference for the full set of commands and options.
Editor Setup
Arity ships a language server, started with arity lsp (stdio, JSON-RPC). It
offers formatting, diagnostics with quick fixes, hover, completion, signature
help, go-to-definition and find-references, rename, document and workspace
symbols, semantic tokens, folding and selection ranges, document links, document
color swatches, and call and type hierarchy.
Configuration is read from an arity.toml discovered from each file’s directory
(see the configuration reference).
VS Code/Positron
Install the Arity extension from the VS Code
Marketplace or Open
VSX. It bundles the arity binary (falling back to a
download) and starts the language server automatically for R files. Editors that
support VS Code extensions, such as Positron, work the same way.
Neovim
With nvim-lspconfig installed,
register arity as a server for R files:
vim.lsp.config("arity", {
cmd = { "arity", "lsp" },
filetypes = { "r" },
root_markers = { "arity.toml", "DESCRIPTION", ".git" },
})
vim.lsp.enable("arity")
Format on save (optional):
vim.api.nvim_create_autocmd("BufWritePre", {
pattern = "*.R",
callback = function() vim.lsp.buf.format() end,
})
Helix
In ~/.config/helix/languages.toml:
[language-server.arity]
command = "arity"
args = ["lsp"]
[[language]]
name = "r"
language-servers = ["arity"]
formatter = { command = "arity", args = ["format"] }
auto-format = true
Other editors
Any LSP-capable editor can use arity by launching arity lsp over stdio for the
r language. Point your client’s R language-server command at arity with the
lsp argument.
Configuration
Arity is configured with a TOML file named arity.toml. All keys are optional;
omitting a key uses its default. Keys are kebab-case, and unknown keys are
rejected with an error (so a typo never silently falls back to a default).
Run arity init to write a commented starter file.
Discovery
For a given file, arity looks for arity.toml by walking up from the file’s
directory through its ancestors, stopping at the first arity.toml it finds or
at a directory containing a .git entry (the repository root), whichever comes
first.
On the command line:
--config <PATH>loads an explicit file and skips discovery.--no-configignores any discovered file and uses the built-in defaults.
Top-level keys
These apply to both format and lint (the first two govern the shared
file walk).
| Key | Type | Default | Description |
|---|---|---|---|
exclude | array of strings | built-in set | gitignore-style patterns to skip, resolved relative to the directory containing arity.toml. Setting it replaces the built-in set (below). |
extend-exclude | array of strings | [] | Like exclude, but added to exclude rather than replacing it. Use this to skip extra paths while keeping the built-in defaults. |
cache | boolean | true | Enable the persistent result cache (currently the format --check already-formatted cache; the cache directory follows [index] cache-dir/$ARITY_CACHE_DIR). The --no-cache CLI flag overrides it per run. |
The built-in default exclude set (the default value of exclude; generated or
vendored files that should not be reformatted or linted) is:
.git/
renv/
revdep/
cpp11.R
RcppExports.R
extendr-wrappers.R
import-standalone-*.R
Excludes apply only to directory walks. A file named explicitly on the
command line is always processed, even if it matches an exclude pattern. Pass
--force-exclude (on format and lint) to apply the patterns to explicitly
named files too—useful for runners like pre-commit that pass staged files as
arguments. The CLI flag --exclude <PATTERN> (on format and lint) adds to
the configured exclude/extend-exclude for a single run.
# Keep the built-in defaults and also skip these:
extend-exclude = ["vendor/", "*.gen.R"]
# Or replace the built-in defaults entirely:
# exclude = ["vendor/", "*.gen.R"]
[format]
| Key | Type | Default | Description |
|---|---|---|---|
line-width | integer (1–1000) | 80 | The width the formatter tries to keep lines within. Not a hard cap. |
indent-width | integer (1–1000) | 2 | Number of spaces per indentation level. |
line-ending | string | "auto" | Newline style: "auto", "lf", "crlf", or "native" (see below). |
line-ending = "auto" mirrors the source file’s first line ending (defaulting
to lf when the file has none); "native" is crlf on Windows and lf
elsewhere; "lf" and "crlf" force that ending.
[format]
line-width = 80
indent-width = 2
line-ending = "auto"
line-width and indent-width can be overridden per run with the
--line-width/--indent-width flags on arity format.
[lint]
| Key | Type | Default | Description |
|---|---|---|---|
select | array of strings | unset | If set, only these rule IDs run. |
ignore | array of strings | [] | Rule IDs to disable (applied on top of select or the default set). |
Rule IDs are the kebab-case names from the rule reference. Unknown
IDs are reported when linting runs, not when the config is parsed. The
--select/--ignore flags on arity lint override these for a single run.
[lint]
select = ["undefined-symbol", "equals-na"]
ignore = ["unused-binding"]
[index]
Controls the R-package symbol index used by the language server (and by namespace-aware lint rules) to resolve names.
| Key | Type | Default | Description |
|---|---|---|---|
library-paths | array of paths | [] | Explicit R library directories, used when automatic discovery misses. |
cache-dir | path | unset | Override the index cache directory (otherwise XDG/$ARITY_CACHE_DIR). |
auto-build | boolean | true | Let the language server lazily index referenced-but-unindexed packages. |
help | boolean | true | Harvest help titles while indexing. false stores names only (faster). |
Note: the downloadable CRAN symbol sidecar is not configured here. Enabling network access is a per-user decision set via the
ARITY_REMOTE_URLenvironment variable, never committed in a sharedarity.toml.
Note: the same applies to the attach probe (
arity index --attach-probe), which observes what a meta-package attaches by runninglibrary()in a fresh R session. Because that executes package attach hooks, it is enabled per run by the flag or per user via theARITY_ATTACH_PROBEenvironment variable, never fromarity.toml. Without it, attach sets are still captured for packages following the tidyversecoreconvention, with a built-in table as the offline fallback.
Reserved for future use
The following are not yet implemented but are reserved so the schema can grow without breaking changes (adding a key is always backward-compatible under the strict unknown-key check):
[format].indent-style("space"or"tab")—tab indentation.[format].skipand a# fmt: skipcomment—opt specific calls out of formatting.[lint.rules.<id>]—per-rule configuration tables (including per-rule severity).- Category names (e.g.
"correctness") inselect/ignore.
Command-Line Help for arity
Arity: a language server, formatter, and linter for R
Usage: arity [OPTIONS] <COMMAND>
Options
--config <PATH>-
Path to an explicit
arity.toml(skips discovery) --no-config-
Ignore any discovered
arity.tomland use built-in defaults --color <WHEN>-
When to use color in output
Default value:
autoPossible values:
auto: Colorize when writing to a terminal andNO_COLORis unset (default)always: Always colorizenever: Never colorize
-q,--quiet-
Suppress informational output (errors are still shown)
-v,--verbose-
Print extra informational output (e.g. per-command summaries)
arity parse
Parse and display the CST tree for debugging
Usage: arity parse [OPTIONS] [FILE]
Arguments
<FILE>- Input file (stdin if not provided)
Options
--quiet- Suppress CST output to stdout
--verify- Verify parser losslessness (input must equal CST text)
arity format
Format .R files
Usage: arity format [OPTIONS] [PATH]...
Arguments
<PATH>...- Input file(s) or path(s) (stdin if omitted)
Options
--verify- Verify formatting idempotence for supported inputs (does not write files)
--check- Check formatting without writing changes; prints a diff for each file that would be reformatted and exits non-zero if any differ
--line-width <N>- Override the configured line width
--indent-width <N>- Override the configured indent width
--exclude <PATTERN>- Additional gitignore-style exclude patterns (repeatable or comma-separated); augments the configured
exclude/extend-exclude --force-exclude- Apply exclude patterns to files named explicitly on the command line too (they are normally always processed); for runners like pre-commit that pass staged files as arguments
--no-cache- Disable the persistent already-formatted cache (read and write) for this run; only affects
--check
arity lint
Lint .R files
Reads stdin when no paths are given. Exit codes: 0 = no findings, 1 = findings (or files blocked by parse errors), 2 = usage/IO error.
Usage: arity lint [OPTIONS] [PATH]...
Arguments
<PATH>...- Input file(s) or path(s) (stdin if omitted)
Options
--stdin-filename <PATH>-
Filename to report for stdin input (for diagnostics)
--fix-
Apply safe autofixes in place and report what remains
--unsafe-fixes-
Also apply fixes that may change behavior (requires –fix)
--select <RULE_ID>-
Only run these rules (overrides config
select); repeatable or comma-separated --ignore <RULE_ID>-
Disable these rules (overrides config
ignore); repeatable or comma-separated --exclude <PATTERN>-
Additional gitignore-style exclude patterns (repeatable or comma-separated); augments the configured
exclude/extend-exclude --force-exclude-
Apply exclude patterns to files named explicitly on the command line too (they are normally always processed); for runners like pre-commit that pass staged files as arguments
--output <OUTPUT>-
Output format
Default value:
prettyPossible values:
pretty: Annotated multi-line snippets (default; matches jarl/rustc-style output)concise: One finding per line (path:line:col: severity [rule] message)json: JSON array of diagnostics, for editor integration
arity index
Build or refresh the installed-package introspection index
Usage: arity index [OPTIONS] [PATH]...
Arguments
<PATH>...- Project path(s) to scan for referenced packages (default: “.”)
Options
--force- Re-harvest even when the installed version is already indexed
--no-help- Skip harvesting help (names only; faster)
--attach-probe- Probe what each meta-package attaches by running
library()in a fresh R session (spawns R and executes package attach hooks; also enabled by settingARITY_ATTACH_PROBE) --cache-dir <DIR>- Override the cache directory
--quiet- Suppress per-package progress output
arity lsp
Run the language server over stdio
Usage: arity lsp
arity completions
Generate a shell completion script (write it to stdout)
Usage: arity completions <SHELL>
Arguments
<SHELL>-
Shell to generate completions for
Possible values:
bash,elvish,fish,powershell,zsh
arity init
Write a starter arity.toml to the current directory
Usage: arity init [OPTIONS]
Options
--force- Overwrite an existing
arity.toml
Benchmarks
Wall-clock speed of arity against other R tooling, measured with hyperfine.
Two operations are covered:
- the formatter, compared against
air(withstyleravailable opt-in); - the linter, compared against
jarl.
Each operation is measured at two scopes: single files (synthetic corpus
tiers) and a whole project (a real R package). arity is the baseline in
every chart, and every other tool’s time is reported relative to it.
This is not a CI gate and not a parity target. Timings are machine- and
run-dependent, and these numbers measure speed only, never output or finding
equivalence (see AIR_COMPAT.md or task air-compat for formatter output
comparison). The tools also pay very different startup floors: styler runs
inside an R process, so a large part of its time on small inputs is interpreter
startup rather than real work. Treat the ratios, not the absolute
milliseconds, as the takeaway.
The figures below are regenerated manually with task bench and committed as a
machine-readable artifact (benches/benchmark_results.json); they are never
re-measured when this site is built or in CI.
How it is measured
For single files, each tool is invoked as a user would pipe (formatters) or point it at a file (linters):
| Tool | Invocation |
|---|---|
arity | arity format / arity lint FILE |
air | air format --stdin-file-path bench.R |
styler | Rscript -e 'styler::style_text(readLines(file("stdin")))' |
jarl | jarl check FILE |
For projects, each tool walks the package’s R/ source tree in one invocation.
Formatters run in check mode so nothing is mutated, but the full formatting work
is still done:
| Tool | Invocation |
|---|---|
arity | arity format --check R/ / arity lint R/ |
air | air format --check R/ |
jarl | jarl check R/ |
arity is the baseline; every other tool’s time is reported relative to it.
Comparison tools absent from the machine are skipped, so a run without jarl
simply omits the linter comparison. The timing backend prefers hyperfine
(warmup plus stddev/min/max); without hyperfine and jq it falls back to a
mean-only shell loop and the min/max columns become blank.
styler is an R package: it pays an interpreter startup floor plus a steep
per-line cost, so it is not measured by default and only ever appears on the
formatter single-file tiers (never on projects, where style_dir would mutate
the checkout). Opt in with ARITY_BENCH_STYLER=1 task bench; even then it is
skipped on tiers too large to format in reasonable time. Because the tools do
such different work, this is a rough scale comparison, not a like-for-like one.
Corpus
Single files are synthetic: every formatter fixture’s expected.R
(crates/arity-formatter/tests/fixtures/formatter/*/expected.R) is concatenated
(sorted, blank-line separated) into a base block, which is repeated to two size
tiers. The content repeats, so it is cache-friendly and not fully representative
of real code; it exists to amortize process startup and show rough scaling, not
to model a real workload.
Projects use a real R package (the tidyr
source tree by default), cloned once at a pinned tag into a local cache. Point
the benchmark at your own checkout with
ARITY_BENCH_PROJECT=/path/to/pkg task bench; only its R/ directory is
measured.
Setup
- arity:
0.11.0 - air:
0.10.0 - jarl:
0.5.0 - backend: hyperfine (min runs: 3)
- host: linux/x86_64, Intel(R) Core(TM) Ultra 7 155U
- generated: 2026-07-11T02:55:45Z
Results
Each operation gets its own section below, split into single files and a whole
project. The arity baseline sits on the dashed line at 1 in every chart;
faster tools fall below it, slower tools rise above.
Formatter
Single files
Data table
small (123094 bytes, 8498 lines)
| Tool | Mean (ms) | Min (ms) | Max (ms) | Relative |
|---|---|---|---|---|
| arity | 26.3464 | 20.5507 | 36.0864 | baseline |
| air | 35.9680 | 29.4418 | 47.8633 | 1.4x slower |
large (1477128 bytes, 101976 lines)
| Tool | Mean (ms) | Min (ms) | Max (ms) | Relative |
|---|---|---|---|---|
| arity | 791.3714 | 773.7293 | 810.6469 | baseline |
| air | 449.5672 | 387.3011 | 588.7086 | 1.8x faster |
Projects
Data table
tidyr (245685 bytes, 8774 lines)
| Tool | Mean (ms) | Min (ms) | Max (ms) | Relative |
|---|---|---|---|---|
| arity | 39.4097 | 32.7052 | 49.4364 | baseline |
| air | 56.7434 | 48.2065 | 70.0263 | 1.4x slower |
Linter
Single files
Data table
small (123094 bytes, 8498 lines)
| Tool | Mean (ms) | Min (ms) | Max (ms) | Relative |
|---|---|---|---|---|
| arity | 37.6006 | 30.2579 | 44.9339 | baseline |
| jarl | 44.5027 | 32.8220 | 55.5076 | 1.2x slower |
large (1477128 bytes, 101976 lines)
| Tool | Mean (ms) | Min (ms) | Max (ms) | Relative |
|---|---|---|---|---|
| arity | 385.7874 | 307.3075 | 435.3728 | baseline |
| jarl | 673.2606 | 628.6883 | 760.4076 | 1.7x slower |
Projects
Data table
tidyr (245685 bytes, 8774 lines)
| Tool | Mean (ms) | Min (ms) | Max (ms) | Relative |
|---|---|---|---|---|
| arity | 34.4145 | 28.2005 | 43.7791 | baseline |
| jarl | 23.1374 | 17.6572 | 30.6717 | 1.5x faster |
Lint rules
Each rule’s reference page is generated from the rule’s own metadata by running
the linter on worked examples. Regenerate with cargo run --example docgen.
Correctness
undefined-symbolunused-bindingduplicate-formalduplicated-argumentsequals-navector-logicunreachable-codeis-numericif-always-trueempty-assignment
Suspicious
assignment-in-conditionimplicit-assignmentbrowsershadowed-builtinredundant-equalsredundant-ifelserepeat
Readability
Performance
Documentation
undefined-symbol
Flag an identifier read that resolves to no in-scope binding and no known package export.
Gated for safety: the rule stays silent for a whole file unless every library()-attached package is indexed, since an un-indexed package could export the otherwise-unresolved name.
This rule is enabled by default.
subtotal resolves to nothing:
total <- subtotal
warning: undefined-symbol
--> example.R:1:10
|
1 | total <- subtotal
| ^^^^^^^^ no in-scope binding or attached package exports `subtotal`
unused-binding
Flag a local binding that is never read in the same file. Function parameters, for-loop variables, and names beginning with . are exempt, since those are meaningful even when unused.
This rule is enabled by default.
x is assigned but never used:
x <- 1
y <- 2
print(y)
warning: unused-binding
--> example.R:1:1
|
1 | x <- 1
| ^ local binding `x` is assigned but never read
= help: Remove the assignment, or prefix the name with `.` to mark it intentional.
duplicate-formal
Flag a function defined with two parameters of the same name. R raises a runtime error (repeated formal argument); this catches it statically.
This rule is enabled by default.
Two parameters named x:
f <- function(x, x) x
error: duplicate-formal
--> example.R:1:18
|
1 | f <- function(x, x) x
| ^ parameter `x` is declared more than once in this function
= help: Rename one of the parameters.
duplicated-arguments
Flag a call that supplies the same argument name more than once (f(a = 1, a = 2)). The call-side sibling of duplicate-formal; reported as a warning with no autofix, since it isn’t always a runtime error.
This rule is enabled by default.
The argument a is supplied twice:
list(a = 1, a = 2)
warning: duplicated-arguments
--> example.R:1:13
|
1 | list(a = 1, a = 2)
| ^ argument `a` is supplied more than once in this call
= help: Remove or rename the duplicate argument.
equals-na
Flag x == NA, which is always NA rather than TRUE/FALSE—almost always a mistake for is.na(x), which is the autofix.
This rule is enabled by default.
Comparing to NA with ==:
x == NA
warning: equals-na
--> example.R:1:1
|
1 | x == NA
| ^^^^^^^ comparison with `NA` is always `NA`; use `is.na()`
= help: Use `is.na(x)`.
After applying the fix:
is.na(x)
vector-logic
Flag the vectorized &/| used directly in an if/while condition, where the scalar &&/|| is meant.
A condition needs a single TRUE/FALSE: R only looks at the first element (a length > 1 condition is an error since R 4.2), and &&/|| short-circuit. The fix doubles the operator. Operators inside a function call (if (any(a | b))) are left alone—a vector result is the point there.
This rule is enabled by default.
Vectorized & in an if condition:
if (a & b) {
go()
}
warning: vector-logic
--> example.R:1:7
|
1 | if (a & b) {
| ^ `&` in a condition; use `&&`
= help: Use the scalar `&&` in an `if`/`while` condition.
After applying the fix:
if (a && b) {
go()
}
unreachable-code
Flag statements that follow an unconditional return() or stop() in a block—once either runs, nothing after it in the same block can be reached, so the trailing code is dead. A direct-statement if/else that exits in both branches likewise leaves its tail unreachable (a control-flow-graph verdict).
The rule fires only when the terminator is a direct statement of the block (a lone return()/stop() guarded by an if leaves the tail reachable) and only when the callee resolves to base R; a local redefinition is left alone. return is additionally required to sit inside a function. The deletion fix is unsafe, and withheld when it would drop a comment.
This rule is enabled by default.
A statement after return() can never run:
f <- function() {
return(1)
2
}
warning: unreachable-code
--> example.R:3:3
|
3 | 2
| ^ code after `return()` can never be reached
= help: Remove the unreachable code, or fix the control flow.
An if/else that exits in both branches leaves its tail dead:
f <- function() {
if (x) return(1) else return(2)
3
}
warning: unreachable-code
--> example.R:3:3
|
3 | 3
| ^ code after this `if` can never be reached (both branches exit)
= help: Remove the unreachable code, or fix the control flow.
is-numeric
Flag is.numeric(x) || is.integer(x) (and the vectorized | spelling), which is just is.numeric(x): is.numeric() already returns TRUE for integer vectors, so the disjunction adds nothing and suggests a misreading of what is.numeric() tests.
The rule fires only when both operands are single-argument calls on the same argument and both callees resolve to base R; a local redefinition of either is left alone.
This rule is enabled by default.
Testing for a numeric vector:
if (is.numeric(x) || is.integer(x)) mean(x)
warning: is-numeric
--> example.R:1:5
|
1 | if (is.numeric(x) || is.integer(x)) mean(x)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `is.numeric(x) || is.integer(x)` is redundant—`is.numeric()` is already `TRUE` for integer vectors
= help: Use `is.numeric(x)`.
After applying the fix:
if (is.numeric(x)) mean(x)
if-always-true
Flag an if whose condition is the literal TRUE or FALSE. The branch is decided statically, so the if is dead control flow. Only the bare literals are flagged—not folded constants (if (1 == 1)) or the rebindable symbols T/F.
This rule is enabled by default.
An if gated on a constant always takes the same branch:
if (TRUE) {
f()
} else {
g()
}
warning: if-always-true
--> example.R:1:5
|
1 | if (TRUE) {
| ^^^^ `if` condition is always `TRUE`
= help: The condition always holds; the branch always runs.
empty-assignment
Flag an assignment whose value is an empty block (x <- {}). An empty block evaluates to NULL, so this is a roundabout x <- NULL—usually a leftover from deleting the block’s body. An empty function body or if branch is not flagged.
This rule is enabled by default.
Assigning an empty block is the same as assigning NULL:
x <- {}
warning: empty-assignment
--> example.R:1:6
|
1 | x <- {}
| ^^ assigning an empty block `{}` is the same as assigning `NULL`
= help: Assign `NULL` or a meaningful value instead.
assignment-in-condition
Flag an assignment (<-, =, <<-, :=) used as the direct condition of an if/while. The bare = form (often a == typo) is autofixed to ==; the others are reported without a fix.
This rule is enabled by default.
= where == was meant:
if (x = 5) print(x)
warning: assignment-in-condition
--> example.R:1:5
|
1 | if (x = 5) print(x)
| ^^^^^ assignment used as a condition; did you mean `==`?
= help: Replace `=` with `==` or move the assignment out.
After applying the fix:
if (x == 5) print(x)
implicit-assignment
Flag an assignment (<-, =, <<-, ->, ->>) nested inside a call or subscript argument, e.g. mean(x <- 1:10). The binding runs as a side effect of the argument and is easy to miss; assign on its own line instead. The if/while condition case is covered by assignment-in-condition, and the data.table / rlang walrus (:=) is left alone.
This rule is disabled by default; enable it with select.
An assignment hidden inside a call argument:
mean(x <- 1:10)
warning: implicit-assignment
--> example.R:1:6
|
1 | mean(x <- 1:10)
| ^^^^^^^^^ assignment nested in a call argument; assign on its own line
= help: Move the assignment to its own statement.
browser
Flag a leftover browser() call. browser() opens R’s interactive debugger and is meant to be removed before code is committed; in a non-interactive session it silently does nothing, so it lingers unnoticed.
Only a call that resolves to base R’s browser is flagged—a same-named user function is left alone. The safe-delete fix is offered only for a browser() that is a direct statement (of a block or the file top level); in any other position it is withheld so the edit can’t break syntax.
This rule is enabled by default.
A browser() call left in after debugging:
f <- function(x) {
browser()
x + 1
}
warning: browser
--> example.R:2:3
|
2 | browser()
| ^^^^^^^^^ leftover `browser()` debugging call
= help: Remove the `browser()` call.
After applying the fix:
f <- function(x) {
x + 1
}
shadowed-builtin
Flag a local binding to a function whose name is exported by a default R package when that name is later called in the same scope (c <- function(...) ...; c(2, 3)). A value binding (names <- names(x)) is exempt: R’s call-position lookup skips non-function locals, so it is not a hazard.
This rule is enabled by default.
Binding a function over base c() and then calling it:
c <- function(x, y) x
c(2, 3)
warning: shadowed-builtin
--> example.R:1:1
|
1 | c <- function(x, y) x
| ^ local binding `c` shadows a base-R name later used in this scope
= help: Rename the local, or fully qualify the base call (e.g. `base::c`).
redundant-equals
Flag comparison to a logical literal: x == TRUE is just x, and x == FALSE is !x.
This rule is enabled by default.
Comparing to TRUE:
if (ready == TRUE) go()
warning: redundant-equals
--> example.R:1:5
|
1 | if (ready == TRUE) go()
| ^^^^^^^^^^^^^ comparison with a logical literal is redundant
= help: Use the expression directly, or negate it.
After applying the fix:
if (ready) go()
redundant-ifelse
Flag ifelse(c, TRUE, FALSE) (which is just c) and ifelse(c, FALSE, TRUE) (which is !c).
This rule is enabled by default.
An ifelse that returns its own condition:
flag <- ifelse(cond, TRUE, FALSE)
warning: redundant-ifelse
--> example.R:1:9
|
1 | flag <- ifelse(cond, TRUE, FALSE)
| ^^^^^^^^^^^^^^^^^^^^^^^^^ `ifelse()` returning `TRUE`/`FALSE` is redundant
= help: Use the condition directly, or negate it.
After applying the fix:
flag <- cond
repeat
Flag while (TRUE), an unconditional loop better written as repeat.
repeat states the intent—loop until a break/return—without the dummy TRUE condition. Only the reserved literal TRUE is matched; the rebindable T is left to true-false-symbol.
This rule is enabled by default.
An unconditional while loop:
while (TRUE) {
poll()
}
warning: repeat
--> example.R:1:1
|
1 | while (TRUE) {
| ^^^^^^^^^^^^ `while (TRUE)` is an unconditional loop; use `repeat`
= help: Write `repeat` for a loop with no exit condition.
After applying the fix:
repeat {
poll()
}
true-false-symbol
Prefer the reserved literals TRUE/FALSE over the rebindable base symbols T/F.
T and F are ordinary base-R bindings, not reserved words—T <- FALSE is legal—so relying on them as boolean shorthand is fragile. The fix is withheld when the name resolves to a local binding, since that is the user’s own variable rather than the shorthand.
This rule is enabled by default.
T and F used as boolean shorthand:
x <- T
y <- F
warning: true-false-symbol
--> example.R:1:6
|
1 | x <- T
| ^ use `TRUE` instead of `T`
= help: `T`/`F` are rebindable; prefer the reserved literals.
warning: true-false-symbol
--> example.R:2:6
|
2 | y <- F
| ^ use `FALSE` instead of `F`
= help: `T`/`F` are rebindable; prefer the reserved literals.
After applying the fix:
x <- TRUE
y <- FALSE
comparison-negation
Flag a negated comparison—!(a == b), !x < y—which reads more clearly as the opposite comparison (a != b, x >= y).
The fix is withheld when a comment in the operand would otherwise be lost.
This rule is enabled by default.
Negating an equality test:
if (!(a == b)) stop()
warning: comparison-negation
--> example.R:1:5
|
1 | if (!(a == b)) stop()
| ^^^^^^^^^ negated comparison is clearer as the opposite operator
= help: Flip the comparison instead of negating it.
After applying the fix:
if (a != b) stop()
outer-negation
Flag any(!x)/all(!x), which by De Morgan’s law read more clearly with the negation pulled outside: !all(x) and !any(x).
The rule fires only when every positional argument is negated (a na.rm argument is allowed and preserved). The fix is withheld when the call sits in a context that binds tighter than !, where the rewrite would need parentheses.
This rule is enabled by default.
Negating every element of an aggregation:
if (any(!ok)) stop()
warning: outer-negation
--> example.R:1:5
|
1 | if (any(!ok)) stop()
| ^^^^^^^^ negating an aggregation is clearer with the negation outside
= help: `any(!x)` is `!all(x)`; `all(!x)` is `!any(x)`.
After applying the fix:
if (!all(ok)) stop()
string-boundary
Flag grepl("^abc", x) and grepl("abc$", x), single-anchored fixed-string matches that are the clearer startsWith(x, "abc") and endsWith(x, "abc")—they state the prefix/suffix test directly and skip regex compilation.
The rule fires only on the clean shape (two positional arguments, a one-end-anchored plain-literal pattern) and only when grepl resolves to base R. The fix is unsafe: on NA or non-character input startsWith/endsWith diverge from grepl (NA vs FALSE, an error vs coercion).
This rule is enabled by default.
Anchored fixed-string matches:
grepl("^abc", x)
warning: string-boundary
--> example.R:1:1
|
1 | grepl("^abc", x)
| ^^^^^^^^^^^^^^^^ single-anchor `grepl()` is the clearer `startsWith()`
= help: Use `startsWith()`.
unnecessary-nesting
Flag an if whose entire body is a second if—the two could be a single if with the conditions joined by &&, dropping a needless level of nesting. It fires only when neither if has an else (an else on either side changes what runs) and the inner if is the sole statement of the outer one.
The fix joins the conditions with &&, parenthesizing each non-primary condition so the grouping is preserved. It is unsafe (collapsing dedents the body, so a reformat may follow) and withheld when it would drop a comment.
This rule is enabled by default.
An if whose only body is another if can be a single if:
if (a) {
if (b) {
do_thing()
}
}
warning: unnecessary-nesting
--> example.R:2:3
|
2 | if (b) {
| ^^ this `if` is nested in another `if` with no `else`
= help: Combine the two conditions with `&&` to drop a level of nesting.
any-is-na
Flag any(is.na(x)), which is the purpose-built anyNA(x)—faster (it short-circuits and builds no intermediate logical vector) and clearer.
The rule fires only on the clean single-argument shape and only when both any and is.na resolve to base R; a local redefinition of either is left alone.
This rule is enabled by default.
Testing for any missing value:
if (any(is.na(x))) stop()
warning: any-is-na
--> example.R:1:5
|
1 | if (any(is.na(x))) stop()
| ^^^^^^^^^^^^^ `any(is.na(x))` is the faster, clearer `anyNA(x)`
= help: Use `anyNA(x)`.
After applying the fix:
if (anyNA(x)) stop()
any-duplicated
Flag any(duplicated(x)), which is the purpose-built anyDuplicated(x) > 0—faster (it short-circuits and builds no intermediate logical vector) and clearer.
The rule fires only on the clean single-argument shape and only when both any and duplicated resolve to base R; a local redefinition of either is left alone. Because the replacement is a comparison, the fix is withheld in a context that binds tighter than a comparison, where the bare rewrite would need parentheses.
This rule is enabled by default.
Testing for any duplicate value:
if (any(duplicated(x))) stop()
warning: any-duplicated
--> example.R:1:5
|
1 | if (any(duplicated(x))) stop()
| ^^^^^^^^^^^^^^^^^^ `any(duplicated(x))` is the faster, clearer `anyDuplicated(x) > 0`
= help: Use `anyDuplicated(x) > 0`.
After applying the fix:
if (anyDuplicated(x) > 0) stop()
coalesce
Flag if (is.null(x)) y else x (and its mirror if (!is.null(x)) x else y), which is the null-coalescing x %||% y—shorter, and it evaluates x once instead of twice.
The rule fires only when is.null resolves to base R; a local redefinition is left alone. The fix is unsafe: %||% needs R >= 4.4 (or rlang), and collapsing the two evaluations of x changes behavior when x has side effects.
This rule is enabled by default.
Falling back to a default when a value is NULL:
y <- if (is.null(x)) default else x
warning: coalesce
--> example.R:1:6
|
1 | y <- if (is.null(x)) default else x
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `if (is.null(x)) y else x` is the null-coalescing `x %||% y`
= help: Use `x %||% y`.
crossprod
Flag t(x) %*% y and x %*% t(y), which are the purpose-built crossprod(x, y) and tcrossprod(x, y)—faster (they go straight to BLAS and never materialize the transpose that t() builds) and clearer.
The rule fires only when one operand is a single-argument t() call and that t resolves to base R; a local redefinition is left alone. When both operands are the same symbol the single-argument form (crossprod(x)) is used.
This rule is enabled by default.
Transposed matrix products:
a <- t(x) %*% y
b <- x %*% t(y)
warning: crossprod
--> example.R:1:6
|
1 | a <- t(x) %*% y
| ^^^^^^^^^^ `t(x) %*% y` is the faster, clearer `crossprod(x, y)`
= help: Use `crossprod(x, y)`.
warning: crossprod
--> example.R:2:6
|
2 | b <- x %*% t(y)
| ^^^^^^^^^^ `x %*% t(y)` is the faster, clearer `tcrossprod(x, y)`
= help: Use `tcrossprod(x, y)`.
After applying the fix:
a <- crossprod(x, y)
b <- tcrossprod(x, y)
lengths
Flag sapply(x, length), which is the purpose-built lengths(x)—faster (a single C pass instead of an R call per element) and clearer. Both return an integer vector and keep x’s names by default.
The rule fires only on the clean two-positional-argument shape and only when sapply resolves to base R and length is not locally rebound; a redefinition of either is left alone.
This rule is enabled by default.
Per-element lengths of a list:
n <- sapply(x, length)
warning: lengths
--> example.R:1:6
|
1 | n <- sapply(x, length)
| ^^^^^^^^^^^^^^^^^ `sapply(x, length)` is the faster, clearer `lengths(x)`
= help: Use `lengths(x)`.
After applying the fix:
n <- lengths(x)
nzchar
Flag comparisons of nchar(x) against zero—nchar(x) > 0, nchar(x) >= 1, nchar(x) != 0, and their mirrored and negated spellings—which are the purpose-built nzchar(x) (or !nzchar(x) for the empty test): faster, and it states the intent directly.
The rule fires only on the clean single-argument shape and only when nchar resolves to base R; a local redefinition is left alone. The fix is unsafe: on NA_character_ input nzchar yields TRUE where the nchar comparison yields NA (exact equivalence would need nzchar(x, keepNA = TRUE)).
This rule is enabled by default.
Testing for non-empty strings:
keep <- x[nchar(x) > 0]
warning: nzchar
--> example.R:1:11
|
1 | keep <- x[nchar(x) > 0]
| ^^^^^^^^^^^^ comparing `nchar()` to zero is the faster, clearer `nzchar()`
= help: Use `nzchar(x)` for non-empty, `!nzchar(x)` for empty.
seq
Flag colon ranges from 1 up to a length—1:length(x), 1:nrow(x), or 1:n—which silently count down when that length is zero (1:0 is c(1L, 0L), not an empty sequence), a classic off-by-one bug for loops over possibly-empty input. seq_along(x) and seq_len(n) return a zero-length sequence instead, and agree with the colon form everywhere else.
The length/nrow/ncol/NROW/NCOL forms fire only when the callee resolves to base R; a redefinition is left alone. Literal ranges (1:10) and computed bounds (1:(n - 1)) are not flagged.
This rule is enabled by default.
Ranges over a vector’s indices and up to a count:
for (i in 1:length(x)) print(x[i])
for (j in 1:n) f(j)
warning: seq
--> example.R:1:11
|
1 | for (i in 1:length(x)) print(x[i])
| ^^^^^^^^^^^ `1:length(x)` counts down (`1:0`) when the length is zero; `seq_along(x)` handles empty input
= help: Use `seq_along(x)`.
warning: seq
--> example.R:2:11
|
2 | for (j in 1:n) f(j)
| ^^^ `1:n` counts down (`1:0`) when the length is zero; `seq_len(n)` handles empty input
= help: Use `seq_len(n)`.
After applying the fix:
for (i in seq_along(x)) print(x[i])
for (j in seq_len(n)) f(j)
class-equals
Flag comparisons of class(x) against a string literal—class(x) == "cls", class(x) != "cls", and the %in% membership spellings—which are inherits(x, "cls") (or its negation): class() returns the whole class vector, so the comparison is elementwise (an error in an if () condition on a multi-class object) and misses subclasses, while inherits() asks the intended question directly without materializing the vector.
The rule fires only on the clean single-argument shape and only when class resolves to base R; a local redefinition is left alone. The fix is unsafe: on a multi-class object the comparison yields an elementwise vector where inherits() yields a scalar, and for S4 objects inherits() follows the formal inheritance chain.
This rule is enabled by default.
Testing an object’s class:
if (class(x) == "factor") levels(x)
warning: class-equals
--> example.R:1:5
|
1 | if (class(x) == "factor") levels(x)
| ^^^^^^^^^^^^^^^^^^^^ comparing `class(x)` to a string is fragile—`class()` returns a vector; `inherits()` asks directly
= help: Use `inherits(x, "cls")` (or `!inherits(x, "cls")`).
fixed-regex
Flag a base-R regex call (grepl, grep, sub, gsub, regexpr, gregexpr, regexec) whose pattern is a plain string literal with no regex metacharacter, and add fixed = TRUE—it skips regex compilation and states that the pattern is a literal.
The rule fires only when the callee resolves to base R and no fixed/ignore.case/perl argument is already present. Because a metacharacter-free pattern matches identically either way, the fix (inserting , fixed = TRUE) is safe.
This rule is enabled by default.
A literal pattern matched as a regex:
grepl("abc", x)
warning: fixed-regex
--> example.R:1:7
|
1 | grepl("abc", x)
| ^^^^^ `grepl()` with a literal pattern should use `fixed = TRUE`
= help: Add `fixed = TRUE`.
After applying the fix:
grepl("abc", x, fixed = TRUE)
sort
Flag sort(x)[1], which sorts the whole vector just to read one extreme — the purpose-built min(x) (or max(x) for decreasing = TRUE) finds it in a single pass and states the intent directly.
The rule fires only on the clean shape — a [1] subset of a sort call with one positional argument and at most a literal decreasing flag — and only when sort resolves to base R; a local redefinition is left alone. The fix is unsafe: sort drops NAs by default while min/max propagate them (exact equivalence would need na.rm = TRUE), and on an empty vector sort(x)[1] is NA while min(x) warns and yields Inf.
This rule is enabled by default.
Reading one extreme off a full sort:
smallest <- sort(x)[1]
largest <- sort(x, decreasing = TRUE)[1]
warning: sort
--> example.R:1:13
|
1 | smallest <- sort(x)[1]
| ^^^^^^^^^^ `sort(x)[1]` sorts everything to read one extreme — use `min(x)`
= help: Use `min(x)`.
warning: sort
--> example.R:2:12
|
2 | largest <- sort(x, decreasing = TRUE)[1]
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `sort(x)[1]` sorts everything to read one extreme — use `max(x)`
= help: Use `max(x)`.
roxygen-unknown-tag
Flag roxygen tags that roxygen2 does not understand.
roxygen2 warns on an unknown tag and drops it from the generated .Rd, so a misspelled tag (@exprot, @parma) silently loses documentation—or worse, an intended @export never reaches the NAMESPACE. Custom tags from extension roclets can be suppressed with # arity-ignore roxygen-unknown-tag.
This rule is enabled by default.
A misspelled @export:
#' Add one
#' @exprot
add_one <- function(x) x + 1
warning: roxygen-unknown-tag
--> example.R:2:4
|
2 | #' @exprot
| ^^^^^^^ `@exprot` is not a tag roxygen2 understands
= help: Check the spelling against the roxygen2 tag index; suppress with `# arity-ignore roxygen-unknown-tag` for extension-roclet tags.
roxygen-title
Flag a documented function whose roxygen block has no title.
The first untagged paragraph (or an explicit @title) becomes the topic title; without one, roxygen2 warns and R CMD check rejects the generated .Rd. An @export with no documentation at all is flagged too—R CMD check reports it as an undocumented export. Blocks that merge into or inherit another topic (@rdname, @describeIn, @inherit*, @template) and @noRd blocks are skipped.
This rule is enabled by default.
A documented, exported function with no title paragraph:
#' @param x A number.
#' @export
add_one <- function(x) x + 1
warning: roxygen-title
--> example.R:1:1
|
1 | #' @param x A number.
| ^^ documentation block has no title
= help: Add a leading prose line (the first paragraph becomes the title) or an explicit `@title`.
roxygen-return
Flag an @exported function documented without @return.
CRAN requires every exported function’s documentation to describe its return value (the .Rd \value section); roxygen2 itself stays silent, so the omission otherwise surfaces only at submission time. @returns is accepted as an alias. @noRd blocks and merged or inherited topics (@rdname, @inherit, …) are skipped.
This rule is enabled by default.
An exported function with no @return:
#' Add one
#' @param x A number.
#' @export
add_one <- function(x) x + 1
warning: roxygen-return
--> example.R:3:4
|
3 | #' @export
| ^^^^^^^ exported function is documented without `@return`
= help: Add `@return` (or `@returns`) describing the value.
roxygen-param
Flag @param documentation that does not match the documented function.
Four shapes are reported: a formal argument with no @param, a @param naming a nonexistent formal (often a rename that never reached the docs), a name documented twice, and a @param missing its name or description. Blocks that inherit or merge documentation (@inheritParams, @rdname, @describeIn, @template) are exempt from the coverage checks; duplicates are always reported.
This rule is enabled by default.
y is undocumented and @param z matches nothing:
#' Add two numbers
#' @param x The first number.
#' @param z The other one.
#' @export
add <- function(x, y) x + y
warning: roxygen-param
--> example.R:3:11
|
3 | #' @param z The other one.
| ^ `@param z` does not match a formal argument of the documented function
= help: Rename it to a formal argument or remove it.
warning: roxygen-param
--> example.R:5:20
|
5 | add <- function(x, y) x + y
| ^ formal argument `y` is not documented with `@param`
= help: Add `@param` for it (or `@inheritParams` a function that documents it).
roxygen-examples
Flag @examples code that does not parse.
R CMD check runs example code, so a syntax error in an @examples section (or an @examplesIf condition) fails the package at check time. The embedded code is reparsed with arity’s own parser and the first syntax error of each snippet is reported at its exact location in the comment. Rd wrappers like \dontrun{} are understood and their contents still checked.
This rule is enabled by default.
An unclosed call in the example:
#' Add one
#' @examples
#' add_one(1
#' @export
add_one <- function(x) x + 1
warning: roxygen-examples
--> example.R:3:11
|
3 | #' add_one(1
| ^ example code does not parse: expected ')' to close function call
= help: `R CMD check` runs example code; fix the syntax error.