Skip to content
Open
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
17 changes: 17 additions & 0 deletions .changeset/cli-optional-value.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@effect/cli": minor
---

Add `withOptionalValue` combinator to `@effect/cli` Options, allowing a
CLI option to be specified on the command line without a value. When the
flag is present but not followed by a value the provided `fallback` is
used; when a value is provided it is used as normal; when the flag is
absent entirely the option behaves as before (required unless wrapped with
`optional()` or `withDefault()`).

```ts
// --log-level → "info" (fallback)
// --log-level debug → "debug" (supplied value)
// (flag absent) → error: Expected to find option '--log-level'
const logLevel = Options.text("log-level").pipe(Options.withOptionalValue("info"))
```
25 changes: 25 additions & 0 deletions packages/cli/src/Options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -556,6 +556,31 @@ export const withPseudoName: {
<A>(self: Options<A>, pseudoName: string): Options<A>
} = InternalOptions.withPseudoName

/**
* Allows the option to be specified on the command line without a value, in
* which case `fallback` is used. When the option is followed by a value that
* value is used as normal. The option itself must still appear on the command
* line unless combined with `.optional()` or `.withDefault()`.
*
* **Example**
*
* ```ts
* import * as Options from "@effect/cli/Options"
*
* // --log-level → "info" (fallback)
* // --log-level debug → "debug" (supplied value)
* // (flag absent) → error: Expected to find option: '--log-level'
* const logLevel = Options.text("log-level").pipe(Options.withOptionalValue("info"))
* ```
*
* @since 1.0.0
* @category combinators
*/
export const withOptionalValue: {
<A>(fallback: A): (self: Options<A>) => Options<A>
<A>(self: Options<A>, fallback: A): Options<A>
} = InternalOptions.withOptionalValue

/**
* @since 1.0.0
* @category combinators
Expand Down
46 changes: 41 additions & 5 deletions packages/cli/src/internal/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export interface Single extends
readonly primitiveType: Primitive.Primitive<unknown>
readonly description: HelpDoc.HelpDoc
readonly pseudoName: Option.Option<string>
readonly optionalValueDefault: Option.Option<unknown>
}>
{}

Expand Down Expand Up @@ -589,7 +590,8 @@ export const withAlias = dual<
aliases,
single.primitiveType,
single.description,
single.pseudoName
single.pseudoName,
single.optionalValueDefault
) as Single
}))

Expand Down Expand Up @@ -645,7 +647,8 @@ export const withDescription = dual<
single.aliases,
single.primitiveType,
description,
single.pseudoName
single.pseudoName,
single.optionalValueDefault
) as Single
}))

Expand All @@ -660,7 +663,23 @@ export const withPseudoName = dual<
single.aliases,
single.primitiveType,
single.description,
Option.some(pseudoName)
Option.some(pseudoName),
single.optionalValueDefault
) as Single))

/** @internal */
export const withOptionalValue = dual<
<A>(fallback: A) => (self: Options.Options<A>) => Options.Options<A>,
<A>(self: Options.Options<A>, fallback: A) => Options.Options<A>
>(2, (self, fallback) =>
modifySingle(self as Instruction, (single) =>
makeSingle(
single.name,
single.aliases,
single.primitiveType,
single.description,
single.pseudoName,
Option.some(fallback as unknown)
) as Single))

