Skip to content

Commit a2af21e

Browse files
committed
Support number vals. Add unit tests.
1 parent a025223 commit a2af21e

File tree

5 files changed

+136
-30
lines changed

5 files changed

+136
-30
lines changed

lib/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export function createCommand<T extends z.ZodSchema>(config: {
2828
description: string
2929
args: T
3030
handler: (args: z.infer<T>) => void | Promise<void>
31-
}): Command {
31+
}): Command<T> {
3232
return {
3333
name: config.name,
3434
description: config.description,

lib/parse.test.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { describe, expect, it } from 'bun:test'
2+
import { z } from 'zod/v4'
3+
import { parseArgs } from './parse'
4+
5+
describe('parseArgs', () => {
6+
it('should parse long-form flags with values', () => {
7+
const schema = z.object({
8+
name: z.string()
9+
})
10+
const argv = ['--name', 'test']
11+
const result = parseArgs({ argv, schema })
12+
expect(result).toEqual({ name: 'test' })
13+
})
14+
15+
it('should parse long-form boolean flags', () => {
16+
const schema = z.object({
17+
bool: z.boolean().optional()
18+
})
19+
const argv = ['--bool']
20+
const result = parseArgs({ argv, schema })
21+
expect(result).toEqual({ bool: true })
22+
})
23+
24+
it('should parse long-form number flags', () => {
25+
const schema = z.object({
26+
number: z.number()
27+
})
28+
const argv = ['--number', '123']
29+
const result = parseArgs({ argv, schema })
30+
expect(result).toEqual({ number: 123 })
31+
})
32+
33+
it('should convert kebab-case flags to camelCase', () => {
34+
const schema = z.object({
35+
kebabCase: z.boolean().optional()
36+
})
37+
const argv = ['--kebab-case']
38+
const result = parseArgs({ argv, schema })
39+
expect(result).toEqual({ kebabCase: true })
40+
})
41+
42+
it('should parse short-form boolean flags', () => {
43+
const schema = z.object({
44+
bool: z.boolean().optional()
45+
})
46+
const argv = ['-b']
47+
const result = parseArgs({ argv, schema })
48+
expect(result).toEqual({ bool: true })
49+
})
50+
51+
it('should parse short-form flags with values', () => {
52+
const schema = z.object({
53+
name: z.string()
54+
})
55+
const argv = ['-n', 'test']
56+
const result = parseArgs({ argv, schema })
57+
expect(result).toEqual({ name: 'test' })
58+
})
59+
60+
it('should throw an error for a missing value for a long-form flag', () => {
61+
const schema = z.object({
62+
name: z.string()
63+
})
64+
const argv = ['--name']
65+
expect(() => {
66+
try {
67+
parseArgs({ argv, schema })
68+
} catch (error) {
69+
throw error.message
70+
}
71+
}).toThrow('Missing value for flag --name')
72+
})
73+
74+
it.skip('should handle combined short-form boolean flags', () => {
75+
const schema = z.object({
76+
bool1: z.boolean().optional(),
77+
bool2: z.boolean().optional()
78+
})
79+
const argv = ['-vf']
80+
const result = parseArgs({ argv, schema })
81+
expect(result).toEqual({ bool1: true, bool2: true })
82+
})
83+
84+
it.skip('should handle combined short-form flags with a value', () => {
85+
const schema = z.object({
86+
name: z.string(),
87+
bool: z.boolean().optional()
88+
})
89+
const argv = ['-nf', 'test']
90+
const result = parseArgs({ argv, schema })
91+
expect(result).toEqual({ name: 'test', bool: true })
92+
})
93+
})

lib/parse.ts

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { z } from 'zod/v4'
22
import { format } from './colors'
33
import { showHelp } from './help'
44
import type { Command } from './types'
5+
import { kebabToCamel } from './utils'
56

67
export async function parseCommand({
78
commands,
@@ -55,31 +56,47 @@ export async function parseCommand({
5556
}
5657
}
5758

58-
function parseArgs<T extends z.ZodType>({
59+
const FLAG_REGEX = /^--([a-zA-Z0-9-_]+)$/
60+
const SHORT_FLAG_REGEX = /^-([a-zA-Z0-9-_])$/
61+
62+
export function parseArgs<T extends z.ZodType>({
5963
argv,
6064
schema
6165
}: {
6266
argv: string[]
6367
schema: T
6468
}): z.infer<T> {
6569
const args: Record<string, unknown> = {}
66-
const flagRegex = /^--([a-zA-Z0-9-_]+)$/
67-
const shortFlagRegex = /^-([a-zA-Z0-9-_]+)$/
68-
69-
const kebabToCamel = (str: string): string =>
70-
str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
7170

7271
for (let i = 0; i < argv.length; i++) {
7372
const arg = argv[i] || ''
73+
const nextArg = argv[i + 1]
74+
const nextIsValue = nextArg && !nextArg.startsWith('-')
75+
76+
const flagMatch = arg.match(FLAG_REGEX)
77+
const shortMatch = arg.match(SHORT_FLAG_REGEX)
78+
79+
if (shortMatch) {
80+
const flags = (shortMatch[1] || '').split('')
81+
for (const flag of flags) {
82+
if (schema instanceof z.ZodObject) {
83+
const shape = schema.shape
84+
const keys = Object.keys(shape)
85+
const matchingKey = keys.find(k => k.toLowerCase().startsWith(flag.toLowerCase()))
86+
if (matchingKey) {
87+
args[matchingKey] = nextIsValue ? nextArg : true
88+
if (nextIsValue) i++ // Skip the next arg
89+
}
90+
}
91+
}
92+
}
7493

75-
const flagMatch = arg.match(flagRegex)
7694
if (flagMatch) {
7795
const flagName = kebabToCamel(flagMatch[1] || '')
7896

79-
const nextArg = argv[i + 1]
80-
const nextIsValue = nextArg && !nextArg.startsWith('-')
81-
97+
// TODO: map between Zod types in a flat, extensible way
8298
let isBoolean = false
99+
let isNumber = false
83100
if (schema instanceof z.ZodObject) {
84101
const shape = schema.shape
85102
const field = shape[flagName]
@@ -88,43 +105,33 @@ function parseArgs<T extends z.ZodType>({
88105
isBoolean = true
89106
} else if (field instanceof z.ZodOptional && field.unwrap() instanceof z.ZodBoolean) {
90107
isBoolean = true
108+
} else if (field instanceof z.ZodNumber) {
109+
isNumber = true
110+
} else if (field instanceof z.ZodOptional && field.unwrap() instanceof z.ZodNumber) {
111+
isNumber = true
91112
}
92113
}
93114
}
94115

95-
if (isBoolean) {
116+
if (isNumber) {
117+
args[flagName] = Number(nextArg)
118+
i++ // Skip the next arg
119+
} else if (isBoolean) {
96120
args[flagName] = true
97121
} else if (nextIsValue) {
98122
args[flagName] = nextArg
99123
i++ // Skip the next arg
100124
} else {
101125
throw new Error(`Missing value for flag --${flagMatch[1]}`)
102126
}
103-
continue
104-
}
105-
106-
const shortMatch = arg.match(shortFlagRegex)
107-
if (shortMatch) {
108-
const flags = (shortMatch[1] || '').split('')
109-
for (const flag of flags) {
110-
if (schema instanceof z.ZodObject) {
111-
const shape = schema.shape
112-
const keys = Object.keys(shape)
113-
const matchingKey = keys.find(k => k.toLowerCase().startsWith(flag.toLowerCase()))
114-
115-
if (matchingKey) {
116-
args[matchingKey] = true
117-
}
118-
}
119-
}
120127
}
121128
}
122129

123130
const { data, success, error } = schema.safeParse(args)
124131

125132
if (!success) {
126133
console.error(format.error(error.message))
127-
process.exit(0)
134+
throw error
128135
}
129136

130137
return data

lib/utils.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function kebabToCamel(str: string): string {
2+
return str.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
3+
}

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
"@rubriclab/config": "*",
1616
"@rubriclab/package": "*"
1717
},
18+
"peerDependencies": {
19+
"zod": "^3.25.0"
20+
},
1821
"simple-git-hooks": {
1922
"post-commit": "bun run rubriclab-postcommit"
2023
},

0 commit comments

Comments
 (0)