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 } ;
4
17
use clap_verbosity_flag:: InfoLevel ;
5
18
use regex:: Regex ;
6
19
@@ -20,8 +33,14 @@ pub enum FileExtension {
20
33
21
34
/// ReScript - Fast, Simple, Fully Typed JavaScript from the Future
22
35
#[ 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" ) ]
23
40
#[ command( version) ]
24
- #[ command( args_conflicts_with_subcommands = true ) ]
41
+ #[ command(
42
+ after_help = "[1m[1m[4mNote:[0m If no command is provided, the [1mbuild[0m command is run by default. See `rescript help build` for more information."
43
+ ) ]
25
44
pub struct Cli {
26
45
/// Verbosity:
27
46
/// -v -> Debug
@@ -35,10 +54,121 @@ pub struct Cli {
35
54
36
55
/// The command to run. If not provided it will default to build.
37
56
#[ command( subcommand) ]
38
- pub command : Option < Command > ,
57
+ pub command : Command ,
58
+ }
39
59
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
42
172
}
43
173
44
174
#[ derive( Args , Debug , Clone ) ]
@@ -136,6 +266,144 @@ pub struct BuildArgs {
136
266
pub warn_error : Option < String > ,
137
267
}
138
268
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
+
139
407
#[ derive( Args , Clone , Debug ) ]
140
408
pub struct WatchArgs {
141
409
#[ command( flatten) ]
@@ -181,7 +449,7 @@ impl From<BuildArgs> for WatchArgs {
181
449
182
450
#[ derive( Subcommand , Clone , Debug ) ]
183
451
pub enum Command {
184
- /// Build the project
452
+ /// Build the project (default command)
185
453
Build ( BuildArgs ) ,
186
454
/// Build, then start a watcher
187
455
Watch ( WatchArgs ) ,
0 commit comments