/** * Low-level regex char-class primitives, shared by every hand-rolled regex * analysis in the codebase — the interpreter's first-set walker * (`./first-set.ts`), `regex()`'s short-scan fast path, and codegen's scannable * lowering (`../compiler/scannable-run.ts`). Kept dependency-free (no combinator, * no codegen imports) so it lands in even the leanest interpreter bundle. * * Everything here parses a regex STRUCTURE to code-point ranges; nothing encodes * a specific byte meaning ("this is whitespace"). `\d`/`\w` lower to their ASCII * ranges (correct for the default, non-`u` engine); `\s` uses the fixed * `SPACE_RANGES` set (unaffected by the `u` flag). */ /** Single-char escapes whose code point is fixed regardless of context. */ export const CLASS_ESCAPES: Record = { t: 9, n: 10, r: 13, f: 12, v: 11, '0': 0 } /** * `\s`'s code-point set per the spec's `WhiteSpace` + `LineTerminator` * productions — TAB/LF/VT/FF/CR, SPACE, NBSP, and the Unicode `Zs` space * separators. Fixed regardless of the `u` flag, so always safe to lower. */ export const SPACE_RANGES: Array<[number, number]> = [ [9, 13], [32, 32], [160, 160], [5760, 5760], [8192, 8202], [8232, 8232], [8233, 8233], [8239, 8239], [8287, 8287], [12288, 12288], [65279, 65279], ] /** * ASCII code-point ranges for the shorthand classes we lower. `\d`/`\w` are * ASCII-only in the default (non-`u`) engine; `\s` maps to `SPACE_RANGES`. */ export function shorthandRanges(ch: 'd' | 'w' | 's'): Array<[number, number]> { if (ch === 'd') return [[48, 57]] if (ch === 's') return SPACE_RANGES // Order matches the canonical `scannable-run` lowering (underscore last), so // `ScanShape` output and generated code stay byte-identical. Membership is // order-independent (ranges are iterated / the FirstSet union sorts), so the // non-sorted order is purely representational — see PR #16 discussion. return [[48, 57], [65, 90], [97, 122], [95, 95]] } /** `\uXXXX` at `body[i]` → its code point and the index past it, or null. */ export function readUnicodeEscape(body: string, i: number): { cp: number; next: number } | null { if (body[i] !== '\\' || body[i + 1] !== 'u') return null const hex = body.slice(i + 2, i + 6) if (!/^[0-9a-fA-F]{4}$/.test(hex)) return null return { cp: Number.parseInt(hex, 16), next: i + 6 } } /** Regex syntax characters that cannot appear in a LITERAL fragment unescaped. */ const META = new Set('()[]{}*+?|^$.'.split('')) /** * A regex fragment that is a plain run of literal characters → its code points, * or null when the fragment contains any operator (so a caller can never mistake * `a*` for the two-char literal `a*`). An empty fragment returns null, because * every caller wants "at least one char to match". */ export function literalCodePoints(frag: string): number[] | null { const out: number[] = [] let i = 0 while (i < frag.length) { const ch = frag[i]! if (ch === '\\') { const e = frag[i + 1] if (e === undefined) return null out.push(e in CLASS_ESCAPES ? CLASS_ESCAPES[e]! : e.codePointAt(0)!) i += 2 continue } if (META.has(ch)) return null out.push(ch.codePointAt(0)!) i += 1 } return out.length ? out : null } /** * Index of the `]` that CLOSES the class opening at `body[0]`, honouring `\]`, * or −1 if the class is unterminated. A leading `^` is negation, never a member; * every other char up to the first unescaped `]` is. This is JS's own non-`u` * reading, under which `[]` is the empty class — so in `[]]` the FIRST `]` * closes and a literal `]` follows, and this returns 1, not 2. */ function classCloseIndex(body: string): number { let i = 1 if (body[i] === '^') i++ while (i < body.length) { const ch = body[i] if (ch === '\\') { i += 2; continue } if (ch === ']') return i i++ } return -1 } /** * A single-character MATCHER fragment as a (possibly negated) range set: a * bracketed class `[…]`/`[^…]`, a `\d`/`\w`/`\s` shorthand, or one literal char. * Anything wider (a group, a multi-char literal, `.`) returns null. * * "Bracketed class" means the WHOLE fragment is ONE class. Testing only that it * opens with `[` and ends with `]` is a different, weaker question, and * `[ \t\n\r\f]*[\$(]` answers it while being a SEQUENCE — a whitespace run, then * one of `$(`. Read as a single class its members become the garbage union of * everything between the OUTER brackets, whitespace and `*` and `[` included, so * scss's `\+(?=[ \t\n\r\f]*[\$(])` matched a `+` before a space; the shape oracle * caught it at 203 positions of the scss corpus. Every caller here asks "is this * ONE char matcher", so a fragment that is not gets null — declining costs a * lowering or widens a first-set to `any()`, both of which only forgo a fast * path, whereas accepting yields a wrong member set, which is a wrong scan or a * wrong dispatch. */ export function parseClassOperand(body: string): { ranges: Array<[number, number]>; negated: boolean } | null { if (body === '\\d' || body === '\\w' || body === '\\s') { return { ranges: shorthandRanges(body[1] as 'd' | 'w' | 's'), negated: false } } if (body.length >= 2 && body[0] === '[') { // The class must close at the LAST char; anything after it is a second token // (a quantifier, another class, a literal) that this fragment cannot express. if (classCloseIndex(body) !== body.length - 1) return null let inner = body.slice(1, -1) const negated = inner.startsWith('^') if (negated) inner = inner.slice(1) const ranges = parseClassRanges(inner) return ranges ? { ranges, negated } : null } const cps = literalCodePoints(body) if (cps && cps.length === 1) return { ranges: [[cps[0]!, cps[0]!]], negated: false } return null } type ClassAtom = { cp: number } | { set: Array<[number, number]> } /** * Parse a regex char-class body (the chars BETWEEN `[` and `]`, negation `^` * already stripped by the caller) to code-point ranges. `\d`/`\w`/`\s` expand to * their ranges; `\uXXXX` and the fixed single-char escapes resolve to their code * point; any other letter escape (`\D`, `\W`, `\S`, `\b`, …) returns null rather * than being mis-read as a literal letter, so callers fall back to a safe * over-approximation instead of a wrong set. */ export function parseClassRanges(body: string): Array<[number, number]> | null { const ranges: Array<[number, number]> = [] let i = 0 const readAtom = (): ClassAtom | null => { const ch = body[i] if (ch === undefined) return null if (ch === '\\') { const uni = readUnicodeEscape(body, i) if (uni) { i = uni.next return { cp: uni.cp } } const e = body[i + 1] if (e === undefined) return null i += 2 if (e in CLASS_ESCAPES) return { cp: CLASS_ESCAPES[e]! } if (e === 'd' || e === 'w' || e === 's') return { set: shorthandRanges(e) } // Any other letter escape is a class we can't safely lower (\D, \W, \S, …). if ((e >= 'a' && e <= 'z') || (e >= 'A' && e <= 'Z')) return null return { cp: e.codePointAt(0)! } } i += ch.length return { cp: ch.codePointAt(0)! } } while (i < body.length) { const lo = readAtom() if (lo === null) return null if ('set' in lo) { ranges.push(...lo.set) continue } if (body[i] === '-' && body[i + 1] !== undefined && body[i + 1] !== ']') { i += 1 const hi = readAtom() if (hi === null || 'set' in hi) return null ranges.push([lo.cp, hi.cp]) } else { ranges.push([lo.cp, lo.cp]) } } return ranges.length ? ranges : null }