Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: experimental type-level RegExp match #288

Merged
merged 24 commits into from
May 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
83590ed
feat: add type-level-regexp
didavid61202 Apr 14, 2023
2927775
add tests
didavid61202 Apr 14, 2023
1ea705d
add subpath export
didavid61202 Apr 14, 2023
218f622
test: update test to 100% coverage
didavid61202 Apr 14, 2023
c74717d
text: update test
didavid61202 Apr 14, 2023
2792070
test: add test for `<dynamic-string>.match`
didavid61202 Apr 14, 2023
3efb3d2
update docs
didavid61202 Apr 14, 2023
31362c9
update docs
didavid61202 Apr 14, 2023
2c7962a
update pnpm-lock
didavid61202 Apr 14, 2023
0ba3d52
chore: update pnpm-lock
didavid61202 Apr 14, 2023
4cd8d9b
update `createRegExp` to variadic function
didavid61202 Apr 14, 2023
87922c5
fix typo in docs
didavid61202 Apr 14, 2023
5a5e756
update playground example
didavid61202 Apr 14, 2023
600e4a5
chore: bump type-level-regexp version
didavid61202 Apr 15, 2023
31ec1a0
Merge branch 'main' into feat/experimental-type-level-regexp
danielroe Apr 15, 2023
a2eb1eb
chore: bump `type-level-regexp` version
didavid61202 Apr 15, 2023
fd6cba7
rename subpath export and test file
didavid61202 Apr 15, 2023
b20f694
Merge branch 'main' into feat/experimental-type-level-regexp
danielroe Apr 15, 2023
a272794
update transform logic and test
didavid61202 Apr 15, 2023
69743b5
chore: bump `type-level-regexp` version to `0.1.12`
didavid61202 Apr 18, 2023
649dbad
chore: bump `type-level-regexp` version to 0.1.13
didavid61202 Apr 18, 2023
2588bdd
chore: bump `type-level-regexp` version to 0.1.14
didavid61202 Apr 22, 2023
0f1359a
Merge branch 'main' into feat/experimental-type-level-regexp
danielroe May 5, 2023
446ce19
chore: update type-level-regexp
danielroe May 5, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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