Skip to content

Commit

Permalink
feat: named capture groups and reference (#66)
Browse files Browse the repository at this point in the history
  • Loading branch information
mdjastrzebski authored Apr 3, 2024
1 parent 65022ee commit 57f7265
Show file tree
Hide file tree
Showing 21 changed files with 386 additions and 62 deletions.
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

0 comments on commit 57f7265

Please sign in to comment.