Skip to content

Instantly share code, notes, and snippets.

@LionyxML

LionyxML/rx.ts Secret

Created September 30, 2026 19:57
Show Gist options
  • Select an option

  • Save LionyxML/b6c078a2d1b13cad42666db1773a9cec to your computer and use it in GitHub Desktop.

Select an option

Save LionyxML/b6c078a2d1b13cad42666db1773a9cec to your computer and use it in GitHub Desktop.
RX - readable regular expressions for JavaScript/TypeScript, inspired by Emacs' `rx` macro.
/* =========================================================
* 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 {};
@yowainwright

Copy link
Copy Markdown

This is great!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment