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 } ;
417use clap_verbosity_flag:: InfoLevel ;
518use 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 = "[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+ ) ]
2544pub 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 ) ]
140408pub struct WatchArgs {
141409 #[ command( flatten) ]
@@ -181,7 +449,7 @@ impl From<BuildArgs> for WatchArgs {
181449
182450#[ derive( Subcommand , Clone , Debug ) ]
183451pub enum Command {
184- /// Build the project
452+ /// Build the project (default command)
185453 Build ( BuildArgs ) ,
186454 /// Build, then start a watcher
187455 Watch ( WatchArgs ) ,
0 commit comments