diff --git a/.changeset/fix-cli-unexpected-arguments.md b/.changeset/fix-cli-unexpected-arguments.md new file mode 100644 index 00000000000..2f6b775cd93 --- /dev/null +++ b/.changeset/fix-cli-unexpected-arguments.md @@ -0,0 +1,5 @@ +--- +"effect": patch +--- + +Reject unexpected positional arguments left after command parsing, including values exceeding `Argument.variadic` maximum bounds. diff --git a/packages/effect/src/unstable/cli/CliError.ts b/packages/effect/src/unstable/cli/CliError.ts index 2ff929399d3..11127053b10 100644 --- a/packages/effect/src/unstable/cli/CliError.ts +++ b/packages/effect/src/unstable/cli/CliError.ts @@ -2,11 +2,11 @@ * Defines structured errors for the unstable CLI parser and runner. * * CLI errors describe problems such as unknown or duplicate flags, missing - * flags or arguments, invalid values, unknown subcommands, user handler - * failures, and requests to show command help. This module includes the - * `CliError` union, the `isCliError` guard, schema-backed error classes with - * display messages, and the `NonShowHelpErrors` union used when parse or - * validation errors should be shown with help output. + * flags or arguments, unexpected positional arguments, invalid values, unknown + * subcommands, user handler failures, and requests to show command help. This + * module includes the `CliError` union, the `isCliError` guard, schema-backed + * error classes with display messages, and the `NonShowHelpErrors` union used + * when parse or validation errors should be shown with help output. * * @since 4.0.0 */ @@ -89,6 +89,7 @@ export type CliError = | DuplicateOption | MissingOption | MissingArgument + | UnexpectedArgument | InvalidValue | UnknownSubcommand | ShowHelp @@ -307,6 +308,49 @@ export class MissingArgument extends Schema.TaggedErrorClass( } } +/** + * Error thrown when positional arguments remain after a command has parsed all + * of its parameters. + * + * **Example** (Reporting unexpected arguments) + * + * ```ts + * import { CliError } from "effect/unstable/cli" + * + * const error = new CliError.UnexpectedArgument({ + * arguments: ["extra.txt"] + * }) + * + * console.log(error.message) + * // "Unexpected positional argument: \"extra.txt\"" + * ``` + * + * @category models + * @since 4.0.0 + */ +export class UnexpectedArgument extends Schema.TaggedErrorClass( + `${TypeId}/UnexpectedArgument` +)("UnexpectedArgument", { + arguments: Schema.Array(Schema.String) +}) { + /** + * Marks this value as an unexpected CLI argument error for runtime guards. + * + * @since 4.0.0 + */ + readonly [TypeId] = TypeId + + /** + * Formats the unexpected positional arguments for display. + * + * @since 4.0.0 + */ + override get message() { + const label = this.arguments.length === 1 ? "argument" : "arguments" + return `Unexpected positional ${label}: ${this.arguments.map((value) => JSON.stringify(value)).join(", ")}` + } +} + /** * Error thrown when an option or argument value is invalid. * @@ -506,6 +550,7 @@ export const NonShowHelpErrors: Schema.Union< typeof DuplicateOption, typeof MissingOption, typeof MissingArgument, + typeof UnexpectedArgument, typeof InvalidValue, typeof UnknownSubcommand, typeof UserError @@ -515,6 +560,7 @@ export const NonShowHelpErrors: Schema.Union< DuplicateOption, MissingOption, MissingArgument, + UnexpectedArgument, InvalidValue, UnknownSubcommand, UserError diff --git a/packages/effect/src/unstable/cli/Command.ts b/packages/effect/src/unstable/cli/Command.ts index bf375ebb5a7..79cd0a9b0e5 100644 --- a/packages/effect/src/unstable/cli/Command.ts +++ b/packages/effect/src/unstable/cli/Command.ts @@ -852,7 +852,7 @@ export const withSharedFlags: { type NextInput = Simplify type NextContextInput = Simplify - const parseShared = makeParser(sharedConfig) as ( + const parseShared = makeParser(sharedConfig, { allowLeftovers: true }) as ( input: ParsedTokens ) => Effect.Effect diff --git a/packages/effect/src/unstable/cli/internal/command.ts b/packages/effect/src/unstable/cli/internal/command.ts index b35279b1c59..53ab146d33a 100644 --- a/packages/effect/src/unstable/cli/internal/command.ts +++ b/packages/effect/src/unstable/cli/internal/command.ts @@ -125,7 +125,7 @@ export const makeCommand = ): HelpDoc => { const args: Array = [] @@ -263,10 +263,18 @@ const appendChoiceKeys = ( * and `parseContext`, and also by `withSharedFlags` to avoid constructing a * full throwaway command. */ -export const makeParser = (cfg: ConfigInternal) => +export const makeParser = ( + cfg: ConfigInternal, + options?: { + readonly allowLeftovers?: boolean | undefined + } | undefined +) => Effect.fnUntraced(function*(input: ParsedTokens) { const parsedArgs: Param.ParsedArgs = { flags: input.flags, arguments: input.arguments } - const values = yield* parseParams(parsedArgs, cfg.orderedParams) + const [remainingArguments, values] = yield* parseParams(parsedArgs, cfg.orderedParams) + if (options?.allowLeftovers !== true && remainingArguments.length > 0) { + return yield* new CliError.UnexpectedArgument({ arguments: remainingArguments }) + } return reconstructTree(cfg.tree, values) }) @@ -275,7 +283,7 @@ export const makeParser = (cfg: ConfigInternal) => * representations. */ const parseParams: (parsedArgs: Param.ParsedArgs, params: ReadonlyArray) => Effect.Effect< - ReadonlyArray, + readonly [remainingArguments: ReadonlyArray, values: ReadonlyArray], CliError.CliError, Environment > = Effect.fnUntraced(function*(parsedArgs, params) { @@ -291,7 +299,7 @@ const parseParams: (parsedArgs: Param.ParsedArgs, params: ReadonlyArray { assert.strictEqual(error.subcommand, "deplyo") assert.isTrue(error.suggestions.includes("deploy")) }).pipe(Effect.provide(TestLayer))) + + it.effect("fails with UnexpectedArgument when a bounded variadic leaves operands", () => + Effect.gen(function*() { + const command = Command.make("test", { + values: Argument.string("value").pipe(Argument.variadic({ max: 2 })) + }) + + const parsedInput = yield* Parser.parseArgs( + Lexer.lex(["one", "two", "three"]), + command + ) + const error = yield* Effect.flip(toImpl(command).parse(parsedInput)) + + assert.instanceOf(error, CliError.UnexpectedArgument) + assert.deepStrictEqual(error.arguments, ["three"]) + }).pipe(Effect.provide(TestLayer))) + + it.effect("allows a bounded variadic to leave an operand for a following argument", () => + Effect.gen(function*() { + const command = Command.make("test", { + values: Argument.string("value").pipe(Argument.variadic({ max: 2 })), + destination: Argument.string("destination") + }) + + const parsedInput = yield* Parser.parseArgs( + Lexer.lex(["one", "two", "destination"]), + command + ) + const result = yield* toImpl(command).parse(parsedInput) + + assert.deepStrictEqual(result, { + values: ["one", "two"], + destination: "destination" + }) + }).pipe(Effect.provide(TestLayer))) + + it.effect("fails with UnexpectedArgument when a fixed argument leaves operands", () => + Effect.gen(function*() { + const command = Command.make("test", { + value: Argument.string("value") + }) + + const parsedInput = yield* Parser.parseArgs( + Lexer.lex(["one", "two"]), + command + ) + const error = yield* Effect.flip(toImpl(command).parse(parsedInput)) + + assert.instanceOf(error, CliError.UnexpectedArgument) + assert.deepStrictEqual(error.arguments, ["two"]) + }).pipe(Effect.provide(TestLayer))) }) describe("formatErrors", () => { @@ -203,4 +254,18 @@ describe("Command errors", () => { ) }) }) + + describe("UnexpectedArgument", () => { + it("formats one or more unexpected positional arguments", () => { + const single = new CliError.UnexpectedArgument({ + arguments: ["extra"] + }) + const multiple = new CliError.UnexpectedArgument({ + arguments: ["first", "second"] + }) + + assert.strictEqual(single.message, `Unexpected positional argument: "extra"`) + assert.strictEqual(multiple.message, `Unexpected positional arguments: "first", "second"`) + }) + }) })