Skip to content

Commit 6b8d4dc

Browse files
authored
feat: default to non-capture, add grouped and groupedAs (#42)
1 parent 14e77b1 commit 6b8d4dc

File tree

10 files changed

+203
-97
lines changed

10 files changed

+203
-97
lines changed

docs/content/2.getting-started/2.usage.md

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,15 @@ All of the helpers above return an object of type `Input` that can be chained wi
5656
| `after`, `before`, `notAfter` and `notBefore` | these activate positive/negative lookahead/lookbehinds. Make sure to check [browser support](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#browser_compatibility) as not all browsers support lookbehinds (notably Safari). |
5757
| `times` | this is a function you can call directly to repeat the previous pattern an exact number of times, or you can use `times.between(min, max)` to specify a range, `times.atLeast(num)` to indicate it must repeat x times or `times.any()` to indicate it can repeat any number of times, _including none_. |
5858
| `optionally` | this is a function you can call to mark the current input as optional. |
59-
| `as` | this defines the entire input so far as a named capture group. You will get type safety when using the resulting RegExp with `String.match()`. |
59+
| `as` | alias for `groupedAs` |
60+
| `groupedAs` | this defines the entire input so far as a named capture group. You will get type safety when using the resulting RegExp with `String.match()`. |
61+
| `grouped` | this defines the entire input so far as an anonymous group. |
6062
| `at` | this allows you to match beginning/ends of lines with `at.lineStart()` and `at.lineEnd()`. |
6163

64+
::alert
65+
By default, for better regex performance, creation input helpers such as `anyOf`, `maybe`, `oneOrMore`, and chaining input helpers such as `or`, `times(.between/atLeast/any)`, or `optionally` will wrap the input in a non-capturing group with `(?:)`. You can use chaining input helper `grouped` after any `Input` type to capture it as an anonymous group.
66+
::
67+
6268
## Debugging
6369

6470
When using `magic-regexp`, a TypeScript generic is generated for you that should show the RegExp that you are constructing, as you go.
@@ -74,7 +80,7 @@ exactly('test.mjs')
7480
// (alias) exactly<"test.mjs">(input: "test.mjs"): Input<"test\\.mjs", never>
7581

7682
exactly('test.mjs').or('something.else')
77-
// (property) Input<"test\\.mjs", never>.or: <"something.else">(input: "something.else") => Input<"(test\\.mjs|something\\.else)", never>
83+
// (property) Input<"test\\.mjs", never>.or: <"something.else">(input: "something.else") => Input<"(?:test\\.mjs|something\\.else)", never>
7884
```
7985

8086
Each function, if you hover over it, shows what's going in, and what's coming out by way of regular expression

docs/content/2.getting-started/3.examples.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@ import { createRegExp, exactly, oneOrMore, digit } from 'magic-regexp'
99

1010
createRegExp(
1111
oneOrMore(digit)
12-
.as('major')
12+
.groupedAs('major')
1313
.and('.')
14-
.and(oneOrMore(digit).as('minor'))
15-
.and(exactly('.').and(oneOrMore(char).as('patch')).optionally())
14+
.and(oneOrMore(digit).groupedAs('minor'))
15+
.and(exactly('.').and(oneOrMore(char).groupedAs('patch')).optionally())
1616
)
17-
// /(?<major>(\d)+)\.(?<minor>(\d)+)(\.(?<patch>(.)+))?/
17+
// /(?<major>\d+)\.(?<minor>\d+)(?:\.(?<patch>.+))?/
1818
```
1919

2020
### References to previously captured groups using the group name
@@ -25,8 +25,8 @@ import { createRegExp, word, char, oneOrMore } from 'magic-regexp'
2525

2626
const TENET_RE = createRegExp(
2727
wordChar
28-
.as('firstChar')
29-
.and(wordChar.as('secondChar'))
28+
.groupedAs('firstChar')
29+
.and(wordChar.groupedAs('secondChar'))
3030
.and(oneOrMore(char))
3131
.and.referenceTo('secondChar')
3232
.and.referenceTo('firstChar')

playground/index.mjs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,17 @@ import assert from 'node:assert'
22
import { createRegExp, exactly, digit, oneOrMore, char, wordChar } from 'magic-regexp'
33

44
// Typed capture groups
5-
const ID_RE = createRegExp(exactly('id-').and(digit.times(5).as('id')))
5+
const ID_RE = createRegExp(exactly('id-').and(digit.times(5).groupedAs('id')))
66
const groups = 'some id-23490 here we go'.match(ID_RE)?.groups
77
console.log(ID_RE, groups?.id)
88

99
// Quick-and-dirty semver
1010
const SEMVER_RE = createRegExp(
1111
oneOrMore(digit)
12-
.as('major')
12+
.groupedAs('major')
1313
.and('.')
14-
.and(oneOrMore(digit).as('minor'))
15-
.and(exactly('.').and(oneOrMore(char).as('patch')).optionally())
14+
.and(oneOrMore(digit).groupedAs('minor'))
15+
.and(exactly('.').and(oneOrMore(char).groupedAs('patch')).optionally())
1616
)
1717
console.log(SEMVER_RE)
1818

@@ -21,8 +21,8 @@ assert.equal(createRegExp(exactly('foo/test.js').after('bar/')).test('bar/foo/te
2121
// References to previously captured groups using the group name
2222
const TENET_RE = createRegExp(
2323
wordChar
24-
.as('firstChar')
25-
.and(wordChar.as('secondChar'))
24+
.groupedAs('firstChar')
25+
.and(wordChar.groupedAs('secondChar'))
2626
.and(oneOrMore(char))
2727
.and.referenceTo('secondChar')
2828
.and.referenceTo('firstChar')

src/core/inputs.ts

Lines changed: 15 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { createInput, Input } from './internal'
22
import type { GetValue, EscapeChar } from './types/escape'
33
import type { Join } from './types/join'
44
import type { MapToGroups, MapToValues, InputSource, GetGroup } from './types/sources'
5-
import { Wrap, wrap } from './wrap'
5+
import { IfUnwrapped, wrap } from './wrap'
66

77
export type { Input }
88

9+
const ESCAPE_REPLACE_RE = /[.*+?^${}()|[\]\\/]/g
10+
911
/** This matches any character in the string provided */
1012
export const charIn = <T extends string>(chars: T) =>
1113
createInput(`[${chars.replace(/[-\\^\]]/g, '\\$&')}]`) as Input<`[${EscapeChar<T>}]`>
@@ -14,12 +16,10 @@ export const charIn = <T extends string>(chars: T) =>
1416
export const charNotIn = <T extends string>(chars: T) =>
1517
createInput(`[^${chars.replace(/[-\\^\]]/g, '\\$&')}]`) as Input<`[^${EscapeChar<T>}]`>
1618

17-
/** This takes an array of inputs and matches any of them. */
18-
export const anyOf = <New extends InputSource<V, T>[], V extends string, T extends string>(
19-
...args: New
20-
) =>
21-
createInput(`(${args.map(a => exactly(a)).join('|')})`) as Input<
22-
`(${Join<MapToValues<New>>})`,
19+
/** This takes an array of inputs and matches any of them */
20+
export const anyOf = <New extends InputSource<string, string>[]>(...args: New) =>
21+
createInput(`(?:${args.map(a => exactly(a)).join('|')})`) as Input<
22+
`(?:${Join<MapToValues<New>>})`,
2323
MapToGroups<New>
2424
>
2525

@@ -47,23 +47,22 @@ export const not = {
4747

4848
/** Equivalent to `?` - this marks the input as optional */
4949
export const maybe = <New extends InputSource<string>>(str: New) =>
50-
createInput(`${wrap(exactly(str))}?`) as Wrap<
50+
createInput(`${wrap(exactly(str))}?`) as IfUnwrapped<
5151
GetValue<New>,
52-
Input<`${GetValue<New>}?`, GetGroup<New>>,
53-
Input<`(${GetValue<New>})?`, GetGroup<New>>
52+
Input<`(?:${GetValue<New>})?`, GetGroup<New>>,
53+
Input<`${GetValue<New>}?`, GetGroup<New>>
5454
>
5555

5656
/** This escapes a string input to match it exactly */
5757
export const exactly = <New extends InputSource<string>>(
5858
input: New
5959
): Input<GetValue<New>, GetGroup<New>> =>
60-
typeof input === 'string'
61-
? (createInput(input.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')) as any)
62-
: input
60+
typeof input === 'string' ? (createInput(input.replace(ESCAPE_REPLACE_RE, '\\$&')) as any) : input
6361

62+
/** Equivalent to `+` - this marks the input as repeatable, any number of times but at least once */
6463
export const oneOrMore = <New extends InputSource<string>>(str: New) =>
65-
createInput(`${wrap(exactly(str))}+`) as Wrap<
64+
createInput(`${wrap(exactly(str))}+`) as IfUnwrapped<
6665
GetValue<New>,
67-
Input<`${GetValue<New>}+`, GetGroup<New>>,
68-
Input<`(${GetValue<New>})+`, GetGroup<New>>
66+
Input<`(?:${GetValue<New>})+`, GetGroup<New>>,
67+
Input<`${GetValue<New>}+`, GetGroup<New>>
6968
>

src/core/internal.ts

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { exactly } from './inputs'
22
import type { GetValue } from './types/escape'
33
import type { InputSource } from './types/sources'
4-
import { Wrap, wrap } from './wrap'
4+
import { IfUnwrapped, wrap } from './wrap'
5+
6+
const GROUPED_AS_REPLACE_RE = /^(?:\(\?:(.+)\)|(\(?.+\)?))$/
7+
const GROUPED_REPLACE_RE = /^(?:\(\??:?(.+)\)([?+*]|{[\d,]+})?|(.+))$/
58

69
export interface Input<V extends string, G extends string = never> {
710
and: {
@@ -17,7 +20,7 @@ export interface Input<V extends string, G extends string = never> {
1720
or: <I extends InputSource<string, any>>(
1821
input: I
1922
) => Input<
20-
`(${V}|${GetValue<I>})`,
23+
`(?:${V}|${GetValue<I>})`,
2124
G | (I extends Input<any, infer NewGroups> ? NewGroups : never)
2225
>
2326
/** this is a positive lookbehind. Make sure to check [browser support](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/RegExp#browser_compatibility) as not all browsers support lookbehinds (notably Safari) */
@@ -30,40 +33,55 @@ export interface Input<V extends string, G extends string = never> {
3033
notBefore: <I extends InputSource<string>>(input: I) => Input<`${V}(?!${GetValue<I>})`, G>
3134
times: {
3235
/** repeat the previous pattern an exact number of times */
33-
<N extends number>(number: N): Wrap<V, Input<`${V}{${N}}`, G>, Input<`(${V}){${N}}`, G>>
36+
<N extends number>(number: N): IfUnwrapped<
37+
V,
38+
Input<`(?:${V}){${N}}`, G>,
39+
Input<`${V}{${N}}`, G>
40+
>
3441
/** specify that the expression can repeat any number of times, _including none_ */
35-
any: () => Wrap<V, Input<`${V}*`, G>, Input<`(${V})*`, G>>
42+
any: () => IfUnwrapped<V, Input<`(?:${V})*`, G>, Input<`${V}*`, G>>
3643
/** specify that the expression must occur at least x times */
3744
atLeast: <N extends number>(
3845
number: N
39-
) => Wrap<V, Input<`${V}{${N},}`, G>, Input<`(${V}){${N},}`, G>>
46+
) => IfUnwrapped<V, Input<`(?:${V}){${N},}`, G>, Input<`${V}{${N},}`, G>>
4047
/** specify a range of times to repeat the previous pattern */
4148
between: <Min extends number, Max extends number>(
4249
min: Min,
4350
max: Max
44-
) => Wrap<V, Input<`${V}{${Min},${Max}}`, G>, Input<`(${V}){${Min},${Max}}`, G>>
51+
) => IfUnwrapped<V, Input<`(?:${V}){${Min},${Max}}`, G>, Input<`${V}{${Min},${Max}}`, G>>
4552
}
53+
/** this defines the entire input so far as a named capture group. You will get type safety when using the resulting RegExp with `String.match()`. Alias for `groupedAs` */
54+
as: <K extends string>(
55+
key: K
56+
) => Input<`(?<${K}>${V extends `(?:${infer S extends string})` ? S : V})`, G | K>
4657
/** this defines the entire input so far as a named capture group. You will get type safety when using the resulting RegExp with `String.match()` */
47-
as: <K extends string>(key: K) => Input<`(?<${K}>${V})`, G | K>
58+
groupedAs: <K extends string>(
59+
key: K
60+
) => Input<`(?<${K}>${V extends `(?:${infer S extends string})` ? S : V})`, G | K>
61+
/** this capture the entire input so far as an anonymous group */
62+
grouped: () => Input<V extends `(?:${infer S})${infer E}` ? `(${S})${E}` : `(${V})`, G>
4863
/** this allows you to match beginning/ends of lines with `at.lineStart()` and `at.lineEnd()` */
4964
at: {
5065
lineStart: () => Input<`^${V}`, G>
5166
lineEnd: () => Input<`${V}$`, G>
5267
}
5368
/** this allows you to mark the input so far as optional */
54-
optionally: () => Wrap<V, Input<`${V}?`, G>, Input<`(${V})?`, G>>
69+
optionally: () => IfUnwrapped<V, Input<`(?:${V})?`, G>, Input<`${V}?`, G>>
5570
toString: () => string
5671
}
5772

5873
export const createInput = <Value extends string, Groups extends string = never>(
5974
s: Value | Input<Value, Groups>
6075
): Input<Value, Groups> => {
76+
const groupedAsFn = (key: string) =>
77+
createInput(`(?<${key}>${`${s}`.replace(GROUPED_AS_REPLACE_RE, '$1$2')})`)
78+
6179
return {
6280
toString: () => s.toString(),
6381
and: Object.assign((input: InputSource<string, any>) => createInput(`${s}${exactly(input)}`), {
6482
referenceTo: (groupName: string) => createInput(`${s}\\k<${groupName}>`),
6583
}),
66-
or: input => createInput(`(${s}|${exactly(input)})`),
84+
or: input => createInput(`(?:${s}|${exactly(input)})`),
6785
after: input => createInput(`(?<=${exactly(input)})${s}`),
6886
before: input => createInput(`${s}(?=${exactly(input)})`),
6987
notAfter: input => createInput(`(?<!${exactly(input)})${s}`),
@@ -74,7 +92,9 @@ export const createInput = <Value extends string, Groups extends string = never>
7492
between: (min: number, max: number) => createInput(`${wrap(s)}{${min},${max}}`) as any,
7593
}),
7694
optionally: () => createInput(`${wrap(s)}?`) as any,
77-
as: key => createInput(`(?<${key}>${s})`),
95+
as: groupedAsFn,
96+
groupedAs: groupedAsFn,
97+
grouped: () => createInput(`${s}`.replace(GROUPED_REPLACE_RE, '($1$3)$2')),
7898
at: {
7999
lineStart: () => createInput(`^${s}`),
80100
lineEnd: () => createInput(`${s}$`),

src/core/wrap.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
import { Input } from './internal'
22
import { StripEscapes } from './types/escape'
33

4-
export type Wrap<T extends string, Yes, No> = T extends `(${string})`
5-
? Yes
6-
: StripEscapes<T> extends `${infer A}${infer B}`
4+
export type IfUnwrapped<
5+
Value extends string,
6+
Yes extends Input<string>,
7+
No extends Input<string>
8+
> = Value extends `(${string})`
9+
? No
10+
: StripEscapes<Value> extends `${infer A}${infer B}`
711
? A extends ''
8-
? Yes
12+
? No
913
: B extends ''
10-
? Yes
11-
: No
14+
? No
15+
: Yes
1216
: never
1317

14-
const NEEDS_WRAP_RE = /^(\(.*\)|\\?.)$/
18+
const NO_WRAP_RE = /^(\(.*\)|\\?.)$/
1519

1620
export const wrap = (s: string | Input<any>) => {
1721
const v = s.toString()
18-
return NEEDS_WRAP_RE.test(v) ? v : `(${v})`
22+
return NO_WRAP_RE.test(v) ? v : `(?:${v})`
1923
}

test/augments.test.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,27 +5,27 @@ import { createRegExp, global, MagicRegExpMatchArray, MagicRegExp, char } from '
55

66
describe('String', () => {
77
it('.match non-global', () => {
8-
const result = 'test'.match(createRegExp(char.as('foo')))
8+
const result = 'test'.match(createRegExp(char.groupedAs('foo')))
99
expect(Array.isArray(result)).toBeTruthy()
1010
expect(result?.groups?.foo).toEqual('t')
1111
expectTypeOf(result).toEqualTypeOf<MagicRegExpMatchArray<
1212
MagicRegExp<'/(?<foo>.)/', 'foo', never>
1313
> | null>()
1414
})
1515
it('.match global', () => {
16-
const result = 'test'.match(createRegExp(char.as('foo'), [global]))
16+
const result = 'test'.match(createRegExp(char.groupedAs('foo'), [global]))
1717
expect(Array.isArray(result)).toBeTruthy()
1818
// @ts-expect-error
1919
expect(result?.groups).toBeUndefined()
2020
expectTypeOf(result).toEqualTypeOf<null | string[]>()
2121
})
2222
it.todo('.matchAll non-global', () => {
2323
// should be deprecated
24-
expectTypeOf('test'.matchAll(createRegExp(char.as('foo')))).toEqualTypeOf<never>()
25-
expectTypeOf('test'.matchAll(createRegExp(char.as('foo'), ['m']))).toEqualTypeOf<never>()
24+
expectTypeOf('test'.matchAll(createRegExp(char.groupedAs('foo')))).toEqualTypeOf<never>()
25+
expectTypeOf('test'.matchAll(createRegExp(char.groupedAs('foo'), ['m']))).toEqualTypeOf<never>()
2626
})
2727
it('.matchAll global', () => {
28-
const results = 'test'.matchAll(createRegExp(char.as('foo'), [global]))
28+
const results = 'test'.matchAll(createRegExp(char.groupedAs('foo'), [global]))
2929
let count = 0
3030
for (const result of results) {
3131
count++
@@ -38,7 +38,9 @@ describe('String', () => {
3838
})
3939
it.todo('.replaceAll non-global', () => {
4040
// should be deprecated
41-
expectTypeOf('test'.replaceAll(createRegExp(char.as('foo')), '')).toEqualTypeOf<never>()
42-
expectTypeOf('test'.replaceAll(createRegExp(char.as('foo'), ['m']), '')).toEqualTypeOf<never>()
41+
expectTypeOf('test'.replaceAll(createRegExp(char.groupedAs('foo')), '')).toEqualTypeOf<never>()
42+
expectTypeOf(
43+
'test'.replaceAll(createRegExp(char.groupedAs('foo'), ['m']), '')
44+
).toEqualTypeOf<never>()
4345
})
4446
})

test/index.test.ts

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,13 @@ describe('inputs', () => {
3333
expect(`${createInput('\\s')}`).toEqual('\\s')
3434
})
3535
it('type infer group names when nesting createInput', () => {
36-
expectTypeOf(createRegExp(createInput(exactly('\\s').as('groupName')))).toEqualTypeOf<
36+
expectTypeOf(createRegExp(createInput(exactly('\\s').groupedAs('groupName')))).toEqualTypeOf<
3737
MagicRegExp<'/(?<groupName>\\s)/', 'groupName', never>
3838
>()
3939
})
4040
it('any', () => {
4141
const regExp = createRegExp(anyOf('foo', 'bar'))
42-
expect(regExp).toMatchInlineSnapshot('/\\(foo\\|bar\\)/')
42+
expect(regExp).toMatchInlineSnapshot('/\\(\\?:foo\\|bar\\)/')
4343
expect(regExp.test('foo')).toBeTruthy()
4444
expect(regExp.test('bar')).toBeTruthy()
4545
expect(regExp.test('baz')).toBeFalsy()
@@ -73,30 +73,32 @@ describe('inputs', () => {
7373
expect(createRegExp(pattern).test('test/thing')).toBeTruthy()
7474
})
7575
it('times', () => {
76-
expect(exactly('test').times.between(1, 3).toString()).toMatchInlineSnapshot('"(test){1,3}"')
77-
expect(exactly('test').times(4).or('foo').toString()).toMatchInlineSnapshot('"((test){4}|foo)"')
76+
expect(exactly('test').times.between(1, 3).toString()).toMatchInlineSnapshot('"(?:test){1,3}"')
77+
expect(exactly('test').times(4).or('foo').toString()).toMatchInlineSnapshot(
78+
'"(?:(?:test){4}|foo)"'
79+
)
7880
})
7981
it('capture groups', () => {
80-
const pattern = anyOf(anyOf('foo', 'bar').as('test'), exactly('baz').as('test2'))
82+
const pattern = anyOf(anyOf('foo', 'bar').groupedAs('test'), exactly('baz').groupedAs('test2'))
83+
const regexp = createRegExp(pattern)
8184

82-
expect('football'.match(createRegExp(pattern))?.groups).toMatchInlineSnapshot(`
85+
expect('football'.match(regexp)?.groups).toMatchInlineSnapshot(`
8386
{
8487
"test": "foo",
8588
"test2": undefined,
8689
}
8790
`)
88-
expect('fobazzer'.match(createRegExp(pattern))?.groups).toMatchInlineSnapshot(`
91+
expect('fobazzer'.match(regexp)?.groups).toMatchInlineSnapshot(`
8992
{
9093
"test": undefined,
9194
"test2": "baz",
9295
}
9396
`)
9497

95-
const regexp = createRegExp(pattern)
9698
expectTypeOf('fobazzer'.match(regexp)).toEqualTypeOf<MagicRegExpMatchArray<
9799
typeof regexp
98100
> | null>()
99-
expectTypeOf('fobazzer'.match(createRegExp(pattern))?.groups).toEqualTypeOf<
101+
expectTypeOf('fobazzer'.match(regexp)?.groups).toEqualTypeOf<
100102
Record<'test' | 'test2', string | undefined> | undefined
101103
>()
102104

@@ -115,16 +117,16 @@ describe('inputs', () => {
115117

116118
''.match(
117119
createRegExp(
118-
anyOf(anyOf('foo', 'bar').as('test'), exactly('baz').as('test2')).and(
119-
digit.times(5).as('id').optionally()
120+
anyOf(anyOf('foo', 'bar').groupedAs('test'), exactly('baz').groupedAs('test2')).and(
121+
digit.times(5).groupedAs('id').optionally()
120122
)
121123
)
122124
)?.groups?.id
123125
})
124126
it('named backreference to capture groups', () => {
125127
const pattern = exactly('foo')
126-
.as('fooGroup')
127-
.and(exactly('bar').as('barGroup'))
128+
.groupedAs('fooGroup')
129+
.and(exactly('bar').groupedAs('barGroup'))
128130
.and('baz')
129131
.and.referenceTo('barGroup')
130132
.and.referenceTo('fooGroup')

0 commit comments

Comments
 (0)