diff --git a/.changeset/soft-ears-judge.md b/.changeset/soft-ears-judge.md new file mode 100644 index 00000000000..f3be9e11ed3 --- /dev/null +++ b/.changeset/soft-ears-judge.md @@ -0,0 +1,5 @@ +--- +"@effect/cli": patch +--- + +Allow options to appear after command arguments (relax POSIX utility syntax guideline 9) by continuing to scan for known options even when arguments are interspersed. diff --git a/packages/cli/src/internal/cliApp.ts b/packages/cli/src/internal/cliApp.ts index ec487af9db8..334365cbdcb 100644 --- a/packages/cli/src/internal/cliApp.ts +++ b/packages/cli/src/internal/cliApp.ts @@ -110,7 +110,8 @@ export const run = dual< ) ), onNonEmpty: (head) => { - const error = InternalHelpDoc.p(`Received unknown argument: '${head}'`) + const kind = head !== "-" && head.startsWith("-") ? "option" : "argument" + const error = InternalHelpDoc.p(`Received unknown ${kind}: '${head}'`) return Effect.zipRight(printDocs(error), Effect.fail(InternalValidationError.invalidValue(error))) } }) diff --git a/packages/cli/src/internal/commandDescriptor.ts b/packages/cli/src/internal/commandDescriptor.ts index ea7e6a13a8c..ed05007831c 100644 --- a/packages/cli/src/internal/commandDescriptor.ts +++ b/packages/cli/src/internal/commandDescriptor.ts @@ -575,7 +575,7 @@ const parseInternal = ( > => parseCommandLine(self, args).pipe(Effect.flatMap((commandOptionsAndArgs) => { const [optionsAndArgs, forcedCommandArgs] = splitForcedArgs(commandOptionsAndArgs) - return InternalOptions.processCommandLine(self.options, optionsAndArgs, config).pipe( + return InternalOptions.processCommandLinePermissive(self.options, optionsAndArgs, config).pipe( Effect.flatMap(([error, commandArgs, optionsType]) => InternalArgs.validate( self.args, diff --git a/packages/cli/src/internal/options.ts b/packages/cli/src/internal/options.ts index 8e6d5986ba9..8d753b4613c 100644 --- a/packages/cli/src/internal/options.ts +++ b/packages/cli/src/internal/options.ts @@ -573,6 +573,53 @@ export const processCommandLine = dual< ) ) +/** + * Like `processCommandLine`, but continues scanning for known options even after + * encountering unknown `-...` / `--...` tokens (treating them as command + * arguments). This is useful at the command level where we want to relax POSIX + * guideline 9 without breaking built-in option parsing (e.g. `-a --help` where + * `--help` is a value). + * + * @internal + */ +export const processCommandLinePermissive = dual< + ( + args: ReadonlyArray, + config: CliConfig.CliConfig + ) => ( + self: Options.Options + ) => Effect.Effect< + [Option.Option, Array, A], + ValidationError.ValidationError, + FileSystem.FileSystem | Path.Path | Terminal.Terminal + >, + ( + self: Options.Options, + args: ReadonlyArray, + config: CliConfig.CliConfig + ) => Effect.Effect< + [Option.Option, Array, A], + ValidationError.ValidationError, + FileSystem.FileSystem | Path.Path | Terminal.Terminal + > +>( + 3, + (self, args, config) => + matchOptionsPermissive(args, toParseableInstruction(self as Instruction), config).pipe( + Effect.flatMap(([error, commandArgs, matchedOptions]) => + parseInternal(self as Instruction, matchedOptions, config).pipe( + Effect.catchAll((e) => + Option.match(error, { + onNone: () => Effect.fail(e), + onSome: (err) => Effect.fail(err) + }) + ), + Effect.map((a) => [error, commandArgs as Array, a as any]) + ) + ) + ) +) + /** @internal */ export const repeated = (self: Options.Options): Options.Options> => makeVariadic(self, Option.none(), Option.none()) @@ -1539,57 +1586,93 @@ const wizardInternal = (self: Instruction, config: CliConfig.CliConfig): Effect. * Returns a possible `ValidationError` when parsing the commands, leftover * arguments from `input` and a mapping between each flag and its values. */ +type MatchOptionsResult = readonly [ + Option.Option, + ReadonlyArray, + HashMap.HashMap> +] + const matchOptions = ( input: ReadonlyArray, options: ReadonlyArray, config: CliConfig.CliConfig -): Effect.Effect< - [ - Option.Option, - ReadonlyArray, - HashMap.HashMap> - ] -> => { - if (Arr.isNonEmptyReadonlyArray(options)) { - return findOptions(input, options, config).pipe( - Effect.flatMap(([otherArgs, otherOptions, map1]) => { - if (HashMap.isEmpty(map1)) { - return Effect.succeed([Option.none(), input, map1] as [ - Option.Option, - ReadonlyArray, - HashMap.HashMap> - ]) +): Effect.Effect => { + const succeed = ( + error: Option.Option, + commandArgs: ReadonlyArray, + matchedOptions: HashMap.HashMap> + ): Effect.Effect => Effect.succeed([error, commandArgs, matchedOptions] as const) + + if (Arr.isEmptyReadonlyArray(input)) { + return succeed(Option.none(), Arr.empty(), HashMap.empty()) + } + if (Arr.isEmptyReadonlyArray(options)) { + return succeed(Option.none(), input, HashMap.empty()) + } + + return findOptions(input, options, config).pipe( + Effect.flatMap(([otherArgs, otherOptions, map1]) => { + if (HashMap.isEmpty(map1)) { + const head = Arr.headNonEmpty(input) + if (head.startsWith("-")) { + return succeed(Option.none(), input, map1) } - return matchOptions(otherArgs, otherOptions, config).pipe( - Effect.map(([error, otherArgs, map2]) => - [error, otherArgs, merge(map1, Arr.fromIterable(map2))] as [ - Option.Option, - ReadonlyArray, - HashMap.HashMap> - ] - ) + const tail = Arr.tailNonEmpty(input) + return matchOptions(tail, options, config).pipe( + Effect.map(([error, commandArgs, map2]) => succeed(error, Arr.prepend(commandArgs, head), map2)), + Effect.flatten ) - }), - Effect.catchAll((e) => - Effect.succeed([Option.some(e), input, HashMap.empty()] as [ - Option.Option, - ReadonlyArray, - HashMap.HashMap> - ]) + } + return matchOptions(otherArgs, otherOptions, config).pipe( + Effect.map(([error, otherArgs, map2]) => succeed(error, otherArgs, merge(map1, Arr.fromIterable(map2)))), + Effect.flatten ) - ) + }), + Effect.catchAll((e) => succeed(Option.some(e), input, HashMap.empty())) + ) +} + +/** + * A permissive variant of `matchOptions` that always treats the head token as a + * command argument when it does not match any known option (even if it begins + * with `-`), and continues scanning the remaining tokens for options. + */ +const matchOptionsPermissive = ( + input: ReadonlyArray, + options: ReadonlyArray, + config: CliConfig.CliConfig +): Effect.Effect => { + const succeed = ( + error: Option.Option, + commandArgs: ReadonlyArray, + matchedOptions: HashMap.HashMap> + ): Effect.Effect => Effect.succeed([error, commandArgs, matchedOptions] as const) + + if (Arr.isEmptyReadonlyArray(input)) { + return succeed(Option.none(), Arr.empty(), HashMap.empty()) } - return Arr.isEmptyReadonlyArray(input) - ? Effect.succeed([Option.none(), Arr.empty(), HashMap.empty()] as [ - Option.Option, - ReadonlyArray, - HashMap.HashMap> - ]) - : Effect.succeed([Option.none(), input, HashMap.empty()] as [ - Option.Option, - ReadonlyArray, - HashMap.HashMap> - ]) + if (Arr.isEmptyReadonlyArray(options)) { + return succeed(Option.none(), input, HashMap.empty()) + } + + return findOptions(input, options, config).pipe( + Effect.catchTag("UnclusteredFlag", () => Effect.succeed([input, options, HashMap.empty()] as const)), + Effect.flatMap(([otherArgs, otherOptions, map1]) => { + if (HashMap.isEmpty(map1)) { + const head = Arr.headNonEmpty(input) + const tail = Arr.tailNonEmpty(input) + return matchOptionsPermissive(tail, options, config).pipe( + Effect.map(([error, commandArgs, map2]) => succeed(error, Arr.prepend(commandArgs, head), map2)), + Effect.flatten + ) + } + return matchOptionsPermissive(otherArgs, otherOptions, config).pipe( + Effect.map(([error, otherArgs, map2]) => succeed(error, otherArgs, merge(map1, Arr.fromIterable(map2)))), + Effect.flatten + ) + }), + Effect.catchAll((e) => succeed(Option.some(e), input, HashMap.empty())) + ) } /** diff --git a/packages/cli/test/CliApp.test.ts b/packages/cli/test/CliApp.test.ts index de5dc108ee6..c22b3942855 100644 --- a/packages/cli/test/CliApp.test.ts +++ b/packages/cli/test/CliApp.test.ts @@ -34,7 +34,7 @@ describe("CliApp", () => { const args = Array.make("node", "test.js", "--bar") const result = yield* Effect.flip(cli(args)) expect(result).toEqual(ValidationError.invalidValue(HelpDoc.p( - "Received unknown argument: '--bar'" + "Received unknown option: '--bar'" ))) }).pipe(runEffect)) diff --git a/packages/cli/test/CommandDescriptor.test.ts b/packages/cli/test/CommandDescriptor.test.ts index 297a21cfb91..1c45818fb4e 100644 --- a/packages/cli/test/CommandDescriptor.test.ts +++ b/packages/cli/test/CommandDescriptor.test.ts @@ -35,6 +35,18 @@ describe("Command", () => { expect(result2).toEqual(CommandDirective.userDefined(Array.empty(), expected2)) }).pipe(runEffect)) + it("should validate a command with arguments followed by options", () => + Effect.gen(function*() { + const args1 = Array.make("tail", "foo.log", "-n", "100") + const args2 = Array.make("grep", "fooBar", "--after", "2", "--before", "3") + const result1 = yield* Descriptor.parse(Tail.command, args1, CliConfig.defaultConfig) + const result2 = yield* Descriptor.parse(Grep.command, args2, CliConfig.defaultConfig) + const expected1 = { name: "tail", options: 100, args: "foo.log" } + const expected2 = { name: "grep", options: [2, 3], args: "fooBar" } + expect(result1).toEqual(CommandDirective.userDefined(Array.empty(), expected1)) + expect(result2).toEqual(CommandDirective.userDefined(Array.empty(), expected2)) + }).pipe(runEffect)) + it("should provide auto-correct suggestions for misspelled options", () => Effect.gen(function*() { const args1 = Array.make("grep", "--afte", "2", "--before", "3", "fooBar") @@ -54,13 +66,20 @@ describe("Command", () => { ))) }).pipe(runEffect)) + it("should treat unknown options as arguments but still parse later options", () => + Effect.gen(function*() { + const args = Array.make("grep", "fooBar", "--wat", "1", "--after", "2", "--before", "3") + const result = yield* Descriptor.parse(Grep.command, args, CliConfig.defaultConfig) + const expected = { name: "grep", options: [2, 3], args: "fooBar" } + expect(result).toEqual(CommandDirective.userDefined(Array.make("--wat", "1"), expected)) + }).pipe(runEffect)) + it("should return an error if an option is missing", () => Effect.gen(function*() { const args = Array.make("grep", "--a", "2", "--before", "3", "fooBar") const result = yield* Effect.flip(Descriptor.parse(Grep.command, args, CliConfig.defaultConfig)) - expect(result).toEqual(ValidationError.missingValue(HelpDoc.sequence( - HelpDoc.p("Expected to find option: '--after'"), - HelpDoc.p("Expected to find option: '--before'") + expect(result).toEqual(ValidationError.missingValue(HelpDoc.p( + "Expected to find option: '--after'" ))) }).pipe(runEffect)) })