Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: named capture groups and reference #66

Merged
merged 12 commits into from
Apr 3, 2024
4 changes: 2 additions & 2 deletions .github/ISSUE_TEMPLATE/bug_report.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
---
name: Bug report
about: Create a report to help us improve
title: "[Bug]"
title: '[Bug]'
labels: ''
assignees: ''

---

**Describe the bug**
A clear and concise description of what the bug is.

**To Reproduce**
Steps to reproduce the behavior:

1.
2.
3.
Expand Down
8 changes: 4 additions & 4 deletions .github/ISSUE_TEMPLATE/feature_request.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
---
name: Feature request
about: Suggest an idea for this project
title: "[Feature]"
title: '[Feature]'
labels: enhancement
assignees: ''

---

**Is your feature request related to a problem? Please describe.**
Expand All @@ -17,10 +16,11 @@ A clear and concise description of what you want to happen.
A clear and concise description of any alternative solutions or features you've considered.

**Checklist**

- [ ] Implementation
- [ ] Tests
- [ ] API docs
- [ ] README docs (if relevant)
- [ ] API docs
- [ ] README docs (if relevant)
- [ ] Example docs & tests (if relevant)

**Additional context**
Expand Down
2 changes: 1 addition & 1 deletion .watchmanconfig
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{}
{}
4 changes: 2 additions & 2 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ nmHoistingLimits: workspaces

plugins:
- path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs
spec: "@yarnpkg/plugin-interactive-tools"
spec: '@yarnpkg/plugin-interactive-tools'
- path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs
spec: "@yarnpkg/plugin-workspace-tools"
spec: '@yarnpkg/plugin-workspace-tools'

yarnPath: .yarn/releases/yarn-3.6.1.cjs
21 changes: 10 additions & 11 deletions CODE_OF_CONDUCT.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

# Contributor Covenant Code of Conduct

## Our Pledge
Expand All @@ -18,23 +17,23 @@ diverse, inclusive, and healthy community.
Examples of behavior that contributes to a positive environment for our
community include:

* Demonstrating empathy and kindness toward other people
* Being respectful of differing opinions, viewpoints, and experiences
* Giving and gracefully accepting constructive feedback
* Accepting responsibility and apologizing to those affected by our mistakes,
- Demonstrating empathy and kindness toward other people
- Being respectful of differing opinions, viewpoints, and experiences
- Giving and gracefully accepting constructive feedback
- Accepting responsibility and apologizing to those affected by our mistakes,
and learning from the experience
* Focusing on what is best not just for us as individuals, but for the overall
- Focusing on what is best not just for us as individuals, but for the overall
community

Examples of unacceptable behavior include:

* The use of sexualized language or imagery, and sexual attention or advances of
- The use of sexualized language or imagery, and sexual attention or advances of
any kind
* Trolling, insulting or derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or email address,
- Trolling, insulting or derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or email address,
without their explicit permission
* Other conduct which could reasonably be considered inappropriate in a
- Other conduct which could reasonably be considered inappropriate in a
professional setting