/** @internal */
Expand Down Expand Up @@ -926,7 +945,10 @@ const getUsageInternal = (self: Instruction): Usage.Usage => {
InternalPrimitive.getChoices(self.primitiveType),
() => Option.some(self.placeholder)
)
return InternalUsage.named(getNames(self), acceptedValues)
const displayValues = Option.isSome(self.optionalValueDefault)
? Option.map(acceptedValues, (v) => `[${v}]`)
: acceptedValues
return InternalUsage.named(getNames(self), displayValues)
}
case "KeyValueMap": {
return getUsageInternal(self.argumentOption as Instruction)
Expand Down Expand Up @@ -1022,7 +1044,8 @@ const makeSingle = <A>(
aliases: ReadonlyArray<string>,
primitiveType: Primitive.Primitive<A>,
description: HelpDoc.HelpDoc = InternalHelpDoc.empty,
pseudoName: Option.Option<string> = Option.none()
pseudoName: Option.Option<string> = Option.none(),
optionalValueDefault: Option.Option<unknown> = Option.none()
): Options.Options<A> => {
const op = Object.create(proto)
op._tag = "Single"
Expand All @@ -1033,6 +1056,7 @@ const makeSingle = <A>(
op.primitiveType = primitiveType
op.description = description
op.pseudoName = pseudoName
op.optionalValueDefault = optionalValueDefault
return op
}

Expand Down Expand Up @@ -1202,6 +1226,9 @@ const parseInternal = (
const tail = Arr.tailNonEmpty(singleNames)
if (Arr.isEmptyReadonlyArray(tail)) {
if (Arr.isEmptyReadonlyArray(head)) {
if (Option.isSome(self.optionalValueDefault)) {
return Effect.succeed(self.optionalValueDefault.value)
}
return InternalPrimitive.validate(self.primitiveType, Option.none(), config).pipe(
Effect.mapError((e) => InternalValidationError.invalidValue(InternalHelpDoc.p(e)))
)
Expand Down Expand Up @@ -1761,6 +1788,10 @@ const parseCommandLine = (
}
return Arr.matchLeft(tail, {
onEmpty: () => {
if (Option.isSome(self.optionalValueDefault)) {
const parsed = Option.some({ name: head, values: Arr.empty() })
return Effect.succeed<ParsedCommandLine>({ parsed, leftover: tail })
}
const error = InternalHelpDoc.p(
`Expected a value following option: '${self.fullName}'`
)
Expand Down Expand Up @@ -1856,6 +1887,11 @@ const parseCommandLine = (
}

if (afterOption.length === 0) {
if (Option.isSome(self.optionalValueDefault)) {
const parsed = Option.some({ name: optionName, values: Arr.empty() })
const leftover = Arr.appendAll(beforeOption, afterOption)
return Effect.succeed<ParsedCommandLine>({ parsed, leftover })
}
const error = InternalHelpDoc.p(
`Expected a value following option: '${self.fullName}'`
)
Expand Down
113 changes: 113 additions & 0 deletions packages/cli/test/Options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -785,4 +785,117 @@ describe("Options", () => {
expect(result).toEqual([["positional"], HashMap.make(["key1", "value1"], ["key2", "value2"])])
}).pipe(runEffect))
})

describe("withOptionalValue", () => {
const level = Options.text("level").pipe(Options.withOptionalValue("info"))

it("uses the supplied value when the flag is followed by a value", () =>
Effect.gen(function*() {
const result = yield* process(level, ["--level", "debug"], CliConfig.defaultConfig)
expect(result).toEqual([[], "debug"])
}).pipe(runEffect))

it("uses the fallback when the flag is present with no value", () =>
Effect.gen(function*() {
const result = yield* process(level, ["--level"], CliConfig.defaultConfig)
expect(result).toEqual([[], "info"])
}).pipe(runEffect))

it("fails when the flag is absent", () =>
Effect.gen(function*() {
const result = yield* Effect.flip(process(level, [], CliConfig.defaultConfig))
expect(result).toEqual(ValidationError.missingValue(HelpDoc.p("Expected to find option: '--level'")))
}).pipe(runEffect))

it("uses the fallback when the flag appears after other args with no trailing value", () =>
Effect.gen(function*() {
const options = Options.all([Options.text("host"), level])
const result = yield* process(
options,
["--host", "localhost", "--level"],
CliConfig.defaultConfig
)
expect(result).toEqual([[], ["localhost", "info"]])
}).pipe(runEffect))

it("uses the supplied value when the flag appears after other args", () =>
Effect.gen(function*() {
const options = Options.all([Options.text("host"), level])
const result = yield* process(
options,
["--host", "localhost", "--level", "warn"],
CliConfig.defaultConfig
)
expect(result).toEqual([[], ["localhost", "warn"]])
}).pipe(runEffect))

it("composes with optional() — flag present no value → Some(fallback), absent → None", () =>
Effect.gen(function*() {
const levelOpt = Options.optional(level)
const result1 = yield* process(levelOpt, ["--level"], CliConfig.defaultConfig)
const result2 = yield* process(levelOpt, [], CliConfig.defaultConfig)
const result3 = yield* process(levelOpt, ["--level", "warn"], CliConfig.defaultConfig)
expect(result1).toEqual([[], Option.some("info")])
expect(result2).toEqual([[], Option.none()])
expect(result3).toEqual([[], Option.some("warn")])
}).pipe(runEffect))

it("composes with withDefault() — flag present no value → fallback, absent → withDefault value", () =>
Effect.gen(function*() {
const levelDefault = level.pipe(Options.withDefault("warn"))
const result1 = yield* process(levelDefault, ["--level"], CliConfig.defaultConfig)
const result2 = yield* process(levelDefault, [], CliConfig.defaultConfig)
expect(result1).toEqual([[], "info"])
expect(result2).toEqual([[], "warn"])
}).pipe(runEffect))

it("preserves aliases applied before withOptionalValue", () =>
Effect.gen(function*() {
const levelAliased = Options.text("level").pipe(
Options.withAlias("l"),
Options.withOptionalValue("info")
)
const result = yield* process(levelAliased, ["-l"], CliConfig.defaultConfig)
expect(result).toEqual([[], "info"])
}).pipe(runEffect))

it("preserves aliases applied after withOptionalValue", () =>
Effect.gen(function*() {
const levelAliased = Options.text("level").pipe(
Options.withOptionalValue("info"),
Options.withAlias("l")
)
const result = yield* process(levelAliased, ["-l"], CliConfig.defaultConfig)
expect(result).toEqual([[], "info"])
}).pipe(runEffect))

it("uses --flag=value syntax normally", () =>
Effect.gen(function*() {
const result = yield* process(level, ["--level=debug"], CliConfig.defaultConfig)
expect(result).toEqual([[], "debug"])
}).pipe(runEffect))

it("works with choice options (canonical use case from the feature request)", () =>
Effect.gen(function*() {
const watch = Options.choice("watch", ["first-failure", "until-done"] as const).pipe(
Options.withOptionalValue("first-failure" as const),
Options.optional
)
const result1 = yield* process(watch, ["--watch"], CliConfig.defaultConfig)
const result2 = yield* process(watch, ["--watch", "until-done"], CliConfig.defaultConfig)
const result3 = yield* process(watch, [], CliConfig.defaultConfig)
expect(result1).toEqual([[], Option.some("first-failure")])
expect(result2).toEqual([[], Option.some("until-done")])
expect(result3).toEqual([[], Option.none()])
}).pipe(runEffect))

it("works with integer options", () =>
Effect.gen(function*() {
const count = Options.integer("count").pipe(Options.withOptionalValue(1))
const result1 = yield* process(count, ["--count"], CliConfig.defaultConfig)
const result2 = yield* process(count, ["--count", "5"], CliConfig.defaultConfig)
expect(result1).toEqual([[], 1])
expect(result2).toEqual([[], 5])
}).pipe(runEffect))
})
})
Loading