Skip to content

Commit 4e4183f

Browse files
Consistent logic around magrittr pipes (#2046)
* catch all relevant pipes in pipe_call_linter() * also in brace_linter() * pipe_continuation_linter * unnecessary_concatenation_linter * unnecessary_placeholder_linter * yoda_test_linter * NEWS * tidy up * consistency in tests, remove %<>% from yoda_test * use local() * another test
1 parent 191e3b7 commit 4e4183f

16 files changed

+136
-61
lines changed

NAMESPACE

+2
Original file line numberDiff line numberDiff line change
@@ -157,3 +157,5 @@ importFrom(utils,relist)
157157
importFrom(utils,tail)
158158
importFrom(xml2,as_list)
159159
importFrom(xml2,xml_find_all)
160+
importFrom(xml2,xml_find_first)
161+
importFrom(xml2,xml_text)

NEWS.md

+6
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
## New and improved features
99

1010
* `library_call_linter()` can detect if all library calls are not at the top of your script (#2027, @nicholas-masel).
11+
* Linters with logic around the magrittr pipe `%>%` consistently apply it to the other pipes `%!>%`, `%T>%`, `%<>%` (and possibly `%$%`) where appropriate (#2008, @MichaelChirico).
12+
+ `brace_linter()`
13+
+ `pipe_call_linter()`
14+
+ `pipe_continuation_linter()`
15+
+ `unnecessary_concatenation_linter()`
16+
+ `unnecessary_placeholder_linter()`
1117
* Several linters avoiding false positives in `$` extractions get the same exceptions for `@` extractions, e.g. `S4@T` will no longer throw a `T_and_F_symbol_linter()` hit (#2039, @MichaelChirico).
1218
+ `T_and_F_symbol_linter()`
1319
+ `for_loop_index_linter()`

R/brace_linter.R

+3-3
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,13 @@ brace_linter <- function(allow_single_line = FALSE) {
6565
#
6666
# note that '{' is not supported in RHS call of base-R's native pipe (`|>`),
6767
# so no exception needs to be made for this operator
68-
"not(
68+
glue("not(
6969
@line1 > parent::expr/preceding-sibling::*[not(self::COMMENT)][1][
7070
self::OP-LEFT-PAREN
7171
or self::OP-COMMA
72-
or (self::SPECIAL and text() = '%>%')
72+
or (self::SPECIAL and ({xp_text_in_table(magrittr_pipes)}) )
7373
]/@line2
74-
)"
74+
)")
7575
))
7676

7777
# TODO (AshesITR): if c_style_braces is TRUE, invert the preceding-sibling condition

R/lintr-package.R

+1-1
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_find_all as_list
15+
#' @importFrom xml2 xml_find_all xml_find_first xml_text as_list
1616
#' @importFrom cyclocomp cyclocomp
1717
#' @importFrom utils tail
1818
#' @rawNamespace

R/pipe_call_linter.R

+7-2
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ pipe_call_linter <- function() {
2323
# NB: the text() here shows up as %&gt;% but that's not matched, %>% is
2424
# NB: use *[1][self::SYMBOL] to ensure the first element is SYMBOL, otherwise
2525
# we include expressions like x %>% .$col
26-
xpath <- "//SPECIAL[text() = '%>%']/following-sibling::expr[*[1][self::SYMBOL]]"
26+
pipes <- setdiff(magrittr_pipes, "%$%")
27+
xpath <- glue("//SPECIAL[{ xp_text_in_table(pipes) }]/following-sibling::expr[*[1][self::SYMBOL]]")
2728

2829
Linter(function(source_expression) {
2930
if (!is_lint_level(source_expression, "expression")) {
@@ -33,12 +34,16 @@ pipe_call_linter <- function() {
3334
xml <- source_expression$xml_parsed_content
3435

3536
bad_expr <- xml2::xml_find_all(xml, xpath)
37+
pipe <- xml_text(xml_find_first(bad_expr, "preceding-sibling::SPECIAL[1]"))
3638

3739
xml_nodes_to_lints(
3840
bad_expr,
3941
source_expression = source_expression,
40-
lint_message = "Use explicit calls in magrittr pipes, i.e., `a %>% foo` should be `a %>% foo()`.",
42+
lint_message =
43+
sprintf("Use explicit calls in magrittr pipes, i.e., `a %1$s foo` should be `a %1$s foo()`.", pipe),
4144
type = "warning"
4245
)
4346
})
4447
}
48+
49+
magrittr_pipes <- c("%>%", "%!>%", "%T>%", "%$%", "%<>%")

R/pipe_continuation_linter.R

+5-4
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,10 @@ pipe_continuation_linter <- function() {
5353
# Where a single-line pipeline is nested inside a larger expression
5454
# e.g. inside a function definition), the outer expression can span multiple lines
5555
# without throwing a lint.
56-
preceding_pipe <- "preceding-sibling::expr[1]/descendant::*[self::SPECIAL[text() = '%>%'] or self::PIPE]"
57-
xpath <- glue::glue("
58-
(//PIPE | //SPECIAL[text() = '%>%'])[
56+
pipe_node <- glue("SPECIAL[{ xp_text_in_table(magrittr_pipes) }]")
57+
preceding_pipe <- glue("preceding-sibling::expr[1]/descendant::*[self::{pipe_node} or self::PIPE]")
58+
xpath <- glue("
59+
(//PIPE | //{pipe_node})[
5960
parent::expr[@line1 < @line2]
6061
and {preceding_pipe}
6162
and (
@@ -73,7 +74,7 @@ pipe_continuation_linter <- function() {
7374
xml <- source_expression$full_xml_parsed_content
7475

7576
pipe_exprs <- xml2::xml_find_all(xml, xpath)
76-
pipe_text <- ifelse(xml2::xml_name(pipe_exprs) == "PIPE", "|>", "%>%")
77+
pipe_text <- xml_text(pipe_exprs)
7778

7879
xml_nodes_to_lints(
7980
pipe_exprs,

R/unnecessary_concatenation_linter.R

+4-3
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,13 @@ unnecessary_concatenation_linter <- function(allow_single_expression = TRUE) { #
6767

6868
non_constant_cond <- "SYMBOL or (expr and not(OP-COLON and count(expr[SYMBOL or expr]) != 2))"
6969

70-
to_pipe_xpath <- "
70+
pipes <- setdiff(magrittr_pipes, "%$%")
71+
to_pipe_xpath <- glue("
7172
./preceding-sibling::*[1][
7273
self::PIPE or
73-
self::SPECIAL[text() = '%>%']
74+
self::SPECIAL[{ xp_text_in_table(pipes) }]
7475
]
75-
"
76+
")
7677
if (allow_single_expression) {
7778
zero_arg_cond <-
7879
glue::glue("count(expr) = 1 and not( {to_pipe_xpath} / preceding-sibling::expr[ {non_constant_cond} ])")

R/unnecessary_placeholder_linter.R

+3-3
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@
3434
#' @export
3535
unnecessary_placeholder_linter <- function() {
3636
# TODO(michaelchirico): handle R4.2.0 native placeholder _ as well
37-
xpath <- "
38-
//SPECIAL[text() = '%>%' or text() = '%T>%' or text() = '%<>%']
37+
xpath <- glue("
38+
//SPECIAL[{ xp_text_in_table(magrittr_pipes) }]
3939
/following-sibling::expr[
4040
expr/SYMBOL_FUNCTION_CALL
4141
and not(expr[
@@ -47,7 +47,7 @@ unnecessary_placeholder_linter <- function() {
4747
SYMBOL[text() = '.']
4848
and not(preceding-sibling::*[1][self::EQ_SUB])
4949
]
50-
"
50+
")
5151

5252
Linter(function(source_expression) {
5353
if (!is_lint_level(source_expression, "expression")) {

R/yoda_test_linter.R

+3-2
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,12 @@ yoda_test_linter <- function() {
4545
or (STR_CONST and not(OP-DOLLAR or OP-AT))
4646
or ((OP-PLUS or OP-MINUS) and count(expr[NUM_CONST]) = 2)
4747
"
48-
xpath <- glue::glue("
48+
pipes <- setdiff(magrittr_pipes, c("%$%", "%<>%"))
49+
xpath <- glue("
4950
//SYMBOL_FUNCTION_CALL[text() = 'expect_equal' or text() = 'expect_identical' or text() = 'expect_setequal']
5051
/parent::expr
5152
/following-sibling::expr[1][ {const_condition} ]
52-
/parent::expr[not(preceding-sibling::*[self::PIPE or self::SPECIAL[text() = '%>%']])]
53+
/parent::expr[not(preceding-sibling::*[self::PIPE or self::SPECIAL[{ xp_text_in_table(pipes) }]])]
5354
")
5455

5556
second_const_xpath <- glue::glue("expr[position() = 3 and ({const_condition})]")

tests/testthat/helper.R

+13
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,16 @@ skip_if_not_r_version <- function(min_version) {
6161
skip_if_not_utf8_locale <- function() {
6262
testthat::skip_if_not(l10n_info()[["UTF-8"]], "Not a UTF-8 locale")
6363
}
64+
65+
pipes <- function(exclude = NULL) {
66+
if (getRversion() < "4.1.0") exclude <- unique(c(exclude, "|>"))
67+
all_pipes <- c(
68+
standard = "%>%",
69+
greedy = "%!>%",
70+
tee = "%T>%",
71+
assignment = "%<>%",
72+
extraction = "%$%",
73+
native = "|>"
74+
)
75+
all_pipes[!all_pipes %in% exclude]
76+
}

tests/testthat/test-brace_linter.R

+5-5
Original file line numberDiff line numberDiff line change
@@ -470,7 +470,7 @@ test_that("code with pipes is handled correctly", {
470470

471471
expect_lint(
472472
trim_some("
473-
1:4 %>% {
473+
1:4 %!>% {
474474
sum(.)
475475
}
476476
"),
@@ -481,7 +481,7 @@ test_that("code with pipes is handled correctly", {
481481
# %>%\n{ is allowed
482482
expect_lint(
483483
trim_some("
484-
1:4 %>%
484+
1:4 %T>%
485485
{
486486
sum(.)
487487
}
@@ -492,7 +492,7 @@ test_that("code with pipes is handled correctly", {
492492

493493
expect_lint(
494494
trim_some("
495-
1:4 %>% { sum(.)
495+
xx %<>% { sum(.)
496496
}
497497
"),
498498
list(
@@ -503,9 +503,9 @@ test_that("code with pipes is handled correctly", {
503503

504504
expect_lint(
505505
trim_some("
506-
1:4 %>%
506+
x %>%
507507
{
508-
sum(.) }
508+
uvwxyz }
509509
"),
510510
list(
511511
list(message = lint_msg_closed, line_number = 3L, column_number = 12L)

tests/testthat/test-pipe_call_linter.R

+31
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ test_that("pipe_call_linter skips allowed usages", {
2626
}
2727
")
2828
expect_lint(lines, NULL, linter)
29+
30+
# extraction pipe uses RHS symbols
31+
expect_lint("a %$% b", NULL, linter)
2932
})
3033

3134
test_that("pipe_call_linter blocks simple disallowed usages", {
@@ -58,3 +61,31 @@ test_that("pipe_call_linter blocks simple disallowed usages", {
5861
pipe_call_linter()
5962
)
6063
})
64+
65+
local({
66+
pipes <- pipes(exclude = c("%$%", "|>"))
67+
linter <- pipe_call_linter()
68+
patrick::with_parameters_test_that(
69+
"All pipe operators are caught",
70+
{
71+
expect_lint(sprintf("a %s foo()", pipe), NULL, linter)
72+
expect_lint(sprintf("a %s foo", pipe), sprintf("`a %s foo`", pipe), linter)
73+
},
74+
pipe = pipes,
75+
.test_name = names(pipes)
76+
)
77+
})
78+
79+
test_that("Multiple lints give custom messages", {
80+
expect_lint(
81+
trim_some("
82+
a %>% b
83+
c %T>% d
84+
"),
85+
list(
86+
list(message = "%>%", line_number = 1L),
87+
list(message = "%T>%", line_number = 2L)
88+
),
89+
pipe_call_linter()
90+
)
91+
})

tests/testthat/test-pipe_continuation_linter.R

+18
Original file line numberDiff line numberDiff line change
@@ -188,3 +188,21 @@ local({
188188
code_string = valid_code
189189
)
190190
})
191+
192+
local({
193+
linter <- pipe_continuation_linter()
194+
pipes <- pipes()
195+
cases <- expand.grid(pipe1 = pipes, pipe2 = pipes, stringsAsFactors = FALSE)
196+
cases <- within(cases, {
197+
.test_name <- sprintf("(%s, %s)", pipe1, pipe2)
198+
})
199+
patrick::with_parameters_test_that(
200+
"Various pipes are linted correctly",
201+
expect_lint(
202+
sprintf("a %s b() %s\n c()", pipe1, pipe2),
203+
rex::rex(sprintf("`%s` should always have a space before it", pipe2)),
204+
linter
205+
),
206+
.cases = cases
207+
)
208+
})

tests/testthat/test-unnecessary_concatenation_linter.R

+19-27
Original file line numberDiff line numberDiff line change
@@ -45,34 +45,26 @@ test_that("unnecessary_concatenation_linter blocks disallowed usages", {
4545
)
4646
})
4747

48-
test_that("Correctly handles concatenation within magrittr pipes", {
48+
local({
49+
pipes <- pipes(exclude = "%$%")
4950
linter <- unnecessary_concatenation_linter()
50-
expect_lint('"a" %>% c("b")', NULL, linter)
51-
expect_lint(
52-
'"a" %>% c()',
53-
"Unneeded concatenation of a constant",
54-
linter
55-
)
56-
expect_lint(
57-
'"a" %>% list("b", c())',
58-
"Unneeded concatenation without arguments",
59-
linter
60-
)
61-
})
62-
63-
test_that("Correctly handles concatenation within native pipes", {
64-
skip_if_not_r_version("4.1.0")
65-
linter <- unnecessary_concatenation_linter()
66-
expect_lint('"a" |> c("b")', NULL, linter)
67-
expect_lint(
68-
'"a" |> c()',
69-
"Unneeded concatenation of a constant",
70-
linter
71-
)
72-
expect_lint(
73-
'"a" |> list("b", c())',
74-
"Unneeded concatenation without arguments",
75-
linter
51+
patrick::with_parameters_test_that(
52+
"Correctly handles concatenation within magrittr pipes",
53+
{
54+
expect_lint(sprintf('"a" %s c("b")', pipe), NULL, linter)
55+
expect_lint(
56+
sprintf('"a" %s c()', pipe),
57+
"Unneeded concatenation of a constant",
58+
linter
59+
)
60+
expect_lint(
61+
sprintf('"a" %s list("b", c())', pipe),
62+
"Unneeded concatenation without arguments",
63+
linter
64+
)
65+
},
66+
pipe = pipes,
67+
.test_name = names(pipes)
7668
)
7769
})
7870

tests/testthat/test-unnecessary_placeholder_linter.R

+7-7
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
linter <- unnecessary_placeholder_linter()
2+
pipes <- pipes(exclude = "|>")
3+
14
patrick::with_parameters_test_that(
25
"unnecessary_placeholder_linter skips allowed usages",
36
{
4-
linter <- unnecessary_placeholder_linter()
5-
67
# . used in position other than first --> ok
78
expect_lint(sprintf("x %s foo(y, .)", pipe), NULL, linter)
89
# ditto for nested usage
@@ -14,14 +15,13 @@ patrick::with_parameters_test_that(
1415
# . used inside a scope --> ok
1516
expect_lint(sprintf("x %s { foo(arg = .) }", pipe), NULL, linter)
1617
},
17-
.test_name = c("forward", "assignment", "tee"),
18-
pipe = c("%>%", "%<>%", "%T>%")
18+
.test_name = names(pipes),
19+
pipe = pipes
1920
)
2021

2122
patrick::with_parameters_test_that(
2223
"unnecessary_placeholder_linter blocks simple disallowed usages",
2324
{
24-
linter <- unnecessary_placeholder_linter()
2525
expect_lint(
2626
sprintf("x %s sum(.)", pipe),
2727
rex::rex("Don't use the placeholder (`.`) when it's not needed"),
@@ -34,6 +34,6 @@ patrick::with_parameters_test_that(
3434
unnecessary_placeholder_linter()
3535
)
3636
},
37-
.test_name = c("forward", "assignment", "tee"),
38-
pipe = c("%>%", "%<>%", "%T>%")
37+
.test_name = names(pipes),
38+
pipe = pipes
3939
)

tests/testthat/test-yoda_test_linter.R

+9-4
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,15 @@ test_that("yoda_test_linter ignores strings in $ expressions", {
3838
})
3939

4040
# if we only inspect the first argument & ignore context, get false positives
41-
test_that("yoda_test_linter ignores usage in pipelines", {
42-
expect_lint("foo() %>% expect_identical(2)", NULL, yoda_test_linter())
43-
skip_if_not_r_version("4.1.0")
44-
expect_lint("bar() |> expect_equal('a')", NULL, yoda_test_linter())
41+
local({
42+
pipes <- pipes(exclude = c("%<>%", "%$%"))
43+
linter <- yoda_test_linter()
44+
patrick::with_parameters_test_that(
45+
"yoda_test_linter ignores usage in pipelines",
46+
expect_lint(sprintf("foo() %s expect_identical(2)", pipe), NULL, linter),
47+
pipe = pipes,
48+
.test_name = names(pipes)
49+
)
4550
})
4651

4752
test_that("yoda_test_linter throws a special message for placeholder tests", {

0 commit comments

Comments
 (0)