-
-
Save LionyxML/b6c078a2d1b13cad42666db1773a9cec to your computer and use it in GitHub Desktop.
RX - readable regular expressions for JavaScript/TypeScript, inspired by Emacs' `rx` macro.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* ========================================================= | |
| * RX - readable regular expressions for JavaScript/TypeScript, | |
| * inspired by Emacs' `rx` macro. | |
| * | |
| * Post: https://rahuljuliato.com/posts/emacs-rx-in-typescript | |
| * | |
| * Single file, no dependencies. The DSL comes first, then a set | |
| * of examples comparing hand-written regexps against RX. | |
| * | |
| * Run the examples (Node 22.18+ or 23.6+ strips the types itself): | |
| * | |
| * node rx.ts | |
| * | |
| * Older Node versions: npx tsx rx.ts | |
| * Plain JavaScript: strip the type annotations and it runs as-is. | |
| * | |
| * No setup at all? Paste this whole file into the TypeScript | |
| * Playground, https://www.typescriptlang.org/play, hit "Run" and | |
| * check the "Logs" tab. If it complains about named capturing | |
| * groups, set "Target" to ES2018 or later under "TS Config". | |
| * ========================================================= */ | |
| /* | |
| * Emacs: (rx bos (+ digit) eos) | |
| * TS: RX(start, oneOrMore(digit), end) | |
| * | |
| * Rules borrowed from rx: | |
| * - plain strings are LITERALS (escaped for you) | |
| * - every form takes a list of items = implicit `seq` | |
| * - brackets are only added when precedence needs them | |
| * | |
| * Emacs rx -> here | |
| * seq : and -> seq(...) (implicit everywhere) | |
| * or | -> or(...) | |
| * any in char -> anyOf("a-z", "_", digit) | |
| * not-char -> notChar("a-z") | |
| * not -> not(charset) | |
| * * + ? (0+ 1+ opt) -> zeroOrMore oneOrMore optional | |
| * *? +? ?? -> zeroOrMoreLazy oneOrMoreLazy optionalLazy | |
| * = >= ** repeat -> repeat atLeast between | |
| * group -> group(...) | |
| * group-n -> named("name", ...) (JS can't number groups) | |
| * backref -> backref(1) / backref("name") | |
| * literal -> literal(s) (plain strings do this too) | |
| * regexp regex -> raw("...") / raw(/.../) | |
| * rx-define rx-let -> just a `const` | |
| * bos eos -> start end | |
| * bol eol -> lineStart lineEnd (need the "m" flag) | |
| * bow eow -> wordStart wordEnd | |
| * word-boundary -> wordBoundary | |
| * not-word-boundary -> notWordBoundary | |
| * nonl -> notNewline | |
| * anychar anything -> anything | |
| * unmatchable -> unmatchable | |
| * char classes -> digit hexDigit space blank wordChar notWordChar | |
| * alpha alnum lower upper punct control | |
| * graphic printing ascii nonascii | |
| * | |
| * Left out, no JS equivalent: point, symbol-start/end, syntax, | |
| * category, intersection, minimal-match/maximal-match, eval. | |
| * Char classes here are ASCII; in Emacs they are Unicode-aware. | |
| * ========================================================= */ | |
| /* ========================================================= | |
| * CORE | |
| * ========================================================= */ | |
| // How a node behaves when combined with others: | |
| // atom -> single unit, a quantifier can be glued right after it | |
| // seq -> safe to concatenate, needs (?:) to be quantified | |
| // alt -> has a top-level `|`, needs (?:) almost everywhere | |
| type Kind = "atom" | "seq" | "alt"; | |
| interface RxNode { | |
| readonly src: string; | |
| readonly kind: Kind; | |
| // char sets only: the text that goes inside [ ], so sets can merge | |
| readonly set?: string; | |
| readonly neg?: boolean; | |
| } | |
| type Item = string | RxNode; | |
| const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| const escSet = (s: string) => s.replace(/[\]\\^-]/g, "\\$&"); | |
| // rx: (literal EXPR) — a string computed at runtime, matched as-is | |
| const literal = (s: string): RxNode => ({ | |
| src: esc(s), | |
| kind: s.length === 1 ? "atom" : "seq", | |
| }); | |
| const toNode = (x: Item): RxNode => (typeof x === "string" ? literal(x) : x); | |
| // wrap a node so a quantifier applies to all of it | |
| const quantifiable = (n: RxNode) => | |
| n.kind === "atom" ? n.src : `(?:${n.src})`; | |
| // rx: (regexp EXPR) — escape hatch, trust the regexp as-is | |
| const raw = (re: string | RegExp): RxNode => ({ | |
| src: typeof re === "string" ? re : re.source, | |
| kind: "alt", | |
| }); | |
| /* ========================================================= | |
| * COMPOSITION | |
| * ========================================================= */ | |
| const seq = (...xs: Item[]): RxNode => { | |
| const nodes = xs.map(toNode).filter((n) => n.src !== ""); | |
| if (nodes.length === 0) return { src: "", kind: "seq" }; | |
| if (nodes.length === 1) return nodes[0]; | |
| let src = ""; | |
| for (const n of nodes) { | |
| const part = n.kind === "alt" ? `(?:${n.src})` : n.src; | |
| // `\1` followed by a literal `0` would read as `\10` | |
| if (/\\\d+$/.test(src) && /^\d/.test(part)) src += "(?:)"; | |
| src += part; | |
| } | |
| return { src, kind: "seq" }; | |
| }; | |
| const unmatchable: RxNode = { src: "(?!)", kind: "atom" }; | |
| // Like rx: when every branch is a plain string, try the longest first, | |
| // so or("in", "int") matches "int" instead of stopping at "in". | |
| const or = (...xs: Item[]): RxNode => { | |
| if (xs.length === 0) return unmatchable; | |
| if (xs.length === 1) return toNode(xs[0]); | |
| const branches = xs.every((x) => typeof x === "string") | |
| ? [...(xs as string[])].sort((a, b) => b.length - a.length) | |
| : xs; | |
| return { src: branches.map((x) => toNode(x).src).join("|"), kind: "alt" }; | |
| }; | |
| /* ========================================================= | |
| * CHARACTER SETS | |
| * ========================================================= */ | |
| const set = (body: string, neg = false): RxNode => ({ | |
| src: neg ? `[^${body}]` : `[${body}]`, | |
| kind: "atom", | |
| set: body, | |
| neg, | |
| }); | |
| // class escapes are sets too, so they can go inside anyOf(...) | |
| const classEscape = (e: string): RxNode => ({ | |
| src: e, | |
| kind: "atom", | |
| set: e, | |
| neg: false, | |
| }); | |
| // Same reading as rx: inside a string, "a-z" is a range, while a `-` | |
| // at the start or end is just a dash ("+-" is plus or minus). | |
| const intervals = (s: string): string => { | |
| let body = ""; | |
| let i = 0; | |
| while (i < s.length) { | |
| if (i < s.length - 2 && s[i + 1] === "-") { | |
| body += `${escSet(s[i])}-${escSet(s[i + 2])}`; | |
| i += 3; | |
| } else { | |
| body += escSet(s[i]); | |
| i += 1; | |
| } | |
| } | |
| return body; | |
| }; | |
| // rx: (any "a-z" "_" digit) — also known as `in` and `char` | |
| const anyOf = (...xs: Item[]): RxNode => { | |
| const body = xs | |
| .map((x) => { | |
| if (typeof x === "string") return intervals(x); | |
| if (x.set === undefined || x.neg) | |
| throw new Error(`anyOf: not a positive char set: ${x.src}`); | |
| return x.set; | |
| }) | |
| .join(""); | |
| return set(body); | |
| }; | |
| // rx: (not charset) — not(digit) -> \D, not(anyOf(",;")) -> [^,;] | |
| const not = (x: Item): RxNode => { | |
| const n = typeof x === "string" ? anyOf(x) : x; | |
| if (n.set === undefined) throw new Error(`not: not a char set: ${n.src}`); | |
| if (n.neg) return set(n.set); | |
| if (/^\\[dswDSW]$/.test(n.src)) { | |
| const c = n.src[1]; | |
| const flipped = c === c.toLowerCase() ? c.toUpperCase() : c.toLowerCase(); | |
| return classEscape(`\\${flipped}`); | |
| } | |
| return set(n.set, true); | |
| }; | |
| // rx: (not-char "a-z" ...) — shorthand for (not (any ...)) | |
| const notChar = (...xs: Item[]) => not(anyOf(...xs)); | |
| // rx char classes, `[[:name:]]` in Emacs | |
| const digit = classEscape("\\d"); | |
| const space = classEscape("\\s"); | |
| const wordChar = classEscape("\\w"); | |
| const notWordChar = not(wordChar); | |
| const lower = anyOf("a-z"); | |
| const upper = anyOf("A-Z"); | |
| const alpha = anyOf(lower, upper); | |
| const alnum = anyOf(alpha, "0-9"); | |
| const hexDigit = anyOf("0-9a-fA-F"); | |
| const blank = set(" \\t"); | |
| const control = set("\\x00-\\x1f\\x7f"); | |
| const punct = anyOf("!-/:-@[-`{-~"); | |
| const graphic = anyOf("!-~"); | |
| const printing = anyOf(" -~"); | |
| const ascii = set("\\x00-\\x7f"); | |
| const nonascii = set("\\u0080-\\uffff"); | |
| // rx: `nonl` is any char but newline; `anything` really is anything | |
| const notNewline: RxNode = { src: ".", kind: "atom" }; | |
| const anything = set("\\s\\S"); | |
| /* ========================================================= | |
| * ANCHORS (zero-width) | |
| * ========================================================= */ | |
| const zeroWidth = (src: string): RxNode => ({ src, kind: "seq" }); | |
| // rx: bos / eos | |
| const start = zeroWidth("^"); | |
| const end = zeroWidth("$"); | |
| // rx: bol / eol — same symbols, only per line with the "m" flag | |
| const lineStart = start; | |
| const lineEnd = end; | |
| const wordBoundary = zeroWidth("\\b"); | |
| const notWordBoundary = zeroWidth("\\B"); | |
| // rx: bow / eow — JS has no \< \>, so a boundary plus a lookaround | |
| const wordStart = zeroWidth("\\b(?=\\w)"); | |
| const wordEnd = zeroWidth("\\b(?<=\\w)"); | |
| /* ========================================================= | |
| * GROUPS & BACKREFERENCES | |
| * ========================================================= */ | |
| const group = (...xs: Item[]): RxNode => ({ | |
| src: `(${seq(...xs).src})`, | |
| kind: "atom", | |
| }); | |
| // rx has (group-n N ...); JS can't pick group numbers, but it can name them | |
| const named = (name: string, ...xs: Item[]): RxNode => ({ | |
| src: `(?<${name}>${seq(...xs).src})`, | |
| kind: "atom", | |
| }); | |
| const backref = (ref: number | string): RxNode => ({ | |
| src: typeof ref === "number" ? `\\${ref}` : `\\k<${ref}>`, | |
| kind: "atom", | |
| }); | |
| /* ========================================================= | |
| * QUANTIFIERS | |
| * ========================================================= */ | |
| const quantifier = | |
| (suffix: string) => | |
| (...xs: Item[]): RxNode => ({ | |
| src: quantifiable(seq(...xs)) + suffix, | |
| kind: "seq", | |
| }); | |
| // greedy — rx: * + ? | |
| const zeroOrMore = quantifier("*"); | |
| const oneOrMore = quantifier("+"); | |
| const optional = quantifier("?"); | |
| // lazy — rx: *? +? ?? | |
| const zeroOrMoreLazy = quantifier("*?"); | |
| const oneOrMoreLazy = quantifier("+?"); | |
| const optionalLazy = quantifier("??"); | |
| // rx: (= n ...) (>= n ...) (** n m ...) | |
| const repeat = (n: number, ...xs: Item[]) => quantifier(`{${n}}`)(...xs); | |
| const atLeast = (n: number, ...xs: Item[]) => quantifier(`{${n},}`)(...xs); | |
| const between = (n: number, m: number, ...xs: Item[]) => | |
| quantifier(`{${n},${m}}`)(...xs); | |
| /* ========================================================= | |
| * ENTRY POINTS | |
| * ========================================================= */ | |
| // rx(...) -> the regexp source string (like Emacs, rx returns a string) | |
| // RX(...) -> a ready-to-use RegExp, no more `new RegExp(seq(...))` | |
| const rx = (...xs: Item[]): string => seq(...xs).src; | |
| function RX(...xs: Item[]): RegExp { | |
| return new RegExp(rx(...xs)); | |
| } | |
| // Emacs uses `case-fold-search` for this; JS puts it on the regexp | |
| RX.flags = (flags: string, ...xs: Item[]): RegExp => | |
| new RegExp(rx(...xs), flags); | |
| /* ========================================================= | |
| * EXAMPLES | |
| * ========================================================= */ | |
| const check = ( | |
| title: string, | |
| regex: RegExp, | |
| dsl: RegExp, | |
| samples: string[], | |
| ) => { | |
| console.log(`\n${title}`); | |
| console.log(` regex: ${regex}`); | |
| console.log(` RX: ${dsl}`); | |
| for (const s of samples) { | |
| const a = regex.test(s); | |
| const b = dsl.test(s); | |
| console.log( | |
| ` ${JSON.stringify(s).padEnd(26)} ${a} ${b}${a === b ? "" : " <-- MISMATCH"}`, | |
| ); | |
| } | |
| }; | |
| /* --------------------------------------------------------- | |
| * Basics | |
| * --------------------------------------------------------- */ | |
| /* 1. DIGITS ONLY */ | |
| check("1) digits only", /^\d+$/, RX(start, oneOrMore(digit), end), [ | |
| "123", | |
| "12a", | |
| ]); | |
| /* 2. LETTERS ONLY */ | |
| check("2) letters only", /^[a-zA-Z]+$/, RX(start, oneOrMore(alpha), end), [ | |
| "Hello", | |
| "He11o", | |
| ]); | |
| /* 3. OPTIONAL LETTER (color / colour) */ | |
| check( | |
| "3) optional letter", | |
| /^colou?r$/, | |
| RX(start, "colo", optional("u"), "r", end), | |
| ["color", "colour", "colouur"], | |
| ); | |
| /* 4. TWO WORDS */ | |
| { | |
| const word = oneOrMore(wordChar); | |
| check("4) two words", /^\w+\s\w+$/, RX(start, word, space, word, end), [ | |
| "hello world", | |
| "hello", | |
| ]); | |
| } | |
| /* 5. NUMBER ANYWHERE (no anchors) */ | |
| check("5) number inside text", /\d+/, RX(oneOrMore(digit)), [ | |
| "abc123def", | |
| "abcdef", | |
| ]); | |
| /* --------------------------------------------------------- | |
| * Literals | |
| * --------------------------------------------------------- */ | |
| /* 6. PHONE NUMBER — strings are literals, "(" needs no escaping */ | |
| check( | |
| "6) phone number", | |
| /^\(\d{3}\)\s\d{3}-\d{4}$/, | |
| RX( | |
| start, | |
| "(", | |
| repeat(3, digit), | |
| ")", | |
| space, | |
| repeat(3, digit), | |
| "-", | |
| repeat(4, digit), | |
| end, | |
| ), | |
| ["(123) 456-7890", "123-456-7890"], | |
| ); | |
| /* 7. LITERAL — text from the outside world, escaped for free */ | |
| { | |
| const userInput = "1+1=2? (maybe)"; | |
| const naive = new RegExp(userInput); | |
| const safe = RX(literal(userInput)); | |
| console.log("\n7) literal (runtime text)"); | |
| console.log(` new RegExp(input): ${naive} ->`, naive.test(userInput)); | |
| console.log(` RX(literal(input)): ${safe} ->`, safe.test(userInput)); | |
| } | |
| /* --------------------------------------------------------- | |
| * Character sets | |
| * --------------------------------------------------------- */ | |
| /* 8. ANY — ranges inside strings, like (any "0-9a-fA-F") */ | |
| check( | |
| "8) hex color", | |
| /^#[0-9a-fA-F]{6}$/, | |
| RX(start, "#", repeat(6, hexDigit), end), | |
| ["#ff00aa", "#ff00ag"], | |
| ); | |
| /* 9. ANY — a dash at the edge is just a dash, like (any "+-") */ | |
| check( | |
| "9) signed integer", | |
| /^[+-]?\d+$/, | |
| RX(start, optional(anyOf("+-")), oneOrMore(digit), end), | |
| ["-42", "+7", "42", "*42"], | |
| ); | |
| /* 10. NOT — negating a merged set (simple email) */ | |
| { | |
| const part = oneOrMore(not(anyOf(space, "@"))); | |
| check( | |
| "10) email", | |
| /^[^\s@]+@[^\s@]+\.[^\s@]+$/, | |
| RX(start, part, "@", part, ".", part, end), | |
| ["a@b.com", "a b@c.com", "a@b"], | |
| ); | |
| } | |
| /* 11. NOT on a class — (not digit) becomes \D */ | |
| check("11) no digits", /^[^\d]+$/, RX(start, oneOrMore(not(digit)), end), [ | |
| "abc", | |
| "a1b", | |
| ]); | |
| /* 12. NOT-CHAR — CSV with 3 fields */ | |
| { | |
| const field = oneOrMore(notChar(",")); | |
| check( | |
| "12) csv line", | |
| /^[^,]+,[^,]+,[^,]+$/, | |
| RX(start, field, ",", field, ",", field, end), | |
| ["a,b,c", "a,b,"], | |
| ); | |
| } | |
| /* 13. NOT-WORDCHAR — split on anything that isn't a word char */ | |
| { | |
| const sep = RX.flags("g", oneOrMore(notWordChar)); | |
| console.log("\n13) not-wordchar"); | |
| console.log(` RX: ${sep}`); | |
| console.log(" ", "hello, world -- foo_bar!".split(sep)); | |
| } | |
| /* 14. THE CHAR CLASSES — who matches what */ | |
| { | |
| const classes = { | |
| digit, | |
| hexDigit, | |
| space, | |
| blank, | |
| wordChar, | |
| notWordChar, | |
| alpha, | |
| alnum, | |
| lower, | |
| upper, | |
| punct, | |
| control, | |
| graphic, | |
| printing, | |
| ascii, | |
| nonascii, | |
| }; | |
| const chars = ["a", "F", "7", " ", "\t", "_", "!", "\x07", "é"]; | |
| console.log("\n14) char classes"); | |
| console.log( | |
| ` ${"".padEnd(12)}${chars.map((c) => JSON.stringify(c).padEnd(10)).join("")}`, | |
| ); | |
| for (const [name, cls] of Object.entries(classes)) { | |
| const re = RX(start, cls, end); | |
| const row = chars.map((c) => (re.test(c) ? "x" : ".").padEnd(10)).join(""); | |
| console.log(` ${name.padEnd(12)}${row}`); | |
| } | |
| } | |
| /* 15. ANYTHING vs NOTNEWLINE — a C comment spanning lines */ | |
| { | |
| const code = "a = 1; /* first\n second */ b = 2;"; | |
| const nonl = RX("/*", zeroOrMoreLazy(notNewline), "*/"); | |
| const any = RX("/*", zeroOrMoreLazy(anything), "*/"); | |
| console.log("\n15) anything vs notNewline"); | |
| console.log(` ${nonl} ->`, nonl.exec(code)?.[0] ?? null); | |
| console.log(` ${any} ->`, JSON.stringify(any.exec(code)?.[0])); | |
| } | |
| /* --------------------------------------------------------- | |
| * Alternatives | |
| * --------------------------------------------------------- */ | |
| /* 16. OR */ | |
| check( | |
| "16) or", | |
| /^(?:cat|dog|bird)$/, | |
| RX(start, or("cat", "dog", "bird"), end), | |
| ["cat", "bird", "cow"], | |
| ); | |
| /* 17. OR picks the longest string first (just like rx) */ | |
| { | |
| const naive = /in|int|interface/; | |
| const kw = RX(or("in", "int", "interface")); | |
| console.log("\n17) or: longest match first"); | |
| console.log(` regex: ${naive} ->`, naive.exec("interface Foo")?.[0]); | |
| console.log(` RX: ${kw} ->`, kw.exec("interface Foo")?.[0]); | |
| } | |
| /* 18. UNMATCHABLE — what or() with no branches gives you */ | |
| check("18) unmatchable", /(?!)/, RX(or()), ["anything", ""]); | |
| check(" unmatchable (direct)", /a(?!)/, RX("a", unmatchable), ["a"]); | |
| /* --------------------------------------------------------- | |
| * Repetition | |
| * --------------------------------------------------------- */ | |
| /* 19. BETWEEN — (** 2 4 digit) */ | |
| check("19) between", /^\d{2,4}$/, RX(start, between(2, 4, digit), end), [ | |
| "12", | |
| "12345", | |
| ]); | |
| /* 20. AT LEAST — (>= 3 digit) */ | |
| check("20) at least N", /\d{3,}/, RX(atLeast(3, digit)), ["12", "123"]); | |
| /* 21. LAZY — greedy vs lazy side by side */ | |
| { | |
| const html = "<b>bold</b> and <i>italic</i>"; | |
| const greedy = RX("<", oneOrMore(notNewline), ">"); | |
| const lazy = RX("<", oneOrMoreLazy(notNewline), ">"); | |
| console.log("\n21) oneOrMore vs oneOrMoreLazy"); | |
| console.log(` ${greedy} ->`, greedy.exec(html)?.[0]); | |
| console.log(` ${lazy} ->`, lazy.exec(html)?.[0]); | |
| const gOpt = RX(group(optional("x")), group(zeroOrMore("x"))); | |
| const lOpt = RX(group(optionalLazy("x")), group(zeroOrMore("x"))); | |
| console.log(' optional vs optionalLazy on "xx":'); | |
| console.log(` ${gOpt} ->`, gOpt.exec("xx")?.slice(1)); | |
| console.log(` ${lOpt} ->`, lOpt.exec("xx")?.slice(1)); | |
| } | |
| /* --------------------------------------------------------- | |
| * Groups | |
| * --------------------------------------------------------- */ | |
| /* 22. GROUP + BACKREF — same word twice */ | |
| check( | |
| "22) repeated word", | |
| /^(\w+)\s\1$/, | |
| RX(start, group(oneOrMore(wordChar)), space, backref(1), end), | |
| ["hello hello", "hello world"], | |
| ); | |
| /* 23. GROUP around an OR */ | |
| check( | |
| "23) or + group", | |
| /^(mr|ms)\s\w+$/, | |
| RX(start, group(or("mr", "ms")), space, oneOrMore(wordChar), end), | |
| ["mr john", "dr john"], | |
| ); | |
| /* 24. NAMED GROUPS — extract parts of a date */ | |
| { | |
| const date = RX( | |
| named("y", repeat(4, digit)), | |
| "-", | |
| named("m", repeat(2, digit)), | |
| "-", | |
| named("d", repeat(2, digit)), | |
| ); | |
| check( | |
| "24) named groups (date)", | |
| /(?<y>\d{4})-(?<m>\d{2})-(?<d>\d{2})/, | |
| date, | |
| ["2026-09-30"], | |
| ); | |
| console.log(" groups:", { ...date.exec("2026-09-30")?.groups }); | |
| } | |
| /* 25. NAMED BACKREF — open/close tags */ | |
| check( | |
| "25) named backref", | |
| /<(?<tag>\w+)>.*?<\/\k<tag>>/, | |
| RX( | |
| "<", | |
| named("tag", oneOrMore(wordChar)), | |
| ">", | |
| zeroOrMoreLazy(notNewline), | |
| "</", | |
| backref("tag"), | |
| ">", | |
| ), | |
| ["<b>bold</b>", "<b>oops</i>"], | |
| ); | |
| /* --------------------------------------------------------- | |
| * Anchors | |
| * --------------------------------------------------------- */ | |
| /* 26. LINE START / END — every TODO line, with "gm" */ | |
| { | |
| const text = "TODO: write post\nDONE: fix rx\nTODO: publish"; | |
| const todo = RX.flags( | |
| "gm", | |
| lineStart, | |
| "TODO: ", | |
| named("task", oneOrMore(notNewline)), | |
| lineEnd, | |
| ); | |
| console.log("\n26) lineStart / lineEnd"); | |
| console.log(` RX: ${todo}`); | |
| console.log( | |
| " ", | |
| [...text.matchAll(todo)].map((m) => m.groups?.task), | |
| ); | |
| } | |
| /* 27. WORD BOUNDARY vs NOT WORD BOUNDARY */ | |
| check("27) whole word", /\bcat\b/, RX(wordBoundary, "cat", wordBoundary), [ | |
| "the cat sat", | |
| "concatenate", | |
| ]); | |
| check( | |
| " inside a word", | |
| /\Bcat\B/, | |
| RX(notWordBoundary, "cat", notWordBoundary), | |
| ["the cat sat", "concatenate"], | |
| ); | |
| /* 28. WORD START / END — bow / eow */ | |
| { | |
| const text = "unhappy happy happiness"; | |
| const starts = RX.flags("g", wordStart, "happ"); | |
| const ends = RX.flags("g", "happy", wordEnd); | |
| console.log("\n28) wordStart / wordEnd"); | |
| console.log( | |
| ` ${starts} ->`, | |
| [...text.matchAll(starts)].map((m) => m.index), | |
| ); | |
| console.log( | |
| ` ${ends} ->`, | |
| [...text.matchAll(ends)].map((m) => m.index), | |
| ); | |
| } | |
| /* --------------------------------------------------------- | |
| * Escape hatch, flags, strings | |
| * --------------------------------------------------------- */ | |
| /* 29. RAW — reuse a regexp you already have, like (regexp ...) */ | |
| { | |
| const legacyZip = /\d{5}(?:-\d{4})?/; | |
| check( | |
| "29) raw", | |
| /^[A-Z]{2} \d{5}(?:-\d{4})?$/, | |
| RX(start, repeat(2, upper), " ", raw(legacyZip), end), | |
| ["CA 94103", "NY 10001-1234", "CA 9410"], | |
| ); | |
| } | |
| /* 30. FLAGS */ | |
| check("30) flags", /^hello$/i, RX.flags("i", start, "hello", end), [ | |
| "HeLLo", | |
| "help", | |
| ]); | |
| /* 31. rx() gives the source string, for when you need to embed it */ | |
| console.log("\n31) rx() as string"); | |
| console.log( | |
| " ", | |
| rx(start, oneOrMore(digit), optional(".", oneOrMore(digit)), end), | |
| ); | |
| /* --------------------------------------------------------- | |
| * The big one | |
| * --------------------------------------------------------- */ | |
| /* 32. SEMVER — the official regex from semver.org. | |
| * Each `const` here plays the role of rx-define. */ | |
| { | |
| const num = or("0", seq(anyOf("1-9"), zeroOrMore(digit))); | |
| const idChar = anyOf(alnum, "-"); | |
| const preId = or( | |
| num, | |
| seq(zeroOrMore(digit), anyOf(alpha, "-"), zeroOrMore(idChar)), | |
| ); | |
| const dotted = (x: Item) => seq(x, zeroOrMore(".", x)); | |
| const semver = RX( | |
| start, | |
| named("major", num), | |
| ".", | |
| named("minor", num), | |
| ".", | |
| named("patch", num), | |
| optional("-", named("pre", dotted(preId))), | |
| optional("+", named("build", dotted(oneOrMore(idChar)))), | |
| end, | |
| ); | |
| check( | |
| "32) semver", | |
| /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/, | |
| semver, | |
| ["1.2.3", "1.0.0-alpha.1+build.5", "01.2.3", "1.2", "1.2.3-"], | |
| ); | |
| console.log(" groups:", { | |
| ...semver.exec("1.0.0-alpha.1+build.5")?.groups, | |
| }); | |
| } | |
| export {}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This is great!