Skip to content
Draft
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/soft-ears-judge.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion packages/cli/src/internal/cliApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)))
}
})
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/internal/commandDescriptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
169 changes: 126 additions & 43 deletions packages/cli/src/internal/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,53 @@
)
)

/**
* 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<string>,
config: CliConfig.CliConfig
) => <A>(
self: Options.Options<A>
) => Effect.Effect<
[Option.Option<ValidationError.ValidationError>, Array<string>, A],
ValidationError.ValidationError,
FileSystem.FileSystem | Path.Path | Terminal.Terminal
>,
<A>(
self: Options.Options<A>,
args: ReadonlyArray<string>,
config: CliConfig.CliConfig
) => Effect.Effect<
[Option.Option<ValidationError.ValidationError>, Array<string>, 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<string>, a as any])
)
)
)
)

/** @internal */
export const repeated = <A>(self: Options.Options<A>): Options.Options<Array<A>> =>
makeVariadic(self, Option.none(), Option.none())
Expand Down Expand Up @@ -1539,57 +1586,93 @@
* 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<ValidationError.ValidationError>,
ReadonlyArray<string>,
HashMap.HashMap<string, ReadonlyArray<string>>
]

const matchOptions = (
input: ReadonlyArray<string>,
options: ReadonlyArray<ParseableInstruction>,
config: CliConfig.CliConfig
): Effect.Effect<
[
Option.Option<ValidationError.ValidationError>,
ReadonlyArray<string>,
HashMap.HashMap<string, ReadonlyArray<string>>
]
> => {
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<ValidationError.ValidationError>,
ReadonlyArray<string>,
HashMap.HashMap<string, ReadonlyArray<string>>
])
): Effect.Effect<MatchOptionsResult> => {
const succeed = (
error: Option.Option<ValidationError.ValidationError>,
commandArgs: ReadonlyArray<string>,
matchedOptions: HashMap.HashMap<string, ReadonlyArray<string>>
): Effect.Effect<MatchOptionsResult> => 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)

Check failure on line 1616 in packages/cli/src/internal/options.ts

View workflow job for this annotation

GitHub Actions / Snapshot

Argument of type 'readonly string[]' is not assignable to parameter of type 'readonly [string, ...string[]]'.

Check failure on line 1616 in packages/cli/src/internal/options.ts

View workflow job for this annotation

GitHub Actions / Types

Argument of type 'readonly string[]' is not assignable to parameter of type 'readonly [string, ...string[]]'.

Check failure on line 1616 in packages/cli/src/internal/options.ts

View workflow job for this annotation

GitHub Actions / Types

Argument of type 'readonly string[]' is not assignable to parameter of type 'readonly [string, ...string[]]'.
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<ValidationError.ValidationError>,
ReadonlyArray<string>,
HashMap.HashMap<string, ReadonlyArray<string>>
]
)
const tail = Arr.tailNonEmpty(input)

Check failure on line 1620 in packages/cli/src/internal/options.ts

View workflow job for this annotation

GitHub Actions / Snapshot

Argument of type 'readonly string[]' is not assignable to parameter of type 'readonly [string, ...string[]]'.

Check failure on line 1620 in packages/cli/src/internal/options.ts

View workflow job for this annotation

GitHub Actions / Types

Argument of type 'readonly string[]' is not assignable to parameter of type 'readonly [string, ...string[]]'.

Check failure on line 1620 in packages/cli/src/internal/options.ts

View workflow job for this annotation

GitHub Actions / Types

Argument of type 'readonly string[]' is not assignable to parameter of type 'readonly [string, ...string[]]'.
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<ValidationError.ValidationError>,
ReadonlyArray<string>,
HashMap.HashMap<string, ReadonlyArray<string>>
])
}
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<string>,
options: ReadonlyArray<ParseableInstruction>,
config: CliConfig.CliConfig
): Effect.Effect<MatchOptionsResult> => {
const succeed = (
error: Option.Option<ValidationError.ValidationError>,
commandArgs: ReadonlyArray<string>,
matchedOptions: HashMap.HashMap<string, ReadonlyArray<string>>
): Effect.Effect<MatchOptionsResult> => 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<ValidationError.ValidationError>,
ReadonlyArray<string>,
HashMap.HashMap<string, ReadonlyArray<string>>
])
: Effect.succeed([Option.none(), input, HashMap.empty()] as [
Option.Option<ValidationError.ValidationError>,
ReadonlyArray<string>,
HashMap.HashMap<string, ReadonlyArray<string>>
])
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)

Check failure on line 1662 in packages/cli/src/internal/options.ts

View workflow job for this annotation

GitHub Actions / Snapshot

Argument of type 'readonly string[]' is not assignable to parameter of type 'readonly [string, ...string[]]'.

Check failure on line 1662 in packages/cli/src/internal/options.ts

View workflow job for this annotation

GitHub Actions / Types

Argument of type 'readonly string[]' is not assignable to parameter of type 'readonly [string, ...string[]]'.

Check failure on line 1662 in packages/cli/src/internal/options.ts

View workflow job for this annotation

GitHub Actions / Types

Argument of type 'readonly string[]' is not assignable to parameter of type 'readonly [string, ...string[]]'.
const tail = Arr.tailNonEmpty(input)

Check failure on line 1663 in packages/cli/src/internal/options.ts

View workflow job for this annotation

GitHub Actions / Snapshot

Argument of type 'readonly string[]' is not assignable to parameter of type 'readonly [string, ...string[]]'.

Check failure on line 1663 in packages/cli/src/internal/options.ts

View workflow job for this annotation

GitHub Actions / Types

Argument of type 'readonly string[]' is not assignable to parameter of type 'readonly [string, ...string[]]'.

Check failure on line 1663 in packages/cli/src/internal/options.ts

View workflow job for this annotation

GitHub Actions / Types

Argument of type 'readonly string[]' is not assignable to parameter of type 'readonly [string, ...string[]]'.
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()))
)
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/test/CliApp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
25 changes: 22 additions & 3 deletions packages/cli/test/CommandDescriptor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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))
})
Expand Down
Loading