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 |
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"
Source: R FAQ 7.14: What are valid names?; R documentation: logical (TRUE, FALSE, T, F); R documentation: ls
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 |
|
No: it never renames |
space after a comma |
|
Yes |
spaces around infix operators |
|
Yes; |
no spaces inside parentheses; space before |
|
Yes |
lines of at most 80 characters |
|
No: it does not wrap long lines |
two-space indentation, no tabs |
|
Yes ( |
braces: |
|
Yes |
no semicolons |
|
Yes, it splits the line |
|
|
Yes, with |
double quotes |
|
Yes, with |
|
|
No |
|
|
No |
|
|
Layout yes; it does not change |
no commented-out code |
|
No |
|
|
No |
no trailing blanks or whitespace |
|
Yes |
Source: tidyverse style guide: Syntax (community); tidyverse style guide: Functions (community); tidyverse style guide: Pipes (community); tidyverse style guide: Files (community); lintr package documentation: Get started; styler package documentation: tidyverse_style
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 |
|
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; |
explicit |
Right-hand assignment |
one accepted form of pipe assignment is |
not supported |
|
not discussed in the style part |
do not use it |
Namespaces |
not a style rule |
qualify every external function ( |
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 |
|---|---|---|
|
|
|
|
curly braces on the wrong line, or |
|
|
no space after a comma, or a space before one |
|
|
comments that parse as code |
none |
|
|
none |
|
a space between a function name and the |
none |
|
indentation that differs from the one the structure of the code asks for |
|
|
no spaces around an infix operator |
|
|
lines longer than the limit |
|
|
names longer than the limit |
|
|
names that are not snake case or symbols |
|
|
undefined names and unused local variables, through codetools |
|
|
no space between |
none |
|
a pipe other than the configured one |
|
|
a multi-line pipeline in which a pipe is not followed by a new line |
none |
|
single-quoted strings, unless the text contains double quotes |
double quote as the delimiter |
|
|
|
|
|
|
|
|
none |
|
spaces just inside |
none |
|
no space before the |
none |
|
|
none |
|
blank lines at the end of the file |
none |
|
spaces at the end of a line |
|
|
|
none |
|
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
Source: lintr package documentation: available_linters; lintr package documentation: linters_with_defaults; lintr package documentation: all_linters; lintr package documentation: linters_with_tags
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 |
|---|---|
|
calls to functions on a list (by default |
|
operators on a list (by default |
|
a whole-number literal such as |
|
paths built with a separator inside the string, and absolute paths |
|
|
|
packages attached with |
|
comments that contain TODO, FIXME or similar; |
|
|
|
functions above a cyclomatic-complexity threshold (needs the cyclocomp package) |
Source: lintr package documentation: undesirable_function_linter; lintr package documentation: undesirable_operator_linter; lintr package documentation: implicit_integer_linter; lintr package documentation: nonportable_path_linter; lintr package documentation: absolute_path_linter; lintr package documentation: library_call_linter; lintr package documentation: unused_import_linter; lintr package documentation: todo_comment_linter; lintr package documentation: nested_ifelse_linter; lintr package documentation: cyclocomp_linter
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."
Source: lintr package documentation: undesirable_function_linter; lintr package documentation: modify_defaults
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.
Source: lintr package documentation: Get started (the .lintr file); lintr package documentation: use_lintr; R documentation: read.dcf
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 |
|---|---|---|
|
the list of linters, usually |
|
|
files and directories that are not linted; a named entry ( |
none ( |
|
the encoding of the source files; a |
|
|
a regular expression for lines to exclude |
|
|
regular expressions that open and close an excluded range |
the patterns that match |
|
a regular expression that excludes the next line |
the pattern that matches |
|
how a comment names the linters to exclude and how the names are separated |
the |
|
where cached results are stored |
a directory under the user’s cache |
|
quit with an error status when there are lints (see the CI section) |
|
Source: lintr package documentation: Get started (configuring linters); lintr package documentation: read_settings
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()
#> ^~~~~~~
Source: lintr package documentation: Get started (the .lintr file); lintr package documentation: lint_package
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
Source: lintr package documentation: Get started (the exclusion settings); lintr package documentation: read_settings
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')
#> ^~~~~
Source: lintr package documentation: make_linter_from_xpath; lintr package documentation: Creating new linters
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
Source: r-lib/actions: examples (lint.yaml, community); usethis package documentation: use_github_action
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)
Source: styler package documentation: tidyverse_style; styler package documentation: specify_math_token_spacing; styler package documentation: stylerignore
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)
}
Source: R documentation: globalVariables
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
Source: Writing R Extensions: Checking packages; R Internals: Tools (the R_CHECK environment variables)
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. |
cyclocomp (1.1.2) |
computes the cyclomatic complexity of R code; |
spelling (2.3.2) |
spell checking of Rd files, |
urlchecker (2.0.0) |
the URL checks of |
rcmdcheck (1.4.0, installed in the image) |
runs |
pkgcheck |
rOpenSci’s checks for a package before its peer review; it collates the output of goodpractice, |
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) |
|
Source: goodpractice on CRAN (community); cyclocomp on CRAN (community); spelling on CRAN (community); urlchecker on CRAN (community); pkgcheck documentation (community); languageserver on CRAN (community); formatR on CRAN (community)
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")
Source: languageserver README (community); lintr package documentation: Editor setup; Air documentation: Editors (community); RStudio User Guide: Code diagnostics; RStudio User Guide: Keyboard shortcuts
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 |
|
as the guide |
Logical values |
|
as the guide |
Loops over indices |
|
|
Applying a function |
|
|
Pipes |
the native pipe |
as the guide; |
Anonymous functions |
|
the guide asks |
Returns |
implicit; |
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 |
|
the guide says nothing about linters |
Dates and times in output |
a fixed |
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 |
R 3.3.0 |
|
R 4.1.0 |
The native pipe |
lintr 3.0.0 |
Every linter is a function factory ( |
lintr 3.1.0 |
|
lintr 3.1.1 |
Experimental support for a |
lintr 3.2.0 |
|
lintr 3.3.0-1 |
|
lintr 3.4.0 |
|
styler 1.3.0 |
The cache of styled code |
styler 1.4.0 |
|
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 |
Source: lintr news (CRAN); lintr package documentation: Changelog; styler package documentation: Changelog; R NEWS: Changes in R 3.x; R NEWS: Changes in R 4.1.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 |
|---|---|---|
|
|
|
|
|
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 |
a |
|
the naming linters were removed in lintr 3.0.0; the curly-brace linters were merged into |
|
opt in: |
no longer default since 3.2.0 |
|
|
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 |
|
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; |
Google’s current guide says that it is moving away from dot.case because of S3 methods |
|
|
the manual for writing packages recommends |
|
|
|
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 |
|
|
the guide and |
|
|
|
a bare |
|
a bare marker hides every present and future lint on the line |
ignoring |
|
the note is a codetools finding that the reader of the code can act on |
Where to go next
-
Pipes and the Native Pipe for the
|>rules and the lintr linters that check a pipeline. -
Functions for
return(), lambdas and the other function rules thatreturn_linterand the style guide cover. -
Assignment, Names and Values for the assignment operators and the rules for names.
-
Language Objects and Quoting and Evaluation and Non-Standard Evaluation for the syntax tree behind the parse data and for the data masks that confuse codetools.
-
Creating Packages, Testing with testthat and Publishing to CRAN for the package that was linted here, for lintr in the test suite and for the notes that CRAN reads.
-
Packages, Libraries and Namespaces for
importFrom()and::, which silence the undefined-function notes. -
Debugging and Defensive Programming and Input Validation for the mistakes that run, as opposed to the ones that a linter can find first.
-
IDEs and the R Console for RStudio, Positron and VS Code.
-
Scripts and the Command Line for running a linter from a shell or a hook.
References
-
Writing R Extensions: Checking packages; Tidying R code
-
R Internals: Tools (the
_R_CHECK_*environment variables) -
R FAQ 7.14: What are valid names?
-
R NEWS: Changes in R 4.1.0; R 3.x NEWS: Changes in R 3.3.0
-
codetools package documentation: checkUsage, checkUsageEnv and checkUsagePackage
-
lintr package documentation: lint, lint_dir and lint_package
-
lintr package documentation: Get started; Continuous integration; Editor setup; Creating new linters
-
lintr package documentation: linters; default_linters; linters_with_defaults and modify_defaults; linters_with_tags; all_linters; available_linters
-
lintr package documentation: use_lintr; read_settings; executing_linters
-
lintr package documentation: sarif_output, checkstyle_output and gitlab_output
-
lintr package documentation: undesirable_function_linter; undesirable_operator_linter; implicit_integer_linter; nonportable_path_linter; absolute_path_linter; library_call_linter; unused_import_linter; todo_comment_linter; nested_ifelse_linter; cyclocomp_linter
-
styler package documentation: style_text; style_file; style_pkg; tidyverse_style; specify_math_token_spacing; stylerignore
-
styler package documentation: Get started; Third-party integrations; Changelog