diff --git a/.changeset/cli-optional-value.md b/.changeset/cli-optional-value.md new file mode 100644 index 00000000000..1d7112ac4b0 --- /dev/null +++ b/.changeset/cli-optional-value.md @@ -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")) +``` diff --git a/packages/cli/src/Options.ts b/packages/cli/src/Options.ts index 4a01a4a8f8d..c1823ed31f0 100644 --- a/packages/cli/src/Options.ts +++ b/packages/cli/src/Options.ts @@ -556,6 +556,31 @@ export const withPseudoName: { (self: Options, pseudoName: string): Options } = 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: { + (fallback: A): (self: Options) => Options + (self: Options, fallback: A): Options +} = InternalOptions.withOptionalValue + /** * @since 1.0.0 * @category combinators diff --git a/packages/cli/src/internal/options.ts b/packages/cli/src/internal/options.ts index d6f5207f006..f9ed1db3296 100644 --- a/packages/cli/src/internal/options.ts +++ b/packages/cli/src/internal/options.ts @@ -88,6 +88,7 @@ export interface Single extends readonly primitiveType: Primitive.Primitive readonly description: HelpDoc.HelpDoc readonly pseudoName: Option.Option + readonly optionalValueDefault: Option.Option }> {} @@ -589,7 +590,8 @@ export const withAlias = dual< aliases, single.primitiveType, single.description, - single.pseudoName + single.pseudoName, + single.optionalValueDefault ) as Single })) @@ -645,7 +647,8 @@ export const withDescription = dual< single.aliases, single.primitiveType, description, - single.pseudoName + single.pseudoName, + single.optionalValueDefault ) as Single })) @@ -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< + (fallback: A) => (self: Options.Options) => Options.Options, + (self: Options.Options, fallback: A) => Options.Options +>(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 */ @@ -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) @@ -1022,7 +1044,8 @@ const makeSingle = ( aliases: ReadonlyArray, primitiveType: Primitive.Primitive, description: HelpDoc.HelpDoc = InternalHelpDoc.empty, - pseudoName: Option.Option = Option.none() + pseudoName: Option.Option = Option.none(), + optionalValueDefault: Option.Option = Option.none() ): Options.Options => { const op = Object.create(proto) op._tag = "Single" @@ -1033,6 +1056,7 @@ const makeSingle = ( op.primitiveType = primitiveType op.description = description op.pseudoName = pseudoName + op.optionalValueDefault = optionalValueDefault return op } @@ -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))) ) @@ -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({ parsed, leftover: tail }) + } const error = InternalHelpDoc.p( `Expected a value following option: '${self.fullName}'` ) @@ -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({ parsed, leftover }) + } const error = InternalHelpDoc.p( `Expected a value following option: '${self.fullName}'` ) diff --git a/packages/cli/test/Options.test.ts b/packages/cli/test/Options.test.ts index e31383a0481..820574f5dd2 100644 --- a/packages/cli/test/Options.test.ts +++ b/packages/cli/test/Options.test.ts @@ -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)) + }) })