Style, Linting and Static Analysis

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

Checked against R 4.6.1 (2026-06-24) on 2026-10-20; R 4.6.2 is scheduled for 2026-10-29. Besides base R, the examples that were run use the lintr package (3.4.0) and the styler package (1.11.0) from CRAN, the codetools package (0.2-20), which ships with R as a recommended package, the testthat package (3.3.2), which a few package examples attach, and the shelf package (0.1.0), the small package that Creating Packages builds and that this page lints, styles and checks as a copy. Other tools are named in a sentence and were not run: languageserver, goodpractice, cyclocomp, spelling, urlchecker, pkgcheck, formatR and Air. Style, linting and static analysis are three jobs. A style guide is a document of rules for people. A formatter rewrites the layout of code to follow the rules. A linter reads code and reports lines that break a rule or look wrong, and static analysis in the strict sense finds mistakes such as an undefined variable from the structure of the code without running it. Only codetools, and the R CMD check that uses it, come with R. lintr and styler are packages from the r-lib and tidyverse teams; the style guides they implement are community documents, not part of R.

Every example is a script that was run with Rscript on R 4.6.1 with warnings printed as they happen (options(warn = 1)), and the lines that start with #> are what it printed. The scripts also pass the default lintr linters; a # nolint comment marks a place where the code on show is exactly what a linter dislikes (a function with a deliberate typo, or the "before" file that the lint and style examples work on). That "before" file is bad on purpose: it is not linted, it lives in a separate directory, and the examples that need it carry its text as a string. The names books, loans, shelf_today and loan_days come from the running library-shelf scenario described on the section index; a script that needs them starts with source("scenario.R"). When lintr is given a string of code it names the file <text>; when it is given a file it prints the absolute path of the file, and the shell transcripts below show that path shortened to ~/shelf. The R CMD check transcripts show only the lines that matter: the header with the platform, compiler and time is trimmed.

The page is organised the way a project meets these tools. It first separates the jobs, then reads the community style guide and says which rule each tool enforces, shows what a linter and a formatter actually read (the parser’s token table), and goes through lintr, styler and codetools one at a time with the same messy file as the thread. It then shows how R CMD check uses codetools, mentions the other tools and the editors, and ends with the conventions of this reference, version notes and the habits older tutorials still teach.

Three jobs, four kinds of tool

The tools differ in what they read, what they may change and how sure their answer is.

Kind What it does Changes files? On this page

Style guide

Rules for people: names, spaces, line length, where the braces go. It is a document, so it cannot be run

No

The tidyverse style guide and Google’s R style guide (both community documents)

Formatter

Rewrites the layout of code (spaces, indentation, line breaks and a few tokens) so that it follows the rules

Yes, which is why it should run on files under version control

styler; Air is named in a sentence

Linter

Reads code without running it and reports a line, a column and a message for every place that breaks a rule or looks wrong

No

lintr

Static analysis

Finds likely mistakes from the structure of the code: an undefined variable, a local variable that is never used, a call to a function that does not exist

No

codetools, and the R CMD check step that uses it

The division is not sharp. Some lintr linters are plain style rules (spaces after commas), some find real mistakes (x == NA is never what was meant), and object_usage_linter is codetools in a lintr wrapper: its help page says that it checks closures with codetools::checkUsage() and that it runs eval() on the code, so it must not be used with untrusted code (lintr package documentation: object_usage_linter). A formatter is the opposite case: it never reports anything, it only rewrites, and a clean formatter run says nothing about whether the code is right.

R itself checks very little. The parser rejects code that does not parse and nothing else; an undefined variable is found only when the line runs. Everything on this page therefore runs before the code does, in the editor, in a pre-commit hook, in continuous integration or as one step of R CMD check. The manual for writing packages states the minimum in a sentence: R CMD check checks the R code of a package for possible problems with codetools.

The tidyverse style guide

The most widely used style for R code is the tidyverse style guide (style.tidyverse.org), a community document written by the tidyverse team and not part of R. It has a part for analyses (files, syntax, functions, pipes, ggplot2) and a part for packages (files, documentation, tests, error messages, NEWS). The R language does not require any of it, and base R itself does not follow it, but it is the style that lintr’s default linters and styler’s tidyverse_style() implement, so reading the guide is the quickest way to know what those tools will say. This section summarises the rules in the guide’s own order; the guide is the authority and changes from time to time (the version notes at the end list what changed in the tools after the single-indent change of function definitions).

Names

The guide asks for variable and function names that use only lowercase letters, numbers and underscores (snake case), with nouns for variables and verbs for functions, concise but meaningful, and that avoid reusing the names of common functions and variables (T, c, mean). It reserves the dot for the S3 system: a method is called function.class, so a function or class name with dots in it produces confusing methods such as as.data.frame.data.frame(). The R FAQ describes what the language allows, which is more: a syntactic name consists of letters, digits, the dot and the underscore and starts with a letter or with a dot not followed by a digit (R FAQ 7.14).

Base R was not written to this guide, and its own function names mix the styles. The script counts the functions of the base package whose names start with a lowercase letter, by style, and then shows the two ways in which T and F are not like TRUE and FALSE.

# How base R names its own functions.
fns <- ls(baseenv())
is_fn <- vapply(fns, \(nm) is.function(get(nm, baseenv())), logical(1L))
fns <- fns[is_fn & grepl("^[a-z]", fns)]
snake <- grepl("^[a-z][a-z0-9]*(_[a-z0-9]+)+$", fns)
dotted <- grepl("^[a-z][a-z0-9]*(\\.[a-z0-9]+)+$", fns)
camel <- grepl("^[a-z][a-z0-9]*([A-Z][a-z0-9]*)+$", fns)
c(
  snake_case = sum(snake),
  dot.case = sum(dotted),
  camelCase = sum(camel),
  lowercase_only = sum(grepl("^[a-z][a-z0-9]*$", fns))
)
#>     snake_case       dot.case      camelCase lowercase_only
#>              8            344            200            371
fns[snake]
#> [1] "l10n_info"       "numeric_version" "package_version" "pcre_config"
#> [5] "rep_len"         "seq_along"       "seq_len"         "sort_by"
head(fns[dotted], 4L)
#> [1] "all.equal"             "all.equal.character"   "all.equal.default"
#> [4] "all.equal.environment"
head(fns[camel], 4L)
#> [1] "activeBindingFunction" "addNA"                 "addTaskCallback"
#> [4] "allowInterrupts"

# T and F are ordinary variables; TRUE and FALSE are reserved words.
T <- FALSE # nolint: T_and_F_symbol_linter, object_name_linter.
isTRUE(c(TRUE, T)[2L]) # nolint: T_and_F_symbol_linter.
#> [1] FALSE
rm(T) # nolint: T_and_F_symbol_linter.
T # nolint: T_and_F_symbol_linter.
#> [1] TRUE
tryCatch(TRUE <- FALSE, error = \(e) conditionMessage(e))
#> [1] "invalid (do_set) left-hand side to assignment"

In base R 4.6.1 there are 344 dotted names (all.equal, is.na, as.numeric) against 8 in snake case (seq_len, seq_along, rep_len, sort_by, numeric_version, package_version, pcre_config and l10n_info), and 200 in camel case (addNA, readLines, tryCatch). A new package that follows the tidyverse guide will therefore sit next to base functions in three styles; object_name_linter checks only the names the package defines.

The last two lines of the script are the reason for the guide’s rule about T and F. The help page says that TRUE and FALSE are reserved words and T and F are global variables whose initial values are those constants. Assigning to T is allowed, and c(TRUE, T) then holds TRUE and FALSE; assigning to TRUE is a syntax-level error (invalid (do_set) left-hand side to assignment). T_and_F_symbol_linter is a default linter for that reason.

Spacing, braces and line length

