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 allow_implicit_else argument for return_linter #2321

Merged
merged 51 commits into from
Dec 6, 2023
Merged
Show file tree
Hide file tree
Changes from 46 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
1780e62
New implicit_else_return_linter
MichaelChirico Nov 20, 2023
7bc4bc5
Merge branch 'main' into implicit_else_return
MichaelChirico Nov 20, 2023
80b97d9
Merge branch 'main' into implicit_else_return
MichaelChirico Nov 24, 2023
b203459
initial merge of tests
MichaelChirico Nov 24, 2023
7f50101
Use trim_some() in tests
MichaelChirico Nov 24, 2023
c58cf6c
Initial merge of R sources
MichaelChirico Nov 24, 2023
74df3de
1st "completed" merge
MichaelChirico Nov 24, 2023
ea237e9
Fix undefined, document
MichaelChirico Nov 24, 2023
59ecba5
fix 'except' for implicit returns; tests passing
MichaelChirico Nov 24, 2023
2245582
some tests with return_style="explicit" too
MichaelChirico Nov 24, 2023
f267729
tidy up NEWS
MichaelChirico Nov 24, 2023
c111abd
indent XPath
MichaelChirico Nov 27, 2023
3422602
revert
MichaelChirico Nov 27, 2023
7eeb3aa
Merge branch 'main' into implicit_else_return
MichaelChirico Nov 27, 2023
1f183a2
Merge remote-tracking branch 'origin/implicit_else_return' into impli…
MichaelChirico Nov 27, 2023
19602cc
fix incorrect test, add a regression test
MichaelChirico Nov 27, 2023
49926b7
try a better test description
MichaelChirico Nov 27, 2023
7e5cb6b
partial revert, quick fix caused new false positives
MichaelChirico Nov 27, 2023
46ac49c
land on descendant if needed for better metadata
MichaelChirico Nov 27, 2023
7c72f70
explicitly test for 2 lints
MichaelChirico Nov 27, 2023
463e1ec
fix vectorized case, add test
MichaelChirico Nov 27, 2023
613ed36
remove ' to hide 'commented code'
MichaelChirico Nov 27, 2023
86a580b
handle nested terminal if/else
MichaelChirico Nov 28, 2023
62b9565
Merge branch 'main' into return-recurse
MichaelChirico Nov 28, 2023
69253f3
better comment
MichaelChirico Nov 28, 2023
21553c0
more robust courtesy of r-devel
MichaelChirico Nov 28, 2023
f768750
catch another edge case
MichaelChirico Nov 28, 2023
0a8ef29
narrower line, unused object
MichaelChirico Nov 28, 2023
54f5017
another test as exposed by failure on other test files
MichaelChirico Nov 28, 2023
3cb24eb
another edge case exposed by lint_package()
MichaelChirico Nov 28, 2023
aa07981
try with Recall()
MichaelChirico Nov 28, 2023
b4060c7
correct handling of empty "{" for explicit returns
MichaelChirico Nov 28, 2023
669dfcd
add a "weird" test of nested empty "{"
MichaelChirico Nov 29, 2023
faee3c2
simplify recursive signature with fixed "params"
MichaelChirico Nov 29, 2023
e005b4d
drop need for list() by using lapply()
MichaelChirico Nov 29, 2023
1067599
# nolint
MichaelChirico Nov 29, 2023
b34b1a4
Merge branch 'return-recurse' into implicit_else_return
MichaelChirico Nov 29, 2023
826e9c3
try and fix merge gore
MichaelChirico Nov 29, 2023
d08d0f6
implement recursive implicit else linting
MichaelChirico Nov 29, 2023
f09a408
Add a test of nested if/else+implicit nested else
MichaelChirico Nov 29, 2023
ecc499d
mention parent PR in NEWS
MichaelChirico Nov 29, 2023
68ffc86
another regression test
MichaelChirico Nov 29, 2023
0220b36
also test that except= doesnt apply to return_stlye="implicit"
MichaelChirico Nov 29, 2023
9fdbe14
clarifying comment
MichaelChirico Nov 29, 2023
33e1562
pull adjustment for implict+!allow+except= outside the loop
MichaelChirico Nov 29, 2023
cd0bda5
flatten_lints() not necessary
MichaelChirico Nov 29, 2023
4726d08
flatten_lints() not necessary
MichaelChirico Nov 29, 2023
6e85a30
ensure = assignments are handled correctly
MichaelChirico Nov 29, 2023
73b9aee
Merge branch 'implicit_else_return' of github.com:r-lib/lintr into im…
MichaelChirico Nov 29, 2023
d8e84ac
Merge branch 'main' into implicit_else_return
MichaelChirico Dec 4, 2023
743322d
Merge branch 'main' into implicit_else_return
AshesITR Dec 6, 2023
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 NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ importFrom(utils,tail)
importFrom(utils,txtProgressBar)
importFrom(xml2,as_list)
importFrom(xml2,xml_attr)
importFrom(xml2,xml_children)
importFrom(xml2,xml_find_all)
importFrom(xml2,xml_find_chr)
importFrom(xml2,xml_find_first)
Expand Down
8 changes: 6 additions & 2 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,19 @@

