Skip to content

Commit 1a2d5d4

Browse files
authored
feat: type-safe String.match (#68)
1 parent 4afe083 commit 1a2d5d4

File tree

8 files changed

+175
-56
lines changed

8 files changed

+175
-56
lines changed

src/core/inputs.ts

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
import { createInput, Input } from './internal'
22
import type { GetValue, EscapeChar } from './types/escape'
33
import type { Join } from './types/join'
4-
import type { MapToGroups, MapToValues, InputSource, GetGroup } from './types/sources'
4+
import type {
5+
MapToGroups,
6+
MapToValues,
7+
InputSource,
8+
GetGroup,
9+
MapToCapturedGroupsArr,
10+
GetCapturedGroupsArr,
11+
} from './types/sources'
512
import { IfUnwrapped, wrap } from './wrap'
613

714
export type { Input }
@@ -20,7 +27,8 @@ export const charNotIn = <T extends string>(chars: T) =>
2027
export const anyOf = <New extends InputSource<string, string>[]>(...args: New) =>
2128
createInput(`(?:${args.map(a => exactly(a)).join('|')})`) as Input<
2229
`(?:${Join<MapToValues<New>>})`,
23-
MapToGroups<New>
30+
MapToGroups<New>,
31+
MapToCapturedGroupsArr<New>
2432
>
2533

2634
export const char = createInput('.')
@@ -49,20 +57,20 @@ export const not = {
4957
export const maybe = <New extends InputSource<string>>(str: New) =>
5058
createInput(`${wrap(exactly(str))}?`) as IfUnwrapped<
5159
GetValue<New>,
52-
Input<`(?:${GetValue<New>})?`, GetGroup<New>>,
53-
Input<`${GetValue<New>}?`, GetGroup<New>>
60+
Input<`(?:${GetValue<New>})?`, GetGroup<New>, GetCapturedGroupsArr<New>>,
61+
Input<`${GetValue<New>}?`, GetGroup<New>, GetCapturedGroupsArr<New>>
5462
>
5563

5664
/** This escapes a string input to match it exactly */
5765
export const exactly = <New extends InputSource<string>>(
5866
input: New
59-
): Input<GetValue<New>, GetGroup<New>> =>
67+
): Input<GetValue<New>, GetGroup<New>, GetCapturedGroupsArr<New>> =>
6068
typeof input === 'string' ? (createInput(input.replace(ESCAPE_REPLACE_RE, '\\$&')) as any) : input
6169

6270
/** Equivalent to `+` - this marks the input as repeatable, any number of times but at least once */
6371
export const oneOrMore = <New extends InputSource<string>>(str: New) =>
6472
createInput(`${wrap(exactly(str))}+`) as IfUnwrapped<
6573
GetValue<New>,
66-
Input<`(?:${GetValue<New>})+`, GetGroup<New>>,
67-
Input<`${GetValue<New>}+`, GetGroup<New>>
74+
Input<`(?:${GetValue<New>})+`, GetGroup<New>, GetCapturedGroupsArr<New>>,
75+
Input<`${GetValue<New>}+`, GetGroup<New>, GetCapturedGroupsArr<New>>
6876
>

src/core/internal.ts

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,108 @@
11
import { exactly } from './inputs'
22
import type { GetValue } from './types/escape'
3-
import type { InputSource } from './types/sources'
3+
import type { GetCapturedGroupsArr, InputSource } from './types/sources'
44
import { IfUnwrapped, wrap } from './wrap'
55

66
const GROUPED_AS_REPLACE_RE = /^(?:\(\?:(.+)\)|(\(?.+\)?))$/
7-
const GROUPED_REPLACE_RE = /^(?:\(\??:?(.+)\)([?+*]|{[\d,]+})?|(.+))$/
7+
const GROUPED_REPLACE_RE = /^(?:\(\?:(.+)\)([?+*]|{[\d,]+})?|(.+))$/
88

9-
export interface Input<V extends string, G extends string = never> {
9+
export interface Input<
10+
V extends string,
11+
G extends string = never,
12+
C extends (string | undefined)[] = []
13+
> {
1014
and: {
1115
/** this adds a new pattern to the current input */
1216
<I extends InputSource<string, any>>(input: I): Input<
1317
`${V}${GetValue<I>}`,
14-
G | (I extends Input<any, infer NewGroups> ? NewGroups : never)
18+
G | (I extends Input<any, infer NewGroups> ? NewGroups : never),
19+
[...C, ...GetCapturedGroupsArr<I>]
1520
>
1621
/** this adds a new pattern to the current input, with the pattern reference to a named group. */
17-
referenceTo: <N extends G>(groupName: N) => Input<`${V}\\k<${N}>`, G>
22+
referenceTo: <N extends G>(groupName: N) => Input<`${V}\\k<${N}>`, G, C>
1823
}
1924
/** this provides an alternative to the current input */
2025
or: <I extends InputSource<string, any>>(
2126
input: I
2227
) => Input<
2328
`(?:${V}|${GetValue<I>})`,
24-
G | (I extends Input<any, infer NewGroups> ? NewGroups : never)
29+
G | (I extends Input<any, infer NewGroups> ? NewGroups : never),
30+
[...C, ...GetCapturedGroupsArr<I>]
2531
>
2632
/** 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) */
27-
after: <I extends InputSource<string>>(input: I) => Input<`(?<=${GetValue<I>})${V}`, G>
33+
after: <I extends InputSource<string>>(
34+
input: I
35+
) => Input<`(?<=${GetValue<I>})${V}`, G, [...GetCapturedGroupsArr<I>, ...C]>
2836
/** this is a positive lookahead */
29-
before: <I extends InputSource<string>>(input: I) => Input<`${V}(?=${GetValue<I>})`, G>
37+
before: <I extends InputSource<string>>(
38+
input: I
39+
) => Input<`${V}(?=${GetValue<I>})`, G, [...C, ...GetCapturedGroupsArr<I>]>
3040
/** these is a negative 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) */
31-
notAfter: <I extends InputSource<string>>(input: I) => Input<`(?<!${GetValue<I>})${V}`, G>
41+
notAfter: <I extends InputSource<string>>(
42+
input: I
43+
) => Input<`(?<!${GetValue<I>})${V}`, G, [...GetCapturedGroupsArr<I, true>, ...C]>
3244
/** this is a negative lookahead */
33-
notBefore: <I extends InputSource<string>>(input: I) => Input<`${V}(?!${GetValue<I>})`, G>
45+
notBefore: <I extends InputSource<string>>(
46+
input: I
47+
) => Input<`${V}(?!${GetValue<I>})`, G, [...C, ...GetCapturedGroupsArr<I, true>]>
3448
times: {
3549
/** repeat the previous pattern an exact number of times */
3650
<N extends number>(number: N): IfUnwrapped<
3751
V,
38-
Input<`(?:${V}){${N}}`, G>,
39-
Input<`${V}{${N}}`, G>
52+
Input<`(?:${V}){${N}}`, G, C>,
53+
Input<`${V}{${N}}`, G, C>
4054
>
4155
/** specify that the expression can repeat any number of times, _including none_ */
42-
any: () => IfUnwrapped<V, Input<`(?:${V})*`, G>, Input<`${V}*`, G>>
56+
any: () => IfUnwrapped<V, Input<`(?:${V})*`, G>, Input<`${V}*`, G, C>>
4357
/** specify that the expression must occur at least x times */
4458
atLeast: <N extends number>(
4559
number: N
46-
) => IfUnwrapped<V, Input<`(?:${V}){${N},}`, G>, Input<`${V}{${N},}`, G>>
60+
) => IfUnwrapped<V, Input<`(?:${V}){${N},}`, G>, Input<`${V}{${N},}`, G, C>>
4761
/** specify a range of times to repeat the previous pattern */
4862
between: <Min extends number, Max extends number>(
4963
min: Min,
5064
max: Max
51-
) => IfUnwrapped<V, Input<`(?:${V}){${Min},${Max}}`, G>, Input<`${V}{${Min},${Max}}`, G>>
65+
) => IfUnwrapped<V, Input<`(?:${V}){${Min},${Max}}`, G, C>, Input<`${V}{${Min},${Max}}`, G, C>>
5266
}
5367
/** 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` */
5468
as: <K extends string>(
5569
key: K
56-
) => Input<`(?<${K}>${V extends `(?:${infer S extends string})` ? S : V})`, G | K>
70+
) => Input<
71+
`(?<${K}>${V extends `(?:${infer S})` ? S : V})`,
72+
G | K,
73+
[`(?<${K}>${V extends `(?:${infer S})` ? S : V})`, ...C]
74+
>
5775
/** 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()` */
5876
groupedAs: <K extends string>(
5977
key: K
60-
) => Input<`(?<${K}>${V extends `(?:${infer S extends string})` ? S : V})`, G | K>
78+
) => Input<
79+
`(?<${K}>${V extends `(?:${infer S})` ? S : V})`,
80+
G | K,
81+
[`(?<${K}>${V extends `(?:${infer S})` ? S : V})`, ...C]
82+
>
6183
/** this capture the entire input so far as an anonymous group */
62-
grouped: () => Input<V extends `(?:${infer S})${infer E}` ? `(${S})${E}` : `(${V})`, G>
84+
grouped: () => Input<
85+
V extends `(?:${infer S})${infer E}` ? `(${S})${E}` : `(${V})`,
86+
G,
87+
[V extends `(?:${infer S})${'' | '?' | '+' | '*' | `{${string}}`}` ? `(${S})` : `(${V})`, ...C]
88+
>
6389
/** this allows you to match beginning/ends of lines with `at.lineStart()` and `at.lineEnd()` */
6490
at: {
65-
lineStart: () => Input<`^${V}`, G>
66-
lineEnd: () => Input<`${V}$`, G>
91+
lineStart: () => Input<`^${V}`, G, C>
92+
lineEnd: () => Input<`${V}$`, G, C>
6793
}
6894
/** this allows you to mark the input so far as optional */
69-
optionally: () => IfUnwrapped<V, Input<`(?:${V})?`, G>, Input<`${V}?`, G>>
95+
optionally: () => IfUnwrapped<V, Input<`(?:${V})?`, G, C>, Input<`${V}?`, G, C>>
7096
toString: () => string
7197
}
7298

