Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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


arity v0.23.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, inlay hints, and call and type hierarchy.

Renaming reaches beyond symbols: moving or renaming an .R file, or a folder of them, rewrites the source() paths that referred to it, and rebases the moved files’ own source() paths to their new location.

Beyond the quick fixes attached to lint diagnostics, the server offers a cursor-context refactor that writes a roxygen2 skeleton for the function you are on; see the code action reference.

Configuration is read from an arity.toml discovered from each file’s directory (see the configuration reference).

DESCRIPTION files

The server also serves a package’s DESCRIPTION, which is a different grammar from R. There it offers the packaging diagnostics, completion of package names in Depends, Imports, Suggests, LinkingTo, and Enhances, and hover showing a dependency’s installed version and title. An unsaved edit counts immediately, so adding a package to Imports clears the undeclared-dependency findings in the R files that use it without saving first.

The installed version is also shown inline, as an inlay hint after each dependency:

Imports:
    dplyr (>= 1.0.0) 1.1.4,
    rlang 1.1.4,

Only indexed packages get one, so a dependency you have not installed stays bare. Arity has no setting of its own for these — your editor’s inlay hint switch (editor.inlayHints.enabled in VS Code) turns them off.

Diagnostics are reported only for a DESCRIPTION at a package root of its own, matching what arity lint walks. A complete miniature package under tests/testthat/ is fixture data for a test, so it stays quiet.

A DESCRIPTION is formatted too, by default, so format-on-save canonicalizes it the same way it canonicalizes your .R files. Set description = false under [format] in arity.toml to leave it alone.

Only whole-document formatting is offered: canonical field order is a property of the whole file, so editor.formatOnSaveMode: "modifications" (and format-selection generally) will not touch a DESCRIPTION.

Editors need to be told to send the file, since most do not recognize DESCRIPTION on their own. The VS Code extension does this for you; for the rest, see the sections below.

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.

Using only some features

The formatter, linter, and language features share one server but can be turned off independently, so you can adopt just the parts you want:

  • arity.formatting.enable — use arity as a formatter.
  • arity.diagnostics.enable — show arity diagnostics (the linter).
  • arity.languageFeatures.enable — hover, completion, navigation, symbols, rename, code actions, semantic tokens, and the rest.

All three default to true. They are client-side gates, so the server keeps running and the toggles take effect without a restart or reinstall. For a formatter-only setup, turn off the other two:

{
  "arity.diagnostics.enable": false,
  "arity.languageFeatures.enable": false
}

Turning off arity.diagnostics.enable this way suppresses every diagnostic, including the syntax/parse errors that an arity.toml [lint] selection cannot silence. The arity.toml route stays the right tool when you want to keep parse errors but mute specific lint rules across every editor and the CLI.

Zed

Arity attaches to Zed’s R language, which the R extension provides. Install that one first, then install Arity from the extensions view (zed: extensions in the command palette).

Zed uses the arity on your PATH when there is one, and otherwise downloads the release binary matching your platform. Keeping Arity on the PATH is the better option on distributions that cannot run the generic release build, NixOS above all.

The R extension also ships r_language_server. Zed runs both servers unless you say otherwise, so name the ones you want and put Arity first when it should handle formatting, in settings.json:

{
  "languages": {
    "R": {
      "language_servers": ["arity-language-server", "r_language_server"],
      "formatter": "language_server",
      "format_on_save": "on"
    }
  }
}

To run Arity alone, drop "r_language_server" from the list.

Editor settings go under the server’s id and act as fallbacks when the project has no arity.toml:

{
  "lsp": {
    "arity-language-server": {
      "settings": {
        "lineWidth": 100,
        "indentWidth": 2
      }
    }
  }
}

An arity.toml in the project is authoritative, so prefer the file when the whole team should share the behavior.

To point Zed at a particular binary, set binary.path:

{
  "lsp": {
    "arity-language-server": {
      "binary": { "path": "/opt/arity/bin/arity", "arguments": ["lsp"] }
    }
  }
}

arguments replaces the command line rather than extending it, so it has to keep naming a subcommand that speaks LSP.

The R extension recognizes files by .r or .R suffix. It does not assign its language to DESCRIPTION, so the Arity extension cannot attach to those files until Zed’s R language definition covers them.

Neovim

With nvim-lspconfig installed, register arity as a server for R files:

-- Neovim ships no filetype for DESCRIPTION, so give it one. The name is yours
-- to choose: arity routes on the file name, not on what the client calls it.
vim.filetype.add({ filename = { DESCRIPTION = "r-description" } })

vim.lsp.config("arity", {
  cmd = { "arity", "lsp" },
  filetypes = { "r", "r-description" },
  root_markers = { "arity.toml", "DESCRIPTION", ".git" },
})
vim.lsp.enable("arity")

Drop the vim.filetype.add line and the second entry in filetypes if you only want arity on .R files.

Format on save (optional):

vim.api.nvim_create_autocmd("BufWritePre", {
  pattern = { "*.R", "DESCRIPTION" },
  callback = function() vim.lsp.buf.format() end,
})

The DESCRIPTION entry matters: without it, the file is attached to the server and linted but never formatted on save.

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.

To get the DESCRIPTION features too, add the file to whatever the client uses to decide which documents to send. Arity decides the grammar from the file name, so the language id the client reports does not matter, and one that says r is still handled as DCF.

Integrations

Beyond running arity directly, several integrations wire it into version control, CI, and other tooling. Each installs a prebuilt binary, so none of them need a Rust toolchain or an R installation.

For editor and language-server setup, see Editor Setup instead.

GitHub Actions

arity-action installs arity and runs the format and lint checks in CI:

jobs:
  arity:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - uses: jolars/arity-action@v1

By default this runs both arity format --check and arity lint over the whole repository. The inputs:

InputDefaultDescription
path.File or directory to check
versionlatestVersion to install (latest or vX.Y.Z)
formattrueRun arity format --check
linttrueRun arity lint
config(none)Path to an arity.toml to use
verify-checksumtrueVerify the downloaded asset against its published hash

It also exposes the installed version as the version output. To run only one of the two checks, turn the other off:

- uses: jolars/arity-action@v1
  with:
    path: R
    lint: false

Resolving latest picks the newest release that actually carries an asset for the runner’s platform, rather than the newest release outright, so a release whose binaries are still uploading does not break the job.

pre-commit

arity-pre-commit provides pre-commit hooks. It installs a prebuilt binary wheel from PyPI.

repos:
  - repo: https://github.com/jolars/arity-pre-commit
    # tracks the arity release it installs
    rev: v0.15.0
    hooks:
      # Lint .R files
      - id: arity-lint
      # Format the same files in place
      - id: arity-format

To apply safe autofixes as part of linting, pass the flag through:

      - id: arity-lint
        args: [--fix]

Both hooks run with --force-exclude. pre-commit passes staged files as explicit arguments, and files named explicitly are normally always processed; the flag applies the exclude patterns from your arity.toml to them anyway, so a staged file you have excluded stays excluded. See Configuration for those patterns.

mise-en-place

Arity is in the aqua registry as jolars/arity, so mise can install it through its aqua backend:

mise use aqua:jolars/arity

Or in mise.toml:

[tools]
"aqua:jolars/arity" = "latest"

Replace latest with a released version to pin it.

The same registry entry works with aqua directly:

aqua g -i jolars/arity

dprint

dprint-plugin-arity is a dprint plugin that runs the arity formatter (not the linter) inside dprint, so R files are formatted alongside the rest of a project’s languages. Add it with:

dprint config add jolars/arity

That writes a versioned, checksummed entry into your dprint.json:

{
  "arity": {},
  "plugins": [
    "https://plugins.dprint.dev/jolars/arity-x.x.x.wasm@<checksum>"
  ]
}

Configure it under the arity key:

KeyValuesDefault
lineWidthintegerdprint global, else 80
indentWidthintegerdprint global, else 2
lineEndingauto, lf, crlf, nativefrom global newLineKind
roxygenMarkdownbooleanfalse

These mirror the [format] keys in arity.toml, and the plugin’s output is byte-identical to arity format for equivalent settings.

Note that the plugin reads its configuration from dprint.json, not from arity.toml. One setting has no equivalent on the dprint side: the arity CLI discovers whether roxygen comments are markdown by default by reading the package’s DESCRIPTION, but a dprint plugin is a WebAssembly module with no filesystem access and cannot. So a package whose DESCRIPTION sets Roxygen: list(markdown = TRUE) needs roxygenMarkdown set explicitly:

{
  "arity": { "roxygenMarkdown": true }
}

Per-block @md and @noMd tags still take precedence over that default, exactly as they do in the CLI.

Performance

Wall-clock speed of arity against other R tooling, measured with hyperfine. Two operations are covered:

  • the formatter, compared against air and styler;
  • the linter, compared against jarl and lintr.

Each operation is measured at two scopes: single files (the largest source file of each benchmarked package, plus two synthetic corpus tiers) and whole projects (real R packages). arity is the baseline in every chart, and every other tool’s time is reported relative to it.

The tools also pay very different startup floors: styler and lintr run inside an R process, so a large part of their 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):

ToolInvocation
arityarity format / arity lint FILE
airair format --stdin-file-path bench.R
stylerRscript -e 'styler::style_text(readLines(file("stdin")))'
jarljarl check FILE
lintrRscript -e 'lintr::lint(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:

ToolInvocation
arityarity format --check R/ / arity lint R/
airair format --check R/
jarljarl check R/
lintrRscript -e 'lintr::lint_dir("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 it from the linter charts. 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.

The R-backed tools need two caveats. styler and lintr pay an interpreter startup floor plus a steep per-line cost, so they are skipped on documents above 20,000 lines to keep a run tractable; styler is additionally absent from the project charts, because style_dir would rewrite the checkout and it has no check-only directory mode. styler’s persistent on-disk cache of already-styled expressions is deactivated for the measurement, so that every run does the full work rather than inheriting whatever an earlier run happened to style. Because the tools do such different work, this is a rough scale comparison, not a like-for-like one. Set ARITY_BENCH_NO_R=1 to leave both out of a run.

Corpus

