Skip to content

feat: null element support (POC) #104

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

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions src/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@ import { encode } from './encoder';
* @param flags RegExp flags object
* @returns RegExp object
*/
export function buildRegExp(sequence: RegexSequence, flags?: RegexFlags): RegExp {
const pattern = encode(sequence).pattern;
export function buildRegExp(sequence: RegexSequence, flags?: RegexFlags): RegExp | undefined {
const pattern = encode(sequence)?.pattern;
if (!pattern) {
return undefined;
}

ensureUnicodeFlagIfNeeded(pattern, flags);

const flagsString = encodeFlags(flags ?? {});
Expand All @@ -21,8 +25,8 @@ export function buildRegExp(sequence: RegexSequence, flags?: RegexFlags): RegExp
* @param elements Single regex element or array of elements
* @returns regex pattern string
*/
export function buildPattern(sequence: RegexSequence): string {
return encode(sequence).pattern;
export function buildPattern(sequence: RegexSequence): string | undefined {
return encode(sequence)?.pattern;
}

function encodeFlags(flags: RegexFlags): string {
Expand Down
8 changes: 2 additions & 6 deletions src/constructs/__tests__/char-class.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ test('`charClass` joins character escapes', () => {
expect(charClass(word, nonDigit)).toEqualRegex(/[\w\D]/);
});

test('`charClass` throws on empty text', () => {
expect(() => charClass()).toThrowErrorMatchingInlineSnapshot(`"Expected at least one element"`);
test('`charClass` on empty text', () => {
expect(charClass()).toBeNull();
});

test('`charRange` pattern', () => {
Expand Down Expand Up @@ -96,10 +96,6 @@ test('`anyOf` handles basic cases pattern', () => {
expect(['x', anyOf('ab'), 'x']).toEqualRegex(/x[ab]x/);
});

test('`anyOf` throws on empty text', () => {
expect(() => anyOf('')).toThrowErrorMatchingInlineSnapshot(`"Expected at least one character"`);
});

test('`anyOf` pattern with quantifiers', () => {
expect(['x', oneOrMore(anyOf('abc')), 'x']).toEqualRegex(/x[abc]+x/);
expect(['x', optional(anyOf('abc')), 'x']).toEqualRegex(/x[abc]?x/);
Expand Down
6 changes: 2 additions & 4 deletions src/constructs/__tests__/choice-of.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,6 @@ test('`choiceOf` pattern using nested regex', () => {
);
});

test('`choiceOf` throws on empty options', () => {
expect(() => choiceOf()).toThrowErrorMatchingInlineSnapshot(
`"Expected at least one alternative"`,
);
test('`choiceOf` on empty options', () => {
expect(choiceOf()).toBeNull();
});
6 changes: 2 additions & 4 deletions src/constructs/__tests__/encoder.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,6 @@ test('`buildRegExp` throws error on unknown element', () => {
`);
});

test('`buildPattern` throws on empty text', () => {
expect(() => buildPattern('')).toThrowErrorMatchingInlineSnapshot(
`"Expected at least one character"`,
);
test('`buildPattern` on empty text', () => {
expect(buildPattern('')).toBeUndefined();
});
4 changes: 2 additions & 2 deletions src/constructs/__tests__/repeat.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ test('`repeat` pattern optimizes grouping for atoms', () => {
expect(repeat(digit, { min: 1, max: 5 })).toEqualRegex(/\d{1,5}/);
});

test('`repeat` throws on no children', () => {
expect(() => repeat([], 1)).toThrowErrorMatchingInlineSnapshot(`"Expected at least one element"`);
test('`repeat` accepts no children', () => {
expect(repeat([], 1)).toBeNull();
});

test('greedy `repeat` quantifier pattern', () => {
Expand Down
12 changes: 9 additions & 3 deletions src/constructs/capture.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { encode } from '../encoder';
import type { EncodedRegex, RegexSequence } from '../types';
import { ensureElements } from '../utils';

export type CaptureOptions = {
/**
Expand All @@ -17,18 +18,23 @@
* - 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): EncodedRegex {
export function capture(sequence: RegexSequence, options?: CaptureOptions): EncodedRegex | null {
const elements = ensureElements(sequence);
if (elements.length === 0) {
return null;
}

const name = options?.name;
if (name) {
return {
precedence: 'atom',
pattern: `(?<${name}>${encode(sequence).pattern})`,
pattern: `(?<${name}>${encode(elements).pattern})`,

Check failure on line 31 in src/constructs/capture.ts

View workflow job for this annotation

GitHub Actions / Build Library

Object is possibly 'null'.
};
}

return {
precedence: 'atom',
pattern: `(${encode(sequence).pattern})`,
pattern: `(${encode(elements).pattern})`,

Check failure on line 37 in src/constructs/capture.ts

View workflow job for this annotation

GitHub Actions / Build Library

Object is possibly 'null'.
};
}

Expand Down
24 changes: 14 additions & 10 deletions src/constructs/char-class.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
import type { CharacterClass, CharacterEscape, EncodedRegex } from '../types';
import { ensureText } from '../utils';

/**
* Creates a character class which matches any one of the given characters.
*
* @param elements - Member characters or character ranges.
* @returns Character class.
*/
export function charClass(...elements: Array<CharacterClass | CharacterEscape>): CharacterClass {
if (!elements.length) {
throw new Error('Expected at least one element');
export function charClass(
...elements: Array<CharacterClass | CharacterEscape | null>
): CharacterClass | null {
const allElements = elements.flatMap((c) => c?.elements).filter((c) => c != null);
if (allElements.length === 0) {
return null;
}

return {
elements: elements.map((c) => c.elements).flat(),
elements: allElements,

Check failure on line 18 in src/constructs/char-class.ts

View workflow job for this annotation

GitHub Actions / Build Library

Type '(string | undefined)[]' is not assignable to type 'string[]'.
encode: encodeCharClass,
};
}
Expand Down Expand Up @@ -46,9 +48,7 @@
* @param chars - Characters to match.
* @returns Character class.
*/
export function anyOf(chars: string): CharacterClass {
ensureText(chars);

export function anyOf(chars: string): CharacterClass | null {
return {
elements: chars.split('').map(escapeChar),
encode: encodeCharClass,
Expand All @@ -61,7 +61,7 @@
* @param element - Character class or character escape to negate.
* @returns Negated character class.
*/
export function negated(element: CharacterClass | CharacterEscape): EncodedRegex {
export function negated(element: CharacterClass | CharacterEscape): EncodedRegex | null {
return encodeCharClass.call(element, true);
}

Expand All @@ -79,7 +79,11 @@
function encodeCharClass(
this: CharacterClass | CharacterEscape,
isNegated?: boolean,
): EncodedRegex {
): EncodedRegex | null {
if (this.elements.length === 0) {
return null;
}

return {
precedence: 'atom',
pattern: `[${isNegated ? '^' : ''}${this.elements.join('')}]`,
Expand Down
6 changes: 3 additions & 3 deletions src/constructs/choice-of.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,18 @@
* @param alternatives - Alternatives to choose from.
* @returns Choice of alternatives.
*/
export function choiceOf(...alternatives: RegexSequence[]): EncodedRegex {
export function choiceOf(...alternatives: RegexSequence[]): EncodedRegex | null {
if (alternatives.length === 0) {
throw new Error('Expected at least one alternative');
return null;
}

const encodedAlternatives = alternatives.map((c) => encode(c));
const encodedAlternatives = alternatives.map((c) => encode(c)).filter((c) => c != null);
if (encodedAlternatives.length === 1) {
return encodedAlternatives[0]!;
}

return {
precedence: 'disjunction',
pattern: encodedAlternatives.map((n) => n.pattern).join('|'),

Check failure on line 22 in src/constructs/choice-of.ts

View workflow job for this annotation

GitHub Actions / Build Library

'n' is possibly 'null'.
};
}
8 changes: 7 additions & 1 deletion src/constructs/lookahead.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { encode } from '../encoder';
import type { EncodedRegex, RegexSequence } from '../types';
import { ensureElements } from '../utils';

/**
* Positive lookahead assertion.
Expand All @@ -15,9 +16,14 @@
* // /(?=abc)/
* ```
*/
export function lookahead(sequence: RegexSequence): EncodedRegex {
export function lookahead(sequence: RegexSequence): EncodedRegex | null {
const elements = ensureElements(sequence);
if (elements.length === 0) {
return null;
}

return {
precedence: 'atom',
pattern: `(?=${encode(sequence).pattern})`,

Check failure on line 27 in src/constructs/lookahead.ts

View workflow job for this annotation

GitHub Actions / Build Library

Object is possibly 'null'.
};
}
8 changes: 7 additions & 1 deletion src/constructs/lookbehind.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { encode } from '../encoder';
import type { EncodedRegex, RegexSequence } from '../types';
import { ensureElements } from '../utils';

/**
* Positive lookbehind assertion.
Expand All @@ -15,9 +16,14 @@
* // /(?<=abc)/
* ```
*/
export function lookbehind(sequence: RegexSequence): EncodedRegex {
export function lookbehind(sequence: RegexSequence): EncodedRegex | null {
const elements = ensureElements(sequence);
if (elements.length === 0) {
return null;
}

return {
precedence: 'atom',
pattern: `(?<=${encode(sequence).pattern})`,

Check failure on line 27 in src/constructs/lookbehind.ts

View workflow job for this annotation

GitHub Actions / Build Library

Object is possibly 'null'.
};
}
8 changes: 7 additions & 1 deletion src/constructs/negative-lookahead.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { encode } from '../encoder';
import type { EncodedRegex, RegexSequence } from '../types';
import { ensureElements } from '../utils';

/**
* Negative lookahead assertion.
Expand All @@ -15,9 +16,14 @@
* // /(?=abc)/
* ```
*/
export function negativeLookahead(sequence: RegexSequence): EncodedRegex {
export function negativeLookahead(sequence: RegexSequence): EncodedRegex | null {
const elements = ensureElements(sequence);
if (elements.length === 0) {
return null;
}

return {
precedence: 'atom',
pattern: `(?!${encode(sequence).pattern})`,

Check failure on line 27 in src/constructs/negative-lookahead.ts

View workflow job for this annotation

GitHub Actions / Build Library

Object is possibly 'null'.
};
}
9 changes: 7 additions & 2 deletions src/constructs/negative-lookbehind.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { encode } from '../encoder';
import type { EncodedRegex, RegexSequence } from '../types';

import { ensureElements } from '../utils';
/**
* Negative lookbehind assertion.
*
Expand All @@ -15,9 +15,14 @@
* // /(?<!abc)/
* ```
*/
export function negativeLookbehind(sequence: RegexSequence): EncodedRegex {
export function negativeLookbehind(sequence: RegexSequence): EncodedRegex | null {
const elements = ensureElements(sequence);
if (elements.length === 0) {
return null;
}

return {
precedence: 'atom',
pattern: `(?<!${encode(sequence).pattern})`,

Check failure on line 26 in src/constructs/negative-lookbehind.ts

View workflow job for this annotation

GitHub Actions / Build Library

Object is possibly 'null'.
};
}
27 changes: 24 additions & 3 deletions src/constructs/quantifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,15 @@ export interface QuantifierOptions {
* @param sequence - Elements to match zero or more of.
* @param options - Quantifier options.
*/
export function zeroOrMore(sequence: RegexSequence, options?: QuantifierOptions): EncodedRegex {
export function zeroOrMore(
sequence: RegexSequence,
options?: QuantifierOptions,
): EncodedRegex | null {
const elements = ensureElements(sequence);
if (elements.length === 0) {
return null;
}

return {
precedence: 'sequence',
pattern: `${encodeAtomic(elements)}*${options?.greedy === false ? '?' : ''}`,
Expand All @@ -26,8 +33,15 @@ export function zeroOrMore(sequence: RegexSequence, options?: QuantifierOptions)
* @param sequence - Elements to match one or more of.
* @param options - Quantifier options.
*/
export function oneOrMore(sequence: RegexSequence, options?: QuantifierOptions): EncodedRegex {
export function oneOrMore(
sequence: RegexSequence,
options?: QuantifierOptions,
): EncodedRegex | null {
const elements = ensureElements(sequence);
if (elements.length === 0) {
return null;
}

return {
precedence: 'sequence',
pattern: `${encodeAtomic(elements)}+${options?.greedy === false ? '?' : ''}`,
Expand All @@ -40,8 +54,15 @@ export function oneOrMore(sequence: RegexSequence, options?: QuantifierOptions):
* @param sequence - Elements to match zero or one of.
* @param options - Quantifier options.
*/
export function optional(sequence: RegexSequence, options?: QuantifierOptions): EncodedRegex {
export function optional(
sequence: RegexSequence,
options?: QuantifierOptions,
): EncodedRegex | null {
const elements = ensureElements(sequence);
if (elements.length === 0) {
return null;
}

return {
precedence: 'sequence',
pattern: `${encodeAtomic(elements)}?${options?.greedy === false ? '?' : ''}`,
Expand Down
5 changes: 4 additions & 1 deletion src/constructs/repeat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ export type RepeatOptions = number | { min: number; max?: number; greedy?: boole
* @param sequence - Sequence to match.
* @param options - Quantifier options.
*/
export function repeat(sequence: RegexSequence, options: RepeatOptions): EncodedRegex {
export function repeat(sequence: RegexSequence, options: RepeatOptions): EncodedRegex | null {
const elements = ensureElements(sequence);
if (elements.length === 0) {
return null;
}

if (typeof options === 'number') {
return {
Expand Down
27 changes: 20 additions & 7 deletions src/encoder.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import type { EncodedRegex, RegexElement, RegexSequence } from './types';
import { ensureElements, ensureText } from './utils';
import { ensureElements } from './utils';

export function encode(sequence: RegexSequence): EncodedRegex {
export function encode(sequence: RegexSequence): EncodedRegex | null {
const elements = ensureElements(sequence);
const encoded = elements.map((n) => encodeElement(n));
const encoded = elements.map((n) => encodeElement(n)).filter((n) => n != null);
if (encoded.length === 0) {
return null;
}

if (encoded.length === 1) {
return encoded[0]!;
Expand All @@ -12,17 +15,25 @@
return {
precedence: 'sequence',
pattern: encoded
.map((n) => (n.precedence === 'disjunction' ? encodeAtomic(n) : n.pattern))

Check failure on line 18 in src/encoder.ts

View workflow job for this annotation

GitHub Actions / Build Library

'n' is possibly 'null'.
.join(''),
};
}

export function encodeAtomic(sequence: RegexSequence): string {
export function encodeAtomic(sequence: RegexSequence): string | null {
const encoded = encode(sequence);
if (encoded == null) {
return null;
}

return encoded.precedence === 'atom' ? encoded.pattern : `(?:${encoded.pattern})`;
}

function encodeElement(element: RegexElement): EncodedRegex {
function encodeElement(element: RegexElement): EncodedRegex | null {
if (element == null) {
return null;
}

if (typeof element === 'string') {
return encodeText(element);
}
Expand All @@ -46,8 +57,10 @@
throw new Error(`Unsupported element. Received: ${JSON.stringify(element, null, 2)}`);
}

function encodeText(text: string): EncodedRegex {
ensureText(text);
function encodeText(text: string): EncodedRegex | null {
if (text.length === 0) {
return null;
}

return {
// Optimize for single character case
Expand Down
Loading
Loading