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_type_linter #924

Merged
merged 20 commits into from
Mar 15, 2022
Merged
Show file tree
Hide file tree
Changes from 4 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
2 changes: 2 additions & 0 deletions DESCRIPTION
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Suggests:
covr,
httr (>= 1.2.1),
mockery,
patrick,
rmarkdown,
rstudioapi (>= 0.2),
testthat (>= 3.0.0),
Expand Down Expand Up @@ -63,6 +64,7 @@ Collate:
'exclude.R'
'expect_lint.R'
'expect_null_linter.R'
'expect_type_linter.R'
'extract.R'
'extraction_operator_linter.R'
'function_left_parentheses.R'
Expand Down
2 changes: 2 additions & 0 deletions NAMESPACE
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export(equals_na_linter)
export(expect_lint)
export(expect_lint_free)
export(expect_null_linter)
export(expect_type_linter)
export(extraction_operator_linter)
export(function_left_parentheses_linter)
export(get_source_expressions)
Expand Down Expand Up @@ -72,6 +73,7 @@ export(with_defaults)
export(with_id)
import(rex)
importFrom(cyclocomp,cyclocomp)
importFrom(glue,glue)
MichaelChirico marked this conversation as resolved.
Show resolved Hide resolved
importFrom(stats,na.omit)
importFrom(utils,capture.output)
importFrom(utils,getParseData)
Expand Down
59 changes: 59 additions & 0 deletions R/expect_type_linter.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
#' Require usage of expect_type(x, type) over expect_equal(typeof(x), type)
#'
#' [testthat::expect_type()] exists specifically for testing the storage type
#' of objects. [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_type_linter")
#' @seealso [linters] for a complete list of linters available in lintr.
#' @export
#' @importFrom glue glue
AshesITR marked this conversation as resolved.
Show resolved Hide resolved
expect_type_linter <- function() {
Linter(function(source_file) {
if (length(source_file$parsed_content) == 0L) {
return(list())
}

xml <- source_file$xml_parsed_content

base_type_tests <- xp_text_in_table(paste0("is.", base_types))
MichaelChirico marked this conversation as resolved.
Show resolved Hide resolved
xpath <- glue::glue("//expr[
(
SYMBOL_FUNCTION_CALL[text() = 'expect_equal' or text() = 'expect_identical']
and following-sibling::expr[1][expr[SYMBOL_FUNCTION_CALL[text() = 'typeof']]]
AshesITR marked this conversation as resolved.
Show resolved Hide resolved
) or (
SYMBOL_FUNCTION_CALL[text() = 'expect_true']
and following-sibling::expr[1][expr[SYMBOL_FUNCTION_CALL[ {base_type_tests} ]]]
MichaelChirico marked this conversation as resolved.
Show resolved Hide resolved
)
]")

bad_expr <- xml2::xml_find_all(xml, xpath)
return(lapply(
bad_expr,
xml_nodes_to_lint,
source_file = source_file,
message = paste(
"expect_type(x, t) is better than expect_equal(typeof(x), t),",
"expect_identical(typeof(x), t), or expect_true(is.<t>(x)).",
'Note that typeof(1) is "double" and typeof(function(x) x) is "closure".'
),
type = "warning"
))
})
}