Single files mix real and synthetic input. The real documents are the largest source file of each benchmarked package, which is the closest stand-in for the per-file work an editor asks of a formatter or linter. The synthetic tiers are built by concatenating every formatter fixture’s expected.R (crates/arity-formatter/tests/fixtures/formatter/*/expected.R, sorted, blank-line separated) into a base block and repeating it to two sizes. That content repeats, so it is cache-friendly and not representative of real code; it exists to amortize process startup and show rough scaling.

Projects use real R packages: the tidyr and MASS source trees, cloned once at pinned tags into a local cache. The two are deliberately unalike—tidyr is modern tidyverse code, MASS is long-lived base-R-style code. Point the benchmark at your own checkout with ARITY_BENCH_PROJECT=/path/to/pkg task bench, which replaces the list entirely; only a package’s R/ directory is measured.

Setup

  • arity: 0.21.0
  • air: 0.11.0
  • jarl: 0.5.0
  • lintr: 3.3.0.1
  • styler: 1.11.0
  • backend: hyperfine (min runs: 3)
  • host: linux/x86_64, AMD Ryzen 9 7900 12-Core Processor
  • generated: 2026-08-28T17:58:10Z

Results

Each operation gets its own section below, split into single files and whole projects. 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

Formatting speed on single files relative to arity, one dot per document: the largest source file of each benchmarked package, then two synthetic corpus tiers. The vertical axis is mean wall-clock time as a ratio to arity on a log scale, so arity lies on the dashed baseline at 1; faster tools fall below it and slower tools rise above. Hover a dot for the exact figures.
Data table
MASS/polr.R (19787 bytes, 534 lines)
ToolMean (ms)Min (ms)Max (ms)Relative
arity4.47273.75547.2952baseline
air7.58677.11959.27031.7x slower
styler2747.71552739.86822755.7076614.3x slower
tidyr/pivot-wide.R (23349 bytes, 807 lines)
ToolMean (ms)Min (ms)Max (ms)Relative
arity3.75803.16985.3659baseline
air4.82624.49246.25191.3x slower
styler1537.29931528.82651547.9296409.1x slower
small (145964 bytes, 9792 lines)
ToolMean (ms)Min (ms)Max (ms)Relative
arity5.24574.42896.7872baseline
air26.799625.193930.31705.1x slower
styler11905.787711841.760812009.14552269.6x slower
large (1751568 bytes, 117504 lines)
ToolMean (ms)Min (ms)Max (ms)Relative
arity38.232736.131341.1023baseline
air316.8726312.6459320.75438.3x slower

Projects

Formatting speed on real R packages (the tidyr and MASS source trees) relative to arity, on the same log-ratio axis.
Data table
tidyr (245685 bytes, 8774 lines)
ToolMean (ms)Min (ms)Max (ms)Relative
arity23.781721.599427.4288baseline
air34.747732.746737.03141.5x slower
MASS (214820 bytes, 5951 lines)
ToolMean (ms)Min (ms)Max (ms)Relative
arity34.612732.556637.1117baseline
air60.616858.774963.52481.8x slower

Linter

Single files

Linting speed on single files relative to arity, one dot per document, on the same log-ratio axis as the formatter charts.
Data table
MASS/polr.R (19787 bytes, 534 lines)
ToolMean (ms)Min (ms)Max (ms)Relative
arity33.418430.979737.2951baseline
jarl12.440211.517013.84982.7x faster
lintr981.4801968.3394996.243129.4x slower
tidyr/pivot-wide.R (23349 bytes, 807 lines)
ToolMean (ms)Min (ms)Max (ms)Relative
arity31.468229.423633.4072baseline
jarl9.97559.309511.22493.2x faster
lintr808.5469800.5652817.604925.7x slower
small (145964 bytes, 9792 lines)
ToolMean (ms)Min (ms)Max (ms)Relative
arity30.667029.175533.1071baseline
jarl29.158227.941031.97011.1x faster
lintr11104.260911030.417811166.3917362.1x slower
large (1751568 bytes, 117504 lines)
ToolMean (ms)Min (ms)Max (ms)Relative
arity648.8999641.3281658.0069baseline
jarl447.2692430.7405485.41091.5x faster

Projects

Linting speed on real R packages (the tidyr and MASS source trees) relative to arity, on the same log-ratio axis.
Data table
tidyr (245685 bytes, 8774 lines)
ToolMean (ms)Min (ms)Max (ms)Relative
arity33.287730.893535.6605baseline
jarl12.994011.691515.63602.6x faster
lintr8109.78228032.93008242.4821243.6x slower
MASS (214820 bytes, 5951 lines)
ToolMean (ms)Min (ms)Max (ms)Relative
arity35.588432.932138.9722baseline
jarl17.653015.977121.07462.0x faster
lintr8419.41928410.04748434.6231236.6x slower

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.

Editor support

Arity publishes a JSON Schema for arity.toml, so editors with TOML support can offer completion, inline documentation, and validation while you edit your configuration.

Schema URL : https://arity.cc/arity.schema.json

The schema is generated from Arity’s configuration types and checked against the repository’s own arity.toml. It therefore stays aligned with the keys, enums, constraints, and defaults accepted by the CLI. The version at the published URL tracks the latest Arity release.

Until arity.toml is registered with SchemaStore, editors need a manual association. With the Even Better TOML extension for VS Code, add this to your user or workspace settings.json:

{
  "evenBetterToml.schema.associations": {
    "^(.*/)?arity\\.toml$": "https://arity.cc/arity.schema.json"
  }
}

Editors and language servers that recognize TOML schema directives can instead put this comment at the top of arity.toml:

#:schema https://arity.cc/arity.schema.json

Other editors—including Neovim with taplo-lsp, Helix, Zed, and IntelliJ—can use the same URL through their JSON Schema association settings.

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-config ignores 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).

KeyTypeDefaultDescription
excludearray of stringsbuilt-in setgitignore-style patterns to skip, resolved relative to the directory containing arity.toml. Setting it replaces the built-in set (below).
extend-excludearray of strings[]Like exclude, but added to exclude rather than replacing it. Use this to skip extra paths while keeping the built-in defaults.
cachebooleantrueEnable 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]

KeyTypeDefaultDescription
line-widthinteger (1–1000)80The width the formatter tries to keep lines within. Not a hard cap.
indent-widthinteger (1–1000)2Number of spaces per indentation level.
line-endingstring"auto"Newline style: "auto", "lf", "crlf", or "native" (see below).
descriptionbooleantrueWhether a package DESCRIPTION is formatted.

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"
description = true

line-width and indent-width can be overridden per run with the --line-width/--indent-width flags on arity format. They apply to DESCRIPTION too, except that its continuation indent is always four spaces — the file format’s convention, and a different axis from R-code nesting.

Setting description = false leaves DESCRIPTION alone, in the CLI (including a buffer piped in under --stdin-filename DESCRIPTION, which is passed through untouched) and in the editor. Reach for it if arity and your package tooling end up disagreeing about the file. Note that extend-exclude is not a substitute: excludes are shared with the linter, so excluding DESCRIPTION would also silence the packaging rules, and the language server applies no exclude filter to formatting at all.

[lint]

KeyTypeDefaultDescription
selectarray of stringsunsetIf set, only these rule IDs run.
ignorearray 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"]

[lint.rules.<id>]

A few rules take options of their own, set in a table named after the rule ID. Rules that take no options have no table.

Unlike select/ignore—where rule IDs are data, checked when linting runs—a rule ID here is part of the schema, so a mistyped one is reported when the config is parsed, alongside any other unknown key.

[lint.rules.undesirable-function]

The function-name policy for undesirable-function.

KeyTypeDefaultDescription
functionstable of name → hintbuilt-in setFlagged functions. Replaces the built-in set.
extend-functionstable of name → hint{}Entries added on top of functions, overriding same-named ones.

The value is the advice shown as the diagnostic’s suggestion; an empty string means “no alternative, just don’t call this”. The functions/extend-functions split works like exclude/extend-exclude: reach for extend-functions unless you really mean to discard the built-in set. Setting functions = {} silences the rule entirely.

The built-in set covers base-R functions that mutate global state (attach, detach, .libPaths, install.packages, setwd, sink, source, options, par, Sys.setenv, Sys.setlocale) and the debugging entry points (debug, debugonce, undebug, trace, untrace). browser() is deliberately absent: it has its own browser rule.

[lint]
select = ["undesirable-function"]

[lint.rules.undesirable-function]
extend-functions = { sapply = "use `vapply()` for a stable return type" }

[compat]

The minimum tool versions the project supports, in the spirit of clippy’s msrv and ruff’s target-version. Consumed by the version-aware lint rules (r-compat, roxygen2-compat): syntax or documentation constructs that need a newer version than the declared floor are flagged.

KeyTypeDefaultDescription
rversion stringunsetMinimum supported R version, e.g. "4.1".
roxygen2version stringunsetThe roxygen2 version the project documents with, e.g. "7.3.2".

Values are plain version strings—the key is the >= floor, so there is no operator syntax.

When a key is unset, the floor is derived per file from the enclosing package’s DESCRIPTION: Depends: R (>= …) supplies r, and Config/roxygen2/version (written by roxygen2 8.0.0 and later) or the legacy RoxygenNote supplies roxygen2. An explicit key here always wins. With neither a key nor a DESCRIPTION fact, the version-aware rules stay silent, so loose scripts see no false positives.

[compat]
r = "4.1"
roxygen2 = "7.3.2"

[index]

Controls the R-package symbol index used by the language server (and by namespace-aware lint rules) to resolve names.

KeyTypeDefaultDescription
library-pathsarray of paths[]Explicit R library directories, used when automatic discovery misses.
cache-dirpathunsetOverride the index cache directory (otherwise XDG/$ARITY_CACHE_DIR).
auto-buildbooleantrueLet the language server lazily index referenced-but-unindexed packages.
helpbooleantrueHarvest 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_URL environment variable, never committed in a shared arity.toml.

Note: the same applies to the attach probe (arity index --attach-probe), which observes what a meta-package attaches by running library() in a fresh R session. Because that executes package attach hooks, it is enabled per run by the flag or per user via the ARITY_ATTACH_PROBE environment variable, never from arity.toml. Without it, attach sets are still captured for packages following the tidyverse core convention, 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].skip and a # fmt: skip comment—opt specific calls out of formatting.
  • severity in a [lint.rules.<id>] table—overriding a rule’s severity.
  • Category names (e.g. "correctness") in select/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.toml and use built-in defaults

