Skip to content

Commit

Permalink
Merge branch 'main' into consecutive_mutate
Browse files Browse the repository at this point in the history
  • Loading branch information
MichaelChirico committed Nov 20, 2023
2 parents c155981 + 0815b2a commit dc528d7
Show file tree
Hide file tree
Showing 12 changed files with 226 additions and 4 deletions.
1 change: 1 addition & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ Collate:
'get_source_expressions.R'
'ids_with_token.R'
'if_not_else_linter.R'
'if_switch_linter.R'
'ifelse_censor_linter.R'
'implicit_assignment_linter.R'
'implicit_integer_linter.R'
Expand Down
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export(get_r_string)
export(get_source_expressions)
export(ids_with_token)
export(if_not_else_linter)
export(if_switch_linter)
export(ifelse_censor_linter)
export(implicit_assignment_linter)
export(implicit_integer_linter)
Expand Down
1 change: 1 addition & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
* `list_comparison_linter()` for discouraging comparisons on the output of `lapply()`, e.g. `lapply(x, sum) > 10` (part of #884, @MichaelChirico).
* `print_linter()` for discouraging usage of `print()` on string literals like `print("Reached here")` or `print(paste("Found", nrow(DF), "rows."))` (#1894, @MichaelChirico).
* `consecutive_mutate_linter()` for encouraging consecutive calls to `dplyr::mutate()` to be combined (part of #884, @MichaelChirico).
* `if_switch_linter()` for encouraging `switch()` over repeated `if`/`else` tests (part of #884, @MichaelChirico).
* `nested_pipe_linter()` for discouraging pipes within pipes, e.g. `df1 %>% inner_join(df2 %>% select(a, b))` (part of #884, @MichaelChirico).
* `nrow_subset_linter()` for discouraging usage like `nrow(subset(x, conditions))` in favor of something like `with(x, sum(conditions))` which doesn't require a full subset of `x` (part of #884, @MichaelChirico).
* `pipe_return_linter()` for discouraging usage of `return()` inside a {magrittr} pipeline (part of #884, @MichaelChirico).
Expand Down
84 changes: 84 additions & 0 deletions R/if_switch_linter.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
#' Require usage of switch() over repeated if/else blocks
#'
#' [switch()] statements in R are used to delegate behavior based
#' on the value of some input scalar string, e.g.
#' `switch(x, a = 1, b = 3, c = 7, d = 8)` will be one of
#' `1`, `3`, `7`, or `8`, depending on the value of `x`.
#'
#' This can also be accomplished by repeated `if`/`else` statements like
#' so: `if (x == "a") 1 else if (x == "b") 2 else if (x == "c") 7 else 8`
#' (implicitly, the last `else` assumes x only takes 4 possible values),
#' but this is more cluttered and slower (note that `switch()` takes the same
#' time to evaluate regardless of the value of `x`, and is faster even
#' when `x` takes the first value (here `a`), and that the `if`/`else`
#' approach is roughly linear in the number of conditions that need to
#' be evaluated, here up to 3 times).
#'
#' @examples
#' # will produce lints
#' lint(
#' text = "if (x == 'a') 1 else if (x == 'b') 2 else 3",
#' linters = if_switch_linter()
#' )
#'
#' # okay
#' lint(
#' text = "switch(x, a = 1, b = 2, 3)",
#' linters = if_switch_linter()
#' )
#'
#' # switch() version not as clear
#' lint(
#' text = "if (x == 'a') 1 else if (x == 'b' & y == 2) 2 else 3",
#' linters = if_switch_linter()
#' )
#'
#' @evalRd rd_tags("if_switch_linter")
#' @seealso [linters] for a complete list of linters available in lintr.
#' @export
if_switch_linter <- function() {
equal_str_cond <- "expr[1][EQ and expr[STR_CONST]]"

# NB: IF AND {...} AND ELSE/... implies >= 3 equality conditions are present
# .//expr/IF/...: the expr in `==` that's _not_ the STR_CONST
# not(preceding::IF): prevent nested matches which might be incorrect globally
# not(. != .): don't match if there are _any_ expr which _don't_ match the top
# expr
xpath <- glue("
//IF
/parent::expr[
not(preceding-sibling::IF)
and {equal_str_cond}
and ELSE/following-sibling::expr[
IF
and {equal_str_cond}
and ELSE/following-sibling::expr[IF and {equal_str_cond}]
]
and not(
.//expr/IF/following-sibling::{equal_str_cond}/expr[not(STR_CONST)]
!= expr[1][EQ]/expr[not(STR_CONST)]
)
]
")

Linter(function(source_expression) {
if (!is_lint_level(source_expression, "expression")) {
return(list())
}

xml <- source_expression$xml_parsed_content

bad_expr <- xml_find_all(xml, xpath)

xml_nodes_to_lints(
bad_expr,
source_expression = source_expression,
lint_message = paste(
"Prefer switch() statements over repeated if/else equality tests,",
"e.g., switch(x, a = 1, b = 2) over",
'if (x == "a") 1 else if (x == "b") 2.'
),
type = "warning"
)
})
}
1 change: 1 addition & 0 deletions inst/lintr/linters.csv
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function_argument_linter,style consistency best_practices
function_left_parentheses_linter,style readability default
function_return_linter,readability best_practices
if_not_else_linter,readability consistency configurable
if_switch_linter,best_practices readability consistency efficiency
ifelse_censor_linter,best_practices efficiency
implicit_assignment_linter,style best_practices readability configurable
implicit_integer_linter,style consistency best_practices configurable
Expand Down
1 change: 1 addition & 0 deletions man/best_practices_linters.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions man/consistency_linters.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions man/efficiency_linters.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 50 additions & 0 deletions man/if_switch_linter.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions man/linters.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions man/readability_linters.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

79 changes: 79 additions & 0 deletions tests/testthat/test-if_switch_linter.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
test_that("if_switch_linter skips allowed usages", {
linter <- if_switch_linter()

# don't apply to simple if/else statements
expect_lint("if (x == 'a') 1 else 2", NULL, linter)
# don't apply to non-character conditions
# (NB: switch _could_ be used for integral input, but this
# interface is IMO a bit clunky / opaque)
expect_lint("if (x == 1) 1 else 2", NULL, linter)
# this also has a switch equivalent, but we don't both handling such
# complicated cases
expect_lint("if (x == 'a') 1 else if (x != 'b') 2 else 3", NULL, linter)
# multiple variables involved --> no clean change
expect_lint("if (x == 'a') 1 else if (y == 'b') 2 else 3", NULL, linter)
# multiple conditions --> no clean change
expect_lint("if (is.character(x) && x == 'a') 1 else if (x == 'b') 2 else 3", NULL, linter)
# simple cases with two conditions might be more natural
# without switch(); require at least three branches to trigger a lint
expect_lint("if (x == 'a') 1 else if (x == 'b') 2", NULL, linter)
# still no third if() clause
expect_lint("if (x == 'a') 1 else if (x == 'b') 2 else 3", NULL, linter)
})

test_that("if_switch_linter blocks simple disallowed usages", {
linter <- if_switch_linter()
lint_msg <- rex::rex("Prefer switch() statements over repeated if/else equality tests")

# anything with >= 2 equality statements is deemed switch()-worthy
expect_lint("if (x == 'a') 1 else if (x == 'b') 2 else if (x == 'c') 3", lint_msg, linter)
# expressions are also OK
expect_lint("if (foo(x) == 'a') 1 else if (foo(x) == 'b') 2 else if (foo(x) == 'c') 3", lint_msg, linter)
})

test_that("if_switch_linter handles further nested if/else correctly", {
linter <- if_switch_linter()

# ensure that nested if() doesn't generate multiple lints;
expect_lint(
"if (x == 'a') 1 else if (x == 'b') 2 else if (x == 'c') 3 else if (x == 'd') 4",
rex::rex("Prefer switch() statements over repeated if/else equality tests"),
linter
)
# related to previous test -- if the first condition is non-`==`, the
# whole if/else chain is "tainted" / non-switch()-recommended.
# (technically, switch can work here, but the semantics are opaque)
expect_lint(
"if (x %in% c('a', 'e', 'f')) 1 else if (x == 'b') 2 else if (x == 'c') 3 else if (x == 'd') 4",
NULL,
linter
)
})

test_that("multiple lints have right metadata", {
lint_msg <- rex::rex("Prefer switch() statements over repeated if/else equality tests")

expect_lint(
trim_some("{
if (x == 'a') {
do_a()
} else if (x == 'b') {
do_b()
} else if (x == 'c') {
do_c()
}
if (y == 'A') {
do_A()
} else if (y == 'B') {
do_B()
} else if (y == 'C') {
do_C()
}
}"),
list(
list(lint_msg, line_number = 2L),
list(lint_msg, line_number = 9L)
),
if_switch_linter()
)
})

0 comments on commit dc528d7

Please sign in to comment.