Skip to content
Closed
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
15 changes: 15 additions & 0 deletions .changeset/cli-boolean-flag-fallbacks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"effect": patch
---

Run `withFallbackConfig` and `withFallbackPrompt` for boolean flags that are absent from the command line.

Both fallbacks were driven by the `MissingOption` error, which a boolean flag never produces because it parses as `false` when absent. Applying either combinator to a `Flag.boolean` therefore did nothing, including in the documented example:

```ts
const verbose = Flag.boolean("verbose").pipe(
Flag.withFallbackConfig(Config.boolean("VERBOSE"))
)
```

The fallback now runs whenever the flag is absent, so `VERBOSE=true` is honored. An explicit `--verbose` or `--no-verbose` still wins, and `optional`/`withDefault` still supply the value before any fallback is consulted. When the config is missing the flag falls back to `false` as before; a cancelled prompt fails with `MissingOption`, matching the existing behavior for other flags.
79 changes: 69 additions & 10 deletions packages/effect/src/unstable/cli/Param.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1356,6 +1356,37 @@ export const withDefault: {
)
})

/**
* Returns the name of the boolean flag a param reads, if it reads exactly one
* and always parses it.
*
* An absent boolean flag parses as `false` instead of failing with
* `MissingOption`, so fallbacks that react to that error never run for one.
* Fallback combinators use this to detect the absent case themselves. Params
* wrapped by `optional` or `withDefault` already supply their own value, and
* alternatives introduced by `orElse` may provide the value instead, so neither
* takes part in a fallback.
*/
const booleanFlagName = <Kind extends ParamKind, A>(self: Param<Kind, A>): string | undefined =>
matchParam(self, {
Single: (single) => single.kind === "flag" && Primitive.isBoolean(single.primitiveType) ? single.name : undefined,
Map: (mapped) => booleanFlagName(mapped.param),
Transform: (transformed) => transformed.alternatives.length === 0 ? booleanFlagName(transformed.param) : undefined,
Optional: () => undefined,
Variadic: () => undefined
})

/**
* Checks whether a flag was provided on the command line.
*
* The parser records aliases and `--no-` negations under the canonical flag
* name, so an explicitly negated boolean flag counts as provided.
*/
const isFlagProvided = (args: ParsedArgs, name: string): boolean => {
const values = args.flags[name]
return values !== undefined && values.length > 0
}

