diff --git a/package-lock.json b/package-lock.json index a72f9ae..075aa2a 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.13", "typescript": "^4.9.5" } }, @@ -3432,6 +3433,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/type-level-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/type-level-regexp/-/type-level-regexp-0.1.13.tgz", + "integrity": "sha512-nNq3nayYK6HUdPuhMTn+c/IMmxv7YsMgHHAOIJBo3nzPzrR0vlDgmAAloGQ+bU03xYNDyDLukBb9lh/fr4glog==", + "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 1133e8d..cb14ae7 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "main": "./dist/index.js", "types": "dist/index.d.ts", "scripts": { - "build": "tsc src/index.ts -d --emitDeclarationOnly --outDir dist", + "build": "tsc -d --emitDeclarationOnly --outDir dist", "prepublishOnly": "npm run test && npm run build", "test": "jest", "clear-test": "jest --clearCache", @@ -33,6 +33,7 @@ "jest": "^29.4.2", "prettier": "^2.8.4", "ts-jest": "^29.0.5", + "type-level-regexp": "^0.1.13", "typescript": "^4.9.5" } } diff --git a/src/internals/strings/Strings.ts b/src/internals/strings/Strings.ts index c918c44..2140f07 100644 --- a/src/internals/strings/Strings.ts +++ b/src/internals/strings/Strings.ts @@ -115,15 +115,70 @@ export namespace Strings { : never; } + /** + * Match a string against a regular expression (support `i` and `g` flags). + * @param args[0] - The string to match. + * @param RawRegExp - The regular expression to match. Support both "//" or "" syntax. + * @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/gi">, "12aBef34AeCf56">; // ["aBef", "AeCf"] + * ``` + */ + export type Match< + RawRegExp extends string | unset | _ = unset, + Str = unset + > = RawRegExp extends RawRegExp + ? PartialApply + : never; + + interface MatchFn extends Fn { + return: this["args"] extends [ + infer RawRegExp extends string, + infer Str, + ...any + ] + ? Call + : never; + } + + /** + * Match a string against a regular expression, return an array of match objects. + * @param args[0] - The string to match. + * @param RawRegExp - The regular expression 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/gi">, "12aBef34AeCf56">; // [["aBef", "Be"] & { index: 2; groups: { g1: "Be"; }; }, ["AeCf", "eC"] & { index: 8; groups: { g1: "eC"; }; }] + * ``` + */ + export type MatchAll< + RawRegExp extends string | unset | _ = unset, + Str = unset + > = RawRegExp extends RawRegExp + ? PartialApply + : never; + + interface MatchAllFn extends Fn { + return: this["args"] extends [ + infer RawRegExp extends string, + 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 (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 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, diff --git a/src/internals/strings/impl/match.ts b/src/internals/strings/impl/match.ts new file mode 100644 index 0000000..7cedc7c --- /dev/null +++ b/src/internals/strings/impl/match.ts @@ -0,0 +1,101 @@ +import { Call, Fn } from "../../core/Core"; +import { Split } from "../../helpers"; +import { T } from "../../.."; + +import { + ParseRegExp, + MatchRegExp, + MatchAllRegExp, + Flag, + Matcher, +} from "type-level-regexp/regexp"; + +type SupportedRegExpReplaceFlags = "i" | "g" | "ig" | "gi"; + +type PrettifyRegExpMatchArray = RegExpMatchResult extends { + _matchArray: infer MatchArray; + index: infer Index; + groups: infer Groups; +} + ? MatchArray & { index: Index; groups: Groups } + : null; + +interface PrettifyRegExpMatchArrayFn extends Fn { + return: this["args"] extends [infer RegExpMatchResult, ...any] + ? PrettifyRegExpMatchArray + : never; +} + +type ResovleRegExpMatchOrError< + Str extends string, + RegExp extends string, + FlagUnion extends Flag, + ParsedResult = ParseRegExp +> = ParsedResult extends Matcher[] + ? "g" extends FlagUnion + ? MatchRegExp + : PrettifyRegExpMatchArray> + : ParsedResult; + +export interface Match extends Fn { + return: this["args"] extends [ + infer Str extends string, + infer RawRegExp extends string, + ...any + ] + ? Str extends Str + ? RawRegExp extends `/${infer RegExp}/` + ? ResovleRegExpMatchOrError + : RawRegExp extends `/${infer RegExp}/${SupportedRegExpReplaceFlags}` + ? RawRegExp extends `/${RegExp}/${infer Flags}` + ? Split[number] extends infer FlagsUnion extends Flag + ? ResovleRegExpMatchOrError + : never + : never + : ResovleRegExpMatchOrError + : never + : never; +} + +type ResovleRegExpMatchAllOrError< + Str extends string, + RegExp extends string, + FlagUnion extends Flag, + ParsedResult = ParseRegExp +> = ParsedResult extends Matcher[] + ? MatchAllRegExp extends { + _matchedTuple: infer MatchTuple extends any[]; + } + ? Call, MatchTuple> + : null + : ParsedResult; + +export interface MatchAll extends Fn { + return: this["args"] extends [ + infer Str extends string, + infer RawRegExp extends string, + ...any + ] + ? Str extends Str + ? RawRegExp extends `/${infer RegExp}/g` + ? ResovleRegExpMatchAllOrError + : RawRegExp extends `/${infer RegExp}/${Exclude< + SupportedRegExpReplaceFlags, + "i" + >}` + ? ResovleRegExpMatchAllOrError< + Str, + RegExp, + Split< + RawRegExp extends `/${RegExp}/${infer Flags extends SupportedRegExpReplaceFlags}` + ? Flags + : never, + "" + >[number] + > + : TypeError & { + msg: "MatchAll called with a non-global RegExp argument"; + } + : never + : never; +} diff --git a/src/internals/strings/impl/replace.ts b/src/internals/strings/impl/replace.ts index f892c42..598f829 100644 --- a/src/internals/strings/impl/replace.ts +++ b/src/internals/strings/impl/replace.ts @@ -1,4 +1,14 @@ import { Fn } from "../../core/Core"; +import { Split } from "../../helpers"; + +import { + Flag, + Matcher, + ParseRegExp, + ReplaceWithRegExp, +} from "type-level-regexp/regexp"; + +type SupportedRegExpReplaceFlags = "i" | "g" | "ig" | "gi"; export type Replace< Str, @@ -10,12 +20,38 @@ export type Replace< : Str : Str; +type ResovleRegExpReplaceOrError< + Str extends string, + RegExp extends string, + To extends string, + FlagUnion extends Flag, + ParsedResult = ParseRegExp +> = ParsedResult extends Matcher[] + ? ReplaceWithRegExp + : ParsedResult; + export interface ReplaceReducer extends Fn { return: this["args"] extends [ infer Str extends string, infer From extends string, ...any ] - ? Replace + ? Str extends Str + ? From extends `/${infer RegExp}/` + ? ResovleRegExpReplaceOrError + : From extends `/${infer RegExp}/${SupportedRegExpReplaceFlags}` + ? ResovleRegExpReplaceOrError< + Str, + RegExp, + To, + Split< + From extends `/${RegExp}/${infer Flags extends SupportedRegExpReplaceFlags}` + ? Flags + : never, + "" + >[number] + > + : Replace + : never : never; } diff --git a/src/internals/strings/impl/strings.ts b/src/internals/strings/impl/strings.ts index cbb0dec..70ee29e 100644 --- a/src/internals/strings/impl/strings.ts +++ b/src/internals/strings/impl/strings.ts @@ -1,5 +1,6 @@ export * from "./split"; export * from "./trim"; +export * from "./match"; export * from "./replace"; export * from "./repeat"; export * from "./compare"; diff --git a/test/strings.test.ts b/test/strings.test.ts index 39a7ac4..9d509a2 100644 --- a/test/strings.test.ts +++ b/test/strings.test.ts @@ -45,6 +45,257 @@ describe("Strings", () => { type test2 = Expect>; }); + describe("Match", () => { + it("support most basic RegExp tokens", () => { + type res1 = $< + // ^? + Strings.Match<"/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"; + }; + } + > + >; + }); + + it("support pattern without wrapping with `//`", () => { + type res2 = $< + // ^? + Strings.Match<"a(?[b-e$]{1,4})\\W\\s\\b\\k(\\d+)">, + "12ab$c- b$c56#" + >; + type test2 = Expect< + Equal< + res2, + ["ab$c- b$c56", "b$c", "56"] & { + index: 2; + groups: { + g1: "b$c"; + }; + } + > + >; + }); + + it("support RegExp global `g` flag", () => { + type res3 = $< + // ^? + Strings.Match<"/c\\w{2,6}/g">, + "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<"/C[a-z]+/ig">, + "Cats and coWs ride in a CAR with cozY cOUch that's made for Comfort." + >; + type test4 = Expect< + Equal + >; + }); + + it("return RegExp syntax errors and hints", () => { + type res5 = $< + // ^? + Strings.Match<"/foo(b(ar)baz/">, + "basicRegExp_foobarbaz" + >; + type test5 = Expect< + Equal< + res5, + { + type: "RegExpSyntaxError"; + message: "Invalid regular expression, missing closing `)`"; + } & SyntaxError + > + >; + + type res6 = $< + // ^? + Strings.Match<"foo(?g1>bar)baz">, + "noWrapWith/_foobarbaz" + >; + type test6 = Expect< + Equal< + res6, + { + type: "RegExpSyntaxError"; + message: "Invalid regular expression, invalid capture group name for capturing `bar`, possibly due to a missing opening '<' and group name"; + } & SyntaxError + > + >; + + type res7 = $< + // ^? + Strings.Match<"/foo[a-zbar/g">, + "withFlag_fooabar" + >; + type test7 = Expect< + Equal< + res7, + { + type: "RegExpSyntaxError"; + message: "Invalid regular expression, missing closing `]`"; + } & SyntaxError + > + >; + }); + }); + + describe("MatchAll", () => { + it("support most basic RegExp tokens", () => { + type res1 = $< + // ^? + Strings.MatchAll<"/c(?[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<"/z(?[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<"/c(?[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<"/c(?[a-z]+)/gi">, + "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"; + }; + } + ] + > + >; + }); + + it("return RegExp syntax errors and hints", () => { + type res5 = $< + // ^? + Strings.MatchAll<"/foo(b(ar)baz/g">, + "basicRegExp_foobarbaz" + >; + type test5 = Expect< + Equal< + res5, + { + type: "RegExpSyntaxError"; + message: "Invalid regular expression, missing closing `)`"; + } & SyntaxError + > + >; + + type res6 = $< + // ^? + Strings.MatchAll<"/foo(?, + "foobarbaz" + >; + 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 + > + >; + + type res7 = $< + // ^? + Strings.Match<"/foo?{2}bar/g">, + "fooabar" + >; + type test7 = Expect< + Equal< + res7, + { + type: "RegExpSyntaxError"; + message: "Invalid regular expression, the preceding token to {2} is not quantifiable"; + } & SyntaxError + > + >; + }); + }); + describe("Replace", () => { it("replaces single letters", () => { type res1 = $, "abc">; @@ -86,6 +337,67 @@ describe("Strings", () => { >; type test4 = Expect>; }); + + it("support using RegExp pattern", () => { + type res8 = $< + // ^? + Strings.Replace< + "/((?:\\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< + "/42\\d{2}(?:-\\d{4}){3}/" | "/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< + '/(<(?:\\/)?)(?\\w{2,16})((?:\\s(?:\\w|=|\\")+)?>)/gi', + "$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("return RegExp syntax errors and hints", () => { + type res11 = $< + // ^? + Strings.Replace<"/(foo)+*baz/">, + "foobarbaz", + "replace" + >; + type test11 = Expect< + Equal< + res11, + { + type: "RegExpSyntaxError"; + message: "Invalid regular expression, the preceding token to * is not quantifiable"; + } & SyntaxError + > + >; + }); }); it("Slice", () => { 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",