From 2bae5c7ed1ca7af4a3ca94923944e28c3be6d86d Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Sat, 4 Nov 2023 11:20:05 -0400 Subject: [PATCH] refactor(@molt/command): modular types (#242) --- .../src/CommandParameter/CommandParameter.ts | 11 +- .../command/src/CommandParameter/transform.ts | 50 ---- .../command/src/CommandParameter/validate.ts | 26 -- .../@molt/command/src/CommandParameter/zod.ts | 24 +- packages/@molt/command/src/Errors/Errors.ts | 20 +- packages/@molt/command/src/Help/Help.ts | 76 +----- .../OpeningArgs/Environment/Environment.ts | 16 +- .../command/src/OpeningArgs/Line/Line.ts | 28 ++- .../command/src/OpeningArgs/OpeningArgs.ts | 62 ++--- .../@molt/command/src/OpeningArgs/helpers.ts | 95 ++------ .../@molt/command/src/OpeningArgs/types.ts | 21 +- packages/@molt/command/src/Type/Type.ts | 181 +------------- packages/@molt/command/src/Type/helpers.ts | 33 +++ .../command/src/Type/types/Scalars/Boolean.ts | 73 +++++- .../src/Type/types/Scalars/Enumeration.ts | 83 ++++++- .../command/src/Type/types/Scalars/Literal.ts | 55 ++++- .../command/src/Type/types/Scalars/Number.ts | 98 +++++++- .../command/src/Type/types/Scalars/String.ts | 197 ++++++++++++++- .../@molt/command/src/Type/types/Union.ts | 134 +++++++++- .../@molt/command/src/TypeAdaptors/zod/zod.ts | 229 +++++++++--------- .../command/src/TypeBuilder/TypeBuilder.ts | 17 ++ .../command/src/TypeBuilder/extension.ts | 21 ++ .../@molt/command/src/TypeBuilder/index.ts | 1 + .../TypeBuilderExtensions/string/extension.ts | 7 + .../src/TypeBuilderExtensions/string/type.ts | 64 +++++ .../command/src/TypeBuilderExtensions/zod.ts | 7 + packages/@molt/command/src/eventPatterns.ts | 4 +- packages/@molt/command/src/executor/parse.ts | 10 +- packages/@molt/command/src/executor/prompt.ts | 18 +- packages/@molt/command/src/helpers.ts | 16 +- .../src/lib/Prompter/Constructors/_core.ts | 2 +- .../command/src/lib/Prompter/Input/_core.ts | 42 ---- .../src/lib/Prompter/Input/types/boolean.ts | 44 ---- .../lib/Prompter/Input/types/enumeration.ts | 53 ---- .../src/lib/Prompter/Input/types/index.ts | 5 - .../src/lib/Prompter/Input/types/number.ts | 38 --- .../src/lib/Prompter/Input/types/string.ts | 36 --- .../src/lib/Prompter/Input/types/union.ts | 81 ------- .../@molt/command/src/lib/Prompter/input.ts | 22 ++ .../help/__snapshots__/rendering.spec.ts.snap | 80 +++--- .../prompt/__snapshots__/prompt.spec.ts.snap | 12 +- .../@molt/command/tests/prompt/prompt.spec.ts | 2 +- 42 files changed, 1082 insertions(+), 1012 deletions(-) delete mode 100644 packages/@molt/command/src/CommandParameter/transform.ts delete mode 100644 packages/@molt/command/src/CommandParameter/validate.ts create mode 100644 packages/@molt/command/src/Type/helpers.ts create mode 100644 packages/@molt/command/src/TypeBuilder/TypeBuilder.ts create mode 100644 packages/@molt/command/src/TypeBuilder/extension.ts create mode 100644 packages/@molt/command/src/TypeBuilder/index.ts create mode 100644 packages/@molt/command/src/TypeBuilderExtensions/string/extension.ts create mode 100644 packages/@molt/command/src/TypeBuilderExtensions/string/type.ts create mode 100644 packages/@molt/command/src/TypeBuilderExtensions/zod.ts delete mode 100644 packages/@molt/command/src/lib/Prompter/Input/_core.ts delete mode 100644 packages/@molt/command/src/lib/Prompter/Input/types/boolean.ts delete mode 100644 packages/@molt/command/src/lib/Prompter/Input/types/enumeration.ts delete mode 100644 packages/@molt/command/src/lib/Prompter/Input/types/index.ts delete mode 100644 packages/@molt/command/src/lib/Prompter/Input/types/number.ts delete mode 100644 packages/@molt/command/src/lib/Prompter/Input/types/string.ts delete mode 100644 packages/@molt/command/src/lib/Prompter/Input/types/union.ts create mode 100644 packages/@molt/command/src/lib/Prompter/input.ts diff --git a/packages/@molt/command/src/CommandParameter/CommandParameter.ts b/packages/@molt/command/src/CommandParameter/CommandParameter.ts index 891fe2e7..afce7f7e 100644 --- a/packages/@molt/command/src/CommandParameter/CommandParameter.ts +++ b/packages/@molt/command/src/CommandParameter/CommandParameter.ts @@ -1,12 +1,17 @@ export * from './input.js' export * from './output.js' export * from './processor/process.js' -export * from './transform.js' export * from './types.js' -export * from './validate.js' import { stripeNegatePrefix } from '../helpers.js' import type { Type } from '../Type/index.js' +import type { ValidationResult } from '../Type/Type.js' import type { Output } from './output.js' +import { Either } from 'effect' + +export const validate = (parameter: Output.Basic, value: unknown): ValidationResult => { + if (parameter.optionality._tag === `optional` && value === undefined) return Either.right(value as T) + return parameter.type.validate(value) +} export const findByName = (name: string, specs: Output[]): null | Output => { for (const spec of specs) { @@ -57,7 +62,7 @@ export const hasName = (spec: Output, name: string): null | NameHit => { export const isOrHasType = (spec: Output, typeTag: Type.Type['_tag']): boolean => { return spec.type._tag === `TypeUnion` - ? spec.type.members.find((_) => _._tag === typeTag) !== undefined + ? (spec.type as Type.Union).members.find((_) => _._tag === typeTag) !== undefined : spec.type._tag === typeTag } diff --git a/packages/@molt/command/src/CommandParameter/transform.ts b/packages/@molt/command/src/CommandParameter/transform.ts deleted file mode 100644 index 3f8d8ddf..00000000 --- a/packages/@molt/command/src/CommandParameter/transform.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { casesExhausted, entries } from '../helpers.js' -import type { Output } from './output.js' -import { Alge } from 'alge' - -/** - * Apply transformations specific in the parameter. For example strings can be trimmed. - */ -export const transform = (spec: Output, value: T): T => { - return Alge.match(spec) - .Basic((spec) => transformBasic(spec, value)) - .Exclusive((spec) => transformExclusive(spec, value)) - .done() -} - -const transformBasic = (spec: Output.Basic, value: unknown): any => { - if (spec.type._tag === `TypeString`) { - if (typeof value === `string`) { - if (spec.type.transformations) { - entries(spec.type.transformations ?? {}).reduce((v, t) => { - return t[0] === `trim` - ? v.trim() - : t[0] === `toCase` - ? t[1] === `upper` - ? v.toUpperCase() - : v.toLowerCase() - : casesExhausted(t[0]) - }, value) - let value_ = value - if (spec.type.transformations?.trim) { - value_ = value_.trim() - } - if (spec.type.transformations?.toCase) { - if (spec.type.transformations.toCase === `upper`) { - value_ = value_.toUpperCase() - } else if (spec.type.transformations.toCase === `lower`) { - value_ = value_.toLowerCase() - } - } - return value_ - } - } - } - - return value -} - -const transformExclusive = (_spec: Output.Exclusive, _value: unknown): any => { - // todo do we need this? - return null as any -} diff --git a/packages/@molt/command/src/CommandParameter/validate.ts b/packages/@molt/command/src/CommandParameter/validate.ts deleted file mode 100644 index 270e8229..00000000 --- a/packages/@molt/command/src/CommandParameter/validate.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Type } from '../Type/index.js' -import type { Output } from './output.js' -import { Alge } from 'alge' -import { Either } from 'effect' - -export const validate = (spec: Output, value: T): Type.ValidationResult => { - return Alge.match(spec) - .Basic((spec) => validateBasic(spec, value)) - .Exclusive((spec) => validateExclusive(spec, value)) - .done() -} - -const validateBasic = (spec: Output.Basic, value: T): Type.ValidationResult => { - if (value === undefined) { - if (spec.optionality._tag === `required`) { - return Either.left({ value, errors: [`Value is undefined. A value is required.`] }) - } - return Either.right(value) - } - return Type.validate(spec.type, value) -} - -const validateExclusive = (_spec: Output.Exclusive, _value: T): Type.ValidationResult => { - // todo do we need this? - return null as any -} diff --git a/packages/@molt/command/src/CommandParameter/zod.ts b/packages/@molt/command/src/CommandParameter/zod.ts index 9cbaf3b6..26bab26c 100644 --- a/packages/@molt/command/src/CommandParameter/zod.ts +++ b/packages/@molt/command/src/CommandParameter/zod.ts @@ -1,10 +1,4 @@ -import { ZodHelpers } from '../lib/zodHelpers/index.js' -import { stripOptionalAndDefault } from '../lib/zodHelpers/index_.js' -import { z } from 'zod' - -export type SomeType = SomeBasicType | SomeUnionType - -export type SomeBasicParameterType = SomeBasicType | SomeUnionType +import type { z } from 'zod' type ZodEnumBase = z.ZodEnum<[string, ...string[]]> @@ -33,19 +27,3 @@ export type SomeBasicTypeScalar = | z.ZodLiteral | z.ZodLiteral | z.ZodLiteral - -export const isUnionType = (type: SomeBasicType | SomeUnionType): type is SomeUnionType => { - const type_ = stripOptionalAndDefault(type) - const isUnion = type_._def.typeName === z.ZodFirstPartyTypeKind.ZodUnion - return isUnion -} - -export const getUnionScalar = (zodType: SomeUnionType): SomeUnionTypeScalar => { - const type = ZodHelpers.stripOptionalAndDefault(zodType) - return type -} - -export const getBasicScalar = (zodType: SomeBasicType): SomeBasicTypeScalar => { - const type = ZodHelpers.stripOptionalAndDefault(zodType) - return type -} diff --git a/packages/@molt/command/src/Errors/Errors.ts b/packages/@molt/command/src/Errors/Errors.ts index 098ab34e..d37fcbdf 100644 --- a/packages/@molt/command/src/Errors/Errors.ts +++ b/packages/@molt/command/src/Errors/Errors.ts @@ -23,25 +23,25 @@ export namespace Global { export class ErrorDuplicateLineArg extends Error { public override name: 'ErrorDuplicateFlag' = `ErrorDuplicateFlag` - public spec: CommandParameter.Output - constructor(params: { spec: CommandParameter.Output; flagName: string }) { + public parameter: CommandParameter.Output + constructor(params: { parameter: CommandParameter.Output; flagName: string }) { const message = `The parameter "${params.flagName}" was passed an argument multiple times via flags.` super(message) - this.spec = params.spec + this.parameter = params.parameter } } export class ErrorDuplicateEnvArg extends Error { public override name: 'ErrorDuplicateEnvArg' = `ErrorDuplicateEnvArg` - public spec: CommandParameter.Output + public parameter: CommandParameter.Output public instances: { value: string; name: string; prefix: string | null }[] constructor(params: { - spec: CommandParameter.Output + parameter: CommandParameter.Output instances: { value: string; name: string; prefix: string | null }[] }) { - const message = `The parameter "${params.spec.name.canonical}" was passed an argument multiple times via different parameter aliases in the environment.` + const message = `The parameter "${params.parameter.name.canonical}" was passed an argument multiple times via different parameter aliases in the environment.` super(message) - this.spec = params.spec + this.parameter = params.parameter this.instances = params.instances } } @@ -59,10 +59,10 @@ export class ErrorFailedToGetDefaultArgument extends Error { export class ErrorMissingArgument extends Error { public override name: 'ErrorMissingArgument' = `ErrorMissingArgument` public spec: CommandParameter.Output - constructor(params: { spec: CommandParameter.Output }) { - const message = `Missing argument for flag "${params.spec.name.canonical}".` + constructor(params: { parameter: CommandParameter.Output }) { + const message = `Missing argument for flag "${params.parameter.name.canonical}".` super(message) - this.spec = params.spec + this.spec = params.parameter } } diff --git a/packages/@molt/command/src/Help/Help.ts b/packages/@molt/command/src/Help/Help.ts index 5d109b39..d2aa3fe4 100644 --- a/packages/@molt/command/src/Help/Help.ts +++ b/packages/@molt/command/src/Help/Help.ts @@ -4,7 +4,6 @@ import { Tex } from '../lib/Tex/index.js' import { Text } from '../lib/Text/index.js' import type { Settings } from '../Settings/index.js' import { Term } from '../term.js' -import type { Type } from '../Type/index.js' import chalk from 'chalk' import camelCase from 'lodash.camelcase' import snakeCase from 'lodash.snakecase' @@ -102,10 +101,7 @@ export const render = ( .rows([ ...basicAndUnionSpecsWithoutHelp.map((spec) => [ parameterName(spec), - Tex.block( - { maxWidth: 40, padding: { right: 9, bottom: 1 } }, - parameterTypeAndDescription(settings, spec), - ), + Tex.block({ maxWidth: 40, padding: { right: 9, bottom: 1 } }, spec.type.help(settings)), Tex.block({ maxWidth: 24 }, parameterDefault(spec)), ...(isEnvironmentEnabled ? [parameterEnvironment(spec, settings)] : []), ]), @@ -127,7 +123,7 @@ export const render = ( ], ...Object.values(mexGroup.parameters).map((spec) => [ parameterName(spec), - parameterTypeAndDescription(settings, spec), + spec.type.help(settings), parameterDefault(spec), ...(isEnvironmentEnabled ? [parameterEnvironment(spec, settings)] : []), ]), @@ -276,48 +272,6 @@ const parameterName = (spec: CommandParameter.Output) => { ) } -const parameterTypeAndDescription = (settings: Settings.Output, spec: CommandParameter.Output) => { - const t = spec.type - if (t._tag === `TypeUnion`) { - const unionMemberIcon = Term.colors.accent(`◒`) - const isOneOrMoreMembersWithDescription = t.members.some((_) => _.description !== null) - const isExpandedMode = - isOneOrMoreMembersWithDescription || settings.helpRendering.union.mode === `expandAlways` - const isExpandedModeViaForceSetting = isExpandedMode && !isOneOrMoreMembersWithDescription - if (isExpandedMode) { - const types = t.members.flatMap((m) => { - return Tex.block( - { - padding: { bottomBetween: isExpandedModeViaForceSetting ? 0 : 1 }, - border: { - left: (index) => - `${index === 0 ? unionMemberIcon : Term.colors.dim(Text.chars.borders.vertical)} `, - }, - }, - (__) => __.block(typeScalar(m)).block(m.description), - ) - }) - return Tex.block((__) => - __.block(Term.colors.dim(Text.chars.borders.leftTop + Text.chars.borders.horizontal + `union`)) - .block( - { padding: { bottom: 1 }, border: { left: `${Term.colors.dim(Text.chars.borders.vertical)} ` } }, - spec.description, - ) - .block(types) - .block(Term.colors.dim(Text.chars.borders.leftBottom + Text.chars.borders.horizontal)), - ) - } else { - const types = t.members.map((m) => typeTagsToTypeScriptName[m._tag]).join(` | `) - return Tex.block(($) => $.block(types).block(spec.description ?? null)) - } - } - - // const maybeZodEnum = ZodHelpers.getEnum(spec.zodType) - return Tex.block({ padding: { bottom: spec._tag === `Exclusive` ? 0 : 1 } }, ($) => - $.block(typeScalar(spec.type)).block(spec.description), - ) -} - const parameterEnvironment = (spec: CommandParameter.Output, settings: Settings.Output) => { return spec.environment?.enabled ? Term.colors.secondary(Text.chars.check) + @@ -338,32 +292,6 @@ const parameterEnvironment = (spec: CommandParameter.Output, settings: Settings. : Term.colors.dim(Text.chars.x) } -/** - * Render an enum type into a column. - */ -const typeEnum = (type: Type.Scalar.Enumeration) => { - const separator = Term.colors.accent(` ${Text.chars.pipe} `) - const members = Object.values(type.members) - const lines = members.map((member) => Term.colors.positive(String(member))).join(separator) - - // eslint-disable-next-line - return members.length > 1 ? lines : `${lines} ${Term.colors.dim(`(enum)`)}` -} - const title = (string: string) => { return Text.line(string.toUpperCase()) } - -const typeScalar = (type: Type.Type): string => { - if (type._tag === `TypeEnum`) return typeEnum(type) - return Term.colors.positive(typeTagsToTypeScriptName[type._tag]) -} - -const typeTagsToTypeScriptName = { - TypeLiteral: `literal`, - TypeUnion: `union`, - TypeString: `string`, - TypeNumber: `number`, - TypeEnum: `enum`, - TypeBoolean: `boolean`, -} diff --git a/packages/@molt/command/src/OpeningArgs/Environment/Environment.ts b/packages/@molt/command/src/OpeningArgs/Environment/Environment.ts index 33ef9921..cf7aa748 100644 --- a/packages/@molt/command/src/OpeningArgs/Environment/Environment.ts +++ b/packages/@molt/command/src/OpeningArgs/Environment/Environment.ts @@ -1,7 +1,7 @@ import { CommandParameter } from '../../CommandParameter/index.js' import { Errors } from '../../Errors/index.js' import type { Index, RequireField } from '../../lib/prelude.js' -import { parseRawInput } from '../helpers.js' +import { parseSerializedValue } from '../helpers.js' import type { EnvironmentArgumentReport } from '../types.js' import camelCase from 'lodash.camelcase' import snakecase from 'lodash.snakecase' @@ -32,15 +32,15 @@ export const parse = (environment: RawInputs, specs: CommandParameter.Output[]): ) for (const envar of envars) { - for (const spec of specsWithEnvironmentSupport) { - const match = checkInputMatch(envar, spec) + for (const parameter of specsWithEnvironmentSupport) { + const match = checkInputMatch(envar, parameter) // Case 1 if (!match) continue // Case 2 // Check for multiple envars pointing to the same parameter. - const report = result.reports[spec.name.canonical] + const report = result.reports[parameter.name.canonical] if (report) { const instance = { name: match.name, @@ -53,7 +53,7 @@ export const parse = (environment: RawInputs, specs: CommandParameter.Output[]): } else { report.errors.push( new Errors.ErrorDuplicateEnvArg({ - spec, + parameter, instances: [instance], }), ) @@ -62,9 +62,9 @@ export const parse = (environment: RawInputs, specs: CommandParameter.Output[]): } // Case 3 - const value = parseRawInput(match.nameWithNegation, match.value, spec) - result.reports[spec.name.canonical] = { - spec, + const value = parseSerializedValue(match.nameWithNegation, match.value, parameter) + result.reports[parameter.name.canonical] = { + parameter, value, errors: [], source: { diff --git a/packages/@molt/command/src/OpeningArgs/Line/Line.ts b/packages/@molt/command/src/OpeningArgs/Line/Line.ts index 7745d538..941e910f 100644 --- a/packages/@molt/command/src/OpeningArgs/Line/Line.ts +++ b/packages/@molt/command/src/OpeningArgs/Line/Line.ts @@ -2,7 +2,7 @@ import { CommandParameter } from '../../CommandParameter/index.js' import { Errors } from '../../Errors/index.js' import { stripeNegatePrefixLoose } from '../../helpers.js' import type { Index } from '../../lib/prelude.js' -import { isNegated, parseRawInput, stripeDashPrefix } from '../helpers.js' +import { isNegated, parseSerializedValue, stripeDashPrefix } from '../helpers.js' import type { ArgumentReport } from '../types.js' import camelCase from 'lodash.camelcase' @@ -21,7 +21,7 @@ interface ParsedInputs { * Parse line input into an intermediary representation that is suited to comparison against * the parameter specs. */ -export const parse = (rawLineInputs: RawInputs, specs: CommandParameter.Output[]): ParsedInputs => { +export const parse = (rawLineInputs: RawInputs, parameters: CommandParameter.Output[]): ParsedInputs => { const globalErrors: GlobalParseErrors[] = [] const rawLineInputsPrepared = rawLineInputs @@ -51,14 +51,14 @@ export const parse = (rawLineInputs: RawInputs, specs: CommandParameter.Output[] * If union with boolean or boolean then we interpret foo argument as being a boolean. * Otherwise it is an error. */ - if (CommandParameter.isOrHasType(pendingReport.spec, `TypeBoolean`)) { + if (CommandParameter.isOrHasType(pendingReport.parameter, `TypeBoolean`)) { pendingReport.value = { value: true, _tag: `boolean`, negated: isNegated(camelCase(pendingReport.source.name)), } } else { - pendingReport.errors.push(new Errors.ErrorMissingArgument({ spec: pendingReport.spec })) + pendingReport.errors.push(new Errors.ErrorMissingArgument({ parameter: pendingReport.parameter })) } } } @@ -75,20 +75,20 @@ export const parse = (rawLineInputs: RawInputs, specs: CommandParameter.Output[] const flagNameNoDashPrefix = stripeDashPrefix(rawLineInput) const flagNameNoDashPrefixCamel = camelCase(flagNameNoDashPrefix) const flagNameNoDashPrefixNoNegate = stripeNegatePrefixLoose(flagNameNoDashPrefixCamel) - const spec = CommandParameter.findByName(flagNameNoDashPrefixCamel, specs) - if (!spec) { + const parameter = CommandParameter.findByName(flagNameNoDashPrefixCamel, parameters) + if (!parameter) { globalErrors.push(new Errors.Global.ErrorUnknownFlag({ flagName: flagNameNoDashPrefixNoNegate })) continue } - const existing = reports[spec.name.canonical] + const existing = reports[parameter.name.canonical] if (existing) { // TODO Handle once we support multiple values (arrays). // TODO richer structured info about the duplication. For example if // duplicated across aliases, make it easy to report a nice message explaining that. existing.errors.push( new Errors.ErrorDuplicateLineArg({ - spec, + parameter, flagName: flagNameNoDashPrefixNoNegate, }), ) @@ -96,7 +96,7 @@ export const parse = (rawLineInputs: RawInputs, specs: CommandParameter.Output[] } currentReport = { - spec, + parameter, errors: [], value: PENDING_VALUE, // eslint-disable-line source: { @@ -105,12 +105,16 @@ export const parse = (rawLineInputs: RawInputs, specs: CommandParameter.Output[] }, } - reports[spec.name.canonical] = currentReport + reports[parameter.name.canonical] = currentReport continue } else if (currentReport) { // TODO catch error and put into errors array - currentReport.value = parseRawInput(currentReport.spec.name.canonical, rawLineInput, currentReport.spec) + currentReport.value = parseSerializedValue( + currentReport.parameter.name.canonical, + rawLineInput, + currentReport.parameter, + ) currentReport = null continue } else { @@ -125,7 +129,7 @@ export const parse = (rawLineInputs: RawInputs, specs: CommandParameter.Output[] return { globalErrors, - reports: reports, + reports, } } diff --git a/packages/@molt/command/src/OpeningArgs/OpeningArgs.ts b/packages/@molt/command/src/OpeningArgs/OpeningArgs.ts index 273e0900..ab91c371 100644 --- a/packages/@molt/command/src/OpeningArgs/OpeningArgs.ts +++ b/packages/@molt/command/src/OpeningArgs/OpeningArgs.ts @@ -1,4 +1,4 @@ -import { CommandParameter } from '../CommandParameter/index.js' +import type { CommandParameter } from '../CommandParameter/index.js' import { Errors } from '../Errors/index.js' import { errorFromUnknown, groupBy } from '../lib/prelude.js' import { Environment } from './Environment/index.js' @@ -10,11 +10,11 @@ export { Line } from './Line/index.js' export * from './types.js' export const parse = ({ - specs, + parameters, line, environment, }: { - specs: CommandParameter.Output[] + parameters: CommandParameter.Output[] line: Line.RawInputs environment: Environment.RawInputs }): ParseResult => { @@ -23,12 +23,12 @@ export const parse = ({ basicParameters: {}, mutuallyExclusiveParameters: {}, } - const envParseResult = Environment.parse(environment, specs) - const lineParseResult = Line.parse(line, specs) + const envParseResult = Environment.parse(environment, parameters) + const lineParseResult = Line.parse(line, parameters) result.globalErrors.push(...lineParseResult.globalErrors, ...envParseResult.globalErrors) - const specsByVariant = groupBy(specs, `_tag`) + const specsByVariant = groupBy(parameters, `_tag`) const specVariantsBasic = specsByVariant.Basic ?? [] @@ -36,7 +36,7 @@ export const parse = ({ * Handle "basic" parameters. This excludes "Exclusive Parameter Groups" which are handled later. */ - for (const spec of specVariantsBasic) { + for (const parameter of specVariantsBasic) { /** * A note about types. * @@ -51,7 +51,7 @@ export const parse = ({ // todo, a strict mode where errors are NOT ignored from env parsing when line is present const argReport = - lineParseResult.reports[spec.name.canonical] ?? envParseResult.reports[spec.name.canonical] + lineParseResult.reports[parameter.name.canonical] ?? envParseResult.reports[parameter.name.canonical] /** * An opening argument was given. Process it. @@ -62,10 +62,10 @@ export const parse = ({ * If there were any errors during the input parsing phase then do not continue with the parameter. */ if (argReport.errors.length > 0) { - result.basicParameters[argReport.spec.name.canonical] = { + result.basicParameters[argReport.parameter.name.canonical] = { _tag: `error`, errors: argReport.errors, - spec, + parameter, } continue } @@ -73,32 +73,34 @@ export const parse = ({ /** * Given a raw value was correctly passed, validate it according to the parameter spec. */ - result.basicParameters[argReport.spec.name.canonical] = Alge.match(argReport.value) + result.basicParameters[argReport.parameter.name.canonical] = Alge.match(argReport.value) .boolean((argReportValue) => { return { _tag: `supplied` as const, - spec, + parameter, value: argReportValue.negated ? !argReportValue.value : argReportValue.value, } }) .else((argReportValue) => { - const valueTransformed = CommandParameter.transform(spec, argReportValue.value) - const validationResult = CommandParameter.validate(spec, valueTransformed) + // eslint-disable-next-line + const valueTransformed = parameter.type.transform?.(argReportValue.value) ?? argReportValue.value + const validationResult = parameter.type.validate(valueTransformed) return Alge.match(validationResult) .Right((result) => { return { _tag: `supplied` as const, - spec, + parameter: parameter, + // eslint-disable-next-line value: result.right, } }) .Left((result) => { return { _tag: `error` as const, - spec, + parameter, errors: [ new Errors.ErrorInvalidArgument({ - spec, + spec: parameter, validationErrors: result.left.errors, value: result.left.value, }), @@ -114,7 +116,7 @@ export const parse = ({ * No opening argument was given. Process this fact according to spec (e.g. ok b/c optional, apply default, ... etc.) */ - result.basicParameters[spec.name.canonical] = Alge.match(spec.optionality) + result.basicParameters[parameter.name.canonical] = Alge.match(parameter.optionality) .default((optionality) => { let defaultValue try { @@ -122,10 +124,10 @@ export const parse = ({ } catch (someError) { return { _tag: `error` as const, - spec, + parameter, errors: [ new Errors.ErrorFailedToGetDefaultArgument({ - spec, + spec: parameter, cause: errorFromUnknown(someError), }), ], @@ -133,21 +135,21 @@ export const parse = ({ } return { _tag: `supplied` as const, - spec, + parameter, value: defaultValue, } }) .required(() => { return { _tag: `error` as const, - spec, - errors: [new Errors.ErrorMissingArgument({ spec })], + parameter: parameter, + errors: [new Errors.ErrorMissingArgument({ parameter })], } }) .optional(() => { return { _tag: `omitted` as const, - spec, + parameter, } }) .done() @@ -175,7 +177,7 @@ export const parse = ({ if (group.optionality._tag === `optional`) { result.mutuallyExclusiveParameters[group.label] = { _tag: `omitted`, - spec: group, + parameter: group, } continue } @@ -205,7 +207,7 @@ export const parse = ({ result.mutuallyExclusiveParameters[group.label] = { _tag: `error`, - spec: group, + parameter: group, errors: [ new Errors.ErrorMissingArgumentForMutuallyExclusiveParameters({ group, @@ -218,10 +220,10 @@ export const parse = ({ if (argsToGroup.length > 1) { result.mutuallyExclusiveParameters[group.label] = { _tag: `error`, - spec: group, + parameter: group, errors: [ new Errors.ErrorArgumentsToMutuallyExclusiveParameters({ - offenses: argsToGroup.map((_) => ({ spec: _.spec, arg: _ })), + offenses: argsToGroup.map((_) => ({ spec: _.parameter, arg: _ })), }), ], } @@ -233,9 +235,9 @@ export const parse = ({ result.mutuallyExclusiveParameters[group.label] = { _tag: `supplied`, spec: group, - parameter: arg.spec, + parameter: arg.parameter, value: { - _tag: arg.spec.name.canonical, + _tag: arg.parameter.name.canonical, value: arg.value.value, }, } diff --git a/packages/@molt/command/src/OpeningArgs/helpers.ts b/packages/@molt/command/src/OpeningArgs/helpers.ts index 33311f85..ff018a87 100644 --- a/packages/@molt/command/src/OpeningArgs/helpers.ts +++ b/packages/@molt/command/src/OpeningArgs/helpers.ts @@ -1,9 +1,7 @@ import type { CommandParameter } from '../CommandParameter/index.js' -import { BooleanLookup, negateNamePattern, parseEnvironmentVariableBoolean } from '../helpers.js' -import type { Type } from '../Type/index.js' -import type { LiteralValue } from '../Type/Type.js' +import { negateNamePattern } from '../helpers.js' import type { Value } from './types.js' -import { Alge } from 'alge' +import { Either } from 'effect' import camelCase from 'lodash.camelcase' import { z } from 'zod' @@ -14,24 +12,23 @@ export const stripeDashPrefix = (flagNameInput: string): string => { export const zodPassthrough = () => z.any().transform((_) => _ as T) // prettier-ignore -export const parseRawInput = (name: string, rawValue: string, spec: CommandParameter.Output): Value => { - const parsedValue = parseRawValue(rawValue, spec.type) - if (parsedValue === null) { +export const parseSerializedValue = (name: string, serializedValue: string, spec: CommandParameter.Output): Value => { + const either = spec.type.deserialize(serializedValue) + if (Either.isLeft(either)) { const expectedTypes = spec.type._tag - throw new Error(`Failed to parse input ${name} with value ${rawValue}. Expected type of ${expectedTypes}.`) + throw new Error(`Failed to parse input ${name} with value ${serializedValue}. Expected type of ${expectedTypes}.`) } - if (typeof parsedValue === `string`) return { _tag: `string`, value: parsedValue } - if (typeof parsedValue === `number`) return { _tag: `number`, value: parsedValue } - if (typeof parsedValue === `undefined`) return { _tag: `undefined`, value: undefined } - if (typeof parsedValue === `boolean`){ - // dump(isEnvarNegated(name, spec)) - return { _tag: `boolean`, value: parsedValue, negated: isEnvarNegated(name, spec) } + // TODO make return unknown + const value = either.right // eslint-disable-line + const type = typeof value + if (type === `string`) return { _tag: `string`, value: value as string } + if (type === `number`) return { _tag: `number`, value: value as number } + if (type === `undefined`) return { _tag: `undefined`, value: undefined } + if (type === `boolean`) { + // dump(isEnvarNegated(name, spec)) + return { _tag: `boolean`, value: value as boolean, negated: isEnvarNegated(name, spec) } } - return casesHandled(parsedValue) -} - -const casesHandled = (value: never): never => { - throw new Error(`Unhandled case ${String(value)}`) + throw new Error(`Supported type ${type}.`) } /** @@ -54,63 +51,3 @@ const stripeNamespace = (name: string, spec: CommandParameter.Output): string => } return name } - -/** - * For a union we infer the value to be the type of the first variant type that matches. - * This means that variant order matters since there are sub/super type relationships. - * For example a number is a subset of string type. If there is a string and number variant - * we should first check if the value could be a number, than a string. - */ -const variantOrder: Type.Type['_tag'][] = [`TypeNumber`, `TypeBoolean`, `TypeString`, `TypeEnum`, `TypeUnion`] - -export const parseRawValue = ( - value: string, - type: Type.Type, -): null | undefined | boolean | number | string => { - return Alge.match(type) - .TypeLiteral((t) => parseLiteral(t, value)) - .TypeString(() => value) - .TypeEnum((t) => parseEnum(t, value)) - .TypeBoolean(() => parseEnvironmentVariableBoolean(value)) - .TypeNumber(() => { - const result = Number(value) - return isNaN(result) ? null : result - }) - .TypeUnion((t) => { - return ( - t.members - .sort((a, b) => variantOrder.indexOf(a._tag) - variantOrder.indexOf(b._tag)) - .map((m) => parseRawValue(value, m)) - .find((m) => m !== null) ?? null - ) - }) - .done() -} - -/** - * Enums can be given a base type of numbers or strings. Examples: - * - * - number: `z.nativeEnum(\{ a: 1, b: 2\})` - * - string: `z.enum(['a','b','c'])` - * - * It is not possible to have an enum that mixes numbers and strings. - * - * When we receive a raw value, we infer its base type based on checking the type first member of the enum. - */ -export const parseEnum = (type: Type.Scalar.Enumeration, value: string): string | number => { - const isNumberEnum = type.members.find((_) => typeof _ === `number`) - if (isNumberEnum) return Number(value) - return value -} - -export const parseLiteral = (spec: Type.Scalar.Literal, value: string): LiteralValue => { - if (typeof spec.value === `string`) return value - if (typeof spec.value === `undefined`) return undefined - if (typeof spec.value === `number`) return Number(value) - if (typeof spec.value === `boolean`) { - const v = (BooleanLookup as Record)[value] - if (!v) throw new Error(`Invalid boolean literal value: ${value}`) - return v - } - return casesHandled(spec.value) -} diff --git a/packages/@molt/command/src/OpeningArgs/types.ts b/packages/@molt/command/src/OpeningArgs/types.ts index 8357f392..9a984392 100644 --- a/packages/@molt/command/src/OpeningArgs/types.ts +++ b/packages/@molt/command/src/OpeningArgs/types.ts @@ -3,15 +3,16 @@ import type { Errors } from '../Errors/index.js' import type { Environment } from './Environment/index.js' import type { LocalParseErrors } from './Line/Line.js' -export interface EnvironmentArgumentReport - extends Argument { - spec: Spec +export interface EnvironmentArgumentReport< + Parameter extends CommandParameter.Output = CommandParameter.Output, +> extends Argument { + parameter: Parameter errors: Environment.LocalParseErrors[] } -export interface ArgumentReport +export interface ArgumentReport extends Argument { - spec: Spec + parameter: Parameter errors: LocalParseErrors[] } @@ -63,18 +64,18 @@ export type ParseError = export type ParseResultBasicSupplied = { _tag: 'supplied' - spec: CommandParameter.Output.Basic + parameter: CommandParameter.Output.Basic value: CommandParameter.ArgumentValue } export type ParseResultBasicError = { _tag: 'error' - spec: CommandParameter.Output.Basic + parameter: CommandParameter.Output.Basic errors: ParseErrorBasic[] } export type ParseResultBasicOmitted = { _tag: 'omitted' - spec: CommandParameter.Output.Basic + parameter: CommandParameter.Output.Basic } export type ParseResultBasic = ParseResultBasicSupplied | ParseResultBasicError | ParseResultBasicOmitted @@ -88,7 +89,7 @@ export type ParseResultExclusiveGroupSupplied = { export type ParseResultExclusiveGroupError = { _tag: 'error' - spec: CommandParameter.Output.ExclusiveGroup + parameter: CommandParameter.Output.ExclusiveGroup errors: ParseErrorExclusiveGroup[] } @@ -96,7 +97,7 @@ export type ParseResultExclusiveGroup = | ParseResultExclusiveGroupSupplied | { _tag: 'omitted' - spec: CommandParameter.Output.ExclusiveGroup + parameter: CommandParameter.Output.ExclusiveGroup } | ParseResultExclusiveGroupError diff --git a/packages/@molt/command/src/Type/Type.ts b/packages/@molt/command/src/Type/Type.ts index 82c7f3a5..2e0d3772 100644 --- a/packages/@molt/command/src/Type/Type.ts +++ b/packages/@molt/command/src/Type/Type.ts @@ -1,182 +1,11 @@ -import { Patterns } from '../lib/Patterns/index.js' -import type { Scalar } from './types/Scalar.js' -import type { Union } from './types/Union.js' -import { Alge } from 'alge' -import { Either } from 'effect' +import type { Type, TypeSymbol } from './helpers.js' +import type { Either } from 'effect' +export * from './helpers.js' export { Scalar } from './types/Scalar.js' export * from './types/Scalars/index.js' export * from './types/Union.js' -export type Type = Scalar | Union +export type ValidationResult = Either.Either<{ value: unknown; errors: string[] }, T> -export type ValidationResult = Either.Either<{ value: T; errors: string[] }, T> - -// prettier-ignore -export type Infer<$Type extends Type> = - $Type extends Scalar.Boolean ? boolean : - $Type extends Scalar.Number ? number : - $Type extends Scalar.String ? string : - $Type extends Scalar.Enumeration ? Members[number] : - $Type extends Union ? Infer> : - $Type extends Scalar.Literal ? Value : - never - -export const validate = (type: Type, value: T): ValidationResult => { - return Alge.match(type) - .TypeLiteral((_) => - value === _.value - ? Either.right(value) - : Either.left({ value, errors: [`Value is not equal to literal.`] }), - ) - .TypeBoolean(() => - typeof value === `boolean` - ? Either.right(value) - : Either.left({ value, errors: [`Value is not a boolean.`] }), - ) - .TypeEnum((type) => - type.members.includes(value as any) - ? Either.right(value) - : Either.left({ value, errors: [`Value is not a member of the enum.`] }), - ) - .TypeNumber((type) => { - const errors: string[] = [] - if (typeof value !== `number`) { - return Either.left({ value, errors: [`Value is not a number.`] }) - } - if (type.int && !Number.isInteger(value)) { - errors.push(`Value is not an integer.`) - } - if (type.min) { - if (value < type.min) { - errors.push(`value must be bigger than ${type.min}.`) - } - } - if (type.max) { - if (value > type.max) { - errors.push(`value must be smaller than ${type.max}.`) - } - } - if (type.multipleOf) { - if (value % type.multipleOf !== 0) { - errors.push(`Value is not a multiple of ${type.multipleOf}.`) - } - } - if (errors.length > 0) { - return Either.left({ value, errors }) - } - return Either.right(value) - }) - .TypeString((type) => { - const errors: string[] = [] - if (typeof value !== `string`) { - return Either.left({ value, errors: [`Value is not a string.`] }) - } - if (type.regex && !type.regex.test(value)) { - errors.push(`Value does not conform to Regular Expression.`) - } - if (type.min) { - if (value.length < type.min) { - errors.push(`Value is too short.`) - } - } - if (type.max) { - if (value.length > type.max) { - errors.push(`Value is too long.`) - } - } - if (type.includes) { - if (!value.includes(type.includes)) { - errors.push(`Value does not include ${type.includes}.`) - } - } - if (type.pattern) { - Alge.match(type.pattern) - .cuid(() => { - if (!Patterns.cuid.test(value)) { - errors.push(`Value is not a cuid.`) - } - }) - .url(() => { - try { - new URL(value) - } catch (error) { - errors.push(`Value is not a URL.`) - } - }) - .email(() => { - if (!Patterns.email.test(value)) { - errors.push(`Value is not an email address.`) - } - }) - .uuid(() => { - if (!Patterns.uuid.test(value)) { - errors.push(`Value is not a uuid.`) - } - }) - .ulid(() => { - if (!Patterns.ulid.test(value)) { - errors.push(`Value is not a ulid.`) - } - }) - .dateTime((type) => { - if (!Patterns.dateTime({ offset: type.offset, precision: type.precision }).test(value)) { - errors.push(`Value is not a conforming datetime.`) - } - }) - .cuid2(() => { - if (!Patterns.cuid2.test(value)) { - errors.push(`Value is not a cuid2.`) - } - }) - .ip((type) => { - const ip4 = Patterns.ipv4.test(value) - if (type.version === 4 && !ip4) { - errors.push(`Value is not an ipv4 address.`) - return - } - const ip6 = Patterns.ipv6.test(value) - if (type.version === 6 && !ip6) { - errors.push(`Value is not an ipv6 address.`) - return - } - if (!ip4 && !ip6) { - errors.push(`Value is not an ipv4 or ipv6 address.`) - } - }) - .emoji(() => { - if (!Patterns.emoji.test(value)) { - errors.push(`Value is not an emoji.`) - } - }) - .done() - } - if (type.startsWith) { - if (!value.startsWith(type.startsWith)) { - errors.push(`Value does not start with ${type.startsWith}.`) - } - } - if (type.endsWith) { - if (!value.endsWith(type.endsWith)) { - errors.push(`Value does not end with ${type.endsWith}.`) - } - } - if (type.length) { - if (value.length !== type.length) { - errors.push(`Value does not have the length ${type.length}.`) - } - } - if (errors.length > 0) { - return Either.left({ value, errors }) - } - return Either.right(value) - }) - .TypeUnion((type) => { - const result = type.members.find((member) => validate(member, value)) - if (!result) { - return Either.left({ value, errors: [`Value does not fit any member of the union.`] }) - } - return Either.right(value) - }) - .done() -} +export type Infer<$Type extends Type> = $Type[TypeSymbol] diff --git a/packages/@molt/command/src/Type/helpers.ts b/packages/@molt/command/src/Type/helpers.ts new file mode 100644 index 00000000..e0631f55 --- /dev/null +++ b/packages/@molt/command/src/Type/helpers.ts @@ -0,0 +1,33 @@ +import type { Optionality } from '../lib/Pam/parameter.js' +import type { PromptEngine } from '../lib/PromptEngine/PromptEngine.js' +import type { Tex } from '../lib/Tex/index.js' +import type { ValidationResult } from './Type.js' +import type { Effect, Either } from 'effect' + +export const TypeSymbol = Symbol(`type`) + +export const runtimeIgnore: any = true + +export type TypeSymbol = typeof TypeSymbol + +export interface Type { + _tag: string + description: null | string + [TypeSymbol]: T + validate: (value: unknown) => ValidationResult + transform?: (value: T) => T + priority: number + help: (settings?: any) => string | Tex.Block + display: () => string + displayExpanded: () => string + // TODO use Either type here + deserialize: (serializedValue: string) => Either.Either + prompt: (params: { + channels: PromptEngine.Channels + optionality: Optionality + prompt: string + marginLeft?: number + }) => Effect.Effect +} + +export type Infer> = T[TypeSymbol] diff --git a/packages/@molt/command/src/Type/types/Scalars/Boolean.ts b/packages/@molt/command/src/Type/types/Scalars/Boolean.ts index 2fcd0345..0edecac0 100644 --- a/packages/@molt/command/src/Type/types/Scalars/Boolean.ts +++ b/packages/@molt/command/src/Type/types/Scalars/Boolean.ts @@ -1,12 +1,77 @@ -export interface Boolean { +import { parseEnvironmentVariableBoolean } from '../../../helpers.js' +import { PromptEngine } from '../../../lib/PromptEngine/PromptEngine.js' +import { Tex } from '../../../lib/Tex/index.js' +import { Term } from '../../../term.js' +import type { Type } from '../../helpers.js' +import { runtimeIgnore, TypeSymbol } from '../../helpers.js' +import chalk from 'chalk' +import { Effect, Either } from 'effect' + +export interface Boolean extends Type { _tag: 'TypeBoolean' - description: string | null } +type Boolean_ = Boolean // eslint-disable-line + // eslint-disable-next-line -export const boolean = (description?: string): Boolean => { - return { +export const boolean = (description?: string): Boolean_ => { + const type: Boolean_ = { _tag: `TypeBoolean`, description: description ?? null, + [TypeSymbol]: runtimeIgnore, // eslint-disable-line + priority: 0, + // eslint-disable-next-line + validate: (value: unknown) => { + return typeof value === `boolean` + ? Either.right(value) + : Either.left({ value, errors: [`Value is not a boolean.`] }) + }, + display: () => Term.colors.positive(`boolean`), + displayExpanded: () => type.display(), + help: () => { + return Tex.block(($) => $.block(type.displayExpanded()).block(description ?? null)) as Tex.Block + }, + deserialize: (rawValue) => { + return parseEnvironmentVariableBoolean(rawValue) + }, + prompt: (params) => { + return Effect.gen(function* (_) { + interface State { + value: boolean + } + const initialState: State = { + value: false, + } + const marginLeftSpace = ` `.repeat(params.marginLeft ?? 0) + const pipe = `${chalk.dim(`|`)}` + const no = `${chalk.green(chalk.bold(`no`))} ${pipe} yes` + const yes = `no ${pipe} ${chalk.green(chalk.bold(`yes`))}` + const prompt = PromptEngine.create({ + channels: params.channels, + initialState, + on: [ + { + match: [`left`, `n`], + run: (_state) => ({ value: false }), + }, + { + match: [`right`, `y`], + run: (_state) => ({ value: true }), + }, + { + match: `tab`, + run: (state) => ({ value: !state.value }), + }, + ], + draw: (state) => { + return marginLeftSpace + params.prompt + (state.value ? yes : no) + }, + }) + const state = yield* _(prompt) + if (state === null) return undefined + return state.value + }) + }, } + return type } diff --git a/packages/@molt/command/src/Type/types/Scalars/Enumeration.ts b/packages/@molt/command/src/Type/types/Scalars/Enumeration.ts index b22d154c..e2c3590a 100644 --- a/packages/@molt/command/src/Type/types/Scalars/Enumeration.ts +++ b/packages/@molt/command/src/Type/types/Scalars/Enumeration.ts @@ -1,7 +1,14 @@ -export interface Enumeration<$Members extends Member[] = Member[]> { +import { PromptEngine } from '../../../lib/PromptEngine/PromptEngine.js' +import { Text } from '../../../lib/Text/index.js' +import { Term } from '../../../term.js' +import type { Type } from '../../helpers.js' +import { runtimeIgnore, TypeSymbol } from '../../helpers.js' +import chalk from 'chalk' +import { Effect, Either } from 'effect' + +export interface Enumeration<$Members extends Member[] = Member[]> extends Type<$Members[number]> { _tag: 'TypeEnum' members: $Members - description: string | null } type Member = number | string @@ -9,9 +16,79 @@ export const enumeration = <$Members extends Member[]>( members: $Members, description?: string, ): Enumeration<$Members> => { - return { + const type: Enumeration<$Members> = { _tag: `TypeEnum`, + priority: 10, members, description: description ?? null, + [TypeSymbol]: runtimeIgnore, // eslint-disable-line + validate: (value) => { + return members.includes(value as any) + ? Either.right(value as (typeof members)[number]) + : Either.left({ value, errors: [`Value is not a member of the enum.`] }) + }, + deserialize: (rawValue) => { + const isNumberEnum = members.find((_) => typeof _ === `number`) + if (isNumberEnum) { + const number = Number(rawValue) + if (isNaN(number)) return Either.left(new Error(`Value is not a number.`)) + return Either.right(number) + } + return Either.right(rawValue) + }, + display: () => `enum`, + displayExpanded: () => { + const separator = Term.colors.accent(` ${Text.chars.pipe} `) + const lines = members.map((member) => Term.colors.positive(String(member))).join(separator) + return members.length > 1 ? lines : `${lines} ${Term.colors.dim(`(enum)`)}` + }, + help: () => type.displayExpanded(), + prompt: (params) => { + return Effect.gen(function* (_) { + interface State { + active: number + } + const initialState: State = { + active: 0, + } + const marginLeftSpace = ` `.repeat(params.marginLeft ?? 0) + const prompt = PromptEngine.create({ + channels: params.channels, + initialState, + on: [ + { + match: [`left`, { name: `tab`, shift: true }], + run: (state) => ({ + active: state.active === 0 ? members.length - 1 : state.active - 1, + }), + }, + { + match: [`right`, { name: `tab`, shift: false }], + run: (state) => ({ + active: state.active === members.length - 1 ? 0 : state.active + 1, + }), + }, + ], + draw: (state) => { + return ( + marginLeftSpace + + params.prompt + + members + .map((item, i) => (i === state.active ? `${chalk.green(chalk.bold(item))}` : item)) + .join(chalk.dim(` | `)) + ) + }, + }) + const state = yield* _(prompt) + + if (state === null) return undefined + + const choice = members[state.active] + // prettier-ignore + if (!choice) throw new Error(`No choice selected. Enumeration must be empty. But enumerations should not be empty. This is a bug.`) + return choice + }) + }, } + return type } diff --git a/packages/@molt/command/src/Type/types/Scalars/Literal.ts b/packages/@molt/command/src/Type/types/Scalars/Literal.ts index 01c46249..5bd2152d 100644 --- a/packages/@molt/command/src/Type/types/Scalars/Literal.ts +++ b/packages/@molt/command/src/Type/types/Scalars/Literal.ts @@ -1,7 +1,12 @@ -export interface Literal<$Value extends LiteralValue = LiteralValue> { +import { BooleanLookup, casesExhausted } from '../../../helpers.js' +import { Term } from '../../../term.js' +import type { Type } from '../../helpers.js' +import { runtimeIgnore, TypeSymbol } from '../../helpers.js' +import { Either } from 'effect' + +export interface Literal<$Value extends LiteralValue = LiteralValue> extends Type<$Value> { _tag: 'TypeLiteral' value: $Value - description: string | null } export type LiteralValue = number | string | boolean | undefined @@ -10,9 +15,53 @@ export const literal = ( value: $Value, description?: string, ): Literal<$Value> => { - return { + const type: Literal<$Value> = { + [TypeSymbol]: runtimeIgnore, // eslint-disable-line + priority: 0, _tag: `TypeLiteral`, value, description: description ?? null, + validate: (_value) => { + return _value === value + ? Either.right(_value as typeof value) + : Either.left({ value: _value, errors: [`Value is not equal to literal.`] }) + }, + display: () => `literal`, + displayExpanded: () => { + return Term.colors.positive(String(value)) + }, + help: () => type.displayExpanded(), + prompt: () => { + throw new Error(`Not implemented`) + }, + deserialize: (rawValue) => { + if (typeof value === `string`) return Either.right(rawValue as $Value) + if (typeof value === `undefined`) { + if (rawValue !== `undefined`) { + return Either.left(new Error(`Invalid undefined literal value: ${String(rawValue)}`)) + } + return Either.right(undefined as $Value) + } + if (typeof value === `number`) { + const number = Number(rawValue) + if (isNaN(number)) { + return Either.left(new Error(`Invalid number literal value: ${String(rawValue)}`)) + } + return Either.right(number as $Value) + } + if (typeof value === `boolean`) { + const v = (BooleanLookup as Record)[rawValue] + if (!v) { + return Either.left(new Error(`Invalid boolean literal value: ${String(rawValue)}`)) + } + return Either.right(v as $Value) + } + return casesExhausted(value) + }, + // todo + // prompt: (params) => { + // return Effect. + // } } + return type } diff --git a/packages/@molt/command/src/Type/types/Scalars/Number.ts b/packages/@molt/command/src/Type/types/Scalars/Number.ts index 7c339384..deb7bb92 100644 --- a/packages/@molt/command/src/Type/types/Scalars/Number.ts +++ b/packages/@molt/command/src/Type/types/Scalars/Number.ts @@ -1,8 +1,15 @@ -export interface Number extends Refinements { +import { PromptEngine } from '../../../lib/PromptEngine/PromptEngine.js' +import { Term } from '../../../term.js' +import { runtimeIgnore, type Type, TypeSymbol } from '../../helpers.js' +import { Effect, Either } from 'effect' + +export interface Number extends Type { _tag: 'TypeNumber' - description: string | null + refinements: Refinements } +type Number_ = Number // eslint-disable-line + interface Refinements { int?: boolean min?: number @@ -12,10 +19,91 @@ interface Refinements { } // eslint-disable-next-line -export const number = (refinements?: Refinements, description?: string): Number => { - return { +export const number = (refinements?: Refinements, description?: string): Number_ => { + const type: Number_ = { _tag: `TypeNumber`, - ...refinements, + priority: 5, + refinements: refinements ?? {}, description: description ?? null, + [TypeSymbol]: runtimeIgnore, // eslint-disable-line + deserialize: (serializedValue) => { + const result = Number(serializedValue) + if (isNaN(result)) { + return Either.left(new Error(`Failed to parse number from ${serializedValue}.`)) + } + return Either.right(result) + }, + display: () => Term.colors.positive(`number`), + displayExpanded: () => { + return type.display() + }, + help: () => type.displayExpanded(), + validate: (value) => { + const errors: string[] = [] + + if (typeof value !== `number`) { + return Either.left({ value, errors: [`Value is not a number.`] }) + } + + if (!refinements) return Either.right(value) + + if (refinements.int && !Number.isInteger(value)) { + errors.push(`Value is not an integer.`) + } + if (refinements.min) { + if (value < refinements.min) { + errors.push(`value must be bigger than ${refinements.min}.`) + } + } + if (refinements.max) { + if (value > refinements.max) { + errors.push(`value must be smaller than ${refinements.max}.`) + } + } + if (refinements.multipleOf) { + if (value % refinements.multipleOf !== 0) { + errors.push(`Value is not a multiple of ${refinements.multipleOf}.`) + } + } + + if (errors.length > 0) { + return Either.left({ value, errors }) + } + + return Either.right(value) + }, + prompt: (params) => + Effect.gen(function* (_) { + interface State { + value: string + } + const initialState: State = { value: `` } + const marginLeftSpace = ` `.repeat(params.marginLeft ?? 0) + const prompt = PromptEngine.create({ + channels: params.channels, + cursor: true, + skippable: params.optionality._tag !== `required`, + initialState, + on: [ + { + run: (state, event) => { + return { + value: event.name === `backspace` ? state.value.slice(0, -1) : state.value + event.sequence, + } + }, + }, + ], + draw: (state) => { + return marginLeftSpace + params.prompt + state.value + }, + }) + const state = yield* _(prompt) + if (state === null) return undefined + if (state.value === ``) return undefined + const valueParsed = parseFloat(state.value) + if (isNaN(valueParsed)) return null as any // todo remove cast + return valueParsed + }), } + return type } diff --git a/packages/@molt/command/src/Type/types/Scalars/String.ts b/packages/@molt/command/src/Type/types/Scalars/String.ts index 35417b46..513c08b5 100644 --- a/packages/@molt/command/src/Type/types/Scalars/String.ts +++ b/packages/@molt/command/src/Type/types/Scalars/String.ts @@ -1,13 +1,26 @@ -export interface String extends Refinements { +import { casesExhausted, entries } from '../../../helpers.js' +import { Patterns } from '../../../lib/Patterns/index.js' +import { PromptEngine } from '../../../lib/PromptEngine/PromptEngine.js' +import { Tex } from '../../../lib/Tex/index.js' +import { Term } from '../../../term.js' +import { runtimeIgnore, type Type, TypeSymbol } from '../../helpers.js' +import { Alge } from 'alge' +import { Effect, Either } from 'effect' + +export interface String extends Type { _tag: 'TypeString' - description: string | null + refinements: Refinements + transformations: Transformations +} + +type String_ = String // eslint-disable-line + +interface Transformations { + trim?: boolean + toCase?: 'upper' | 'lower' } interface Refinements { - transformations?: { - trim?: boolean - toCase?: 'upper' | 'lower' - } regex?: RegExp min?: number max?: number @@ -51,11 +64,175 @@ interface Refinements { includes?: string } -// eslint-disable-next-line -export const string = (refinements?: Refinements, description?: string): String => { - return { +export const string = ( + refinements?: Refinements, + transformations?: Transformations, + description?: string, +): String_ => { + const type: String_ = { _tag: `TypeString`, - ...refinements, + priority: -10, + refinements: refinements ?? {}, + transformations: transformations ?? {}, description: description ?? null, + [TypeSymbol]: runtimeIgnore, // eslint-disable-line + display: () => Term.colors.positive(`string`), + displayExpanded: () => type.display(), + help: () => { + return Tex.block(($) => $.block(type.displayExpanded()).block(description ?? null)) as Tex.Block + }, + deserialize: (rawValue) => Either.right(rawValue), + validate: (value) => { + const errors: string[] = [] + + if (typeof value !== `string`) return Either.left({ value, errors: [`Value is not a string.`] }) // prettier-ignore + if (!refinements) return Either.right(value) // prettier-ignore + + if (refinements.regex && !refinements.regex.test(value)) { + errors.push(`Value does not conform to Regular Expression.`) + } + if (refinements.min) { + if (value.length < refinements.min) { + errors.push(`Value is too short.`) + } + } + if (refinements.max) { + if (value.length > refinements.max) { + errors.push(`Value is too long.`) + } + } + if (refinements.includes) { + if (!value.includes(refinements.includes)) { + errors.push(`Value does not include ${refinements.includes}.`) + } + } + + if (refinements.pattern) { + Alge.match(refinements.pattern) + .cuid(() => { + if (!Patterns.cuid.test(value)) { + errors.push(`Value is not a cuid.`) + } + }) + .url(() => { + try { + new URL(value) + } catch (error) { + errors.push(`Value is not a URL.`) + } + }) + .email(() => { + if (!Patterns.email.test(value)) { + errors.push(`Value is not an email address.`) + } + }) + .uuid(() => { + if (!Patterns.uuid.test(value)) { + errors.push(`Value is not a uuid.`) + } + }) + .ulid(() => { + if (!Patterns.ulid.test(value)) { + errors.push(`Value is not a ulid.`) + } + }) + .dateTime((type) => { + if (!Patterns.dateTime({ offset: type.offset, precision: type.precision }).test(value)) { + errors.push(`Value is not a conforming datetime.`) + } + }) + .cuid2(() => { + if (!Patterns.cuid2.test(value)) { + errors.push(`Value is not a cuid2.`) + } + }) + .ip((type) => { + const ip4 = Patterns.ipv4.test(value) + if (type.version === 4 && !ip4) { + errors.push(`Value is not an ipv4 address.`) + return + } + const ip6 = Patterns.ipv6.test(value) + if (type.version === 6 && !ip6) { + errors.push(`Value is not an ipv6 address.`) + return + } + if (!ip4 && !ip6) { + errors.push(`Value is not an ipv4 or ipv6 address.`) + } + }) + .emoji(() => { + if (!Patterns.emoji.test(value)) { + errors.push(`Value is not an emoji.`) + } + }) + .done() + } + + if (refinements.startsWith) { + if (!value.startsWith(refinements.startsWith)) { + errors.push(`Value does not start with ${refinements.startsWith}.`) + } + } + if (refinements.endsWith) { + if (!value.endsWith(refinements.endsWith)) { + errors.push(`Value does not end with ${refinements.endsWith}.`) + } + } + if (refinements.length) { + if (value.length !== refinements.length) { + errors.push(`Value does not have the length ${refinements.length}.`) + } + } + if (errors.length > 0) { + return Either.left({ value, errors }) + } + + return Either.right(value) + }, + transform: (value) => { + if (!transformations) return value + + return entries(transformations ?? {}).reduce((_value, [kind, kindValue]) => { + return kind === `trim` + ? _value.trim() + : kind === `toCase` + ? kindValue === `upper` + ? _value.toUpperCase() + : _value.toLowerCase() + : casesExhausted(kind) + }, value) + }, + prompt: (params) => + Effect.gen(function* (_) { + interface State { + value: string + } + const initialState: State = { value: `` } + const marginLeftSpace = ` `.repeat(params.marginLeft ?? 0) + const prompt = PromptEngine.create({ + channels: params.channels, + cursor: true, + skippable: params.optionality._tag !== `required`, + initialState, + on: [ + { + run: (state, event) => { + return { + value: event.name === `backspace` ? state.value.slice(0, -1) : state.value + event.sequence, + } + }, + }, + ], + draw: (state) => { + return marginLeftSpace + params.prompt + state.value + }, + }) + const state = yield* _(prompt) + if (state === null) return undefined + if (state.value === ``) return undefined + return state.value + }), } + return type } diff --git a/packages/@molt/command/src/Type/types/Union.ts b/packages/@molt/command/src/Type/types/Union.ts index 9909144a..c6eada54 100644 --- a/packages/@molt/command/src/Type/types/Union.ts +++ b/packages/@molt/command/src/Type/types/Union.ts @@ -1,20 +1,144 @@ -import type { Scalar } from './Scalar.js' +import { PromptEngine } from '../../lib/PromptEngine/PromptEngine.js' +import { Tex } from '../../lib/Tex/index.js' +import { Text } from '../../lib/Text/index.js' +import { Term } from '../../term.js' +import { runtimeIgnore, type Type, TypeSymbol } from '../helpers.js' +import chalk from 'chalk' +import { Effect, Either } from 'effect' -export interface Union { +export interface Union + extends Type { _tag: 'TypeUnion' members: Members - description: string | null } -export type Member = Scalar | Union +export type Member = Type export const union = <$Members extends Member[]>( - members: $Members, + members_: $Members, description?: string, ): Union<$Members> => { + /** + * For a union we infer the value to be the type of the first variant type that matches. + * This means that variant order matters since there are sub/super type relationships. + * For example a number is a subset of string type. If there is a string and number variant + * we should first check if the value could be a number, than a string. + */ + const members = members_.sort((a, b) => (a.priority > b.priority ? -1 : 1)) return { _tag: `TypeUnion`, members, + priority: 0, description: description ?? null, + [TypeSymbol]: runtimeIgnore, // eslint-disable-line + deserialize: (serializedValue) => { + return ( + members.map((m) => m.deserialize(serializedValue)).find((m) => Either.isRight(m)) ?? + Either.left(new Error(`No variant matched.`)) + ) + }, + display: () => `union`, + displayExpanded: () => `todo`, + help: (settings) => { + const hasAtLeastOneMemberDescription = members.filter((_) => _.description !== null).length > 0 + //prettier-ignore + const isExpandedMode = + (hasAtLeastOneMemberDescription && settings?.helpRendering?.union?.mode === `expandOnParameterDescription`) || // eslint-disable-line + settings?.helpRendering?.union?.mode === 'expandAlways' // eslint-disable-line + const unionMemberIcon = Term.colors.accent(`◒`) + const isOneOrMoreMembersWithDescription = members.some((_) => _.description !== null) + const isExpandedModeViaForceSetting = isExpandedMode && !isOneOrMoreMembersWithDescription + if (isExpandedMode) { + const types = members.flatMap((m) => { + return Tex.block( + { + padding: { bottomBetween: isExpandedModeViaForceSetting ? 0 : 1 }, + border: { + left: (index) => + `${index === 0 ? unionMemberIcon : Term.colors.dim(Text.chars.borders.vertical)} `, + }, + }, + (__) => __.block(m.displayExpanded()).block(m.description), + ) + }) + return Tex.block((__) => + __.block(Term.colors.dim(Text.chars.borders.leftTop + Text.chars.borders.horizontal + `union`)) + .block( + { + padding: { bottom: 1 }, + border: { left: `${Term.colors.dim(Text.chars.borders.vertical)} ` }, + }, + description ?? null, + ) + .block(types) + .block(Term.colors.dim(Text.chars.borders.leftBottom + Text.chars.borders.horizontal)), + ) as Tex.Block + } else { + const membersRendered = members.map((m) => m.displayExpanded()).join(` | `) + return Tex.block(($) => $.block(membersRendered).block(description ?? null)) as Tex.Block + } + }, + validate: (value) => { + const result = members.find((member) => member.validate(value)._tag === `Right`) + return result + ? Either.right(value) + : Either.left({ value, errors: [`Value does not fit any member of the union.`] }) + }, + prompt: (params) => + Effect.gen(function* (_) { + interface State { + active: number + } + const initialState: State = { + active: 0, + } + const prompt = PromptEngine.create({ + channels: params.channels, + initialState, + on: [ + { + match: [`left`, { name: `tab`, shift: true }], + run: (state) => ({ + active: state.active === 0 ? members.length - 1 : state.active - 1, + }), + }, + { + match: [`right`, { name: `tab`, shift: false }], + run: (state) => ({ + active: state.active === members.length - 1 ? 0 : state.active + 1, + }), + }, + ], + draw: (state) => { + const marginLeftSpace = ` `.repeat(params.marginLeft ?? 0) + // prettier-ignore + const intro = marginLeftSpace + `Different kinds of answers are accepted.` + Text.chars.newline + marginLeftSpace + `Which kind do you want to give?` + // prettier-ignore + + const choices = + marginLeftSpace + + params.prompt + + members + .map((item, i) => + i === state.active + ? `${chalk.green(chalk.bold(item.display()))}` + : item.display(), + ) + .join(chalk.dim(` | `)) + return Text.chars.newline + intro + Text.chars.newline + Text.chars.newline + choices + }, + }) + + const state = yield* _(prompt) + + if (state === null) return undefined + + const choice = members[state.active] + // prettier-ignore + if (!choice) throw new Error(`No choice selected. Enumeration must be empty. But enumerations should not be empty. This is a bug.`) + + const res = (yield* _(choice.prompt(params))) as $Members[number] + return res + }), } } diff --git a/packages/@molt/command/src/TypeAdaptors/zod/zod.ts b/packages/@molt/command/src/TypeAdaptors/zod/zod.ts index 5a27ce4d..a5269fbf 100644 --- a/packages/@molt/command/src/TypeAdaptors/zod/zod.ts +++ b/packages/@molt/command/src/TypeAdaptors/zod/zod.ts @@ -42,149 +42,156 @@ export const fromZod = (zodType: z.ZodFirstPartySchemaTypes): Type.Type => _from // prettier-ignore const _fromZod = (zodType: z.ZodFirstPartySchemaTypes,previousDescription?:string): Type.Type => { const zt = zodType - const description = previousDescription??zt.description + const description = previousDescription ?? zt.description if (ZodHelpers.isString(zt)) { - const checks = mapZodStringChecks(zt._def.checks) - return Type.string(checks,description) + const {refinements,transformations} = mapZodStringChecksAndTransformations(zt._def.checks) + return Type.string(refinements, transformations, description) } if (ZodHelpers.isNumber(zt)) { - const checks = mapZodNumberChecks(zt._def.checks) - return Type.number(checks,description) + const {refinements} = mapZodNumberChecksAndTransformations(zt._def.checks) + return Type.number(refinements, description) } if (ZodHelpers.isEnum(zt)) return Type.enumeration(zt._def.values,description) - if (ZodHelpers.isNativeEnum(zt)) return Type.enumeration(Object.values(zt._def.values),description) + if (ZodHelpers.isNativeEnum(zt)) return Type.enumeration(Object.values(zt._def.values),description) if (ZodHelpers.isBoolean(zt)) return Type.boolean(description) if (ZodHelpers.isLiteral(zt)) return Type.literal(zt._def.value,description) if (ZodHelpers.isDefault(zt)) return _fromZod(zt._def.innerType,description) if (ZodHelpers.isOptional(zt)) return _fromZod(zt._def.innerType,description) if (ZodHelpers.isUnion(zt)) { - if (!Array.isArray(zt._def.options)) { - throw new Error(`Unsupported zodType: ${JSON.stringify(zt[`_def`])}`) - } - return Type.union( - zt._def.options.map((_: {_def:{description:undefined|string}}) => { - const description = _._def.description - return _fromZod(_ as any,description) - }), - description - ) + if (!Array.isArray(zt._def.options)) throw new Error(`Unsupported zodType: ${JSON.stringify(zt[`_def`])}`) + const members = zt._def.options.map((_: {_def:{description:undefined|string}}) => { + const description = _._def.description + return _fromZod(_ as any, description) + }) + return Type.union(members, description) } // console.log(zt) throw new Error(`Unsupported zodType: ${JSON.stringify(zt[`_def`])}`) } -const mapZodNumberChecks = (checks: z.ZodNumberCheck[]) => { +const mapZodNumberChecksAndTransformations = (checks: z.ZodNumberCheck[]) => { return checks.reduce( (acc, check) => { - return Alge.match(check) - .int(() => ({ ...acc, int: true })) + return { + refinements: { + ...acc.refinements, + ...Alge.match(check) + .int(() => ({ ...acc, int: true })) + .min((check) => ({ + min: check.value, + })) + .max((check) => ({ + max: check.value, + })) + .finite(() => ({ + finite: true, + })) + .multipleOf((check) => ({ + multipleOf: check.value, + })) + .done(), + }, + } + }, + {} as Simplify>, + ) +} + +const mapZodStringChecksAndTransformations = (checks: z.ZodStringCheck[]) => { + const transformations = [`trim`, `toCase`] as const + return checks.reduce( + (acc, check) => { + const refinementOrTransformation = Alge.match(check) + .regex((check) => ({ + regex: check.regex, + })) .min((check) => ({ min: check.value, })) .max((check) => ({ max: check.value, })) - .finite(() => ({ - finite: true, + .url((check) => ({ + pattern: { type: check.kind }, + })) + .cuid((check) => ({ + pattern: { type: check.kind }, + })) + .cuid2(() => ({ + pattern: { type: `cuid2` as const }, + })) + .uuid((check) => ({ + pattern: { type: check.kind }, + })) + .emoji((_) => ({ + pattern: { type: _.kind }, + })) + .ip((_) => ({ + pattern: { + type: _.kind, + version: _.version + ? Alge.match(_.version) + .v4(() => 4 as const) + .v6(() => 6 as const) + .done() + : null, + }, + })) + .ulid((_) => ({ + pattern: { type: _.kind }, + })) + .datetime((check) => ({ + pattern: { + type: `dateTime` as const, + offset: check.offset, + precision: check.precision, + }, + })) + .email((check) => ({ + pattern: { type: check.kind }, + })) + .endsWith((check) => ({ + endsWith: check.value, + })) + .startsWith((check) => ({ + startsWith: check.value, })) - .multipleOf((check) => ({ - multipleOf: check.value, + .length((check) => ({ + length: check.value, + })) + .includes((_) => ({ + includes: _.value, + })) + // transformations + .trim(() => ({ + trim: true, + })) + .toLowerCase(() => ({ + toCase: `lower` as const, + })) + .toUpperCase(() => ({ + toCase: `upper` as const, })) .done() - }, - {} as Simplify>, - ) -} -const mapZodStringChecks = (checks: z.ZodStringCheck[]): Omit => { - return checks.reduce( - (acc, check) => { - return { - ...acc, - ...Alge.match(check) - .regex((check) => ({ - regex: check.regex, - })) - .min((check) => ({ - min: check.value, - })) - .max((check) => ({ - max: check.value, - })) - .url((check) => ({ - pattern: { type: check.kind }, - })) - .cuid((check) => ({ - pattern: { type: check.kind }, - })) - .cuid2(() => ({ - pattern: { type: `cuid2` as const }, - })) - .uuid((check) => ({ - pattern: { type: check.kind }, - })) - .emoji((_) => ({ - pattern: { type: _.kind }, - })) - .ip((_) => ({ - pattern: { - type: _.kind, - version: _.version - ? Alge.match(_.version) - .v4(() => 4 as const) - .v6(() => 6 as const) - .done() - : null, - }, - })) - .ulid((_) => ({ - pattern: { type: _.kind }, - })) - .datetime((check) => ({ - pattern: { - type: `dateTime` as const, - offset: check.offset, - precision: check.precision, - }, - })) - .email((check) => ({ - pattern: { type: check.kind }, - })) - .endsWith((check) => ({ - endsWith: check.value, - })) - .startsWith((check) => ({ - startsWith: check.value, - })) - .length((check) => ({ - length: check.value, - })) - .includes((_) => ({ - includes: _.value, - })) - // transformations - .trim(() => ({ + const isTransformation = transformations.includes(Object.keys(refinementOrTransformation)[0]! as any) + return isTransformation + ? { + ...acc, transformations: { ...acc.transformations, - trim: true, + ...refinementOrTransformation, }, - })) - .toLowerCase(() => ({ - transformations: { - ...acc.transformations, - toCase: `lower` as const, + } + : { + ...acc, + refinements: { + ...acc.refinements, + ...refinementOrTransformation, }, - })) - .toUpperCase(() => ({ - transformations: { - ...acc.transformations, - toCase: `upper` as const, - }, - })) - .done(), - } + } }, - {} as Simplify>, + {} as Simplify>, ) } diff --git a/packages/@molt/command/src/TypeBuilder/TypeBuilder.ts b/packages/@molt/command/src/TypeBuilder/TypeBuilder.ts new file mode 100644 index 00000000..51cd7bf6 --- /dev/null +++ b/packages/@molt/command/src/TypeBuilder/TypeBuilder.ts @@ -0,0 +1,17 @@ +// import type { Type } from '../Type/helpers.js' +// import type { Extension } from './extension.js' + +// // prettier-ignore +// type TypeBuilderBase<$Extensions extends Record> = $Extensions & { +// $use: < +// $Namespace extends string, +// $Type extends Type, +// $Builder extends (...args: any[]) => $Type, +// $Extension extends Extension<$Namespace, $Type,$Builder>, +// >(extension: $Extension) => +// TypeBuilderBase<{ [k in $Extension['namespace']]: $Extension['builder'] } & $Extensions> +// } + +// export const $use: TypeBuilderBase<{}>['$use'] = (extension) => { +// // todo +// } diff --git a/packages/@molt/command/src/TypeBuilder/extension.ts b/packages/@molt/command/src/TypeBuilder/extension.ts new file mode 100644 index 00000000..0331a0bb --- /dev/null +++ b/packages/@molt/command/src/TypeBuilder/extension.ts @@ -0,0 +1,21 @@ +// import type { Type } from '../Type/helpers.js' + +// export interface Extension< +// $Namespace extends string, +// $Type extends Type, +// $Builder extends (...args: any[]) => $Type, +// > { +// namespace: $Namespace +// type: $Type +// builder: $Builder +// } + +// export const createExtension = < +// $Namespace extends string, +// $Type extends Type, +// $Builder extends (...args: any[]) => $Type, +// $Extension extends Extension<$Namespace, $Type, $Builder>, +// >(extension: { +// namespace: $Namespace +// builder: $Builder +// }): $Extension => extension as any as $Extension diff --git a/packages/@molt/command/src/TypeBuilder/index.ts b/packages/@molt/command/src/TypeBuilder/index.ts new file mode 100644 index 00000000..37dab4b1 --- /dev/null +++ b/packages/@molt/command/src/TypeBuilder/index.ts @@ -0,0 +1 @@ +// export * as TypeBuilder from './TypeBuilder.js' diff --git a/packages/@molt/command/src/TypeBuilderExtensions/string/extension.ts b/packages/@molt/command/src/TypeBuilderExtensions/string/extension.ts new file mode 100644 index 00000000..826f426b --- /dev/null +++ b/packages/@molt/command/src/TypeBuilderExtensions/string/extension.ts @@ -0,0 +1,7 @@ +// import { createExtension } from '../../TypeBuilder/extension.js' +// import { string } from './type.js' + +// export const StringType = createExtension({ +// namespace: `string`, +// builder: string, +// }) diff --git a/packages/@molt/command/src/TypeBuilderExtensions/string/type.ts b/packages/@molt/command/src/TypeBuilderExtensions/string/type.ts new file mode 100644 index 00000000..0be1e1d1 --- /dev/null +++ b/packages/@molt/command/src/TypeBuilderExtensions/string/type.ts @@ -0,0 +1,64 @@ +// import type { Type } from '../../Type/helpers.js' + +// export interface String extends Type, Refinements { +// _tag: 'TypeString' +// } + +// export interface Refinements { +// transformations?: { +// trim?: boolean +// toCase?: 'upper' | 'lower' +// } +// regex?: RegExp +// min?: number +// max?: number +// length?: number +// pattern?: +// | { +// type: 'email' +// } +// | { +// type: 'url' +// } +// | { +// type: 'uuid' +// } +// | { +// type: 'cuid' +// } +// | { +// type: 'cuid2' +// } +// | { +// type: 'ulid' +// } +// | { +// type: 'emoji' +// } +// | { +// type: 'ip' +// /** +// * If `null` then either IPv4 or IPv6 is allowed. +// */ +// version: 4 | 6 | null +// } +// | { +// type: 'dateTime' +// offset: boolean +// precision: null | number +// } +// startsWith?: string +// endsWith?: string +// includes?: string +// } + +// // eslint-disable-next-line +// export const string = (refinements?: Refinements, description?: string): String => { +// return { +// _tag: `TypeString`, +// description: description ?? null, +// // todo make phantom type +// type: null as any, // eslint-disable-line +// ...refinements, +// } +// } diff --git a/packages/@molt/command/src/TypeBuilderExtensions/zod.ts b/packages/@molt/command/src/TypeBuilderExtensions/zod.ts new file mode 100644 index 00000000..c259cbf1 --- /dev/null +++ b/packages/@molt/command/src/TypeBuilderExtensions/zod.ts @@ -0,0 +1,7 @@ +// import { TypeAdaptors } from '../TypeAdaptors/index.js' +// import { createExtension } from '../TypeBuilder/extension.js' + +// export const ZodTypes = createExtension({ +// namespace: `$fromZod`, +// properties: TypeAdaptors.Zod.fromZod, +// }) diff --git a/packages/@molt/command/src/eventPatterns.ts b/packages/@molt/command/src/eventPatterns.ts index 08d77fd3..b8079a94 100644 --- a/packages/@molt/command/src/eventPatterns.ts +++ b/packages/@molt/command/src/eventPatterns.ts @@ -40,9 +40,9 @@ export interface BasicParameterParseEventRejected { export const createEvent = (parseResult: OpeningArgs.ParseResultBasic) => { const specData: CommandParameter.Output.BasicData = { - ...parseResult.spec, + ...parseResult.parameter, _tag: `BasicData` as const, - optionality: parseResult.spec.optionality[`_tag`], + optionality: parseResult.parameter.optionality[`_tag`], } // : { // ...parseResult.spec, diff --git a/packages/@molt/command/src/executor/parse.ts b/packages/@molt/command/src/executor/parse.ts index 23964116..7bcb07f6 100644 --- a/packages/@molt/command/src/executor/parse.ts +++ b/packages/@molt/command/src/executor/parse.ts @@ -22,7 +22,7 @@ export interface ParseProgressPostPromptAnnotation { string, { openingParseResult: OpeningArgs.ParseResult['basicParameters'][string] - spec: OpeningArgs.ParseResult['basicParameters'][string]['spec'] + spec: OpeningArgs.ParseResult['basicParameters'][string]['parameter'] prompt: { enabled: boolean } @@ -36,7 +36,7 @@ export interface ParseProgressPostPrompt { basicParameters: Record< string, { - spec: OpeningArgs.ParseResult['basicParameters'][string]['spec'] + spec: OpeningArgs.ParseResult['basicParameters'][string]['parameter'] openingParseResult: OpeningArgs.ParseResult['basicParameters'][string] prompt: { enabled: boolean @@ -52,7 +52,7 @@ export interface ParseProgressDone { basicParameters: Record< string, { - spec: OpeningArgs.ParseResult['basicParameters'][string]['spec'] + spec: OpeningArgs.ParseResult['basicParameters'][string]['parameter'] openingParseResult: OpeningArgs.ParseResult['basicParameters'][string] prompt: { enabled: boolean @@ -82,7 +82,7 @@ export const parse = ( // dump(specsResult) const openingArgsResult = OpeningArgs.parse({ - specs: specsResult.specs, + parameters: specsResult.specs, line: argInputsLine, environment: argInputsEnvironment, }) @@ -97,7 +97,7 @@ export const parse = ( Object.entries(openingArgsResult.basicParameters).map(([parameterName, openingParseResult]) => { const data = { openingParseResult, - spec: openingParseResult.spec, + spec: openingParseResult.parameter, prompt: { enabled: false, }, diff --git a/packages/@molt/command/src/executor/prompt.ts b/packages/@molt/command/src/executor/prompt.ts index 553ba7db..ecbad15e 100644 --- a/packages/@molt/command/src/executor/prompt.ts +++ b/packages/@molt/command/src/executor/prompt.ts @@ -18,20 +18,20 @@ export const prompt = ( if (prompter === null) return parseProgress as ParseProgressPostPrompt const args: Record = {} - const parameterSpecs = Object.entries(parseProgress.basicParameters) + const parameters = Object.entries(parseProgress.basicParameters) .filter((_) => _[1].prompt.enabled) .map((_) => _[1].spec) - const indexTotal = parameterSpecs.length + const indexTotal = parameters.length let indexCurrent = 1 const gutterWidth = String(indexTotal).length * 2 + 3 - for (const param of parameterSpecs) { + for (const parameter of parameters) { // prettier-ignore const question = Tex({ flow: `horizontal`}) .block({ padding: { right: 2 }}, `${Term.colors.dim(`${indexCurrent}/${indexTotal}`)}`) .block((__) => - __.block(Term.colors.positive(param.name.canonical) + `${param.optionality._tag === `required` ? `` : chalk.dim(` optional (press esc to skip)`)}`) - .block((param.description && Term.colors.dim(param.description)) ?? null) + __.block(Term.colors.positive(parameter.name.canonical) + `${parameter.optionality._tag === `required` ? `` : chalk.dim(` optional (press esc to skip)`)}`) + .block((parameter.description && Term.colors.dim(parameter.description)) ?? null) ) .render() // eslint-disable-next-line no-constant-condition @@ -40,12 +40,14 @@ export const prompt = ( question, prompt: `❯ `, marginLeft: gutterWidth, - parameter: param, + parameter: parameter, }) + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const arg = yield* _(asking) - const validationResult = CommandParameter.validate(param, arg) + const validationResult = CommandParameter.validate(parameter, arg) if (validationResult._tag === `Right`) { - args[param.name.canonical] = validationResult.right + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + args[parameter.name.canonical] = validationResult.right prompter.say(``) // newline indexCurrent++ break diff --git a/packages/@molt/command/src/helpers.ts b/packages/@molt/command/src/helpers.ts index 3d53bef7..5b6b36e7 100644 --- a/packages/@molt/command/src/helpers.ts +++ b/packages/@molt/command/src/helpers.ts @@ -1,3 +1,4 @@ +import { Either } from 'effect' import camelCase from 'lodash.camelcase' import { z } from 'zod' @@ -25,17 +26,20 @@ export const getLowerCaseEnvironment = (): NodeJS.ProcessEnv => lowerCaseObjectK export const lowerCaseObjectKeys = (obj: object) => Object.fromEntries(Object.entries(obj).map(([k, v]) => [k.toLowerCase(), v])) -export const parseEnvironmentVariableBoolean = (value: string): boolean | null => +export const parseEnvironmentVariableBoolean = (serializedValue: string): Either.Either => { // @ts-expect-error ignore // eslint-disable-next-line - environmentVariableBooleanLookup[value] ?? null + const value = environmentVariableBooleanLookup[serializedValue] + if (value === undefined) return Either.left(new Error(`Invalid boolean value: ${value}`)) + return Either.right(value) +} export const parseEnvironmentVariableBooleanOrThrow = (value: string) => { const result = parseEnvironmentVariableBoolean(value) - if (result === null) { - throw new Error(`Invalid boolean value: ${value}`) + if (Either.isLeft(result)) { + throw result.left } - return result + return result.right } export const negateNamePattern = /^no([A-Z].+)/ @@ -67,7 +71,7 @@ export const invertTable = (rows: T[][]): T[][] => { return columns } -export const entries = >( +export const entries = ( obj: O, ): Exclude<{ [k in keyof O]: [k, O[k]] }[keyof O], undefined>[] => Object.entries(obj) as any diff --git a/packages/@molt/command/src/lib/Prompter/Constructors/_core.ts b/packages/@molt/command/src/lib/Prompter/Constructors/_core.ts index 02eebc5a..de09a130 100644 --- a/packages/@molt/command/src/lib/Prompter/Constructors/_core.ts +++ b/packages/@molt/command/src/lib/Prompter/Constructors/_core.ts @@ -2,7 +2,7 @@ import type { Type } from '../../../Type/index.js' import type { Pam } from '../../Pam/index.js' import type { PromptEngine } from '../../PromptEngine/PromptEngine.js' import { Text } from '../../Text/index.js' -import { inputForParameter } from '../Input/_core.js' +import { inputForParameter } from '../input.js' import type { Effect } from 'effect' export interface Prompter { diff --git a/packages/@molt/command/src/lib/Prompter/Input/_core.ts b/packages/@molt/command/src/lib/Prompter/Input/_core.ts deleted file mode 100644 index ae98ebf0..00000000 --- a/packages/@molt/command/src/lib/Prompter/Input/_core.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { casesExhausted } from '../../../helpers.js' -import type { Type } from '../../../Type/index.js' -import type { Pam } from '../../Pam/index.js' -import type { PromptEngine } from '../../PromptEngine/PromptEngine.js' -import * as Inputs from './types/index.js' - -export interface Params { - channels: PromptEngine.Channels - prompt: string - marginLeft?: number - parameter: parameter -} - -export const inputForParameter = (params: Params) => { - const { parameter } = params - - if (parameter.type._tag === `TypeLiteral`) { - throw new Error(`Literals are not supported yet.`) - } - - if (parameter.type._tag === `TypeUnion`) { - return Inputs.union(params as Params>) - } - - if (parameter.type._tag === `TypeBoolean`) { - return Inputs.boolean(params as Params>) - } - - if (parameter.type._tag === `TypeString`) { - return Inputs.string(params as Params>) - } - - if (parameter.type._tag === `TypeNumber`) { - return Inputs.number(params as Params>) - } - - if (parameter.type._tag === `TypeEnum`) { - return Inputs.enumeration(params as Params>) - } - - throw casesExhausted(parameter.type) -} diff --git a/packages/@molt/command/src/lib/Prompter/Input/types/boolean.ts b/packages/@molt/command/src/lib/Prompter/Input/types/boolean.ts deleted file mode 100644 index 134b0f94..00000000 --- a/packages/@molt/command/src/lib/Prompter/Input/types/boolean.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Pam } from '../../../Pam/index.js' -import { PromptEngine } from '../../../PromptEngine/PromptEngine.js' -import type { Params } from '../_core.js' -import chalk from 'chalk' -import { Effect } from 'effect' -import type { Type } from 'packages/@molt/command/src/Type/index.js' - -export const boolean = (params: Params>) => - Effect.gen(function* (_) { - interface State { - answer: boolean - } - const initialState: State = { - answer: false, - } - const marginLeftSpace = ` `.repeat(params.marginLeft ?? 0) - const pipe = `${chalk.dim(`|`)}` - const no = `${chalk.green(chalk.bold(`no`))} ${pipe} yes` - const yes = `no ${pipe} ${chalk.green(chalk.bold(`yes`))}` - const prompt = PromptEngine.create({ - channels: params.channels, - initialState, - on: [ - { - match: [`left`, `n`], - run: (_state) => ({ answer: false }), - }, - { - match: [`right`, `y`], - run: (_state) => ({ answer: true }), - }, - { - match: `tab`, - run: (state) => ({ answer: !state.answer }), - }, - ], - draw: (state) => { - return marginLeftSpace + params.prompt + (state.answer ? yes : no) - }, - }) - const state = yield* _(prompt) - if (state === null) return undefined - return state.answer - }) diff --git a/packages/@molt/command/src/lib/Prompter/Input/types/enumeration.ts b/packages/@molt/command/src/lib/Prompter/Input/types/enumeration.ts deleted file mode 100644 index 093005f9..00000000 --- a/packages/@molt/command/src/lib/Prompter/Input/types/enumeration.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { Pam } from '../../../Pam/index.js' -import { PromptEngine } from '../../../PromptEngine/PromptEngine.js' -import type { Params } from '../_core.js' -import chalk from 'chalk' -import { Effect } from 'effect' -import type { Type } from 'packages/@molt/command/src/Type/index.js' - -export const enumeration = (params: Params>) => - Effect.gen(function* (_) { - interface State { - active: number - } - const initialState: State = { - active: 0, - } - const { parameter } = params - const marginLeftSpace = ` `.repeat(params.marginLeft ?? 0) - const prompt = PromptEngine.create({ - channels: params.channels, - initialState, - on: [ - { - match: [`left`, { name: `tab`, shift: true }], - run: (state) => ({ - active: state.active === 0 ? parameter.type.members.length - 1 : state.active - 1, - }), - }, - { - match: [`right`, { name: `tab`, shift: false }], - run: (state) => ({ - active: state.active === parameter.type.members.length - 1 ? 0 : state.active + 1, - }), - }, - ], - draw: (state) => { - return ( - marginLeftSpace + - params.prompt + - parameter.type.members - .map((item, i) => (i === state.active ? `${chalk.green(chalk.bold(item))}` : item)) - .join(chalk.dim(` | `)) - ) - }, - }) - const state = yield* _(prompt) - - if (state === null) return undefined - - const choice = parameter.type.members[state.active] - // prettier-ignore - if (!choice) throw new Error(`No choice selected. Enumeration must be empty. But enumerations should not be empty. This is a bug.`) - return choice - }) diff --git a/packages/@molt/command/src/lib/Prompter/Input/types/index.ts b/packages/@molt/command/src/lib/Prompter/Input/types/index.ts deleted file mode 100644 index e735bfa7..00000000 --- a/packages/@molt/command/src/lib/Prompter/Input/types/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from './boolean.js' -export * from './enumeration.js' -export * from './number.js' -export * from './string.js' -export * from './union.js' diff --git a/packages/@molt/command/src/lib/Prompter/Input/types/number.ts b/packages/@molt/command/src/lib/Prompter/Input/types/number.ts deleted file mode 100644 index ec0b5b7a..00000000 --- a/packages/@molt/command/src/lib/Prompter/Input/types/number.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { Pam } from '../../../Pam/index.js' -import { PromptEngine } from '../../../PromptEngine/PromptEngine.js' -import type { Params } from '../_core.js' -import { Effect } from 'effect' -import type { Type } from 'packages/@molt/command/src/Type/index.js' - -export const number = (params: Params>) => - Effect.gen(function* (_) { - interface State { - value: string - } - const initialState: State = { value: `` } - const marginLeftSpace = ` `.repeat(params.marginLeft ?? 0) - const prompt = PromptEngine.create({ - channels: params.channels, - cursor: true, - skippable: params.parameter.optionality._tag !== `required`, - initialState, - on: [ - { - run: (state, event) => { - return { - value: event.name === `backspace` ? state.value.slice(0, -1) : state.value + event.sequence, - } - }, - }, - ], - draw: (state) => { - return marginLeftSpace + params.prompt + state.value - }, - }) - const state = yield* _(prompt) - if (state === null) return undefined - if (state.value === ``) return undefined - const valueParsed = parseFloat(state.value) - if (isNaN(valueParsed)) return null as any // todo remove cast - return valueParsed - }) diff --git a/packages/@molt/command/src/lib/Prompter/Input/types/string.ts b/packages/@molt/command/src/lib/Prompter/Input/types/string.ts deleted file mode 100644 index f6be222a..00000000 --- a/packages/@molt/command/src/lib/Prompter/Input/types/string.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { Pam } from '../../../Pam/index.js' -import { PromptEngine } from '../../../PromptEngine/PromptEngine.js' -import type { Params } from '../_core.js' -import { Effect } from 'effect' -import type { Type } from 'packages/@molt/command/src/Type/index.js' - -export const string = (params: Params>) => - Effect.gen(function* (_) { - interface State { - value: string - } - const initialState: State = { value: `` } - const marginLeftSpace = ` `.repeat(params.marginLeft ?? 0) - const prompt = PromptEngine.create({ - channels: params.channels, - cursor: true, - skippable: params.parameter.optionality._tag !== `required`, - initialState, - on: [ - { - run: (state, event) => { - return { - value: event.name === `backspace` ? state.value.slice(0, -1) : state.value + event.sequence, - } - }, - }, - ], - draw: (state) => { - return marginLeftSpace + params.prompt + state.value - }, - }) - const state = yield* _(prompt) - if (state === null) return undefined - if (state.value === ``) return undefined - return state.value - }) diff --git a/packages/@molt/command/src/lib/Prompter/Input/types/union.ts b/packages/@molt/command/src/lib/Prompter/Input/types/union.ts deleted file mode 100644 index e8c0b3c2..00000000 --- a/packages/@molt/command/src/lib/Prompter/Input/types/union.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { Pam } from '../../../Pam/index.js' -import { PromptEngine } from '../../../PromptEngine/PromptEngine.js' -import { Text } from '../../../Text/index.js' -import { create } from '../../Constructors/_core.js' -import type { Params } from '../_core.js' -import chalk from 'chalk' -import { Effect } from 'effect' -import type { Type } from 'packages/@molt/command/src/Type/index.js' - -export const union = (params: Params>) => - Effect.gen(function* (_) { - interface State { - active: number - } - const initialState: State = { - active: 0, - } - const { parameter } = params - const prompt = PromptEngine.create({ - channels: params.channels, - initialState, - on: [ - { - match: [`left`, { name: `tab`, shift: true }], - run: (state) => ({ - active: state.active === 0 ? parameter.type.members.length - 1 : state.active - 1, - }), - }, - { - match: [`right`, { name: `tab`, shift: false }], - run: (state) => ({ - active: state.active === parameter.type.members.length - 1 ? 0 : state.active + 1, - }), - }, - ], - draw: (state) => { - const marginLeftSpace = ` `.repeat(params.marginLeft ?? 0) - // prettier-ignore - const intro = marginLeftSpace + `Different kinds of answers are accepted.` + Text.chars.newline + marginLeftSpace + `Which kind do you want to give?` - // prettier-ignore - const typeNameMapping: Record = { - TypeBoolean:`boolean`, - TypeEnum: `enum`, - TypeLiteral: `literal`, - TypeNumber: `number`, - TypeString: `string`, - TypeUnion: `union` - } - const choices = - marginLeftSpace + - params.prompt + - parameter.type.members - .map((item, i) => - i === state.active - ? `${chalk.green(chalk.bold(typeNameMapping[item._tag]))}` - : typeNameMapping[item._tag], - ) - .join(chalk.dim(` | `)) - return Text.chars.newline + intro + Text.chars.newline + Text.chars.newline + choices - }, - }) - - const state = yield* _(prompt) - - if (state === null) return undefined - - const choice = parameter.type.members[state.active] - // prettier-ignore - if (!choice) throw new Error(`No choice selected. Enumeration must be empty. But enumerations should not be empty. This is a bug.`) - - return yield* _( - create(params.channels).ask({ - ...params, - parameter: { - ...parameter, - ...{ type: choice }, - }, - question: ``, - }), - ) - }) diff --git a/packages/@molt/command/src/lib/Prompter/input.ts b/packages/@molt/command/src/lib/Prompter/input.ts new file mode 100644 index 00000000..23195fe9 --- /dev/null +++ b/packages/@molt/command/src/lib/Prompter/input.ts @@ -0,0 +1,22 @@ +import type { Pam } from '../Pam/index.js' +import type { PromptEngine } from '../PromptEngine/PromptEngine.js' + +export interface Params { + channels: PromptEngine.Channels + prompt: string + marginLeft?: number + parameter: parameter +} + +export const inputForParameter = (params: Params) => { + const { parameter } = params + + if (parameter.type._tag === `TypeLiteral`) { + throw new Error(`Literals are not supported yet.`) + } + + return params.parameter.type.prompt({ + ...params, + optionality: params.parameter.optionality, + }) +} diff --git a/packages/@molt/command/tests/help/__snapshots__/rendering.spec.ts.snap b/packages/@molt/command/tests/help/__snapshots__/rendering.spec.ts.snap index 2089afc4..c4731c81 100644 --- a/packages/@molt/command/tests/help/__snapshots__/rendering.spec.ts.snap +++ b/packages/@molt/command/tests/help/__snapshots__/rendering.spec.ts.snap @@ -972,7 +972,7 @@ PARAMETERS Name Type Default Environment (1) - bar string | number REQUIRED ✓ + bar number | string REQUIRED ✓ b @@ -992,7 +992,7 @@ PARAMETERS Name Type Default Environment (1) - bar string | number  REQUIRED  ✓ + bar number | string  REQUIRED  ✓ b  @@ -1012,7 +1012,7 @@ PARAMETERS Name Type/Description Default Environment (1) - bar string | number REQUIRED ✓ + bar number | string REQUIRED ✓ b Blah blah blah. @@ -1032,7 +1032,7 @@ PARAMETERS Name Type/Description Default Environment (1) - bar string | number  REQUIRED  ✓ + bar number | string  REQUIRED  ✓ b Blah blah blah.  @@ -1053,8 +1053,8 @@ PARAMETERS Name Type Default Environment (1) bar ┌─union REQUIRED ✓ - b ◒ string - ◒ number + b ◒ number + ◒ string └─ @@ -1075,8 +1075,8 @@ PARAMETERS Name Type Default Environment (1) bar ┌─union  REQUIRED  ✓ - b ◒ string - ◒ number + b ◒ number + ◒ string └─  @@ -1097,10 +1097,10 @@ PARAMETERS Name Type Default Environment (1) bar ┌─union REQUIRED ✓ - b ◒ string - │ - ◒ number + b ◒ number │ Blah blah blah number. + │ + ◒ string └─ @@ -1121,10 +1121,10 @@ PARAMETERS Name Type Default Environment (1) bar ┌─union  REQUIRED  ✓ - b ◒ string - │ - ◒ number + b ◒ number │ Blah blah blah number. + │ + ◒ string └─  @@ -1145,11 +1145,11 @@ PARAMETERS Name Type Default Environment (1) bar ┌─union REQUIRED ✓ - b ◒ string - │ Blah blah blah string. - │ - ◒ number + b ◒ number │ Blah blah blah number. + │ + ◒ string + │ Blah blah blah string. └─ @@ -1170,11 +1170,11 @@ PARAMETERS Name Type Default Environment (1) bar ┌─union  REQUIRED  ✓ - b ◒ string - │ Blah blah blah string. - │ - ◒ number + b ◒ number │ Blah blah blah number. + │ + ◒ string + │ Blah blah blah string. └─  @@ -1196,12 +1196,12 @@ PARAMETERS bar ┌─union REQUIRED ✓ b │ Blah blah blah overall. - │ - ◒ string - │ Blah blah blah string. │ ◒ number │ Blah blah blah number. + │ + ◒ string + │ Blah blah blah string. └─ @@ -1223,12 +1223,12 @@ PARAMETERS bar ┌─union  REQUIRED  ✓ b │ Blah blah blah overall. - │ - ◒ string - │ Blah blah blah string. │ ◒ number │ Blah blah blah number. + │ + ◒ string + │ Blah blah blah string. └─  @@ -1250,12 +1250,12 @@ PARAMETERS bar ┌─union undefined ✓ b │ Blah blah blah overall. - │ - ◒ string - │ Blah blah blah string. │ ◒ number │ Blah blah blah number. + │ + ◒ string + │ Blah blah blah string. └─ @@ -1277,12 +1277,12 @@ PARAMETERS bar ┌─union undefined ✓ b │ Blah blah blah overall. - │ - ◒ string - │ Blah blah blah string. │ ◒ number │ Blah blah blah number. + │ + ◒ string + │ Blah blah blah string. └─  @@ -1304,12 +1304,12 @@ PARAMETERS bar ┌─union 1 ✓ b │ Blah blah blah overall. - │ - ◒ string - │ Blah blah blah string. │ ◒ number │ Blah blah blah number. + │ + ◒ string + │ Blah blah blah string. └─ @@ -1331,12 +1331,12 @@ PARAMETERS bar ┌─union 1 ✓ b │ Blah blah blah overall. - │ - ◒ string - │ Blah blah blah string. │ ◒ number │ Blah blah blah number. + │ + ◒ string + │ Blah blah blah string. └─  diff --git a/packages/@molt/command/tests/prompt/__snapshots__/prompt.spec.ts.snap b/packages/@molt/command/tests/prompt/__snapshots__/prompt.spec.ts.snap index bbcb2da2..b70e533a 100644 --- a/packages/@molt/command/tests/prompt/__snapshots__/prompt.spec.ts.snap +++ b/packages/@molt/command/tests/prompt/__snapshots__/prompt.spec.ts.snap @@ -901,19 +901,17 @@ exports[`union > required > asks user to select member to use" > tty 1`] = ` Different kinds of answers are accepted. Which kind do you want to give? - ❯ string | boolean | number", + ❯ number | boolean | string", "", "", " Different kinds of answers are accepted. Which kind do you want to give? - ❯ string | boolean | number", + ❯ number | boolean | string", " ", "[?25h", - " -", "[?25l", "", "", @@ -940,19 +938,17 @@ exports[`union > required > asks user to select member to use" > tty strip ansi Different kinds of answers are accepted. Which kind do you want to give? - ❯ string | boolean | number", + ❯ number | boolean | string", "", "", " Different kinds of answers are accepted. Which kind do you want to give? - ❯ string | boolean | number", + ❯ number | boolean | string", " ", "", - " -", "", "", "", diff --git a/packages/@molt/command/tests/prompt/prompt.spec.ts b/packages/@molt/command/tests/prompt/prompt.spec.ts index 776defd3..e3747cf5 100644 --- a/packages/@molt/command/tests/prompt/prompt.spec.ts +++ b/packages/@molt/command/tests/prompt/prompt.spec.ts @@ -97,7 +97,7 @@ describe(`boolean`, () => { describe(`union`, () => { describe(`required`, () => { it(`asks user to select member to use"`, async () => { - parameters = { a: { schema: z.union([s, b, n]), prompt: true } } + parameters = { a: { schema: z.union([n, b, s]), prompt: true } } keyPresses.push({ ctrl: false, meta: false, sequence: ``, shift: false, name: `tab` }) keyPresses.push({ ctrl: false, meta: false, sequence: ``, shift: false, name: `return` }) keyPresses.push({ ctrl: false, meta: false, sequence: ``, shift: false, name: `tab` })