Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-cli-unexpected-arguments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect": patch
---

Reject unexpected positional arguments left after command parsing, including values exceeding `Argument.variadic` maximum bounds.
56 changes: 51 additions & 5 deletions packages/effect/src/unstable/cli/CliError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -89,6 +89,7 @@ export type CliError =
| DuplicateOption
| MissingOption
| MissingArgument
| UnexpectedArgument
| InvalidValue
| UnknownSubcommand
| ShowHelp
Expand Down Expand Up @@ -307,6 +308,49 @@ export class MissingArgument extends Schema.TaggedErrorClass<MissingArgument>(
}
}

/**
* 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<UnexpectedArgument>(
`${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.
*
Expand Down Expand Up @@ -506,6 +550,7 @@ export const NonShowHelpErrors: Schema.Union<
typeof DuplicateOption,
typeof MissingOption,
typeof MissingArgument,
typeof UnexpectedArgument,
typeof InvalidValue,
typeof UnknownSubcommand,
typeof UserError
Expand All @@ -515,6 +560,7 @@ export const NonShowHelpErrors: Schema.Union<
DuplicateOption,
MissingOption,
MissingArgument,
UnexpectedArgument,
InvalidValue,
UnknownSubcommand,
UserError
Expand Down
2 changes: 1 addition & 1 deletion packages/effect/src/unstable/cli/Command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -852,7 +852,7 @@ export const withSharedFlags: {
type NextInput = Simplify<Input & SharedInput>
type NextContextInput = Simplify<ContextInput & SharedInput>

const parseShared = makeParser(sharedConfig) as (
const parseShared = makeParser(sharedConfig, { allowLeftovers: true }) as (
input: ParsedTokens
) => Effect.Effect<SharedInput, CliError.CliError, Environment>

Expand Down
18 changes: 13 additions & 5 deletions packages/effect/src/unstable/cli/internal/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ export const makeCommand = <const Name extends string, Input, E, R, ContextInput
: Effect.fail(new CliError.ShowHelp({ commandPath, errors: [] }))

const parse = options.parse ?? makeParser(config) as any
const parseContext = options.parseContext ?? makeParser(contextConfig) as any
const parseContext = options.parseContext ?? makeParser(contextConfig, { allowLeftovers: true }) as any

const buildHelpDoc = (commandPath: ReadonlyArray<string>): HelpDoc => {
const args: Array<ArgDoc> = []
Expand Down Expand Up @@ -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)
})

Expand All @@ -275,7 +283,7 @@ export const makeParser = (cfg: ConfigInternal) =>
* representations.
*/
const parseParams: (parsedArgs: Param.ParsedArgs, params: ReadonlyArray<Param.Any>) => Effect.Effect<
ReadonlyArray<unknown>,
readonly [remainingArguments: ReadonlyArray<string>, values: ReadonlyArray<unknown>],
CliError.CliError,
Environment
> = Effect.fnUntraced(function*(parsedArgs, params) {
Expand All @@ -291,7 +299,7 @@ const parseParams: (parsedArgs: Param.ParsedArgs, params: ReadonlyArray<Param.An
currentArguments = remainingArguments
}

return results
return [currentArguments, results] as const
})

/**
Expand Down
67 changes: 66 additions & 1 deletion packages/effect/test/unstable/cli/Errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// @effect-diagnostics floatingEffect:skip-file
import { assert, describe, it } from "@effect/vitest"
import { Effect, FileSystem, Layer, Path, Stdio } from "effect"
import { CliError, CliOutput, Command, Flag } from "effect/unstable/cli"
import { Argument, CliError, CliOutput, Command, Flag } from "effect/unstable/cli"
import { toImpl } from "effect/unstable/cli/internal/command"
import * as Lexer from "effect/unstable/cli/internal/lexer"
import * as Parser from "effect/unstable/cli/internal/parser"
Expand Down Expand Up @@ -114,6 +114,57 @@ describe("Command errors", () => {
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", () => {
Expand Down Expand Up @@ -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"`)
})
})
})
Loading