Skip to content

Commit a1b3b3c

Browse files
authored
Rewatch cli help: do not show build command options in the root help (#7715)
* Rewatch cli help: do not show build command options in the root help * Satisfy clippy * CHANGELOG # Conflicts: # CHANGELOG.md # Conflicts: # CHANGELOG.md * Text + Formatting * Fix implementation * Added tests * Bug fix * Simplify and satisfy clippy * Fix non-utf8 error * Satisfy clippy * rewatch: harden default command parsing * rewatch: add baseline clap behavior tests * add test for "--" * add comments * Move default arg handling to cli module * format * Move env::args_os().collect into cli.rs * Add comment regarding workaround
1 parent 268996b commit a1b3b3c

File tree

3 files changed

+281
-12
lines changed

3 files changed

+281
-12
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626

2727
#### :nail_care: Polish
2828

29+
- Rewatch cli: do not show build command options in the root help. https://github.com/rescript-lang/rescript/pull/7715
30+
2931
#### :house: Internal
3032

3133
# 12.0.0-beta.13

rewatch/src/cli.rs

Lines changed: 276 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,19 @@
1-
use std::{ffi::OsString, ops::Deref};
2-
3-
use clap::{Args, Parser, Subcommand};
1+
// We currently use https://docs.rs/clap/latest/clap/ v4 for command line parsing.
2+
// However, it does not fully fit our use case as it does not support default commands,
3+
// but we want to default to the "build" command if no other command is specified.
4+
//
5+
// Various workarounds exist, but each with its own drawbacks.
6+
// The workaround implemented here (injecting "build" into the args at the right place
7+
// and then parsing again if no other command matches at the first parse attempt)
8+
// avoids flattening all build command options into the root help, but requires careful
9+
// handling of edge cases regarding global flags.
10+
// Correctness is ensured by a comprehensive test suite.
11+
//
12+
// However, we may want to revisit the decision to use clap after the v12 release.
13+
14+
use std::{env, ffi::OsString, ops::Deref};
15+
16+
use clap::{Args, CommandFactory, Parser, Subcommand, error::ErrorKind};
417
use clap_verbosity_flag::InfoLevel;
518
use regex::Regex;
619

@@ -20,8 +33,14 @@ pub enum FileExtension {
2033

2134
/// ReScript - Fast, Simple, Fully Typed JavaScript from the Future
2235
#[derive(Parser, Debug)]
36+
// The shipped binary is `rescript.exe` everywhere, but users invoke it as `rescript` (e.g.
37+
// via `npm run rescript`). Without forcing `bin_name`, clap would print `rescript.exe` in help,
38+
// which leaks the packaging detail into the CLI UX.
39+
#[command(name = "rescript", bin_name = "rescript")]
2340
#[command(version)]
24-
#[command(args_conflicts_with_subcommands = true)]
41+
#[command(
42+
after_help = "Note: If no command is provided, the build command is run by default. See `rescript help build` for more information."
43+
)]
2544
pub struct Cli {
2645
/// Verbosity:
2746
/// -v -> Debug
@@ -35,10 +54,121 @@ pub struct Cli {
3554

3655
/// The command to run. If not provided it will default to build.
3756
#[command(subcommand)]
38-
pub command: Option<Command>,
57+
pub command: Command,
58+
}
3959

40-
#[command(flatten)]
41-
pub build_args: BuildArgs,
60+
/// Parse argv from the current process while treating `build` as the implicit default subcommand
61+
/// when clap indicates the user omitted one. This keeps the top-level help compact while still
62+
/// supporting bare `rescript …` invocations that expect to run the build.
63+
pub fn parse_with_default() -> Result<Cli, clap::Error> {
64+
// Use `args_os` so non-UTF bytes still reach clap for proper error reporting on platforms that
65+
// allow arbitrary argv content.
66+
let raw_args: Vec<OsString> = env::args_os().collect();
67+
parse_with_default_from(&raw_args)
68+
}
69+
70+
/// Parse the provided argv while applying the implicit `build` defaulting rules.
71+
pub fn parse_with_default_from(raw_args: &[OsString]) -> Result<Cli, clap::Error> {
72+
match Cli::try_parse_from(raw_args) {
73+
Ok(cli) => Ok(cli),
74+
Err(err) => {
75+
if should_default_to_build(&err, raw_args) {
76+
let fallback_args = build_default_args(raw_args);
77+
Cli::try_parse_from(&fallback_args)
78+
} else {
79+
Err(err)
80+
}
81+
}
82+
}
83+
}
84+
85+
fn should_default_to_build(err: &clap::Error, args: &[OsString]) -> bool {
86+
match err.kind() {
87+
ErrorKind::MissingSubcommand
88+
| ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
89+
| ErrorKind::UnknownArgument
90+
| ErrorKind::InvalidSubcommand => {
91+
let first_non_global = first_non_global_arg(args);
92+
match first_non_global {
93+
Some(arg) => !is_known_subcommand(arg),
94+
None => true,
95+
}
96+
}
97+
_ => false,
98+
}
99+
}
100+
101+
fn is_global_flag(arg: &OsString) -> bool {
102+
matches!(
103+
arg.to_str(),
104+
Some(
105+
"-v" | "-vv"
106+
| "-vvv"
107+
| "-vvvv"
108+
| "-q"
109+
| "-qq"
110+
| "-qqq"
111+
| "-qqqq"
112+
| "--verbose"
113+
| "--quiet"
114+
| "-h"
115+
| "--help"
116+
| "-V"
117+
| "--version"
118+
)
119+
)
120+
}
121+
122+
fn first_non_global_arg(args: &[OsString]) -> Option<&OsString> {
123+
args.iter().skip(1).find(|arg| !is_global_flag(arg))
124+
}
125+
126+
fn is_known_subcommand(arg: &OsString) -> bool {
127+
let Some(arg_str) = arg.to_str() else {
128+
return false;
129+
};
130+
131+
Cli::command().get_subcommands().any(|subcommand| {
132+
subcommand.get_name() == arg_str || subcommand.get_all_aliases().any(|alias| alias == arg_str)
133+
})
134+
}
135+
136+
fn build_default_args(raw_args: &[OsString]) -> Vec<OsString> {
137+
// Preserve clap's global flag handling semantics by keeping `-v/-q/-h/-V` in front of the
138+
// inserted `build` token while leaving the rest of the argv untouched. This mirrors clap's own
139+
// precedence rules so the second parse sees an argument layout it would have produced if the
140+
// user had typed `rescript build …` directly.
141+
let mut result = Vec::with_capacity(raw_args.len() + 1);
142+
if raw_args.is_empty() {
143+
return vec![OsString::from("build")];
144+
}
145+
146+
let mut globals = Vec::new();
147+
let mut others = Vec::new();
148+
let mut saw_double_dash = false;
149+
150+
for arg in raw_args.iter().skip(1) {
151+
if !saw_double_dash {
152+
if arg == "--" {
153+
saw_double_dash = true;
154+
others.push(arg.clone());
155+
continue;
156+
}
157+
158+
if is_global_flag(arg) {
159+
globals.push(arg.clone());
160+
continue;
161+
}
162+
}
163+
164+
others.push(arg.clone());
165+
}
166+
167+
result.push(raw_args[0].clone());
168+
result.extend(globals);
169+
result.push(OsString::from("build"));
170+
result.extend(others);
171+
result
42172
}
43173

44174
#[derive(Args, Debug, Clone)]
@@ -136,6 +266,144 @@ pub struct BuildArgs {
136266
pub warn_error: Option<String>,
137267
}
138268

269+
#[cfg(test)]
270+
mod tests {
271+
use super::*;
272+
use clap::error::ErrorKind;
273+
use log::LevelFilter;
274+
275+
fn parse(args: &[&str]) -> Result<Cli, clap::Error> {
276+
let raw_args: Vec<OsString> = args.iter().map(OsString::from).collect();
277+
parse_with_default_from(&raw_args)
278+
}
279+
280+
// Default command behaviour.
281+
#[test]
282+
fn no_subcommand_defaults_to_build() {
283+
let cli = parse(&["rescript"]).expect("expected default build command");
284+
assert!(matches!(cli.command, Command::Build(_)));
285+
}
286+
287+
#[test]
288+
fn defaults_to_build_with_folder_shortcut() {
289+
let cli = parse(&["rescript", "someFolder"]).expect("expected build command");
290+
291+
match cli.command {
292+
Command::Build(build_args) => assert_eq!(build_args.folder.folder, "someFolder"),
293+
other => panic!("expected build command, got {other:?}"),
294+
}
295+
}
296+
297+
#[test]
298+
fn trailing_global_flag_is_treated_as_global() {
299+
let cli = parse(&["rescript", "my-project", "-v"]).expect("expected build command");
300+
301+
assert_eq!(cli.verbose.log_level_filter(), LevelFilter::Debug);
302+
match cli.command {
303+
Command::Build(build_args) => assert_eq!(build_args.folder.folder, "my-project"),
304+
other => panic!("expected build command, got {other:?}"),
305+
}
306+
}
307+
308+
#[test]
309+
fn double_dash_keeps_following_args_positional() {
310+
let cli = parse(&["rescript", "--", "-v"]).expect("expected build command");
311+
312+
assert_eq!(cli.verbose.log_level_filter(), LevelFilter::Info);
313+
match cli.command {
314+
Command::Build(build_args) => assert_eq!(build_args.folder.folder, "-v"),
315+
other => panic!("expected build command, got {other:?}"),
316+
}
317+
}
318+
319+
#[test]
320+
fn unknown_subcommand_help_uses_global_help() {
321+
let err = parse(&["rescript", "xxx", "--help"]).expect_err("expected global help");
322+
assert_eq!(err.kind(), ErrorKind::DisplayHelp);
323+
}
324+
325+
// Build command specifics.
326+
#[test]
327+
fn build_help_shows_subcommand_help() {
328+
let err = parse(&["rescript", "build", "--help"]).expect_err("expected subcommand help");
329+
assert_eq!(err.kind(), ErrorKind::DisplayHelp);
330+
let rendered = err.to_string();
331+
assert!(
332+
rendered.contains("Usage: rescript build"),
333+
"unexpected help: {rendered:?}"
334+
);
335+
assert!(!rendered.contains("Usage: rescript [OPTIONS] <COMMAND>"));
336+
}
337+
338+
#[test]
339+
fn build_allows_global_verbose_flag() {
340+
let cli = parse(&["rescript", "build", "-v"]).expect("expected build command");
341+
assert_eq!(cli.verbose.log_level_filter(), LevelFilter::Debug);
342+
assert!(matches!(cli.command, Command::Build(_)));
343+
}
344+
345+
#[test]
346+
fn build_option_is_parsed_normally() {
347+
let cli = parse(&["rescript", "build", "--no-timing"]).expect("expected build command");
348+
349+
match cli.command {
350+
Command::Build(build_args) => assert!(build_args.no_timing),
351+
other => panic!("expected build command, got {other:?}"),
352+
}
353+
}
354+
355+
// Subcommand flag handling.
356+
#[test]
357+
fn respects_global_flag_before_subcommand() {
358+
let cli = parse(&["rescript", "-v", "watch"]).expect("expected watch command");
359+
360+
assert!(matches!(cli.command, Command::Watch(_)));
361+
}
362+
363+
#[test]
364+
fn invalid_option_for_subcommand_does_not_fallback() {
365+
let err = parse(&["rescript", "watch", "--no-timing"]).expect_err("expected watch parse failure");
366+
assert_eq!(err.kind(), ErrorKind::UnknownArgument);
367+
}
368+
369+
// Version/help flag handling.
370+
#[test]
371+
fn version_flag_before_subcommand_displays_version() {
372+
let err = parse(&["rescript", "-V", "build"]).expect_err("expected version display");
373+
assert_eq!(err.kind(), ErrorKind::DisplayVersion);
374+
}
375+
376+
#[test]
377+
fn version_flag_after_subcommand_is_rejected() {
378+
let err = parse(&["rescript", "build", "-V"]).expect_err("expected unexpected argument");
379+
assert_eq!(err.kind(), ErrorKind::UnknownArgument);
380+
}
381+
382+
#[test]
383+
fn global_help_flag_shows_help() {
384+
let err = parse(&["rescript", "--help"]).expect_err("expected clap help error");
385+
assert_eq!(err.kind(), ErrorKind::DisplayHelp);
386+
let rendered = err.to_string();
387+
assert!(rendered.contains("Usage: rescript [OPTIONS] <COMMAND>"));
388+
}
389+
390+
#[test]
391+
fn global_version_flag_shows_version() {
392+
let err = parse(&["rescript", "--version"]).expect_err("expected clap version error");
393+
assert_eq!(err.kind(), ErrorKind::DisplayVersion);
394+
}
395+
396+
#[cfg(unix)]
397+
#[test]
398+
fn non_utf_argument_returns_error() {
399+
use std::os::unix::ffi::OsStringExt;
400+
401+
let args = vec![OsString::from("rescript"), OsString::from_vec(vec![0xff])];
402+
let err = parse_with_default_from(&args).expect_err("expected clap to report invalid utf8");
403+
assert_eq!(err.kind(), ErrorKind::InvalidUtf8);
404+
}
405+
}
406+
139407
#[derive(Args, Clone, Debug)]
140408
pub struct WatchArgs {
141409
#[command(flatten)]
@@ -181,7 +449,7 @@ impl From<BuildArgs> for WatchArgs {
181449

182450
#[derive(Subcommand, Clone, Debug)]
183451
pub enum Command {
184-
/// Build the project
452+
/// Build the project (default command)
185453
Build(BuildArgs),
186454
/// Build, then start a watcher
187455
Watch(WatchArgs),

rewatch/src/main.rs

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,21 @@
11
use anyhow::Result;
2-
use clap::Parser;
32
use log::LevelFilter;
43
use std::{io::Write, path::Path};
54

65
use rescript::{build, cli, cmd, format, lock, watcher};
76

87
fn main() -> Result<()> {
9-
let args = cli::Cli::parse();
8+
let cli = cli::parse_with_default().unwrap_or_else(|err| err.exit());
109

11-
let log_level_filter = args.verbose.log_level_filter();
10+
let log_level_filter = cli.verbose.log_level_filter();
1211

1312
env_logger::Builder::new()
1413
.format(|buf, record| writeln!(buf, "{}:\n{}", record.level(), record.args()))
1514
.filter_level(log_level_filter)
1615
.target(env_logger::fmt::Target::Stdout)
1716
.init();
1817

19-
let mut command = args.command.unwrap_or(cli::Command::Build(args.build_args));
18+
let mut command = cli.command;
2019

2120
if let cli::Command::Build(build_args) = &command {
2221
if build_args.watch {

0 commit comments

Comments
 (0)