## Enforcement Responsibilities
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,6 @@ See [Character Classes API doc](https://callstack.github.io/ts-regex-builder/api
| `lookbehind(...)` | `(?<=...)` | Match preceding text without consuming it |
| `negativeLookbehind(...)` | `(?<!...)` | Reject preceding text without consuming it |


See [Assertions API doc](https://callstack.github.io/ts-regex-builder/api/assertions) for more info.

## Examples
Expand Down
2 changes: 2 additions & 0 deletions jest-setup.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import './test-utils/to-equal-regex';
import './test-utils/to-match-groups';
import './test-utils/to-match-all-groups';
import './test-utils/to-match-named-groups';
import './test-utils/to-match-all-named-groups';
import './test-utils/to-match-string';
4 changes: 2 additions & 2 deletions lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ pre-commit:
parallel: true
commands:
lint:
glob: "*.{js,ts,jsx,tsx}"
glob: '*.{js,ts,jsx,tsx}'
run: npx eslint {staged_files}
types:
glob: "*.{js,ts, jsx, tsx}"
glob: '*.{js,ts, jsx, tsx}'
run: npx tsc --noEmit
commit-msg:
parallel: true
Expand Down
42 changes: 42 additions & 0 deletions src/__tests__/example-html-tags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {
any,
buildRegExp,
capture,
charClass,
charRange,
digit,
oneOrMore,
ref,
zeroOrMore,
} from '..';

test('example: html tag matching', () => {
const tagName = oneOrMore(charClass(charRange('a', 'z'), digit));
const tagContent = zeroOrMore(any, { greedy: false });

const tagMatcher = buildRegExp(
[
'<',
capture(tagName, { name: 'tag' }),
'>',
capture(tagContent, { name: 'content' }),
'</',
ref('tag'),
'>',
],
{ ignoreCase: true, global: true },
);

expect(tagMatcher).toMatchAllNamedGroups('<a>abc</a>', [{ tag: 'a', content: 'abc' }]);
expect(tagMatcher).toMatchAllNamedGroups('<a><b>abc</b></a>', [
{ tag: 'a', content: '<b>abc</b>' },
]);
expect(tagMatcher).toMatchAllNamedGroups('<a>abc1</a><b>abc2</b>', [
{ tag: 'a', content: 'abc1' },
{ tag: 'b', content: 'abc2' },
]);

expect(tagMatcher).not.toMatchString('<a>abc</b>');

expect(tagMatcher).toEqualRegex('<(?<tag>[a-z\\d]+)>(?<content>.*?)<\\/\\k<tag>>');
});
110 changes: 109 additions & 1 deletion src/constructs/__tests__/capture.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import { capture, oneOrMore } from '../..';
import {
any,
anyOf,
buildRegExp,
capture,
digit,
negated,
oneOrMore,
ref,
word,
wordBoundary,
} from '../..';

test('`capture` pattern', () => {
expect(capture('a')).toEqualRegex(/(a)/);
Expand All @@ -12,3 +23,100 @@ test('`capture` matching', () => {
expect(['a', capture('b')]).toMatchGroups('ab', ['ab', 'b']);
expect(['a', capture('b'), capture('c')]).toMatchGroups('abc', ['abc', 'b', 'c']);
});

test('named `capture` pattern', () => {
expect(capture('a', { name: 'xyz' })).toEqualRegex('(?<xyz>a)');
expect(capture('abc', { name: 'xyz' })).toEqualRegex('(?<xyz>abc)');
expect(capture(oneOrMore('abc'), { name: 'xyz' })).toEqualRegex('(?<xyz>(?:abc)+)');
expect(oneOrMore(capture('abc', { name: 'xyz' }))).toEqualRegex('(?<xyz>abc)+');
});

test('named `capture` matching', () => {
expect(capture('b', { name: 'x1' })).toMatchGroups('ab', ['b', 'b']);
expect(capture('b', { name: 'x1' })).toMatchNamedGroups('ab', { x1: 'b' });

expect(['a', capture('b', { name: 'x1' })]).toMatchGroups('ab', ['ab', 'b']);
expect(['a', capture('b', { name: 'x1' })]).toMatchNamedGroups('ab', { x1: 'b' });

expect([capture('a'), capture('b', { name: 'x1' }), capture('c', { name: 'x2' })]).toMatchGroups(
'abc',
['abc', 'a', 'b', 'c'],
);
expect([
capture('a'),
capture('b', { name: 'x1' }),
capture('c', { name: 'x2' }),
]).toMatchNamedGroups('abc', { x1: 'b', x2: 'c' });
});

test('`reference` pattern', () => {
expect([ref('ref0')]).toEqualRegex(/\k<ref0>/);
expect([ref('xyz')]).toEqualRegex(/\k<xyz>/);
expect([capture(any, { name: 'ref0' }), ' ', ref('ref0')]).toEqualRegex('(?<ref0>.) \\k<ref0>');

expect(['xx', capture(any, { name: 'r123' }), ' ', ref('r123'), 'xx']).toEqualRegex(
'xx(?<r123>.) \\k<r123>xx',
);
});

test('`reference` matching basic case', () => {
expect([capture(word, { name: 'a' }), ref('a')]).toMatchString('aa');
expect([capture(digit, { name: 'a' }), ref('a')]).toMatchString('11');

expect([capture(any, { name: 'a' }), ref('a')]).not.toMatchString('ab');
expect([capture(digit, { name: 'a' }), ref('a')]).not.toMatchString('1a');
expect([capture(digit, { name: 'a' }), ref('a')]).not.toMatchString('a1');
});

test('`reference` matching variable case', () => {
const someRef = ref('test');
expect([capture(word, { name: someRef.name }), someRef]).toMatchString('aa');
expect([capture(digit, { name: someRef.name }), someRef]).toMatchString('11');

expect([capture(any, { name: someRef.name }), someRef]).not.toMatchString('ab');
expect([capture(digit, { name: someRef.name }), someRef]).not.toMatchString('1a');
expect([capture(digit, { name: someRef.name }), someRef]).not.toMatchString('a1');
});

test('`reference` matching HTML attributes', () => {
const quoteChars = anyOf('"\'');
const htmlAttributeRegex = buildRegExp([
wordBoundary,
capture(oneOrMore(word), { name: 'name' }),
'=',
capture(quoteChars, { name: 'quote' }),
capture(oneOrMore(negated(quoteChars)), { name: 'value' }),
ref('quote'),
]);

expect(htmlAttributeRegex).toMatchNamedGroups('a="b"', {
name: 'a',
quote: '"',
value: 'b',
});
expect(htmlAttributeRegex).toMatchNamedGroups('aa="bbb"', {
name: 'aa',
quote: '"',
value: 'bbb',
});
expect(htmlAttributeRegex).toMatchNamedGroups(`aa='bbb'`, {
name: 'aa',
quote: `'`,
value: 'bbb',
});
expect(htmlAttributeRegex).toMatchNamedGroups('<input type="number" />', {
quote: '"',
name: 'type',
value: 'number',
});
expect(htmlAttributeRegex).toMatchNamedGroups(`<input type='number' />`, {
quote: "'",
name: 'type',
value: 'number',
});

expect(htmlAttributeRegex).not.toMatchString(`aa="bbb'`);
expect(htmlAttributeRegex).not.toMatchString(`aa='bbb"`);
expect(htmlAttributeRegex).not.toMatchString(`<input type='number" />`);
expect(htmlAttributeRegex).not.toMatchString(`<input type="number' />`);
});
53 changes: 52 additions & 1 deletion src/constructs/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,70 @@ import type { RegexConstruct, RegexElement, RegexSequence } from '../types';
export interface Capture extends RegexConstruct {
type: 'capture';
children: RegexElement[];
options?: CaptureOptions;
}

export function capture(sequence: RegexSequence): Capture {
export type CaptureOptions = {
/**
* Name to be given to the capturing group.
*/
name?: string;
};

export interface Reference extends RegexConstruct {
type: 'reference';
name: string;
}

/**
* Creates a capturing group which allows the matched pattern to be available:
* - in the match results (`String.match`, `String.matchAll`, or `RegExp.exec`)
* - in the regex itself, through {@link ref}
*/
export function capture(sequence: RegexSequence, options?: CaptureOptions): Capture {
return {
type: 'capture',
children: ensureArray(sequence),
options,
encode: encodeCapture,
};
}

/**
* Creates a reference, also known as backreference, which allows matching
* again the exact text that a capturing group previously matched.
*
* In order to form a valid regex, the reference must use the same name as
* a capturing group earlier in the expression.
*
* @param name - Name of the capturing group to reference.
*/
export function ref(name: string): Reference {
return {
type: 'reference',
name,
encode: encodeReference,
};
}

function encodeCapture(this: Capture): EncodeResult {
const name = this.options?.name;
if (name) {
return {
precedence: 'atom',
pattern: `(?<${name}>${encodeSequence(this.children).pattern})`,
};
}

return {
precedence: 'atom',
pattern: `(${encodeSequence(this.children).pattern})`,
};
}

function encodeReference(this: Reference): EncodeResult {
return {
precedence: 'atom',
pattern: `\\k<${this.name}>`,
};
}
8 changes: 7 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
// Types
export type * from './types';
export type { CaptureOptions } from './constructs/capture';
export type { QuantifierOptions } from './constructs/quantifiers';
export type { RepeatOptions } from './constructs/repeat';

// Builders
export { buildPattern, buildRegExp } from './builders';

// Constructs
export {
endOfString,
nonWordBoundary,
notWordBoundary,
startOfString,
wordBoundary,
} from './constructs/anchors';
export { capture } from './constructs/capture';
export { capture, ref } from './constructs/capture';
export {
any,
anyOf,
Expand Down
Loading
Loading