Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## Breaking changes

- Boolean switch aliases now follow logical defaults in help output: `FALSE`
exposes the positive flag, `TRUE` exposes the generated `--no-*` flag, and
`NA` exposes both as a tri-state switch. Boolean switches also accept
explicit values such as `--foo=false`.
- Rapp now parses YAML with YAML 1.2 semantics. Bare `yes` and `no`
non-bool option values are strings, not boolean aliases. Declared
bool options still accept YAML 1.1 bool aliases such as `yes`, `no`,
Expand All @@ -21,6 +25,8 @@

## Bug fixes

- Boolean switches annotated with `#| negative_alias: false` no longer include
or accept a generated `--no-*` alias (#24).
- On macOS, `install_pkg_cli_apps()` now adds the default `~/.local/bin`
install directory to the user's zsh profile when it is not already
on `PATH`, respecting `ZDOTDIR` and warning rather than failing if
Expand Down
4 changes: 2 additions & 2 deletions R/app.R
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ get_app_inputs <- function(app, exprs = app$exprs, pos = integer()) {
e <- exprs[[i]]

# foo <- NULL default positional arg `APP <FOO>`
# foo <- <TRUE|FALSE> default switch `APP --foo` or `APP --no-foo`
# foo <- <TRUE|FALSE> default switch, with aliases from default
# foo <- <string|float|int literal> default opt `APP --foo val`
# foo <- <c()|list()> default opt with action: append `APP --foo val1 --foo val2`
#
Expand Down Expand Up @@ -228,7 +228,7 @@ get_app_inputs <- function(app, exprs = app$exprs, pos = integer()) {
"NULL" = "string"
),

arg_type = if (identical(default, TRUE) || identical(default, FALSE)) {
arg_type = if (is_bool_switch_default(default)) {
"switch"
} else if (is.null(default)) {
"positional"
Expand Down
99 changes: 93 additions & 6 deletions R/args.R
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,30 @@ process_args <- function(args, app) {
on.exit(close(args))
}

stop_unrecognized_arg <- function(arg) {
stop("Arguments not recognized: ", arg, call. = FALSE)
}
is_bool_option_spec <- function(spec) {
!is.null(spec) &&
identical(spec$arg_type, "option") &&
identical(spec$val_type, "bool")
}

short_opt_to_long_opt <- function(short_opt) {
short <- str_drop_prefix(short_opt, "-")
for (i in seq_along(app_opts)) {
if (identical(short, app_opts[[i]][["short"]])) {
return(paste0("--", names(app_opts)[[i]]))
if (identical(app_opts[[i]][["arg_type"]], "switch")) {
if (shows_positive_alias(app_opts[[i]])) {
return(paste0("--", names(app_opts)[[i]]))
}
if (shows_negative_alias(app_opts[[i]])) {
return(paste0("--no-", names(app_opts)[[i]]))
}
return(paste0("--", names(app_opts)[[i]]))
} else {
return(paste0("--", names(app_opts)[[i]]))
}
}
}
}
Expand Down Expand Up @@ -73,6 +92,7 @@ process_args <- function(args, app) {

# resolve these values in this block
name <- val <- spec <- NULL
positive_switch <- FALSE

# --name=val
equals_idx <- regexpr("=", a)
Expand All @@ -81,20 +101,63 @@ process_args <- function(args, app) {
name <- gsub("-", "_", name, fixed = TRUE)
val <- str_drop_prefix(a, equals_idx)
spec <- app_opts[[name]]
if (
!is.null(spec) &&
identical(spec$arg_type, "switch") &&
!accepts_positive_alias(spec)
) {
stop_unrecognized_arg(a)
}
if (!is.null(spec) && identical(spec$arg_type, "switch")) {
positive_switch <- TRUE
}
if (is.null(spec) && startsWith(a, "--no-")) {
alt_name <- str_drop_prefix(name, "no_")
alt_spec <- app_opts[[alt_name]]
if (
!is.null(alt_spec) &&
(
identical(alt_spec$arg_type, "switch") ||
is_bool_option_spec(alt_spec)
)
) {
stop_unrecognized_arg(a)
}
}
} else {
# --name
name <- str_drop_prefix(a, "--")
name <- gsub("-", "_", name, fixed = TRUE)

spec <- app_opts[[name]]
if (
!is.null(spec) &&
identical(spec$arg_type, "switch") &&
!accepts_positive_alias(spec)
) {
stop_unrecognized_arg(a)
}
if (!is.null(spec) && identical(spec$arg_type, "switch")) {
positive_switch <- TRUE
}

# if flag not known, maybe this is a switch flag
if (is.null(spec) && startsWith(a, "--no-")) {
alt_name <- str_drop_prefix(name, "no_")
spec <- app_opts[[alt_name]]
if (!is.null(spec)) {
val <- "false"
name <- alt_name
alt_spec <- app_opts[[alt_name]]
if (
!is.null(alt_spec) &&
identical(alt_spec$arg_type, "switch")
Comment thread
t-kalinowski marked this conversation as resolved.
) {
if (accepts_negative_alias(alt_spec)) {
spec <- alt_spec
val <- "false"
name <- alt_name
} else {
stop_unrecognized_arg(a)
}
} else if (is_bool_option_spec(alt_spec)) {
stop_unrecognized_arg(a)
}
}
}
Expand All @@ -108,10 +171,28 @@ process_args <- function(args, app) {

if (is.null(val)) {
if (identical(spec$arg_type, "switch")) {
val <- "true"
if (positive_switch) {
next_arg <- readLines(args, 1L)
if (length(next_arg)) {
if (is_bool_cli_value(next_arg)) {
val <- next_arg
} else {
pushBack(next_arg, args)
}
}
}
if (is.null(val)) {
val <- "true"
}
} else {
# arg_type == "option"
val <- readLines(args, 1L)
if (!length(val)) {
stop(
sprintf("Missing value for --%s.", to_kebab_case(name)),
call. = FALSE
)
}
}
}

Expand Down Expand Up @@ -282,5 +363,11 @@ normalize_bool_cli_value <- function(val) {
)
}

is_bool_cli_value <- function(val) {
is.character(val) &&
length(val) == 1L &&
normalize_bool_cli_value(val) %in% c("true", "false")
}

to_snake_case <- function(x) gsub("-", "_", x, fixed = TRUE)
to_kebab_case <- function(x) gsub("_", "-", x, fixed = TRUE)
30 changes: 19 additions & 11 deletions R/help.R
Original file line number Diff line number Diff line change
Expand Up @@ -191,18 +191,26 @@ print_app_help <- function(app, yaml = TRUE, command_path = character()) {
details <- c(details, sprintf("[type: %s]", opt$val_type))
}
} else if (identical(opt$arg_type, "switch")) {
default_value <- format_default_value(opt$default)
toggle_flag <- paste0("--no-", cli_name)
toggle_note <- if (isTRUE(opt$default)) {
sprintf("Disable with `%s`.", toggle_flag)
positive_flag <- paste0("--", cli_name)
negative_flag <- paste0("--no-", cli_name)
show_positive <- shows_positive_alias(opt)
show_negative <- shows_negative_alias(opt)
if (!show_positive && !show_negative) {
flag <- paste(positive_flag, format_placeholder(name))
if (!is.null(short_flag) && nzchar(short_flag)) {
flag <- paste0("-", short_flag, ", ", flag)
}
details <- c(details, "[type: bool]")
} else {
sprintf("Enable with `%s`.", paste0("--", cli_name))
}
if (!is.null(default_value)) {
details <- c(details, sprintf("[default: %s]", default_value))
flags <- c(
if (show_positive) positive_flag,
if (show_negative) negative_flag
)
flag <- paste(flags, collapse = " / ")
if (!is.null(short_flag) && nzchar(short_flag)) {
flag <- paste0("-", short_flag, ", ", flag)
}
}
details <- c(details, toggle_note)
flag <- paste(flag, "/", toggle_flag)
}

if (identical(opt$action, "append")) {
Expand Down Expand Up @@ -253,7 +261,7 @@ print_app_help <- function(app, yaml = TRUE, command_path = character()) {
ctx <- label_context(entry$label, indent, label_width)
text <- entry$text
if (!length(text)) {
out <- c(out, ctx$initial)
out <- c(out, paste0(strrep(" ", indent), entry$label))
next
}
for (i in seq_along(text)) {
Expand Down
53 changes: 53 additions & 0 deletions R/utils.R
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,59 @@ compact <- function(x) x[lengths(x) > 0]
`%||%` <- function(x, y) if (is.null(x)) y else x
`subtract<-` <- function(x, value) x - value

is_bool_switch_default <- function(x) is.logical(x) && length(x) == 1L

check_bool_switch_default <- function(default) {
if (!is_bool_switch_default(default)) {
stop(
"Boolean switches must have a TRUE, FALSE, or NA default.",
call. = FALSE
)
}
}

# Parsing is intentionally more permissive than help. accepts_* controls
# command-line forms; shows_* controls the concise help surface.
accepts_positive_alias <- function(opt) {
stopifnot(identical(opt[["arg_type"]], "switch"))

default <- opt[["default"]]
check_bool_switch_default(default)

TRUE
}

accepts_negative_alias <- function(opt) {
stopifnot(identical(opt[["arg_type"]], "switch"))

default <- opt[["default"]]
check_bool_switch_default(default)

if (!is.null(opt[["negative_alias"]])) {
return(!isFALSE(opt[["negative_alias"]]))
}

TRUE
}

shows_positive_alias <- function(opt) {
stopifnot(identical(opt[["arg_type"]], "switch"))

default <- opt[["default"]]
check_bool_switch_default(default)

isFALSE(default) || isTRUE(is.na(default))
}

shows_negative_alias <- function(opt) {
stopifnot(identical(opt[["arg_type"]], "switch"))

default <- opt[["default"]]
check_bool_switch_default(default)

accepts_negative_alias(opt) && (isTRUE(default) || isTRUE(is.na(default)))
}

`append<-` <- function(x, after = NULL, value) {
if (is.null(after)) c(x, value) else append(x, value, after)
}
Expand Down
39 changes: 28 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,31 +113,44 @@ flip-coin --n=1
flip-coin --n 1
```

Bool options, (that is, assignments of `TRUE` or `FALSE` in an R app)
are a little different. They support usage as switches or toggles at the
command line. For example in an R script:
Assignments of `TRUE`, `FALSE`, or `NA` are a little different from other
options. They support usage as switches or toggles at the command line,
and the default controls which aliases are exposed. For example in an R
script:

``` r
echo <- TRUE
```

means that at the command line the following are supported:
means that at the command line the negative alias is supported:

``` r
my-app --echo # TRUE
my-app --echo=true # TRUE
my-app --echo=1 # TRUE

my-app --no-echo # FALSE
my-app --echo=false # FALSE
my-app --echo=0 # FALSE
```

With `echo <- FALSE`, the positive alias `--echo` is supported.
Assignments of `NA` are tri-state boolean switches: omitting the flag
leaves the value as `NA`, `--echo` sets it to `TRUE`, and `--no-echo`
sets it to `FALSE`. Boolean switches also accept explicit values, such
as `--echo=true`, `--echo=false`, `--echo true`, and `--echo false`.

To omit the generated `--no-*` alias for a boolean switch, add
`#| negative_alias: false` above the assignment:

``` r
#| description: Print version and exit.
#| negative_alias: false
version <- FALSE
```

Rapp parses option values as YAML 1.2, where bare `yes` and `no` are
strings rather than boolean aliases for non-bool values. For declared
bool options, Rapp also accepts YAML 1.1 bool aliases such as `yes`,
`no`, `y`, `n`, `on`, and `off` for backward compatibility.

See [Boolean option behavior](docs/boolean-options.md) for a full table
of boolean defaults, annotations, and accepted command-line forms.

Assigning `c()` or `list()` declares an option that can be supplied
multiple times. Use `c()` when you want to keep the exact strings
provided on the command line, and `list()` when you want Rapp to attempt
Expand Down Expand Up @@ -348,6 +361,8 @@ Other YAML fields you can supply to change the behavior of Rapp
- `arg_type`: how the input appears on the CLI (`option`, `switch`, `positional`).
- `action`: whether values replace or accumulate (`replace` vs `append` for
repeatable options and collectors).
- `negative_alias`: override whether boolean switches include a generated
`--no-*` alias.
- `examples`: usage examples to show in `--help`. Use a YAML sequence to list
multiple examples.

Expand All @@ -360,7 +375,9 @@ command line arguments.
|----|----|
| Assignment of scalar literal<br>`foo <- ""` | Option<br>`APP --foo value` |
| Assignment of `NULL`<br>`foo <- NULL` | Positional Arg<br>`APP foo-value` |
| Assignment of `TRUE` or `FALSE`<br>`foo <- TRUE` | Boolean switch<br>`APP --foo` or `APP --no-foo` |
| Assignment of `FALSE`<br>`foo <- FALSE` | Boolean switch<br>`APP --foo` |
| Assignment of `TRUE`<br>`foo <- TRUE` | Boolean switch<br>`APP --no-foo` |
| Assignment of `NA`<br>`foo <- NA` | Tri-state boolean switch<br>`APP --foo` or `APP --no-foo` |
| Assignment of `c()` or `list()`<br>`foo <- c()` | Repeatable option<br>`APP --foo val1 --foo val2` |
| Assignment of `NULL` to name with `...`<br>`args... <- NULL` | Positional Arg Collector<br>`APP foo bar baz` |
| Switch with string literal<br>`switch("", cmd1 = {}, cmd2 = {})` | Required commands<br>`APP --help`<br>`APP cmd1 --help`<br>`APP cmd2 --help` |
Expand Down
Loading
Loading