Skip to content

Commit e03e707

Browse files
committed
feat: named capture groups
1 parent 1674ace commit e03e707

File tree

7 files changed

+108
-7
lines changed

7 files changed

+108
-7
lines changed

docs/API.md

+17-3
Original file line numberDiff line numberDiff line change
@@ -63,14 +63,28 @@ Example: `choiceOf("color", "colour")` matches either `color` or `colour` patter
6363
6464
```ts
6565
function capture(
66-
sequence: RegexSequence
67-
): Capture
66+
sequence: RegexSequence,
67+
options?: {
68+
name?: string;
69+
},
70+
): Capture;
6871
```
6972
70-
Regex syntax: `(...)`.
73+
Regex syntax:
74+
75+
- `(...)` for capturing groups
76+
- `(?<name>...)` for named capturing groups
7177
7278
Captures, also known as capturing groups, extract and store parts of the matched string for later use.
7379
80+
Capture results are available using array-like [`match()` result object](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match#using_match).
81+
82+
#### Named groups
83+
84+
When using `name` options, the group becomes a [named capturing group](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Regular_expressions/Named_capturing_group) allowing to refer to it using name instead of index.
85+
86+
Named capture results are available using [`groups`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/match#using_named_capturing_groups) property on `match()` result.
87+
7488
> [!NOTE]
7589
> TS Regex Builder does not have a construct for non-capturing groups. Such groups are implicitly added when required. E.g., `zeroOrMore(["abc"])` is encoded as `(?:abc)+`.
7690

jest-setup.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import './test-utils/to-equal-regex';
22
import './test-utils/to-match-groups';
3+
import './test-utils/to-match-named-groups';
34
import './test-utils/to-match-all-groups';
45
import './test-utils/to-match-string';

src/constructs/__tests__/capture.test.tsx

+30
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,33 @@ test('`capture` matching', () => {
1212
expect(['a', capture('b')]).toMatchGroups('ab', ['ab', 'b']);
1313
expect(['a', capture('b'), capture('c')]).toMatchGroups('abc', ['abc', 'b', 'c']);
1414
});
15+
16+
test('named `capture` pattern', () => {
17+
// Note: using regex literal causes them lose the capture name for unknown reason.
18+
expect(capture('a', { name: 'x' })).toEqualRegex(new RegExp('(?<x>a)'));
19+
expect(capture('abc', { name: 'xyz' })).toEqualRegex(new RegExp('(?<xyz>abc)'));
20+
expect(oneOrMore(capture('abc', { name: 'A' }))).toEqualRegex(new RegExp('(?<A>abc)+'));
21+
expect([capture('aaa', { name: 'A' }), capture('bbb', { name: 'BB' })]).toEqualRegex(
22+
new RegExp('(?<A>aaa)(?<BB>bbb)'),
23+
);
24+
});
25+
26+
test('named `capture` matching', () => {
27+
expect(capture('b', { name: 'g1' })).toMatchGroups('ab', ['b', 'b']);
28+
expect(capture('b', { name: 'g1' })).toMatchNamedGroups('ab', { g1: 'b' });
29+
30+
expect(['a', capture('b', { name: 'g2' })]).toMatchGroups('ab', ['ab', 'b']);
31+
expect(['a', capture('b', { name: 'g2' })]).toMatchNamedGroups('ab', { g2: 'b' });
32+
33+
expect(['a', capture('b', { name: 'g3' }), capture('c', { name: 'g4' })]).toMatchGroups('abc', [
34+
'abc',
35+
'b',
36+
'c',
37+
]);
38+
expect(['a', capture('b', { name: 'g3' }), capture('c', { name: 'g4' })]).toMatchNamedGroups(
39+
'abc',
40+
{ g3: 'b', g4: 'c' },
41+
);
42+
43+
expect(['a', capture('b'), capture('c', { name: 'g4' })]).toMatchNamedGroups('abc', { g4: 'c' });
44+
});

src/constructs/capture.ts

+15-1
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,31 @@ import type { RegexConstruct, RegexElement, RegexSequence } from '../types';
66
export interface Capture extends RegexConstruct {
77
type: 'capture';
88
children: RegexElement[];
9+
options?: CaptureOptions;
910
}
1011

11-
export function capture(sequence: RegexSequence): Capture {
12+
export interface CaptureOptions {
13+
name?: string;
14+
}
15+
16+
export function capture(sequence: RegexSequence, options?: CaptureOptions): Capture {
1217
return {
1318
type: 'capture',
1419
children: ensureArray(sequence),
20+
options,
1521
encode: encodeCapture,
1622
};
1723
}
1824

1925
function encodeCapture(this: Capture): EncodeResult {
26+
const name = this.options?.name;
27+
if (name) {
28+
return {
29+
precedence: 'atom',
30+
pattern: `(?<${name}>${encodeSequence(this.children).pattern})`,
31+
};
32+
}
33+
2034
return {
2135
precedence: 'atom',
2236
pattern: `(${encodeSequence(this.children).pattern})`,

src/index.ts

+6
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
// Types
12
export type * from './types';
3+
export type { CaptureOptions } from './constructs/capture';
4+
export type { QuantifierOptions } from './constructs/quantifiers';
5+
export type { RepeatOptions } from './constructs/repeat';
26

7+
// Builders
38
export { buildPattern, buildRegExp } from './builders';
49

10+
// Constructs
511
export { endOfString, notWordBoundary, startOfString, wordBoundary } from './constructs/anchors';
612
export { capture } from './constructs/capture';
713
export {

test-utils/to-match-groups.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ import { wrapRegExp } from './utils';
44
export function toMatchGroups(
55
this: jest.MatcherContext,
66
received: RegExp | RegexSequence,
7-
expectedString: string,
7+
inputText: string,
88
expectedGroups: string[],
99
) {
1010
const receivedRegex = wrapRegExp(received);
11-
const matchResult = expectedString.match(receivedRegex);
11+
const matchResult = inputText.match(receivedRegex);
1212
const receivedGroups = matchResult ? [...matchResult] : null;
1313
const options = {
1414
isNot: this.isNot,
@@ -30,7 +30,7 @@ declare global {
3030
namespace jest {
3131
// eslint-disable-next-line @typescript-eslint/no-unused-vars
3232
interface Matchers<R, T = {}> {
33-
toMatchGroups(input: string, expected: string[]): R;
33+
toMatchGroups(inputText: string, expectedGroups: string[]): R;
3434
}
3535
}
3636
}

test-utils/to-match-named-groups.ts

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { RegexSequence } from '../src/types';
2+
import { wrapRegExp } from './utils';
3+
4+
export function toMatchNamedGroups(
5+
this: jest.MatcherContext,
6+
received: RegExp | RegexSequence,
7+
inputText: string,
8+
expectedGroups: Record<string, string>,
9+
) {
10+
const receivedRegex = wrapRegExp(received);
11+
const matchResult = inputText.match(receivedRegex);
12+
const receivedGroups = matchResult ? matchResult.groups : null;
13+
const options = {
14+
isNot: this.isNot,
15+
};
16+
17+
return {
18+
pass: this.equals(receivedGroups, expectedGroups),
19+
message: () =>
20+
this.utils.matcherHint('toMatchGroups', undefined, undefined, options) +
21+
'\n\n' +
22+
`Expected: ${this.isNot ? 'not ' : ''}${this.utils.printExpected(expectedGroups)}\n` +
23+
`Received: ${this.utils.printReceived(receivedGroups)}`,
24+
};
25+
}
26+
27+
expect.extend({ toMatchNamedGroups });
28+
29+
declare global {
30+
namespace jest {
31+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
32+
interface Matchers<R, T = {}> {
33+
toMatchNamedGroups(inputText: string, expectedGroups: Record<string, string>): R;
34+
}
35+
}
36+
}

0 commit comments

Comments
 (0)