Developing Linters in r-lib/lintr
When implementing, extending, or refactoring linters in lintr, adhere to the following architectural, design, and production-readiness guidelines established by project maintainers:
1. Minimalist API Surface & Defaults ("On by Default")
- Avoid unnecessary configuration knobs: When extending a linter with a new check (e.g., adding namespace import checking to
namespace_linter), do not reflexively add new boolean parameters (likecheck_imports = TRUE) to the public function signature unless there is a clear, compelling user need to toggle only that specific sub-check independently. - Bleeding-edge upstream R features (
TODO(R>=x.y.z)): When recommending new syntax or parameters introduced in recent or upcoming upstream R releases (fixed = TRUEadded tolist.files()in R 4.6.0), package authors may not wish to immediately incur that bleeding-edge dependency. In such cases, providing a toggle (check_file_listing = TRUE) allows authors supporting older R versions to opt out (FALSE). Always mark such transitionary options or workarounds with an explicitTODO(R>=x.y.z)comment right above the function signature (# TODO(R>=4.6.0): Deprecate check_file_listing once R 4.6.0 is the minimum supported version.) indicating exactly when the option should be deprecated and enforced universally. - Natural zero-lint fallback: If a check naturally produces zero lints when not applicable (e.g., inspecting
namespace_imports()on a file outside an R package or in a directory without aNAMESPACEfile returnsempty_namespace_data()), run the check unconditionally as part of the standard linter flow. - Keep signatures clean: Preserving existing function signatures (
namespace_linter(check_exports = TRUE, check_nonexports = TRUE)) reduces API complexity and documentation churn.
2. Helper Functions, Readability, & Safe Fallbacks
- Encapsulating non-trivial logic in helper functions is encouraged: While trivial single-use wrappers purely around basic line lookups or single
vapply(...)steps should generally be avoided if they merely fragment linear control flow, helper functions that encapsulate non-trivial logic (such asget_seq_lint_message()orindent_lint_metadata()) are highly valued, even if invoked only once inside a linter callback. The immense readability improvement and clear domain encapsulation far outweigh their single-use nature. - Minimalist return types for linter helpers: Helper functions that format linter messages (e.g.
get_seq_lint_message(seq_expr)) should return a crisp, simple character vector of messages (character()), rather than leaking internal multi-column data structures back to the main linter callback. Keeping helper return types minimal ensures the linter body stays clean (lint_message <- get_seq_lint_message(seq_expr); xml_nodes_to_lints(seq_expr, source_expression, lint_message, type = "warning")) and keeps PR diffs compact. - Encapsulate empty-state checks inside helpers: Place domain 0-length guards directly at the top of helper functions (
if (length(seq_expr) == 0L) return(character())) rather than polluting the main linter body withif (length(...) == 0L)wrappers. - Namespace-aware string formatting in helpers: When stripping parentheses or formatting extracted function calls (e.g. converting
length(x)->length(...)but preserving 0-argument calls liken()), always ensure exclusions are namespace-aware (!grepl("(^|::)n\\(\\)", funcalls)or!funcalls %in% c("n()", "dplyr::n()")). Checkingfuncalls != "n()"naively fails when passed namespace-prefixed calls likedplyr::n(). - Vectorized argument counting via
xml_find_num_(): Delegate node and argument counting directly to libxml2 usingas.integer(xml_find_num_(nodes, "count(...)")). Never useas.integer(xml_find_chr_(nodes, "string(count(...))")). - Early returns on domain empty states: Check for domain empty states early and exit (
if (nrow(ns_imports) == 0L) return(lints)orif (nrow(lint_line_df) == 0L) return(list())) rather than executing checks over empty data structures. - Avoid distracting checks for 0-line files: Do not recommend or implement defensive guards for truly empty zero-line files (
if (length(source_expression$file_lines) == 0L)). Encountering a 0-line file in practical active extraction and parsing is virtually impossible; suggesting such checks during code reviews creates unnecessary distraction and code clutter. - Rely on existing safe fallbacks: Do not write defensive wrappers around functions that already handle
NULLcleanly. For example,namespace_imports(NULL)safely returnsempty_namespace_data(), soif (!is.null(pkg_path)) namespace_imports(pkg_path)is redundant. - Avoid redundant
trimws()on XML node text: Text extracted viaxml_find_chr_(..., "string(...)")orxml_text()is already clean and trimmed byxml2. Avoid callingtrimws()redundantly on extracted XML string vectors. - Use numbered format specifiers (
%1$s,%2$s) for repeated variables insprintf(): When formatting dynamic lint messages where placeholders are repeated (e.g., displaying both the recommended replacement and the observed violation using the exact same function name and parameter string), use numbered format specifiers (%1$s,%2$s,%3$s). This cleanly avoids redundant argument repetitions across thesprintf()call (sprintf("expect_shape(x, %1$s = %2$s) is better than %3$s(%1$s(x), %2$s)", shape_function, shape_arg_var, matched_function)over repeatingshape_function, shape_arg_vartwice). - Lint message design & discernment: The ultimate purpose of
lint_messageis to provide a helpful, actionable cue when a user receives a lint result in their IDE or CI. When considering whether to create separate message variants for related syntactic cases (e.g.sprintf("hello")vssprintf(foo())), exercise discernment with user-friendliness as the north star. Avoid introducing distinct message classes if the information conveyed to the user does not meaningfully differ, as extra message complexity adds code to maintain without delivering practical user value. However, use distinct messages whenever doing so provides significantly clearer guidance to the user.
3. Minimal Diffs & Logical Execution Order
- Structure execution to avoid intermediate mutations: Structure the execution order of sub-checks within a linter to avoid mutating, subsetting, or filtering shared XML node lists and symbol vectors midway through the function.
- Inline condition-dependent vector additions over intermediate state: When conditionally appending items to a list or character vector, inline the check right inside
c()(c("strsplit", if (check_file_listing) c("dir", "list.files"))) instead of assigning an intermediate temporary variable. Becauseif (FALSE)evaluates toNULL(which automatically drops out ofc()), inlining cleanly eliminates unnecessary temporary variable assignments. - Append new checks cleanly: When adding a check to an existing linter (
check_exports,check_nonexports), place the new check cleanly after existing checks so that existing code blocks and variables (packages,symbols,ns_nodes) remain untouched. This keeps diffs small, readable, and easy to review.
4. Self-Linting, Repository Health, & NEWS.md
- Verify zero new violations and dogfood new linters across
lintritself: Whenever a linter is made more strict or a brand-new linter is created, immediately run it acrosslintr's own repository (R/codebase andtests/testthat/suites). Dogfood the new checks and clean up any newly triggered violations across existing package code and tests before proposing the PR. For example, forexpect_shape_linter(), some existing usages ofexpect_identical(nrow(x), n)were updated toexpect_shape(x, nrow = n)in the same PR. - Keep
lintr100% lint-free: Immediately clean up any newly triggered violations across the repository (e.g., changing redundantglue::glue()orcli::cli_warn()calls toglue()andcli_warn()) before proposing the PR. - Descriptive VCS branch / bookmark naming: Avoid non-descriptive, issue-number-only branch names like
fix-issue-NNNN. Always choose short, descriptive branch or bookmark names that clearly convey the functional work or feature being developed (e.g.any-duplicated-highlight-range,unreachable-switch,vector-logic-body). - Informative, commit-specific descriptions in stack/chain VCS workflows (e.g.
jj): When working in stack or chain VCS workflows where commits build sequentially on top of each other, never reuse a single generic feature description across all commits in the stack. Each commit description must concisely and accurately summarize the specific change introduced by that commit (e.g.,Gemini: Unify single-argument sprintf/gettextf lints under single argument messagevsGemini: Optimize argument counting using xml_find_num_() count XPath). - Use author's public GitHub handle in
NEWS.md: Always use the author's public GitHub handle (e.g.@MichaelChirico,@AshesITR). NEWS.mdentry scope: Internal range/span highlighting fixes for existing linters (which only adjust where a lint lands without altering public functions, parameters, or lint messages) do not require aNEWS.mdentry. ReserveNEWS.mdentries strictly for new features, new linters, deprecations, breaking changes, or user-facing parameter/message additions.- Categorize
NEWS.mdentries under hierarchical section headings: Whenever implementing a new user-facing linter enhancement, parameter option, brand-new linter, or significant bug fix, always append a concise, well-formatted entry right under# lintr (in development)inNEWS.md. Ensure entries are nested cleanly under level 2 (##) and level 3 (###) section headings matching standard hierarchy (## New and improved features->### New lintersor### Linter improvements; or## Bug fixes). Never insert uncategorized top-level bullet points straight beneath# lintr (in development).
5. Respecting Contract Boundaries & Avoiding Unexported Internals (:::)
- Strictly respect contract boundaries: Take seriously that
:::means private and avoid violating contract boundaries across packages. Whilelintrprovides%:::%(p %:::% f) to bypass self-lint checks when calling private functions, reaching across package boundaries into unexported internals (knitr:::parse_params,knitr:::file_ext) should be avoided except in rare, justified cases. - Look under the hood for exported alternatives: When tempted to invoke a private internal function from another package, look under the hood first—very often those internals are simple wrappers around
baseR functions or exported utilities from imported dependencies (such asxfun::csv_options(),xfun::divide_chunk(), orxfun::file_ext()). - Pragmatic ~95% correctness over fragile 100% correctness: Even if private upstream helpers include extra edge-case handling or nuance, choose to ignore that nuance for
lintr's purposes.lintrchecks do not have to be demandingly and globally 100% correct in every obscure scenario; achieving ~95% practical correctness using clean, stable, exported APIs is far superior. If a user ever complains about a missing edge case in the future, the implementation can be refined at that time. - Pragmatic namespace scoping & AST robustness: Exercise taste and judgment regarding defensive namespace handling. For standard base functions (such as
is.numeric,is.integer,c,length), do not add complex, verbose AST predicates (e.g.expr[1][not(expr/SYMBOL_PACKAGE) or expr/SYMBOL_PACKAGE[text() = 'base']]) merely to guard against hypothetical third-party packages masking base functions (e.g.pkg::is.numeric). Such defensive guards add visual noise, XPath complexity, and maintenance overhead without delivering practical user value. Reserve explicit namespace guards for cases where package masking is a known, realistic scenario in the R ecosystem.
6. Simple & Idiomatic AST and Condition Checks
- Avoid over-engineered evaluation constructs: When checking parsed parameter values or AST expressions (such as
evaloptions from chunk headers), do not write complex, over-defensive constructs liketryCatch(eval(..., envir = baseenv()))to handle theoretical runtime expressions. - Check exact parser representations: Inspect and match the exact R objects produced by the parser (
xfun::csv_options()produceslogicalFALSEforeval=FALSEand symbolquote(F)foreval=F). A direct, concise check such asif (identical(eval_value, quote(F))) return(TRUE)followed byisFALSE(eval_value)is simpler, safer, and much easier to maintain. - Annotate symbol checks for self-linting: When comparing against
quote(F)orquote(T), add# nolint next: T_and_F_symbol_linter.immediately above the line to preventlintr's self-linting checks from flagging the symbol name.
7. Data Engineering, Centralized State, & Readability First
- Use cohesive data frames (
line_metadata/ internal working state): When computing many parallel properties (such as line-by-line formatting attributes or multi-argument expression parsing), structure intermediate state as a singledata.frame. This enforces parallel alignment and makes code far cleaner to inspect, query, or update via parallel matrix subsetting (df[is_1arg, c("a", "b")] <- list("val", df$expr[is_1arg])). - Internal
data.framevs minimalist return types: Usedata.framestrictly as an internal working data structure within helper functions when reading and modifying multiple parallel vectors. Avoid returning heavy multi-column data frames to main callbacks when only a single output vector (like a character vector of lint messages) is needed. - Omit redundant
stringsAsFactors = FALSE: In modern R (>= 4.0.0),data.frame()defaults tostringsAsFactors = FALSE. OmitstringsAsFactors = FALSEin internal helper data frames. - Descriptive domain variable names for message assembly: Use clear, explicit domain-specific variable names when building linter messages:
preferred_usage(the recommended replacement),observed_usage(the code pattern flagged),is_decreasing,not_seq_along. - Prioritize
with()to eliminate repetitive visual noise: When writing compound logical filtering conditions over multi-column metadata frames (find_bad_lines()), embracewith(line_metadata, !is.na(line) & indent_level != expected_level & !in_str_const)over repetitive$accesses (line_metadata$line,line_metadata$indent_level). The immense readability benefit of eliminating repetitive visual noise far outweighs negligible environment evaluation overhead.
8. Idiomatic R Vectorization over Loops
- Prefer vectorized operations: Avoid using
forloops to iterate over lines or XML nodes if a vectorized alternative exists. UseMap(),vapply(), or logical subsetting. - Vectorized sequence generation: For example, to generate a sequence of indices to mark as inside a string constant:
This is much cleaner and faster than aline1 <- as.integer(xml_attr_(multiline_strings, "line1")) line2 <- as.integer(xml_attr_(multiline_strings, "line2")) is_in_str <- unlist(Map(`:`, line1, line2)) in_str_const[is_in_str] <- TRUEfor (string in multiline_strings)loop.
9. Handling Sequences, Ranges, Directionality, & Boundary Conditions
- Directionality & boundary rules for sequence linters: Linters inspecting sequence generation (such as
seq_linterinspecting1:n,seq(),nrow(x):1) must explicitly distinguish increasing, decreasing, and boundary structures:- Increasing sequences:
1:n,seq(1, n), orseq(from = 1, to = n)-> recommendseq_len(n)orseq_along(x). - Decreasing sequences:
n:1,seq(n, 1), orseq(to = 1, from = n)-> recommendrev(seq_len(n))orrev(seq_along(x)). - Single-element boundaries:
seq(1, 1)orseq(from = 1, to = 1)-> recommendseq_len(1)(orseq_len(1L)), NEVERrev(seq_len(1)). Ensureis_decreasingchecks excludedot_expr1 %in% c("1", "1L")or are scoped exclusively to reverse colon calls (!is_seq & dot_expr2 %in% c("1", "1L")). - Non-positive lower bounds:
seq(0, 1),seq(0, 10),seq(-1, 1),seq(-1, 10),0:1,-1:10are non-standard sequences; they must NOT be flagged as countdowns or converted toseq_len(). Guard in XPath against zero constants and unary minus:and not(expr[NUM_CONST[text() = '0' or text() = '0L'] or OP-MINUS]).
- Increasing sequences:
10. Robust Handling of Literate R Formats & Readable Transitions
- Expect
NA_character_in line content: When linting files, remember that literate programming formats (like.Rmd,.qmd) extract R code by masking non-R lines withNA_character_to preserve line numbers. - Avoid NA propagation in logical expressions: Ensure logical expressions checking conditions over lines immediately guard against
NA(!is.na(line) & ...) to keep resulting booleans clean (FALSE & NA -> FALSE). - Prioritize clear consecutive logic (
diff()) over micro-optimizations: Becauseis_badevaluates toFALSE(notNA) across non-code gaps, computing consecutive state differences using simple arithmetic (diff(is_bad) == 0L) cleanly isolates block transitions across gaps. Never replace clear, readable difference computations with obscure logical shifting constructs solely to avoid implicit conversions or save negligible compute. Readability is paramount.
11. Direct Documentation Workflow & roxygen2 Example Style
- Crisp parameter and linter descriptions: Keep
@descriptionand@paramdocumentation concise and conceptual. If a linter checks a family of spiritually equivalent expressions (e.g.1:length(...),seq(1, n),seq(10, 1),nrow(x):1), summarize the intent at a high level (This linter checks for expressions like \code{1:length(...)} (and many other spiritually equivalent variations) which are a common source of bugs...) rather than enumerating every syntax permutation in the@description. - Roxygen example structure (
@examples) for new or extended linters: When creating or extending a linter, adhere strictly tolintr's standard roxygen examples layout:- Section Division (
# will produce lintsand# okay): Always partition@examplesinto distinct sections beginning with# will produce lintsat top, directly followed by# okay. - Explicit
lint()calls: Format each demonstration cleanly across lines usinglint(text = ..., linters = ...):#' lint( #' text = "list.files(pattern = 'RDS')", #' linters = fixed_regex_linter() #' ) - Multi-line or escaped code (
writeLines): For simple single-line invocations without complex escapes, passtext = "..."directly insidelint(). For multi-line snippets (containing\n) or regex patterns containing heavy backslash escaping ("\\\\."), assign the target code tocode_lines <- "...", display it first usingwriteLines(code_lines), and then passtext = code_linestolint(). This allows users executing help examples to view the rendered target code exactly as seen by the parser:#' code_lines <- 'gsub("\\\\.", "", x)' #' writeLines(code_lines) #' lint( #' text = code_lines, #' linters = fixed_regex_linter() #' ) - Mirrored 1:1 problem-to-solution pairs: Maintain strict 1:1 structural correspondence between cases under
# will produce lintsand their corresponding resolutions right under# okayin the same order. Do NOT introduce orphan or duplicate examples under# okay(e.g. avoid repeatingseq_along(x)multiple times). - Standard footer tags: Always conclude linter roxygen blocks right after
@exampleswith@evalRd rd_tags("<linter_name>"),@seealso [linters] for a complete list of linters available in lintr., and@export.
- Section Division (