denoland/deno · #36835

fix(cli): preserve double dash before the entrypoint

bartlomieju · merged Sep 16, 20263 files · 188 + / 0
libs/cli_parser/src/parse.rs49 + / 0
@@ -119,6 +119,11 @@ fn parse_args(   let mut i = 0;   let mut positional_index = 0;   let mut trailing_mode = false;+  // Set when `--` appears before the first positional: the remaining args+  // fill the positionals literally (a leading hyphen is not a flag).+  let mut positional_only = false;+  // Whether any positional value has been consumed yet.+  let mut positional_started = false;   let mut found_subcommand = skip_subcommand.is_none();   let mut passthrough_from: Option<usize> = None;   // Per-positional trailing: when set, ALL remaining args (including flags)@@ -161,6 +166,29 @@ fn parse_args(       continue;     } +    // Positional-only mode: a `--` appeared before the first positional of+    // an entrypoint-style command (see the `--` handling below), so consume+    // positional values without interpreting leading hyphens as flags, e.g.+    // `deno run -- -script.ts`. Once the positionals are complete, the+    // command's trailing var args receive the rest. Note that a second `--`+    // is forwarded literally here: only the first `--` is special, and it+    // was already consumed to enter this mode.+    if positional_only {+      if let Some(pos_def) = positional_defs.get(positional_index) {+        apply_value_with_delimiter(result, pos_def, arg)?;+        // Move to next positional unless this one accepts multiple+        match pos_def.num_args {+          NumArgs::ZeroOrMore | NumArgs::OneOrMore => {}+          _ => positional_index += 1,+        }+      } else {+        // This mode is only entered when cmd_def.trailing_var_arg is set.+        result.trailing.push(arg.clone());+      }+      i += 1;+      continue;+    }+     // After `--`, everything is trailing     if trailing_mode {       result.trailing.push(arg.clone());@@ -178,6 +206,26 @@ fn parse_args(         i += 1;         continue;       }+      // For entrypoint-style commands (a single-value positional followed+      // by trailing var args: run/serve/eval/task/compile), a `--` before+      // the first positional does not start trailing args; it marks the+      // remaining args as positional-only so the entrypoint itself may+      // start with a hyphen (mirrors clap's trailing_var_arg). Commands+      // with a multi-value positional (test/bench/install) instead mirror+      // clap's `.last(true)`: args after `--` bypass the positional and+      // stay trailing, so they fall through here.+      if !positional_started+        && cmd_def.trailing_var_arg+        && let Some(next_pos) = positional_defs.get(positional_index)+        && !matches!(+          next_pos.num_args,+          NumArgs::ZeroOrMore | NumArgs::OneOrMore+        )+      {+        positional_only = true;+        i += 1;+        continue;+      }       trailing_mode = true;       // Keep the `--` in the forwarded args for subcommands that mirror clap's       // `.last(true)` / external-subcommand behavior; strip it otherwise.@@ -213,6 +261,7 @@ fn parse_args(     } else {       // Positional argument       if let Some(pos_def) = positional_defs.get(positional_index) {+        positional_started = true;         apply_value_with_delimiter(result, pos_def, arg)?;          // If this positional has trailing: true, absorb everything
libs/cli_parser/src/tests.rs73 + / 0
@@ -882,6 +882,25 @@ fn run_double_dash_trailing() {   assert_eq!(r.trailing, vec!["--", "arg1", "--flag"]); } +#[test]+fn run_double_dash_before_script() {+  let r =+    parse(&TEST_ROOT, &svec!["deno", "run", "--", "-echo.ts", "arg1"]).unwrap();+  assert_eq!(r.get_one("script_arg"), Some("-echo.ts"));+  assert_eq!(r.trailing, vec!["arg1"]);+}++#[test]+fn run_double_dash_before_script_keeps_second_separator() {+  let r = parse(+    &TEST_ROOT,+    &svec!["deno", "run", "--", "-echo.ts", "--", "--flag"],+  )+  .unwrap();+  assert_eq!(r.get_one("script_arg"), Some("-echo.ts"));+  assert_eq!(r.trailing, vec!["--", "--flag"]);+}+ #[test] fn global_flags_before_subcommand() {   let r = parse(@@ -1195,6 +1214,48 @@ fn eval_print() {   assert_eq!(r.get_one("code_arg"), Some("1+1")); } +#[test]+fn eval_double_dash_before_code() {+  let r = parse(+    &TEST_ROOT,+    &svec!["deno", "eval", "--", "-1; console.log(0)", "arg1"],+  )+  .unwrap();+  assert_eq!(r.get_one("code_arg"), Some("-1; console.log(0)"));+  assert_eq!(r.trailing, vec!["arg1"]);+}++#[test]+fn eval_double_dash_before_code_keeps_second_separator() {+  // Only the first `--` is special (mirrors clap): it was consumed to make+  // the positional literal, so a later `--` is forwarded as-is even though+  // eval strips the separator in `deno eval code -- a`.+  let r =+    parse(&TEST_ROOT, &svec!["deno", "eval", "--", "code", "--", "a"]).unwrap();+  assert_eq!(r.get_one("code_arg"), Some("code"));+  assert_eq!(r.trailing, vec!["--", "a"]);+}++#[test]+fn upgrade_double_dash_stays_trailing() {+  // Commands without trailing var args don't enter positional-only mode:+  // `--` still starts (unused) trailing args, as before.+  let r =+    parse(&TEST_ROOT, &svec!["deno", "upgrade", "--", "v1", "v2"]).unwrap();+  assert_eq!(r.get_one("version-or-hash-or-channel"), None);+  assert_eq!(r.trailing, vec!["v1", "v2"]);+}++#[test]+fn test_double_dash_stays_trailing() {+  // Commands with a multi-value positional (test/bench) mirror clap's+  // `.last(true)`: args after `--` are script args, not files.+  let r =+    parse(&TEST_ROOT, &svec!["deno", "test", "--", "arg1", "--flag"]).unwrap();+  assert_eq!(r.get_many("files"), None);+  assert_eq!(r.trailing, vec!["arg1", "--flag"]);+}+ #[test] fn unknown_flag_error() {   let err = parse(@@ -1242,6 +1303,18 @@ fn default_subcommand_with_flags() {   assert_eq!(r.get_one("script_arg"), Some("script.ts")); } +#[test]+fn default_subcommand_double_dash_before_script() {+  let r = parse(+    &TEST_ROOT,+    &svec!["deno", "--", "-echo.ts", "--", "--debug"],+  )+  .unwrap();+  assert_eq!(r.subcommand.as_deref(), None);+  assert_eq!(r.get_one("script_arg"), Some("-echo.ts"));+  assert_eq!(r.trailing, vec!["--", "--debug"]);+}+ #[test] fn default_subcommand_with_allow_read_values() {   let r = parse(&TEST_ROOT, &svec!["deno", "--allow-read=/tmp", "script.ts"])
libs/cli_parser/src/tests_full.rs66 + / 0
@@ -9712,6 +9712,72 @@ fn eval_double_dash_stripped() {   assert_eq!(flags.argv, svec!["--", "a&b"]); } +#[test]+fn double_dash_before_entrypoint() {+  // A `--` before the first positional makes the entrypoint literal+  // (it may start with a hyphen) instead of starting trailing args.+  let flags = flags_from_vec(svec!["deno", "run", "--", "-x.ts", "a"]).unwrap();+  assert_eq!(+    flags.subcommand,+    DenoSubcommand::Run(RunFlags::new_default("-x.ts".to_string()))+  );+  assert_eq!(flags.argv, svec!["a"]);++  // Bare form without the `run` subcommand.+  let flags = flags_from_vec(svec!["deno", "--", "-x.ts", "a"]).unwrap();+  assert_eq!(+    flags.subcommand,+    DenoSubcommand::Run(RunFlags {+      bare: true,+      ..RunFlags::new_default("-x.ts".to_string())+    })+  );+  assert_eq!(flags.argv, svec!["a"]);++  let flags = flags_from_vec(svec!["deno", "eval", "--", "-1"]).unwrap();+  assert!(+    matches!(flags.subcommand, DenoSubcommand::Eval(e) if e.code == "-1")+  );++  let flags =+    flags_from_vec(svec!["deno", "task", "--", "build", "hello"]).unwrap();+  assert!(+    matches!(flags.subcommand, DenoSubcommand::Task(t) if t.task.as_deref() == Some("build"))+  );+  assert_eq!(flags.argv, svec!["hello"]);++  // test/bench mirror clap's `.last(true)`: args after `--` are script+  // args, never files — even when no files were given before the `--`.+  let flags = flags_from_vec(svec!["deno", "test", "--", "arg1"]).unwrap();+  assert!(+    matches!(&flags.subcommand, DenoSubcommand::Test(t) if t.files.include.is_empty())+  );+  assert_eq!(flags.argv, svec!["arg1"]);++  // create's package is only recognized before the `--` (clap `.last(true)`+  // took everything after it as package args), so this stays an error.+  assert!(flags_from_vec(svec!["deno", "create", "--", "npm:vite"]).is_err());++  // `--` before the first positional of init still routes everything into+  // its trailing positional and strips a second `--` (unchanged behavior).+  let flags = flags_from_vec(svec![+    "deno", "init", "--npm", "--", "vite", "--", "--serve"+  ])+  .unwrap();+  assert_eq!(+    flags.subcommand,+    DenoSubcommand::Init(InitFlags {+      package: Some("npm:vite".to_string()),+      package_args: svec!["--serve"],+      dir: None,+      lib: false,+      serve: false,+      empty: false,+      yes: false,+    })+  );+}+ #[test] fn sync_types_subcommand() {   let flags = flags_from_vec(svec!["deno", "sync-types"]).unwrap();