Skip to content

Commit fdf83e4

Browse files
Merge dc1e515 into 7970260
2 parents 7970260 + dc1e515 commit fdf83e4

12 files changed

+264
-4
lines changed

DESCRIPTION

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ Collate:
140140
'nested_ifelse_linter.R'
141141
'nonportable_path_linter.R'
142142
'numeric_leading_zero_linter.R'
143+
'nzchar_linter.R'
143144
'object_length_linter.R'
144145
'object_name_linter.R'
145146
'object_usage_linter.R'

NAMESPACE

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ export(nested_ifelse_linter)
102102
export(no_tab_linter)
103103
export(nonportable_path_linter)
104104
export(numeric_leading_zero_linter)
105+
export(nzchar_linter)
105106
export(object_length_linter)
106107
export(object_name_linter)
107108
export(object_usage_linter)

NEWS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
* `sample_int_linter()` for encouraging `sample.int(n, ...)` over equivalents like `sample(1:n, ...)` (part of #884, @MichaelChirico).
2626
* `stopifnot_all_linter()` discourages tests with `all()` like `stopifnot(all(x > 0))`; `stopifnot()` runs `all()` itself, and uses a better error message (part of #884, @MichaelChirico).
2727
* `comparison_negation_linter()` for discouraging negated comparisons when a direct negation is preferable, e.g. `!(x == y)` could be `x != y` (part of #884, @MichaelChirico).
28+
* `nzchar_linter()` for encouraging `nzchar()` to test for empty strings, e.g. `nchar(x) > 0` can be `nzchar(x)` (part of #884, @MichaelChirico).
2829
* `terminal_close_linter()` for discouraging using `close()` to end functions (part of #884, @MichaelChirico). Such usages are not robust to errors, where `close()` will not be run as intended. Put `close()` in an `on.exit()` hook, or use {withr} to manage connections with proper cleanup.
2930
* `list_comparison_linter()` for discouraging comparisons on the output of `lapply()`, e.g. `lapply(x, sum) > 10` (part of #884, @MichaelChirico).
3031
* `print_linter()` for discouraging usage of `print()` on string literals like `print("Reached here")` or `print(paste("Found", nrow(DF), "rows."))` (#1894, @MichaelChirico).

R/expect_comparison_linter.R

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@
4949
#' @export
5050
expect_comparison_linter <- function() {
5151
# != doesn't have a clean replacement
52-
comparator_nodes <- setdiff(as.list(infix_metadata$xml_tag[infix_metadata$comparator]), "NE")
52+
comparator_nodes <- setdiff(infix_metadata$xml_tag[infix_metadata$comparator], "NE")
5353
xpath <- glue("
5454
//SYMBOL_FUNCTION_CALL[text() = 'expect_true']
5555
/parent::expr

R/nzchar_linter.R

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#' Require usage of nzchar where appropriate
2+
#'
3+
#' [nzchar()] efficiently determines which of a vector of strings are empty
4+
#' (i.e., are `""`). It should in most cases be used instead of
5+
#' constructions like `string == ""` or `nchar(string) == 0`.
6+
#'
7+
#' One crucial difference is in the default handling of `NA_character_`, i.e.,
8+
#' missing strings. `nzchar(NA_character_)` is `TRUE`, while `NA_character_ == ""`
9+
#' and `nchar(NA_character_) == 0` are both `NA`.
10+
#'
11+
#' @examples
12+
#' # will produce lints
13+
#' lint(
14+
#' text = "x[x == '']",
15+
#' linters = nzchar_linter()
16+
#' )
17+
#'
18+
#' lint(
19+
#' text = "x[nchar(x) > 0]",
20+
#' linters = nzchar_linter()
21+
#' )
22+
#'
23+
#' # okay
24+
#' lint(
25+
#' text = "x[nchar(x) > 1]",
26+
#' linters = nzchar_linter()
27+
#' )
28+
#'
29+
#' # nzchar()'s primary benefit is for vector input;
30+
#' # for guaranteed-scalar cases like if() conditions, comparing to "" is OK.
31+
#' lint(
32+
#' text = "if (x == '') y",
33+
#' linters = nzchar_linter()
34+
#' )
35+
#'
36+
#' @evalRd rd_tags("nzchar_linter")
37+
#' @seealso [linters] for a complete list of linters available in lintr.
38+
#' @export
39+
nzchar_linter <- function() {
40+
comparator_nodes <- infix_metadata$xml_tag[infix_metadata$comparator]
41+
42+
# use string-length to capture both "" and ''
43+
# if (any(x == "")) is not treated like it's part of if(), but
44+
# any(if (x == "") y else z) _is_ treated so. this condition looks for the
45+
# expr to be inside a call that's _not_ above an IF/WHILE.
46+
comparison_xpath <- glue("
47+
//STR_CONST[string-length(text()) = 2]
48+
/parent::expr
49+
/parent::expr[
50+
({ xp_or(comparator_nodes) })
51+
and (
52+
not(ancestor-or-self::expr[
53+
preceding-sibling::IF
54+
or preceding-sibling::WHILE
55+
])
56+
or ancestor-or-self::expr[
57+
(
58+
preceding-sibling::expr[SYMBOL_FUNCTION_CALL]
59+
or preceding-sibling::OP-LEFT-BRACKET
60+
) and not(
61+
descendant-or-self::expr[IF or WHILE]
62+
)
63+
]
64+
)
65+
]
66+
")
67+
68+
# nchar(., type="width") not strictly compatible with nzchar
69+
# unsure allowNA compatible, so allow it just in case (see TODO in tests)
70+
nchar_xpath <- glue("
71+
//SYMBOL_FUNCTION_CALL[text() = 'nchar']
72+
/parent::expr
73+
/parent::expr
74+
/parent::expr[
75+
({ xp_or(comparator_nodes) })
76+
and not(expr[SYMBOL_SUB[
77+
(
78+
text() = 'type'
79+
and following-sibling::expr[1][STR_CONST[contains(text(), 'width')]]
80+
) or (
81+
text() = 'allowNA'
82+
and following-sibling::expr[1][NUM_CONST[text() = 'TRUE']]
83+
)
84+
]])
85+
and expr[NUM_CONST[text() = '0' or text() = '0L' or text() = '0.0']]
86+
]
87+
")
88+
89+
keepna_note <- paste(
90+
"Whenever missing data is possible,",
91+
"please take care to use nzchar(., keepNA = TRUE);",
92+
"nzchar(NA) is TRUE by default."
93+
)
94+
95+
Linter(function(source_expression) {
96+
if (!is_lint_level(source_expression, "expression")) {
97+
return(list())
98+
}
99+
100+
xml <- source_expression$xml_parsed_content
101+
102+
comparison_expr <- xml_find_all(xml, comparison_xpath)
103+
comparison_lints <- xml_nodes_to_lints(
104+
comparison_expr,
105+
source_expression = source_expression,
106+
lint_message = paste(
107+
'Instead of comparing strings to "", use nzchar().',
108+
"Note that if x is a factor, you'll have use ",
109+
'as.character() to replicate an implicit conversion that happens in x == "".',
110+
keepna_note
111+
),
112+
type = "warning"
113+
)
114+
115+
nchar_expr <- xml_find_all(xml, nchar_xpath)
116+
nchar_lints <- xml_nodes_to_lints(
117+
nchar_expr,
118+
source_expression = source_expression,
119+
lint_message = paste(
120+
"Instead of comparing nchar(x) to 0, use nzchar().",
121+
keepna_note
122+
),
123+
type = "warning"
124+
)
125+
126+
c(comparison_lints, nchar_lints)
127+
})
128+
}

inst/lintr/linters.csv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ nested_ifelse_linter,efficiency readability
5959
no_tab_linter,style consistency deprecated
6060
nonportable_path_linter,robustness best_practices configurable
6161
numeric_leading_zero_linter,style consistency readability
62+
nzchar_linter,efficiency best_practices consistency
6263
object_length_linter,style readability default configurable executing
6364
object_name_linter,style consistency default configurable executing
6465
object_usage_linter,style readability correctness default executing configurable

man/best_practices_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/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/efficiency_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/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.

man/nzchar_linter.Rd

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

tests/testthat/test-nzchar_linter.R

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
test_that("nzchar_linter skips allowed usages", {
2+
linter <- nzchar_linter()
3+
4+
expect_lint("if (any(nzchar(x))) TRUE", NULL, linter)
5+
6+
expect_lint("letters == 'a'", NULL, linter)
7+
8+
expect_lint("which(nchar(x) == 4)", NULL, linter)
9+
expect_lint("which(nchar(x) != 2)", NULL, linter)
10+
})
11+
12+
test_that("nzchar_linter skips as appropriate for other nchar args", {
13+
linter <- nzchar_linter()
14+
15+
# using type="width" can lead to 0-width strings that are counted as
16+
# nzchar, c.f. nchar("\u200b", type="width"), so don't lint this.
17+
# type="bytes" should be >= the value for the default (type="chars")
18+
expect_lint("nchar(x, type='width') == 0L", NULL, linter)
19+
20+
# TODO(michaelchirico): check compatibility of nchar(., allowNA=TRUE).
21+
# there are no examples in ?nchar, nor any relevant usages on StackOverflow.
22+
# just assume they are incompatible now to be conservative.
23+
expect_lint("nchar(x, allowNA=TRUE) == 0L", NULL, linter)
24+
25+
# nzchar also has keepNA argument so a drop-in switch is easy
26+
expect_lint(
27+
"nchar(x, keepNA=TRUE) == 0",
28+
rex::rex("Instead of comparing nchar(x) to 0"),
29+
linter
30+
)
31+
})
32+
33+
test_that("nzchar_linter blocks simple disallowed usages", {
34+
linter <- nzchar_linter()
35+
lint_msg_quote <- rex::rex('Instead of comparing strings to "", use nzchar()')
36+
lint_msg_nchar <- rex::rex("Instead of comparing nchar(x) to 0")
37+
38+
expect_lint("which(x == '')", lint_msg_quote, linter)
39+
expect_lint("any(nchar(x) >= 0)", lint_msg_nchar, linter)
40+
expect_lint("all(nchar(x) == 0L)", lint_msg_nchar, linter)
41+
expect_lint("sum(0.0 < nchar(x))", lint_msg_nchar, linter)
42+
})
43+
44+
test_that("nzchar_linter skips comparison to '' in if/while statements", {
45+
linter <- nzchar_linter()
46+
lint_msg_quote <- rex::rex('Instead of comparing strings to "", use nzchar()')
47+
lint_msg_nchar <- rex::rex("Instead of comparing nchar(x) to 0")
48+
49+
# still lint nchar() comparisons
50+
expect_lint("if (nchar(x) > 0) TRUE", lint_msg_nchar, linter)
51+
expect_lint('if (x == "") TRUE', NULL, linter)
52+
expect_lint('while (x == "") TRUE', NULL, linter)
53+
54+
# nested versions, a la nesting issues with vector_logic_linter
55+
expect_lint('if (TRUE || (x == "" && FALSE)) TRUE', NULL, linter)
56+
expect_lint('if (TRUE && x == "" && FALSE) TRUE', NULL, linter)
57+
expect_lint('if (any(x == "")) TRUE', lint_msg_quote, linter)
58+
expect_lint('if (TRUE || any(x == "" | FALSE)) TRUE', lint_msg_quote, linter)
59+
expect_lint('foo(if (x == "") y else z)', NULL, linter)
60+
})
61+
62+
test_that("multiple lints are generated correctly", {
63+
expect_lint(
64+
trim_some("{
65+
a == ''
66+
nchar(b) != 0
67+
}"),
68+
list(
69+
list(rex::rex('Instead of comparing strings to ""'), line_number = 2L),
70+
list(rex::rex("Instead of comparing nchar(x) to 0"), line_number = 3L)
71+
),
72+
nzchar_linter()
73+
)
74+
})

0 commit comments

Comments
 (0)