diff --git a/src/encoder/__tests__/encoder.test.tsx b/src/__tests__/encoder.test.tsx similarity index 80% rename from src/encoder/__tests__/encoder.test.tsx rename to src/__tests__/encoder.test.tsx index 5867c20..a17459e 100644 --- a/src/encoder/__tests__/encoder.test.tsx +++ b/src/__tests__/encoder.test.tsx @@ -1,11 +1,11 @@ -import { buildPattern, buildRegex } from '../../builders'; +import { buildPattern, buildRegex } from '../builders'; import { one, oneOrMore, optionally, zeroOrMore, -} from '../../components/quantifiers'; -import { repeat } from '../../components/repeat'; +} from '../components/quantifiers'; +import { repeat } from '../components/repeat'; test('basic quantifies', () => { expect(buildPattern('a')).toEqual('a'); @@ -55,15 +55,8 @@ test('"buildPattern" escapes special characters', () => { ); }); -test('buildRegex throws error on unknown element', () => { - expect(() => - // @ts-expect-error intentionally passing incorrect object - buildRegex({ type: 'unknown' }) - ).toThrowErrorMatchingInlineSnapshot(`"Unknown elements type unknown"`); -}); - test('buildPattern throws on empty text', () => { expect(() => buildPattern('')).toThrowErrorMatchingInlineSnapshot( - `"\`encodeText\`: received text should not be empty"` + `"\\"encodeText\\": received text should not be empty"` ); }); diff --git a/src/builders.ts b/src/builders.ts index 13ad226..4dfb5c2 100644 --- a/src/builders.ts +++ b/src/builders.ts @@ -1,6 +1,6 @@ -import type { RegexElement } from './components/types'; -import { encodeSequence } from './encoder/encoder'; -import { isRegexElement } from './utils'; +import type { RegexElement } from './types'; +import { encodeSequence } from './encoder'; +import { isValidElement } from './utils'; export interface RegexFlags { /** Global search. */ @@ -30,7 +30,7 @@ export function buildRegex( first: RegexFlags | RegexElement | string, ...rest: Array ): RegExp { - if (typeof first === 'string' || isRegexElement(first)) { + if (typeof first === 'string' || isValidElement(first)) { return buildRegex({}, first, ...rest); } diff --git a/src/components/__tests__/character-class.test.ts b/src/components/__tests__/character-class.test.ts index 68f47a4..83fa3e1 100644 --- a/src/components/__tests__/character-class.test.ts +++ b/src/components/__tests__/character-class.test.ts @@ -1,13 +1,6 @@ import { buildPattern } from '../../builders'; import { one, oneOrMore } from '../quantifiers'; -import { - any, - anyOf, - digit, - encodeCharacterClass, - whitespace, - word, -} from '../character-class'; +import { any, anyOf, digit, whitespace, word } from '../character-class'; test('"whitespace" character class', () => { expect(buildPattern(whitespace)).toEqual(`\\s`); @@ -65,14 +58,3 @@ test('`anyOf` throws on empty text', () => { `"\`anyOf\` should received at least one character"` ); }); - -test('buildPattern throws on empty text', () => { - expect(() => - encodeCharacterClass({ - type: 'characterClass', - characters: [], - }) - ).toThrowErrorMatchingInlineSnapshot( - `"Character class should contain at least one character"` - ); -}); diff --git a/src/components/__tests__/choice-of.test.ts b/src/components/__tests__/choice-of.test.ts index 3748dab..734d45d 100644 --- a/src/components/__tests__/choice-of.test.ts +++ b/src/components/__tests__/choice-of.test.ts @@ -33,6 +33,6 @@ test('"choiceOf" using nested regex', () => { test('`anyOf` throws on empty options', () => { expect(() => choiceOf()).toThrowErrorMatchingInlineSnapshot( - `"\`choiceOf\` should receive at least one option"` + `"\\"choiceOf\\" should receive at least one option"` ); }); diff --git a/src/components/__tests__/repeat.test.tsx b/src/components/__tests__/repeat.test.tsx index 0bb2225..5f388cd 100644 --- a/src/components/__tests__/repeat.test.tsx +++ b/src/components/__tests__/repeat.test.tsx @@ -24,6 +24,6 @@ test('"repeat"" optimizes grouping for atoms', () => { test('`repeat` throws on no children', () => { expect(() => repeat({ count: 1 })).toThrowErrorMatchingInlineSnapshot( - `"\`repeat\` should receive at least one element"` + `"\\"repeat\\" should receive at least one element"` ); }); diff --git a/src/components/capture.ts b/src/components/capture.ts index bb33e9e..d67fefc 100644 --- a/src/components/capture.ts +++ b/src/components/capture.ts @@ -1,16 +1,26 @@ -import { type EncoderNode, EncoderPrecedence } from '../encoder/types'; -import type { Capture, RegexElement } from './types'; +import { encodeSequence } from '../encoder'; +import { + EncoderPrecedence, + type EncoderResult, + type RegexElement, +} from '../types'; -export function capture(...children: Array): Capture { - return { - type: 'capture', - children, - }; +export class Capture implements RegexElement { + public children: Array; + + constructor(children: Array) { + this.children = children; + } + + encode(): EncoderResult { + const children = encodeSequence(this.children); + return { + precedence: EncoderPrecedence.Atom, + pattern: `(${children.pattern})`, + }; + } } -export function encodeCapture(node: EncoderNode): EncoderNode { - return { - precedence: EncoderPrecedence.Atom, - pattern: `(${node.pattern})`, - }; +export function capture(...children: Array): Capture { + return new Capture(children); } diff --git a/src/components/character-class.ts b/src/components/character-class.ts index 7c6dbd3..ffbf360 100644 --- a/src/components/character-class.ts +++ b/src/components/character-class.ts @@ -1,57 +1,48 @@ -import { type EncoderNode, EncoderPrecedence } from '../encoder/types'; import { escapeText } from '../utils'; -import type { CharacterClass } from './types'; +import { + EncoderPrecedence, + type EncoderResult, + type RegexElement, +} from '../types'; -export const any: CharacterClass = { - type: 'characterClass', - characters: ['.'], -}; +export class CharacterClass implements RegexElement { + public characters: string[]; -export const whitespace: CharacterClass = { - type: 'characterClass', - characters: ['\\s'], -}; + constructor(characters: string[]) { + if (characters.length === 0) { + throw new Error('Character class should contain at least one character'); + } -export const digit: CharacterClass = { - type: 'characterClass', - characters: ['\\d'], -}; - -export const word: CharacterClass = { - type: 'characterClass', - characters: ['\\w'], -}; - -export function anyOf(characters: string): CharacterClass { - const charactersArray = characters.split('').map(escapeText); - if (charactersArray.length === 0) { - throw new Error('`anyOf` should received at least one character'); + this.characters = characters; } - return { - type: 'characterClass', - characters: charactersArray, - }; -} - -export function encodeCharacterClass({ - characters, -}: CharacterClass): EncoderNode { - if (characters.length === 0) { - throw new Error('Character class should contain at least one character'); - } + encode(): EncoderResult { + if (this.characters.length === 1) { + return { + precedence: EncoderPrecedence.Atom, + pattern: this.characters[0]!, + }; + } - if (characters.length === 1) { return { precedence: EncoderPrecedence.Atom, - pattern: characters[0]!, + pattern: `[${reorderHyphen(this.characters).join('')}]`, }; } +} + +export const any = new CharacterClass(['.']); +export const whitespace = new CharacterClass(['\\s']); +export const digit = new CharacterClass(['\\d']); +export const word = new CharacterClass(['\\w']); + +export function anyOf(characters: string): CharacterClass { + const charactersArray = characters.split('').map(escapeText); + if (charactersArray.length === 0) { + throw new Error('`anyOf` should received at least one character'); + } - return { - precedence: EncoderPrecedence.Atom, - pattern: `[${reorderHyphen(characters).join('')}]`, - }; + return new CharacterClass(charactersArray); } // If passed characters includes hyphen (`-`) it need to be moved to diff --git a/src/components/choice-of.ts b/src/components/choice-of.ts index 9f21f31..307652c 100644 --- a/src/components/choice-of.ts +++ b/src/components/choice-of.ts @@ -1,32 +1,34 @@ +import { encodeElement } from '../encoder'; import { - type EncodeElement, - type EncoderNode, EncoderPrecedence, -} from '../encoder/types'; -import type { ChoiceOf, RegexElement } from './types'; + type EncoderResult, + type RegexElement, +} from '../types'; -export function choiceOf(...children: Array): ChoiceOf { - if (children.length === 0) { - throw new Error('`choiceOf` should receive at least one option'); +export class ChoiceOf implements RegexElement { + public children: Array; + + constructor(children: Array) { + if (children.length === 0) { + throw new Error('"choiceOf" should receive at least one option'); + } + + this.children = children; } - return { - type: 'choiceOf', - children, - }; -} + encode(): EncoderResult { + const children = this.children.map(encodeElement); + if (children.length === 1) { + return children[0]!; + } -export function encodeChoiceOf( - element: ChoiceOf, - encodeElement: EncodeElement -): EncoderNode { - const encodedNodes = element.children.map(encodeElement); - if (encodedNodes.length === 1) { - return encodedNodes[0]!; + return { + precedence: EncoderPrecedence.Alternation, + pattern: children.map((n) => n.pattern).join('|'), + }; } +} - return { - precedence: EncoderPrecedence.Alternation, - pattern: encodedNodes.map((n) => n.pattern).join('|'), - }; +export function choiceOf(...children: Array): ChoiceOf { + return new ChoiceOf(children); } diff --git a/src/components/quantifiers.ts b/src/components/quantifiers.ts index 1ba6816..8cd971f 100644 --- a/src/components/quantifiers.ts +++ b/src/components/quantifiers.ts @@ -1,68 +1,105 @@ -import { type EncoderNode, EncoderPrecedence } from '../encoder/types'; +import { encodeSequence } from '../encoder'; import { toAtom } from '../utils'; -import type { - One, - OneOrMore, - Optionally, - RegexElement, - ZeroOrMore, -} from './types'; +import { + EncoderPrecedence, + type EncoderResult, + type RegexElement, +} from '../types'; + +export class One implements RegexElement { + public children: Array; + + constructor(children: Array) { + if (children.length === 0) { + throw new Error('"one" should receive at least one element'); + } + + this.children = children; + } + + encode(): EncoderResult { + return encodeSequence(this.children); + } +} export function one(...children: Array): One { - return { - type: 'one', - children, - }; + return new One(children); +} + +export class OneOrMore implements RegexElement { + public children: Array; + + constructor(children: Array) { + if (children.length === 0) { + throw new Error('"oneOrMore" should receive at least one element'); + } + + this.children = children; + } + + encode(): EncoderResult { + const children = encodeSequence(this.children); + return { + precedence: EncoderPrecedence.Sequence, + pattern: `${toAtom(children)}+`, + }; + } } export function oneOrMore( ...children: Array ): OneOrMore { - return { - type: 'oneOrMore', - children, - }; + return new OneOrMore(children); +} + +export class Optionally implements RegexElement { + public children: Array; + + constructor(children: Array) { + if (children.length === 0) { + throw new Error('"optionally" should receive at least one element'); + } + + this.children = children; + } + + encode(): EncoderResult { + const children = encodeSequence(this.children); + return { + precedence: EncoderPrecedence.Sequence, + pattern: `${toAtom(children)}?`, + }; + } } export function optionally( ...children: Array ): Optionally { - return { - type: 'optionally', - children, - }; + return new Optionally(children); } -export function zeroOrMore( - ...children: Array -): ZeroOrMore { - return { - type: 'zeroOrMore', - children, - }; -} +export class ZeroOrMore implements RegexElement { + public children: Array; -export function encodeOne(node: EncoderNode) { - return node; -} + constructor(children: Array) { + if (children.length === 0) { + throw new Error('"zeroOrMore" should receive at least one element'); + } -export function encodeOneOrMore(node: EncoderNode): EncoderNode { - return { - precedence: EncoderPrecedence.Sequence, - pattern: `${toAtom(node)}+`, - }; -} + this.children = children; + } -export function encodeOptionally(node: EncoderNode): EncoderNode { - return { - precedence: EncoderPrecedence.Sequence, - pattern: `${toAtom(node)}?`, - }; + encode(): EncoderResult { + const children = encodeSequence(this.children); + return { + precedence: EncoderPrecedence.Sequence, + pattern: `${toAtom(children)}*`, + }; + } } -export function encodeZeroOrMore(node: EncoderNode): EncoderNode { - return { - precedence: EncoderPrecedence.Sequence, - pattern: `${toAtom(node)}*`, - }; +export function zeroOrMore( + ...children: Array +): ZeroOrMore { + return new ZeroOrMore(children); } diff --git a/src/components/repeat.ts b/src/components/repeat.ts index 17b53d0..51824e5 100644 --- a/src/components/repeat.ts +++ b/src/components/repeat.ts @@ -1,35 +1,46 @@ -import { type EncoderNode, EncoderPrecedence } from '../encoder/types'; +import { encodeSequence } from '../encoder'; import { toAtom } from '../utils'; -import type { RegexElement, Repeat, RepeatConfig } from './types'; +import { + EncoderPrecedence, + type EncoderResult, + type RegexElement, +} from '../types'; -export function repeat( - config: RepeatConfig, - ...children: Array -): Repeat { - if (children.length === 0) { - throw new Error('`repeat` should receive at least one element'); +export type RepeatConfig = { count: number } | { min: number; max?: number }; + +export class Repeat implements RegexElement { + public children: Array; + public config: RepeatConfig; + + constructor(children: Array, config: RepeatConfig) { + if (children.length === 0) { + throw new Error('"repeat" should receive at least one element'); + } + + this.children = children; + this.config = config; } - return { - type: 'repeat', - children, - config, - }; -} + encode(): EncoderResult { + const children = encodeSequence(this.children); + if ('count' in this.config) { + return { + precedence: EncoderPrecedence.Sequence, + pattern: `${toAtom(children)}{${this.config.count}}`, + }; + } -export function encodeRepeat( - config: RepeatConfig, - node: EncoderNode -): EncoderNode { - if ('count' in config) { return { precedence: EncoderPrecedence.Sequence, - pattern: `${toAtom(node)}{${config.count}}`, + pattern: `${toAtom(children)}{${this.config.min},${ + this.config?.max ?? '' + }}`, }; } - - return { - precedence: EncoderPrecedence.Sequence, - pattern: `${toAtom(node)}{${config.min},${config?.max ?? ''}}`, - }; +} +export function repeat( + config: RepeatConfig, + ...children: Array +): Repeat { + return new Repeat(children, config); } diff --git a/src/components/types.ts b/src/components/types.ts deleted file mode 100644 index 648b679..0000000 --- a/src/components/types.ts +++ /dev/null @@ -1,49 +0,0 @@ -export type RegexElement = Capture | CharacterClass | ChoiceOf | Quantifier; - -export type Quantifier = One | OneOrMore | Optionally | ZeroOrMore | Repeat; - -export type CharacterClass = { - type: 'characterClass'; - characters: string[]; -}; - -// Components -export type ChoiceOf = { - type: 'choiceOf'; - children: Array; -}; - -// Quantifiers -export type One = { - type: 'one'; - children: Array; -}; - -export type OneOrMore = { - type: 'oneOrMore'; - children: Array; -}; - -export type Optionally = { - type: 'optionally'; - children: Array; -}; - -export type ZeroOrMore = { - type: 'zeroOrMore'; - children: Array; -}; - -export type Repeat = { - type: 'repeat'; - children: Array; - config: RepeatConfig; -}; - -export type RepeatConfig = { count: number } | { min: number; max?: number }; - -// Captures -export type Capture = { - type: 'capture'; - children: Array; -}; diff --git a/src/encoder.ts b/src/encoder.ts new file mode 100644 index 0000000..84057f1 --- /dev/null +++ b/src/encoder.ts @@ -0,0 +1,38 @@ +import { + EncoderPrecedence, + type EncoderResult, + type RegexElement, +} from './types'; +import { concatNodes, escapeText } from './utils'; + +export function encodeSequence( + elements: Array +): EncoderResult { + return concatNodes(elements.map((c) => encodeElement(c))); +} + +export function encodeElement(element: RegexElement | string): EncoderResult { + if (typeof element === 'string') { + return encodeText(element); + } + + return element.encode(); +} + +function encodeText(text: string): EncoderResult { + if (text.length === 0) { + throw new Error('"encodeText": received text should not be empty'); + } + + if (text.length === 1) { + return { + precedence: EncoderPrecedence.Atom, + pattern: escapeText(text), + }; + } + + return { + precedence: EncoderPrecedence.Sequence, + pattern: escapeText(text), + }; +} diff --git a/src/encoder/encoder.ts b/src/encoder/encoder.ts deleted file mode 100644 index 5a586aa..0000000 --- a/src/encoder/encoder.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { RegexElement } from '../components/types'; -import { encodeCapture } from '../components/capture'; -import { encodeCharacterClass } from '../components/character-class'; -import { encodeChoiceOf } from '../components/choice-of'; -import { - encodeOne, - encodeOneOrMore, - encodeOptionally, - encodeZeroOrMore, -} from '../components/quantifiers'; -import { encodeRepeat } from '../components/repeat'; -import { concatNodes, escapeText } from '../utils'; -import { type EncoderNode, EncoderPrecedence } from './types'; - -export function encodeSequence( - elements: Array -): EncoderNode { - return concatNodes(elements.map((c) => encodeElement(c))); -} - -export function encodeElement(element: RegexElement | string): EncoderNode { - if (typeof element === 'string') { - return encodeText(element); - } - - if (element.type === 'characterClass') { - return encodeCharacterClass(element); - } - - if (element.type === 'choiceOf') { - return encodeChoiceOf(element, encodeElement); - } - - if (element.type === 'repeat') { - return encodeRepeat(element.config, encodeSequence(element.children)); - } - - if (element.type === 'one') { - return encodeOne(encodeSequence(element.children)); - } - - if (element.type === 'oneOrMore') { - return encodeOneOrMore(encodeSequence(element.children)); - } - - if (element.type === 'optionally') { - return encodeOptionally(encodeSequence(element.children)); - } - - if (element.type === 'zeroOrMore') { - return encodeZeroOrMore(encodeSequence(element.children)); - } - - if (element.type === 'capture') { - return encodeCapture(encodeSequence(element.children)); - } - - // @ts-expect-error User passed incorrect type - throw new Error(`Unknown elements type ${element.type}`); -} - -function encodeText(text: string): EncoderNode { - if (text.length === 0) { - throw new Error('`encodeText`: received text should not be empty'); - } - - if (text.length === 1) { - return { - precedence: EncoderPrecedence.Atom, - pattern: escapeText(text), - }; - } - - return { - precedence: EncoderPrecedence.Sequence, - pattern: escapeText(text), - }; -} diff --git a/src/index.ts b/src/index.ts index 652875f..df4ad3b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -export type * from './components/types'; +export type * from './types'; export { buildPattern, buildRegex } from './builders'; diff --git a/src/test-utils.ts b/src/test-utils.ts index cea4dde..29b7c02 100644 --- a/src/test-utils.ts +++ b/src/test-utils.ts @@ -1,5 +1,5 @@ import { buildRegex } from './builders'; -import type { RegexElement } from './components/types'; +import type { RegexElement } from './types'; export function execRegex( text: string, diff --git a/src/encoder/types.ts b/src/types.ts similarity index 65% rename from src/encoder/types.ts rename to src/types.ts index 8a9125a..96a048e 100644 --- a/src/encoder/types.ts +++ b/src/types.ts @@ -1,9 +1,11 @@ -import type { RegexElement } from '../components/types'; +export interface RegexElement { + encode: () => EncoderResult; +} /** * Encoded regex pattern with information about its type (atom, sequence) */ -export interface EncoderNode { +export interface EncoderResult { precedence: EncoderPrecedence; pattern: string; } @@ -23,7 +25,5 @@ export const EncoderPrecedence = { Alternation: 3, } as const; -type ValueOf = T[keyof T]; -type EncoderPrecedence = ValueOf; - -export type EncodeElement = (element: RegexElement | string) => EncoderNode; +export type ValueOf = T[keyof T]; +export type EncoderPrecedence = ValueOf; diff --git a/src/utils.ts b/src/utils.ts index d0d930d..4575be5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,5 +1,8 @@ -import type { RegexElement } from './components/types'; -import { type EncoderNode, EncoderPrecedence } from './encoder/types'; +import { + EncoderPrecedence, + type EncoderResult, + type RegexElement, +} from './types'; /** * Returns atomic pattern for given node. @@ -7,7 +10,7 @@ import { type EncoderNode, EncoderPrecedence } from './encoder/types'; * @param node * @returns */ -export function toAtom(node: EncoderNode): string { +export function toAtom(node: EncoderResult): string { if (node.precedence === EncoderPrecedence.Atom) { return node.pattern; } @@ -15,7 +18,7 @@ export function toAtom(node: EncoderNode): string { return `(?:${node.pattern})`; } -export function concatNodes(nodes: EncoderNode[]): EncoderNode { +export function concatNodes(nodes: EncoderResult[]): EncoderResult { if (nodes.length === 1) { return nodes[0]!; } @@ -30,8 +33,8 @@ export function concatNodes(nodes: EncoderNode[]): EncoderNode { }; } -export function isRegexElement(element: unknown): element is RegexElement { - return typeof element === 'object' && element !== null && 'type' in element; +export function isValidElement(element: unknown): element is RegexElement { + return typeof element === 'object' && element !== null && 'encode' in element; } // Source: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping