Skip to content

Commit f9ef06a

Browse files
Merge f4d344c into 3d9e6d7
2 parents 3d9e6d7 + f4d344c commit f9ef06a

File tree

4 files changed

+89
-21
lines changed

4 files changed

+89
-21
lines changed

NEWS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
+ `yoda_test_linter()`
2424
* `sprintf_linter()` is pipe-aware, so that `x %>% sprintf(fmt = "%s")` no longer lints (#1943, @MichaelChirico).
2525
* `line_length_linter()` helpfully includes the line length in the lint message (#2057, @MichaelChirico).
26+
* `conjunct_test_linter()` also lints usage like `dplyr::filter(x, A & B)` in favor of using `dplyr::filter(x, A, B)` (part of #884, @MichaelChirico).
2627
* `paste_linter()` gains detection for file paths that are better constructed with `file.path()`, e.g. `paste0(dir, "/", file)` would be better as `file.path(dir, file)` (part of #884, @MichaelChirico).
2728

2829
### New linters

R/conjunct_test_linter.R

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#' Force `&&` conditions in `expect_true()` and `expect_false()` to be written separately
1+
#' Force `&&` conditions to be written separately where appropriate
22
#'
33
#' For readability of test outputs, testing only one thing per call to
44
#' [testthat::expect_true()] is preferable, i.e.,
@@ -7,6 +7,11 @@
77
#'
88
#' Similar reasoning applies to `&&` usage inside [stopifnot()] and `assertthat::assert_that()` calls.
99
#'
10+
#' Relatedly, `dplyr::filter(DF, A & B)` is the same as `dplyr::filter(DF, A, B)`, but the
11+
#' latter will be more readable / easier to format for long conditions. Note that this linter
12+
#' assumes usages of `filter()` are `dplyr::filter()`; if you're using another function named `filter()`,
13+
#' e.g. [stats::filter()], please namespace-qualify it to avoid false positives.
14+
#'
1015
#' @param allow_named_stopifnot Logical, `TRUE` by default. If `FALSE`, "named" calls to `stopifnot()`,
1116
#' available since R 4.0.0 to provide helpful messages for test failures, are also linted.
1217
#'
@@ -44,26 +49,37 @@
4449
conjunct_test_linter <- function(allow_named_stopifnot = TRUE) {
4550
expect_true_assert_that_xpath <- "
4651
//SYMBOL_FUNCTION_CALL[text() = 'expect_true' or text() = 'assert_that']
47-
/parent::expr
48-
/following-sibling::expr[1][AND2]
52+
/parent::expr
53+
/following-sibling::expr[1][AND2]
54+
/parent::expr
4955
"
5056
named_stopifnot_condition <- if (allow_named_stopifnot) "and not(preceding-sibling::*[1][self::EQ_SUB])" else ""
5157
stopifnot_xpath <- glue("
5258
//SYMBOL_FUNCTION_CALL[text() = 'stopifnot']
53-
/parent::expr
54-
/following-sibling::expr[1][AND2 {named_stopifnot_condition}]
59+
/parent::expr
60+
/following-sibling::expr[1][AND2 {named_stopifnot_condition}]
61+
/parent::expr
5562
")
5663
expect_false_xpath <- "
5764
//SYMBOL_FUNCTION_CALL[text() = 'expect_false']
58-
/parent::expr
59-
/following-sibling::expr[1][OR2]
65+
/parent::expr
66+
/following-sibling::expr[1][OR2]
67+
/parent::expr
6068
"
61-
xpath <- paste0(
62-
c(expect_true_assert_that_xpath, stopifnot_xpath, expect_false_xpath),
63-
"/parent::expr",
64-
collapse = " | "
69+
test_xpath <- paste(
70+
expect_true_assert_that_xpath,
71+
stopifnot_xpath,
72+
expect_false_xpath,
73+
sep = " | "
6574
)
6675

76+
filter_xpath <- "
77+
//SYMBOL_FUNCTION_CALL[text() = 'filter']
78+
/parent::expr[not(SYMBOL_PACKAGE[text() != 'dplyr'])]
79+
/parent::expr
80+
/expr[AND]
81+
"
82+
6783
Linter(function(source_expression) {
6884
# need the full file to also catch usages at the top level
6985
if (!is_lint_level(source_expression, "file")) {
@@ -72,24 +88,37 @@ conjunct_test_linter <- function(allow_named_stopifnot = TRUE) {
7288

7389
xml <- source_expression$full_xml_parsed_content
7490

75-
bad_expr <- xml_find_all(xml, xpath)
76-
77-
if (length(bad_expr) == 0L) {
78-
return(list())
79-
}
91+
test_expr <- xml_find_all(xml, test_xpath)
8092

81-
matched_fun <- xp_call_name(bad_expr)
82-
operator <- xml_find_chr(bad_expr, "string(expr/*[self::AND2 or self::OR2])")
93+
matched_fun <- xp_call_name(test_expr)
94+
operator <- xml_find_chr(test_expr, "string(expr/*[self::AND2 or self::OR2])")
8395
replacement_fmt <- ifelse(
8496
matched_fun %in% c("expect_true", "expect_false"),
8597
"write multiple expectations like %1$s(A) and %1$s(B)",
8698
"write multiple conditions like %s(A, B)."
8799
)
88100
lint_message <- paste(
89101
sprintf("Instead of %s(A %s B),", matched_fun, operator),
90-
sprintf(replacement_fmt, matched_fun),
102+
# as.character() needed for 0-lint case where ifelse(logical(0)) returns logical(0)
103+
sprintf(as.character(replacement_fmt), matched_fun),
91104
"The latter will produce better error messages in the case of failure."
92105
)
93-
xml_nodes_to_lints(bad_expr, source_expression, lint_message = lint_message, type = "warning")
106+
test_lints <- xml_nodes_to_lints(
107+
test_expr,
108+
source_expression = source_expression,
109+
lint_message = lint_message,
110+
type = "warning"
111+
)
112+
113+
filter_expr <- xml_find_all(xml, filter_xpath)
114+
115+
filter_lints <- xml_nodes_to_lints(
116+
filter_expr,
117+
source_expression = source_expression,
118+
lint_message = "Use dplyr::filter(DF, A, B) instead of dplyr::filter(DF, A & B).",
119+
type = "warning"
120+
)
121+
122+
c(test_lints, filter_lints)
94123
})
95124
}

man/conjunct_test_linter.Rd

Lines changed: 6 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tests/testthat/test-conjunct_test_linter.R

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,3 +109,36 @@ test_that("conjunct_test_linter's allow_named_stopifnot argument works", {
109109
conjunct_test_linter(allow_named_stopifnot = FALSE)
110110
)
111111
})
112+
113+
test_that("conjunct_test_linter skips allowed usages", {
114+
linter <- conjunct_test_linter()
115+
116+
expect_lint("dplyr::filter(DF, A, B)", NULL, linter)
117+
expect_lint("dplyr::filter(DF, !(A & B))", NULL, linter)
118+
# | is the "top-level" operator here
119+
expect_lint("dplyr::filter(DF, A & B | C)", NULL, linter)
120+
expect_lint("dplyr::filter(DF, A | B & C)", NULL, linter)
121+
})
122+
123+
test_that("conjunct_test_linter blocks simple disallowed usages", {
124+
linter <- conjunct_test_linter()
125+
lint_msg <- rex::rex("Use dplyr::filter(DF, A, B) instead of dplyr::filter(DF, A & B)")
126+
127+
expect_lint("dplyr::filter(DF, A & B)", lint_msg, linter)
128+
expect_lint("dplyr::filter(DF, A & B & C)", lint_msg, linter)
129+
130+
# more common usage, in pipes
131+
expect_lint("DF %>% dplyr::filter(A & B)", lint_msg, linter)
132+
})
133+
134+
test_that("filter() is assumed to be dplyr::filter() by default, unless o/w specified", {
135+
linter <- conjunct_test_linter()
136+
137+
expect_lint("stats::filter(A & B)", NULL, linter)
138+
expect_lint("ns::filter(A & B)", NULL, linter)
139+
expect_lint(
140+
"DF %>% filter(A & B)",
141+
rex::rex("Use dplyr::filter(DF, A, B) instead of dplyr::filter(DF, A & B)"),
142+
linter
143+
)
144+
})

0 commit comments

Comments
 (0)