--color <WHEN>

When to use color in output

Default value: auto

Possible values:

  • auto: Colorize when writing to a terminal and NO_COLOR is unset (default)
  • always: Always colorize
  • never: Never colorize
-q, --quiet

Suppress informational output (errors are still shown); under format --check this drops the per-file diff, leaving the list of files that would be reformatted and the summary

-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. Pass - for stdin, also read when the path is omitted and stdin is not a terminal

Options

--quiet
Suppress CST output to stdout
--verify
Verify parser losslessness (input must equal CST text)

arity format

Format R files and package DESCRIPTIONs

Usage: arity format [OPTIONS] [PATH]...

Arguments

<PATH>...
Input file(s) or director(ies). Pass - for stdin, also read when paths are omitted and stdin is not a terminal

Options

--stdin-filename <PATH>
Filename the stdin buffer stands for; decides which grammar it is formatted as. Without it, stdin is formatted as R
--verify
Verify idempotence and R syntax/comment preservation (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. Requires path arguments: there is no file on disk to report on when reading stdin
--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 given -, or when paths are omitted and stdin is not a terminal. 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 director(ies). Pass - for stdin, also read when paths are omitted and stdin is not a terminal

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: pretty

Possible 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 setting ARITY_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

Code actions

Code actions are the editor’s “do something here” menu, served by arity lsp over textDocument/codeAction for the cursor position or selection. Arity offers two families: quick fixes, which come from a lint finding, and refactors, which are computed from the code under the cursor and need no diagnostic.

In VS Code and Positron both families are gated by arity.languageFeatures.enable (see Editor Setup).

Quick fixes

Every lint finding that carries a fix is offered as a quickfix action when the cursor or selection overlaps the finding’s range; a zero-width cursor touching the edge of the range counts as overlapping. The action’s title is the fix’s own description, and it is attached to the diagnostic, so clients that fix from the lightbulb on a squiggle find it there.

Both safe and unsafe fixes appear. On the command line an unsafe fix is applied only with arity lint --fix --unsafe-fixes, because the CLI edits in bulk; in the editor you are approving one edit at a time with the diff in front of you, so the distinction stops carrying its weight. Which rules have a fix, and whether it is safe, is recorded per rule in the lint rule reference.

A fix is a textual edit and does not owe you layout: it may leave a line the formatter would break differently, because layout is the formatter’s job. The intended sequence is fix, then format.

Refactors

Add/Update roxygen documentation

A refactor action that generates or extends the roxygen2 block for the function under the cursor. It is offered when the cursor sits anywhere in a function bound by a simple assignment (name <- function(...)), including inside the body. The function must be the direct value of the assignment, so a function nested in a call on the right-hand side does not qualify.

Add — when no roxygen block immediately precedes the function, insert a skeleton above it: a title placeholder, one @param per formal in declaration order, and @return, at the statement’s own indentation. A blank line between a block and the function detaches it, following roxygen2’s own rule, so a detached block counts as no block.

add <- function(x, y = 1) {
  x + y
}

becomes

#' Title
#'
#' @param x
#' @param y
#'
#' @return
add <- function(x, y = 1) {
  x + y
}

Update — when a block is already attached but some formals are undocumented, insert only the missing @param lines, in formal order, after the last existing @param (or after the introductory prose if there is no @param yet):

#' Add two numbers
#'
#' @param x A number.
add <- function(x, y = 1) {
  x + y
}

becomes

#' Add two numbers
#'
#' @param x A number.
#' @param y
add <- function(x, y = 1) {
  x + y
}

The action is non-destructive: existing prose and tags are never rewritten, reordered, or removed. Nothing is offered when every formal is already documented. Descriptions are left empty for you to fill in; arity does not invent documentation text.

Directives

A directive is an ordinary # comment that tells arity to stand down. One grammar covers the formatter, the linter, and both at once:

# arity[-format|-lint] <verb> [<rule>][: <reason>]

The verb says how far it reaches, the prefix says who it addresses:

formatterlinterboth
next# arity-format skip: why# arity-lint skip <rule>: why# arity skip: why
from# arity-format off# arity-lint off <rule># arity off
to# arity-format on# arity-lint on# arity on
file# arity-format skip-file: why# arity-lint skip-file <rule>: why# arity skip-file: why

Only a lint directive names a rule. # arity-format has no lint half to scope, and # arity covers every rule by construction.

skip — the next statement

Applies to the next non-trivia sibling: the next piece of code after the comment, whatever that is. The attachment skips blank lines and other comments, so the directive can sit above a block of documentation and still land on the code below it.

# arity-lint skip unused-binding: part of the documented API
config <- list(width = 80)

A trailing comment attaches the same way — to the code that follows it, not the code on its own line:

x <- 1 # arity-lint skip browser: applies to the NEXT statement, not this one
browser()

For the formatter, skip hands the marked statement back byte for byte — its own column, its interior alignment, its blank lines. Nothing about its layout is decided, which is the whole point:

# arity-format skip: the rows are the matrix
m <- matrix(c(1, 0,
              0, 1), nrow = 2)

offon — a region

Everything between the two markers, or to the end of the file if the on never comes. The markers themselves are ordinary comments and are formatted normally.

# arity-format off: generated by tools/codegen.R
lookup <- c(
  "a" = 1,
    "bb"  = 22
)
# arity-format on

An on closes every region opened with the same prefix. # arity off and # arity-format off are separate regions, and one does not close the other; an on that closes nothing is reported by misplaced-suppression.

skip-file — the whole file

For a generated or vendored file. # arity-format skip-file makes arity format hand the file back unchanged, so --check reports it clean.

# arity-lint skip-file unused-binding: generated by tools/codegen.R

Every rule at once

Writing a : where the rule ID would go widens a lint directive to every rule, including every rule arity ships in the future:

# arity-lint skip-file: generated, do not lint

The # arity column does the same by construction. Both are rarely what you want at file or region scope; prefer the rule-scoped form, which blanket-suppression will not flag.

In DESCRIPTION

The lint directives work in a DESCRIPTION too, on a line of their own:

Package: mypkg
# arity-lint skip unused-dependency: loaded reflectively by the plugin registry
Imports: somepkg

DCF has no trailing comments — a # in the middle of a value is part of that value — so a directive must start at column zero, and one written after a field applies to the field that follows it. A # line between the continuation lines of a single field is the exception: R’s read.dcf skips it and resumes the value, so a directive there covers the whole field it interrupts.

Of the formatter directives, only # arity-format skip-file is honored there.

Reasons

The text after the : is free-form and arity never interprets it — but telling a tool to stand down is a standing claim that it is wrong here, and that claim outlives whoever wrote it. Recording why keeps the next reader from having to guess whether it was considered or expedient. unexplained-suppression enforces the convention; it is off by default, so enable it with select.

Directives are linted too

Directives fail silently by nature: when one goes wrong, the symptom is that nothing happens, which is exactly what success looks like. Six meta rules make those failures visible:

RuleFlags
misnamed-suppressiona rule ID or a verb that does not exist
misplaced-suppressiona directive that can never take effect
blanket-suppressiona directive that names no rule
unexplained-suppressiona directive with no reason (off by default)
outdated-suppressiona directive that no longer silences anything
deprecated-suppressionone of the spellings arity shipped with

They read the directives of .R files only; a directive in a DESCRIPTION is honored but not linted.

Limits

  • One rule per directive. There is no comma-separated list: # arity-lint skip a, b reads the rule ID as a, and suppresses neither. Write a separate comment per rule. misnamed-suppression catches the mistake.
  • The formatter acts on whole statements. A # arity-format directive is honored at the top level and in a block body — the places where whole lines can be handed back untouched. Between two call arguments it is inert; misplaced-suppression says so. A lint directive has no such limit: it attaches by node.
  • A format region does not leave its block. An unclosed # arity-format off inside { ... } ends at the closing brace. A lint region is a byte range and runs on until on or end of file.
  • Syntax errors cannot be suppressed. A file that does not parse is reported before any rule runs, so # arity-lint skip syntax-error has no effect (and is itself flagged as a misnamed rule).
  • meta findings need the file-wide form. A finding about a directive is spanned on a comment, and a skip attaches past comments to the next piece of code — so it can never land on the directive above it. Use # arity-lint skip-file <meta-rule>: <reason>, or turn the rule off in configuration with [lint] ignore.
  • A bare # arity needs a verb. # arity is great is prose, not a typo’d directive, so it is left alone rather than reported. The prefixed forms are unambiguous and are checked.
  • Roxygen lines are not directives. #' arity-lint … is documentation content, not a directive.

Deprecated spellings

# arity-ignore <rule>: <reason> and # arity-ignore-file <rule>: <reason> are what the linter shipped with. They still work, and mean exactly # arity-lint skip and # arity-lint skip-file — but they are deprecated, and deprecated-suppression flags them with a safe autofix, so arity lint --fix migrates a codebase in one pass:

arity lint --fix --select deprecated-suppression .

Mixing the two (# arity-ignore skip <rule>) is an error, and misnamed-suppression reports it.

Turning a rule off entirely

For a rule you never want, arity.toml is the better tool than a comment in every file:

[lint]
ignore = ["unused-binding"]

See Configuration.

Lint rules

arity lint runs a set of built-in rules over each file and reports a finding for every match. This page is the catalogue: one section per rule, keyed by its stable rule ID. That ID is what a finding reports, what select/ignore target in the [lint] table, and what an # arity-lint skip comment names (see Directives).

Where a rewrite is unambiguous a rule carries an autofix. A safe fix (shown below as “After applying the fix”) is applied by arity lint --fix; an unsafe one is applied only with --unsafe-fixes or as an editor code action, so it has no “after” block here. A fix is a textual edit and never lays code out, so the intended pipeline is fix-then-format.

Every example below is linted live to produce its diagnostic and fixed output, so this page never drifts from the rules’ actual behavior.

Correctness

Suspicious

Readability

Performance

Documentation

Packaging

Meta

Correctness

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. In an analyzed package, a package-local call argument is checked only when its matched formal is proven to evaluate the promise normally; capture, opaque forwarding, and ambiguous behavior stay silent.

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. A function bound to a generic.class name is exempt too: S3 dispatch reaches a method without reading its name, registered in the NAMESPACE or not.

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)

equals-nan

Flag ==, !=, and %in% comparisons with NaN; use is.nan() to test for NaN values.

This rule is enabled by default.

Comparing a value with NaN:

if (x == NaN) handle_nan()
warning: equals-nan
 --> example.R:1:5
  |
1 | if (x == NaN) handle_nan()
  |     ^^^^^^^^ comparison with `NaN` does not reliably test for NaN values
  = help: Use `is.nan(x)`.

After applying the fix:

if (is.nan(x)) handle_nan()

equals-null

Flag ==, !=, and %in% comparisons with NULL; use is.null() for a scalar null test.

This rule is enabled by default.

Comparing a value with NULL:

if (x == NULL) handle_null()
warning: equals-null
 --> example.R:1:5
  |
1 | if (x == NULL) handle_null()
  |     ^^^^^^^^^ comparison with `NULL` does not produce a scalar null test
  = help: Use `is.null(x)`.

After applying the fix:

if (is.null(x)) handle_null()

missing-argument

Flag an empty, non-trailing call argument such as f(a, , b). Trailing commas and intentional missing function-formal defaults are excluded.

This rule is enabled by default.

The second call argument is missing:

paste("a", , "b")
warning: missing-argument
 --> example.R:1:12
  |
1 | paste("a", , "b")
  |            ^ call contains an empty argument before this comma
  = help: Supply the intended argument explicitly.

rep-times-ignored

Flag a base rep() call that supplies both times and length.out. length.out normally determines the result length, making times ineffective. There is no autofix because times can still matter when length.out is invalid or NA.

This rule is enabled by default.

length.out normally overrides times:

rep(x, times = 2, length.out = 10)
warning: rep-times-ignored
 --> example.R:1:8
  |
1 | rep(x, times = 2, length.out = 10)
  |        ^^^^^ `times` is normally ignored when `length.out` is supplied
  = help: Remove `times` after confirming `length.out` is always valid.

sprintf

Validate literal formats passed to base sprintf(): flag invalid conversions, definitely missing or excess arguments, and a call whose format contains no fields. The literal-only case has a safe autofix.

This rule is enabled by default.

A literal format with no fields needs no sprintf() call:

label <- sprintf("ready: 100%%")
warning: sprintf
 --> example.R:1:10
  |
1 | label <- sprintf("ready: 100%%")
  |          ^^^^^^^^^^^^^^^^^^^^^^^ `sprintf()` is pointless because the format has no fields
  = help: Use the string literal directly.

After applying the fix:

label <- "ready: 100%"

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.

download-file

Flag a download.file() call whose mode is not portable.

The default mode = "w" is text mode: on Windows it translates line endings, corrupting any binary payload, while the same call works on Unix. R recommends mode = "wb" (or "ab" to append), so the rule reports an omitted mode, an explicit mode = "w" / "a", and a mode supplied next to method = "curl" / "wget" (which shell out and ignore it).

Arguments are matched the way R matches them, so a positional or partially-named method/mode is understood. The callee must resolve to base R, and a mode/method that is not a string literal is skipped rather than guessed at. There is no autofix: the shapes need an argument inserted or deleted, not rewritten.

This rule is enabled by default.

Relying on the default mode = "w", which corrupts a binary download on Windows:

download.file(url, destfile)
warning: download-file
 --> example.R:1:1
  |
1 | download.file(url, destfile)
  | ^^^^^^^^^^^^^ `download.file()` relies on the default `mode = "w"`, which corrupts binary downloads on Windows
  = help: Pass `mode = "wb"` (or `mode = "ab"` to append).

mode is ignored by method = "curl" and method = "wget":

download.file(url, destfile, method = "curl", mode = "wb")
warning: download-file
 --> example.R:1:47
  |
1 | download.file(url, destfile, method = "curl", mode = "wb")
  |                                               ^^^^^^^^^^^ `mode` is ignored by `download.file(method = "curl")`
  = help: Drop the `mode` argument, or use a `method` that honors it (`"curl"` shells out to an external downloader).

internal-function

Flag pkg:::name, which reaches past a package’s namespace to an object it never exported.

An unexported object is not part of the package’s interface: it carries no compatibility promise, no documentation, and no deprecation cycle, so it can be renamed, reshaped, or removed in any patch release—and the breakage surfaces only at run time. CRAN’s policy bars a package from using ::: on a package in its own Depends/Imports/Suggests for the same reason.

The exported form pkg::name is never flagged, and neither is a package reaching into its own internals: mypkg:::helper inside mypkg is a redundant qualifier in R/ and the idiomatic way to unit-test an unexported function in tests/, so the rule reads the enclosing package’s name from DESCRIPTION and skips a self-reference.

There is no autofix: the repair is to find an exported equivalent, vendor the implementation, or ask upstream to export it—rewriting ::: to :: would only turn a working call into a load-time error.

This rule is enabled by default.

Calling an unexported function through ::::

utils:::.getHelpFile(path)
warning: internal-function
 --> example.R:1:1
  |
1 | utils:::.getHelpFile(path)
  | ^^^^^^^^^^^^^^^^^^^^ `utils:::.getHelpFile` uses an unexported object, which may change or disappear without notice
  = help: Use an exported function from `utils`, or ask upstream to export `.getHelpFile`.

r-compat

Flag syntax newer than the project’s minimum supported R version.

The floor comes from [compat] r in arity.toml, or from Depends: R (>= …) in the package DESCRIPTION when the key is unset; with neither, the rule stays silent. Raw strings (r"(…)") need R 4.0.0, the native pipe |> and the lambda shorthand \(x) need 4.1.0, and the pipe placeholder _ needs 4.2.0 — on an older R, each is a syntax error. Only the lambda carries a fix (function(x) is its exact meaning); the pipe and raw strings have no drop-in textual equivalent, so those findings are report-only.

This rule is enabled by default.

The native pipe under a declared R (>= 4.0) floor (r = "4.0" under [compat] in arity.toml):

y <- c(1, 2) |> sum()
print(y)
warning: r-compat
 --> example.R:1:14
  |
1 | y <- c(1, 2) |> sum()
  |              ^^ the native pipe `|>` requires R >= 4.1.0, but this project supports R >= 4.0
  = help: Raise the floor (`[compat] r` in `arity.toml`, or `Depends: R (>= …)` in `DESCRIPTION`) or rewrite with older syntax.

Suspicious

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 can usually be written as x, and x == FALSE as !x. The fix is unsafe because equality coerces non-logical operands while the direct form does not.

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.

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

all-equal

Flag all.equal() used directly as a condition, negated, or passed to isFALSE(). A disagreement returns a character vector rather than FALSE, so these forms do not reliably test equality. Use isTRUE(all.equal(...)) instead. Only base-R callees are flagged. The fix is unsafe because it deliberately changes existing behavior.

This rule is enabled by default.

Testing the return value of all.equal() directly:

if (all.equal(actual, expected)) pass()
warning: all-equal
 --> example.R:1:5
  |
1 | if (all.equal(actual, expected)) pass()
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^ `all.equal()` does not return `FALSE` for unequal objects
  = help: Use `isTRUE(all.equal(...))` to test equality.

pipe-return

Flag base return used directly on the right-hand side of the magrittr pipe %>%, whether written as return() or a bare name. It returns from the pipe stage rather than from the surrounding function, so the apparent early return is misleading. Wrap the whole pipeline in return(), or assign its result and return that value. No fix is offered because the intended control flow cannot be inferred. A locally redefined %>% is left alone.

This rule is enabled by default.

A return() stage does not exit the surrounding function:

f <- function(x) {
  x %>% sum() %>% return()
  FALSE
}
warning: pipe-return
 --> example.R:2:19
  |
2 |   x %>% sum() %>% return()
  |                   ^^^^^^^^ `return` after `%>%` does not exit the surrounding function
  = help: Wrap the pipeline in `return()`, or assign and return its result.

function-return-assignment

Flag an assignment passed directly to base return(). The assigned value is returned, but the binding remains as a side effect. Move the assignment before return() or return the value directly. No fix is offered because the intended behavior cannot be inferred.

This rule is enabled by default.

Assigning while returning a value:

f <- function() return(result <- compute())
warning: function-return-assignment
 --> example.R:1:24
  |
1 | f <- function() return(result <- compute())
  |                        ^^^^^^^^^^^^^^^^^^^ assignment inside `return()` has a side effect
  = help: Move the assignment before `return()` or return the value directly.

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()
}

