R: Match parameters to expression tree from parse(text = )

Viewed 79

I am reading in some code and need to determine the parameter names of the arguments that were passed. If the person named the parameters, I can find them with x[[param_name]]. When it isn't explicit, how do I find them?

In this example, I am trying to find the "from" parameter. I can see that it is index #4 but it could be in position #2 or elsewhere and any or all of them could be unnamed.

Thank you for your time.

x <- parse(text = "seq.int(to = 10, by = 2, 0)")

code_as_call <- as.call(x)[[1]]

View(code_as_call)

param_to <- code_as_call[["to"]]
param_by <- code_as_call[["by"]]
param_from <- "???" # how do I find this?

enter image description here

Update:

With help from @MichaelChirico, this is the solution I came up with. I had to consider partial matching as an option.

arg_names <- function(x) {
  x_fn <- x[[1]][[3]]

  default_args <- c("", formalArgs(args(eval(x_fn[[1]]))))
  missing_names <- is.null(names(x_fn))

  seq_args <- seq_along(x_fn)
  #skip_last <- head(seq_args, -1)
  
  
  if (missing_names) {
    # assign names after first position
    names(x_fn) <- default_args[seq_args]
  } else {
    
    orig_args <- names(x_fn)
    has_name <- nchar(orig_args) > 0
    
    # line up args including partial matches
    explicit_args <- pmatch(orig_args[has_name], default_args)
    # update names
    names(x_fn)[which(has_name)] <- default_args[explicit_args]
    updated_args <- names(x_fn)
    
    # missing args
    avail_args <- setdiff(default_args, updated_args[has_name])
    missing_name <- which(!has_name)
    implicit_args <- avail_args[seq_along(missing_name)]
    # update names
    names(x_fn)[missing_name] <- implicit_args
  }
  
  names(x_fn)
}

arg_names(expression(new_string <- gsub(' ', '_', 'a b c')))
#> [1] ""            "pattern"     "replacement" "x"
arg_names(expression(new_string <- gsub(x = 'a b c', ' ', '_')))
#> [1] ""            "x"           "pattern"     "replacement"
arg_names(expression(new_string <- gsub(x = 'a b c', pat = ' ', rep = '_')))
#> [1] ""            "x"           "pattern"     "replacement"

Created on 2020-07-26 by the reprex package (v0.3.0)

2 Answers

This is not a fully general solution but hopefully illustrates all the necessary base helper metaprogramming functions you'll need to get there.

We first have to get the names of the available arguments:

xi = x[[1L]]

all_args = setdiff(formalArgs(args(eval(xi[[1L]]))), '...')
all_args
# [1] "from"       "to"         "by"         "length.out" "along.with"

NB: often, we can just use formalArgs directly, but as seq.int is a Primitive function (is.primitive(seq.int)), formals returns NULL -- see ?formals which recommends formals(args(.)) in this case.

Then, we have to see what was used in the actual call:

used_args = names(xi[-1L])
# [1] "to" "by" ""  

Then, we take arguments in order from all_args among those that aren't named:

avail_args = setdiff(all_args, used_args[has_name])
implicit_args = avail_args[seq_along(used_args[!has_name])]
implicit_args
# [1] from

This is basically what match.call() does:

match.call returns a call in which all of the specified arguments are specified by their full names.

All of the following are equivalent ways to use it:

match.call( gsub, call("gsub", ' ', '_', 'a b c') )     
# gsub(pattern = " ", replacement = "_", x = "a b c")

code <- expression(gsub(x = 'a b c', pat = ' ', rep = '_'))
match.call( gsub, code )
# gsub(pattern = " ", replacement = "_", x = "a b c")

code <- quote(gsub(x = 'a b c', pat = ' ', rep = '_'))
match.call( gsub, as.call(code) )
# gsub(pattern = " ", replacement = "_", x = "a b c")

The function doesn't support primitives like seq.int, but you can trick it by wrapping the function name in args(). This effectively constructs a new function that has exactly the same signature.

match.call( seq.int, code_as_call )                   # Fails on primitives
# Error in match.call(seq.int, code_as_call) : 
#   invalid 'definition' argument

match.call( args("seq.int"), code_as_call )           # Use the args() trick
# seq.int(from = 0, to = 10, by = 2)
Related