Skip to content

Commit 3143a60

Browse files
committed
chore: add support for objects and nested fields
1 parent 88c960f commit 3143a60

File tree

5 files changed

+311
-97
lines changed

5 files changed

+311
-97
lines changed

packages/arg-parser/src/arg-metadata.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,22 +63,29 @@ export function getUnsupportedArgs(schema: z.ZodObject): string[] {
6363
return unsupported;
6464
}
6565

66-
export class UnknownCliArgumentError extends Error {
66+
export class InvalidArgumentError extends Error {
67+
constructor(message: string) {
68+
super(message);
69+
this.name = 'InvalidArgumentError';
70+
}
71+
}
72+
73+
export class UnknownArgumentError extends Error {
6774
/** The argument that was not parsed. */
6875
readonly argument: string;
6976
constructor(argument: string) {
7077
super(`Unknown argument: ${argument}`);
71-
this.name = 'UnknownCliArgumentError';
78+
this.name = 'UnknownArgumentError';
7279
this.argument = argument;
7380
}
7481
}
7582

76-
export class UnsupportedCliArgumentError extends Error {
83+
export class UnsupportedArgumentError extends Error {
7784
/** The argument that was not supported. */
7885
readonly argument: string;
7986
constructor(argument: string) {
8087
super(`Unsupported argument: ${argument}`);
81-
this.name = 'UnsupportedCliArgumentError';
88+
this.name = 'UnsupportedArgumentError';
8289
this.argument = argument;
8390
}
8491
}

packages/arg-parser/src/arg-parser.spec.ts

Lines changed: 168 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import {
88
getLocale,
99
parseArgs,
1010
parseArgsWithCliOptions,
11-
UnknownCliArgumentError,
12-
UnsupportedCliArgumentError,
11+
UnknownArgumentError,
12+
UnsupportedArgumentError,
1313
} from './arg-parser';
1414
import { z } from 'zod/v4';
1515
import { coerceIfBoolean, coerceIfFalse } from './utils';
16+
import { InvalidArgumentError } from './arg-metadata';
1617

1718
describe('arg-parser', function () {
1819
describe('.getLocale', function () {
@@ -300,18 +301,9 @@ describe('arg-parser', function () {
300301
const argv = [uri, '--what'];
301302

302303
it('raises an error', function () {
303-
try {
304+
expect(() => {
304305
parseArgsWithCliOptions({ args: argv }).parsed;
305-
} catch (err: any) {
306-
if (err instanceof UnknownCliArgumentError) {
307-
expect(stripAnsi(err.message)).to.equal(
308-
'Unknown argument: --what'
309-
);
310-
return;
311-
}
312-
expect.fail('Expected UnknownCliArgumentError');
313-
}
314-
expect.fail('parsing unknown parameter did not throw');
306+
}).to.throw(UnknownArgumentError, 'Unknown argument: --what');
315307
});
316308
});
317309
});
@@ -438,7 +430,7 @@ describe('arg-parser', function () {
438430
expect(
439431
() => parseArgsWithCliOptions({ args: argv }).parsed
440432
).to.throw(
441-
UnsupportedCliArgumentError,
433+
UnsupportedArgumentError,
442434
'Unsupported argument: gssapiHostName'
443435
);
444436
});
@@ -655,7 +647,7 @@ describe('arg-parser', function () {
655647
expect(
656648
() => parseArgsWithCliOptions({ args: argv }).parsed
657649
).to.throw(
658-
UnsupportedCliArgumentError,
650+
UnsupportedArgumentError,
659651
'Unsupported argument: sslFIPSMode'
660652
);
661653
});
@@ -1417,6 +1409,155 @@ describe('arg-parser', function () {
14171409
},
14181410
});
14191411
});
1412+
1413+
describe('object fields', function () {
1414+
it('parses object fields', function () {
1415+
const options = parseArgs({
1416+
args: ['--objectField', '{"foo":"bar"}'],
1417+
schema: z.object({
1418+
objectField: z.object({
1419+
foo: z.string(),
1420+
}),
1421+
}),
1422+
});
1423+
1424+
expect(options.parsed).to.deep.equal({
1425+
objectField: {
1426+
foo: 'bar',
1427+
},
1428+
});
1429+
});
1430+
1431+
it('enforces the schema of the object field', function () {
1432+
const schema = z.object({
1433+
objectField: z.object({
1434+
foo: z.number(),
1435+
}),
1436+
});
1437+
expect(
1438+
parseArgs({
1439+
args: ['--objectField', '{"foo":3}'],
1440+
schema,
1441+
}).parsed.objectField
1442+
).to.deep.equal({ foo: 3 });
1443+
expect(() =>
1444+
parseArgs({
1445+
args: ['--objectField', '{"foo":"hello"}'],
1446+
schema,
1447+
})
1448+
).to.throw(InvalidArgumentError, 'expected number, received string');
1449+
});
1450+
1451+
it('can handle --a.b format', () => {
1452+
const schema = z.object({
1453+
a: z.object({
1454+
number: z.number(),
1455+
string: z.string(),
1456+
boolean: z.boolean(),
1457+
}),
1458+
});
1459+
expect(
1460+
parseArgs({
1461+
args: [
1462+
'--a.number',
1463+
'3',
1464+
'--a.string',
1465+
'hello',
1466+
'--a.boolean',
1467+
'true',
1468+
],
1469+
schema,
1470+
}).parsed.a
1471+
).to.deep.equal({
1472+
number: 3,
1473+
string: 'hello',
1474+
boolean: true,
1475+
});
1476+
});
1477+
1478+
it('can handle nested object fields', () => {
1479+
const schema = z.object({
1480+
parent: z.object({
1481+
child: z.string(),
1482+
nested: z.object({
1483+
deep: z.number(),
1484+
}),
1485+
}),
1486+
});
1487+
expect(
1488+
parseArgs({
1489+
args: ['--parent.child', 'hello', '--parent.nested.deep', '42'],
1490+
schema,
1491+
}).parsed.parent
1492+
).to.deep.equal({
1493+
child: 'hello',
1494+
nested: {
1495+
deep: 42,
1496+
},
1497+
});
1498+
});
1499+
1500+
it('can handle multiple types in nested objects', () => {
1501+
const schema = z.object({
1502+
config: z.object({
1503+
enabled: z.boolean(),
1504+
name: z.string(),
1505+
count: z.number(),
1506+
tags: z.array(z.string()),
1507+
}),
1508+
});
1509+
const result = parseArgs({
1510+
args: [
1511+
'--config.enabled',
1512+
'--config.name',
1513+
'test',
1514+
'--config.count',
1515+
'10',
1516+
'--config.tags',
1517+
'tag1',
1518+
'--config.tags',
1519+
'tag2',
1520+
],
1521+
schema,
1522+
});
1523+
expect(result.parsed.config).to.deep.equal({
1524+
enabled: true,
1525+
name: 'test',
1526+
count: 10,
1527+
tags: ['tag1', 'tag2'],
1528+
});
1529+
});
1530+
1531+
it('generateYargsOptionsFromSchema processes nested objects', () => {
1532+
const schema = z.object({
1533+
server: z.object({
1534+
host: z.string(),
1535+
port: z.number(),
1536+
ssl: z.boolean(),
1537+
}),
1538+
});
1539+
const options = generateYargsOptionsFromSchema({ schema });
1540+
1541+
expect(options.string).to.include('server.host');
1542+
expect(options.number).to.include('server.port');
1543+
expect(options.boolean).to.include('server.ssl');
1544+
expect(options.coerce).to.have.property('server');
1545+
});
1546+
1547+
it('generateYargsOptionsFromSchema processes deeply nested objects', () => {
1548+
const schema = z.object({
1549+
level1: z.object({
1550+
level2: z.object({
1551+
level3: z.string(),
1552+
}),
1553+
}),
1554+
});
1555+
const options = generateYargsOptionsFromSchema({ schema });
1556+
1557+
expect(options.string).to.include('level1.level2.level3');
1558+
expect(options.coerce).to.have.property('level1');
1559+
});
1560+
});
14201561
});
14211562