/**
* Adds a fallback config that is loaded when a required parameter is missing.
*
Expand All @@ -1367,12 +1398,14 @@ export const withDefault: {
* **Details**
*
* Provided CLI values win. Config is loaded only after a missing option or
* missing argument error.
* missing argument error. A boolean flag is never missing because it parses as
* `false` when absent, so config is loaded whenever the flag is absent from the
* command line, and `--flag` or `--no-flag` still wins.
*
* **Gotchas**
*
* Missing config preserves the original missing-parameter error. Config parse
* failure becomes `CliError.InvalidValue`.
* Missing config preserves the original missing-parameter error, or `false` for
* a boolean flag. Config parse failure becomes `CliError.InvalidValue`.
*
* @see {@link withDefault} for a pure default value
* @see {@link withFallbackPrompt} for prompting interactively when input is missing
Expand All @@ -1397,20 +1430,36 @@ export const withFallbackConfig: {
expected: configError.message,
kind: error._tag === "MissingOption" ? "flag" : "argument"
})
const runConfig = (error: CliError.MissingOption | CliError.MissingArgument, args: ParsedArgs) =>
const runConfig = (
error: CliError.MissingOption | CliError.MissingArgument,
args: ParsedArgs,
onMissing: LazyArg<
Effect.Effect<readonly [leftover: ReadonlyArray<string>, value: A | B], CliError.CliError, Environment>
>
) =>
Config.option(config).pipe(
Effect.mapError((configError) => toInvalidValue(error, configError)),
Effect.flatMap(Option.match({
onNone: () => Effect.fail(error),
onNone: onMissing,
onSome: (value) => Effect.succeed([args.arguments, value as A | B] as const)
}))
)
const flagName = booleanFlagName(self)
return transform(
self,
(parse) => (args) =>
parse(args).pipe(
Effect.catchTag(["MissingOption", "MissingArgument"], (error) => runConfig(error, args))
(parse) => (args) => {
// A boolean flag parses as `false` when absent instead of failing, so the
// config has to be loaded before parsing rather than after it fails.
if (flagName !== undefined && !isFlagProvided(args, flagName)) {
return runConfig(new CliError.MissingOption({ option: flagName }), args, () => parse(args))
}
return parse(args).pipe(
Effect.catchTag(
["MissingOption", "MissingArgument"],
(error) => runConfig(error, args, () => Effect.fail(error))
)
)
}
)
})

Expand All @@ -1426,6 +1475,9 @@ export const withFallbackConfig: {
*
* `FallbackPrompt` accepts either a `Prompt` or an effect that builds one.
* Effectful prompt creation is lazy and runs only when the fallback is needed.
* A boolean flag is never missing because it parses as `false` when absent, so
* the prompt runs whenever the flag is absent from the command line, and
* `--flag` or `--no-flag` still wins.
*
* **Gotchas**
*
Expand All @@ -1451,12 +1503,19 @@ export const withFallbackPrompt: {
Effect.map((value) => [args.arguments, value as A | B] as const),
Effect.catchTag("QuitError", () => Effect.fail(error))
)
const flagName = booleanFlagName(self)
return transform(
self,
(parse) => (args) =>
parse(args).pipe(
(parse) => (args) => {
// A boolean flag parses as `false` when absent instead of failing, so the
// prompt has to run before parsing rather than after it fails.
if (flagName !== undefined && !isFlagProvided(args, flagName)) {
return runPrompt(new CliError.MissingOption({ option: flagName }), args)
}
return parse(args).pipe(
Effect.catchTag(["MissingOption", "MissingArgument"], (error) => runPrompt(error, args))
)
}
)
})

Expand Down
176 changes: 173 additions & 3 deletions packages/effect/test/unstable/cli/Param.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,19 +271,69 @@ describe("Param", () => {
assert.instanceOf(error, CliError.InvalidValue)
}).pipe(Effect.provide(TestLayer)))

it.effect("does not prompt for missing boolean flags", () =>
it.effect("prompts for boolean flags that are absent", () =>
Effect.gen(function*() {
const prompt = Prompt.text({ message: "Verbose" })
const prompt = Prompt.confirm({ message: "Verbose" })
const flag = Flag.boolean("verbose").pipe(Flag.withFallbackPrompt(prompt))

const [, value] = yield* flag.parse({
yield* MockTerminal.inputText("y")

const [remaining, value] = yield* flag.parse({
flags: {},
arguments: ["tail"]
})

assert.strictEqual(value, true)
assert.deepStrictEqual(remaining, ["tail"])
}).pipe(Effect.provide(TestLayer)))

it.effect("does not prompt for negated boolean flags", () =>
Effect.gen(function*() {
const prompt = Prompt.confirm({ message: "Verbose" })
const flag = Flag.boolean("verbose").pipe(Flag.withFallbackPrompt(prompt))

// `--no-verbose` is recorded under the canonical flag name
const [, value] = yield* flag.parse({
flags: { verbose: ["false"] },
arguments: []
})

assert.strictEqual(value, false)
}).pipe(Effect.provide(TestLayer)))

it.effect("prefers defaults over fallback prompts for boolean flags", () =>
Effect.gen(function*() {
const prompt = Prompt.confirm({ message: "Verbose" })
const flag = Flag.boolean("verbose").pipe(
Flag.withDefault(true),
Flag.withFallbackPrompt(prompt)
)

const [, value] = yield* flag.parse({
flags: {},
arguments: []
})

assert.strictEqual(value, true)
}).pipe(Effect.provide(TestLayer)))

it.effect("returns MissingOption when a boolean flag prompt is cancelled", () =>
Effect.gen(function*() {
const prompt = Prompt.confirm({ message: "Verbose" })
const flag = Flag.boolean("verbose").pipe(Flag.withFallbackPrompt(prompt))

yield* MockTerminal.inputKey("c", { ctrl: true })

const error = yield* Effect.flip(
flag.parse({
flags: {},
arguments: []
})
)

assert.instanceOf(error, CliError.MissingOption)
}).pipe(Effect.provide(TestLayer)))

it.effect("returns MissingOption when prompt is cancelled", () =>
Effect.gen(function*() {
const prompt = Prompt.text({ message: "Name" })
Expand Down Expand Up @@ -439,5 +489,125 @@ describe("Param", () => {
Effect.provide(TestLayer)
)
})

it.effect("uses ConfigProvider when a boolean flag is absent", () => {
const provider = ConfigProvider.fromEnv({
env: {
VERBOSE: "true"
}
})

return Effect.gen(function*() {
const flag = Flag.boolean("verbose").pipe(
Flag.withFallbackConfig(Config.boolean("VERBOSE"))
)

const [, value] = yield* flag.parse({
flags: {},
arguments: []
})

assert.strictEqual(value, true)
}).pipe(
Effect.provideService(ConfigProvider.ConfigProvider, provider),
Effect.provide(TestLayer)
)
})

it.effect("uses negated boolean flags before reading config fallbacks", () => {
const provider = ConfigProvider.fromEnv({
env: {
VERBOSE: "true"
}
})

return Effect.gen(function*() {
const flag = Flag.boolean("verbose").pipe(
Flag.withFallbackConfig(Config.boolean("VERBOSE"))
)

// `--no-verbose` is recorded under the canonical flag name
const [, value] = yield* flag.parse({
flags: { verbose: ["false"] },
arguments: []
})

assert.strictEqual(value, false)
}).pipe(
Effect.provideService(ConfigProvider.ConfigProvider, provider),
Effect.provide(TestLayer)
)
})

it.effect("returns false when a boolean flag is absent and config is missing", () => {
const provider = ConfigProvider.fromEnv({ env: {} })

return Effect.gen(function*() {
const flag = Flag.boolean("verbose").pipe(
Flag.withFallbackConfig(Config.boolean("VERBOSE"))
)

const [, value] = yield* flag.parse({
flags: {},
arguments: []
})

assert.strictEqual(value, false)
}).pipe(
Effect.provideService(ConfigProvider.ConfigProvider, provider),
Effect.provide(TestLayer)
)
})

it.effect("prefers defaults over config fallbacks for boolean flags", () => {
const provider = ConfigProvider.fromEnv({
env: {
VERBOSE: "false"
}
})

return Effect.gen(function*() {
const flag = Flag.boolean("verbose").pipe(
Flag.withDefault(true),
Flag.withFallbackConfig(Config.boolean("VERBOSE"))
)

const [, value] = yield* flag.parse({
flags: {},
arguments: []
})

assert.strictEqual(value, true)
}).pipe(
Effect.provideService(ConfigProvider.ConfigProvider, provider),
Effect.provide(TestLayer)
)
})

it.effect("returns InvalidValue when boolean config fails to parse", () => {
const provider = ConfigProvider.fromEnv({
env: {
VERBOSE: "nope"
}
})

return Effect.gen(function*() {
const flag = Flag.boolean("verbose").pipe(
Flag.withFallbackConfig(Config.boolean("VERBOSE"))
)

const error = yield* Effect.flip(
flag.parse({
flags: {},
arguments: []
})
)

assert.instanceOf(error, CliError.InvalidValue)
}).pipe(
Effect.provideService(ConfigProvider.ConfigProvider, provider),
Effect.provide(TestLayer)
)
})
})
})