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

expect_null_linter #887

Merged
merged 36 commits into from
Mar 12, 2022
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
43778a6
New expect_null_linter
MichaelChirico Nov 15, 2021
2b6c53b
NEWS
MichaelChirico Nov 15, 2021
4da6666
add tests
MichaelChirico Nov 15, 2021
10f87e5
add xpath utilities
MichaelChirico Nov 15, 2021
1bf7d38
roxygenize()
MichaelChirico Nov 15, 2021
7ae8df4
ignore nested .lintr configs
MichaelChirico Nov 15, 2021
842c15b
remove unneeded param from docs
MichaelChirico Nov 15, 2021
4696d5b
functionalize usage in tests
MichaelChirico Nov 15, 2021
5bc7b4b
remove unused arg
MichaelChirico Nov 15, 2021
b007d37
redocument
MichaelChirico Nov 15, 2021
e324084
remove @title
MichaelChirico Nov 15, 2021
bf8f726
revert
MichaelChirico Nov 15, 2021
18810c0
Merge branch 'master' into expect-null
MichaelChirico Nov 16, 2021
29a9d4c
Merge branch 'master' into expect-null
MichaelChirico Mar 11, 2022
719e3c7
Merge branch 'expect-null' of github.com:r-lib/lintr into expect-null
MichaelChirico Mar 11, 2022
76f1e69
remove google_linters page
MichaelChirico Mar 11, 2022
6f81ae2
switch to standalone docs
MichaelChirico Mar 11, 2022
c03b606
tweak NEWS
MichaelChirico Mar 11, 2022
4f90533
markdown format code
MichaelChirico Mar 11, 2022
5fce64c
add doc page for new tag
MichaelChirico Mar 11, 2022
b736aab
Merge branch 'master' into expect-null
MichaelChirico Mar 11, 2022
0df3979
add package_development to backport_linter and package_hooks_linter
MichaelChirico Mar 12, 2022
c15b51d
Merge branch 'expect-null' of github.com:r-lib/lintr into expect-null
MichaelChirico Mar 12, 2022
1dbdbe8
refactor to use a literal xpath
MichaelChirico Mar 12, 2022
d102c7e
another false positive test case
MichaelChirico Mar 12, 2022
a01e708
also lint on expect_equal(NULL, x)
MichaelChirico Mar 12, 2022
c04d24d
customize the lint message to the actual usage
MichaelChirico Mar 12, 2022
8712d9f
qualify xml2 namespace
MichaelChirico Mar 12, 2022
86496ab
pick up glue dep
MichaelChirico Mar 12, 2022
797e26b
manually add glue to deps
MichaelChirico Mar 12, 2022
1c5a97d
extra blank line
MichaelChirico Mar 12, 2022
fb2e6ac
remove glue
MichaelChirico Mar 12, 2022
06a6291
glue from DESC
MichaelChirico Mar 12, 2022
a32b298
glue from ns
MichaelChirico Mar 12, 2022
1a270de
glue from roxy
MichaelChirico Mar 12, 2022
6b6df30
manually revert collate order
MichaelChirico Mar 12, 2022
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
3 changes: 3 additions & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Imports:
codetools,
cyclocomp,
digest,
glue,
jsonlite,
knitr,
stats,
Expand Down Expand Up @@ -62,6 +63,7 @@ Collate:
'equals_na_linter.R'
'exclude.R'
'expect_lint.R'
'expect_null_linter.R'
'extract.R'
'extraction_operator_linter.R'
'function_left_parentheses.R'
Expand Down Expand Up @@ -103,5 +105,6 @@ Collate:
'undesirable_operator_linter.R'
'unneeded_concatenation_linter.R'
'with_id.R'
'xp_utils.R'
'zzz.R'
Roxygen: list(markdown = TRUE)
2 changes: 2 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export(duplicate_argument_linter)
export(equals_na_linter)
export(expect_lint)
export(expect_lint_free)
export(expect_null_linter)
export(extraction_operator_linter)
export(function_left_parentheses_linter)
export(get_source_expressions)
Expand Down Expand Up @@ -71,6 +72,7 @@ export(with_defaults)
export(with_id)
import(rex)
importFrom(cyclocomp,cyclocomp)
importFrom(glue,glue)
importFrom(stats,na.omit)
importFrom(utils,capture.output)
importFrom(utils,getParseData)
Expand Down
2 changes: 2 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ function calls. (#850, #851, @renkun-ken)
* New function `available_linters()` to list available linters and their tags
This feature is extensible by package authors providing add-on linters for {lintr}.
* `lintr` now uses the 3rd edition of `testthat` (@MichaelChirico, #910)
* `lintr` is adopting a new set of linters provided as part of Google's extension to the tidyverse style guide (#884, @michaelchirico)
+ `expect_null_linter()` Require usage of `expect_null(x)` over `expect_equal(x, NULL)` and similar

# lintr 2.0.1

Expand Down
50 changes: 50 additions & 0 deletions R/expect_null_linter.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#' expect_null Linter
#'
#' Require usage of `expect_null(x)` over `expect_equal(x, NULL)` and similar
#' usages.
#'
#' [testthat::expect_null()] exists specifically for testing for `NULL` objects.
AshesITR marked this conversation as resolved.
Show resolved Hide resolved
#' [testthat::expect_equal()], [testthat::expect_identical()], and
#' [testthat::expect_true()] can also be used for such tests,
#' but it is better to use the tailored function instead.
#'
#' @evalRd rd_tags("expect_null_linter")
#' @seealso [linters] for a complete list of linters available in lintr.
#' @importFrom glue glue
#' @export
expect_null_linter <- function() {
AshesITR marked this conversation as resolved.
Show resolved Hide resolved
Linter(function(source_file) {
if (length(source_file$parsed_content) == 0L) {
return(list())
}

xml <- source_file$xml_parsed_content

# two cases two match:
# (1) expect_{equal,identical}(x, NULL)
# (2) expect_true(is.null(x))
xpath <- glue::glue("//expr[
(
SYMBOL_FUNCTION_CALL[ {xp_text_in_table(c('expect_equal', 'expect_identical'))} ]
and following-sibling::expr[position() <= 2 and NULL_CONST]
) or (
SYMBOL_FUNCTION_CALL[text() = 'expect_true']
and following-sibling::expr[1][expr[SYMBOL_FUNCTION_CALL[text() = 'is.null']]]
)
]")
MichaelChirico marked this conversation as resolved.
Show resolved Hide resolved

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

lapply(bad_expr, gen_expect_null_lint, source_file)
})
}

gen_expect_null_lint <- function(expr, source_file) {
matched_function <- xml2::xml_text(xml2::xml_find_first(expr, "SYMBOL_FUNCTION_CALL"))
if (matched_function %in% c("expect_equal", "expect_identical")) {
lint_msg <- sprintf("expect_null(x) is better than %s(x, NULL)", matched_function)
} else {
lint_msg <- "expect_null(x) is better than expect_true(is.null(x))"
}
xml_nodes_to_lint(expr, source_file, lint_msg, type = "warning")
}
8 changes: 8 additions & 0 deletions R/linter_tag_docs.R
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,11 @@ NULL
#' @evalRd rd_linters("configurable")
#' @seealso [linters] for a complete list of linters available in lintr.
NULL

#' Package development linters
#' @name package_development_linters
#' @description
#' Linters useful to package developers, for example for writing consistent tests.
#' @evalRd rd_linters("package_development")
#' @seealso [linters] for a complete list of linters available in lintr.
NULL
29 changes: 0 additions & 29 deletions R/utils.R
Original file line number Diff line number Diff line change
Expand Up @@ -199,35 +199,6 @@ unescape <- function(str, q="`") {
str
}

# like `text() %in% table`, translated to XPath 1.0
xp_text_in_table <- function(table) paste("text() = ", quote_wrap(table, "'"), collapse = " or ")

# convert an XML match into a Lint
xml_nodes_to_lint <- function(xml, source_file, message,
type = c("style", "warning", "error"),
offset = 0L, global = FALSE) {
type <- match.arg(type, c("style", "warning", "error"))
line1 <- xml2::xml_attr(xml, "line1")[1]
col1 <- as.integer(xml2::xml_attr(xml, "col1")) + offset

line_elt <- if (global) "file_lines" else "lines"

if (xml2::xml_attr(xml, "line2") == line1) {
col2 <- as.integer(xml2::xml_attr(xml, "col2")) + offset
} else {
col2 <- unname(nchar(source_file[[line_elt]][line1]))
}
return(Lint(
filename = source_file$filename,
line_number = as.integer(line1),
column_number = as.integer(col1),
type = type,
message = message,
line = source_file[[line_elt]][line1],
ranges = list(c(col1 - offset, col2))
))
}

# interface to work like options() or setwd() -- returns the old value for convenience
set_lang <- function(new_lang) {
old_lang <- Sys.getenv("LANGUAGE", unset = NA)
Expand Down
53 changes: 53 additions & 0 deletions R/xp_utils.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# utils for working with xpaths

# like `text() %in% table`, translated to XPath 1.0
xp_text_in_table <- function(table) paste("text() = ", quote_wrap(table, "'"), collapse = " or ")

# convert an XML match into a Lint
xml_nodes_to_lint <- function(xml, source_file, message,
type = c("style", "warning", "error"),
offset = 0L,
global = FALSE) {
type <- match.arg(type, c("style", "warning", "error"))
line1 <- xml2::xml_attr(xml, "line1")[1]
col1 <- as.integer(xml2::xml_attr(xml, "col1")) + offset

line_elt <- if (global) "file_lines" else "lines"

if (xml2::xml_attr(xml, "line2") == line1) {
col2 <- as.integer(xml2::xml_attr(xml, "col2")) + offset
} else {
col2 <- unname(nchar(source_file[[line_elt]][line1]))
}
return(Lint(
filename = source_file$filename,
line_number = as.integer(line1),
column_number = as.integer(col1),
type = type,
message = message,
line = source_file[[line_elt]][line1],
ranges = list(c(col1 - offset, col2))
))
}

#' Safer wrapper for paste(..., sep = " and ")
#'
#' The intent is to use this for readability when combining XPath conditions so
#' the `...` elements should be in that language, but this is not enforced.
#'
#' Inputs are also wrapped in `()` so as not to risk mixing operator precedence.
#'
#' @param ... Series of conditions
#' @noRd
xp_and <- function(...) sprintf("(%s)", paste(..., sep = ") and ("))

#' Safer wrapper for paste(..., sep = " or ")
#'
#' The intent is to use this for readability when combining XPath conditions so
#' the `...` elements should be in that language, but this is not enforced.
#'
#' Inputs are also wrapped in `()` so as not to risk mixing operator precedence.
#'
#' @param ... Series of conditions
#' @noRd
xp_or <- function(...) sprintf("(%s)", paste(..., sep = ") or ("))
5 changes: 3 additions & 2 deletions inst/lintr/linters.csv
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
linter,tags
absolute_path_linter,robustness best_practices configurable
assignment_linter,style consistency default
backport_linter,robustness configurable package_development
closed_curly_linter,style readability default configurable
commas_linter,style readability default
commented_code_linter,style readability best_practices default
cyclocomp_linter,style readability best_practices default configurable
equals_na_linter,robustness correctness common_mistakes default
expect_null_linter,package_development best_practices
MichaelChirico marked this conversation as resolved.
Show resolved Hide resolved
extraction_operator_linter,style best_practices
function_left_parentheses_linter,style readability default
implicit_integer_linter,style consistency best_practices
Expand All @@ -17,6 +19,7 @@ object_length_linter,style readability default configurable
object_name_linter,style consistency default configurable
object_usage_linter,style readability correctness default
open_curly_linter,style readability default configurable
package_hooks_linter,style correctness package_development
paren_brace_linter,style readability default
pipe_continuation_linter,style readability default
semicolon_terminator_linter,style readability default configurable
Expand All @@ -32,12 +35,10 @@ undesirable_function_linter,style efficiency configurable robustness best_practi
undesirable_operator_linter,style efficiency configurable robustness best_practices
unneeded_concatenation_linter,style readability efficiency
assignment_spaces_linter,style readability
backport_linter,robustness configurable
duplicate_argument_linter,correctness common_mistakes configurable
missing_argument_linter,correctness common_mistakes configurable
missing_package_linter,robustness common_mistakes
namespace_linter,correctness robustness configurable
package_hooks_linter,style correctness
paren_body_linter,style readability default
pipe_call_linter,style readability
sprintf_linter,correctness common_mistakes
2 changes: 1 addition & 1 deletion man/backport_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/best_practices_linters.Rd

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

24 changes: 24 additions & 0 deletions man/expect_null_linter.Rd

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

10 changes: 6 additions & 4 deletions man/linters.Rd

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

19 changes: 19 additions & 0 deletions man/package_development_linters.Rd

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

2 changes: 1 addition & 1 deletion man/package_hooks_linter.Rd

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

42 changes: 42 additions & 0 deletions tests/testthat/test-expect_null_linter.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
test_that("expect_null_linter skips allowed usages", {
# NB: this usage should be expect_false(), but this linter should
# nevertheless apply (e.g. for maintainers not using expect_not_linter)
expect_lint("expect_true(!is.null(x))", NULL, expect_null_linter())
# NB: also applies to tinytest, but it's sufficient to test testthat
expect_lint("testthat::expect_true(!is.null(x))", NULL, expect_null_linter())

# length-0 could be NULL, but could be integer() or list(), so let it pass
expect_lint("expect_length(x, 0L)", NULL, expect_null_linter())

# no false positive for is.null() at the wrong positional argument
expect_lint("expect_true(x, is.null(y))", NULL, expect_null_linter())
})

test_that("expect_null_linter blocks simple disallowed usages", {
expect_lint(
"expect_equal(x, NULL)",
MichaelChirico marked this conversation as resolved.
Show resolved Hide resolved
rex::rex("expect_null(x) is better than expect_equal(x, NULL)"),
expect_null_linter()
)

# expect_identical is treated the same as expect_equal
expect_lint(
"testthat::expect_identical(x, NULL)",
rex::rex("expect_null(x) is better than expect_identical(x, NULL)"),
expect_null_linter()
)

# reverse order lints the same
expect_lint(
"expect_equal(NULL, x)",
rex::rex("expect_null(x) is better than expect_equal(x, NULL)"),
expect_null_linter()
)

# different equivalent usage
expect_lint(
"expect_true(is.null(foo(x)))",
rex::rex("expect_null(x) is better than expect_true(is.null(x))"),
expect_null_linter()
)
})