14221563
describe('parseArgsWithCliOptions', function () {
@@ -1450,13 +1591,23 @@ describe('arg-parser', function () {
14501591
'true',
14511592
'--deprecatedField',
14521593
'100',
1594+
'--complexField',
1595+
'false',
14531596
],
14541597
schema: z.object({
14551598
extendedField: z.number(),
14561599
replacedField: z.number(),
14571600
deprecatedField: z.number().register(argMetadata, {
14581601
deprecationReplacement: 'replacedField',
14591602
}),
1603+
// TODO: The expected behavior right now is pre-processing doesn't happen as part of the arg-parser.
1604+
// What we instead focus on is making sure the output is passed as expected type (i.e. z.boolean())
1605+
// The assumption is that external users will pass the output through their schema after this parse.
1606+
// With greater testing, we should support schema assertion directly in the parser.
1607+
complexField: z.preprocess(
1608+
(value: unknown) => value === 'true',
1609+
z.boolean()
1610+
),
14601611
}),
14611612
});
14621613

@@ -1468,6 +1619,7 @@ describe('arg-parser', function () {
14681619
extendedField: 90,
14691620
tls: true,
14701621
fileNames: [],
1622+
complexField: false,
14711623
},
14721624
deprecated: {
14731625
ssl: 'tls',
@@ -1491,7 +1643,7 @@ describe('arg-parser', function () {
14911643
extendedField: z.enum(['90', '100']),
14921644
}),
14931645
})
1494-
).to.throw(UnknownCliArgumentError, 'Unknown argument: --unknownField');
1646+
).to.throw(UnknownArgumentError, 'Unknown argument: --unknownField');
14951647
});
14961648
});
14971649
});

0 commit comments

Comments
 (0)