Skip to content

Commit aa585b1

Browse files
PaulJPhilpPaulPhilpmdjastrzebski
authored
feat: support for lookarounds and non-capture groups (#64)
Co-authored-by: Paul Philp <paul@getamity.com> Co-authored-by: Maciej Jastrzębski <mdjastrzebski@gmail.com>
1 parent 5a56d68 commit aa585b1

18 files changed

+809
-28
lines changed

README.md

+19-12
Original file line numberDiff line numberDiff line change
@@ -104,23 +104,30 @@ See [Regex Builder API doc](./docs/API.md#builder) for more info.
104104

105105
### Regex Constructs
106106

107-
| Construct | Regex Syntax | Notes |
108-
| ------------------- | ------------ | ------------------------------- |
109-
| `capture(...)` | `(...)` | Create a capture group |
110-
| `choiceOf(x, y, z)` | `x\|y\|z` | Match one of provided sequences |
107+
| Construct | Regex Syntax | Notes |
108+
| ------------------------- | ------------ | ------------------------------------------- |
109+
| `choiceOf(x, y, z)` | `x\|y\|z` | Match one of provided sequences |
110+
| `capture(...)` | `(...)` | Create a capture group |
111+
| `lookahead(...)` | `(?=...)` | Match subsequent text without consuming it |
112+
| `negativeLookhead(...)` | `(?!...)` | Reject subsequent text without consuming it |
113+
| `lookbehind(...)` | `(?<=...)` | Match preceding text without consuming it |
114+
| `negativeLookbehind(...)` | `(?<!...)` | Reject preceding text without consuming it |
111115

112116
See [Regex Constructs API doc](./docs/API.md#constructs) for more info.
113117

118+
> [!NOTE]
119+
> TS Regex Builder does not have a construct for non-capturing groups. Such groups are implicitly added when required.
120+
114121
### Quantifiers
115122

116-
| Quantifier | Regex Syntax | Description |
117-
| ----------------------------------------------- | ------------ | -------------------------------------------------------------- |
118-
| `zeroOrMore(x)` | `x*` | Zero or more occurence of a pattern |
119-
| `oneOrMore(x)` | `x+` | One or more occurence of a pattern |
120-
| `optional(x)` | `x?` | Zero or one occurence of a pattern |
121-
| `repeat(x, n)` | `x{n}` | Pattern repeats exact number of times |
122-
| `repeat(x, { min: n, })` | `x{n,}` | Pattern repeats at least given number of times |
123-
| `repeat(x, { min: n, max: n2 })` | `x{n1,n2}` | Pattern repeats between n1 and n2 number of times |
123+
| Quantifier | Regex Syntax | Description |
124+
| -------------------------------- | ------------ | ------------------------------------------------- |
125+
| `zeroOrMore(x)` | `x*` | Zero or more occurence of a pattern |
126+
| `oneOrMore(x)` | `x+` | One or more occurence of a pattern |
127+
| `optional(x)` | `x?` | Zero or one occurence of a pattern |
128+
| `repeat(x, n)` | `x{n}` | Pattern repeats exact number of times |
129+
| `repeat(x, { min: n, })` | `x{n,}` | Pattern repeats at least given number of times |
130+
| `repeat(x, { min: n, max: n2 })` | `x{n1,n2}` | Pattern repeats between n1 and n2 number of times |
124131

125132
See [Quantifiers API doc](./docs/API.md#quantifiers) for more info.
126133

docs/API.md

+58-7
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,20 @@ It optionally accepts a list of regex flags:
4646

4747
These functions and objects represent available regex constructs.
4848

49+
### `choiceOf()`
50+
51+
```ts
52+
function choiceOf(
53+
...alternatives: RegexSequence[]
54+
): ChoiceOf {
55+
```
56+
57+
Regex syntax: `a|b|c`.
58+
59+
The `choiceOf` (disjunction) construct matches one out of several possible sequences. It functions similarly to a logical OR operator in programming. It can match simple string options as well as complex patterns.
60+
61+
Example: `choiceOf("color", "colour")` matches either `color` or `colour` pattern.
62+
4963
### `capture()`
5064
5165
```ts
@@ -58,19 +72,56 @@ Regex syntax: `(...)`.
5872

5973
Captures, also known as capturing groups, extract and store parts of the matched string for later use.
6074

61-
### `choiceOf()`
75+
> [!NOTE]
76+
> 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)+`.
77+
78+
### `lookahead()`
6279

6380
```ts
64-
function choiceOf(
65-
...alternatives: RegexSequence[]
66-
): ChoiceOf {
81+
function lookahead(
82+
sequence: RegexSequence
83+
): Lookahead
6784
```
6885

69-
Regex syntax: `a|b|c`.
86+
Regex syntax: `(?=...)`.
7087

71-
The `choiceOf` (disjunction) construct matches one out of several possible sequences. It functions similarly to a logical OR operator in programming. It can match simple string options as well as complex patterns.
88+
Allows for conditional matching by checking for subsequent patterns in regexes without consuming them.
7289

73-
Example: `choiceOf("color", "colour")` matches either `color` or `colour` pattern.
90+
### `negativeLookahead()`
91+
92+
```ts
93+
function negativeLookahead(
94+
sequence: RegexSequence
95+
): NegativeLookahead
96+
```
97+
98+
Regex syntax: `(?!...)`.
99+
100+
Allows for matches to be rejected if a specified subsequent pattern is present, without consuming any characters.
101+
102+
### `lookbehind()`
103+
104+
```ts
105+
function lookbehind(
106+
sequence: RegexSequence
107+
): Lookahead
108+
```
109+
110+
Regex syntax: `(?<=...)`.
111+
112+
Allows for conditional matching by checking for preceeding patterns in regexes without consuming them.
113+
114+
### `negativeLookbehind()`
115+
116+
```ts
117+
function negativeLookahead(
118+
sequence: RegexSequence
119+
): NegativeLookahead
120+
```
121+
122+
Regex syntax: `(?<!...)`.
123+
124+
Allows for matches to be rejected if a specified preceeding pattern is present, without consuming any characters.
74125

75126
## Quantifiers
76127

docs/Examples.md

+67-3
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ Encoded regex: `/^#?(?:[a-f\d]{6}|[a-f\d]{3})$/i`.
4747

4848
See tests: [example-hex-color.ts](../src/__tests__/example-hex-color.ts).
4949

50-
## Simple URL validation
50+
## URL validation
5151

5252
This regex validates (in a simplified way) whether a given string is a URL.
5353

@@ -75,7 +75,9 @@ const isValid = regex.test("https://hello.github.com");
7575

7676
Encoded regex: `/^(?:(?:http|https):\/\/)?(?:(?:[a-z\d]|[a-z\d][a-z\d-]*[a-z\d])\.)+[a-z][a-z\d]+$/`.
7777

78-
See tests: [example-url.ts](../src/__tests__/example-url.ts).
78+
See tests: [example-url-simple.ts](../src/__tests__/example-url-simple.ts).
79+
80+
For more advanced URL validation check: [example-url-advanced.ts](../src/__tests__/example-url-advanced.ts).
7981

8082
## Email address validation
8183

@@ -109,7 +111,6 @@ See tests: [example-email.ts](../src/__tests__/example-email.ts).
109111

110112
This regex validates if a given string is a valid JavaScript number.
111113

112-
113114
```ts
114115
const sign = anyOf('+-');
115116
const exponent = [anyOf('eE'), optional(sign), oneOrMore(digit)];
@@ -185,3 +186,66 @@ const isValid = regex.test(192.168.0.1");
185186
Encoded regex: `/^(?:(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])\.){3}(?:[0-9]|[1-9][0-9]|1[0-9][0-9]|2[0-4][0-9]|25[0-5])$/,`.
186187
187188
See tests: [example-regexp.ts](../src/__tests__/example-regexp.ts).
189+
190+
## Simple password validation
191+
192+
This regex corresponds to following password policy:
193+
- at least one uppercase letter
194+
- at least one lowercase letter
195+
- at least one digit
196+
- at least one special character
197+
- at least 8 characters long
198+
199+
```ts
200+
const atLeastOneUppercase = lookahead([zeroOrMore(any), /[A-Z]/]);
201+
const atLeastOneLowercase = lookahead([zeroOrMore(any), /[a-z]/]);
202+
const atLeastOneDigit = lookahead([zeroOrMore(any), /[0-9]/]);
203+
const atLeastOneSpecialChar = lookahead([zeroOrMore(any), /[^A-Za-z0-9\s]/]);
204+
const atLeastEightChars = /.{8,}/;
205+
206+
// Match
207+
const validPassword = buildRegExp([
208+
startOfString,
209+
atLeastOneUppercase,
210+
atLeastOneLowercase,
211+
atLeastOneDigit,
212+
atLeastOneSpecialChar,
213+
atLeastEightChars,
214+
endOfString
215+
]);
216+
217+
const isValid = regex.test("Aa$123456");
218+
```
219+
220+
Encoded regex: `/^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[^A-Za-z0-9\s])(?:.{8,})$/`.
221+
222+
See tests: [example-password.ts](../src/__tests__/example-password.ts).
223+
224+
## Match currency values
225+
226+
```ts
227+
const currencySymbol = '$€£¥R₿';
228+
const decimalSeparator = '.';
229+
230+
const firstThousandsClause = repeat(digit, { min: 1, max: 3 });
231+
const thousandsSeparator = ',';
232+
const thousands = repeat(digit, 3);
233+
const thousandsClause = [optional(thousandsSeparator), thousands];
234+
const cents = repeat(digit, 2);
235+
const isCurrency = lookbehind(anyOf(currencySymbol));
236+
237+
const currencyRegex = buildRegExp([
238+
isCurrency,
239+
optional(whitespace),
240+
firstThousandsClause,
241+
zeroOrMore(thousandsClause),
242+
optional([decimalSeparator, cents]),
243+
endOfString,
244+
]);
245+
246+
const isValid = regex.test("£1,000");
247+
```
248+
249+
Encoded regex: `/(?<=[$€£¥R₿])\s?\d{1,3}(?:,?\d{3})*(?:\.\d{2})?$/`.
250+
251+
See tests: [example-currency.ts](../src/__tests__/example-currency.ts).

src/__tests__/example-currency.ts

+41
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { buildRegExp } from '../builders';
2+
import { anyOf, digit, endOfString, optional, repeat, whitespace, zeroOrMore } from '../index';
3+
import { lookbehind } from '../constructs/lookbehind';
4+
5+
const currencySymbol = '$€£¥R₿';
6+
const decimalSeparator = '.';
7+
8+
const firstThousandsClause = repeat(digit, { min: 1, max: 3 });
9+
const thousandsSeparator = ',';
10+
const thousands = repeat(digit, 3);
11+
const thousandsClause = [optional(thousandsSeparator), thousands];
12+
const cents = repeat(digit, 2);
13+
const isCurrency = lookbehind(anyOf(currencySymbol));
14+
15+
test('example: extracting currency values', () => {
16+
const currencyRegex = buildRegExp([
17+
isCurrency,
18+
optional(whitespace),
19+
firstThousandsClause,
20+
zeroOrMore(thousandsClause),
21+
optional([decimalSeparator, cents]),
22+
endOfString,
23+
]);
24+
25+
expect(currencyRegex).toMatchString('$10');
26+
expect(currencyRegex).toMatchString('$ 10');
27+
expect(currencyRegex).not.toMatchString('$ 10.');
28+
expect(currencyRegex).toMatchString('$ 10');
29+
expect(currencyRegex).not.toMatchString('$10.5');
30+
expect(currencyRegex).toMatchString('$10.50');
31+
expect(currencyRegex).not.toMatchString('$10.501');
32+
expect(currencyRegex).toMatchString('€100');
33+
expect(currencyRegex).toMatchString('£1,000');
34+
expect(currencyRegex).toMatchString('$ 100000000000000000');
35+
expect(currencyRegex).toMatchString('€ 10000');
36+
expect(currencyRegex).toMatchString('₿ 100,000');
37+
expect(currencyRegex).not.toMatchString('10$');
38+
expect(currencyRegex).not.toMatchString('£A000');
39+
40+
expect(currencyRegex).toEqualRegex(/(?<=[$£¥R])\s?\d{1,3}(?:,?\d{3})*(?:\.\d{2})?$/);
41+
});

src/__tests__/example-filename.ts

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { buildRegExp, choiceOf, endOfString, negativeLookbehind, oneOrMore } from '../index';
2+
3+
const isRejectedFileExtension = negativeLookbehind(choiceOf('js', 'css', 'html'));
4+
5+
test('example: filename validator', () => {
6+
const filenameRegex = buildRegExp([
7+
oneOrMore(/[A-Za-z0-9_]/),
8+
isRejectedFileExtension,
9+
endOfString,
10+
]);
11+
12+
expect(filenameRegex).toMatchString('index.ts');
13+
expect(filenameRegex).toMatchString('index.tsx');
14+
expect(filenameRegex).toMatchString('ind/ex.ts');
15+
expect(filenameRegex).not.toMatchString('index.js');
16+
expect(filenameRegex).not.toMatchString('index.html');
17+
expect(filenameRegex).not.toMatchString('index.css');
18+
expect(filenameRegex).not.toMatchString('./index.js');
19+
expect(filenameRegex).not.toMatchString('./index.html');
20+
expect(filenameRegex).not.toMatchString('./index.css');
21+
});

src/__tests__/example-password.ts

+43
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { any, buildRegExp, endOfString, lookahead, startOfString, zeroOrMore } from '../index';
2+
3+
//^(?=.*[A-Z])(?=.*[a-z])(?=.*\d)(?=.*[^A-Za-z0-9\s]).{8,}$
4+
5+
//
6+
// The password policy is as follows:
7+
// - At least one uppercase letter
8+
// - At least one lowercase letter
9+
// - At least one digit
10+
// - At least one special character
11+
// - At least 8 characters long
12+
13+
const atLeastOneUppercase = lookahead([zeroOrMore(any), /[A-Z]/]);
14+
const atLeastOneLowercase = lookahead([zeroOrMore(any), /[a-z]/]);
15+
const atLeastOneDigit = lookahead([zeroOrMore(any), /[0-9]/]);
16+
const atLeastOneSpecialChar = lookahead([zeroOrMore(any), /[^A-Za-z0-9\s]/]);
17+
const atLeastEightChars = /.{8,}/;
18+
19+
test('Example: Validating passwords', () => {
20+
const validPassword = buildRegExp([
21+
startOfString,
22+
atLeastOneUppercase,
23+
atLeastOneLowercase,
24+
atLeastOneDigit,
25+
atLeastOneSpecialChar,
26+
atLeastEightChars,
27+
endOfString,
28+
]);
29+
30+
expect(validPassword).toMatchString('Aaaaa$aaaaaaa1');
31+
expect(validPassword).not.toMatchString('aaaaaaaaaaa');
32+
expect(validPassword).toMatchString('9aaa#aaaaA');
33+
expect(validPassword).not.toMatchString('Aa');
34+
expect(validPassword).toMatchString('Aa$123456');
35+
expect(validPassword).not.toMatchString('Abba');
36+
expect(validPassword).not.toMatchString('#password');
37+
expect(validPassword).toMatchString('#passworD666');
38+
expect(validPassword).not.toMatchString('Aa%1234');
39+
40+
expect(validPassword).toEqualRegex(
41+
/^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9])(?=.*[^A-Za-z0-9\s])(?:.{8,})$/,
42+
);
43+
});

0 commit comments

Comments
 (0)