Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New linter for complex conditional expressions #2676

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Collate:
'commas_linter.R'
'commented_code_linter.R'
'comparison_negation_linter.R'
'complex_conditional_linter.R'
'condition_call_linter.R'
'condition_message_linter.R'
'conjunct_test_linter.R'
Expand Down
1 change: 1 addition & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export(closed_curly_linter)
export(commas_linter)
export(commented_code_linter)
export(comparison_negation_linter)
export(complex_conditional_linter)
export(condition_call_linter)
export(condition_message_linter)
export(conjunct_test_linter)
Expand Down
4 changes: 2 additions & 2 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
* `scalar_in_linter` is now configurable to allow other `%in%` like operators to be linted. The data.table operator `%chin%` is no longer linted by default; use `in_operators = "%chin%"` to continue linting it. (@F-Noelle)
* `lint()` and friends now normalize paths to forward slashes on Windows (@olivroy, #2613).
* `undesirable_function_linter()`, `undesirable_operator_linter()`, and `list_comparison_linter()` were removed from the tag `efficiency` (@IndrajeetPatil, #2655). If you use `linters_with_tags("efficiency")` to include these linters, you'll need to adjust your config to keep linting your code against them. We did not find any such users on GitHub.


## Bug fixes

Expand Down Expand Up @@ -47,7 +46,7 @@
+ `return_functions` to customize which functions are equivalent to `return()` as "exit" clauses, e.g. `rlang::abort()` can be considered in addition to the default functions like `stop()` and `q()` from base (#2271 and part of #884, @MichaelChirico and @MEO265).
+ `except` to customize which functions are ignored entirely (i.e., whether they have a return of the specified style is not checked; #2271 and part of #884, @MichaelChirico and @MEO265). Namespace hooks like `.onAttach()` and `.onLoad()` are always ignored.
+ `except_regex`, the same purpose as `except=`, but filters functions by pattern. This is motivated by {RUnit}, where test suites are based on unit test functions matched by pattern, e.g. `^Test`, and where explicit return may be awkward (#2335, @MichaelChirico).
* `unnecessary_lambda_linter` is extended to encourage vectorized comparisons where possible, e.g. `sapply(x, sum) > 0` instead of `sapply(x, function(x) sum(x) > 0)` (part of #884, @MichaelChirico). Toggle this behavior with argument `allow_comparison`.
* `unnecessary_lambda_linter()` is extended to encourage vectorized comparisons where possible, e.g. `sapply(x, sum) > 0` instead of `sapply(x, function(x) sum(x) > 0)` (part of #884, @MichaelChirico). Toggle this behavior with argument `allow_comparison`.
* `backport_linter()` is slightly faster by moving expensive computations outside the linting function (#2339, #2348, @AshesITR and @MichaelChirico).
* `Linter()` has a new argument `linter_level` (default `NA`). This is used by `lint()` to more efficiently check for expression levels than the idiom `if (!is_lint_level(...)) { return(list()) }` (#2351, @AshesITR).
* `string_boundary_linter()` recognizes regular expression calls like `grepl("^abc$", x)` that can be replaced by using `==` instead (#1613, @MichaelChirico).
Expand Down Expand Up @@ -83,6 +82,7 @@
* `pipe_return_linter()` for discouraging usage of `return()` inside a {magrittr} pipeline (part of #884, @MichaelChirico).
* `one_call_pipe_linter()` for discouraging one-step pipelines like `x |> as.character()` (#2330 and part of #884, @MichaelChirico).
* `object_overwrite_linter()` for discouraging re-use of upstream package exports as local variables (#2344, #2346 and part of #884, @MichaelChirico and @AshesITR).
* `complex_conditional_linter()` for encouraging refactoring of complex conditional expressions (like `if (x > 0 && y < 0 || z == 0)`) via well-named abstractions (#2676, @IndrajeetPatil).

### Lint accuracy fixes: removing false positives

Expand Down
111 changes: 111 additions & 0 deletions R/complex_conditional_linter.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
#' Complex Conditional Expressions Linter
#'
#' Detects complex conditional expressions and suggests extracting
#' them into Boolean functions or variables for improved readability and reusability.
#'
#' For example, if you have a conditional expression with more than two logical operands,
#'
#' ```
#' if (looks_like_a_duck(x) &&
#' swims_like_a_duck(x) &&
#' quacks_like_a_duck(x)) {
#' ...
#' }
#' ````
#'
#' to improve its readability and reusability, you can extract the conditional expression.
#'
#' You can either extract it into a Boolean function:
#'
#' ```
#' is_duck <- function(x) {
#' looks_like_a_duck(x) &&
#' swims_like_a_duck(x) &&
#' quacks_like_a_duck(x)
#' }
#'
#' if (is_duck(x)) {
#' ...
#' }
#' ```
#'
#' or into a Boolean variable:
#'
#' ```
#' is_duck <- looks_like_a_duck(x) &&
#' swims_like_a_duck(x) &&
#' quacks_like_a_duck(x)
#'
#' if (is_duck) {
#' ...
#' }
#' ```
#'
#' In addition to improving code readability, extracting complex conditional expressions
#' has the added benefit of introducing a reusable abstraction.
#'
#' @param threshold Integer. The maximum number of logical operators (`&&` or `||`)
#' allowed in a conditional expression. The default is `2L`, meaning any conditional expression
#' with more than two logical operators will be flagged.
#'
#' @examples
#' # will produce lints
#' code <- "if (a && b && c) { do_something() }"
#' writeLines(code)
#' lint(
#' text = code,
#' linters = complex_conditional_linter()
#' )
#'
#' # okay
#' code <- "if (ready_to_do_something) { do_something() }"
#' writeLines(code)
#' lint(
#' text = code,
#' linters = complex_conditional_linter()
#' )
#'
#' code <- "if (a && b && c) { do_something() }"
#' writeLines(code)
#' lint(
#' text = code,
#' linters = complex_conditional_linter(threshold = 2L)
#' )
#'
#' @evalRd rd_tags("complex_conditional_linter")
#' @seealso [linters] for a complete list of linters available in lintr.
#' @export
complex_conditional_linter <- function(threshold = 2L) {
stopifnot(is.numeric(threshold), length(threshold) == 1L, threshold >= 1L)
threshold <- as.integer(threshold)

xpath <- glue::glue("//expr[
parent::expr[IF or WHILE]
and
preceding-sibling::*[1][self::OP-LEFT-PAREN]
and
following-sibling::*[1][self::OP-RIGHT-PAREN]
and
count(descendant-or-self::*[AND2 or OR2]) > {threshold}
]")


Linter(linter_level = "expression", function(source_expression) {
xml <- source_expression$xml_parsed_content

nodes <- xml2::xml_find_all(xml, xpath)

lints <- xml_nodes_to_lints(
nodes,
source_expression = source_expression,
lint_message = paste0(
"Complex conditional with more than ",
threshold,
" logical operator(s). Consider extracting into a boolean function or variable for readability and reusability."
),
type = "warning"
)

lints
})
}
3 changes: 2 additions & 1 deletion R/get_source_expressions.R
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,9 @@ get_source_expressions <- function(filename, lines = NULL) {
names(source_expression$lines) <- seq_along(source_expression$lines)
source_expression$content <- get_content(source_expression$lines)
parsed_content <- get_source_expression(source_expression, error = function(e) lint_parse_error(e, source_expression))
is_unreliable_lint <- is.na(e$line) || !nzchar(e$line) || e$message == "unexpected end of input"

if (is_lint(e) && (is.na(e$line) || !nzchar(e$line) || e$message == "unexpected end of input")) {
if (is_lint(e) && is_unreliable_lint) {
# Don't create expression list if it's unreliable (invalid encoding or unhandled parse error)
expressions <- list()
} else {
Expand Down
6 changes: 3 additions & 3 deletions R/lint.R
Original file line number Diff line number Diff line change
Expand Up @@ -360,11 +360,11 @@ validate_linter_object <- function(linter, name) {
)
}

# A linter factory is a function whose last call is to `Linter()`
is_linter_factory <- function(fun) {
# A linter factory is a function whose last call is to Linter()
bdexpr <- body(fun)
# covr internally transforms each call into if (TRUE) { covr::count(...); call }
while (is.call(bdexpr) && (bdexpr[[1L]] == "{" || (bdexpr[[1L]] == "if" && bdexpr[[2L]] == "TRUE"))) {
# covr internally transforms each call into `if (TRUE) { covr::count(...); call }`
while (is.call(bdexpr) && (bdexpr[[1L]] == "{" || (bdexpr[[1L]] == "if" && bdexpr[[2L]] == "TRUE"))) { # nolint: complex_conditional_linter
bdexpr <- bdexpr[[length(bdexpr)]]
}
is.call(bdexpr) && identical(bdexpr[[1L]], as.name("Linter"))
Expand Down
2 changes: 1 addition & 1 deletion R/unnecessary_nesting_linter.R
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ unnecessary_nesting_linter <- function(
unnecessary_else_brace_lints <- xml_nodes_to_lints(
unnecessary_else_brace_expr,
source_expression = source_expression,
lint_message = "Simplify this condition by using 'else if' instead of 'else { if.",
lint_message = "Simplify this condition by using 'else if' instead of 'else { if'.",
type = "warning"
)

Expand Down
3 changes: 2 additions & 1 deletion R/utils.R
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
`%||%` <- function(x, y) {
if (is.null(x) || length(x) == 0L || (is.atomic(x[[1L]]) && is.na(x[[1L]]))) {
is_atomic_and_missing <- is.atomic(x[[1L]]) && is.na(x[[1L]])
if (is.null(x) || length(x) == 0L || is_atomic_and_missing) {
y
} else {
x
Expand Down
1 change: 1 addition & 0 deletions inst/lintr/linters.csv
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ closed_curly_linter,defunct
commas_linter,style readability default configurable
commented_code_linter,style readability best_practices default
comparison_negation_linter,readability consistency
complex_conditional_linter,style readability best_practices configurable
condition_call_linter,style tidy_design best_practices configurable
condition_message_linter,best_practices consistency
conjunct_test_linter,package_development best_practices readability configurable pkg_testthat
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.

87 changes: 87 additions & 0 deletions man/complex_conditional_linter.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/configurable_linters.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.

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

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

Loading
Loading