Skip to content

Commit 2d00c46

Browse files
committed
Relax boolean switch parsing
1 parent cbc5784 commit 2d00c46

8 files changed

Lines changed: 209 additions & 76 deletions

File tree

NEWS.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22

33
## Breaking changes
44

5-
- Boolean switch aliases now follow logical defaults: `FALSE` exposes the
6-
positive flag and `TRUE` exposes the generated `--no-*` flag. `NA` remains
7-
a value-taking boolean option, so it can still indicate that an option was
8-
not supplied.
5+
- Boolean switch aliases now follow logical defaults in help output: `FALSE`
6+
exposes the positive flag, `TRUE` exposes the generated `--no-*` flag, and
7+
`NA` exposes both as a tri-state switch. Boolean switches also accept
8+
explicit values such as `--foo=false`.
99
- Rapp now parses YAML with YAML 1.2 semantics. Bare `yes` and `no`
1010
non-bool option values are strings, not boolean aliases. Declared
1111
bool options still accept YAML 1.1 bool aliases such as `yes`, `no`,

R/app.R

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ get_app_inputs <- function(app, exprs = app$exprs, pos = integer()) {
228228
"NULL" = "string"
229229
),
230230

231-
arg_type = if (is_auto_bool_switch_default(default)) {
231+
arg_type = if (is_bool_switch_default(default)) {
232232
"switch"
233233
} else if (is.null(default)) {
234234
"positional"

R/args.R

Lines changed: 36 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@ process_args <- function(args, app) {
1919
for (i in seq_along(app_opts)) {
2020
if (identical(short, app_opts[[i]][["short"]])) {
2121
if (identical(app_opts[[i]][["arg_type"]], "switch")) {
22-
if (has_positive_alias(app_opts[[i]])) {
22+
if (show_positive_alias(app_opts[[i]])) {
2323
return(paste0("--", names(app_opts)[[i]]))
2424
}
25-
if (has_negative_alias(app_opts[[i]])) {
25+
if (show_negative_alias(app_opts[[i]])) {
2626
return(paste0("--no-", names(app_opts)[[i]]))
2727
}
28+
return(paste0("--", names(app_opts)[[i]]))
2829
} else {
2930
return(paste0("--", names(app_opts)[[i]]))
3031
}
@@ -86,6 +87,7 @@ process_args <- function(args, app) {
8687

8788
# resolve these values in this block
8889
name <- val <- spec <- NULL
90+
positive_switch <- FALSE
8991

9092
# --name=val
9193
equals_idx <- regexpr("=", a)
@@ -101,13 +103,15 @@ process_args <- function(args, app) {
101103
) {
102104
stop_unrecognized_arg(a)
103105
}
106+
if (!is.null(spec) && identical(spec$arg_type, "switch")) {
107+
positive_switch <- TRUE
108+
}
104109
if (is.null(spec) && startsWith(a, "--no-")) {
105110
alt_name <- str_drop_prefix(name, "no_")
106111
alt_spec <- app_opts[[alt_name]]
107112
if (
108113
!is.null(alt_spec) &&
109-
identical(alt_spec$arg_type, "switch") &&
110-
!has_negative_alias(alt_spec)
114+
identical(alt_spec$arg_type, "switch")
111115
) {
112116
stop_unrecognized_arg(a)
113117
}
@@ -125,6 +129,9 @@ process_args <- function(args, app) {
125129
) {
126130
stop_unrecognized_arg(a)
127131
}
132+
if (!is.null(spec) && identical(spec$arg_type, "switch")) {
133+
positive_switch <- TRUE
134+
}
128135

129136
# if flag not known, maybe this is a switch flag
130137
if (is.null(spec) && startsWith(a, "--no-")) {
@@ -154,10 +161,28 @@ process_args <- function(args, app) {
154161

155162
if (is.null(val)) {
156163
if (identical(spec$arg_type, "switch")) {
157-
val <- "true"
164+
if (positive_switch) {
165+
next_arg <- readLines(args, 1L)
166+
if (length(next_arg)) {
167+
if (is_bool_cli_value(next_arg)) {
168+
val <- next_arg
169+
} else {
170+
pushBack(next_arg, args)
171+
}
172+
}
173+
}
174+
if (is.null(val)) {
175+
val <- "true"
176+
}
158177
} else {
159178
# arg_type == "option"
160179
val <- readLines(args, 1L)
180+
if (!length(val)) {
181+
stop(
182+
sprintf("Missing value for --%s.", to_kebab_case(name)),
183+
call. = FALSE
184+
)
185+
}
161186
}
162187
}
163188

@@ -328,5 +353,11 @@ normalize_bool_cli_value <- function(val) {
328353
)
329354
}
330355

356+
is_bool_cli_value <- function(val) {
357+
is.character(val) &&
358+
length(val) == 1L &&
359+
normalize_bool_cli_value(val) %in% c("true", "false")
360+
}
361+
331362
to_snake_case <- function(x) gsub("-", "_", x, fixed = TRUE)
332363
to_kebab_case <- function(x) gsub("_", "-", x, fixed = TRUE)

R/help.R

Lines changed: 38 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -194,38 +194,49 @@ print_app_help <- function(app, yaml = TRUE, command_path = character()) {
194194
default_value <- format_default_value(opt$default)
195195
positive_flag <- paste0("--", cli_name)
196196
negative_flag <- paste0("--no-", cli_name)
197-
show_positive <- has_positive_alias(opt)
198-
show_negative <- has_negative_alias(opt)
199-
flags <- c(
200-
if (show_positive) positive_flag,
201-
if (show_negative) negative_flag
202-
)
203-
flag <- paste(flags, collapse = " / ")
204-
if (!is.null(short_flag) && nzchar(short_flag)) {
205-
flag <- paste0("-", short_flag, ", ", flag)
206-
}
207-
toggle_note <- if (isTRUE(opt$default)) {
208-
if (show_negative) {
209-
sprintf("Disable with `%s`.", negative_flag)
210-
} else {
211-
character()
197+
show_positive <- show_positive_alias(opt)
198+
show_negative <- show_negative_alias(opt)
199+
if (!show_positive && !show_negative) {
200+
flag <- paste(positive_flag, format_placeholder(name))
201+
if (!is.null(short_flag) && nzchar(short_flag)) {
202+
flag <- paste0("-", short_flag, ", ", flag)
212203
}
213-
} else if (isFALSE(opt$default)) {
214-
if (show_positive) {
215-
sprintf("Enable with `%s`.", positive_flag)
216-
} else {
217-
character()
204+
if (!is.null(default_value)) {
205+
details <- c(details, sprintf("[default: %s]", default_value))
218206
}
207+
details <- c(details, "[type: bool]")
219208
} else {
220-
c(
221-
if (show_positive) sprintf("Enable with `%s`.", positive_flag),
222-
if (show_negative) sprintf("Disable with `%s`.", negative_flag)
209+
flags <- c(
210+
if (show_positive) positive_flag,
211+
if (show_negative) negative_flag
223212
)
213+
flag <- paste(flags, collapse = " / ")
214+
if (!is.null(short_flag) && nzchar(short_flag)) {
215+
flag <- paste0("-", short_flag, ", ", flag)
216+
}
217+
toggle_note <- if (isTRUE(opt$default)) {
218+
if (show_negative) {
219+
sprintf("Disable with `%s`.", negative_flag)
220+
} else {
221+
character()
222+
}
223+
} else if (isFALSE(opt$default)) {
224+
if (show_positive) {
225+
sprintf("Enable with `%s`.", positive_flag)
226+
} else {
227+
character()
228+
}
229+
} else {
230+
c(
231+
if (show_positive) sprintf("Enable with `%s`.", positive_flag),
232+
if (show_negative) sprintf("Disable with `%s`.", negative_flag)
233+
)
234+
}
235+
if (!is.null(default_value)) {
236+
details <- c(details, sprintf("[default: %s]", default_value))
237+
}
238+
details <- c(details, toggle_note)
224239
}
225-
if (!is.null(default_value)) {
226-
details <- c(details, sprintf("[default: %s]", default_value))
227-
}
228-
details <- c(details, toggle_note)
229240
}
230241

231242
if (identical(opt$action, "append")) {

R/utils.R

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,6 @@ compact <- function(x) x[lengths(x) > 0]
6868
`%||%` <- function(x, y) if (is.null(x)) y else x
6969
`subtract<-` <- function(x, value) x - value
7070

71-
is_auto_bool_switch_default <- function(x) {
72-
identical(x, TRUE) || identical(x, FALSE)
73-
}
74-
7571
is_bool_switch_default <- function(x) is.logical(x) && length(x) == 1L
7672

7773
check_bool_switch_default <- function(default) {
@@ -89,9 +85,7 @@ has_positive_alias <- function(opt) {
8985
default <- opt[["default"]]
9086
check_bool_switch_default(default)
9187

92-
isFALSE(default) ||
93-
isTRUE(is.na(default)) ||
94-
isFALSE(opt[["negative_alias"]])
88+
TRUE
9589
}
9690

9791
has_negative_alias <- function(opt) {
@@ -104,7 +98,25 @@ has_negative_alias <- function(opt) {
10498
return(!isFALSE(opt[["negative_alias"]]))
10599
}
106100

107-
isTRUE(default) || isTRUE(is.na(default))
101+
TRUE
102+
}
103+
104+
show_positive_alias <- function(opt) {
105+
stopifnot(identical(opt[["arg_type"]], "switch"))
106+
107+
default <- opt[["default"]]
108+
check_bool_switch_default(default)
109+
110+
isFALSE(default) || isTRUE(is.na(default))
111+
}
112+
113+
show_negative_alias <- function(opt) {
114+
stopifnot(identical(opt[["arg_type"]], "switch"))
115+
116+
default <- opt[["default"]]
117+
check_bool_switch_default(default)
118+
119+
has_negative_alias(opt) && (isTRUE(default) || isTRUE(is.na(default)))
108120
}
109121

110122
`append<-` <- function(x, after = NULL, value) {

README.md

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ flip-coin --n=1
113113
flip-coin --n 1
114114
```
115115

116-
Assignments of `TRUE` or `FALSE` are a little different from other
116+
Assignments of `TRUE`, `FALSE`, or `NA` are a little different from other
117117
options. They support usage as switches or toggles at the command line,
118118
and the default controls which aliases are exposed. For example in an R
119119
script:
@@ -129,9 +129,10 @@ my-app --no-echo # FALSE
129129
```
130130

131131
With `echo <- FALSE`, the positive alias `--echo` is supported.
132-
Assignments of `NA` remain boolean options that take a value, so omitting
133-
`echo <- NA` leaves `echo` as `NA` and supplying `--echo true` or
134-
`--echo false` records the caller's choice.
132+
Assignments of `NA` are tri-state boolean switches: omitting the flag
133+
leaves the value as `NA`, `--echo` sets it to `TRUE`, and `--no-echo`
134+
sets it to `FALSE`. Boolean switches also accept explicit values, such
135+
as `--echo=true`, `--echo=false`, `--echo true`, and `--echo false`.
135136

136137
To omit the generated `--no-*` alias for a boolean switch, add
137138
`#| negative_alias: false` above the assignment:
@@ -147,6 +148,9 @@ strings rather than boolean aliases for non-bool values. For declared
147148
bool options, Rapp also accepts YAML 1.1 bool aliases such as `yes`,
148149
`no`, `y`, `n`, `on`, and `off` for backward compatibility.
149150

151+
See [Boolean option behavior](docs/boolean-options.md) for a full table
152+
of boolean defaults, annotations, and accepted command-line forms.
153+
150154
Assigning `c()` or `list()` declares an option that can be supplied
151155
multiple times. Use `c()` when you want to keep the exact strings
152156
provided on the command line, and `list()` when you want Rapp to attempt
@@ -373,7 +377,7 @@ command line arguments.
373377
| Assignment of `NULL`<br>`foo <- NULL` | Positional Arg<br>`APP foo-value` |
374378
| Assignment of `FALSE`<br>`foo <- FALSE` | Boolean switch<br>`APP --foo` |
375379
| Assignment of `TRUE`<br>`foo <- TRUE` | Boolean switch<br>`APP --no-foo` |
376-
| Assignment of `NA`<br>`foo <- NA` | Boolean option<br>`APP --foo true` or `APP --foo false` |
380+
| Assignment of `NA`<br>`foo <- NA` | Tri-state boolean switch<br>`APP --foo` or `APP --no-foo` |
377381
| Assignment of `c()` or `list()`<br>`foo <- c()` | Repeatable option<br>`APP --foo val1 --foo val2` |
378382
| Assignment of `NULL` to name with `...`<br>`args... <- NULL` | Positional Arg Collector<br>`APP foo bar baz` |
379383
| Switch with string literal<br>`switch("", cmd1 = {}, cmd2 = {})` | Required commands<br>`APP --help`<br>`APP cmd1 --help`<br>`APP cmd2 --help` |

docs/boolean-options.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Boolean Option Behavior
2+
3+
Logical defaults become boolean command-line inputs. Help output shows the
4+
most useful spelling for changing the default, while parsing also accepts
5+
explicit boolean values for convenience.
6+
7+
In the table below, `TRUE`, `FALSE`, and `NA` are the resulting R values.
8+
`reject` means the command-line form is not accepted.
9+
10+
| R definition | Help shows | Omitted | `--foo` | `--no-foo` | `--foo=true` | `--foo=false` | `--foo true` | `--foo false` |
11+
|---|---|---:|---:|---:|---:|---:|---:|---:|
12+
| `foo <- FALSE` | `--foo` | `FALSE` | `TRUE` | `FALSE` | `TRUE` | `FALSE` | `TRUE` | `FALSE` |
13+
| `foo <- TRUE` | `--no-foo` | `TRUE` | `TRUE` | `FALSE` | `TRUE` | `FALSE` | `TRUE` | `FALSE` |
14+
| `foo <- NA` | `--foo / --no-foo` | `NA` | `TRUE` | `FALSE` | `TRUE` | `FALSE` | `TRUE` | `FALSE` |
15+
| `#| negative_alias: false`<br>`foo <- FALSE` | `--foo` | `FALSE` | `TRUE` | reject | `TRUE` | `FALSE` | `TRUE` | `FALSE` |
16+
| `#| negative_alias: false`<br>`foo <- TRUE` | `--foo <FOO>` | `TRUE` | `TRUE` | reject | `TRUE` | `FALSE` | `TRUE` | `FALSE` |
17+
| `#| negative_alias: false`<br>`foo <- NA` | `--foo` | `NA` | `TRUE` | reject | `TRUE` | `FALSE` | `TRUE` | `FALSE` |
18+
| `#| arg_type: option`<br>`foo <- NA` | `--foo <FOO>` | `NA` | reject | reject | `TRUE` | `FALSE` | `TRUE` | `FALSE` |
19+
20+
Bare `--foo` and `--no-foo` count as supplied values for boolean switches.
21+
Use `#| arg_type: option` when a boolean input should require an explicit
22+
value after `--foo`.
23+
24+
`#| negative_alias: false` only disables the generated `--no-foo` spelling.
25+
It does not disable the positive spelling or explicit value forms such as
26+
`--foo=false`.

0 commit comments

Comments
 (0)