# NB: the full list of values that can arise from `typeof(x)` is available
# in ?typeof (or, slightly more robustly, in the R source: src/main/util.c.
# Not all of them are available in is.<type> form, e.g. 'any' or
# 'special'. 'builtin' and 'closure' are special cases, corresponding to
# is.primitive and is.function (essentially).
base_types <- c(
"raw", "logical", "integer", "double", "complex", "character", "list",
"numeric", "function", "primitive", "environment", "pairlist", "promise",
# Per ?is.language, it's the same as is.call || is.name || is.expression.
# so by blocking it, we're forcing more precise tests of one of
# those directly ("language", "symbol", and "expression", resp.)
# NB: is.name and is.symbol are identical.
"language", "call", "name", "symbol", "expression"
)
17 changes: 9 additions & 8 deletions inst/lintr/linters.csv
Original file line number Diff line number Diff line change
@@ -1,44 +1,45 @@
linter,tags
absolute_path_linter,robustness best_practices configurable
assignment_linter,style consistency default
assignment_spaces_linter,style readability
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
duplicate_argument_linter,correctness common_mistakes configurable
equals_na_linter,robustness correctness common_mistakes default
expect_null_linter,package_development best_practices
expect_type_linter,package_development best_practices
extraction_operator_linter,style best_practices
function_left_parentheses_linter,style readability default
implicit_integer_linter,style consistency best_practices
infix_spaces_linter,style readability default
line_length_linter,style readability default configurable
missing_argument_linter,correctness common_mistakes configurable
missing_package_linter,robustness common_mistakes
namespace_linter,correctness robustness configurable
no_tab_linter,style consistency default
nonportable_path_linter,robustness best_practices configurable
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_body_linter,style readability default
paren_brace_linter,style readability default
pipe_call_linter,style readability
pipe_continuation_linter,style readability default
semicolon_terminator_linter,style readability default configurable
seq_linter,robustness efficiency consistency best_practices default
single_quotes_linter,style consistency readability default
spaces_inside_linter,style readability default
spaces_left_parentheses_linter,style readability default
sprintf_linter,correctness common_mistakes
T_and_F_symbol_linter,style readability robustness consistency best_practices default
todo_comment_linter,style configurable
trailing_blank_lines_linter,style default
trailing_whitespace_linter,style default
undesirable_function_linter,style efficiency configurable robustness best_practices
undesirable_operator_linter,style efficiency configurable robustness best_practices
unneeded_concatenation_linter,style readability efficiency
assignment_spaces_linter,style readability
MichaelChirico marked this conversation as resolved.
Show resolved Hide resolved
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
paren_body_linter,style readability default
pipe_call_linter,style readability
sprintf_linter,correctness common_mistakes
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.

20 changes: 20 additions & 0 deletions man/expect_type_linter.Rd

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

7 changes: 4 additions & 3 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/package_development_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 tests/testthat.R
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
library(testthat)
library(patrick)
MichaelChirico marked this conversation as resolved.
Show resolved Hide resolved
library(lintr)

test_check("lintr")
54 changes: 54 additions & 0 deletions tests/testthat/test-expect_type_linter.R
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
test_that("expect_type_linter skips allowed usages", {
# expect_type doesn't have an inverted version
expect_lint("expect_true(!is.numeric(x))", NULL, expect_type_linter())
# NB: also applies to tinytest, but it's sufficient to test testthat
expect_lint("testthat::expect_true(!is.numeric(x))", NULL, expect_type_linter)

# other is.<x> calls are not suitable for expect_type in particular
expect_lint("expect_true(is.data.frame(x))", NULL, expect_type_linter())

# expect_type(x, ...) cannot be cleanly used here:
expect_lint("expect_true(typeof(x) %in% c('builtin', 'closure'))", NULL, expect_type_linter)
})

test_that("expect_type_linter blocks simple disallowed usages", {
expect_lint(
"expect_equal(typeof(x), 'double')",
rex::rex("expect_type(x, t) is better than expect_equal(typeof(x), t)"),
expect_type_linter
)

# expect_identical is treated the same as expect_equal
expect_lint(
"testthat::expect_identical(typeof(x), 'language')",
rex::rex("expect_type(x, t) is better than expect_equal(typeof(x), t)"),
expect_type_linter
)

# different equivalent usage
expect_lint(
"expect_true(is.complex(foo(x)))",
rex::rex("expect_type(x, t) is better than expect_equal(typeof(x), t)"),
expect_type_linter
)
})


local({
# test for lint errors appropriately raised for all is.<type> calls
is_types <- c(
"raw", "logical", "integer", "double", "complex", "character", "list",
"numeric", "function", "primitive", "environment", "pairlist", "promise",
"language", "call", "name", "symbol", "expression"
)
patrick::with_parameters_test_that(
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We use patrick to handle parameterized tests. This particular case could be refactored into a for loop if we'd like to avoid the dep, but there will be other cases where it provides a cleaner solution.

"expect_type_linter catches expect_true(is.<base type>)",
expect_lint(
sprintf("expect_true(is.%s(x))", is_type),
rex::rex("expect_type(x, t) is better than expect_equal(typeof(x), t)"),
expect_type_linter
),
.test_name = is_types,
is_type = is_types
)
})