diff --git a/package-lock.json b/package-lock.json index 7efd28c..eaf970c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "jest": "^29.4.2", "prettier": "^2.8.4", "ts-jest": "^29.0.5", + "type-level-regexp": "^0.1.17", "tsup": "^6.7.0", "typescript": "^4.9.5" } @@ -4400,6 +4401,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-level-regexp": { + "version": "0.1.17", + "resolved": "https://registry.npmjs.org/type-level-regexp/-/type-level-regexp-0.1.17.tgz", + "integrity": "sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg==", + "dev": true + }, "node_modules/typescript": { "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", diff --git a/package.json b/package.json index a2dff45..f69aecd 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "module": "./dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsup src/index.ts --format esm,cjs && tsc src/index.ts --declaration --emitDeclarationOnly --outDir dist", + "build": "tsup src/index.ts --format esm,cjs && tsc --declaration --emitDeclarationOnly --outDir dist", "prepublishOnly": "npm run test && npm run build", "test": "jest", "clear-test": "jest --clearCache", @@ -33,6 +33,7 @@ "prettier": "^2.8.4", "ts-jest": "^29.0.5", "tsup": "^6.7.0", + "type-level-regexp": "^0.1.17", "typescript": "^4.9.5" } } diff --git a/src/internals/strings/Strings.ts b/src/internals/strings/Strings.ts index 32830de..816d3c7 100644 --- a/src/internals/strings/Strings.ts +++ b/src/internals/strings/Strings.ts @@ -13,6 +13,22 @@ export namespace Strings { | null | undefined; + /** + * Create a RegExp object with the given pattern and flags, use for `Strings.Replace`, `Strings.Match`, `Strings.MatchAll`. + * @param Pattern - The pattern of the RegExp. + * @param Flags - The flags of the RegExp. + * @returns A RegExp object. + * @example + * ```ts + * type T0 = Call[b-e]{1,2})F", "i">>, "12aBef34">; // ["aBef", "Be"] & { index: 2; groups: { g1: "Be" } } + * type T1 = Call[b-e]{1,2})f", "g" | "i">>, "12aBef34AeCf56">; // ["aBef", "AeCf"] + * ``` + */ + export type RegExp< + Pattern extends string, + Flags extends Impl.SupportedRegExpFlags = never + > = Impl.RegExpStruct; + /** * Get the length of a string. * @param args[0] - The string to get the length of. @@ -114,25 +130,76 @@ export namespace Strings { : never; } + /** + * Match a string against a regular expression `Strings.RegExp` (support `i` and `g` flags). + * @param args[0] - The string to match. + * @param RawRegExp - The regular expression `Strings.RegExp` to match. + * @returns The matched object with match array and `index` and `groups` properties. + * ```ts + * type T0 = Call[b-e]{1,2})F", "i">>, "12aBef34">; // ["aBef", "Be"] & { index: 2; groups: { g1: "Be" } } + * type T1 = Call[b-e]{1,2})f", "g" | "i">>, "12aBef34AeCf56">; // ["aBef", "AeCf"] + * ``` + */ + export type Match< + RE extends RegExp | unset | _ = unset, + Str = unset + > = PartialApply; + + interface MatchFn extends Fn { + return: this["args"] extends [ + infer RE extends RegExp, + infer Str, + ...any + ] + ? Call + : never; + } + + /** + * Match a string against a regular expression `Strings.RegExp`, return an array of match objects. + * @param args[0] - The string to match. + * @param RawRegExp - The regular expression `Strings.RegExp` to match, `g` flag is required (also support `i` flag). + * @returns Array of matched object, each with a match array and `index` and `groups` properties. + * ```ts + * type T0 = Call[b-e]{1,2})f", "g" | "i">>, "12aBef34AeCf56">; // [["aBef", "Be"] & { index: 2; groups: { g1: "Be"; }; }, ["AeCf", "eC"] & { index: 8; groups: { g1: "eC"; }; }] + * ``` + */ + export type MatchAll< + RE extends RegExp | unset | _ = unset, + Str = unset + > = RE extends RE ? PartialApply : never; + + interface MatchAllFn extends Fn { + return: this["args"] extends [ + infer RE extends RegExp, + infer Str, + ...any + ] + ? Call + : never; + } + /** * Replace all instances of a substring in a string. * @param args[0] - The string to replace. - * @param from - The substring to replace. - * @param to - The substring to replace with. + * @param from - The substring to replace or a RegExp pattern `Strings.RegExp` (support `i` flag). + * @param to - The substring to replace with, can include special replacement patterns when replacing with a RegExp. see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_string_as_the_replacement for more details. * @returns The replaced string. * @example * ```ts - * type T0 = Call,"a.b.c.d">; // "a/b/c/d" + * type T0 = Call, "/">, "a.b.c.d">; // "a/b/c/d" + * type T1 = Call\\d{4})/(?\\d{1,2})/(?\\d{1,2})", "i">, "My b$1 is $.$, $2">, "Birthday: 1991/9/15">; // "My birthday is 9.15, 1991" + * ``` */ export type Replace< - from extends string | unset | _ = unset, + from extends string | RegExp | unset | _ = unset, to extends string | unset | _ = unset, str = unset > = PartialApply; interface ReplaceFn extends Fn { return: this["args"] extends [ - infer From extends string, + infer From extends string | RegExp, infer To extends string, infer Str, ...any @@ -162,21 +229,26 @@ export namespace Strings { /** * Split a string into a tuple of strings. * @param args[0] - The string to split. - * @param sep - The separator to split the string with. + * @param sep - The separator to split the string with, can be a union of strings or RegExp pattern `Strings.RegExp` (support `i` flag) * @returns The split string. * @warning - 🔥 using an empty sep with emojis in the string will destroy the emoji 🔥 * @example * ```ts * type T0 = Call,"a,b,c">; // ["a","b","c"] + * type T1 = Call>, "1--2-3.4..5">; // ["1", "2-3", "4", "5"] * ``` */ export type Split< - Sep extends string | unset | _ = unset, + Sep extends string | RegExp | unset | _ = unset, Str extends string | unset | _ = unset > = PartialApply; export interface SplitFn extends Fn { - return: this["args"] extends [infer Sep extends string, infer Str, ...any] + return: this["args"] extends [ + infer Sep extends string | RegExp, + infer Str, + ...any + ] ? Impl.Split : never; } diff --git a/src/internals/strings/impl/match.ts b/src/internals/strings/impl/match.ts new file mode 100644 index 0000000..9949d0f --- /dev/null +++ b/src/internals/strings/impl/match.ts @@ -0,0 +1,53 @@ +import { Fn } from "../../core/Core"; + +import { RegExpStruct } from "./regexp"; + +import { MatchRegExp, MatchAllRegExp } from "type-level-regexp/regexp"; + +type PrettifyRegExpMatchArray = RegExpMatchResult extends { + _matchArray: infer MatchArray; + index: infer Index; + groups: infer Groups; +} + ? MatchArray & { index: Index; groups: Groups } + : null; + +export interface Match extends Fn { + return: this["args"] extends [ + infer Str extends string, + infer RE extends RegExpStruct, + ...any + ] + ? Str extends Str + ? "g" extends RE["flags"] + ? MatchRegExp, RE["flags"]> + : PrettifyRegExpMatchArray< + MatchRegExp, RE["flags"]> + > + : never + : never; +} + +export interface MatchAll extends Fn { + return: this["args"] extends [ + infer Str extends string, + infer RE extends RegExpStruct, + ...any + ] + ? Str extends Str + ? "g" extends RE["flags"] + ? MatchAllRegExp extends { + _matchedTuple: infer MatchTuple extends any[]; + } + ? { + [Key in keyof MatchTuple]: PrettifyRegExpMatchArray< + MatchTuple[Key] + >; + } + : null + : TypeError & { + msg: "MatchAll called with a non-global RegExp argument"; + } + : never + : never; +} diff --git a/src/internals/strings/impl/regexp.ts b/src/internals/strings/impl/regexp.ts new file mode 100644 index 0000000..f6bf054 --- /dev/null +++ b/src/internals/strings/impl/regexp.ts @@ -0,0 +1,20 @@ +import { Matcher, ParseRegExp } from "type-level-regexp/regexp"; + +declare const RegExpSymbol: unique symbol; +type RegExpSymbol = typeof RegExpSymbol; +export type SupportedRegExpFlags = "i" | "g"; + +export type RegExpStruct< + Pattern extends string, + Flags extends SupportedRegExpFlags = never, + ParsedMatchersOrError = string extends Pattern + ? Matcher[] + : ParseRegExp +> = ParsedMatchersOrError extends Matcher[] + ? { + type: RegExpSymbol; + pattern: Pattern; + flags: Flags; + parsedMatchers: ParsedMatchersOrError; + } + : ParsedMatchersOrError; diff --git a/src/internals/strings/impl/replace.ts b/src/internals/strings/impl/replace.ts index f892c42..5936abd 100644 --- a/src/internals/strings/impl/replace.ts +++ b/src/internals/strings/impl/replace.ts @@ -1,4 +1,7 @@ import { Fn } from "../../core/Core"; +import { RegExpStruct } from "./regexp"; + +import { ReplaceWithRegExp } from "type-level-regexp/regexp"; export type Replace< Str, @@ -13,9 +16,18 @@ export type Replace< export interface ReplaceReducer extends Fn { return: this["args"] extends [ infer Str extends string, - infer From extends string, + infer From extends string | RegExpStruct, ...any ] - ? Replace + ? Str extends Str + ? keyof RegExpStruct extends keyof From + ? ReplaceWithRegExp< + Str, + Exclude["parsedMatchers"], + To, + Exclude["flags"] + > + : Replace, To> + : never : never; } diff --git a/src/internals/strings/impl/split.ts b/src/internals/strings/impl/split.ts index ebe5592..4c3bfe8 100644 --- a/src/internals/strings/impl/split.ts +++ b/src/internals/strings/impl/split.ts @@ -1,4 +1,8 @@ +import { Call, PartialApply } from "../../core/Core"; import * as H from "../../helpers"; +import { Tuples } from "../../tuples/Tuples"; +import { Match } from "./match"; +import { RegExpStruct } from "./regexp"; type ConcatSplits< Parts extends string[], @@ -22,14 +26,31 @@ type SplitManySep< /** * Split a string into a tuple. * @param Str - The string to split. - * @param Sep - The separator to split on, can be a union of strings of more than one character. + * @param Sep - The separator to split on, can be a union of strings of more than one character, or a union of RegExp pattern `Strings.RegExp` (support `i` flag) * @returns The tuple of each split. if sep is an empty string, returns a tuple of each character. */ export type Split< Str, - Sep extends string, - Seps = H.UnionToTuple -> = Seps extends string[] + Sep extends string | RegExpStruct, + Seps = keyof RegExpStruct extends keyof Sep + ? H.UnionToTuple extends infer REs extends RegExpStruct[] + ? H.UnionToTuple< + Call< + Tuples.FlatMap>, + { + [K in keyof REs]: RegExpStruct< + REs[K]["pattern"], + REs[K]["flags"] | "g", + REs[K]["parsedMatchers"] + >; + } + >[number] + > + : never + : H.UnionToTuple +> = H.IsNever extends true + ? [...(Str extends "" ? [] : [Str])] + : Seps extends string[] ? Str extends string ? SplitManySep : [] diff --git a/src/internals/strings/impl/strings.ts b/src/internals/strings/impl/strings.ts index cbb0dec..33a3148 100644 --- a/src/internals/strings/impl/strings.ts +++ b/src/internals/strings/impl/strings.ts @@ -1,6 +1,8 @@ export * from "./split"; export * from "./trim"; +export * from "./match"; export * from "./replace"; export * from "./repeat"; export * from "./compare"; export * from "./length"; +export * from "./regexp"; diff --git a/test/strings.test.ts b/test/strings.test.ts index 57da1b3..704ce87 100644 --- a/test/strings.test.ts +++ b/test/strings.test.ts @@ -45,6 +45,289 @@ describe("Strings", () => { type test2 = Expect>; }); + describe("RegExp", () => { + it("with valid RegExp Pattern return a object with `pattern`, `flags` and `parsedMatchers` props", () => { + type res1 = Omit< + Strings.RegExp<"(?:\\w{2,3})(?[a-z]+)\\k", "g" | "i">, + "type" + >; + // ^? + type test1 = Expect< + Equal< + res1, + { + pattern: "(?:\\w{2,3})(?[a-z]+)\\k"; + flags: "g" | "i"; + parsedMatchers: [ + { + type: "repeat"; + greedy: true; + from: "2"; + to: "3"; + value: [ + { + type: "char"; + } + ]; + }, + { + type: "namedCapture"; + name: "g1"; + value: [ + { + type: "oneOrMore"; + greedy: true; + value: [ + { + type: "charSet"; + value: "a-z"; + } + ]; + } + ]; + }, + { + type: "backreference"; + value: "g1"; + } + ]; + } + > + >; + }); + + it("return `SyntaxError` with error message when RegExp pattern is invalid", () => { + type res1 = Strings.RegExp<"foo(b(ar)baz">; + // ^? + + type test1 = Expect< + Equal< + res1, + { + type: "RegExpSyntaxError"; + message: "Invalid regular expression, missing closing `)`"; + } & SyntaxError + > + >; + + type res2 = $< + // ^? + Strings.Match< + //@ts-expect-error - { message: "Invalid regular expression, opening `(`"; type: "RegExpSyntaxError"; } & SyntaxError + Strings.RegExp<"foobar)baz"> + >, + "foobarbaz" + >; + + type res3 = Strings.RegExp<"foo[a-zbar", "g">; + // ^? + type test3 = Expect< + Equal< + res3, + { + type: "RegExpSyntaxError"; + message: "Invalid regular expression, missing closing `]`"; + } & SyntaxError + > + >; + type res4 = Strings.RegExp<"foo?{2}bar", "g">; + // ^? + type test4 = Expect< + Equal< + res4, + { + type: "RegExpSyntaxError"; + message: "Invalid regular expression, the preceding token to {2} is not quantifiable"; + } & SyntaxError + > + >; + type res5 = Strings.RegExp<"foo(?g1>bar)baz">; + // ^? + type test5 = Expect< + Equal< + res5, + { + type: "RegExpSyntaxError"; + message: "Invalid regular expression, invalid capture group name for capturing `bar`, possibly due to a missing opening '<' and group name"; + } & SyntaxError + > + >; + type res6 = Strings.RegExp<"foo(?; + // ^? + type test6 = Expect< + Equal< + res6, + { + type: "RegExpSyntaxError"; + message: "Invalid regular expression, invalid capture group name of `g1bar`, possibly due to a missing closing '>' for group name"; + } & SyntaxError + > + >; + }); + }); + + describe("Match", () => { + it("support most basic RegExp tokens", () => { + type res1 = $< + // ^? + Strings.Match< + Strings.RegExp<"a(?[b-e$]{1,4})\\W\\s\\b\\k(\\d+)"> + >, + "12ab$c- b$c56#" + >; + type test1 = Expect< + Equal< + res1, + ["ab$c- b$c56", "b$c", "56"] & { + index: 2; + groups: { + g1: "b$c"; + }; + } + > + >; + + type res2 = $< + // ^? + Strings.Match< + Strings.RegExp<"a(?\\w*)(?\\d+)(?\\w*)(?\\w+)"> + >, + "abcd1234xyz" + >; + type test2 = Expect< + Equal< + res2, + ["abcd1234xyz", "bcd123", "4", "xy", "z"] & { + index: 0; + groups: { + g1: "bcd123"; + g2: "4"; + g3: "xy"; + g4: "z"; + }; + } + > + >; + }); + + it("support RegExp global `g` flag", () => { + type res3 = $< + // ^? + Strings.Match>, + "cats and cows ride in a car with cozy couch that's made for comfort." + >; + type test3 = Expect< + Equal + >; + }); + + it("support RegExp case case insensitive `i` flag", () => { + type res4 = $< + // ^? + Strings.Match>, + "Cats and coWs ride in a CAR with cozY cOUch that's made for Comfort." + >; + type test4 = Expect< + Equal + >; + }); + }); + + describe("MatchAll", () => { + it("support most basic RegExp tokens", () => { + type res1 = $< + // ^? + Strings.MatchAll[a-z]+)", "g">>, + "my cats love to play with toy car under the couch." + >; + type test1 = Expect< + Equal< + res1, + [ + ["cats", "ats"] & { + index: 3; + groups: { + letters: "ats"; + }; + }, + ["car", "ar"] & { + index: 30; + groups: { + letters: "ar"; + }; + }, + ["couch", "ouch"] & { + index: 44; + groups: { + letters: "ouch"; + }; + } + ] + > + >; + }); + + it("return `null` if no match", () => { + type res2 = $< + // ^? + Strings.MatchAll[a-z]+)", "g">>, + "my cats love to play with toy car under the couch." + >; + + type test2 = Expect>; + }); + + it("require global `g`", () => { + type res3 = $< + // ^? + Strings.MatchAll[a-z]+)">>, + "my cats love to play with toy car under the couch." + >; + + type test3 = Expect< + Equal< + res3, + TypeError & { + msg: "MatchAll called with a non-global RegExp argument"; + } + > + >; + }); + + it("support RegExp case insensitive `i` flag", () => { + type res4 = $< + // ^? + Strings.MatchAll[a-z]+)", "g" | "i">>, + "my Cats love to play with toy CAR under the cOucH." + >; + type test4 = Expect< + Equal< + res4, + [ + ["Cats", "ats"] & { + index: 3; + groups: { + letters: "ats"; + }; + }, + ["CAR", "AR"] & { + index: 30; + groups: { + letters: "AR"; + }; + }, + ["cOucH", "OucH"] & { + index: 44; + groups: { + letters: "OucH"; + }; + } + ] + > + >; + }); + }); + describe("Replace", () => { it("replaces single letters", () => { type res1 = $, "abc">; @@ -86,6 +369,53 @@ describe("Strings", () => { >; type test4 = Expect>; }); + + it("support using RegExp pattern", () => { + type res8 = $< + // ^? + Strings.Replace< + Strings.RegExp<"((?:\\w|\\s)+):\\s(?\\d{4})/(?\\d{1,2})/(?\\d{1,2})">, + "The $1 is $.$, $2" + >, + "release day: 2023/2/13" + >; + type test8 = Expect>; + }); + + it("support using union of RegExp pattern", () => { + type res9 = $< + // ^? + Strings.Replace< + | Strings.RegExp<"42\\d{2}(?:-\\d{4}){3}"> + | Strings.RegExp<"token-[a-zA-Z0-9_]+">, + "" + >, + "credit card number: 4232-3242-5823-8421, myToken: token-shekh23xz2jd_32jd213" + >; + type test9 = Expect< + Equal, myToken: "> + >; + }); + + it("support using RegExp pattern with flags", () => { + type res10 = $< + // ^? + Strings.Replace< + Strings.RegExp< + '(<(?:\\/)?)(?[a-z]{2,16})((?:\\s(?:\\w|=|\\")+)?>)', + "g" | "i" + >, + "$1My$$3" + >, + 'HotScript X type-level RegExp!

Type level madness.

READ MORE
' + >; + type test10 = Expect< + Equal< + res10, + 'HotScript X type-level RegExp!

Type level madness.

READ MORE
' + > + >; + }); }); it("Slice", () => { @@ -116,6 +446,17 @@ describe("Strings", () => { type res4 = $, "1--2-3.4..5">; // ^? type test4 = Expect>; + + type res5 = $>, "1--2-3.4..5">; + // ^? + type test5 = Expect>; + + type res6 = $< + // ^? + Strings.Split | Strings.RegExp<"[*-.:]">>, + "{name:'foo',age:18}" + >; + type test6 = Expect>; }); it("Repeat", () => { diff --git a/tsconfig.json b/tsconfig.json index c3dfd85..f128827 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "compilerOptions": { "module": "ESNext", + "moduleResolution": "node", "declaration": true, "esModuleInterop": true, "target": "ESNext",