The guide puts a space after a comma and never before it; no spaces inside the parentheses of a call; a space before the parenthesis of if, for and while; a space after the parentheses of function; and a space on both sides of most infix operators. The exceptions that take no space are the high-precedence operators ::, $, @, [, [[, ^, unary minus and plus, and :. The embracing operator is written with inner spaces, {{ var }}. Extra spaces are allowed only to align = or <- in neighbouring lines.

A line should have at most 80 characters, and a call that does not fit goes one argument per line with the closing parenthesis on its own line. A braced expression has { as the last character of its line, indents its contents by two spaces and has } as the first character of its line, with else on the same line as }. The body of a loop is always braced; a one-line if may omit the braces only when it has no side effects and does not change the control flow, so if (y < 0) stop("Y is negative") belongs in braces. Semicolons are never recommended.

Assignment, quotes and logical values

Assignment uses <-, not =. Writing R Extensions says the same in its section on tidying code: it recommends the consistent use of <- rather than = for assignment. Strings use double quotes unless the text itself contains double quotes and no single quotes. TRUE and FALSE are written in full. The guide also asks for explicit conditions: if (length(x) > 0) instead of if (length(x)), && and || (never & and |) inside an if, and no assignment inside a function call.

Control flow and functions

Anything that changes the control flow (return(), stop(), break, next) goes on its own line in braces. The guide asks for return() only for early exits: the last expression of a function is its value, so add_two <- function(x, y) { x + y } is preferred to a version that ends with return(x + y). A function that is called for its side effects should return its first argument invisibly so that it can sit inside a pipe, which is what print() methods do (invisible(x)).

Short anonymous functions use the lambda \(x) x + 1 (R 4.1.0), but the guide says not to use \() for functions that span several lines, for named functions or inside a pipe; it asks for function(x) { in those cases. A function definition that does not fit on one line has two accepted layouts: the single-indent layout, with each argument indented by two spaces and ) { on its own line, and the hanging-indent layout, with the arguments aligned under the first one.

Pipes

The guide recommends the base pipe |> over magrittr’s %>% (it says that as of R 4.3.0 the base pipe has every feature of magrittr that it recommends). |> has a space before it and usually a new line after it, and from the second step on each line is indented by two spaces, so each step is one line. A short pipe may stay on one line, but the guide says that short pipes tend to grow and recommends one function per line. A pipe is for a sequence of steps applied to one object; it is not used when several objects are manipulated at once or when an intermediate object deserves a name. The page Pipes and the Native Pipe covers the operator itself.

Comments and files

A comment starts with # followed by one space and explains why, not what or how; comments are in sentence case and end with a full stop only when there are at least two sentences. A script that uses add-on packages loads them all at the very beginning. File names are lower case, contain no spaces or special characters, use - or _ to separate words and end in .R; files that must be used in order start with a number padded to the same width (01-load-data.R), and a name that says final is a warning sign.

What each tool enforces

The table lines the rules up with the tools of this page: the default lintr linter that reports a violation, and whether styler repairs it. The table is a summary made from running the tools, not from the guide’s text; a cell that says "no" means that the tool has no rule for it.

Rule of the guide lintr default linter styler (tidyverse_style())

snake case names, dots only for S3

object_name_linter (snake_case and symbols), object_length_linter (30 characters)

No: it never renames

space after a comma

commas_linter

Yes

spaces around infix operators

infix_spaces_linter

Yes; math_token_spacing chooses which operators

no spaces inside parentheses; space before ( of if, for; none for a call

spaces_inside_linter, spaces_left_parentheses_linter, function_left_parentheses_linter, paren_body_linter

Yes

lines of at most 80 characters

line_length_linter (80)

No: it does not wrap long lines

two-space indentation, no tabs

indentation_linter, whitespace_linter

Yes (indent_by)

braces: { ends the line, } starts one

brace_linter

Yes

no semicolons

semicolon_linter

Yes, it splits the line

<- and not =

assignment_linter

Yes, with scope = "tokens"

double quotes

quotes_linter

Yes, with scope = "tokens"

TRUE and FALSE, not T and F

T_and_F_symbol_linter

No

return() only for early exits

return_linter

No

|> and not %>%; one step per line

pipe_consistency_linter, pipe_continuation_linter

Layout yes; it does not change %>% into |>

no commented-out code

commented_code_linter

No

&& in conditions

vector_logic_linter

No

no trailing blanks or whitespace

trailing_blank_lines_linter, trailing_whitespace_linter

Yes

The rest of the page shows each cell of that table with real output: the lintr column in the section on lintr, the styler column in the section on styler.

Google’s R style guide is a fork

Google publishes an R style guide (google.github.io/styleguide/Rguide.html, again a community document) that describes itself as a fork of the tidyverse guide: it explains only the differences, and says that Google runs styler on every file that is checked in. The differences it lists are few:

Topic Tidyverse guide Google’s fork

Function names

snake case, verbs

BigCamelCase, and a private function starts with a dot

Object names

snake case

Not dot.case any more: the guide says that it used to recommend dot.case and is moving away from it because it clashes with S3 methods

Return

implicit; return() for early exits

explicit return() at the end: it asks not to rely on implicit return

Right-hand assignment

one accepted form of pipe assignment is -> at the end

not supported

attach()

not discussed in the style part

do not use it

Namespaces

not a style rule

qualify every external function (purrr::map()); @importFrom rather than @import

The practical consequence is on the lintr side. The default linters follow the tidyverse rules, so return_linter reports Google’s explicit returns and object_name_linter reports BigCamelCase. A team that has chosen Google’s style configures the opposite choices, return_linter(return_style = "explicit") and an object_name_linter that accepts both styles, object_name_linter(styles = c("snake_case", "CamelCase")), in its .lintr file; lintr cannot apply a different style to functions than to other objects, so accepting both is the closest it gets. The section on configuration shows where such a line goes.

What a linter or a formatter reads

Both kinds of tool start from the parser’s token table, not from the abstract syntax tree that quote() returns. The syntax tree has no comments and no layout, but a formatter must keep the comments and a linter must say in which column a mistake starts. When the option keep.source is TRUE, or parse() is called with keep.source = TRUE, R’s parser records every token with its position, and getParseData() returns that record as a data frame. The help page dates the feature to R 3.0.0, when code from Romain Francois’s parser package was added to the parser. Each row has line1, col1, line2, col2, an id and a parent (the rows form a tree), a token type such as SYMBOL, LEFT_ASSIGN, EQ_ASSIGN, NUM_CONST or COMMENT, a terminal flag and the text. The script shows the terminal rows of the same statement written two ways.

# The token table that linters and formatters read: one row per token.
exprs <- parse(text = "n = n+1", keep.source = TRUE)
tokens <- getParseData(exprs)
tokens[tokens$terminal, c("line1", "col1", "token", "text")]
#>   line1 col1     token text
#> 1     1    1    SYMBOL    n
#> 2     1    3 EQ_ASSIGN    =
#> 5     1    5    SYMBOL    n
#> 6     1    6       '+'    +
#> 8     1    7 NUM_CONST    1

# Comments are tokens too, which is why a formatter can keep them.
exprs <- parse(text = "n <- n + 1 # count", keep.source = TRUE)
tokens <- getParseData(exprs)
tokens[tokens$terminal, c("col1", "token", "text")]
#>   col1       token    text
#> 1    1      SYMBOL       n
#> 2    3 LEFT_ASSIGN      <-
#> 4    6      SYMBOL       n
#> 5    8         '+'       +
#> 7   10   NUM_CONST       1
#> 9   12     COMMENT # count

The two assignments differ in one token: = is EQ_ASSIGN and <- is LEFT_ASSIGN, which is exactly what assignment_linter looks for and what styler’s scope = "tokens" rewrites. The comment # count is a COMMENT token with a position, which is how a formatter can keep it and how commented_code_linter can try to parse it as code. Both tools build on this table: lintr turns it into an XML document and asks XPath questions of it (the next section writes a linter that way), and styler works on what its documentation calls a nested parse table, built from getParseData(). The consequence is the same for both: code that does not parse cannot be styled, and lintr reports it as a lint of type "error". The page Language Objects and Quoting explains the syntax tree itself.

lintr

lintr (lintr.r-lib.org) is a linter for R code. It is a CRAN package, installed with install.packages("lintr"), and it is not part of R. It lints files, directories and packages, chooses its rules from a configuration file called .lintr, and reports in the console, in RStudio’s Markers pane, in editors through the language server, and as GitHub annotations in continuous integration. The version used here is 3.4.0.

lint(), lint_dir() and lint_package()

lint() lints one file, or a string of code when it is given text = or a filename that contains a newline. lint_dir() lints every file of a directory whose name matches a pattern; the default pattern covers .R, .Rmd, .qmd, .Rnw, .Rhtml, .Rrst, .Rtex and .Rtxt, in any letter case. lint_package() lints the places where a package keeps R code: R/, tests/, inst/, vignettes/, data-raw/, demo/ and exec/. All three return an object of class lints, which prints one block per lint: file:line:column: type: [linter] message, then the line of code, then carets under the offending part. When there is nothing to report the printed result is an informational message, No lints found.

library(lintr)

# lint(text =) checks code held in a string; "<text>" stands for the file name.
lints <- lint(text = "x = 1\n")
lints
#> <text>:1:3: style: [assignment_linter] Use <- for assignment, not =.
#> x = 1
#>   ^
class(lints)
#> [1] "lints" "list"
class(lints[[1L]])
#> [1] "lint" "list"
names(lints[[1L]])
#> [1] "filename"      "line_number"   "column_number" "type"
#> [5] "message"       "line"          "ranges"        "linter"
as.data.frame(lints)[, c("line_number", "column_number", "type", "linter")]
#>   line_number column_number  type            linter
#> 1           1             3 style assignment_linter

# Clean code gives an empty "lints" object and a message.
clean <- lint(text = "x <- 1\n")
clean
#> ℹ No lints found.
length(clean)
#> [1] 0

A lints object is a list of lint objects, so length(), [[ and lapply() work on it, and as.data.frame() flattens it into one row per lint with the columns filename, line_number, column_number, type, message, line and linter. A lint has one of the types style, warning or error; the linter that found it is recorded by name, not by its function. Two things are good to know about the call itself. lint() reads the .lintr file only when it lints a file, not when it lints text = (the parse_settings argument defaults to !inline_data, and the 3.3.0-1 release notes say that ad hoc calls such as lint(text = "str") no longer pick up settings automatically). And cache = TRUE stores results so that unchanged code is not linted again.

The next script lints the "before" file of this page. It is an overdue-loans report written the way the tutorials of 2012 might have written it: = for assignment, 1:length(), T for TRUE, x == NA, sapply(), a function called Total.Overdue, a semicolon, an explicit return() and a line that is too long. The file is not linted, on purpose; the script holds its text in a raw string, writes it to a file called messy.R in the working directory, lints that file and deletes it. The string sits between # nolint start and # nolint end comments so that lintr does not lint the long line inside the string.

library(lintr)

# The "before" file, written to the working directory and deleted at the end.
# nolint start: line_length_linter.
messy <- r"---(# messy.R: an overdue-loans report, written badly on purpose
source("scenario.R")
loan_days = 21
count_overdue <- function(loans,today){
  n=0; unused = "x"
  for(i in 1:length(loans$loan_date)){
    if(is.na(loans$return_date[i])==T & !is.na(loans$loan_date[i])) {
      if (today - loans$loan_date[i] > loan_days) n = n+1 }
  }
  return( n )
}
per_member = sapply(split(loans, loans$member), function (x) nrow(x))
n_unreturned = sum(loans$return_date == NA)
Total.Overdue <- count_overdue(loans, shelf_today)   # the total
#old_total <- Total.Overdue + 1
message("There are ", Total.Overdue, " overdue loans among ", nrow(loans), " loans on the shelf, counted on ", format(shelf_today))
print(n_unreturned); print(length(per_member))
)---"
# nolint end
cat(messy, file = "messy.R")

lints <- lint("messy.R")
length(lints)
#> [1] 29
report <- as.data.frame(lints)
report <- report[, c("line_number", "column_number", "type", "linter")]
report
#>    line_number column_number    type                           linter
#> 1            3            11   style                assignment_linter
#> 2            4            33   style                    commas_linter
#> 3            4            38   style                paren_body_linter
#> 4            4            39   style                     brace_linter
#> 5            5             4   style                assignment_linter
#> 6            5             4   style              infix_spaces_linter
#> 7            5             6   style                 semicolon_linter
#> 8            5             8 warning              object_usage_linter
#> 9            5            15   style                assignment_linter
#> 10           6             6   style   spaces_left_parentheses_linter
#> 11           6            12 warning                       seq_linter
#> 12           7             7   style   spaces_left_parentheses_linter
#> 13           7            35   style              infix_spaces_linter
#> 14           7            38   style            T_and_F_symbol_linter
#> 15           7            39 warning              vector_logic_linter
#> 16           8             6   style               indentation_linter
#> 17           8            56   style              infix_spaces_linter
#> 18           8            59   style                     brace_linter
#> 19          10             3   style                    return_linter
#> 20          10            10   style             spaces_inside_linter
#> 21          10            12   style             spaces_inside_linter
#> 22          12            12   style                assignment_linter
#> 23          12            57   style function_left_parentheses_linter
#> 24          13            14   style                assignment_linter
#> 25          13            20 warning                 equals_na_linter
#> 26          14             1   style               object_name_linter
#> 27          15             2   style            commented_code_linter
#> 28          16            81   style               line_length_linter
#> 29          17            20   style                 semicolon_linter
sort(table(report$linter), decreasing = TRUE)
#>
#>                assignment_linter              infix_spaces_linter
#>                                5                                3
#>                     brace_linter                 semicolon_linter
#>                                2                                2
#>             spaces_inside_linter   spaces_left_parentheses_linter
#>                                2                                2
#>                    commas_linter            commented_code_linter
#>                                1                                1
#>                 equals_na_linter function_left_parentheses_linter
#>                                1                                1
#>               indentation_linter               line_length_linter
#>                                1                                1
#>               object_name_linter              object_usage_linter
#>                                1                                1
#>                paren_body_linter                    return_linter
#>                                1                                1
#>                       seq_linter            T_and_F_symbol_linter
#>                                1                                1
#>              vector_logic_linter
#>                                1
table(report$type)
#>
#>   style warning
#>      25       4
unlink("messy.R")

Twenty-nine lints from nineteen linters in seventeen lines. The two columns that matter first are linter, which tells where to read more, and type: four of the lints are warnings, and they are the ones that point to mistakes and not to taste. Line 6 is seq_linter (1:length(loans$loan_date) runs the loop twice with the values 1 and 0 when the data frame has no rows), line 7 has vector_logic_linter (& inside an if), line 13 is equals_na_linter (x == NA is NA, never TRUE) and line 5 is object_usage_linter (unused is assigned and never read). The other 25 are style. When a file is linted, lintr prints the absolute path of the file in front of every lint; the script above prints a data frame instead, so no path appears.

The default linters

linters_with_defaults() is the function that lintr uses when no .lintr file says otherwise, and default_linters is the list it starts from. In 3.4.0 the list has 26 linters. The next script writes one small piece of code for each of them, lints it with that linter alone and prints the first message, so the table of messages below is real output and not a transcription of the documentation.

library(lintr)

# One small piece of code per default linter, and the message it produces.
long_line <- paste0("x <- c(", paste(rep("1111", 17L), collapse = ", "), ")")
long_name <- "a_very_long_object_name_that_goes_on_and_on <- 1"
snippets <- list(
  assignment_linter = "x = 1",
  brace_linter = "f <- function() {1}",
  commas_linter = "c(1,2)",
  commented_code_linter = "# x <- 1",
  equals_na_linter = "x == NA",
  function_left_parentheses_linter = "print (1)",
  indentation_linter = "f <- function() {\n    1\n}",
  infix_spaces_linter = "1+2",
  line_length_linter = long_line,
  object_length_linter = long_name,
  object_name_linter = "MyVar <- 1",
  object_usage_linter = "f <- function() {\n  y <- 1\n}",
  paren_body_linter = "f <- function(x)x",
  pipe_consistency_linter = "x %>% f()",
  pipe_continuation_linter = "x |> f() |>\n  g()",
  quotes_linter = "x <- 'a'",
  return_linter = "f <- function() {\n  return(1)\n}",
  semicolon_linter = "a <- 1; b <- 2",
  seq_linter = "1:length(x)",
  spaces_inside_linter = "c( 1)",
  spaces_left_parentheses_linter = "if(TRUE) 1",
  T_and_F_symbol_linter = "x <- T",
  trailing_blank_lines_linter = "x <- 1\n\n",
  trailing_whitespace_linter = "x <- 1 ",
  vector_logic_linter = "if (a & b) 1",
  whitespace_linter = "\tx <- 1"
)
for (name in names(snippets)) {
  linter <- get(name, asNamespace("lintr"))
  lints <- lint(text = paste0(snippets[[name]], "\n"), linters = list(linter()))
  cat(sprintf("%-32s %-8s %s\n", name, lints[[1L]]$type, lints[[1L]]$message))
}
#> assignment_linter                style    Use <- for assignment, not =.
#> brace_linter                     style    Opening curly braces should never go on their own line and should always be followed by a new line.
#> commas_linter                    style    Put a space after a comma.
#> commented_code_linter            style    Remove commented code.
#> equals_na_linter                 warning  Use is.na() instead of x == NA
#> function_left_parentheses_linter style    Remove spaces before the left parenthesis in a function call.
#> indentation_linter               style    Indentation should be 2 spaces but is 4 spaces.
#> infix_spaces_linter              style    Put spaces around all infix operators.
#> line_length_linter               style    Lines should not be more than 80 characters. This line is 108 characters.
#> object_length_linter             style    Variable and function names should not be longer than 30 characters.
#> object_name_linter               style    Variable and function name style should match snake_case or symbols.
#> object_usage_linter              warning  local variable 'y' assigned but may not be used
#> paren_body_linter                style    Put a space between a right parenthesis and a body expression.
#> pipe_consistency_linter          style    Use the |> pipe operator instead of the %>% pipe operator.
#> pipe_continuation_linter         style    Put a space before `|>` and a new line after it, unless the full pipeline fits on one line.
#> quotes_linter                    style    Only use double-quotes.
#> return_linter                    style    Use implicit return behavior; explicit return() is not needed.
#> semicolon_linter                 style    Replace compound semicolons by a newline.
#> seq_linter                       warning  Use seq_along(...) instead of 1:length(...), which is likely to be wrong in the empty edge case.
#> spaces_inside_linter             style    Do not place spaces after parentheses.
#> spaces_left_parentheses_linter   style    Place a space before left parenthesis, except in a function call.
#> T_and_F_symbol_linter            style    Use TRUE instead of the symbol T.
#> trailing_blank_lines_linter      style    Remove trailing blank lines.
#> trailing_whitespace_linter       style    Remove trailing whitespace.
#> vector_logic_linter              warning  Use `&&` in conditional expressions.
#> whitespace_linter                style    Use spaces to indent, not tabs.
setequal(names(snippets), names(default_linters))
#> [1] TRUE

The linters, with the setting that matters most for each (the settings are from the lintr documentation and were checked against the function signatures):

Linter Reports Default setting

assignment_linter

= used for assignment; since 3.4.0 also <<-

operator = "<-"

brace_linter

curly braces on the wrong line, or } not on its own line

allow_single_line = FALSE

commas_linter

no space after a comma, or a space before one

allow_trailing = FALSE

commented_code_linter

comments that parse as code

none

equals_na_linter

x == NA, x != NA and x %in% NA

none

function_left_parentheses_linter

a space between a function name and the ( of a call

none

indentation_linter

indentation that differs from the one the structure of the code asks for

indent = 2L, hanging_indent_style = "tidy"

infix_spaces_linter

no spaces around an infix operator

allow_multiple_spaces = TRUE

line_length_linter

lines longer than the limit

length = 80L

object_length_linter

names longer than the limit

length = 30L

object_name_linter

names that are not snake case or symbols

styles = c("snake_case", "symbols")

object_usage_linter

undefined names and unused local variables, through codetools

skip_with = TRUE

paren_body_linter

no space between ) and the body of a function

none

pipe_consistency_linter

a pipe other than the configured one

pipe = "|>"

pipe_continuation_linter

a multi-line pipeline in which a pipe is not followed by a new line

none

quotes_linter

single-quoted strings, unless the text contains double quotes

double quote as the delimiter

return_linter

return() as the last expression of a function

return_style = "implicit"

semicolon_linter

; between or after statements

allow_compound = FALSE, allow_trailing = FALSE

seq_linter

1:length(x), 1:nrow(x) and similar, and seq_len(length(x))

none

spaces_inside_linter

spaces just inside (, [ or [[

none

spaces_left_parentheses_linter

no space before the ( of if, for, while

none

T_and_F_symbol_linter

T and F

none

trailing_blank_lines_linter

blank lines at the end of the file

none

trailing_whitespace_linter

spaces at the end of a line

allow_empty_lines = FALSE, allow_in_strings = TRUE

vector_logic_linter

& and | inside if, while and similar conditions

none

whitespace_linter

tabs used for indentation

none

Four things in that list were different in older lintr releases, and an older tutorial may still describe them. cyclocomp_linter was a default linter until lintr 3.2.0 removed it, because the tidyverse style guide has no rule about complexity (it needs the cyclocomp package, which is not installed here). return_linter became a default in lintr 3.2.0 and pipe_consistency_linter in lintr 3.3.0-1, with its default changed to require the native pipe. indentation_linter appeared in lintr 3.1.0. And assignment_linter accepted <<- until 3.4.0 (the version notes give the details).

Tags, and the linters that are not default

A default linter is a rule of the tidyverse style guide or a likely mistake; lintr has many more. available_linters() lists them with their package and their tags, all_linters() returns all of them as a list, linters_with_tags() returns those with some tag, and linters_with_defaults() keeps the defaults and applies changes: a named argument set to NULL removes that linter, an unnamed linter call adds it or replaces its settings. The tags are best_practices, common_mistakes, configurable, consistency, correctness, default, efficiency, executing, package_development, pkg_testthat, readability, regex, robustness, style and tidy_design.

library(lintr)

length(default_linters)
#> [1] 26
class(default_linters)
#> [1] "list"
class(default_linters[[1L]])
#> [1] "linter"   "function"
class(line_length_linter(100L))
#> [1] "linter"   "function"

# Tags group the linters; "default" marks the 26 above.
available_tags()
#>  [1] "best_practices"      "common_mistakes"     "configurable"
#>  [4] "consistency"         "correctness"         "default"
#>  [7] "efficiency"          "executing"           "package_development"
#> [10] "pkg_testthat"        "readability"         "regex"
#> [13] "robustness"          "style"               "tidy_design"
nrow(available_linters())
#> [1] 117
count_tag <- \(tag) nrow(available_linters(tags = tag))
vapply(available_tags(), count_tag, integer(1L))
#>      best_practices     common_mistakes        configurable         consistency
#>                  65                  13                  44                  33
#>         correctness             default          efficiency           executing
#>                   7                  26                  30                   6
#> package_development        pkg_testthat         readability               regex
#>                  14                  12                  66                   4
#>          robustness               style         tidy_design
#>                  19                  40                   1
available_linters(tags = "correctness")$linter
#> [1] "duplicate_argument_linter" "equals_na_linter"
#> [3] "missing_argument_linter"   "namespace_linter"
#> [5] "object_usage_linter"       "package_hooks_linter"
#> [7] "sprintf_linter"
available_linters(tags = "executing")$linter
#> [1] "namespace_linter"        "object_length_linter"
#> [3] "object_name_linter"      "object_overwrite_linter"
#> [5] "object_usage_linter"     "unused_import_linter"

# Keep the defaults, change one, drop one, add one.
mine <- linters_with_defaults(
  line_length_linter(100L),
  commented_code_linter = NULL,
  todo_comment_linter()
)
length(mine)
#> [1] 26
setdiff(names(mine), names(default_linters))
#> [1] "todo_comment_linter"
setdiff(names(default_linters), names(mine))
#> [1] "commented_code_linter"

# all_linters() includes cyclocomp_linter(), which needs the cyclocomp package.
tryCatch(all_linters(), warning = \(w) conditionMessage(w))
#> [1] "Cyclocomp complexity is computed using `cyclocomp::cyclocomp()`.\nℹ Please install the needed cyclocomp package."
length(all_linters(cyclocomp_linter = NULL))
#> [1] 116

There are 117 linters in 3.4.0, 26 of them default; readability (66 linters) and best_practices (65) are the largest tags, style has 40. linters_with_defaults() returns a named list in which each element is a function with the class linter; a linter is created by calling its factory, so line_length_linter(100L) is a linter and line_length_linter is not (the section on errors shows the message). The correctness tag marks seven linters, executing six. The last two lines of the script show the one dependency lintr has on an optional package: all_linters() includes cyclocomp_linter(), which needs the cyclocomp package, and warns when it is missing; all_linters(cyclocomp_linter = NULL) leaves it out.

Some non-default linters are worth switching on in most projects:

Linter Reports

undesirable_function_linter

calls to functions on a list (by default library, source, attach, setwd, options, sapply, mapply, structure, Sys.setenv and others), with the suggested alternative; the list is data that modify_defaults() edits

undesirable_operator_linter

operators on a list (by default <<-, ->> and :::)

implicit_integer_linter

a whole-number literal such as 5 that is a double, when 5L or 5.0 was meant; strict, since every literal counts

nonportable_path_linter, absolute_path_linter

paths built with a separator inside the string, and absolute paths

library_call_linter

library() calls that are not at the top of the file, library("pkg", character.only = TRUE), and runs of suppressMessages() calls

unused_import_linter

packages attached with library() that the code never uses (an executing linter)

todo_comment_linter

comments that contain TODO, FIXME or similar; except_regex allows linked ones

nested_ifelse_linter

ifelse() inside ifelse(); the message suggests case_when(), fcase() or a lookup table

cyclocomp_linter

functions above a cyclomatic-complexity threshold (needs the cyclocomp package)

library(lintr)

code <- c(
  "library(dplyr)",
  'setwd("/home/me/project")',
  'books <- read.csv("C:/data/books.csv")',
  "squares <- sapply(1:10, function(i) i^2)",
  "# TODO: handle the empty shelf",
  'size <- ifelse(n > 9, "big", ifelse(n > 3, "medium", "small"))',
  "attach(mtcars)"
)
linters <- list(
  undesirable_function_linter(),
  absolute_path_linter(),
  nonportable_path_linter(),
  todo_comment_linter(),
  nested_ifelse_linter()
)
lint(text = paste0(paste(code, collapse = "\n"), "\n"), linters = linters)
#> <text>:1:1: style: [undesirable_function_linter] Avoid undesirable function "library". As an alternative, use roxygen2's @importFrom statement in packages and `::` in scripts, instead of modifying the global search path.
#> library(dplyr)
#> ^~~~~~~
#> <text>:2:1: style: [undesirable_function_linter] Avoid undesirable function "setwd". As an alternative, use withr::with_dir() for a temporary change instead of modifying the global working directory.
#> setwd("/home/me/project")
#> ^~~~~
#> <text>:2:8: warning: [absolute_path_linter] Do not use absolute paths.
#> setwd("/home/me/project")
#>        ^~~~~~~~~~~~~~~~
#> <text>:2:8: warning: [nonportable_path_linter] Use file.path() to construct portable file paths.
#> setwd("/home/me/project")
#>        ^~~~~~~~~~~~~~~~
#> <text>:3:20: warning: [absolute_path_linter] Do not use absolute paths.
#> books <- read.csv("C:/data/books.csv")
#>                    ^~~~~~~~~~~~~~~~~
#> <text>:3:20: warning: [nonportable_path_linter] Use file.path() to construct portable file paths.
#> books <- read.csv("C:/data/books.csv")
#>                    ^~~~~~~~~~~~~~~~~
#> <text>:4:12: style: [undesirable_function_linter] Avoid undesirable function "sapply". As an alternative, use vapply() with an appropriate `FUN.VALUE=` argument to obtain type-stable simplification.
#> squares <- sapply(1:10, function(i) i^2)
#>            ^~~~~~
#> <text>:5:1: style: [todo_comment_linter] Remove TODO comments.
#> # TODO: handle the empty shelf
#> ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
#> <text>:6:30: warning: [nested_ifelse_linter] Don't use nested ifelse() calls; instead, try (1) data.table::fcase; (2) dplyr::case_when; or (3) using a lookup table.
#> size <- ifelse(n > 9, "big", ifelse(n > 3, "medium", "small"))
#>                              ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
#> <text>:7:1: style: [undesirable_function_linter] Avoid undesirable function "attach". As an alternative, use roxygen2's @importFrom statement in packages, or `::` in scripts. attach() modifies the global search path.
#> attach(mtcars)
#> ^~~~~~

# The list of undesirable functions is data, and can be edited.
names(default_undesirable_functions)
#>  [1] ".libPaths"     "attach"        "browser"       "debug"
#>  [5] "debugcall"     "debugonce"     "detach"        "library"
#>  [9] "mapply"        "options"       "par"           "require"
#> [13] "sapply"        "setwd"         "sink"          "source"
#> [17] "structure"     "Sys.setenv"    "Sys.setlocale" "trace"
#> [21] "undebug"       "untrace"
mine <- modify_defaults(
  default_undesirable_functions,
  library = NULL,
  sapply = NULL
)
length(default_undesirable_functions)
#> [1] 22
length(mine)
#> [1] 20

# implicit_integer_linter wants 5L or 5.0 for a whole-number literal.
numbers <- "n <- 5\nm <- 5L\nk <- 1:3\n"
lints <- lint(text = numbers, linters = implicit_integer_linter())
vapply(lints, \(x) x$message, character(1L))
#> [1] "Use 5L or 5.0 to avoid implicit integers."
#> [2] "Use 1L or 1.0 to avoid implicit integers."
#> [3] "Use 3L or 3.0 to avoid implicit integers."

The undesirable-function list is advice, not a ban: library is on it because it changes the search path of a package or script, and the lintr message suggests :: and @importFrom as the alternative. A script that attaches one package at the top is fine, which is why the list is meant to be edited with modify_defaults(), as the script does. implicit_integer_linter is the strictest of the group; this reference’s examples would be full of its lints (n <- 5), so it is off.

The .lintr file

The settings of a project live in a file called .lintr in the project root. It is a file in Debian control format (DCF, the format of DESCRIPTION): one name: value per field, with continuation lines indented, and the value of each field is evaluated as R code when lintr reads it. lintr::use_lintr() writes a starting file; in a package it also adds ^\.lintr$ to .Rbuildignore, so that the file is not part of the built tarball. The shelf package ships the file that use_lintr(type = "tidyverse") wrote. The script prints it, reads it as DCF and then lints the package twice.

library(lintr)

# The .lintr file the package ships (written by use_lintr(type = "tidyverse")).
cat(readLines(".lintr"), sep = "\n")
#> linters: linters_with_defaults() # see vignette("lintr")
#> encoding: "UTF-8"

# It is a DCF file: read.dcf() splits it into fields, each value is R code.
read.dcf(".lintr")
#>      linters                                             encoding
#> [1,] "linters_with_defaults() # see vignette(\"lintr\")" "\"UTF-8\""

# In a fresh Rscript session, testthat is not attached.
lint_package()
#> tests/testthat/helper-shelf.R:6:12: warning: [object_usage_linter] no visible global function definition for 'test_path'
#>   read.csv(test_path("fixtures", "books-small.csv"))
#>            ^~~~~~~~~

# object_usage_linter() looks functions up where R would; attach testthat first.
library(testthat)
lint_package()
#> ℹ No lints found.

# lint_dir() lints one directory; paths are relative to the working directory.
lint_dir("R")
#> ℹ No lints found.

The two lint_package() calls show a property that matters in continuous integration. In a fresh Rscript session the first call reports a warning in tests/testthat/helper-shelf.R: no visible global function definition for test_path. The helper uses testthat’s test_path() without a prefix, as testthat helper files do, and object_usage_linter runs codetools::checkUsage() in the environment where lintr is running, where testthat has not been attached. After library(testthat) the same call reports no lints. The linters tagged executing (namespace_linter, object_length_linter, object_name_linter, object_overwrite_linter, object_usage_linter and unused_import_linter) evaluate parts of the code, for example to load the packages that a package imports, and their documentation asks that the package itself is loaded, with pkgload::load_all() or by installing it, before linting, and that they are not used on untrusted code (lintr package documentation: executing_linters). A CI job that lints a package therefore installs it first; the job shown later does.

The fields that .lintr supports are the settings that default_settings lists, and the lintr documentation describes these:

Field Meaning Default

linters

the list of linters, usually linters_with_defaults(...), linters_with_tags(...) or all_linters(...)

default_linters

exclusions

files and directories that are not linted; a named entry ("R/zzz.R" = c(2L, 6L)) excludes only the listed lines of that file; globs such as knitr* are accepted since 3.3.0-1; paths are relative to the .lintr file

none (lint_dir() skips renv and packrat, lint_package() skips R/RcppExports.R)

encoding

the encoding of the source files; a Encoding field in DESCRIPTION or a .Rproj file overrides it

UTF-8

exclude

a regular expression for lines to exclude

#[[:space:]]*nolint

exclude_start, exclude_end

regular expressions that open and close an excluded range

the patterns that match # nolint start and # nolint end

exclude_next

a regular expression that excludes the next line

the pattern that matches # nolint next

exclude_linter, exclude_linter_sep

how a comment names the linters to exclude and how the names are separated

the : name_linter. form

cache_directory

where cached results are stored

a directory under the user’s cache

error_on_lint

quit with an error status when there are lints (see the CI section)

FALSE

A setting can also come from an R option named lintr. plus the name (options(lintr.exclude = "# skip lint") beats the file) or from an argument of lint(); the search order for the file itself is the option lintr.linter_file (default .lintr, or the environment variable R_LINTR_LINTER_FILE), the directory of the linted file, the closest parent directory, the home directory and finally the user’s configuration directory. Since lintr 3.1.1 a file named .lintr.R is also accepted, with plain R assignments instead of DCF; the release notes call that support experimental.

use_lintr() writes the starting file for a project. The next script runs it on a copy of the package in a temporary directory, first with type = "tidyverse" (the default linters), then again to show that it refuses to overwrite a file, then with type = "full", which starts from every linter in lintr and excludes the directories renv and packrat. The message about .Rbuildignore appears because the shelf package already has the entry; in a package that lacks it, use_lintr() adds the line. The error message of the second call names the temporary path, so the script shortens it.

library(lintr)

invisible(file.copy("~/shelf", tempdir(), recursive = TRUE))
pkg <- file.path(tempdir(), "shelf")

# use_lintr() writes .lintr and adds it to .Rbuildignore.
unlink(file.path(pkg, ".lintr"))
use_lintr(path = pkg, type = "tidyverse")
#> Configuration file ^\.lintr$ is already ignored in `.Rbuildignore`.
cat(readLines(file.path(pkg, ".lintr")), sep = "\n")
#> linters: linters_with_defaults() # see vignette("lintr")
#> encoding: "UTF-8"

# It will not overwrite an existing file; the message names the temporary path.
tryCatch(
  use_lintr(path = pkg, type = "tidyverse"),
  error = \(e) sub("at '.*'", "at '.lintr'", conditionMessage(e))
)
#> [1] "Found an existing configuration file at '.lintr'."

# type = "full" starts from all the linters of lintr.
unlink(file.path(pkg, ".lintr"))
use_lintr(path = pkg, type = "full")
#> Configuration file ^\.lintr$ is already ignored in `.Rbuildignore`.
cat(readLines(file.path(pkg, ".lintr")), sep = "\n")
#> linters: all_linters(packages = "lintr") # see vignette("lintr")
#> encoding: "UTF-8"
#> exclusions: list("renv", "packrat") # see ?lintr::exclude

The next script works on a copy of the package in a temporary directory, so the package itself is not changed. It writes a stricter .lintr (lines of up to 100 characters, no commented-code check, two linters added, three places skipped), lints, then uses the other two ways of silencing a place: a named entry in exclusions and a # nolint comment.

library(lintr)
library(testthat)

# Work on a copy of the package in a temporary directory.
invisible(file.copy("~/shelf", tempdir(), recursive = TRUE))
pkg <- file.path(tempdir(), "shelf")

# A stricter .lintr: wider lines, no commented-code check, two extra linters,
# and three places that are not linted at all.
config <- c(
  "linters: linters_with_defaults(",
  "    line_length_linter(100L),",
  "    commented_code_linter = NULL,",
  "    undesirable_function_linter(),",
  "    todo_comment_linter()",
  "  )",
  'exclusions: list("tests/testthat.R", "vignettes", "data-raw")',
  'encoding: "UTF-8"'
)
writeLines(config, file.path(pkg, ".lintr"))
lint_package(pkg)
#> R/book.R:47:3: style: [undesirable_function_linter] Avoid undesirable function "structure". As an alternative, Use `class<-`, `names<-`, and `attr<-` to set attributes.
#>   structure(
#>   ^~~~~~~~~
#> R/book.R:118:3: style: [undesirable_function_linter] Avoid undesirable function "structure". As an alternative, Use `class<-`, `names<-`, and `attr<-` to set attributes.
#>   structure(
#>   ^~~~~~~~~
#> R/zzz.R:2:9: style: [undesirable_function_linter] Avoid undesirable function "options". As an alternative, use withr::with_options() for a temporary change instead of permanently modifying the session options.
#>   op <- options()
#>         ^~~~~~~
#> R/zzz.R:6:5: style: [undesirable_function_linter] Avoid undesirable function "options". As an alternative, use withr::with_options() for a temporary change instead of permanently modifying the session options.
#>     options(op_shelf[to_set])
#>     ^~~~~~~

# A named entry in exclusions skips only the listed lines of one file.
config[7L] <- paste0(
  'exclusions: list("tests/testthat.R", "vignettes", "data-raw", ',
  '"R/zzz.R" = c(2L, 6L))'
)
writeLines(config, file.path(pkg, ".lintr"))
lint_package(pkg)
#> R/book.R:47:3: style: [undesirable_function_linter] Avoid undesirable function "structure". As an alternative, Use `class<-`, `names<-`, and `attr<-` to set attributes.
#>   structure(
#>   ^~~~~~~~~
#> R/book.R:118:3: style: [undesirable_function_linter] Avoid undesirable function "structure". As an alternative, Use `class<-`, `names<-`, and `attr<-` to set attributes.
#>   structure(
#>   ^~~~~~~~~

# A comment on the line does the same from inside the code.
config[7L] <- 'exclusions: list("tests/testthat.R", "vignettes", "data-raw")'
writeLines(config, file.path(pkg, ".lintr"))
zzz <- readLines(file.path(pkg, "R", "zzz.R"))
zzz[6L] <- paste(zzz[6L], "# nolint: undesirable_function_linter.")
writeLines(zzz, file.path(pkg, "R", "zzz.R"))
lint_package(pkg)
#> R/book.R:47:3: style: [undesirable_function_linter] Avoid undesirable function "structure". As an alternative, Use `class<-`, `names<-`, and `attr<-` to set attributes.
#>   structure(
#>   ^~~~~~~~~
#> R/book.R:118:3: style: [undesirable_function_linter] Avoid undesirable function "structure". As an alternative, Use `class<-`, `names<-`, and `attr<-` to set attributes.
#>   structure(
#>   ^~~~~~~~~
#> R/zzz.R:2:9: style: [undesirable_function_linter] Avoid undesirable function "options". As an alternative, use withr::with_options() for a temporary change instead of permanently modifying the session options.
#>   op <- options()
#>         ^~~~~~~

The four lints are legitimate observations about code that is fine for a package: structure() builds the object of the book class, and .onLoad() sets a package option, which options() is for. That is why the lint is advice and why lintr has three layers of silencing, from the widest to the narrowest. The linters field turns a linter off for the whole project. exclusions turns off files, directories or lines of a file without touching the code. A # nolint comment turns off one line and has the advantage that the reason sits next to the code.

Excluding code: nolint

A lint on a line is dropped when the line carries a comment that matches the exclude pattern. The forms, which the next script runs on seven lines, are # nolint (all linters, this line), # nolint: assignment_linter. (only the named linters, this line, with a trailing full stop and several linters separated by commas), # nolint start and # nolint end (a range, optionally with : linter_name. after start) and # nolint next (the next line).

library(lintr)

# Each line of "code" is lintable on its own; the comments are the markers.
code <- c(
  "a = 1",
  "b = 2 # nolint",
  "c = 3 # nolint: assignment_linter.",
  "d = 4 # nolint: object_name_linter.",
  "# nolint start",
  "e = 5",
  "f = 6",
  "# nolint end",
  "# nolint next",
  "g = 7",
  "# nolint start: assignment_linter.",
  "h <- sapply(1:3, identity); i = 9",
  "# nolint end",
  ""
)
lints <- lint(text = paste(code, collapse = "\n"))
as.data.frame(lints)[, c("line_number", "linter")]
#>   line_number            linter
#> 1           1 assignment_linter
#> 2           4 assignment_linter
#> 3          12  semicolon_linter

# The marker patterns are settings, and they are regular expressions.
default_settings$exclude
#> #[[:space:]]*nolint
default_settings$exclude_start
#> #[[:space:]]*nolint start
default_settings$exclude_end
#> #[[:space:]]*nolint end
default_settings$exclude_next
#> #[[:space:]]*nolint next

Line 4 shows that the named form excludes only what it names: # nolint: object_name_linter. does not silence the assignment lint. The range on lines 11 to 13 silences assignment_linter and leaves the semicolon lint on line 12. This reference uses the named form with start/end or on one line, so that a reader sees which rule is being set aside (the examples above on T and F, and the string that holds the "before" file). A bare # nolint is the opposite habit: it silences every present and future linter on the line. A marker inside a string is not a comment, so it does not count: the scripts on this page contain such markers in strings and are linted normally.

Writing a linter

A linter is a function with the class linter that returns a function of a source expression. The fastest way to write one is make_linter_from_xpath(), which takes an XPath query on the XML version of the parse data and a message. The script reports every call to print(), a rule some projects have for scripts that should use message(). The query uses the token names of the earlier table: SYMBOL_FUNCTION_CALL is the token of a name in the position of a function being called.

library(lintr)

# A linter built from an XPath query on the parse tree: report calls to print().
no_print_linter <- make_linter_from_xpath(
  xpath = "//SYMBOL_FUNCTION_CALL[text() = 'print']",
  lint_message = "Use message() for progress output."
)
class(no_print_linter)
#> [1] "function"
lint(text = "print('hi')\nx <- 1\n", linters = list(no_print_linter()))
#> <text>:1:1: warning: [no_print_linter] Use message() for progress output.
#> print('hi')
#> ^~~~~

The vignette on creating linters describes the more general Linter() constructor, the variant make_linter_from_function_xpath() for linters about calls of named functions, and expect_lint(), the testthat expectation that checks the lints of a piece of code.

lintr in continuous integration

A CI job runs lintr::lint_package() and fails when there are lints. Two settings make the failure visible. The environment variable LINTR_ERROR_ON_LINT=true (or the error_on_lint setting) makes printing a non-empty lints object quit R with a non-zero status, and when R runs on GitHub Actions the printing turns every lint into an annotation on the pull request. The transcript runs lint() on a one-line file in a shell, with and without the variable; the file is printed with its absolute path, shortened here to ~/shelf.

printf 'x = 1\n' > demo.R
LINTR_ERROR_ON_LINT=true Rscript -e 'lintr::lint("demo.R")'
echo "exit status: $?"
Rscript -e 'lintr::lint("demo.R")'
echo "exit status: $?"
rm demo.R
~/shelf/demo.R:1:3: style: [assignment_linter] Use <- for assignment, not =.
x = 1
  ^
exit status: 31
~/shelf/demo.R:1:3: style: [assignment_linter] Use <- for assignment, not =.
x = 1
  ^
exit status: 0

The status 31 is what lintr 3.4.0 used when the transcript was recorded; the documentation only says that the process is killed. The lint is printed in both runs, and without the variable the script ends normally, which is why a CI job that does not set it passes with lints in its log. For GitHub Actions the r-lib/actions project publishes ready-made workflows, and usethis::use_github_action("lint") copies the lint.yaml one into .github/workflows; lint-changed-files and lint-project are the variants for pull requests and for projects that are not packages. The workflow is a YAML file of the following shape (read on 2026-10-21 from the example in the repository, not run here, with actions/checkout@v6 as the example pins it):

on:
  push:
    branches: [main, master]
  pull_request:

name: lint.yaml

permissions: read-all

jobs:
  lint:
    runs-on: ubuntu-latest
    env:
      GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}
    steps:
      - uses: actions/checkout@v6

      - uses: r-lib/actions/setup-r@v2

      - uses: r-lib/actions/setup-r-dependencies@v2
        with:
          extra-packages: any::lintr, local::.
          needs: lint

      - name: Lint
        run: lintr::lint_package()
        shell: Rscript {0}
        env:
          LINTR_ERROR_ON_LINT: true

The setup-r-dependencies step installs the package with local::., so the executing linters can load it. Other CI systems read other formats, and lintr writes three of them from a lints object returned by lint_package(): sarif_output() (SARIF, which GitHub code scanning reads), gitlab_output() (GitLab’s code-quality report) and checkstyle_output() (Checkstyle XML). sarif_output() stops with the error Package path needs to be a relative path. when the lints carry absolute paths, which is why it is used on the result of lint_package() or lint_dir() and not on that of lint() for a file. The script writes the first two to temporary files and prints their first lines; the severity of a style lint is info in the GitLab file and note in SARIF.

library(lintr)
library(testthat)

invisible(file.copy("~/shelf", tempdir(), recursive = TRUE))
pkg <- file.path(tempdir(), "shelf")
config <- c(
  "linters: linters_with_defaults(undesirable_function_linter())",
  'exclusions: list("tests", "vignettes", "data-raw")'
)
writeLines(config, file.path(pkg, ".lintr"))
lints <- lint_package(pkg)
length(lints)
#> [1] 4

# Machine-readable output for CI systems: GitLab code quality ...
gitlab <- tempfile(fileext = ".json")
gitlab_output(lints, filename = gitlab)
cat(head(readLines(gitlab), 13L), sep = "\n")
#> [
#>   {
#>     "description": "Avoid undesirable function \"structure\". As an alternative, Use `class<-`, `names<-`, and `attr<-` to set attributes.",
#>     "check_name": "undesirable_function_linter",
#>     "fingerprint": "68c40a06654e2a71431c56d460ce3106dd57bdff",
#>     "location": {
#>       "path": "R/book.R",
#>       "lines": {
#>         "begin": 47
#>       }
#>     },
#>     "severity": "info"
#>   },

# ... and SARIF, which GitHub code scanning reads.
sarif <- tempfile(fileext = ".sarif")
sarif_output(lints, filename = sarif)
cat(head(readLines(sarif), 20L), sep = "\n")
#> {
#>   "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json",
#>   "version": "2.1.0",
#>   "runs": [
#>     {
#>       "tool": {
#>         "driver": {
#>           "name": "lintr",
#>           "informationUri": "https://lintr.r-lib.org/",
#>           "version": "3.4.0",
#>           "rules": [
#>             {
#>               "id": "undesirable_function_linter",
#>               "fullDescription": {
#>                 "text": "Avoid undesirable function \"structure\". As an alternative, Use `class<-`, `names<-`, and `attr<-` to set attributes."
#>               },
#>               "defaultConfiguration": {
#>                 "level": "note"
#>               }
#>             }

# checkstyle_output() writes the Checkstyle XML format.
checkstyle <- tempfile(fileext = ".xml")
checkstyle_output(lints, filename = checkstyle)
file.exists(checkstyle)
#> [1] TRUE

Common errors and warnings

The messages below are the real ones of lintr 3.4.0. A syntax error is not an R error: the linter turns it into a lint of type error so that the other files are still linted. The rest are the leftovers of older code: linters given by name, a linter function that was not called, a function that was removed, and a file that is not there.

library(lintr)

# Code that does not parse is reported as a lint of type "error".
lints <- lint(text = "x <- (1\n")
lints
#> <text>:1:8: error: [error] unexpected end of input
#> x <- (1
#>        ^
lints[[1L]]$type
#> [1] "error"
lints[[1L]]$linter
#> [1] "error"

# Linters given by name, the lintr 2.x way, are refused.
tryCatch(
  lint(text = "x = 1\n", linters = "assignment_linter"),
  error = \(e) conditionMessage(e)
)
#> [1] "ℹ Expected `linters()` to be a function of class <linter>.\n✖ Instead, it is a string."

# A linter must be called: seq_linter() is a linter, seq_linter a factory.
tryCatch(
  lint(text = "1:length(x)\n", linters = seq_linter),
  error = \(e) conditionMessage(e)
)
#> [1] "ℹ Expected `linters()` to be a function of class <linter>.\n✖ Instead, it is a function."

# with_defaults() was removed in lintr 3.3.0-1.
exists("with_defaults", where = asNamespace("lintr"))
#> [1] FALSE
tryCatch(with_defaults(), error = \(e) conditionMessage(e))
#> [1] "could not find function \"with_defaults\""

# A file that does not exist.
tryCatch(lint("no-such-file.R"), error = \(e) conditionMessage(e))
#> Warning in file(con, "r") :
#>   cannot open file 'no-such-file.R': No such file or directory
#> [1] "cannot open the connection"

Calling the file-not-found case gives first R’s warning from file() and then the error cannot open the connection, which is not very informative; the file name is in the warning. Passing a linter positionally, as in lint("file.R", seq_linter), which lintr 2.x allowed, is the third case above. When lint_package() reports no visible global function definition or no visible binding for global variable for code that is correct, the cause is almost always one of two: the package or its imports are not loaded in the session, or the code uses non-standard evaluation. The section on codetools returns to both.

styler

styler (styler.r-lib.org) is a formatter for R code: it rewrites the layout of code to follow the tidyverse style guide. It is a CRAN package (1.11.0 here), used from the console, from RStudio addins, from languageserver in other editors, in pre-commit hooks and in CI. Its functions are style_text() for a character vector, style_file() for files, style_dir() for a directory and style_pkg() for a package, and the style itself is tidyverse_style(). styler styles .R, .Rmd, .Rmarkdown, .qmd and .Rnw files, and .Rprofile; in a package it also styles the code in roxygen examples (include_roxygen_examples = TRUE).

Two principles run through its documentation. First, styler is a formatter, so it changes layout and not meaning: spaces, line breaks, indentation and a short list of tokens (= as assignment becomes <-, a single-quoted string becomes double-quoted, a semicolon becomes a line break). Second, the more a change can alter the code, the later styler lets it happen: the scope argument orders the changes from the least to the most invasive, and the manual for style_file() explains that styling below scope = "tokens" leaves the abstract syntax tree unchanged, so that styler can check it (round-trip validation), whereas token changes cannot be verified and have to be reviewed by a person.

style_text() and scope

style_text() returns the styled lines, and one element of the character vector is one line of code. The result has the class vertical; cat(x, sep = "\n") writes it plainly (printing it asks for the prettycode package to colour it and warns when it is missing). The script styles a four-line function that has every kind of layout problem, first with the default scope and then with each scope in turn.

library(styler)

code <- c(
  "count_late <- function(loans,today){",
  "n=0; for(i in seq_len(nrow(loans))){",
  "if(is.na(loans$return_date[i]))n=n+1}",
  "n}"
)

# style_text() returns the styled lines; one element is one line of code.
styled <- style_text(code)
class(styled)
#> [1] "vertical"
cat(styled, sep = "\n")
#> count_late <- function(loans, today) {
#>   n <- 0
#>   for (i in seq_len(nrow(loans))) {
#>     if (is.na(loans$return_date[i])) n <- n + 1
#>   }
#>   n
#> }

# scope says how invasive styling may be; each level includes the earlier ones.
for (scope in c("none", "spaces", "indention", "line_breaks", "tokens")) {
  cat("scope =", scope, "\n")
  cat(style_text(code, scope = scope), sep = "\n")
}
#> scope = none
#> count_late <- function(loans,today){
#> n=0; for(i in seq_len(nrow(loans))){
#> if(is.na(loans$return_date[i]))n=n+1}
#> n}
#> scope = spaces
#> count_late <- function(loans, today) {
#> n = 0; for (i in seq_len(nrow(loans))) {
#> if (is.na(loans$return_date[i])) n = n + 1}
#> n}
#> scope = indention
#> count_late <- function(loans, today) {
#>   n = 0; for (i in seq_len(nrow(loans))) {
#>     if (is.na(loans$return_date[i])) n = n + 1}
#>   n}
#> scope = line_breaks
#> count_late <- function(loans, today) {
#>   n = 0; for (i in seq_len(nrow(loans))) {
#>     if (is.na(loans$return_date[i])) n = n + 1
#>   }
#>   n
#> }
#> scope = tokens
#> count_late <- function(loans, today) {
#>   n <- 0
#>   for (i in seq_len(nrow(loans))) {
#>     if (is.na(loans$return_date[i])) n <- n + 1
#>   }
#>   n
#> }

# I() names the levels explicitly: change tokens, but leave the spacing alone.
cat(style_text("a=2", scope = I(c("tokens", "indention"))), sep = "\n")
#> a<-2

The five scopes are, in order of invasiveness, "none" (nothing), "spaces" (spaces between tokens on one line), "indention" (the indentation at the start of a line), "line_breaks" (line breaks between tokens) and "tokens" (the tokens themselves, which is the default). A string names a level and includes all the less invasive ones; I() names the levels one by one, as the last line of the script does: with I(c("tokens", "indention")) the = becomes <- but the missing spaces are left alone, so the result is a<-2. The output above shows the order. With "spaces" the spacing is fixed but the indentation is not. "indention" fixes the indentation, but the closing braces still follow the last statement on its line (n = n + 1}). "line_breaks" moves each closing brace to its own line, but the semicolon in n = 0; for stays. Only "tokens" splits the semicolon and turns the assignments into <-.

Options of tidyverse_style()

tidyverse_style() has six arguments: scope, strict, indent_by, start_comments_with_one_space, reindention and math_token_spacing. style_text(), style_file() and the others pass ... to it, so style_text(x, scope = "spaces") and style_text(x, transformers = tidyverse_style(scope = "spaces")) are the same call. strict = TRUE forces exactly one space where the style wants one; strict = FALSE only inserts a space where there is none and leaves extra ones, which keeps the alignment of assignments that the author lined up on purpose. indent_by is the width of one indentation level. math_token_spacing is a list that says which math operators have spaces around them (specify_math_token_spacing(zero = ...)); the tidyverse default puts spaces around +, -, * and / and none around ^. start_comments_with_one_space normalises #comment to # comment, and reindention lets some comments keep a different indentation.

library(styler)

# strict = TRUE (the default) forces one space; strict = FALSE keeps alignment.
aligned <- c("ab <- 3", "a  <-3", "x    <- 1+  2")
cat(style_text(aligned), sep = "\n")
#> ab <- 3
#> a <- 3
#> x <- 1 + 2
cat(style_text(aligned, strict = FALSE), sep = "\n")
#> ab <- 3
#> a  <- 3
#> x    <- 1 +  2

# indent_by is the width of one indentation level.
body <- c("f <- function() {", "x <- 1", "}")
cat(style_text(body, indent_by = 4), sep = "\n")
#> f <- function() {
#>     x <- 1
#> }

# math_token_spacing chooses which operators get spaces around them.
spacing <- specify_math_token_spacing(zero = c("'/'", "'*'", "'^'"))
cat(style_text("1++1/2*2^2", math_token_spacing = spacing), sep = "\n")
#> 1 + +1/2*2^2

# styler: off and styler: on protect a block.
protected <- c("a=1", "# styler: off", "b   =  2", "# styler: on", "c=3")
cat(style_text(protected), sep = "\n")
#> a <- 1
#> # styler: off
#> b   =  2
#> # styler: on
#> c <- 3

# Pipes get one step per line; a short pipe stays on one line.
pipes <- c("x %>% f(1) %>%", "g()", "x |> f()|>g()", "x|>f(2)")
cat(style_text(pipes), sep = "\n")
#> x %>%
#>   f(1) %>%
#>   g()
#> x |>
#>   f() |>
#>   g()
#> x |> f(2)

Four lines of the output deserve attention. strict = FALSE left a <- 3 and x <- 1 + 2 as the author wrote them, where the default collapsed them to one space. math_token_spacing gave 1 + +1/2*2^2 (the spaces went where the table said, and the unary plus after + got its own). The pipelines with two pipes were split into one step per line, while the single pipe x|>f(2) became x |> f(2) on one line; styler adds those line breaks after %>% and |> but never changes one pipe into the other. And the lines between # styler: off and # styler: on are left as written, which is how a deliberately aligned block survives a formatter (help("stylerignore", package = "styler") describes the markers and how to change them).

What styler does not do

The same snippet through style_text() shows the limit of a formatter. It shortened nothing and renamed nothing; it did not touch the T, the 1:length(x), the sapply() or the comparison with NA, because none of them is a layout problem, and it did not break a call that is longer than the line limit.

library(styler)

# styler lays code out; it does not rename or rewrite what the code means.
long_call <- paste0(
  "very_long_call(first_argument = 1, second_argument = 2, ",
  "third_argument = 3, fourth = 4)"
)
code <- c(
  "total.count = sapply(split(loans, loans$member), nrow)",
  "for(i in 1:length(x)) if (x[i] == NA) y = T",
  long_call
)
cat(style_text(code), sep = "\n")
#> total.count <- sapply(split(loans, loans$member), nrow)
#> for (i in 1:length(x)) if (x[i] == NA) y <- T
#> very_long_call(first_argument = 1, second_argument = 2, third_argument = 3, fourth = 4)

# Code that does not parse cannot be styled.
tryCatch(style_text("x <- ("), error = \(e) conditionMessage(e))
#> [1] "✖ Styling failed\nCaused by error in `parse()`:\n! <text>:2:0: unexpected end of input\n1: x <- (\n   ^"

# An unknown scope is refused.
tryCatch(
  style_text("a=1", scope = "everything"),
  error = \(e) conditionMessage(e)
)
#> [1] "`scope` must be one of \"none\", \"spaces\", \"indention\", \"line_breaks\", or\n\"tokens\", not \"everything\"."

The first line changed in one place only (= became <-); the dotted name total.count and the sapply() stayed. The second line got a space after for and an <-, and kept 1:length(x), x == NA and T. The third line, 87 characters long, was not wrapped. The two errors at the end are styler’s own: code that does not parse cannot be styled (styler runs the parser first, so the error is the parser’s), and scope accepts only the five names. Air, a newer formatter written in Rust, differs in this one respect: its site says that it keeps code within a line width of 80 characters by splitting long calls over several lines, which styler does not do (the section on editors says more).

Files, directories and packages

style_file() styles files in place, style_dir() a directory (recursive by default) and style_pkg() the code of a package. All three return, invisibly, a data frame with one row per file and the column changed; they print a progress report, one line per file, with a tick for an unchanged file and an information sign for a changed one. The documentation of style_file() begins with a warning to examine the result carefully, because the function overwrites files, and recommends styling only files under version control and styling first with a scope below "tokens", committing, and only then with tokens. The argument dry makes the call safe: "off" (the default) writes the changes back, "on" computes them and writes nothing, and "fail" raises an error when styling would change a file, which turns styler into a CI check. dry was added in styler 1.4.0.

The script writes the "before" file to disk, runs the three modes in turn and then lints the styled file, to see what a formatter leaves for a person.

library(styler)
library(lintr)

# The same "before" file as in the lintr example.
# nolint start: line_length_linter.
messy <- r"---(# messy.R: an overdue-loans report, written badly on purpose
source("scenario.R")
loan_days = 21
count_overdue <- function(loans,today){
  n=0; unused = "x"
  for(i in 1:length(loans$loan_date)){
    if(is.na(loans$return_date[i])==T & !is.na(loans$loan_date[i])) {
      if (today - loans$loan_date[i] > loan_days) n = n+1 }
  }
  return( n )
}
per_member = sapply(split(loans, loans$member), function (x) nrow(x))
n_unreturned = sum(loans$return_date == NA)
Total.Overdue <- count_overdue(loans, shelf_today)   # the total
#old_total <- Total.Overdue + 1
message("There are ", Total.Overdue, " overdue loans among ", nrow(loans), " loans on the shelf, counted on ", format(shelf_today))
print(n_unreturned); print(length(per_member))
)---"
# nolint end
cat(messy, file = "messy.R")

# dry = "on" reports what would change and writes nothing.
dry_run <- style_file("messy.R", dry = "on")
#> Styling  1  files:
#>  messy.R ℹ
#> ────────────────────────────────────────
#> Status	Count	Legend
#> ✔ 	0	File unchanged.
#> ℹ 	1	File changed.
#> ✖ 	0	Styling threw an error.
#> ────────────────────────────────────────
#> Please review the changes carefully!
dry_run
#>      file changed
#> 1 messy.R    TRUE
identical(readLines("messy.R"), strsplit(messy, "\n")[[1L]])
#> [1] TRUE

# dry = "fail" turns the same finding into an error, for a CI step.
msg <- tryCatch(
  style_file("messy.R", dry = "fail"),
  error = \(e) conditionMessage(e)
)
#> Styling  1  files:
#>  messy.R
cat("\n", msg, "\n", sep = "")
#>
#> ℹ In index: 1.
#> Caused by error in `map_lgl()`:
#> ℹ In index: 1.
#> Caused by error in `handlers[[1L]]()`:
#> ! File `messy.R` would be modified by styler and argument dry is set to 'fail'.

# The real run rewrites the file; a second run finds nothing to do.
style_file("messy.R")
#> Styling  1  files:
#>  messy.R ℹ
#> ────────────────────────────────────────
#> Status	Count	Legend
#> ✔ 	0	File unchanged.
#> ℹ 	1	File changed.
#> ✖ 	0	Styling threw an error.
#> ────────────────────────────────────────
#> Please review the changes carefully!
style_file("messy.R")
#> Styling  1  files:
#>  messy.R ✔
#> ────────────────────────────────────────
#> Status	Count	Legend
#> ✔ 	1	File unchanged.
#> ℹ 	0	File changed.
#> ✖ 	0	Styling threw an error.
#> ────────────────────────────────────────

# What is left for a human: lintr on the styled file.
after <- as.data.frame(lint("messy.R"))
after[, c("line_number", "column_number", "linter")]
#>   line_number column_number                linter
#> 1           6             3   object_usage_linter
#> 2           7            13            seq_linter
#> 3           8            41 T_and_F_symbol_linter
#> 4           8            42   vector_logic_linter
#> 5          12             3         return_linter
#> 6          15            21      equals_na_linter
#> 7          16             1    object_name_linter
#> 8          17             3 commented_code_linter
#> 9          18            81    line_length_linter
cat(readLines("messy.R"), sep = "\n")
#> # messy.R: an overdue-loans report, written badly on purpose
#> source("scenario.R")
#> loan_days <- 21
#> count_overdue <- function(loans, today) {
#>   n <- 0
#>   unused <- "x"
#>   for (i in 1:length(loans$loan_date)) {
#>     if (is.na(loans$return_date[i]) == T & !is.na(loans$loan_date[i])) {
#>       if (today - loans$loan_date[i] > loan_days) n <- n + 1
#>     }
#>   }
#>   return(n)
#> }
#> per_member <- sapply(split(loans, loans$member), function(x) nrow(x))
#> n_unreturned <- sum(loans$return_date == NA)
#> Total.Overdue <- count_overdue(loans, shelf_today) # the total
#> # old_total <- Total.Overdue + 1
#> message("There are ", Total.Overdue, " overdue loans among ", nrow(loans), " loans on the shelf, counted on ", format(shelf_today))
#> print(n_unreturned)
#> print(length(per_member))
unlink("messy.R")

Of the 29 lints in the "before" file, styler removed 20: all of the spacing, brace, comma, semicolon, assignment, quote and indentation lints. Nine remain, and they are the ones that need a decision: an unused variable (object_usage_linter), 1:length() (seq_linter), T and & in the condition, the explicit return(), x == NA, the dotted name, the commented-out code (it is still a comment after styling, now with a space) and the long line. A formatter and a linter are complementary, then: run the formatter first so that the linter’s output is only about things that matter.

style_dir() and style_pkg() work the same way over many files. style_pkg() skips R/RcppExports.R, R/cpp11.R, R/import-standalone*.R and the directories packrat and renv by default. The next script runs the package-level tools on a copy of the shelf package. shelf passes lint_package() without a lint, but styler would change four of its files. The reason is one layout: a call whose later arguments are aligned under the first one, with the closing parenthesis after the last argument (book("Salt and Static", ..., 2018, and then "Mystery", 244) under the first argument). lintr’s indentation_linter accepts it; styler rewrites it with every argument line indented by two spaces and the closing parenthesis on its own line. The change reaches the roxygen examples in R/book.R, a chunk of the vignette and the tests.

library(styler)
library(lintr)
library(testthat)

invisible(file.copy("~/shelf", tempdir(), recursive = TRUE))
pkg <- file.path(tempdir(), "shelf")

# The package is lintr-clean ...
lint_package(pkg)
#> ℹ No lints found.

# ... but styler would change four of its files (dry = "on" writes nothing).
style_pkg(pkg, dry = "on")
#> Styling  13  files:
#>  R/book.R                      ℹ
#>  R/clean.R                     ✔
#>  R/data.R                      ✔
#>  R/isbn.R                      ✔
#>  R/loans.R                     ✔
#>  R/zzz.R                       ✔
#>  tests/testthat.R              ✔
#>  tests/testthat/helper-shelf.R ✔
#>  tests/testthat/test-book.R    ℹ
#>  tests/testthat/test-clean.R   ℹ
#>  tests/testthat/test-loans.R   ✔
#>  data-raw/books.R              ✔
#>  vignettes/shelf.Rmd           ℹ
#> ────────────────────────────────────────
#> Status	Count	Legend
#> ✔ 	9	File unchanged.
#> ℹ 	4	File changed.
#> ✖ 	0	Styling threw an error.
#> ────────────────────────────────────────
#> Please review the changes carefully!

# One of the changes: a call whose arguments hang from the first line.
code <- c(
  'b <- book("Salt and Static", "Mara Quill", "978-0-01-400145-3", 2018,',
  '          "Mystery", 244)'
)
cat(style_text(code), sep = "\n")
#> b <- book(
#>   "Salt and Static", "Mara Quill", "978-0-01-400145-3", 2018,
#>   "Mystery", 244
#> )

# Apply the changes to the copy: lintr still finds nothing, styler nothing left.
invisible(style_pkg(pkg))
#> Styling  13  files:
#>  R/book.R                      ℹ
#>  R/clean.R                     ✔
#>  R/data.R                      ✔
#>  R/isbn.R                      ✔
#>  R/loans.R                     ✔
#>  R/zzz.R                       ✔
#>  tests/testthat.R              ✔
#>  tests/testthat/helper-shelf.R ✔
#>  tests/testthat/test-book.R    ℹ
#>  tests/testthat/test-clean.R   ℹ
#>  tests/testthat/test-loans.R   ✔
#>  data-raw/books.R              ✔
#>  vignettes/shelf.Rmd           ℹ
#> ────────────────────────────────────────
#> Status	Count	Legend
#> ✔ 	9	File unchanged.
#> ℹ 	4	File changed.
#> ✖ 	0	Styling threw an error.
#> ────────────────────────────────────────
#> Please review the changes carefully!
lint_package(pkg)
#> ℹ No lints found.
style_pkg(pkg, dry = "on")
#> Styling  13  files:
#>  R/book.R                      ✔
#>  R/clean.R                     ✔
#>  R/data.R                      ✔
#>  R/isbn.R                      ✔
#>  R/loans.R                     ✔
#>  R/zzz.R                       ✔
#>  tests/testthat.R              ✔
#>  tests/testthat/helper-shelf.R ✔
#>  tests/testthat/test-book.R    ✔
#>  tests/testthat/test-clean.R   ✔
#>  tests/testthat/test-loans.R   ✔
#>  data-raw/books.R              ✔
#>  vignettes/shelf.Rmd           ✔
#> ────────────────────────────────────────
#> Status	Count	Legend
#> ✔ 	13	File unchanged.
#> ℹ 	0	File changed.
#> ✖ 	0	Styling threw an error.
#> ────────────────────────────────────────

This is a gap between the two tools. The default hanging_indent_style = "tidy" of indentation_linter accepts a hanging indent when the call does not end with a closing parenthesis on a line of its own, and a block indent when it does; styler’s tidyverse_style() always writes the block layout for a call that does not fit on one line, as the style guide’s rule on long function calls asks. A project that uses both should therefore run styler, commit the result, and let lintr check the rest. shelf was written to pass lintr only, which is why the copy needed the four changes; after style_pkg() the copy still passes lint_package() and a second style_pkg(dry = "on") finds nothing to change.

Caching, addins and other ways to run styler

styler is slow on large projects, so since version 1.3.0 it has a cache of styled code. The cache stores the hash of code that already complies, so it takes no disk space to speak of; it is specific to a version of styler, and by default it is cleaned of files older than six days to comply with the CRAN policy (the option styler.cache_root can set a permanent one; cache_info(), cache_activate(), cache_clear() and cache_deactivate() manage it). In RStudio the addins Style active file and Style selection call style_file() and style_text() with the transformers in the option styler.addins_style_transformer. The vignette on third-party integrations lists the rest: the style-files hook of the precommit package (community) that styles before every commit, usethis::use_tidy_style() for a whole project, the GitHub Action that runs style_pkg() when source files change (usethis::use_github_action("style")), the knitr chunk option tidy = "styler" that formats code in R Markdown without changing the source, and the R language server, which uses styler for format-on-save in VS Code and other editors.

codetools: checkUsage() and findGlobals()

codetools is a recommended package: it ships with R, is installed with every standard installation and needs nothing from CRAN. Its author is Luke Tierney. It analyses closures without running them, and its two jobs are checkUsage(), which reports possible problems in a function, and findGlobals(), which lists the global names a function uses. checkUsage() is what object_usage_linter calls and what R CMD check calls on every function of a package.

checkUsage()

checkUsage(fun, name, report = cat, all = FALSE, ...) checks one closure. Without all, it reports names that have no visible definition (global variables and functions) and local variables that are assigned and not used. name is the label printed at the start of every message; leaving it out prints <anonymous>. The script defines a function with a typo, totl, and an unused local, and checks it. findGlobals() returns everything the function uses from outside: operators, for, base functions and global variables; merge = FALSE separates functions from variables. The last call removes loan_days, which source("scenario.R") had defined, and shows that the check consults the function’s environment.

library(codetools)
source("scenario.R")

# A function with a typo and a local variable that is never used.
# nolint start: object_usage_linter.
late_days <- function(loans, today) {
  unused <- 1L
  total <- 0L
  for (i in seq_len(nrow(loans))) {
    total <- total + (today - loans$loan_date[i] > loan_days)
  }
  totl
}
# nolint end
checkUsage(late_days, name = "late_days")
#> late_days: no visible binding for global variable ‘totl’
#> late_days: local variable ‘unused’ assigned but may not be used

# findGlobals() lists what the function takes from outside itself.
findGlobals(late_days)
#>  [1] "-"         "("         "["         "{"         "+"         "<-"
#>  [7] ">"         "$"         "for"       "loan_days" "nrow"      "seq_len"
#> [13] "totl"
findGlobals(late_days, merge = FALSE)$variables
#> [1] "loan_days" "totl"

# Globals are looked up in the function's environment: remove loan_days.
rm(loan_days)
checkUsage(late_days, name = "late_days")
#> late_days: no visible binding for global variable ‘loan_days’
#> late_days: no visible binding for global variable ‘totl’
#> late_days: local variable ‘unused’ assigned but may not be used

The message forms are fixed and worth recognising, because they are also the text of R CMD check notes: no visible binding for global variable ‘name’ (a variable that is neither local, nor an argument, nor found in the environments of the function), no visible global function definition for ‘name’ (the same for a name that is called), and local variable ‘name’ assigned but may not be used. With all = TRUE further messages appear, such as parameter ‘x’ changed by assignment and parameter ‘x’ may not be used. The arguments that matter: all, suppressLocal (all local-variable messages), suppressParamAssigns, suppressParamUnused, suppressLocalUnused, suppressUndefined, skipWith, suppressPartialMatchArgs and report, a function that receives each message (the default is cat). The next script shows the difference all = TRUE makes, a false alarm that non-standard evaluation causes, the skipWith argument that avoids it for with() and a custom report.

library(codetools)

# nolint start: object_usage_linter.
shelve <- function(x) {
  y <- x + 1
  x <- "reassigned"
  helper_that_is_missing(y)
}
# nolint end
checkUsage(shelve, name = "shelve")
#> shelve: no visible global function definition for ‘helper_that_is_missing’
checkUsage(shelve, name = "shelve", all = TRUE)
#> shelve: no visible global function definition for ‘helper_that_is_missing’
#> shelve: parameter ‘x’ changed by assignment

# Non-standard evaluation looks like an undefined variable to codetools.
by_genre <- function(d) {
  subset(d, genre == "fiction") # nolint: object_usage_linter.
}
checkUsage(by_genre, name = "by_genre")
#> by_genre: no visible binding for global variable ‘genre’

# skipWith skips the code inside with() and within().
in_genre <- function(d) with(d, genre == "fiction")
checkUsage(in_genre, name = "in_genre")
#> in_genre: no visible binding for global variable ‘genre’
checkUsage(in_genre, name = "in_genre", skipWith = TRUE)

# report is a function that receives each message; the default is cat().
checkUsage(shelve, name = "shelve", report = \(msg) cat("*", msg))
#> * shelve: no visible global function definition for ‘helper_that_is_missing’

subset(d, genre == "fiction") is correct code, and codetools reports genre because it cannot know that subset() evaluates its second argument inside the data frame. That is the one big source of false positives in static analysis of R, and the reason that functions with data masks (dplyr, subset(), with(), transform(), model formulas) draw notes. lintr’s object_usage_linter(skip_with = TRUE) and R CMD check both skip the body of with() by default (checkUsage() itself defaults to skipWith = FALSE), which is why only the call without skipWith = TRUE complained.

checkUsagePackage() and checkUsageEnv()

checkUsageEnv(env) checks every closure in an environment and checkUsagePackage(pack) every function of a loaded package (it checks the namespace’s internal frame). They take the options of checkUsage(). The script checks shelf with the default options, which print nothing, and with all = TRUE, which prints the kind of message a team can safely ignore or fix at leisure: the unused libname and pkgname that the hook signatures .onLoad() and .onAttach() require, and four parameters that are reassigned.

suppressPackageStartupMessages(library(shelf))

# checkUsagePackage() checks every function of a loaded package.
codetools::checkUsagePackage("shelf")
cat("default: nothing to report\n")
#> default: nothing to report
codetools::checkUsagePackage("shelf", all = TRUE)
#> .onAttach: parameter ‘libname’ may not be used
#> .onAttach: parameter ‘pkgname’ may not be used
#> .onLoad: parameter ‘libname’ may not be used
#> .onLoad: parameter ‘pkgname’ may not be used
#> clean_books: parameter ‘books’ changed by assignment
#> clean_loans: parameter ‘loans’ changed by assignment
#> loan_summary: parameter ‘loans’ changed by assignment
#> overdue_loans: parameter ‘loans’ changed by assignment

R CMD check as static analysis

R CMD check runs codetools as one of its steps, which the log calls "checking R code for possible problems". The manual for writing packages says so in the paragraph that also lists the checks of the package-loading hooks, of the argument lists of S3 methods and of foreign function calls. The same step reports calls to functions of the default packages other than base that the package does not import. R 3.3.0 changed that: since then the check attaches only the base package while it analyses the code, so a use of head() or median() without utils:: or an import is reported as an undefined global, with a suggested importFrom() for the NAMESPACE file (R NEWS, Changes in R 3.3.0). This is the origin of the most common note in package development.

The transcripts below reproduce it in a copy of shelf. A file R/overdue-top.R is added with a function that uses the column days_out of a data frame and head(). The commands build the package without building the vignette and check it without the PDF manual (which needs a TeX installation that the environment of these examples does not have) and without the tests; the header of the check, with the platform, the compiler and the time, is trimmed from the output.

cat > shelf/R/overdue-top.R <<'EOT'
overdue_top <- function(loans, today = shelf_today()) {
  late <- overdue_loans(loans, today)
  late <- late[order(-days_out), ]
  head(late, n = 3L)
}
EOT
R CMD build --no-build-vignettes shelf
R CMD check --no-manual --no-tests --ignore-vignettes shelf_0.1.0.tar.gz
* checking R code for possible problems ... NOTE
overdue_top: no visible binding for global variable ‘days_out’
overdue_top: no visible global function definition for ‘head’
Undefined global functions or variables:
  days_out head
Consider adding
  importFrom("utils", "head")
to your NAMESPACE file.
...
Status: 1 NOTE

The two lines have different causes and different fixes. head is a function of a base package that the package uses without importing it; the check suggests the fix, and the usual fix is to qualify the call, utils::head(), or to add @importFrom utils head to the roxygen block (which writes importFrom(utils, head) into NAMESPACE; see Packages, Libraries and Namespaces). A package that calls utils:: or stats:: functions should also list utils or stats in Imports, which is what usethis::use_package("utils") does. days_out is a column of a data frame. It is not a variable, and the code is right, but codetools cannot know that. The remedy for a variable used through non-standard evaluation is utils::globalVariables(), called once in a source file of the package, outside any function; it declares the names as globals for the purpose of the check. The second version of the file does both. It is a file of the package, R/overdue-top.R, shown as it was checked, and not a script to run on its own.

utils::globalVariables("days_out")

overdue_top <- function(loans, today = shelf_today()) {
  late <- overdue_loans(loans, today)
  late <- late[order(-days_out), ]
  utils::head(late, n = 3L)
}

With that file in place the same two commands print checking R code for possible problems ... OK and Status: OK. The usual alternative for dplyr code, recommended in dplyr’s documentation for package authors, is to avoid the global variable: .data$days_out for data masking (with @importFrom rlang .data) and the string "days_out" for tidy selection, instead of a bare name (dplyr package documentation: using dplyr in packages). The function was removed from the copy again after the experiment.

The check does not report unused local variables by default. Its default profile for codetools is skipWith = TRUE, suppressPartialMatchArgs = FALSE and suppressLocalUnused = TRUE. The environment variable _R_CHECK_CODETOOLS_PROFILE_ changes the profile, and the manual for writing packages recommends suppressLocalUnused=FALSE as a revealing customisation, because an unused local assignment can be a mistyped name or a <- that should have been <<-. The third experiment shows it:

cat > shelf/R/overdue-top.R <<'EOT'
overdue_count <- function(loans, today = shelf_today()) {
  scratch <- overdue_loans(loans, today)
  nrow(overdue_loans(loans, today))
}
EOT
R CMD build --no-build-vignettes shelf
R CMD check --no-manual --no-tests --ignore-vignettes shelf_0.1.0.tar.gz
_R_CHECK_CODETOOLS_PROFILE_="suppressLocalUnused=FALSE" R CMD check --no-manual --no-tests --ignore-vignettes shelf_0.1.0.tar.gz
rm shelf/R/overdue-top.R

The first check ends with checking R code for possible problems ... OK and Status: OK; the second prints

* checking R code for possible problems ... NOTE
overdue_count: local variable ‘scratch’ assigned but may not be used
...
Status: 1 NOTE

The manual adds that this setting can give false positives, most often because of non-standard evaluation in formulas and because the intention is to return objects in the environment of a function. The environment variable _R_CHECK_USE_CODETOOLS_ (true by default when the recommended packages are installed) turns the whole step off; leaving it on is almost always right. The other static checks in R CMD check have their own pages: undocumented objects and mismatched usage sections (Documentation with Rd, roxygen2 and Vignettes), the namespace and dependency checks (Creating Packages) and the submission rules (Publishing to CRAN).

Other tools, briefly

The packages below were not installed in the image, so they are described from their CRAN pages (read on 2026-10-21) and not run.

Tool What it is

goodpractice (1.2.0)

gives advice on building R packages: functions and syntax to avoid, package structure, code complexity and code formatting. goodpractice::gp() runs it on a package; its CRAN dependencies include lintr, cyclocomp, rcmdcheck and covr

cyclocomp (1.1.2)

computes the cyclomatic complexity of R code; cyclocomp_linter of lintr uses it, and lintr 3.2.0 made it optional

spelling (2.3.2)

spell checking of Rd files, DESCRIPTION, vignettes and other documents; spelling::spell_check_package() is a typical tests/spelling.R

urlchecker (2.0.0)

the URL checks of R CMD check for older versions of R, faster because the requests are concurrent; urlchecker::url_check()

rcmdcheck (1.4.0, installed in the image)

runs R CMD check from R and returns the result as an object

pkgcheck

rOpenSci’s checks for a package before its peer review; it collates the output of goodpractice, R CMD check, statistics from the pkgstats package and checks of the package’s structure

languageserver (0.3.20)

the Language Server Protocol server for R; shows lintr diagnostics and formats with styler in editors (next section)

formatR (1.14)

formatR::tidy_source() formats R source code: it adds spaces and indentation, and by its CRAN description preserves comments only under certain conditions

R CMD check also has the tools functions behind its documentation checks (tools::undoc(), tools::codoc(), tools::checkFF(), tools::checkRd()), which can be called on their own. They belong to the page on documentation.

Editors

Every editor integration of these tools goes through one of two routes: the editor calls the R package directly (RStudio), or it talks to the R language server (almost everything else).

RStudio. lintr’s diagnostics show in the Markers pane (lint a file and look at the result), and the package provides two addins, for the current source and for the package, which its documentation suggests binding to Alt+Shift+L and Ctrl+Shift+Alt+L. RStudio also has its own diagnostics, switched on in Tools > Global Options > Code > Diagnostics, including an option to "provide R style diagnostics (e.g. whitespace)", and its own command Reformat Selection (Ctrl+Shift+A in the shortcuts reference). styler’s addins Style active file and Style selection run style_file() and style_text(). See IDEs and the R Console.

VS Code and other editors. The community package languageserver implements the Language Server Protocol for R. In VS Code the R extension from REditorSupport uses it; the lintr documentation says that installing the package and the extension turns lintr on by default. The language server reads .lintr files, as its README says, for the linters; it formats code with styler, with styler::tidyverse_style(indent_by = options$tabSize) as the default style, which the option languageserver.formatting_style can replace. The same server serves Neovim, Emacs (eglot, lsp-mode), Sublime Text and others.

The next block is a fragment: it installs a package from CRAN and was not run.

install.packages("languageserver")

Positron and Air. Positron has its own R support (the Ark kernel and language server described on the page on IDEs). The Air project (posit-dev.github.io/air) is an R formatter and language server written in Rust; its documentation has editor guides for VS Code and Positron, RStudio, Zed, Neovim, Helix and Emacs, and its formatter page says that it keeps lines within a width of 80 characters by splitting long calls and function definitions over several lines, which styler does not do. A project normally picks one formatter, styler or Air. The development version of lintr, whose NEWS.md on GitHub was read on 2026-10-21, adds two arguments to line_length_linter() for compatibility with Air, allow_alignment_calls and allow_long_test_names; the released 3.4.0 used here has only length and ignore_string_bodies, so those two are upcoming. Emacs ESS integrates lintr through flycheck, according to the lintr documentation.

The style of this reference

The examples in this reference follow the tidyverse guide as the default linters enforce it, with a handful of choices that the guide leaves open or that differ from it. Knowing them helps when a reader copies a script and runs their own linter over it.

Convention What the examples do Where it differs from the tidyverse guide or from base R

Assignment

<- everywhere; = only for arguments

as the guide

Logical values

TRUE and FALSE, never T and F

as the guide

Loops over indices

seq_along(x) and seq_len(n), never 1:length(x)

seq_linter is a default linter

Applying a function

vapply() with a declared result type; sapply() appears only in a contrast marked # nolint

sapply() is on the undesirable list of lintr, not a rule of the guide

Pipes

the native pipe |>, one step per line in a pipeline

as the guide; %>% is mentioned, not used

Anonymous functions

\(x) for short functions

the guide asks function(x) { for lambdas that span several lines, and the examples sometimes use \(x) { there; lintr does not flag it

Returns

implicit; return() for early exits

as the guide; Google’s guide differs

Names

snake case for objects the examples define

base R names keep their dots and camel case

Line length

at most 80 characters, comments included

as the guide

Quotes

double quotes; single quotes only around a string that contains double quotes

as the guide

Output in comments

lines that start with #>

a convention of reprex, not of the guide

Silencing a linter

# nolint: name_linter. on the line, or start/end around a block

the guide says nothing about linters

Dates and times in output

a fixed shelf_today, never Sys.Date() in a printed result

so that output is reproducible

Every script in the section was run and then linted with the default linters of lintr 3.4.0 (lintr::lint() with no .lintr), and the rules above are those the linters accept; scripts that are bad on purpose live in a separate directory and are said to be unlinted where they appear.

Version notes

The entries come from the NEWS files of the tools (the lintr and styler NEWS.md files that ship with the packages; lintr’s pkgdown changelog page stops at 3.2.0 and the CRAN copy of the news has the later releases) and from the R NEWS.

Version Change

R 3.0.0

The parser keeps a detailed table of every token, which getParseData() retrieves (R documentation: getParseData)

R 3.3.0

R CMD check checks code usage with codetools while only the base package is attached; functions of other default packages that are used without being imported are reported, with a suggested NAMESPACE addition

R 4.1.0

The native pipe |> and the lambda \(x)

lintr 3.0.0

Every linter is a function factory (seq_linter(), not seq_linter); .lintr exclusions are relative to the file and may name directories; with_defaults() is deprecated in favour of linters_with_defaults() and modify_defaults(); semicolon_terminator_linter() becomes semicolon_linter(); closed_curly_linter(), open_curly_linter() and paren_brace_linter() are merged into brace_linter(); return() leaves the undesirable list

lintr 3.1.0

indentation_linter() is added

lintr 3.1.1

Experimental support for a .lintr.R configuration written as plain R; configuration is validated and invalid settings warn; assignment_linter() lints the assignment pipe %<>%; equals_na_linter() also lints x %in% NA

lintr 3.2.0

return_linter() becomes a default; cyclocomp_linter() is no longer default and cyclocomp moves to Suggests; lint() prints a message when it finds no lints; assignment_linter() gets the operator argument; many deprecated items become fully deprecated

lintr 3.3.0-1

pipe_consistency_linter() is a default linter that requires the native pipe; with_defaults(), passing linters by name and the old curly-brace linters are removed; lint(text = ) no longer reads settings unless parse_settings = TRUE; brace_linter() gets function_bodies; exclusions accepts globs; line_length_linter() gets ignore_string_bodies

lintr 3.4.0

assignment_linter() allows only <- by default (<<- is linted); indentation_linter() follows the updated tidyverse rule for multi-line function definitions (single indent, ) { on its own line); six linters deprecated earlier are removed; excluding cyclocomp_linter() no longer warns when cyclocomp is missing

styler 1.3.0

The cache of styled code

styler 1.4.0

dry mode for style_file() and its relatives

styler 1.9.0

Function declarations indented twice (the tidyverse’s guidance of the time) are accepted

styler 1.10.0

Between 20% and 40% faster, by replacing base functionality with vctrs

styler 1.11.0

Adapts to the single-indent semantics of the style guide; removes excessive line breaks between top-level expressions and blank lines after an opening and before a closing brace; no spaces around the dollar operator; empty curly braces have no spaces around them; formats code with a comment inside {{ }}; coloured output in the R API; webR chunks in R Markdown and Quarto; minimum R version 4.0

The tidyverse style guide itself moved in the same direction as the tools: its page on functions now describes the single-indent layout for function definitions that do not fit on a line next to the hanging one, and its pages recommend the native pipe and the lambda. The guide changes in place, so a rule that an old tutorial quotes from it may no longer be there.

Where older tutorials differ

Older tutorial writes Current practice Why

lintr::with_defaults(line_length_linter(100))

linters_with_defaults(line_length_linter(100L))

with_defaults() was deprecated in lintr 3.0.0 and removed in 3.3.0-1 (could not find function "with_defaults")

lint("file.R", linters = list("assignment_linter")), or lint(file, seq_linter)

lint("file.R", linters = list(assignment_linter()))

linters are factories since 3.0.0; names and uncalled functions were deprecated in 3.0.0 and are refused since 3.3.0-1 (the error says that linters() is expected to be a function of class <linter>)

a .lintr with camel_case_linter, snake_case_linter or multiple_dots_linter, or with closed_curly_linter or open_curly_linter

object_name_linter(styles = ...) and brace_linter()

the naming linters were removed in lintr 3.0.0; the curly-brace linters were merged into brace_linter() in 3.0.0 and removed in 3.3.0-1

cyclocomp_linter taken to be a default (its limit is 15)

opt in: linters_with_defaults(cyclocomp_linter()) and install cyclocomp

no longer default since 3.2.0

formatR::tidy_source() to tidy a file

styler::style_file()

formatR preserves comments only under certain conditions, according to its CRAN description; styler works on the token table, where comments are tokens

the tidy-up recipe of source(keep.source = FALSE) plus dump()

styler::style_file()

that recipe, in Writing R Extensions, drops comments from functions, as the manual warns

dot.case object names, which Google’s guide used to recommend

snake case in the tidyverse guide; BigCamelCase functions in Google’s current guide

Google’s current guide says that it is moving away from dot.case because of S3 methods

= for assignment, as in most other languages

<-

the manual for writing packages recommends <- consistently; assignment_linter is a default

T and F, 1:length(x) and sapply() in examples

TRUE and FALSE, seq_along(x), vapply()

T_and_F_symbol_linter and seq_linter are defaults; sapply() is on the undesirable list

double-indented function arguments

single-indent or hanging-indent definitions

lintr 3.4.0 and styler 1.11.0 follow the guide’s single-indent rule

%>% as the first token of every pipeline

|>

the guide and pipe_consistency_linter (default since lintr 3.3.0-1) prefer the native pipe

attach(df) to avoid typing df$

with(df, ...) or df$col

undesirable_function_linter lists attach; Google’s guide says not to use it; lintr and R CMD check skip the body of with() by default

a bare # nolint to silence a lint

# nolint: name_linter.

a bare marker hides every present and future lint on the line

ignoring R CMD check notes about global variables

utils::globalVariables(), .data$col or utils::fun()

the note is a codetools finding that the reader of the code can act on

Where to go next

References