XPath Style and Composition in {lintr}
When implementing, refactoring, or reviewing XPath expressions across lintr (R/), adhere to the following composition, styling, robust AST targeting, and performance guidelines established by project maintainers:
1. Direct Path Steps & Predicates over self::* Transitions
- Avoid redundant
self::*navigation axes: When filtering nodes based on ancestral or parent structure, express structural requirements directly inside the target predicate rather than prepending an explicitself::*[...] /step before traversing to siblings or children. - Leverage shared parent context for siblings: Because following or preceding siblings (
following-sibling::*,preceding-sibling::*) share the exact same structural parent (parent::*) as the starting context node, express structural parent conditions directly inside the sibling predicates without extra bracket nesting (parent::expr/OP-LEFT-BRACEover[parent::expr[OP-LEFT-BRACE]]).# Anti-pattern: Unnecessary self::* step and nested bracket evaluation self::*[parent::expr[OP-LEFT-BRACE]] /following-sibling::*[not(self::OP-RIGHT-BRACE)] # Preferred: Direct verification of parent structure within the sibling predicate following-sibling::*[ parent::expr/OP-LEFT-BRACE and not(self::OP-RIGHT-BRACE) ][1]
2. Modular Composition (glue::glue()) & Helper Utilities
- Decompose complex queries into modular variables: Avoid large, monolithic XPath strings. Break distinct node criteria, structural targets, and sub-conditions into well-named variables (
expr_after_control,terminal_call_cond,unreachable_expr_cond_ws) and synthesize the complete expression cleanly usingglue(). - Parenthesize compound expressions prior to unions or depth traversing: When combining complex branches via union (
|) or traversing deeper via//expr[...]or/following-sibling::*, wrap individual sub-expressions inside explicit parentheses within the compositeglue()string:xpath_after_terminal_node <- glue(" ({expr_after_control} | {terminal_fun_expr})//expr[{terminal_call_cond}]/{unreachable_expr_cond_ws} | ({expr_after_control} | {terminal_fun_expr})//expr[{terminal_call_cond}]/{unreachable_expr_cond_sc} | ({expr_after_control})//expr[NEXT or BREAK]/{unreachable_expr_cond_ws} ") - Use built-in
{lintr}XPath helpers for 3+ items: Use internal helper functions likexp_or(),xp_and(), andxp_text_in_table()for tables of 3 or more entries (e.g.xp_text_in_table(c("sprintf", "gettextf", "paste"))). For length-2 conditions (text() = 'foo' or text() = 'bar'), write direct literal disjunctions instead—never suggest or replace length-2 table checks withxp_text_in_table(), as direct literal comparisons are cleaner and avoid string interpolation overhead. - Factor shared invariant predicates out of compound disjunctions: When matching multiple candidate structures inside a predicate using
or, factor shared conjuncts out to the surrounding scope instead of distributing them inside every branch. For example, useSTR_CONST and (branch_A or branch_B)rather than(branch_A and STR_CONST) or (branch_B and STR_CONST). This reduces evaluation duplication and instantly exposes what truly separates the target branches. - Order unnested single conditions before multi-condition parenthesized expressions: Inside compound
orstructures (STR_CONST and (...)), place single-condition branches (preceding-sibling::*[not(self::COMMENT)][2][self::SYMBOL_SUB[...]]) ahead of multi-condition compound branches ((position() = 2 - count(...) and not(EQ_SUB))). Ordering unnested conditions first eliminates distracting double-bracket indentation right at the opening transition ((( ... ) or ... )), markedly improving visual structure and readability. - Enforcing allowed named-argument whitelists via De Morgan's law: To ensure that all named arguments present on a function call (if any exist) belong exclusively to an allowed set of keyword names (e.g. only
fromandtonamed arguments inseq()), negate the existence of any disallowed named argument:not(SYMBOL_SUB[text() != 'from' and text() != 'to']). Always document this with an explanatory comment:# not(SYMBOL_SUB[...]) ensures all named arguments (if any) are exclusively 'from' or 'to'. - Concise AST argument role determination in multi-argument calls: For functions where arguments may appear positionally, named, or reversed (
seq(1, 10),seq(from = 1, to = 10),seq(to = 10, from = 1),seq(to = 10, 1),seq(10, from = 1),seq(from = 1, 10)):- To identify if the first argument token (
expr[2]) represents a specific role (e.g.to), use concise sibling lookups:./expr[2]/preceding-sibling::SYMBOL_SUB[text() = 'to'] | ./expr[2]/following-sibling::SYMBOL_SUB[text() = 'from'] - In XPath matching predicates, support both named permutations cleanly:
(expr[3]/preceding-sibling::SYMBOL_SUB[1][text() = 'from'] or expr[2]/preceding-sibling::SYMBOL_SUB[1][text() = 'to']).
- To identify if the first argument token (
3. Multi-Line Formatting, Indentation, & Visual Hierarchy
- Format multi-line strings clearly: For non-trivial predicates or sequential criteria, break strings across lines with clean leading whitespace indentation that accurately reflects logical depth and bracket scope (
[...]). - Avoid
paste()for multi-line XPath strings: Write clean, multi-line literal strings directly rather than piecing together fragments withpaste(...). - Lead with operators and axis steps on continuation lines: Start continuation lines cleanly with logical boolean operators (
and,or,|) or navigation path boundaries (/following-sibling::*,/parent::expr) so visual structure and conditions can be assessed rapidly:unreachable_expr_cond_ws <- " following-sibling::*[ parent::expr/OP-LEFT-BRACE and not(self::OP-RIGHT-BRACE or self::OP-SEMICOLON or self::ELSE or preceding-sibling::ELSE) and (not(self::COMMENT) or @line2 > preceding-sibling::*[not(self::COMMENT)][1]/@line2) ][1]"
4. XPath Performance & Entry Point Anchoring
- Avoid
//*XPaths entirely: Wildcard descendant scanning (//*) is a severe performance bottleneck across{xml2}andlibxml2. Even an extensive union chain of specific node names (//A[expr] | //B[expr]) consistently yields substantial speed optimizations (often 3x or greater) compared to unconstrained wildcard matching. - Avoid
//exprXPaths as entry points: Because over one-third of all AST nodes in an R codebase are<expr>, querying//expras an initial anchor only eliminates a minimal portion of the parse tree. Always anchor initial traversals on more specific syntax nodes (//IF,//FOR,//RIGHT_ASSIGN,//EQ_SUB). - Use
xml_find_function_calls()over//SYMBOL_FUNCTION_CALLentry points: Whenever a linter checks for calls to specific functions, never anchor directly on//SYMBOL_FUNCTION_CALL[text() = 'foo' or text() = 'bar']. Instead, fetch pre-cached structures viasource_expression$xml_find_function_calls(c("foo", "bar")), which executes significantly faster across multi-option lookups. - Prefer
xml_find_lgl_()withboolean(...)ornot(...)overis.na(xml_find_first_()):xml_find_first()creates intermediate R objects: anXPtrNodeexternal pointer, a list withnodeanddocelements, names, and class attributes. When testing existence or matching, this object allocation is pure overhead.xml_find_lgl_(x, "boolean(...)")evaluatesXPATH_BOOLEANdirectly insidelibxml2and returns a flat atomic logical vector with zero intermediate R object allocations. On realistic parse trees,xml_find_lgl_()is ~1.7x faster and consumes 67–75% less memory on large nodesets thanxml_find_first_().- XPath boolean requirement: In XPath 1.0,
xml_find_lgl_()requires an expression that evaluates to a boolean (e.g.boolean(...),not(...),=,!=). Passing a raw node query likexml_find_lgl_(x, "SYMBOL")throws an error. - Use positive framing with
not(...)over double-negatives (!xml_find_lgl_(..., "boolean(...)")):- Check presence:
has_match <- xml_find_lgl_(x, "boolean(XPATH)") - Check absence:
is_missing <- xml_find_lgl_(x, "not(XPATH)") - Avoid
!xml_find_lgl_(x, "boolean(XPATH)")whenxml_find_lgl_(x, "not(XPATH)")directly expresses the condition without forcing the reader to parse a double negative.
- Check presence:
- Safely handling
xml_missing():- On
xml2::xml_missing()inputs (e.g. files with syntax errors or unparsed source expressions),xml_find_lgl_(xml, ...)returnslogical(0). - If testing
xmlin a scalarif (...)condition at the file level, guard againstlength 0withisTRUE()or!isTRUE()(e.g.if (isTRUE(xml_find_lgl_(xml, xpath)))orif (!isTRUE(xml_find_lgl_(xml, xpath)))), or!any(xml_find_lgl_(...))for nodesets.
- On
- Prefer
xml_name_()or XPathname(...)over XPathself::TAGorxml_find_first_for node tag matching:- Direct tag comparison
xml_name_(nodes) == "TAG"on AST nodes in R is ~47x faster than evaluating an XPathself::TAGquery orxml_find_first_(nodes, "self::TAG"). - When extracting the tag name of child or sibling nodes without needing the node object, use
xml_find_chr_(nodes, "name(*[2])")instead ofxml_name_(xml_find_first_(nodes, "*[2]")).
- Direct tag comparison
- Extracting node text via
xml_find_chr_(x, "string(...)")vsxml_text(xml_find_first_(x, ...)):xml_find_chr_(x, "string(XPATH)")extracts text directly at the C level without allocating intermediatexml_nodeobjects, allocating 0 bytes on scalarxml_nodeinputs and ~94% less memory on nodesets.- Missing node behavior (
""vsNA): Per W3C XPath 1.0 (§4.2), converting an empty nodeset to a string returns""(empty string), whereasxml_text(xml_find_first_())returnsNA_character_.""is safer in direct comparisons (if (xml_find_chr_(node, "string(OP)") == "%>%")evaluates cleanly toFALSEinstead of throwing amissing value where TRUE/FALSE needederror when the operator is absent).
- Avoid multiple
xml_find_chr_calls for properties of the same node: If you need multiple properties from the same target node (e.g. both tag name and text, or multiple attributes like@line1and@col1), find the node once viaxml_find_first_()/xml_find_all_()and extract the properties directly (xml_name_(),xml_text(),xml_attr_()). Evaluating the full XPath traversal multiple times via separatexml_find_chr_()calls is ~2x slower and uses more memory than a single node lookup followed by direct C-pointer property access.
- Prefer vectorized
xml_find_num_()with XPathcount()overvapply(length(xml_find_all_)): To count argument or node occurrences across a nodeset, wrap the XPath query incount(...)and executeas.integer(xml_find_num_(nodes, "count(...)"))instead of iterating withvapply(nodes, \(node) length(xml_find_all_(node, xpath)), integer(1L)). This delegates counting tolibxml2's C-level XPath evaluator in a single vectorized pass across all nodes. Always cast toas.integer()for explicit integer typing. Never useas.integer(xml_find_chr_(..., "string(count(...))")). - Precise preceding-sibling relative anchoring for named keyword argument values: When excluding or matching the value
<expr>of a specific keyword argument (such asdomain = <expr>ingettextf()), do not write unindexed preceding-sibling predicates likenot(preceding-sibling::*[...][self::SYMBOL_SUB[text() = 'domain']]). Unindexedpreceding-sibling::*evaluates across all preceding siblings of subsequent positional arguments, falsely matching earlier keyword tokens and breaking argument counts. Precisely anchor to the immediate preceding non-comment sibling token instead:preceding-sibling::*[not(self::COMMENT)][1][self::EQ_SUB]/preceding-sibling::*[not(self::COMMENT)][1][self::SYMBOL_SUB[text() = 'domain']] - Anchor on
EQ_SUBinstead ofSYMBOL_SUBwhen matching named arguments: Because valid string literal parameter names (foo("a" = 1)) parse as<STR_CONST>rather than<SYMBOL_SUB>, queries anchored strictly onSYMBOL_SUBskip quoted argument names. Anchoring on<EQ_SUB>reliably matches all named assignment arguments. - Exact predicate matching for AST symbols: Always match symbol text explicitly with
expr[SYMBOL[text() = '.N']]rather than loose comparisons likeexpr[SYMBOL = '.N']. - Be cautious with wildcard (
*) and bareexprsibling lookups:- Sibling lookups like
preceding-sibling::*[1]andfollowing-sibling::*[1]frequently land on<COMMENT>nodes because comments can appear almost anywhere across the AST. Explicitly filter out comment nodes when tracing syntax structure (preceding-sibling::*[not(self::COMMENT)][1]). - Depending on the active R version,
=assignment expressions (a = 1) may wrap in<equal_assign>or<expr_or_assign_or_help>nodes rather than standard<expr>nodes (unlikea <- 1). Therefore, rigid lookups such aspreceding-sibling::expr[1]can silently jump over assignments.
- Sibling lookups like
- Differentiate whitespace sequences from
<exprlist>(OP-SEMICOLON) boundaries: Statements separated by semicolons (return(x); y <- 2) in R's AST are enclosed inside<exprlist[OP-SEMICOLON]>nodes rather than typical<expr>sibling sequences. - Account for grammar specificities and node representations:
- Unary Operators and Negative Numbers (
OP-MINUS): In R's parse tree, unary minus-1or unary plus+1wraps the numeric constant inside a nested<expr>:<expr><OP-MINUS>-</OP-MINUS><expr><NUM_CONST>1</NUM_CONST></expr></expr>.- A direct child query
expr[NUM_CONST[text() = '1']]matches only positive1, NOT-1. - A descendant query
expr//NUM_CONST[text() = '1']orexpr[.//NUM_CONST[text() = '1']]matches BOTH1and-1. - When matching positive numeric boundaries (e.g.
1or1L) in sequence generation, explicitly guard against non-positive numbers (e.g.0,0L, or negative numbers) withand not(expr[NUM_CONST[text() = '0' or text() = '0L'] or OP-MINUS]).
- A direct child query
- Logical Constants vs Shorthands:
TRUEandFALSEappear as<NUM_CONST>nodes (NUM_CONST[text() = 'TRUE']), while shorthandsTandFappear as<SYMBOL>nodes (SYMBOL[text() = 'T']). - Magrittr vs Native Pipes (
SPECIALvsPIPE): Native pipe|>appears as<PIPE>, whereas magrittr%>%appears as<SPECIAL>. Since all custom infix operators (%%,%in%,%*%) also parse as<SPECIAL>, verify node text explicitly (SPECIAL[text() = '%>%']). Keep pipeline restructuring in mind when designing positional checking (x |> f(arg)makesargpositional argument 2 inside the underlying evaluation). forloop AST structure:forloops differ sharply fromwhile()andif()constructs by enclosing their header elements inside a<forcond>node (<forcond>containing<OP-LEFT-PAREN>,<SYMBOL>,<IN>,<expr>, and<OP-RIGHT-PAREN>).- S4 slot (
@) vs Dollar ($) extraction: Forx$y, the right-hand side directly separates property symbols (SYMBOL) from method calls (SYMBOL_FUNCTION_CALL). However, the right-hand side ofx@y(orx@y()) always resolves to a<SLOT>node. To distinguish between property access and function invocations across@, check whether an<OP-LEFT-PAREN>(() sibling follows. - Sub-tree value equality (
<expr1> = <expr2>): Comparing two node branches with XPath=tests aggregate string equality across all descendant text nodes. Because child<COMMENT>nodes inside either branch change the aggregate string value, guard against or exclude embedded comments before checking structural equality.
- Unary Operators and Negative Numbers (
5. Self-Documentation & Explicit Exclusion Comments
- Annotate structural distinctions: Provide brief, explicit comments above multi-line modular XPath definitions clarifying structural design decisions or separate handling regimes (
# normal case: expression after terminal call is on the next linevs# robustness case: expression after terminal call is on the same line). - Document syntax exclusions right where defined: Whenever an XPath guards against edge-case structures (
not(OP-DOLLAR or OP-AT)ornot(self::ELSE or preceding-sibling::ELSE)), document the practical rationale or tracking reference directly above (# NB: use not(OP-DOLLAR) to prevent matching process$stop(), #1051) so regressions are prevented during future structural iterations.