Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions src/completion/enumerate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,8 @@ export async function enumerate (
const eqIdx = incomplete.indexOf('=')
const flag = incomplete.slice(0, eqIdx)
const partial = incomplete.slice(eqIdx + 1)
const completer = registry?.get(flag)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const completer = registry?.get(flag) ?? (current as any)._enumCompleters?.get(flag) as DynamicCompleter | undefined
if (completer != null) {
const cands = await safeRun(completer)
return {
Expand All @@ -206,7 +207,8 @@ export async function enumerate (
}

if (previous != null && previous.startsWith('--')) {
const completer = registry?.get(previous)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const completer = registry?.get(previous) ?? (current as any)._enumCompleters?.get(previous) as DynamicCompleter | undefined
if (completer != null) {
const cands = await safeRun(completer)
return {
Expand Down
11 changes: 11 additions & 0 deletions src/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,17 @@ export function defineCommand<T extends z.ZodType> (config: CommandConfig<T>): O
} else if (arg.type === 'enum') {
const attrName = camelCase(arg.cliFlag)
cmd.option(`--${arg.cliFlag} <value>`, desc, singleValueGuard<string>(cmd, attrName, `--${arg.cliFlag}`))
if (arg.enumValues != null && arg.enumValues.length > 0) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let completers = (cmd as any)._enumCompleters as Map<string, () => string[]> | undefined
if (completers == null) {
completers = new Map()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Object.defineProperty(cmd as any, '_enumCompleters', { value: completers, enumerable: false })
}
const vals = arg.enumValues
completers.set(`--${arg.cliFlag}`, () => Array.from(vals))
}
} else {
// string: accumulate repeated values with comma separation
const attrName = camelCase(arg.cliFlag)
Expand Down
38 changes: 37 additions & 1 deletion src/lib/schema-args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ export interface SchemaArgDefinition {
*/
acceptsArrayForm?: boolean

/**
* Valid enum values extracted from the Zod schema, present when `type === 'enum'`.
* Used by the completion engine to offer candidates for this flag without a live cluster.
*/
enumValues?: readonly string[]

/**
* Marks args whose CLI string value needs a non-trivial transformation before reaching the wire.
*
Expand Down Expand Up @@ -87,6 +93,8 @@ interface ZodFieldDef {
defaultValue?: unknown
getter?: () => z.ZodType
options?: z.ZodType[]
/** Present on `z.enum(...)` nodes — maps each value to itself. */
entries?: Record<string, string>
}

/**
Expand Down Expand Up @@ -124,6 +132,32 @@ function unwrapField (field: z.ZodType): { typeName: string, isOptional: boolean
return { typeName: def.type, isOptional: false }
}

/**
* Walks a Zod field through wrapper types (`optional`, `default`, `lazy`, `union`) and
* returns the enum values if any branch resolves to a `z.enum(...)` node.
*
* Returns `undefined` for non-enum fields. Never throws.
*/
function extractEnumValues (field: z.ZodType): readonly string[] | undefined {
const def = field.def as ZodFieldDef
if (def.type === 'enum' && def.entries != null) {
return Object.values(def.entries)
}
if ((def.type === 'optional' || def.type === 'default') && def.innerType != null) {
return extractEnumValues(def.innerType as z.ZodType)
}
if (def.type === 'lazy' && typeof def.getter === 'function') {
return extractEnumValues(def.getter())
}
if (def.type === 'union' && Array.isArray(def.options)) {
for (const opt of def.options) {
const vals = extractEnumValues(opt)
if (vals != null) return vals
}
}
return undefined
}

const CLI_TYPES = new Set(['string', 'number', 'boolean', 'object', 'array', 'enum'])

/**
Expand Down Expand Up @@ -158,6 +192,7 @@ export function extractSchemaArgs (schema: unknown): SchemaArgDefinition[] {
const foundIn = extractFoundIn(fieldSchema as z.ZodType)
const acceptsArrayForm = type !== 'array' && schemaAcceptsArrayForm(fieldSchema as z.ZodType)
const parseStyle = schemaContainsId(fieldSchema as z.ZodType, 'Sort') ? 'sort-pairs' as const : undefined
const enumValues = type === 'enum' ? extractEnumValues(fieldSchema as z.ZodType) : undefined
return {
schemaKey: key,
cliFlag: toKebabCase(key),
Expand All @@ -167,7 +202,8 @@ export function extractSchemaArgs (schema: unknown): SchemaArgDefinition[] {
description,
...(foundIn !== undefined ? { foundIn } : {}),
...(acceptsArrayForm ? { acceptsArrayForm: true } : {}),
...(parseStyle !== undefined ? { parseStyle } : {})
...(parseStyle !== undefined ? { parseStyle } : {}),
...(enumValues !== undefined ? { enumValues } : {})
}
})
}
Expand Down
44 changes: 44 additions & 0 deletions test/completion/enumerate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,3 +261,47 @@ describe('enumerate -- edge cases', () => {
assert.deepEqual(result.candidates, ['stack'])
})
})

describe('enumerate -- command-level enum completers (_enumCompleters)', () => {
function buildProgramWithEnumFlag (): Command {
const program = buildSampleProgram()
const catNodes = defineCommand({
name: 'nodes',
description: 'Cat nodes',
handler: () => ({}),
})
// Simulate what factory attaches for enum flags
const completers = new Map<string, () => string[]>([
['--h', () => ['cpu', 'ram', 'name']],
])
Object.defineProperty(catNodes, '_enumCompleters', { value: completers, enumerable: false })
const cat = defineGroup({ name: 'cat', description: 'Cat APIs' }, catNodes)
const es = defineGroup({ name: 'es', description: 'ES' }, cat)
program.addCommand(es)
return program
}

it('returns enum values when previous word is a flag with a command-level completer', async () => {
const result = await enumerate(buildProgramWithEnumFlag(), ['es', 'cat', 'nodes', '--h', ''])
assert.deepEqual(result.candidates, ['cpu', 'ram', 'name'])
})

it('prefix-filters enum candidates', async () => {
const result = await enumerate(buildProgramWithEnumFlag(), ['es', 'cat', 'nodes', '--h', 'c'])
assert.deepEqual(result.candidates, ['cpu'])
})

it('supports --flag=partial form for command-level enum completers', async () => {
const result = await enumerate(buildProgramWithEnumFlag(), ['es', 'cat', 'nodes', '--h=ra'])
assert.deepEqual(result.candidates, ['ram'])
assert.equal(result.directive & DIRECTIVE_NO_SPACE, DIRECTIVE_NO_SPACE)
})

it('global registry completer takes priority over command-level enum completer', async () => {
const registry: DynamicCompleterRegistry = {
get: (flag) => flag === '--h' ? () => ['override'] : undefined,
}
const result = await enumerate(buildProgramWithEnumFlag(), ['es', 'cat', 'nodes', '--h', ''], registry)
assert.deepEqual(result.candidates, ['override'])
})
})
34 changes: 34 additions & 0 deletions test/lib/schema-args.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,3 +316,37 @@ describe('extractSchemaArgs acceptsArrayForm detection (#167)', () => {
assert.notEqual(args[0]?.acceptsArrayForm, true)
})
})

describe('extractSchemaArgs -- enumValues', () => {
it('populates enumValues for a plain z.enum field', () => {
const schema = z.object({ col: z.enum(['cpu', 'ram', 'name']) })
const args = extractSchemaArgs(schema)
assert.deepEqual(args[0]?.enumValues, ['cpu', 'ram', 'name'])
})

it('populates enumValues through optional wrapper', () => {
const schema = z.object({ col: z.enum(['cpu', 'ram']).optional() })
const args = extractSchemaArgs(schema)
assert.deepEqual(args[0]?.enumValues, ['cpu', 'ram'])
})

it('populates enumValues from union(enum, string) — ES cat column pattern', () => {
const CatColumn = z.union([z.enum(['cpu', 'ram', 'name']), z.string()])
const schema = z.object({ h: CatColumn.optional() })
const args = extractSchemaArgs(schema)
assert.deepEqual(args[0]?.enumValues, ['cpu', 'ram', 'name'])
assert.equal(args[0]?.type, 'enum')
})

it('leaves enumValues undefined for plain string fields', () => {
const schema = z.object({ name: z.string() })
const args = extractSchemaArgs(schema)
assert.equal(args[0]?.enumValues, undefined)
})

it('leaves enumValues undefined for number fields', () => {
const schema = z.object({ count: z.number() })
const args = extractSchemaArgs(schema)
assert.equal(args[0]?.enumValues, undefined)
})
})
Loading