Arity
Arity is a language server, formatter, and linter for the R language.
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.6.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 ranges, and call 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 govern file discovery for both format and lint, which share one
file walk.
| Key | Type | Default | Description |
|---|---|---|---|
exclude | array of strings | [] | Additional gitignore-style patterns to skip, resolved relative to the directory containing arity.toml. |
default-exclude | boolean | true | Whether to also apply the built-in default exclude set (below). |
The built-in default exclude set (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. The CLI
flag --exclude <PATTERN> (on format and lint) adds to the configured
exclude for a single run.
exclude = ["vendor/", "*.gen.R"]
default-exclude = true
[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.
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
This document contains the help content for the arity command-line program.
arity
Arity: a language server, formatter, and linter for R
Usage: arity [OPTIONS] <COMMAND>
Subcommands:
parse— Parse and display the CST tree for debuggingformat— Format .R fileslint— Lint .R filesindex— Build or refresh the installed-package introspection indexlsp— Run the language server over stdiocompletions— Generate a shell completion script (write it to stdout)init— Write a starterarity.tomlto the current directory
Options:
-
--config <PATH>— Path to an explicitarity.toml(skips discovery) -
--no-config— Ignore any discoveredarity.tomland use built-in defaults -
--color <WHEN>— When to use color in outputDefault 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 configuredexclude
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 configselect); repeatable or comma-separated -
--ignore <RULE_ID>— Disable these rules (overrides configignore); repeatable or comma-separated -
--exclude <PATTERN>— Additional gitignore-style exclude patterns (repeatable or comma-separated); augments the configuredexclude -
--output <OUTPUT>— Output formatDefault 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)--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 forPossible 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 existingarity.toml
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-code
Suspicious
Readability
Performance
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.
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.
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.
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.
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.
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.
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.
The rule fires only when the terminator is a direct statement of the block (a 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.
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.
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.
= 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)
shadowed-builtin
Flag a local binding whose name is exported by a default R package when that name is later used as a call in the same scope (c <- 1; c(2, 3)). The two-step trigger keeps false positives down.
Shadowing base c() and then calling it:
c <- 1
c(2, 3)
warning: shadowed-builtin
--> example.R:1:1
|
1 | c <- 1
| ^ 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.
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).
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.
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.
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.
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.
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()
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.
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.
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()