Skip to content

Commit 7c2c1ca

Browse files
boneskullclaude
andcommitted
feat!: add merge() combinator for combining parsers
Adds a new merge() function that combines multiple parsers into one, providing a cleaner alternative to the callable parser syntax: // With merge() - linear, readable merge( opt.options({ priority: opt.enum(['low', 'medium', 'high']) }), pos.positionals(pos.string({ name: 'task' })), ) // Callable syntax - inside-out, confusing )( pos.positionals(...)(opt.options(...)) The merge() function: - Accepts 2-4 parsers (with overloads for type safety) - Merges option schemas (later overrides earlier) - Concatenates positional schemas - Chains transforms from merged parsers Updated README to document merge() and use it in examples. BREAKING CHANGE: none, additive only 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 8ba0a55 commit 7c2c1ca

5 files changed

Lines changed: 228 additions & 28 deletions

File tree

README.md

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -122,16 +122,12 @@ const text = words.join(' ');
122122
console.log(values.uppercase ? text.toUpperCase() : text);
123123
```
124124

125-
### Zero (0) Dependencies
126-
127-
Only Node.js v22+.
128-
129125
### Command-Based CLI
130126

131127
For a CLI with multiple subcommands:
132128

133129
```typescript
134-
import { bargs, opt, pos } from '@boneskull/bargs';
130+
import { bargs, merge, opt, pos } from '@boneskull/bargs';
135131

