Skip to content

Commit 0a961fb

Browse files
keyword_quote_linter for unnecessary quoting (#2030)
1 parent 788bdca commit 0a961fb

13 files changed

+448
-4
lines changed

DESCRIPTION

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ Collate:
113113
'inner_combine_linter.R'
114114
'is_lint_level.R'
115115
'is_numeric_linter.R'
116+
'keyword_quote_linter.R'
116117
'length_levels_linter.R'
117118
'lengths_linter.R'
118119
'library_call_linter.R'

NAMESPACE

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export(infix_spaces_linter)
7777
export(inner_combine_linter)
7878
export(is_lint_level)
7979
export(is_numeric_linter)
80+
export(keyword_quote_linter)
8081
export(length_levels_linter)
8182
export(lengths_linter)
8283
export(library_call_linter)
@@ -163,4 +164,5 @@ importFrom(xml2,xml_find_all)
163164
importFrom(xml2,xml_find_chr)
164165
importFrom(xml2,xml_find_first)
165166
importFrom(xml2,xml_find_num)
167+
importFrom(xml2,xml_name)
166168
importFrom(xml2,xml_text)

NEWS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
### New linters
2727

28+
* `keyword_quote_linter()` for finding unnecessary or discouraged quoting of symbols in assignment, function arguments, or extraction (part of #884, @MichaelChirico). Quoting is unnecessary when the target is a valid R name, e.g. `c("a" = 1)` can be `c(a = 1)`. The same goes to assignment (`"a" <- 1`) and extraction (`x$"a"`). Where quoting is necessary, the linter encourages doing so with backticks (e.g. `` x$`a b` `` instead of `x$"a b"`).
2829
* `length_levels_linter()` for using the specific function `nlevels()` instead of checking `length(levels(x))` (part of #884, @MichaelChirico).
2930

3031
## Changes to defaults

R/keyword_quote_linter.R

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#' Block unnecessary quoting in calls
2+
#'
3+
#' Any valid symbol can be used as a keyword argument to an R function call.
4+
#' Sometimes, it is necessary to quote (or backtick) an argument that is
5+
#' not an otherwise valid symbol (e.g. creating a vector whose names have
6+
#' spaces); besides this edge case, quoting should not be done.
7+
#'
8+
#' The most common source of violation for this is creating named vectors,
9+
#' lists, or data.frame-alikes, but it can be observed in other calls as well.
10+
#'
11+
#' Similar reasoning applies to extractions with `$` or `@`.
12+
#'
13+
#' @examples
14+
#' # will produce lints
15+
#' lint(
16+
#' text = 'data.frame("a" = 1)',
17+
#' linters = keyword_quote_linter()
18+
#' )
19+
#'
20+
#' lint(
21+
#' text = "data.frame(`a` = 1)",
22+
#' linters = keyword_quote_linter()
23+
#' )
24+
#'
25+
#' lint(
26+
#' text = 'my_list$"key"',
27+
#' linters = keyword_quote_linter()
28+
#' )
29+
#'
30+
#' lint(
31+
#' text = 's4obj@"key"',
32+
#' linters = keyword_quote_linter()
33+
#' )
34+
#'
35+
#' # okay
36+
#' lint(
37+
#' text = "data.frame(`a b` = 1)",
38+
#' linters = keyword_quote_linter()
39+
#' )
40+
#'
41+
#' lint(
42+
#' text = 'my_list$`a b`',
43+
#' linters = keyword_quote_linter()
44+
#' )
45+
#'
46+
#' @evalRd rd_tags("keyword_quote_linter")
47+
#' @seealso [linters] for a complete list of linters available in lintr.
48+
#' @export
49+
# TODO(michaelchirico): offer a stricter version of this that
50+
# requires backticks to be used for non-syntactic names (i.e., not quotes).
51+
# Here are the relevant xpaths:
52+
# //expr[expr[SYMBOL_FUNCTION_CALL]]/SYMBOL_SUB[starts-with(text(), '`')]
53+
# //expr[expr[SYMBOL_FUNCTION_CALL]]/STR_CONST[{is_quoted(text())}]
54+
keyword_quote_linter <- function() {
55+
# NB: xml2 uses xpath 1.0 which doesn't support matches() for regex, so we
56+
# have to jump out of xpath to complete this lint.
57+
# It's also a bit tough to get the escaping through R and then xpath to
58+
# work as intended, hence the rather verbose declaration here.
59+
quote_cond <- xp_or(
60+
"starts-with(text(), '\"')",
61+
"starts-with(text(), '`')",
62+
'starts-with(text(), "\'")'
63+
)
64+
# SYMBOL_SUB for backticks, STR_CONST for quoted names
65+
call_arg_xpath <- glue("
66+
//SYMBOL_FUNCTION_CALL
67+
/parent::expr
68+
/parent::expr
69+
/*[(self::SYMBOL_SUB or self::STR_CONST) and {quote_cond}]
70+
")
71+
72+
# also exclude $ or @, which are handled below
73+
assignment_candidate_cond <- "
74+
not(OP-DOLLAR or OP-AT)
75+
and (STR_CONST or SYMBOL[starts-with(text(), '`')])
76+
"
77+
assignment_xpath <- glue("
78+
(//EQ_ASSIGN | //LEFT_ASSIGN[text() != ':='])
79+
/preceding-sibling::expr[{ assignment_candidate_cond }]
80+
| //RIGHT_ASSIGN/following-sibling::expr[{ assignment_candidate_cond }]
81+
")
82+
83+
extraction_xpath <- "
84+
(//OP-DOLLAR | //OP-AT)/following-sibling::STR_CONST
85+
| //OP-DOLLAR/following-sibling::SYMBOL[starts-with(text(), '`')]
86+
| //OP-AT/following-sibling::SLOT[starts-with(text(), '`')]
87+
"
88+
89+
no_quote_msg <- "Use backticks to create non-syntactic names, not quotes."
90+
clarification <- "i.e., if the name is not a valid R symbol (see ?make.names)."
91+
92+
Linter(function(source_expression) {
93+
if (!is_lint_level(source_expression, "expression")) {
94+
return(list())
95+
}
96+
97+
xml <- source_expression$xml_parsed_content
98+
99+
call_arg_expr <- xml_find_all(xml, call_arg_xpath)
100+
101+
invalid_call_quoting <- is_valid_r_name(get_r_string(call_arg_expr))
102+
103+
call_arg_lints <- xml_nodes_to_lints(
104+
call_arg_expr[invalid_call_quoting],
105+
source_expression = source_expression,
106+
lint_message = paste("Only quote named arguments to functions if necessary,", clarification),
107+
type = "warning"
108+
)
109+
110+
assignment_expr <- xml_find_all(xml, assignment_xpath)
111+
112+
invalid_assignment_quoting <- is_valid_r_name(get_r_string(assignment_expr))
113+
# NB: XPath is such that there is exactly 1 node per match, making xml_children() ideal.
114+
# xml_child() gets it wrong for 0 (an error) and >1 match.
115+
assignment_to_string <- xml_name(xml2::xml_children(assignment_expr)) == "STR_CONST"
116+
117+
string_assignment_lints <- xml_nodes_to_lints(
118+
assignment_expr[assignment_to_string & !invalid_assignment_quoting],
119+
source_expression = source_expression,
120+
lint_message = no_quote_msg,
121+
type = "warning"
122+
)
123+
124+
assignment_lints <- xml_nodes_to_lints(
125+
assignment_expr[invalid_assignment_quoting],
126+
source_expression = source_expression,
127+
lint_message = paste("Only quote targets of assignment if necessary,", clarification),
128+
type = "warning"
129+
)
130+
131+
extraction_expr <- xml_find_all(xml, extraction_xpath)
132+
133+
invalid_extraction_quoting <- is_valid_r_name(get_r_string(extraction_expr))
134+
extraction_of_string <- xml_name(extraction_expr) == "STR_CONST"
135+
136+
string_extraction_lints <- xml_nodes_to_lints(
137+
extraction_expr[extraction_of_string & !invalid_extraction_quoting],
138+
source_expression = source_expression,
139+
lint_message = no_quote_msg,
140+
type = "warning"
141+
)
142+
143+
extraction_expr <- extraction_expr[invalid_extraction_quoting]
144+
extractor <- xml_find_chr(extraction_expr, "string(preceding-sibling::*[1])")
145+
gen_extractor <- ifelse(extractor == "$", "[[", "slot()")
146+
147+
extraction_lints <- xml_nodes_to_lints(
148+
extraction_expr,
149+
source_expression = source_expression,
150+
lint_message = paste(
151+
"Only quote targets of extraction with", extractor, "if necessary,", clarification,
152+
"Use backticks to create non-syntactic names, or use", gen_extractor, "to extract by string."
153+
),
154+
type = "warning"
155+
)
156+
157+
c(call_arg_lints, string_assignment_lints, assignment_lints, string_extraction_lints, extraction_lints)
158+
})
159+
}
160+
161+
#' Check if a string could be assigned as an R variable.
162+
#'
163+
#' See [make.names()] for the description of syntactically valid names in R.
164+
#'
165+
#' @noRd
166+
is_valid_r_name <- function(x) make.names(x) == x

R/lintr-package.R

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
#' @importFrom rex rex regex re_matches re_substitutes character_class
1313
#' @importFrom stats na.omit
1414
#' @importFrom utils capture.output head getParseData relist
15-
#' @importFrom xml2 xml_attr xml_find_all xml_find_chr xml_find_num xml_find_first xml_text as_list
15+
#' @importFrom xml2 xml_attr xml_find_all xml_find_chr xml_find_num xml_find_first xml_name xml_text as_list
1616
#' @importFrom cyclocomp cyclocomp
1717
#' @importFrom utils tail
1818
#' @rawNamespace

inst/lintr/linters.csv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ indentation_linter,style readability default configurable
4141
infix_spaces_linter,style readability default configurable
4242
inner_combine_linter,efficiency consistency readability
4343
is_numeric_linter,readability best_practices consistency
44+
keyword_quote_linter,readability consistency style
4445
length_levels_linter,readability best_practices consistency
4546
lengths_linter,efficiency readability best_practices
4647
library_call_linter,style best_practices readability

man/consistency_linters.Rd

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/keyword_quote_linter.Rd

Lines changed: 60 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/length_levels_linter.Rd

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

man/linters.Rd

Lines changed: 4 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)