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 23 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0417757
initial draft
IndrajeetPatil Oct 19, 2024
031329f
docs
IndrajeetPatil Oct 19, 2024
bd616ad
Create test-complex-conditional-linter.R
IndrajeetPatil Oct 19, 2024
f402853
fix XPath
IndrajeetPatil Oct 26, 2024
1acf920
simplify xpath
IndrajeetPatil Oct 26, 2024
575db33
fix failing tests
IndrajeetPatil Oct 26, 2024
78b0130
accept numeric values
IndrajeetPatil Oct 26, 2024
0269a5f
fix examples
IndrajeetPatil Oct 26, 2024
c6929fd
Update test-unnecessary_nesting_linter.R
IndrajeetPatil Oct 26, 2024
49acffd
Merge branch 'main' into f1830-complex-conditional-linter
IndrajeetPatil Oct 26, 2024
66c8102
Update NEWS.md
IndrajeetPatil Oct 30, 2024
746eba2
change default and check for input edge cases
IndrajeetPatil Nov 1, 2024
bea5900
Update test-complex-conditional-linter.R
IndrajeetPatil Nov 1, 2024
fed5efa
Merge branch 'main' into f1830-complex-conditional-linter
IndrajeetPatil Nov 1, 2024
249db6e
Merge branch 'main' into f1830-complex-conditional-linter
IndrajeetPatil Nov 18, 2024
16896a0
clean the new lints
IndrajeetPatil Nov 21, 2024
f6a5b61
base and internal copies not the same
IndrajeetPatil Nov 21, 2024
a685c99
fix tests
IndrajeetPatil Nov 21, 2024
532b9c2
rename test file
IndrajeetPatil Nov 24, 2024
421c1db
Merge branch 'main' into f1830-complex-conditional-linter
IndrajeetPatil Nov 27, 2024
c04e2f6
Merge branch 'main' into f1830-complex-conditional-linter
IndrajeetPatil Feb 3, 2025
3bb5f63
update for patrick
IndrajeetPatil Feb 3, 2025
8de0c31
Merge branch 'main' into f1830-complex-conditional-linter
IndrajeetPatil Feb 13, 2025
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 @@ -32,6 +32,7 @@ export(clear_cache)
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
5 changes: 5 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
+ Linters `closed_curly_linter()`, `open_curly_linter()`, `paren_brace_linter()`, and `semicolon_terminator_linter()`.
+ `linter=` argument of `Lint()`.

## New linters

* `complex_conditional_linter()` for encouraging refactoring of complex conditional expressions (like `if (x > 0 && y < 0 || z == 0)`) via well-named abstractions (#2676, @IndrajeetPatil).

# lintr 3.2.0

## Deprecations & breaking changes
Expand Down Expand Up @@ -93,6 +97,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 @@ -353,11 +353,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 @@ -277,7 +277,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 @@ -10,6 +10,7 @@ class_equals_linter,best_practices robustness consistency
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 lintr.Rproj
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
Version: 1.0
ProjectId: ab4c2695-5b43-4cc6-8ea0-0daf83afd8a3

RestoreWorkspace: No
SaveWorkspace: No
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