Skip to content

Commit

Permalink
feat: experimental type-level RegExp match (#288)
Browse files Browse the repository at this point in the history
  • Loading branch information
didavid61202 authored May 5, 2023
1 parent 0ff5bcf commit 1bf8bb4
Show file tree
Hide file tree
Showing 13 changed files with 507 additions and 30 deletions.
4 changes: 2 additions & 2 deletions build.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ import { defineBuildConfig } from 'unbuild'
export default defineBuildConfig({
declaration: true,
rollup: { emitCJS: true },
entries: ['./src/index', './src/transform'],
externals: ['magic-regexp'],
entries: ['./src/index', './src/transform', './src/further-magic'],
externals: ['magic-regexp', 'type-level-regexp'],
})
27 changes: 26 additions & 1 deletion docs/content/2.getting-started/2.usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ There are a range of helpers that can be used to activate pattern matching, and
| `exactly` | This takes a variable number of inputs and concatenate their patterns, and escapes string inputs to match it exactly. |

::alert
All helpers that takes `string` and `Input` are variadic functions, so you can pass in one or multiple arguments of `string` or `Input` to them and they will be concatenated to one pattern. for example,s `exactly('foo', maybe('bar'))` is equivalent to `exactly('foo').and(maybe('bar'))`.
All helpers that takes `string` and `Input` are variadic functions, so you can pass in one or multiple arguments of `string` or `Input` to them and they will be concatenated to one pattern. for example, `exactly('foo', maybe('bar'))` is equivalent to `exactly('foo').and(maybe('bar'))`.
::

## Chaining inputs
Expand Down Expand Up @@ -100,3 +100,28 @@ exactly('test.mjs').or('something.else')
Each function, if you hover over it, shows what's going in, and what's coming out by way of regular expression

You can also call `.toString()` on any input to see the same information at runtime.

## Type-Level match result (experimental)
We also provide an experimental feature that allows you to obtain the type-level results of a RegExp match or replace in string literals. To try this feature, please import all helpers from a subpath export `magic-regexp/further-magic` instead of `magic-regexp`.

```ts
import { createRegExp, exactly, digit } from 'magic-regexp/further-magic'
```

This feature is especially useful when you want to obtain the type of the matched groups or test if your RegExp matches and captures from a given string as expected.

This feature works best for matching literal strings such as
```ts
'foo'.match(createRegExp(exactly('foo').groupedAs('g1')))
```
which will return a matched result of type `['foo', 'foo']`. `result.groups` of type `{ g1: 'foo' }`, `result.index` of type `0` and `result.length` of type `2`.

If matching with dynamic string, such as
```ts
myString.match(createRegExp(exactly('foo').or('bar').groupedAs('g1')))
```
the type of the matched result will be `null`, or array of union of possible matches `["bar", "bar"] | ["foo", "foo"]` and `result.groups` will be type `{ g1: "bar" } | { g1: "foo" }`.

::alert
For more usage details please see the [usage examples](3.examples.md#type-level-regexp-match-and-replace-result-experimental) or [test](https://github.com/danielroe/magic-regexp/blob/main/test/further-magic.test.ts). For type-related issues, please report them to [type-level-regexp](https://github.com/didavid61202/type-level-regexp).
::
78 changes: 78 additions & 0 deletions docs/content/2.getting-started/3.examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,81 @@ const TENET_RE = createRegExp(

assert.equal(TENET_RE.test('TEN<==O==>NET'), true)
```

### Type-level RegExp match and replace result (experimental)

::alert
This feature is still experimental, to try it please import `createRegExp ` and all `Input` helpers from `magic-regexp/further-magic` instead of `magic-regexp`.
::

When matching or replacing with literal string such as `magic-regexp v3.2.5.beta.1 just release!`

```ts
import {
createRegExp,
oneOrMore,
exactly,
nyOf,
digit,
wordChar
} from 'magic-regexp/further-magic'

const literalString = 'magic-regexp 3.2.5.beta.1 just release!'

const semverRegExp = createRegExp(
oneOrMore(digit)
.as('major')
.and('.')
.and(oneOrMore(digit).as('minor'))
.and(
exactly('.')
.and(oneOrMore(anyOf(wordChar, '.')).groupedAs('patch'))
.optionally()
)
)

// `String.match()` example
const matchResult = literalString.match(semverRegExp)
matchResult[0] // "3.2.5.beta.1"
matchResult[3] // "5.beta.1"
matchResult.length // 4
matchResult.index // 14
matchResult.groups
// groups: {
// major: "3";
// minor: "2";
// patch: "5.beta.1";
// }

// `String.replace()` example
const replaceResult = literalString.replace(
semverRegExp,
`minor version "$2" brings many great DX improvements, while patch "$<patch>" fix some bugs and it's`
)

replaceResult // "magic-regexp minor version \"2\" brings many great DX improvements, while patch \"5.beta.1\" fix some bugs and it's just release!"
```

When matching dynamic string, the result will be union of possible matches

```ts
let myString = 'dynamic'

const RegExp = createRegExp(exactly('foo').or('bar').groupedAs('g1'))
const matchAllResult = myString.match(RegExp)

matchAllResult
// null | RegExpMatchResult<{
// matched: ["bar", "bar"] | ["foo", "foo"];
// namedCaptures: ["g1", "bar"] | ["g1", "foo"];
// input: string;
// restInput: undefined;
// }>
matchAllResult?.[0] // ['foo', 'foo'] | ['bar', 'bar']
matchAllResult?.length // 2 | undefined
matchAllResult?.groups
// groups: {
// g1: "foo" | "bar";
// } | undefined
```

3 changes: 3 additions & 0 deletions further-magic.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Legacy stub for previous TS versions

export * from './dist/further-magic'
9 changes: 8 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
"require": "./dist/transform.cjs",
"types": "./dist/transform.d.ts"
},
"./further-magic": {
"import": "./dist/further-magic.mjs",
"require": "./dist/further-magic.cjs",
"types": "./dist/further-magic.d.ts"
},
"./nuxt": "./nuxt.mjs"
},
"main": "./dist/index.cjs",
Expand All @@ -24,7 +29,8 @@
"files": [
"dist",
"nuxt.mjs",
"transform.d.ts"
"transform.d.ts",
"further-magic.d.ts"
],
"scripts": {
"build": "unbuild",
Expand All @@ -45,6 +51,7 @@
"estree-walker": "^3.0.1",
"magic-string": "^0.30.0",
"mlly": "^1.0.0",
"type-level-regexp": "~0.1.16",
"ufo": "^1.0.0",
"unplugin": "^1.0.0"
},
Expand Down
21 changes: 11 additions & 10 deletions playground/index.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import assert from 'node:assert'
import { createRegExp, exactly, digit, oneOrMore, char, wordChar } from 'magic-regexp'
import { createRegExp, exactly, maybe, digit, oneOrMore, char, wordChar } from 'magic-regexp'
/**
* change to
* import {...} from 'magic-regexp/further-magic'
* to try type level RegExp match results (experimental)
*/

// Typed capture groups
const ID_RE = createRegExp(exactly('id-').and(digit.times(5).groupedAs('id')))
Expand All @@ -8,22 +13,18 @@ console.log(ID_RE, groups?.id)

// Quick-and-dirty semver
const SEMVER_RE = createRegExp(
oneOrMore(digit)
.groupedAs('major')
.and('.')
.and(oneOrMore(digit).groupedAs('minor'))
.and(exactly('.').and(oneOrMore(char).groupedAs('patch')).optionally())
oneOrMore(digit).groupedAs('major'),
'.',
oneOrMore(digit).groupedAs('minor'),
maybe('.', oneOrMore(char).groupedAs('patch'))
)
console.log(SEMVER_RE)

assert.equal(createRegExp(exactly('foo/test.js').after('bar/')).test('bar/foo/test.js'), true)

// References to previously captured groups using the group name
const TENET_RE = createRegExp(
wordChar
.groupedAs('firstChar')
.and(wordChar.groupedAs('secondChar'))
.and(oneOrMore(char))
exactly(wordChar.groupedAs('firstChar'), wordChar.groupedAs('secondChar'), oneOrMore(char))
.and.referenceTo('secondChar')
.and.referenceTo('firstChar')
)
Expand Down
7 changes: 7 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

136 changes: 136 additions & 0 deletions src/further-magic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import type { Flag } from './core/flags'
import type { Join, UnionToTuple } from './core/types/join'
import type { InputSource, MapToGroups, MapToValues } from './core/types/sources'
import type {
MatchRegExp,
MatchAllRegExp,
ParseRegExp,
RegExpMatchResult,
ReplaceWithRegExp,
} from 'type-level-regexp/regexp'

import { exactly } from './core/inputs'

const NamedGroupsS = Symbol('NamedGroupsType')
const ValueS = Symbol('Value')
const FlagsS = Symbol('Flags')

export type MagicRegExp<
Value extends string,
NamedGroups extends string | never = never,
Flags extends Flag[] | never = never
> = RegExp & {
[NamedGroupsS]: NamedGroups
[ValueS]: Value
[FlagsS]: Flags
}

export const createRegExp: {
/** Create Magic RegExp from Input helpers and string (string will be sanitized) */
<Inputs extends InputSource[]>(...inputs: Inputs): MagicRegExp<
`/${Join<MapToValues<Inputs>, '', ''>}/`,
MapToGroups<Inputs>,
[]
>
<
Inputs extends InputSource[],
FlagUnion extends Flag | undefined = undefined,
CloneFlagUnion extends Flag | undefined = FlagUnion,
Flags extends Flag[] = CloneFlagUnion extends undefined
? []
: UnionToTuple<FlagUnion> extends infer F extends Flag[]
? F
: never
>(
...inputs: [...Inputs, [...Flags] | string | Set<FlagUnion>]
): MagicRegExp<
`/${Join<MapToValues<Inputs>, '', ''>}/${Join<Flags, '', ''>}`,
MapToGroups<Inputs>,
Flags
>
} = (...inputs: any[]) => {
const flags =
inputs.length > 1 &&
(Array.isArray(inputs[inputs.length - 1]) || inputs[inputs.length - 1] instanceof Set)
? inputs.pop()
: undefined
return new RegExp(exactly(...inputs).toString(), [...(flags || '')].join('')) as any
}

export * from './core/flags'
export * from './core/inputs'
export * from './core/types/magic-regexp'
export { spreadRegExpIterator, spreadRegExpMatchArray } from 'type-level-regexp/regexp'

// Add additional overload to global String object types to allow for typed capturing groups
declare global {
interface String {
match<InputString extends string, RegExpPattern extends string, Flags extends Flag[]>(
this: InputString,
regexp: MagicRegExp<`/${RegExpPattern}/${Join<Flags, '', ''>}`, string, Flags>
): MatchRegExp<
InputString,
ParseRegExp<RegExpPattern>,
Flag[] extends Flags ? never : Flags[number]
>

/** @deprecated String.matchAll requires global flag to be set. */
matchAll<R extends MagicRegExp<string, string, Exclude<Flag, 'g'>[]>>(regexp: R): never

matchAll<InputString extends string, RegExpPattern extends string, Flags extends Flag[]>(
this: InputString,
regexp: MagicRegExp<`/${RegExpPattern}/${Join<Flags, '', ''>}`, string, Flags>
): MatchAllRegExp<
InputString,
ParseRegExp<RegExpPattern>,
Flag[] extends Flags ? never : Flags[number]
>

/** @deprecated String.matchAll requires global flag to be set. */
matchAll<R extends MagicRegExp<string, string, never>>(regexp: R): never

replace<
InputString extends string,
RegExpPattern extends string,
Flags extends Flag[],
ReplaceValue extends string,
RegExpParsedAST extends any[] = string extends RegExpPattern
? never
: ParseRegExp<RegExpPattern>,
MatchResult = MatchRegExp<InputString, RegExpParsedAST, Flags[number]>,
Match extends any[] = MatchResult extends RegExpMatchResult<
{
matched: infer MatchArray extends any[]
namedCaptures: [string, any]
input: infer Input extends string
restInput: string | undefined
},
{
index: infer Index extends number
groups: infer Groups
input: string
keys: (...arg: any) => any
}
>
? [...MatchArray, Index, Input, Groups]
: never
>(
this: InputString,
regexp: MagicRegExp<`/${RegExpPattern}/${Join<Flags, '', ''>}`, string, Flags>,
replaceValue: ReplaceValue | ((...match: Match) => ReplaceValue)
): any[] extends RegExpParsedAST
? never
: ReplaceWithRegExp<InputString, RegExpParsedAST, ReplaceValue, Flags[number]>

/** @deprecated String.replaceAll requires global flag to be set. */
replaceAll<R extends MagicRegExp<string, string, never>>(
searchValue: R,
replaceValue: string | ((substring: string, ...args: any[]) => string)
): never
/** @deprecated String.replaceAll requires global flag to be set. */
replaceAll<R extends MagicRegExp<string, string, Exclude<Flag, 'g'>[]>>(
searchValue: R,
replaceValue: string | ((substring: string, ...args: any[]) => string)
): never
}
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { MagicRegExp, MagicRegExpMatchArray } from './core/types/magic-rege
import { InputSource, MapToCapturedGroupsArr, MapToGroups, MapToValues } from './core/types/sources'

export const createRegExp: {
/** Create Magic RegExp from Input helpers and strin (string will be sanitized) */
/** Create Magic RegExp from Input helpers and string (string will be sanitized) */
<Inputs extends InputSource[]>(...inputs: Inputs): MagicRegExp<
`/${Join<MapToValues<Inputs>, '', ''>}/`,
MapToGroups<Inputs>,
Expand Down
4 changes: 3 additions & 1 deletion src/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ export const MagicRegExpTransformPlugin = createUnplugin(() => {
transform(code, id) {
if (!code.includes('magic-regexp')) return

const statements = findStaticImports(code).filter(i => i.specifier === 'magic-regexp')
const statements = findStaticImports(code).filter(
i => i.specifier === 'magic-regexp' || i.specifier === 'magic-regexp/further-magic'
)
if (!statements.length) return

const contextMap: Context = { ...magicRegExp }
Expand Down
Loading

1 comment on commit 1bf8bb4

@vercel
Copy link

@vercel vercel bot commented on 1bf8bb4 May 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.