undesirable-function

Flag a call to a function the project has banned, with the configured alternative as the suggestion.

The name -> suggestion map is set in [lint.rules.undesirable-function]: functions replaces the built-in set, extend-functions adds to it. The built-in set covers base-R functions that mutate global state (attach, setwd, options, Sys.setenv, …) and the debugging entry points (debug, trace, …); browser() is left to the dedicated browser rule.

Only bare-name calls are flagged, and a locally redefined name is skipped. There is no autofix — the rule knows the call is unwanted, not what should replace it.

This rule is disabled by default; enable it with select.

attach() is in the built-in set — it puts a data frame’s columns on the search path, so later code silently depends on load order:

attach(mtcars)
mean(mpg)
warning: undesirable-function
 --> example.R:1:1
  |
1 | attach(mtcars)
  | ^^^^^^ call to undesirable function `attach`
  = help: Avoid `attach()`: use `with()` or refer to columns explicitly.

for-loop-index

Flag a for loop whose index symbol is also read in its own sequence expression, as in for (x in x) or for (x in seq_along(x)). R evaluates the sequence once and then binds the index over it, so the original value is destroyed by the first iteration and is not what a reader would expect after the loop.

Only a genuine read of the name counts: a field name (for (x in df$x)), an argument name (for (x in list(x = 1))), or a read belonging to a function literal inside the sequence is not a re-use and is not flagged. No fix is offered—the repair is to rename the index or the sequence, which means inventing a name.

This rule is enabled by default.

The loop index overwrites the vector being iterated over:

for (x in x) {
  print(x)
}
warning: for-loop-index
 --> example.R:1:6
  |
