From 1941f39553abf389625292c9ba15ee33d3cade51 Mon Sep 17 00:00:00 2001 From: Benjamin Fischer Date: Thu, 21 Aug 2025 00:58:15 +0200 Subject: [PATCH 1/3] feat(flags): skip optional arguments that have an empty string as value --- command/command.ts | 14 ++++--- command/test/command/arguments_test.ts | 14 +++++++ command/test/command/option_test.ts | 11 +++++ flags/flags.ts | 42 +++++++++++++------ flags/test/flags/empty_options_test.ts | 56 ++++++++++++++++++++++++++ flags/types/number.ts | 8 ++-- 6 files changed, 125 insertions(+), 20 deletions(-) create mode 100644 flags/test/flags/empty_options_test.ts diff --git a/command/command.ts b/command/command.ts index 4c1e9f8f..b84635c0 100644 --- a/command/command.ts +++ b/command/command.ts @@ -2137,16 +2137,20 @@ export class Command< }; if (expectedArg.variadic) { - arg = args.splice(0, args.length).map((value) => - parseArgValue(value) - ); + arg = args + .splice(0, args.length) + .filter((arg) => !(expectedArg.optional && arg === "")) + .map((value) => parseArgValue(value)); } else { - arg = parseArgValue(args.shift() as string); + const value = args.shift() as string; + if (!(expectedArg.optional && value === "")) { + arg = parseArgValue(value); + } } if (expectedArg.variadic && Array.isArray(arg)) { params.push(...arg); - } else if (typeof arg !== "undefined") { + } else { params.push(arg); } } diff --git a/command/test/command/arguments_test.ts b/command/test/command/arguments_test.ts index c3aa6563..897d04b5 100644 --- a/command/test/command/arguments_test.ts +++ b/command/test/command/arguments_test.ts @@ -94,6 +94,20 @@ test("should parse multi argument option", async () => { assertEquals(args, ["mod.ts"]); }); +test("should ignore optional arguments with an empty string as value", async () => { + const { args } = await new Command() + .arguments("[foo] [bar] [baz] [...beep]") + .parse(["", "bar-value", "", "", "", "beep-value-3", "", "beep-value-5"]); + + assertEquals(args, [ + undefined, + "bar-value", + undefined, + "beep-value-3", + "beep-value-5", + ]); +}); + test("should throw an error for invalid number types", async () => { await assertRejects( async () => { diff --git a/command/test/command/option_test.ts b/command/test/command/option_test.ts index ebe7f9ac..e2500db9 100644 --- a/command/test/command/option_test.ts +++ b/command/test/command/option_test.ts @@ -273,3 +273,14 @@ test("command - option - global option value handler", async () => { .parse(["foo", "--foo", "bar"]); assertEquals(options, { foo: { value: "bar" } }); }); + +test("command - option - should skip optional arguments with an empty value", async () => { + const { options } = await new Command() + .option("--foo [value:number]", "...") + .option("--bar ", "...") + .option("--baz ", "...") + .option("--beep ", "...") + .parse(["--foo", "", "--bar", "", "--baz", "", "--beep", "1"]); + + assertEquals(options, { beep: 1 }); +}); diff --git a/flags/flags.ts b/flags/flags.ts index a7f16a80..8e12da7c 100644 --- a/flags/flags.ts +++ b/flags/flags.ts @@ -147,6 +147,7 @@ function parseArgs( let current: string = args[argsIndex]; let currentValue: string | undefined; let negate = false; + let skipArgument = false; // literal args after -- if (inLiteral) { @@ -187,7 +188,7 @@ function parseArgs( // split value: --foo="bar=baz" => --foo bar=baz const equalSignIndex = current.indexOf("="); if (equalSignIndex !== -1) { - currentValue = current.slice(equalSignIndex + 1) || undefined; + currentValue = current.slice(equalSignIndex + 1); current = current.slice(0, equalSignIndex); } @@ -256,6 +257,10 @@ function parseArgs( parseNext(option); + if (skipArgument) { + continue; + } + if (typeof ctx.flags[propName] === "undefined") { if (option.args?.length && !option.args?.[optionArgsIndex].optional) { throw new MissingOptionValueError(option.name); @@ -359,6 +364,10 @@ function parseArgs( } } + if (skipArgument) { + return; + } + if ( typeof result !== "undefined" && (option.args.length > 1 || arg.variadic) @@ -382,7 +391,7 @@ function parseArgs( return false; } const nextValue = currentValue ?? args[argsIndex + 1]; - if (!nextValue) { + if (nextValue === undefined) { return false; } if (option.args.length > 1 && optionArgsIndex >= option.args.length) { @@ -413,17 +422,26 @@ function parseArgs( arg: ArgumentOptions, value: string, ): unknown { - const result: unknown = opts.parse - ? opts.parse({ - label: "Option", - type: arg.type || OptionType.STRING, - name: `--${option.name}`, - value, - }) - : parseDefaultType(option, arg, value); - - if (typeof result !== "undefined") { + let result: unknown; + + if (!option.required && value === "") { + // if the value is empty and the argument is optional, + // we can skip the argument. + skipArgument = true; increase = true; + } else { + result = opts.parse + ? opts.parse({ + label: "Option", + type: arg.type || OptionType.STRING, + name: `--${option.name}`, + value, + }) + : parseDefaultType(option, arg, value); + + if (typeof result !== "undefined") { + increase = true; + } } return result; diff --git a/flags/test/flags/empty_options_test.ts b/flags/test/flags/empty_options_test.ts new file mode 100644 index 00000000..7a728361 --- /dev/null +++ b/flags/test/flags/empty_options_test.ts @@ -0,0 +1,56 @@ +import { test } from "@cliffy/internal/testing/test"; +import { assertEquals } from "@std/assert"; +import { parseFlags } from "../../flags.ts"; + +test("[flags] should skip optional arguments with an empty value", () => { + const { flags, unknown, literal } = parseFlags( + [ + "--foo", + "", + "--bar", + "", + "--baz", + "", + "--boop", + "1", + "--beep", + "", + "--beep", + "beep-value-2", + "--beep", + "", + "--beep", + "beep-value-4", + ], + { + flags: [{ + name: "foo", + type: "number", + optionalValue: true, + }, { + name: "bar", + type: "boolean", + }, { + name: "baz", + type: "string", + }, { + name: "beep", + type: "string", + collect: true, + }, { + name: "boop", + type: "number", + }], + }, + ); + + assertEquals(flags, { + beep: [ + "beep-value-2", + "beep-value-4", + ], + boop: 1, + }); + assertEquals(unknown, []); + assertEquals(literal, []); +}); diff --git a/flags/types/number.ts b/flags/types/number.ts index f1d8ae7c..5efcb746 100644 --- a/flags/types/number.ts +++ b/flags/types/number.ts @@ -3,9 +3,11 @@ import { InvalidTypeError } from "../_errors.ts"; /** Number type handler. Excepts any numeric value. */ export const number: TypeHandler = (type: ArgumentValue): number => { - const value = Number(type.value); - if (Number.isFinite(value)) { - return value; + if (type.value) { + const value = Number(type.value); + if (Number.isFinite(value)) { + return value; + } } throw new InvalidTypeError(type); From fd38195f04b8d3a6d756cef20c299b26059ad568 Mon Sep 17 00:00:00 2001 From: Benjamin Fischer Date: Fri, 22 Aug 2025 23:48:53 +0200 Subject: [PATCH 2/3] add support for variadic and multi argument options --- command/test/command/option_test.ts | 54 ++++++++++++++++++++++++-- flags/flags.ts | 20 +++++++++- flags/test/flags/empty_options_test.ts | 30 ++++++++++++++ 3 files changed, 98 insertions(+), 6 deletions(-) diff --git a/command/test/command/option_test.ts b/command/test/command/option_test.ts index e2500db9..14964134 100644 --- a/command/test/command/option_test.ts +++ b/command/test/command/option_test.ts @@ -275,12 +275,58 @@ test("command - option - global option value handler", async () => { }); test("command - option - should skip optional arguments with an empty value", async () => { - const { options } = await new Command() + const { options, args } = await new Command() + .noExit() .option("--foo [value:number]", "...") .option("--bar ", "...") .option("--baz ", "...") - .option("--beep ", "...") - .parse(["--foo", "", "--bar", "", "--baz", "", "--beep", "1"]); + .option("--beep ", "...", { collect: true }) + .option("--boop ", "...") + .option("--multi [value:string] [value:string]", "...") + .option("--variadic [...value:string]", "...") + .parse([ + "--foo", + "", + "--bar", + "", + "--baz", + "", + "--boop", + "1", + "--beep", + "", + "--beep", + "beep-value-2", + "--beep", + "", + "--beep", + "beep-value-4", + "--multi", + "", + "multi-value-2", + "--variadic", + "", + "variadic-value-2", + "", + "variadic-value-4", + ]); - assertEquals(options, { beep: 1 }); + assertEquals({ options, args }, { + options: { + beep: [ + "beep-value-2", + "beep-value-4", + ], + boop: 1, + multi: [ + undefined, + "multi-value-2", + ], + variadic: [ + "variadic-value-2", + "variadic-value-4", + ], + }, + args: [], + }); }); diff --git a/flags/flags.ts b/flags/flags.ts index 8e12da7c..6698b244 100644 --- a/flags/flags.ts +++ b/flags/flags.ts @@ -293,6 +293,8 @@ function parseArgs( /** Parse next argument for current option. */ // deno-lint-ignore no-inner-declarations function parseNext(option: FlagOptions): void { + let skipOptionArgument = false; + if (negate) { setFlagValue(false); return; @@ -364,6 +366,10 @@ function parseArgs( } } + if (skipOptionArgument && hasNext(arg)) { + parseNext(option); + return; + } if (skipArgument) { return; } @@ -375,6 +381,9 @@ function parseArgs( if (!ctx.flags[propName]) { setFlagValue([]); } + if (result === "") { + result = undefined; + } (ctx.flags[propName] as Array).push(result); @@ -424,10 +433,17 @@ function parseArgs( ): unknown { let result: unknown; - if (!option.required && value === "") { + if ((!option.required || arg.optional) && value === "") { // if the value is empty and the argument is optional, // we can skip the argument. - skipArgument = true; + if (arg.variadic) { + skipOptionArgument = true; + } else if (option.args?.length === 1) { + skipArgument = true; + } else { + // will be mapped to undefined later. + result = ""; + } increase = true; } else { result = opts.parse diff --git a/flags/test/flags/empty_options_test.ts b/flags/test/flags/empty_options_test.ts index 7a728361..acda9c9e 100644 --- a/flags/test/flags/empty_options_test.ts +++ b/flags/test/flags/empty_options_test.ts @@ -21,6 +21,14 @@ test("[flags] should skip optional arguments with an empty value", () => { "", "--beep", "beep-value-4", + "--multi", + "", + "multi-value-2", + "--variadic", + "", + "variadic-value-2", + "", + "variadic-value-4", ], { flags: [{ @@ -40,6 +48,20 @@ test("[flags] should skip optional arguments with an empty value", () => { }, { name: "boop", type: "number", + }, { + name: "multi", + args: [{ + type: "string", + optional: true, + }, { + type: "string", + optional: true, + }], + }, { + name: "variadic", + type: "string", + optional: true, + variadic: true, }], }, ); @@ -50,6 +72,14 @@ test("[flags] should skip optional arguments with an empty value", () => { "beep-value-4", ], boop: 1, + multi: [ + undefined, + "multi-value-2", + ], + variadic: [ + "variadic-value-2", + "variadic-value-4", + ], }); assertEquals(unknown, []); assertEquals(literal, []); From 67ff53c867078648a6e255e3f1c3bb67c5f20ed5 Mon Sep 17 00:00:00 2001 From: Benjamin Fischer Date: Fri, 22 Aug 2025 23:59:33 +0200 Subject: [PATCH 3/3] fix variadic options --- command/test/command/option_test.ts | 5 ++++- flags/flags.ts | 6 ++++-- flags/test/flags/empty_options_test.ts | 6 ++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/command/test/command/option_test.ts b/command/test/command/option_test.ts index 14964134..2589bb4a 100644 --- a/command/test/command/option_test.ts +++ b/command/test/command/option_test.ts @@ -282,7 +282,7 @@ test("command - option - should skip optional arguments with an empty value", as .option("--baz ", "...") .option("--beep ", "...", { collect: true }) .option("--boop ", "...") - .option("--multi [value:string] [value:string]", "...") + .option("--multi [value:string] [value:string] [value:string]", "...") .option("--variadic [...value:string]", "...") .parse([ "--foo", @@ -304,11 +304,13 @@ test("command - option - should skip optional arguments with an empty value", as "--multi", "", "multi-value-2", + "", "--variadic", "", "variadic-value-2", "", "variadic-value-4", + "", ]); assertEquals({ options, args }, { @@ -321,6 +323,7 @@ test("command - option - should skip optional arguments with an empty value", as multi: [ undefined, "multi-value-2", + undefined, ], variadic: [ "variadic-value-2", diff --git a/flags/flags.ts b/flags/flags.ts index 6698b244..c97e0d4b 100644 --- a/flags/flags.ts +++ b/flags/flags.ts @@ -366,8 +366,10 @@ function parseArgs( } } - if (skipOptionArgument && hasNext(arg)) { - parseNext(option); + if (skipOptionArgument) { + if (hasNext(arg)) { + parseNext(option); + } return; } if (skipArgument) { diff --git a/flags/test/flags/empty_options_test.ts b/flags/test/flags/empty_options_test.ts index acda9c9e..2f08fe08 100644 --- a/flags/test/flags/empty_options_test.ts +++ b/flags/test/flags/empty_options_test.ts @@ -24,11 +24,13 @@ test("[flags] should skip optional arguments with an empty value", () => { "--multi", "", "multi-value-2", + "", "--variadic", "", "variadic-value-2", "", "variadic-value-4", + "", ], { flags: [{ @@ -56,6 +58,9 @@ test("[flags] should skip optional arguments with an empty value", () => { }, { type: "string", optional: true, + }, { + type: "string", + optional: true, }], }, { name: "variadic", @@ -75,6 +80,7 @@ test("[flags] should skip optional arguments with an empty value", () => { multi: [ undefined, "multi-value-2", + undefined, ], variadic: [ "variadic-value-2",