136132
await bargs
137133
.create('tasks', {
@@ -145,10 +141,16 @@ await bargs
145141
)
146142
.command(
147143
'add',
148-
pos.positionals(pos.string({ name: 'text', required: true })),
144+
// Use merge() to combine positionals with command-specific options
145+
merge(
146+
opt.options({
147+
priority: opt.enum(['low', 'medium', 'high'], { default: 'medium' }),
148+
}),
149+
pos.positionals(pos.string({ name: 'text', required: true })),
150+
),
149151
({ positionals, values }) => {
150152
const [text] = positionals;
151-
console.log(`Adding task: ${text}`);
153+
console.log(`Adding ${values.priority} priority task: ${text}`);
152154
if (values.verbose) console.log('Verbose mode enabled');
153155
},
154156
'Add a task',
@@ -168,8 +170,8 @@ await bargs
168170
```
169171

170172
```shell
171-
$ tasks add "Buy groceries" --verbose
172-
Adding task: Buy groceries
173+
$ tasks add "Buy groceries" --priority high --verbose
174+
Adding high priority task: Buy groceries
173175
Verbose mode enabled
174176

175177
$ tasks list --all
@@ -322,23 +324,36 @@ const parser = pos.positionals(pos.variadic('string', { name: 'files' }));
322324

323325
## Merging Parsers
324326

325-
Options and positionals parsers can be merged by calling one with the other:
327+
Use `merge()` to combine multiple parsers into one:
326328

327329
```typescript
328-
const options = opt.options({
329-
priority: opt.enum(['low', 'medium', 'high'] as const, { default: 'medium' }),
330-
});
330+
import { merge, opt, pos } from '@boneskull/bargs';
331+
332+
const combined = merge(
333+
opt.options({
334+
priority: opt.enum(['low', 'medium', 'high'], { default: 'medium' }),
335+
}),
336+
pos.positionals(pos.string({ name: 'task', required: true })),
337+
);
338+
// Type: Parser<{ priority: 'low' | 'medium' | 'high' }, [string]>
339+
```
340+
341+
You can merge as many parsers as needed—options are merged (later overrides earlier), and positionals are concatenated.
331342

343+
Alternatively, parsers can be merged by calling one with the other:
344+
345+
```typescript
346+
const options = opt.options({ priority: opt.enum(['low', 'medium', 'high']) });
332347
const positionals = pos.positionals(
333348
pos.string({ name: 'task', required: true }),
334349
);
335350

336-
// Merge: call positionals with options
337-
const combined = positionals(options);
338-
// Type: Parser<{ priority: 'low' | 'medium' | 'high' }, [string]>
351+
// These are equivalent:
352+
const combined1 = positionals(options);
353+
const combined2 = options(positionals);
339354
```
340355

341-
This works in either direction—`options(positionals)` or `positionals(options)`.
356+
Use whichever style you find more readable.
342357

343358
## Transforms
344359

@@ -481,10 +496,12 @@ try {
481496
} catch (error) {
482497
if (error instanceof ValidationError) {
483498
// Config validation failed (e.g., invalid schema)
499+
// i.e., "you screwed up"
484500
console.error(`Config error at "${error.path}": ${error.message}`);
485501
} else if (error instanceof HelpError) {
486-
// User needs guidance (e.g., unknown option)
487-
console.error(error.message);
502+
// Likely invalid options, command or positionals;
503+
// re-throw to trigger help display
504+
throw error;
488505
} else if (error instanceof BargsError) {
489506
// General bargs error
490507
console.error(error.message);
@@ -547,6 +564,10 @@ const plain = stripAnsi('\x1b[32m--verbose\x1b[0m'); // '--verbose'
547564

548565
The `handle(parser, fn)` function is exported for advanced use cases where you need to create a `Command` object outside the fluent builder. It's mostly superseded by `.command(name, parser, handler)`.
549566

567+
## Dependencies
568+
569+
**bargs** has zero (0) dependencies. Only Node.js v22+.
570+
550571
## Motivation
551572

552573
I've always reached for [yargs](https://github.com/yargs/yargs) in my CLI projects. However, I find myself repeatedly doing the same things; I have a sort of boilerplate in my head, ready to go (`requiresArg: true` and `nargs: 1`, amirite?). I don't want boilerplate in my head. I wanted to distill my chosen subset of yargs' behavior into a composable API. And so **bargs** was begat.

examples/tasks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ const globalOptions = opt.options({
5050
// Add command parser: priority option + text positional
5151
// First create options, then merge with positionals
5252
const addOptions = opt.options({
53-
priority: opt.enum(['low', 'medium', 'high'] as const, { default: 'medium' }),
53+
priority: opt.enum(['low', 'medium', 'high'], { default: 'medium' }),
5454
});
5555
const addPositionals = pos.positionals(
5656
pos.string({ name: 'text', required: true }),

src/bargs.ts

Lines changed: 133 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,16 +59,23 @@ type TransformFn<
5959
> = (
6060
result: ParseResult<V1, P1>,
6161
) => ParseResult<V2, P2> | Promise<ParseResult<V2, P2>>;
62+
63+
// ═══════════════════════════════════════════════════════════════════════════════
64+
// MERGE COMBINATOR
65+
// ═══════════════════════════════════════════════════════════════════════════════
66+
6267
/**
6368
* Create a command with a handler (terminal in the pipeline).
6469
*/
6570
export function handle<V, P extends readonly unknown[]>(
6671
fn: HandlerFn<V, P>,
6772
): (parser: Parser<V, P>) => Command<V, P>;
73+
6874
export function handle<V, P extends readonly unknown[]>(
6975
parser: Parser<V, P>,
7076
fn: HandlerFn<V, P>,
7177
): Command<V, P>;
78+
7279
export function handle<V, P extends readonly unknown[]>(
7380
parserOrFn: HandlerFn<V, P> | Parser<V, P>,
7481
maybeFn?: HandlerFn<V, P>,
@@ -120,17 +127,12 @@ export function map<
120127
P2 extends readonly unknown[],
121128
>(fn: TransformFn<V1, P1, V2, P2>): (parser: Parser<V1, P1>) => Parser<V2, P2>;
122129

123-
// ═══════════════════════════════════════════════════════════════════════════════
124-
// MAP COMBINATOR
125-
// ═══════════════════════════════════════════════════════════════════════════════
126-
127130
export function map<
128131
V1,
129132
P1 extends readonly unknown[],
130133
V2,
131134
P2 extends readonly unknown[],
132135
>(parser: Parser<V1, P1>, fn: TransformFn<V1, P1, V2, P2>): Parser<V2, P2>;
133-
134136
export function map<
135137
V1,
136138
P1 extends readonly unknown[],
@@ -166,6 +168,132 @@ export function map<
166168
} as Parser<V2, P2> & { __transform: typeof fn };
167169
};
168170
}
171+
/**
172+
* Merge multiple parsers into one.
173+
*
174+
* Combines options and positionals from all parsers. Later parsers' options
175+
* override earlier ones if there are conflicts.
176+
*
177+
* @example
178+
*
179+
* ```typescript
180+
* const parser = merge(
181+
* opt.options({ verbose: opt.boolean() }),
182+
* pos.positionals(pos.string({ name: 'file', required: true })),
183+
* );
184+
* ```
185+
*/
186+
export function merge<
187+
V1,
188+
P1 extends readonly unknown[],
189+
V2,
190+
P2 extends readonly unknown[],
191+
>(
192+
p1: Parser<V1, P1>,
193+
p2: Parser<V2, P2>,
194+
): Parser<V1 & V2, readonly [...P1, ...P2]>;
195+
196+
export function merge<
197+
V1,
198+
P1 extends readonly unknown[],
199+
V2,
200+
P2 extends readonly unknown[],
201+
V3,
202+
P3 extends readonly unknown[],
203+
>(
204+
p1: Parser<V1, P1>,
205+
p2: Parser<V2, P2>,
206+
p3: Parser<V3, P3>,
207+
): Parser<V1 & V2 & V3, readonly [...P1, ...P2, ...P3]>;
208+
209+
// ═══════════════════════════════════════════════════════════════════════════════
210+
// MAP COMBINATOR
211+
// ═══════════════════════════════════════════════════════════════════════════════
212+
213+
export function merge<
214+
V1,
215+
P1 extends readonly unknown[],
216+
V2,
217+
P2 extends readonly unknown[],
218+
V3,
219+
P3 extends readonly unknown[],
220+
V4,
221+
P4 extends readonly unknown[],
222+
>(
223+
p1: Parser<V1, P1>,
224+
p2: Parser<V2, P2>,
225+
p3: Parser<V3, P3>,
226+
p4: Parser<V4, P4>,
227+
): Parser<V1 & V2 & V3 & V4, readonly [...P1, ...P2, ...P3, ...P4]>;
228+
229+
export function merge(
230+
...parsers: Array<Parser<unknown, readonly unknown[]>>
231+
): Parser<unknown, readonly unknown[]> {
232+
if (parsers.length === 0) {
233+
throw new BargsError('merge() requires at least one parser');
234+
}
235+
236+
// Start with the first parser and fold the rest in
237+
let result = parsers[0]!;
238+
239+
for (let i = 1; i < parsers.length; i++) {
240+
const next = parsers[i]!;
241+
242+
// Merge options schemas
243+
const mergedOptions = {
244+
...result.__optionsSchema,
245+
...next.__optionsSchema,
246+
};
247+
248+
// Merge positionals schemas
249+
const mergedPositionals = [
250+
...result.__positionalsSchema,
251+
...next.__positionalsSchema,
252+
];
253+
254+
// Preserve transforms from both parsers (chain them)
255+
type AsyncTransform = (
256+
r: ParseResult<unknown, readonly unknown[]>,
257+
) =>
258+
| ParseResult<unknown, readonly unknown[]>
259+
| Promise<ParseResult<unknown, readonly unknown[]>>;
260+
261+
const resultWithTransform = result as { __transform?: AsyncTransform };
262+
const nextWithTransform = next as { __transform?: AsyncTransform };
263+
264+
let mergedTransform: AsyncTransform | undefined;
265+
if (resultWithTransform.__transform && nextWithTransform.__transform) {
266+
// Chain transforms
267+
const t1 = resultWithTransform.__transform;
268+
const t2 = nextWithTransform.__transform;
269+
mergedTransform = (r) => {
270+
const r1 = t1(r);
271+
if (r1 instanceof Promise) {
272+
return r1.then(t2);
273+
}
274+
return t2(r1);
275+
};
276+
} else {
277+
mergedTransform =
278+
nextWithTransform.__transform ?? resultWithTransform.__transform;
279+
}
280+
281+
result = {
282+
__brand: 'Parser',
283+
__optionsSchema: mergedOptions,
284+
__positionals: [] as unknown as readonly unknown[],
285+
__positionalsSchema: mergedPositionals,
286+
__values: {} as unknown,
287+
};
288+
289+
if (mergedTransform) {
290+
(result as { __transform?: AsyncTransform }).__transform =
291+
mergedTransform;
292+
}
293+
}
294+
295+
return result;
296+
}
169297
// ═══════════════════════════════════════════════════════════════════════════════
170298
// CLI BUILDER
171299
// ═══════════════════════════════════════════════════════════════════════════════

src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
*/
2525

2626
// Main API
27-
export { bargs, handle, map } from './bargs.js';
27+
export { bargs, handle, map, merge } from './bargs.js';
2828

2929
// Errors
3030
export { BargsError, HelpError, ValidationError } from './errors.js';

test/combinators.test.ts

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,63 @@
11
/**
2-
* Tests for parser combinators: map, handle.
2+
* Tests for parser combinators: merge, map, handle.
33
*/
44
import { expect } from 'bupkis';
55
import { describe, it } from 'node:test';
66

7-
import { handle, map } from '../src/bargs.js';
7+
import { handle, map, merge } from '../src/bargs.js';
88
import { opt, pos } from '../src/opt.js';
99

10+
describe('merge()', () => {
11+
it('merges two parsers', () => {
12+
const parser = merge(
13+
opt.options({ verbose: opt.boolean() }),
14+
pos.positionals(pos.string({ name: 'file', required: true })),
15+
);
16+
17+
expect(parser.__brand, 'to be', 'Parser');
18+
expect(parser.__optionsSchema, 'to satisfy', {
19+
verbose: { type: 'boolean' },
20+
});
21+
expect(parser.__positionalsSchema, 'to satisfy', [
22+
{ name: 'file', type: 'string' },
23+
]);
24+
});
25+
26+
it('merges three parsers', () => {
27+
const parser = merge(
28+
opt.options({ verbose: opt.boolean() }),
29+
opt.options({ output: opt.string() }),
30+
pos.positionals(pos.string({ name: 'input' })),
31+
);
32+
33+
expect(parser.__brand, 'to be', 'Parser');
34+
expect(parser.__optionsSchema, 'to satisfy', {
35+
output: { type: 'string' },
36+
verbose: { type: 'boolean' },
37+
});
38+
});
39+
40+
it('later options override earlier ones', () => {
41+
const parser = merge(
42+
opt.options({ name: opt.string({ default: 'first' }) }),
43+
opt.options({ name: opt.string({ default: 'second' }) }),
44+
);
45+
46+
expect(parser.__optionsSchema.name?.default, 'to be', 'second');
47+
});
48+
49+
it('concatenates positionals', () => {
50+
const parser = merge(
51+
pos.positionals(pos.string({ name: 'a' })),
52+
pos.positionals(pos.string({ name: 'b' })),
53+
);
54+
55+
expect(parser.__positionalsSchema, 'to have length', 2);
56+
expect(parser.__positionalsSchema[0]?.name, 'to be', 'a');
57+
expect(parser.__positionalsSchema[1]?.name, 'to be', 'b');
58+
});
59+
});
60+
1061
describe('map()', () => {
1162
describe('curried form', () => {
1263
it('returns a function that transforms a parser', () => {

0 commit comments

Comments
 (0)