1 | for (x in x) {
  |      ^^^^^^ loop index `x` is also read in the loop's sequence
  = help: Rename the loop index so iterating does not overwrite `x`.

The same mistake one call deep:

for (i in seq_along(i)) {
  print(i)
}
warning: for-loop-index
 --> example.R:1:6
  |
1 | for (i in seq_along(i)) {
  |      ^^^^^^^^^^^^^^^^^ loop index `i` is also read in the loop's sequence
  = help: Rename the loop index so iterating does not overwrite `i`.

for-loop-dup-index

Flag a nested for loop that reuses the index variable of an enclosing for loop. R loops introduce no scope, so the inner loop overwrites the outer index rather than shadowing it: the outer loop resumes with a corrupted counter and any later read of the name sees the inner loop’s last value.

A loop nested inside a function defined in the outer body is not flagged—it runs in its own frame and leaves the outer index alone. No fix is offered, since the repair is to invent a new index name.

This rule is enabled by default.

The inner loop overwrites the outer loop’s counter:

for (i in 1:10) {
  for (i in 1:5) {
    print(i)
  }
}
warning: for-loop-dup-index
 --> example.R:2:8
  |
2 |   for (i in 1:5) {
  |        ^^^^^^^^ loop index `i` is already the index of an enclosing `for` loop
  = help: Rename this loop index so it does not overwrite the enclosing loop's `i`.

unused-function

Flag an exported function that nothing in the project calls. The complement of unused-binding, which stays quiet on public API: a function is reported here only when it is declared exported (a roxygen @export, or a NAMESPACE export()) and no file that can see it reads it. S3 methods are exempt — dispatch reaches them without a direct call, so having no caller says nothing about them. Disabled by default, since a library’s exported functions are meant to be called from outside the project.

This rule is disabled by default; enable it with select.

add_one is exported but never called anywhere in the package:

#' Add one
#'
#' @export
add_one <- function(x) {
  x + 1
}
warning: unused-function
 --> example.R:4:1
  |
4 | add_one <- function(x) {
  | ^^^^^^^ exported function `add_one` is never called
  = help: Remove it, or stop exporting it if it is not part of the public API.

duplicated-function-definition

Flag a function name defined twice among the same run of statements, where the earlier definition is replaced before it is ever used. R evaluates both assignments, so every call reaches the second one and the first body is dead code — nearly always a copy-paste or merge artifact.

Only definitions that are siblings in one statement list are paired, so definition-by-condition (if (x) f <- function() 1 else f <- function() 2) is not flagged: only one branch runs. A redefinition that follows a genuine use of the earlier definition is not flagged either — that is a deliberate rewrite, not a duplicate. No fix is offered: which definition to keep is a judgement call.

This rule is enabled by default.

The first calc is replaced before it is ever called:

calc <- function(x) {
  x + 1
}

calc <- function(x) {
  x * 2
}

calc(3)
warning: duplicated-function-definition
 --> example.R:5:1
  |
5 | calc <- function(x) {
  | ^^^^ function `calc` is redefined here; the definition on line 1 is never used
  = help: Remove the definition that is overwritten, or give one of them a different name.

The same mistake inside a function body:

process <- function(data) {
  clean <- function(x) x[!is.na(x)]
  clean <- function(x) x[x > 0]
  clean(data)
}
warning: duplicated-function-definition
 --> example.R:3:3
  |
3 |   clean <- function(x) x[x > 0]
  |   ^^^^^ function `clean` is redefined here; the definition on line 2 is never used
  = help: Remove the definition that is overwritten, or give one of them a different name.

Readability

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.

Performance

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)`.

matrix-apply

Flag apply(x, 1/2, sum/mean) when the corresponding rowSums, colSums, rowMeans, or colMeans call is clearer and faster. The exact supported argument shape and all relevant base names are verified before rewriting.

This rule is enabled by default.

Dedicated matrix row and column helpers:

totals <- apply(x, 1, sum)
warning: matrix-apply
 --> example.R:1:11
  |
1 | totals <- apply(x, 1, sum)
  |           ^^^^^^^^^^^^^^^^ Use `rowSums(x)` instead of this indirect base call.
  = help: Use `rowSums(x)`.

which-grepl

Flag which(grepl(pattern, x)), which makes two passes where grep(pattern, x) directly returns matching indices. Both calls must resolve to base R.

This rule is enabled by default.

Direct matching indices:

i <- which(grepl("^a", x))
warning: which-grepl
 --> example.R:1:6
  |
1 | i <- which(grepl("^a", x))
  |      ^^^^^^^^^^^^^^^^^^^^^ Use `grep(pattern, x)` instead of this indirect base call.
  = help: Use `grep(pattern, x)`.

After applying the fix:

i <- grep("^a", x)

rep-len

Flag the exact rep(x, length.out = n) shape, for which rep_len(x, n) is the direct base primitive. Calls with times, each, or other arguments are excluded.

This rule is enabled by default.

Direct length-limited repetition:

y <- rep(x, length.out = n)
warning: rep-len
 --> example.R:1:6
  |
1 | y <- rep(x, length.out = n)
  |      ^^^^^^^^^^^^^^^^^^^^^^ Use `rep_len(x, n)` instead of this indirect base call.
  = help: Use `rep_len(x, n)`.

After applying the fix:

y <- rep_len(x, n)

system-file

Flag redundant nesting of base file.path() and system.file(). system.file() already accepts path components through ..., so exact clean shapes can be flattened safely.

This rule is enabled by default.

Path components passed directly to system.file:

p <- system.file(file.path("a", "b"), package = "pkg")
warning: system-file
 --> example.R:1:6
  |
1 | p <- system.file(file.path("a", "b"), package = "pkg")
  |      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `system.file(..., package = pkg)` instead of this indirect base call.
  = help: Use `system.file(..., package = pkg)`.

After applying the fix:

p <- system.file("a", "b", package = "pkg")

list2df

Flag do.call(cbind.data.frame, x) in favor of list2DF(x) on R 4.0 or newer; exact arguments and base resolution avoid changing recycling or dispatch behavior.

This rule is enabled by default.

A list converted directly to a data frame:

df <- do.call(cbind.data.frame, x)
warning: list2df
 --> example.R:1:7
  |
1 | df <- do.call(cbind.data.frame, x)
  |       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use `list2DF(x)` instead of this indirect base call.
  = help: Use `list2DF(x)`.

length-levels

Flag length(levels(x)), for which base nlevels(x) expresses the intent directly. Both nested calls must resolve to base R.

This rule is enabled by default.

A factor’s number of levels:

n <- length(levels(x))
warning: length-levels
 --> example.R:1:6
  |
1 | n <- length(levels(x))
  |      ^^^^^^^^^^^^^^^^^ Use `nlevels(x)` instead of this indirect base call.
  = help: Use `nlevels(x)`.

After applying the fix:

n <- nlevels(x)

boolean-arithmetic

Flag arithmetic existence tests such as length(which(p)) == 0 and sum(p, na.rm = TRUE) > 0 when any(p, na.rm = TRUE) states the intent more directly and avoids materializing indices or counting matches. Empty tests use !any(...); positive tests use any(...).

The rule recognizes comparisons against zero or one, including mirrored spellings. A sum() argument must be syntactically logical and use na.rm = TRUE; a bare sum(p) == 0 is left alone because sum() and any() differ when TRUE and NA coexist. Source calls must resolve to base R. The fix is unsafe because classed values can change Summary method dispatch.

This rule is enabled by default.

Testing whether any condition holds:

if (length(which(ok)) == 0) stop()
warning: boolean-arithmetic
 --> example.R:1:5
  |
1 | if (length(which(ok)) == 0) stop()
  |     ^^^^^^^^^^^^^^^^^^^^^^ counting logical values is less direct than `!any(...)`
  = help: Use `!any(...)` with `na.rm = TRUE`.

Documentation

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-lint skip 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-lint skip 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, and a block owning a topic is satisfied by a title on any block merging into it, anywhere in the package—the title belongs to the topic. Skipped too is a bare @export on a function the package’s NAMESPACE registers with S3method()—that generates no topic and no undocumented export.

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 blocks that merge into or inherit another topic (@rdname, @inherit, …) are skipped, and a block owning a topic is satisfied by a @return on any block merging into it, anywhere in the package—the \value section belongs to the topic. A titleless S3 method is skipped too (registered with S3method(), so it is not exported and generates no .Rd); the generic’s topic owns the value.

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. Coverage is judged against the generated topic, not the block: @rdname and @describeIn merge several functions into one .Rd, so a block owning a topic is judged against the union of every joiner’s formals, anywhere in the package. Blocks that inherit documentation from another object (@inheritParams, @template, …) or that join a topic owned elsewhere are exempt from the coverage checks, and a titleless S3 method (registered with S3method(), so it generates no .Rd) is exempt from the missing-@param check; 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.

roxygen2-compat

Flag documentation constructs mismatched with the project’s roxygen2 version.

The targeted version comes from [compat] roxygen2 in arity.toml, or from the package DESCRIPTION (Config/roxygen2/version, then the legacy RoxygenNote); without either, the rule stays silent. Targeting a version below 8.0.0 flags syntax only 8.0.0 understands—@prop, @R6method, `Rd expr` render-time code spans, @inheritParams argument filters (which older versions silently misread as argument names), and backtick-quoted names containing spaces. Targeting 8.0.0 or later flags a single-line tag (@rdname, @importFrom, …) whose value spans lines, which 8.0.0 warns about.

This rule is enabled by default.

An @inheritParams filter under a declared roxygen2 7.x (roxygen2 = "7.3.2" under [compat] in arity.toml):

#' Add one
#' @inheritParams other -verbose
add_one <- function(x) x + 1
warning: roxygen2-compat
 --> example.R:2:25
  |
2 | #' @inheritParams other -verbose
  |                         ^^^^^^^^ `@inheritParams` argument filters require roxygen2 >= 8.0.0; older versions silently misread them as argument names (this project targets 7.3.2)
  = help: Drop the filters or raise `[compat] roxygen2`.

Packaging

undeclared-dependency

Flag package code that reaches a package its DESCRIPTION never declares.

dplyr::filter() in R/ works on the author’s machine because dplyr happens to be installed there; on a clean machine it is a load-time error, and R CMD check reports it. Declaring the dependency is what causes it to be installed, so leaving it out is a bug that only ever surfaces somewhere else.

The rule matches pkg::name, pkg:::name, and the package argument of library, require, requireNamespace, and loadNamespace—at any depth, since the conditional-dependency idiom lives inside a function body.

The exempt set is R’s own: everything declared in any of Depends, Imports, Suggests, LinkingTo, or Enhances, the package’s own name, and the packages R ships at base priority—except methods and stats4, which R CMD check still expects a package to declare. Only files directly in R/ are checked: a test’s or a vignette’s dependencies belong in Suggests, and R does not scan those directories for this check either.

There is no autofix. A fix is an edit to the file the finding is in, and the repair here is a line in DESCRIPTION.

This rule is enabled by default.

In R/ of a package whose DESCRIPTION declares only Imports: rlang:

summarize <- function(data) {
  dplyr::group_by(data, id)
}
warning: undeclared-dependency
 --> example.R:2:3
  |
2 |   dplyr::group_by(data, id)
  |   ^^^^^ package `dplyr` is used here but is not declared in DESCRIPTION
  = help: Add `dplyr` to `Imports:` in DESCRIPTION.

description-missing-field

Flag a DESCRIPTION missing a field R requires.

R CMD build refuses a package whose DESCRIPTION omits Package, Version, Title, Description, Author, Maintainer, or License. Authors@R satisfies Author and Maintainer, since R CMD build derives both from it. A field that is present but empty declares nothing and counts as missing.

Every missing field is reported as one finding rather than one each: the defect is that the file is incomplete, and it takes one decision—and one suppression—to settle.

A file with no fields at all is left alone; that is not an incomplete package description.

There is no autofix: each of these fields needs a value only the author has.

This rule is enabled by default.

A DESCRIPTION that R CMD build would reject:

Package: mypkg
Version: 0.1.0
warning: description-missing-field
 --> DESCRIPTION:1:1
  |
1 | Package: mypkg
  | ^^^^^^^ DESCRIPTION is missing the required fields `Title`, `Description`, `Author`, `Maintainer`, `License`
  = help: Add the fields `Title`, `Description`, `Author`, `Maintainer`, `License`, or declare `Authors@R` in place of `Author` and `Maintainer`.

description-duplicate-field

Flag a DESCRIPTION field declared more than once.

A repeated field is a silent mistake: nothing errors, and the last value quietly replaces the earlier value. Both arity and R’s read.dcf apply that rule.

The finding is reported on the later occurrence, which is both the repeat and the value that takes effect. Duplicates are detected across DCF records, so a stray blank line does not hide one.

There is no autofix: removing a duplicate means choosing a value, and a fix would silently make that choice for the author.

This rule is enabled by default.

A field declared twice, with the later value silently taking effect:

Package: mypkg
Version: 0.1.0
License: MIT + file LICENSE
Version: 0.2.0
warning: description-duplicate-field
 --> DESCRIPTION:4:1
  |
4 | Version: 0.2.0
  | ^^^^^^^ `Version` is already declared on line 2; this later occurrence silently replaces its value
  = help: Keep one `Version` field and delete the other.

description-unknown-field

Flag a likely misspelling of a standard DESCRIPTION field.

This is deliberately a near-miss check rather than a whitelist: arbitrary fields, including Config/*, are legal. A name is reported only when it is one edit from a standard field, or when whitespace separates an otherwise standard name from its colon. R treats that whitespace as part of the name, so Package : mypkg declares Package rather than Package and is silently ignored.

There is no autofix: renaming a field changes the metadata R reads, so the author should confirm the intended spelling.

This rule is enabled by default.

A misspelled field that R silently ignores:

Package: mypkg
Version: 0.1.0
Suggest: testthat
warning: description-unknown-field
 --> DESCRIPTION:3:1
  |
3 | Suggest: testthat
  | ^^^^^^^ `Suggest` is not a standard DESCRIPTION field; did you mean `Suggests`?
  = help: Rename the field to `Suggests`.

description-version-constraint

Flag a dependency entry whose parenthesized part is not a version constraint.

R reads pkg (>= 1.0.0): an operator and a version. Anything else—dplyr (1.0.0), dplyr (>=), dplyr (latest)—states no bound at all, and R’s dependency check enforces nothing. The line reads as a requirement and behaves as none, so the package installs against exactly the versions its author meant to exclude.

Checked in all five dependency fields, R included: Depends: R (>= 4.1) is the most common constraint in any DESCRIPTION.

There is no autofix. dplyr (1.0.0) most likely means >=, but it could mean == or >, and guessing would invent a requirement the author never wrote.

This rule is enabled by default.

A version requirement R will not enforce:

Package: mypkg
Depends: R (4.1)
Imports: dplyr (1.0.0)
warning: description-version-constraint
 --> DESCRIPTION:2:10
  |
2 | Depends: R (4.1)
  |          ^^^^^^^ the version constraint on `R` states no bound, so R enforces nothing here
  = help: Write a comparison operator and a version, as in `(>= 1.0.0)`.
warning: description-version-constraint
 --> DESCRIPTION:3:10
  |
3 | Imports: dplyr (1.0.0)
  |          ^^^^^^^^^^^^^ the version constraint on `dplyr` states no bound, so R enforces nothing here
  = help: Write a comparison operator and a version, as in `(>= 1.0.0)`.

description-package-in-multiple-fields

Flag a package listed in more than one of Depends, Imports, Suggests, and Enhances.

Writing R Extensions says a package should be listed in only one of these fields. They are a choice, not an accumulation, and every pair contradicts itself: Imports plus Suggests declares the package both required and optional, Depends plus Imports both attached and not. R settles it by picking one field, so the second declaration is inert, and R CMD check reports the pair.

LinkingTo is deliberately excluded. A package that supplies headers and R code belongs in both LinkingTo and Imports—the Rcpp idiom—and R’s own check leaves it out of the comparison for the same reason. R is excluded too: it names the language, not a package.

The finding sits on the later listing, and names the field holding the earlier one. There is no autofix: which field to keep is a decision about whether the code may rely on the package at all.

This rule is enabled by default.

A package declared as both a hard requirement and an optional one:

Package: mypkg
Version: 0.1.0
Imports: dplyr, rlang
Suggests: dplyr, testthat
warning: description-package-in-multiple-fields
 --> DESCRIPTION:4:11
  |
4 | Suggests: dplyr, testthat
  |           ^^^^^ `dplyr` is already listed in `Imports`; a package belongs in only one dependency field
  = help: List `dplyr` in either `Imports` or `Suggests`, and delete the other entry.

description-malformed-name

Flag a Package value R will not accept as a package name.

R’s valid_package_name is [[:alpha:]][[:alnum:].]*[[:alnum:]]: a letter, then letters, digits, and periods, ending in a letter or digit. So underscores, hyphens, and a leading period are all out, and a name is at least two characters long—except the literal R, which R’s check spells out as an alternative.

A Package naming one of the packages R itself ships (stats, utils, methods, …) is reported too, since that package could never be installed alongside the one R ships. A description declaring Priority: base is exempt, which is how the base packages name themselves.

The letter and digit classes are matched as Unicode, exactly as R matches them under a UTF-8 locale, so café is accepted—a stricter reading would report a defect R CMD check does not have.

An absent or empty Package is description-missing-field’s finding, not this one’s.

There is no autofix: the name is also in the NAMESPACE, the file names, the tests, and every pkg:: that reaches the package, so renaming is the author’s.

This rule is enabled by default.

A name R’s valid_package_name rejects, since underscores are not name characters:

Package: my_pkg
Version: 0.1.0
warning: description-malformed-name
 --> DESCRIPTION:1:10
  |
1 | Package: my_pkg
  |          ^^^^^^ `my_pkg` is not a valid package name: R requires a letter, then letters, digits, and periods, ending in a letter or digit
  = help: Rename the package: at least two characters, starting with a letter, ending in a letter or digit, and made of letters, digits, and periods.

A name R already ships:

Package: stats
Version: 0.1.0
warning: description-malformed-name
 --> DESCRIPTION:1:10
  |
1 | Package: stats
  |          ^^^^^ `stats` is the name of a base R package
  = help: Rename the package to one R does not already ship.

description-malformed-version

Flag a Version value R or CRAN will object to.

R’s valid_package_version is ([[:digit:]]+[.-]){1,}[[:digit:]]+: runs of digits joined by . or -. The trailing run is written separately from the repeated group, so a version has at least two components—a bare Version: 1 is one R rejects, and so is any component that is not digits (1.0.0-beta, v1.0).

Two CRAN pretest NOTEs are reported by the same rule, since the repair is the same: a component with a leading zero (1.01, which sorts before 1.1 as text and equal to it as a version), and an implausibly large component (1234 or more). Calendar versioning is exempt from both, exactly as CRAN exempts it: 2026.01 keeps its zero, and a four-digit component that reads as a year is not an absurd one.

The digit class is matched as ASCII, exactly as R matches it under a UTF-8 locale—note that this is the opposite of description-malformed-name, whose letter class is Unicode there. A description declaring Priority: base is exempt, since a base package’s version is R’s own to spell.

An absent or empty Version is description-missing-field’s finding, not this one’s.

There is no autofix: which number a release carries is a decision about the release, and it is also in the package’s tags, its NEWS.md, and every constraint a dependent puts on it.

This rule is enabled by default.

A version R’s valid_package_version rejects, since a component has to be digits:

Package: mypkg
Version: 1.0.0-beta
warning: description-malformed-version
 --> DESCRIPTION:2:10
  |
2 | Version: 1.0.0-beta
  |          ^^^^^^^^^^ `1.0.0-beta` is not a valid package version: R requires runs of digits joined by `.` or `-`
  = help: Renumber the release: at least two components, each one digits, separated by `.` or `-`.

A component with a leading zero, which sorts one way as text and another as a version:

Package: mypkg
Version: 1.01
warning: description-malformed-version
 --> DESCRIPTION:2:10
  |
2 | Version: 1.01
  |          ^^^^ `1.01` has a component with a leading zero
  = help: Drop the leading zero: `1.01` and `1.1` are the same version to R, but not to anything that sorts the text.

A component too large to be a release number:

Package: mypkg
Version: 1.0.5000
warning: description-malformed-version
 --> DESCRIPTION:2:10
  |
2 | Version: 1.0.5000
  |          ^^^^^^^^ `1.0.5000` has an implausibly large component (`5000`)
  = help: Check the number: a component of 1234 or more is usually a typo or a date in the wrong slot.

description-malformed-maintainer

Flag a Maintainer value R or CRAN will object to.

R’s .valid_maintainer_field_regexp wants exactly one Name <address>, or the literal ORPHANED. A missing address (Maintainer: Jane Doe) is the common case and fails it outright.

Three CRAN pretest checks cover the rest of the field and are reported by the same rule, since they are one conversation about who maintains the package: text after the address, which is what two maintainers look like (R’s own regexp accepts those, so this is the clause that catches them); an address with no name in front of it; and a comma in an unquoted display name, which reads as a list of people—"Doe, Jane" <[email protected]> is the repair.

R’s regexp is ported as written and deliberately not tightened to RFC 5322: a quoted local part, a domain with no TLD, and a domain label starting with - are all addresses R CMD check accepts. A Maintainer wrapped across continuation lines is accepted too, exactly as R accepts it.

An absent or empty Maintainer is not this rule’s finding: R derives one from Authors@R, and whether the package names a maintainer at all is description-missing-field’s subject.

There is no autofix: an address cannot be invented, a name cannot be invented, and whether a comma separates a surname from a given name or separates two people is a question only the author can answer.

This rule is enabled by default.

A maintainer with no address, which is what R’s .valid_maintainer_field_regexp mostly catches:

Package: mypkg
Version: 0.1.0
Maintainer: Jane Doe
warning: description-malformed-maintainer
 --> DESCRIPTION:3:13
  |
3 | Maintainer: Jane Doe
  |             ^^^^^^^^ `Jane Doe` has no email address
  = help: Add the maintainer's address: `Name <[email protected]>`, or `ORPHANED` if the package has no maintainer.

Two maintainers, where R’s Maintainer holds exactly one:

Package: mypkg
Version: 0.1.0
Maintainer: Jane Doe <[email protected]>, John Roe <[email protected]>
warning: description-malformed-maintainer
 --> DESCRIPTION:3:13
  |
3 | Maintainer: Jane Doe <[email protected]>, John Roe <[email protected]>
  |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `Jane Doe <[email protected]>, John Roe <[email protected]>` names more than one person
  = help: Name one maintainer here and credit everyone else in `Authors@R`: R's `Maintainer` is the single person to write to.

A comma in an unquoted display name, which reads as a list of people:

Package: mypkg
Version: 0.1.0
Maintainer: Doe, Jane <[email protected]>
warning: description-malformed-maintainer
 --> DESCRIPTION:3:13
  |
3 | Maintainer: Doe, Jane <[email protected]>
  |             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the maintainer name `Doe, Jane` contains a comma but is not quoted
  = help: Quote the name (`"Doe, Jane" <[email protected]>`), so the comma does not read as a second maintainer.

description-text-format

Check the package Description field against R’s sentence-level and CRAN’s lexical conventions. The text must begin with a capital and end in ., !, or ? (optionally followed by one quote or closing parenthesis). It must not begin by repeating the package name or Title, or with This package, Functions for, The package, A package, In this package, or In the package.

Single-quoted function identifiers such as 'case_when()' are flagged too. Write function names without single quotes.

HTTP(S) URLs, doi:10.../... references, and recognizable arXiv: identifiers must be enclosed in angle brackets, with no whitespace after the colon. Arity safely wraps an unambiguous bare reference. All prose changes and quote removal are report-only because they require the author’s judgment.

This rule is enabled by default.

Boilerplate prose, a quoted function identifier, and a missing final period:

Package: mypkg
Title: Model Fitting
Description: This package calls 'fit_model()'
warning: description-text-format
 --> DESCRIPTION:3:14
  |
3 | Description: This package calls 'fit_model()'
  |              ^^^^^^^^^^^^ the `Description` field must not start with `This package`
  = help: Start with a concise statement of what the package does.
warning: description-text-format
 --> DESCRIPTION:3:33
  |
3 | Description: This package calls 'fit_model()'
  |                                 ^^^^^^^^^^^^^ the function identifier `fit_model()` must not be single-quoted
  = help: Write it as `fit_model()` without quotes.
warning: description-text-format
 --> DESCRIPTION:3:44
  |
3 | Description: This package calls 'fit_model()'
  |                                            ^ the `Description` field must end with `.`, `!`, or `?`
  = help: End the description with sentence punctuation.

A bare URL, for which arity can safely add angle brackets:

Package: mypkg
Title: Model Fitting
Description: See https://example.com for details.
warning: description-text-format
 --> DESCRIPTION:3:18
  |
3 | Description: See https://example.com for details.
  |                  ^^^^^^^^^^^^^^^^^^^ the reference `https://example.com` must be enclosed in angle brackets
  = help: Write it as `<https://example.com>`.

After applying the fix:

Package: mypkg
Title: Model Fitting
Description: See <https://example.com> for details.

description-encoding

Flag text outside R’s ISO-8859 byte set in a DESCRIPTION with no Encoding field, and non-ASCII text in fields R requires to be ASCII: Package, Version, License, and Encoding.

A missing declaration has a safe fix: arity only lints text it has already decoded as UTF-8, so it can append Encoding: UTF-8 without guessing. Non-ASCII content in an ASCII-only field has no autofix because choosing replacement text requires the author.

This rule is enabled by default.

A package containing UTF-8 text without declaring its encoding:

Package: mypkg
Version: 0.1.0
Title: A 日本語 package
License: MIT
warning: description-encoding
 --> DESCRIPTION:3:10
  |
3 | Title: A 日本語 package
  |          ^^ DESCRIPTION contains text that requires an encoding declaration
  = help: Add `Encoding: UTF-8`.

After applying the fix:

Package: mypkg
Version: 0.1.0
Title: A 日本語 package
License: MIT
Encoding: UTF-8

description-authors-at-r

Flag an Authors@R field R will not read the way its author meant it.

R CMD build derives Author and Maintainer from this field, and the derivation is exacting: it needs a person with the cre role, a non-empty name, and an email. Without one it errors out—“Authors@R field gives no person with maintainer role, valid email address and non-empty name”—so person("Jane", "Doe", role = c("aut", "cre")) is a package that does not build.

The rest of R’s .check_package_description_authors_at_R_field is reported by the same rule: a field that is not R, or that holds a call R refuses to evaluate (only person, as.person, c, list, paste, and paste0 are allowed); a person with no name or no role, who is credited nowhere at all; a role outside the MARC relator table, which person() silently drops; more than one cre, where R stores exactly one maintainer; and a malformed or duplicated ORCID iD or ROR ID. Both identifiers are self-validating—an ORCID carries a MOD 11-2 check digit—so no network is involved.

Two checks on the neighboring Author field are here for the same reason, since both are Authors@R content written under the wrong key: a value that begins with the field header Author:, and a value that is a person(...) or c(...) call, which R stores verbatim and never evaluates.

Nothing is evaluated. The value is parsed with arity’s own R parser and resolved only as far as literal text goes; a computed argument resolves to unknown and every finding that depends on it is withheld, so the rule reports strictly less than R CMD check does and never more.

There is no autofix: an email, a name, a role, and a check digit are all facts about a person that only that person has.

This rule is enabled by default.

A creator with no email, which is what R needs to derive a Maintainer and refuses to build without:

Package: mypkg
Version: 0.1.0
Authors@R: person("Jane", "Doe", role = c("aut", "cre"))
warning: description-authors-at-r
 --> DESCRIPTION:3:12
  |
3 | Authors@R: person("Jane", "Doe", role = c("aut", "cre"))
  |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the package's `cre` has no email address, so R can derive no `Maintainer`
  = help: Add `email = "[email protected]"`. `R CMD build` needs a `cre` with a name and an address, and errors out without one.

A person credited nowhere, because person() drops anyone with no role:

Package: mypkg
Version: 0.1.0
Authors@R: c(
    person("Jane", "Doe", , "[email protected]", c("aut", "cre")),
    person("John", "Roe")
  )
warning: description-authors-at-r
 --> DESCRIPTION:5:5
  |
5 |     person("John", "Roe")
  |     ^^^^^^^^^^^^^^^^^^^^^ this person has no role, so R credits them nowhere
  = help: Add a `role`, such as `"aut"` for an author or `"ctb"` for a contributor: `person()` drops anyone with no role from `Author`.

An ORCID iD whose MOD 11-2 check digit does not add up:

Package: mypkg
Version: 0.1.0
Authors@R: person("Jane", "Doe", , "[email protected]", c("aut", "cre"),
    comment = c(ORCID = "0000-0002-1825-0098"))
warning: description-authors-at-r
 --> DESCRIPTION:4:25
  |
4 |     comment = c(ORCID = "0000-0002-1825-0098"))
  |                         ^^^^^^^^^^^^^^^^^^^^^ `0000-0002-1825-0098` is not a valid ORCID iD
  = help: An ORCID iD is `0000-0002-1825-0097` and carries a check digit, so a mistyped one is decidable without asking orcid.org.

R code under the Author key, which R stores as a plain string and never evaluates:

Package: mypkg
Version: 0.1.0
Author: person("Jane", "Doe", role = c("aut", "cre"))
warning: description-authors-at-r
 --> DESCRIPTION:3:9
  |
3 | Author: person("Jane", "Doe", role = c("aut", "cre"))
  |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `person("Jane", "Doe", role = c("aut", "cre"))` is R code under the `Author` key
  = help: Move the call to `Authors@R`, which R evaluates. `Author` is a plain string R prints as written, brackets and quotes included.

description-empty-person

Flag a person() in Authors@R that supplies no arguments.

person()—and person(NULL), which takes the same early return—is a zero-length person vector, not a nameless person. R concatenates it away without a word: c(person("Jane", …), person()) is a one-element vector, Author and Maintainer derive exactly as they would have, and nothing in R CMD check will ever mention it.

So this is style rather than correctness. The call is a contributor someone opened and never filled in, left in shipped metadata where it reads as an intention—and it is invisible to every tool that reads the field unless one looks for it.

It is a rule of its own rather than a clause of description-authors-at-r because it is the one packaging finding R CMD check does not back, and keeping the ids apart keeps that rule’s claim exact—as well as letting this one be suppressed on its own.

A person carrying anything at all is a person, however little R can make of them: person(role = "ctb") and even person("") are description-authors-at-r’s subject, not this rule’s. A computed argument could be NULL and could be a name, so the rule stays silent there.

There is no autofix: deleting the call means deleting a comma that belongs to its neighbor, and filling the person in is the author’s.

This rule is enabled by default.

A contributor opened and never filled in. R drops the call silently, so the credit was never going to appear:

Package: mypkg
Version: 0.1.0
Authors@R: c(
    person("Jane", "Doe", , "[email protected]", c("aut", "cre")),
    person()
  )
warning: description-empty-person
 --> DESCRIPTION:5:5
  |
5 |     person()
  |     ^^^^^^^^ this `person()` supplies nothing, so it names nobody
  = help: Fill the person in, or delete the call: R reads `person()` as a zero-length person vector and drops it silently.

unused-dependency

Flag an Imports: entry that nothing in the package reaches.

An Imports entry promises that installing this package installs that one, so an entry no code reaches costs every user a download, a build, and a constraint to satisfy for nothing. R CMD check reports it too.

A package counts as reached by pkg::, pkg:::, a library, require, requireNamespace, or loadNamespace call at any depth, a NAMESPACE import()/importFrom()/importClassesFrom()/importMethodsFrom(), or a roxygen @import/@importFrom tag. Exempt on top of that: anything also in LinkingTo (the Rcpp skeleton), methods when the package defines an S4 or reference class, and any package whose name appears as a plain string (a dynamic do.call("::", …) or system.file(package = …)).

Only Imports is checked. Depends is an API decision the package’s own code may never name; Suggests is for tests, vignettes, and examples, reached from outside the package’s own code; LinkingTo and Enhances are invisible to R-source analysis.

Usage is folded over every R file the run analyzed under the package root, so a package reached only from tests/, inst/, or data-raw/ counts as used when those files are in the run (arity lint .) and is reported when they are not (arity lint R/). A vignette’s R code is never analyzed either way, so a dependency used only there is reported—it belongs in Suggests.

It reports on absence, and a wrong finding would have a maintainer delete a dependency their package needs, so it stays silent unless the run analyzed the package’s whole R/ source set and read its NAMESPACE—which is also why it is off by default.

There is no autofix: removing an entry from a comma-separated list is not a local edit, and this is not a claim a tool should act on destructively.

This rule is disabled by default; enable it with select.

In a package whose only R source is f <- function() rlang::abort("no"):

Package: mypkg
Version: 0.1.0
Imports: rlang, tibble
warning: unused-dependency
 --> DESCRIPTION:3:17
  |
3 | Imports: rlang, tibble
  |                 ^^^^^^ `tibble` is declared in `Imports:` but nothing in the package uses it
  = help: Remove `tibble` from `Imports:`, or move it to `Suggests:` if only tests, vignettes, or examples need it.

Meta

misnamed-suppression

Flags an # arity directive that names a rule arity does not ship, or that does not parse as a directive at all (an unknown verb, a missing one, a rule named where the form takes none). Either way it suppresses nothing, and does so silently — the failure mode of a suppression is that no output appears, which is also what success looks like. When exactly one shipped rule ID is an unambiguous near-match, the fix rewrites the ID and leaves the reason text alone; otherwise the finding is report-only. Note that syntax-error is not a lint rule: parse errors are reported before any rule runs and cannot be suppressed.

This rule is enabled by default.

The rule ID is misspelled, so the directive suppresses nothing:

# arity-lint skip unusd-binding: leftover from a refactor
x <- 1
warning: misnamed-suppression
 --> example.R:1:19
  |
1 | # arity-lint skip unusd-binding: leftover from a refactor
  |                   ^^^^^^^^^^^^^ `unusd-binding` is not an arity lint rule, so this directive suppresses nothing
  = help: did you mean `unused-binding`?

After applying the fix:

# arity-lint skip unused-binding: leftover from a refactor
x <- 1

A comma-separated list is not supported — write one directive per rule:

# arity-lint skip browser, repeat: debugging
x <- 1
warning: misnamed-suppression
 --> example.R:1:19
  |
1 | # arity-lint skip browser, repeat: debugging
  |                   ^^^^^^^^ `browser,` is not an arity lint rule, so this directive suppresses nothing
  = help: a directive names one rule; write a separate `# arity-lint skip` per rule

blanket-suppression

Flags an # arity-lint directive that names no rule where it could have. # arity-lint skip-file: <reason> disables every rule for the file, and # arity-lint off: <reason> does so until the matching on — including every rule arity ships in the future, so the code quietly stops being checked as the rule set grows. A directive with nothing after the verb is the opposite failure: it names nothing, so it suppresses nothing. Both are fixed by naming the rule. Not flagged: the rule-scoped # arity-lint skip-file <rule>, broad in range but narrow in effect, and # arity skip: <reason>, broad in rules but bounded to one statement. Report-only — choosing the rules for the author would guess at intent in either direction.

This rule is enabled by default.

Disabling every rule for the file, including rules that do not exist yet:

# arity-lint skip-file: generated by a script
x <- 1
warning: blanket-suppression
 --> example.R:1:1
  |
1 | # arity-lint skip-file: generated by a script
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this directive disables every lint rule for the whole file
  = help: scope it with `# arity-lint skip-file <rule>: <reason>`

A directive with no rule ID suppresses nothing at all:

# arity-lint skip
x <- 1
warning: blanket-suppression
 --> example.R:1:1
  |
1 | # arity-lint skip
  | ^^^^^^^^^^^^^^^^^ this directive names no rule, so it suppresses nothing
  = help: name the rule: `# arity-lint skip <rule>: <reason>`

misplaced-suppression

Flags an # arity directive written where it can never take effect. A # arity-format directive is honored in statement lists — the top level and a block body — because that is where the formatter can splice source back verbatim; between two call arguments it marks nothing. An # arity-lint on with no open region closes nothing, which usually means its off was written with a different prefix (# arity off and # arity-lint off are separate regions). Both fail silently: a directive that does nothing looks exactly like one that worked. Report-only — moving the comment would mean guessing which statement the author meant.

This rule is enabled by default.

The formatter acts on whole statements, so a directive between two arguments marks nothing:

f(
  a = 1,
  # arity-format skip: hand-aligned
  b = 2
)
warning: misplaced-suppression
 --> example.R:3:3
  |
3 |   # arity-format skip: hand-aligned
  |   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the formatter ignores a directive here; it acts on whole statements
  = help: move it above the statement, at the top level or in a block body

An on closes only a region opened with the same prefix, so this one closes nothing:

# arity off
x <- 1
# arity-lint on
y <- 2
warning: misplaced-suppression
 --> example.R:3:1
  |
3 | # arity-lint on
  | ^^^^^^^^^^^^^^^ this `on` closes no open region, so it does nothing
  = help: open one first, with the same prefix: `# arity-lint off <rule>: <reason>`

deprecated-suppression

Flags # arity-ignore and # arity-ignore-file, the spellings the linter shipped with, and rewrites them to # arity-lint skip and # arity-lint skip-file. Both still parse and behave identically, so nothing is broken and nothing changes when the fix is applied — this is a migration aid, so that a codebase reaches one spelling before the aliases are removed. The fix is Safe and replaces the prefix alone: the rule ID, the reason, and the author’s spacing are left exactly as written. Directives in a DESCRIPTION are not covered, as with every meta rule.

This rule is enabled by default.

The shipped spelling of # arity-lint skip:

# arity-ignore unused-binding: part of the documented API
config <- list(width = 80)
warning: deprecated-suppression
 --> example.R:1:3
  |
1 | # arity-ignore unused-binding: part of the documented API
  |   ^^^^^^^^^^^^ this spelling is deprecated; it means `# arity-lint skip`
  = help: write `# arity-lint skip` instead

After applying the fix:

# arity-lint skip unused-binding: part of the documented API
config <- list(width = 80)

…and of # arity-lint skip-file:

# arity-ignore-file unused-binding: generated by tools/codegen.R
x <- 1
warning: deprecated-suppression
 --> example.R:1:3
  |
1 | # arity-ignore-file unused-binding: generated by tools/codegen.R
  |   ^^^^^^^^^^^^^^^^^ this spelling is deprecated; it means `# arity-lint skip-file`
  = help: write `# arity-lint skip-file` instead

After applying the fix:

# arity-lint skip-file unused-binding: generated by tools/codegen.R
x <- 1

unexplained-suppression

Flags an # arity directive that carries no reason — the text after the :. Telling a tool to stand down is a standing claim that it is wrong at this spot, and without a reason the next reader cannot tell a considered exception from noise someone silenced under deadline, so it becomes permanent by default. An # arity-lint on is exempt: it closes a region whose off already gave the reason. Disabled by default, since requiring reasons is a house style rather than a defect; enable it with select. Report-only: writing the reason is the fix, and inventing one would fabricate a justification.

This rule is disabled by default; enable it with select.

The directive says what to silence, but not why:

# arity-lint skip unused-binding
x <- 1
warning: unexplained-suppression
 --> example.R:1:1
  |
1 | # arity-lint skip unused-binding
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ this suppression gives no reason
  = help: add one after the rule: `# arity-lint skip <rule>: <reason>`

outdated-suppression

Flags an # arity-lint directive that suppressed nothing on this run — the code it was written for has changed, but the directive stayed. A stale suppression is misleading (it asserts arity is wrong at a spot where arity says nothing) and it is a trap: it will silence a real finding if the shape ever comes back. The fix deletes the directive.

To avoid reporting a directive that is merely dormant, the rule only fires when the rule the directive names actually ran — a rule excluded by select/ignore, or one that is off by default, leaves its directives alone — or when the directive is dangling, with no code after it to attach to. Directives naming no rule are left to blanket-suppression, and unknown rule IDs to misnamed-suppression.

This rule is enabled by default.

x is read, so unused-binding finds nothing and the directive is dead:

# arity-lint skip unused-binding: no longer needed
x <- 1
print(x)
warning: outdated-suppression
 --> example.R:1:1
  |
1 | # arity-lint skip unused-binding: no longer needed
  | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `unused-binding` reports nothing here; this suppression is no longer needed

After applying the fix:

x <- 1
print(x)