## Changes to default linters

* New default linter `return_linter()` for the style guide rule that terminal returns should be left implicit (#1100, @MEO265).
* New default linter `return_linter()` for the style guide rule that terminal returns should be left implicit (#1100, #2354, and #2356, @MEO265 and @MichaelChirico).

## New and improved features

* More helpful errors for invalid configs (#2253, @MichaelChirico).
* `library_call_linter()` is extended
+ to encourage all packages to be attached with `library(symbol)`, not `library("symbol", character.only = TRUE)` or "vectorized" approaches looping over package names (part of #884, @MichaelChirico).
+ to discourage many consecutive calls to `suppressMessages()` or `suppressPackageStartupMessages()` (part of #884, @MichaelChirico).
* `return_linter()` also has an argument `return_style` (`"implicit"` by default) which checks that all functions confirm to the specified return style of `"implicit"` or `"explicit"` (part of #884, @MichaelChirico, @AshesITR and @MEO265).
* `return_linter()` also has arguments for fine-tuning which functions get linted:
MichaelChirico marked this conversation as resolved.
Show resolved Hide resolved
+ `return_style` (`"implicit"` by default) which checks that all functions confirm to the specified return style of `"implicit"` or `"explicit"` (#2271 and part of #884, @MichaelChirico, @AshesITR and @MEO265).
+ `allow_implicit_else` (default `TRUE`) which, when `FALSE`, checks that all terminal `if` statements are paired with a corresponding `else` statement (part of #884, @MichaelChirico).
+ `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.
* `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).
Expand Down
2 changes: 1 addition & 1 deletion R/keyword_quote_linter.R
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ keyword_quote_linter <- function() {
invalid_assignment_quoting <- is_valid_r_name(get_r_string(assignment_expr))
# NB: XPath is such that there is exactly 1 node per match, making xml_children() ideal.
# xml_child() gets it wrong for 0 (an error) and >1 match.
assignment_to_string <- xml_name(xml2::xml_children(assignment_expr)) == "STR_CONST"
assignment_to_string <- xml_name(xml_children(assignment_expr)) == "STR_CONST"

string_assignment_lints <- xml_nodes_to_lints(
assignment_expr[assignment_to_string & !invalid_assignment_quoting],
Expand Down
2 changes: 1 addition & 1 deletion R/lintr-package.R
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
#' @importFrom utils capture.output getParseData getTxtProgressBar globalVariables head relist
#' setTxtProgressBar tail txtProgressBar
#' @importFrom xml2 as_list
#' xml_attr xml_find_all xml_find_chr xml_find_lgl xml_find_num xml_find_first xml_name xml_text
#' xml_attr xml_children xml_find_all xml_find_chr xml_find_lgl xml_find_num xml_find_first xml_name xml_text
#' @rawNamespace
#' if (getRversion() >= "4.0.0") {
#' importFrom(tools, R_user_dir)
Expand Down
183 changes: 105 additions & 78 deletions R/return_linter.R
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
#' the default, enforeces the Tidyverse guide recommendation to leave terminal
#' returns implicit. `"explicit"` style requires that `return()` always be
#' explicitly supplied.
#' @param allow_implicit_else Logical, default `TRUE`. If `FALSE`, functions with a terminal
#' `if` clause must always have an `else` clause, making the `NULL` alternative explicit
#' if necessary.
#' @param return_functions Character vector of functions that are accepted as terminal calls
#' when `return_style = "explicit"`. These are in addition to exit functions
#' from base that are always allowed: [stop()], [q()], [quit()], [invokeRestart()],
Expand All @@ -32,6 +35,13 @@
#' linters = return_linter(return_style = "explicit")
#' )
#'
#' code <- "function(x) {\n if (x > 0) 2\n}"
#' writeLines(code)
#' lint(
#' text = code,
#' linters = return_linter(allow_implicit_else = FALSE)
#' )
#'
#' # okay
#' code <- "function(x) {\n x + 1\n}"
#' writeLines(code)
Expand All @@ -47,6 +57,12 @@
#' linters = return_linter(return_style = "explicit")
#' )
#'
#' code <- "function(x) {\n if (x > 0) 2 else NULL\n}"
#' writeLines(code)
#' lint(
#' text = code,
#' linters = return_linter(allow_implicit_else = FALSE)
#' )
#'
#' @evalRd rd_tags("return_linter")
#' @seealso
Expand All @@ -55,26 +71,26 @@
#' @export
return_linter <- function(
return_style = c("implicit", "explicit"),
allow_implicit_else = TRUE,
return_functions = NULL,
except = NULL) {
return_style <- match.arg(return_style)

if (!allow_implicit_else || return_style == "explicit") {
except_xpath <- glue("parent::expr[not(
preceding-sibling::expr/SYMBOL[{ xp_text_in_table(union(special_funs, except)) }]
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was a bit worried about the preceding-sibling::expr and parent::expr here in the context of = or -> assignment. Added a test for the former; the latter is not relevant I think, because of operator precedence:

function() {
  ...
} -> .onLoad

Does not work as "intended" -- it actually parses like:

function() ( foo <- { } )

So right-assignment of a function would actually have to look like:

(function() {
  ...
}) -> .onLoad

That seems pretty unlikely to me, so ignoring it for now unless there's a user request.

Copy link
Collaborator Author

@MichaelChirico MichaelChirico Nov 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

)]")
}

if (return_style == "implicit") {
xpath <- "
(//FUNCTION | //OP-LAMBDA)
/following-sibling::expr[1][*[1][self::OP-LEFT-BRACE]]
/expr[last()][
expr[1][
not(OP-DOLLAR or OP-AT)
and SYMBOL_FUNCTION_CALL[text() = 'return']
]
]
"
msg <- "Use implicit return behavior; explicit return() is not needed."
body_xpath <- "(//FUNCTION | //OP-LAMBDA)/following-sibling::expr[1]"
params <- list(
implicit = TRUE,
type = "style",
lint_xpath = "SYMBOL_FUNCTION_CALL[text() = 'return']",
lint_message = "Use implicit return behavior; explicit return() is not needed."
)
} else {
# See `?.onAttach`; these functions are all exclusively used for their
# side-effects, so implicit return is generally acceptable

except <- union(special_funs, except)

base_return_functions <- c(
Expand All @@ -94,80 +110,91 @@ return_linter <- function(

return_functions <- union(base_return_functions, return_functions)

control_calls <- c("IF", "FOR", "WHILE", "REPEAT")

# from top, look for a FUNCTION definition that uses { (one-line
# function definitions are excepted), then look for failure to find
# return() on the last() expr of the function definition.
# exempt .onLoad which shows up in the tree like
# <expr><expr><SYMBOL>.onLoad</></><LEFT_ASSIGN></><expr><FUNCTION>...
# simple final expression (no control flow) must be
# <expr><expr> CALL( <expr> ) </expr></expr>
# NB: if this syntax _isn't_ used, the node may not be <expr>, hence
# the use of /*[...] below and self::expr here. position() = 1 is
# needed to guard against a few other cases.
# We also need to make sure that this expression isn't followed by a pipe
# symbol, which would indicate that we need to also check the last
# expression.
# pipe expressions are like
# ...
# <SPECIAL>%&gt;%</SPECIAL>
# <expr><expr><SYMBOL_FUNCTION_CALL>return</SYMBOL_FUNCTION_CALL>
# </expr></expr>
# Unlike the following case, the return should be the last expression in
# the sequence.
# conditional expressions are like
# <expr><IF> ( <expr> ) <expr> [ <ELSE> <expr>] </expr>
# we require _any_ call to return() in either of the latter two <expr>, i.e.,
# we don't apply recursive logic to check every branch, only that the
# two top level branches have at least two return()s
# because of special 'in' syntax for 'for' loops, the condition is
# tagged differently than for 'if'/'while' conditions (simple PAREN)
xpath <- glue("
(//FUNCTION | //OP-LAMBDA)[parent::expr[not(
preceding-sibling::expr[SYMBOL[{ xp_text_in_table(except) }]]
)]]
body_xpath <- glue("
(//FUNCTION | //OP-LAMBDA)[{ except_xpath }]
/following-sibling::expr[OP-LEFT-BRACE and expr[last()]/@line1 != @line1]
/expr[last()]
/*[
(
position() = 1
and (
(
{ xp_or(paste0('self::', setdiff(control_calls, 'IF'))) }
) or (
not({ xp_or(paste0('self::', control_calls)) })
and not(
following-sibling::PIPE
or following-sibling::SPECIAL[text() = '%>%']
)
and not(self::expr/SYMBOL_FUNCTION_CALL[
{ xp_text_in_table(return_functions) }
])
)
)
) or (
preceding-sibling::IF
and self::expr
and position() > 4
and not(.//SYMBOL_FUNCTION_CALL[{ xp_text_in_table(return_functions) }])
)
]
")
msg <- "All functions must have an explicit return()."
params <- list(
implicit = FALSE,
type = "warning",
lint_xpath = glue("self::*[not(
(self::expr | following-sibling::SPECIAL[text() = '%>%']/following-sibling::expr/expr[1])
/SYMBOL_FUNCTION_CALL[{ xp_text_in_table(return_functions) }]
)]"),
lint_message = "All functions must have an explicit return()."
)
}

params$allow_implicit_else <- allow_implicit_else

Linter(linter_level = "expression", function(source_expression) {
xml <- source_expression$xml_parsed_content
if (is.null(xml)) return(list())

xml_nodes <- xml_find_all(xml, xpath)
body_expr <- xml_find_all(xml, body_xpath)

xml_nodes_to_lints(
xml_nodes,
source_expression = source_expression,
lint_message = msg,
type = "style"
params$source_expression <- source_expression

if (params$implicit && !params$allow_implicit_else) {
# can't incorporate this into the body_xpath for implicit return style,
# since we still lint explicit returns for except= functions.
allow_implicit_else <- is.na(xml_find_first(body_expr, except_xpath))
} else {
allow_implicit_else <- rep(params$allow_implicit_else, length(body_expr))
}
# nested_return_lints not "vectorized" due to xml_children()
Map(
function(expr, allow_implicit_else) {
params$allow_implicit_else <- allow_implicit_else
nested_return_lints(expr, params)
},
body_expr, allow_implicit_else
)
})
}

nested_return_lints <- function(expr, params) {
child_expr <- xml_children(expr)
if (length(child_expr) == 0L) {
return(list())
}
child_node <- xml_name(child_expr)

if (child_node[1L] == "OP-LEFT-BRACE") {
expr_idx <- which(child_node %in% c("expr", "equal_assign", "expr_or_assign_or_help"))
if (length(expr_idx) == 0L) { # empty brace expression {}
if (params$implicit) {
return(list())
} else {
return(list(xml_nodes_to_lints(
expr,
source_expression = params$source_expression,
lint_message = params$lint_message,
type = params$type
)))
}
}
nested_return_lints(child_expr[[tail(expr_idx, 1L)]], params)
} else if (child_node[1L] == "IF") {
expr_idx <- which(child_node %in% c("expr", "equal_assign", "expr_or_assign_or_help"))
return_lints <- lapply(child_expr[expr_idx[-1L]], nested_return_lints, params)
if (params$allow_implicit_else || length(expr_idx) == 3L) {
return(return_lints)
}
implicit_else_lints <- list(xml_nodes_to_lints(
expr,
source_expression = params$source_expression,
lint_message = "All functions with terminal if statements must have a corresponding terminal else clause",
type = "warning"
))
c(return_lints, implicit_else_lints)
} else {
xml_nodes_to_lints(
xml_find_first(child_expr[[1L]], params$lint_xpath),
source_expression = params$source_expression,
lint_message = params$lint_message,
type = params$type
)
}
}
18 changes: 18 additions & 0 deletions man/return_linter.Rd

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

Loading