73-
export const createInput = <Value extends string, Groups extends string = never>(
74-
s: Value | Input<Value, Groups>
75-
): Input<Value, Groups> => {
99+
export const createInput = <
100+
Value extends string,
101+
Groups extends string = never,
102+
CaptureGroupsArr extends (string | undefined)[] = []
103+
>(
104+
s: Value | Input<Value, Groups, CaptureGroupsArr>
105+
): Input<Value, Groups, CaptureGroupsArr> => {
76106
const groupedAsFn = (key: string) =>
77107
createInput(`(?<${key}>${`${s}`.replace(GROUPED_AS_REPLACE_RE, '$1$2')})`)
78108

src/core/types/magic-regexp.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,40 @@
11
const NamedGroupsS = Symbol('NamedGroups')
22
const ValueS = Symbol('Value')
3+
const CapturedGroupsArrS = Symbol('CapturedGroupsArr')
34
const FlagsS = Symbol('Flags')
45

56
export type MagicRegExp<
67
Value extends string,
78
NamedGroups extends string | never = never,
9+
CapturedGroupsArr extends (string | undefined)[] = [],
810
Flags extends string | never = never
911
> = RegExp & {
1012
[NamedGroupsS]: NamedGroups
13+
[CapturedGroupsArrS]: CapturedGroupsArr
1114
[ValueS]: Value
1215
[FlagsS]: Flags
1316
}
1417

15-
type ExtractGroups<T extends MagicRegExp<string, string, string>> = T extends MagicRegExp<
16-
string,
17-
infer V,
18-
string
19-
>
20-
? V
21-
: never
18+
type ExtractGroups<T extends MagicRegExp<string, string, (string | undefined)[], string>> =
19+
T extends MagicRegExp<string, infer V, (string | undefined)[], string> ? V : never
2220

23-
export type MagicRegExpMatchArray<T extends MagicRegExp<string, string, string>> = Omit<
21+
type StringWithHint<S extends string> = string & {
22+
_capturedBy: S
23+
}
24+
25+
export type StringCapturedBy<S extends string> = StringWithHint<S>
26+
27+
export type MapToStringCapturedBy<Ar extends (string | undefined)[]> = {
28+
[K in keyof Ar]: Ar[K] extends string ? StringCapturedBy<Ar[K]> | undefined : undefined
29+
}
30+
31+
export type MagicRegExpMatchArray<T extends MagicRegExp<string, string, any[], string>> = Omit<
2432
RegExpMatchArray,
2533
'groups'
2634
> & {
2735
groups: Record<ExtractGroups<T>, string | undefined>
28-
}
36+
} & {
37+
[index: number | string | symbol]: never
38+
} & (T extends MagicRegExp<string, string, infer CapturedGroupsArr, string>
39+
? readonly [string | undefined, ...MapToStringCapturedBy<CapturedGroupsArr>]
40+
: {})

src/core/types/sources.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ export type InputSource<S extends string = never, T extends string = never> = S
55
export type GetGroup<T extends InputSource<string>> = T extends Input<string, infer Group>
66
? Group
77
: never
8+
export type GetCapturedGroupsArr<
9+
T extends InputSource<string>,
10+
MapToUndefined extends boolean = false
11+
> = T extends Input<string, any, infer CapturedGroupArr>
12+
? MapToUndefined extends true
13+
? { [K in keyof CapturedGroupArr]: undefined }
14+
: CapturedGroupArr
15+
: []
816
export type MapToValues<T extends InputSource<any, any>[]> = T extends [infer First, ...infer Rest]
917
? First extends InputSource<string>
1018
? [GetValue<First>, ...MapToValues<Rest>]
@@ -19,3 +27,13 @@ export type MapToGroups<T extends InputSource<any, string>[]> = T extends [
1927
? K | MapToGroups<Rest>
2028
: MapToGroups<Rest>
2129
: never
30+
31+
type Flatten<T extends any[]> = T extends [infer L, ...infer R]
32+
? L extends any[]
33+
? [...Flatten<L>, ...Flatten<R>]
34+
: [L, ...Flatten<R>]
35+
: []
36+
37+
export type MapToCapturedGroupsArr<T extends InputSource<any, string>[]> = Flatten<{
38+
[K in keyof T]: T[K] extends Input<any, any, infer C> ? C : string[]
39+
}>

src/index.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,16 @@ import type { MagicRegExp, MagicRegExpMatchArray } from './core/types/magic-rege
66
export const createRegExp = <
77
Value extends string,
88
NamedGroups extends string = never,
9+
CapturedGroupsArr extends (string | undefined)[] = [],
910
Flags extends Flag[] = never[]
1011
>(
11-
raw: Input<Value, NamedGroups> | Value,
12+
raw: Input<Value, NamedGroups, CapturedGroupsArr> | Value,
1213
flags?: [...Flags] | string | Set<Flag>
1314
) =>
1415
new RegExp(exactly(raw).toString(), [...(flags || '')].join('')) as MagicRegExp<
1516
`/${Value}/${Join<Flags, '', ''>}`,
1617
NamedGroups,
18+
CapturedGroupsArr,
1719
Flags[number]
1820
>
1921

@@ -24,27 +26,31 @@ export * from './core/types/magic-regexp'
2426
// Add additional overload to global String object types to allow for typed capturing groups
2527
declare global {
2628
interface String {
27-
match<R extends MagicRegExp<string, string, Exclude<Flag, 'g'>>>(
29+
match<R extends MagicRegExp<string, string, (string | undefined)[], Exclude<Flag, 'g'>>>(
2830
regexp: R
2931
): MagicRegExpMatchArray<R> | null
30-
match<R extends MagicRegExp<string, string, 'g'>>(regexp: R): string[] | null
32+
match<R extends MagicRegExp<string, string, (string | undefined)[], 'g'>>(
33+
regexp: R
34+
): string[] | null
3135

3236
/** @deprecated String.matchAll requires global flag to be set. */
33-
matchAll<R extends MagicRegExp<string, string, never>>(regexp: R): never
37+
matchAll<R extends MagicRegExp<string, string, (string | undefined)[], never>>(regexp: R): never
3438
/** @deprecated String.matchAll requires global flag to be set. */
35-
matchAll<R extends MagicRegExp<string, string, Exclude<Flag, 'g'>>>(regexp: R): never
39+
matchAll<R extends MagicRegExp<string, string, (string | undefined)[], Exclude<Flag, 'g'>>>(
40+
regexp: R
41+
): never
3642

37-
matchAll<R extends MagicRegExp<string, string, string>>(
43+
matchAll<R extends MagicRegExp<string, string, (string | undefined)[], string>>(
3844
regexp: R
3945
): IterableIterator<MagicRegExpMatchArray<R>>
4046

4147
/** @deprecated String.replaceAll requires global flag to be set. */
42-
replaceAll<R extends MagicRegExp<string, string, never>>(
48+
replaceAll<R extends MagicRegExp<string, string, (string | undefined)[], never>>(
4349
searchValue: R,
4450
replaceValue: string | ((substring: string, ...args: any[]) => string)
4551
): never
4652
/** @deprecated String.replaceAll requires global flag to be set. */
47-
replaceAll<R extends MagicRegExp<string, string, Exclude<Flag, 'g'>>>(
53+
replaceAll<R extends MagicRegExp<string, string, (string | undefined)[], Exclude<Flag, 'g'>>>(
4854
searchValue: R,
4955
replaceValue: string | ((substring: string, ...args: any[]) => string)
5056
): never

test/augments.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ describe('String', () => {
99
expect(Array.isArray(result)).toBeTruthy()
1010
expect(result?.groups?.foo).toEqual('t')
1111
expectTypeOf(result).toEqualTypeOf<MagicRegExpMatchArray<
12-
MagicRegExp<'/(?<foo>.)/', 'foo', never>
12+
MagicRegExp<'/(?<foo>.)/', 'foo', ['(?<foo>.)'], never>
1313
> | null>()
1414
})
1515
it('.match global', () => {
@@ -31,7 +31,7 @@ describe('String', () => {
3131
count++
3232
expect([...'test'].includes(result?.groups.foo || '')).toBeTruthy()
3333
expectTypeOf(result).toEqualTypeOf<
34-
MagicRegExpMatchArray<MagicRegExp<'/(?<foo>.)/g', 'foo', 'g'>>
34+
MagicRegExpMatchArray<MagicRegExp<'/(?<foo>.)/g', 'foo', ['(?<foo>.)'], 'g'>>
3535
>()
3636
}
3737
expect(count).toBe(4)

0 commit comments

Comments
 (0)