diff --git a/NAMESPACE b/NAMESPACE index 8f8c406..59645e1 100644 --- a/NAMESPACE +++ b/NAMESPACE @@ -31,6 +31,7 @@ export(assert_file_has_extension) export(assert_finite) export(assert_flag) export(assert_function) +export(assert_function_expects) export(assert_function_expects_n_arguments) export(assert_greater_than) export(assert_greater_than_or_equal_to) diff --git a/NEWS.md b/NEWS.md index da0a03c..3761704 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,7 @@ # assertions 0.2.0 +* Added `assert_function_expects()` for checking required argument names in functions + * Added `assert_all_strings_contain()` and `assert_string_contains()` for checking character inputs against regular expressions * Added `assert_packages_installed()` diff --git a/R/assert_functions.R b/R/assert_functions.R index 6ae3df0..118b8c1 100644 --- a/R/assert_functions.R +++ b/R/assert_functions.R @@ -25,6 +25,33 @@ function_expects_n_arguments_advanced <- function(x, n, dots = c("throw_error"," } +# Expect function has arguments +# @param +# @param required the names of parameters the function must support (character) +# +# @returns TRUE if the function contains the expected parameters, otherwise returns a string (the error message) +# +function_expects_advanced <- function(x, required){ + if(!is.function(x)) { + value_class <- toString(class(x)) + return(paste0("{.strong '{arg_name}'} must be a function, not a ", value_class)) + } + + declared_args <- setdiff(func_arg_names(x), "...") + if(!is_subset(required, declared_args)){ + missing_args <- setopts_exlusive_to_first(required, declared_args) + missing_count <- length(missing_args) + missing_args <- paste0("`", paste(missing_args, collapse = "`, `"), "`") + return(paste0("Function '{arg_name}' is missing the following parameter", + if(missing_count == 1) "" else "s", + " in its signature: {.strong ", missing_args, "}" + )) + } + + return(TRUE) +} + + # Assertions -------------------------------------------------------------- #' Assert function expects n arguments @@ -42,3 +69,28 @@ function_expects_n_arguments_advanced <- function(x, n, dots = c("throw_error"," #' #' @export assert_function_expects_n_arguments <- assert_create(func = function_expects_n_arguments_advanced) + +#' Assert function expects specific parameter names +#' +#' Assert that a function signature includes required set of parameter names in its +#' formal argument list, regardless of whether those parameters have default +#' values. The `...` argument is ignored. +#' +#' @param x a function to check for required parameter names +#' @param required a character vector of parameter names that must appear in +#' the function signature (order does not matter) +#' @inheritParams common_roxygen_params +#' +#' @return invisible(TRUE) if function `x` declares all required parameters, +#' otherwise aborts with the error message specified by `msg` +#' +#' @examples +#' my_fun <- function(x, y = 1, ...) x + y +#' assert_function_expects(my_fun, c("x", "y")) +#' +#' try({ +#' assert_function_expects(my_fun, c("x", "z")) +#' }) +#' +#' @export +assert_function_expects <- assert_create(func = function_expects_advanced) diff --git a/R/utils.R b/R/utils.R index 40af336..41f84e5 100644 --- a/R/utils.R +++ b/R/utils.R @@ -68,6 +68,16 @@ func_supports_variable_arguments <- function(func){ func_args_as_pairlist <- function(func){ formals(args(func)) } + +func_required_arg_names <- function(func){ + args <- formals(args(func)) + if(length(args) == 0){ + return(character(0)) + } + required_args <- args[vapply(args, is.symbol, FUN.VALUE = TRUE)] + required_args <- names(required_args) + setdiff(required_args, "...") +} # # func_args_as_alist <- function(func){ # a= unlist(func_args_as_pairlist(func)) diff --git a/man/assert_function_expects.Rd b/man/assert_function_expects.Rd new file mode 100644 index 0000000..bdb71bd --- /dev/null +++ b/man/assert_function_expects.Rd @@ -0,0 +1,43 @@ +% Generated by roxygen2: do not edit by hand +% Please edit documentation in R/assert_functions.R +\name{assert_function_expects} +\alias{assert_function_expects} +\title{Assert function expects specific parameter names} +\usage{ +assert_function_expects( + x, + required, + msg = NULL, + call = rlang::caller_env(), + arg_name = NULL +) +} +\arguments{ +\item{x}{a function to check for required parameter names} + +\item{required}{a character vector of parameter names that must appear in +the function signature (order does not matter)} + +\item{msg}{The error message thrown if the assertion fails (string)} + +\item{call}{Only relevant when pooling assertions into multi-assertion helper functions. See \link[cli]{cli_abort} for details.} + +\item{arg_name}{Advanced use only. Name of the argument passed (default: NULL, will automatically extract arg_name).} +} +\value{ +invisible(TRUE) if function \code{x} declares all required parameters, +otherwise aborts with the error message specified by \code{msg} +} +\description{ +Assert that a function signature includes required set of parameter names in its +formal argument list, regardless of whether those parameters have default +values. The \code{...} argument is ignored. +} +\examples{ +my_fun <- function(x, y = 1, ...) x + y +assert_function_expects(my_fun, c("x", "y")) + +try({ + assert_function_expects(my_fun, c("x", "z")) +}) +} diff --git a/tests/testthat/test-assert_functions.R b/tests/testthat/test-assert_functions.R index 3f91c31..ab845c6 100644 --- a/tests/testthat/test-assert_functions.R +++ b/tests/testthat/test-assert_functions.R @@ -6,6 +6,9 @@ fn_1_arg <- function(a) {} fn_2_args <- function(a, b) {} fn_1_arg_with_dots <- function(a, ...) {} fn_2_args_with_dots <- function(a, b, ...) {} +fn_with_required_args <- function(x, y, z = 1, ...) {} +fn_with_defaults <- function(x = 1, y = 2) {} +fn_with_no_required <- function(...) {} # Unit tests for `function_expects_n_arguments_advanced` test_that("function_expects_n_arguments_advanced behaves correctly for exact argument count", { @@ -69,6 +72,21 @@ test_that("function_expects_n_arguments_advanced handles `dots` parameter correc expect_true(function_expects_n_arguments_advanced(fn_2_args_with_dots, Inf, dots = "count_as_inf")) }) +test_that("function_expects_advanced validates required argument names", { + expect_true(function_expects_advanced(fn_2_args, c("a", "b"))) + expect_true(function_expects_advanced(fn_with_required_args, c("x", "y"))) + + expect_match(function_expects_advanced(fn_2_args, "c"), + "missing the following parameter", fixed = TRUE) + expect_match(function_expects_advanced(fn_2_args, c("b", "c")), + "missing the following parameter", fixed = TRUE) + expect_match(function_expects_advanced(fn_with_no_required, "x"), + "missing the following parameter", fixed = TRUE) + expect_true(function_expects_advanced(fn_with_defaults, "x")) + expect_match(function_expects_advanced(1, "x"), + "must be a function, not a", fixed = TRUE) +}) + @@ -105,3 +123,53 @@ cli::test_that_cli("assert_function_expects_n_arguments() works", config = "plai # Custom error messages work expect_error(assert_function_expects_n_arguments(my_func, 3, msg = "Custom error message"), "Custom error message") }) + +cli::test_that_cli("assert_function_expects() works", config = "plain", { + + # Function with required args and one defaulted argument + my_func <- function(a, b, c = 1) { a + b + c } + + # Function that accepts additional arguments via ... + my_func_dots <- function(a, b, ...) { a + b } + + # Succeeds when required parameters are present (ignores defaults) + expect_true(assert_function_expects(my_func, c("a", "b"))) + + # Succeeds when required parameters are present and ... is ignored + expect_true(assert_function_expects(my_func_dots, c("a", "b"))) + + # Succeeds when checking for a parameter with a default value + expect_true(assert_function_expects(my_func, c("c"))) + + # Function missing one of the required parameters + my_func2 <- function(a, c) { a + c } + + # Errors when a required parameter is absent from the signature + expect_error( + assert_function_expects(my_func2, c("a", "b")), + "Function 'my_func2' is missing the following parameter in its signature: `b`" + ) + + # Errors when input is not a function + expect_error( + assert_function_expects(123, "a"), + "'123' must be a function, not a numeric" + ) + + # Errors when function has no matching required parameters + expect_error( + assert_function_expects(fn_with_no_required, "a"), + "Function 'fn_with_no_required' is missing the following parameter in its signature: `a`" + ) + + # Succeeds when required parameter exists even if it has a default + expect_true(assert_function_expects(fn_with_defaults, "x")) + + # Uses custom error message when provided + expect_error( + assert_function_expects(my_func, c("a", "d"), msg = "Custom error message"), + "Custom error message" + ) + +}) +