Reuse regex literal sites and scan plain patterns directly (#918)

This commit is contained in:
Chris Tate
2026-10-13 08:17:34 -05:00
committed by GitHub
24 changed files with 790 additions and 199 deletions
@@ -1,7 +1,7 @@
/* Focused LLVM expression emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { matchStringSelfConcat, undefinedArmTag } from "../../ir/analysis.js";
import { isRefCounted } from "../../ir/ir.js";
import { isRefCounted, type IrExpr } from "../../ir/ir.js";
import { mangleRecordClone, mangleRecordNew } from "../mangle.js";
import { arrNewCall, elemAccess } from "./shapes.js";
import { LlvmUnsupportedError } from "./unsupported.js";
@@ -484,6 +484,26 @@ export function emitOperatorExpr(
}
}
/** The immortal template of a regex literal: one static per (pattern,
* flags) pair. Pattern/flags strings intern NOW (the literal table is still
* open — the runtime ABI's regex-literal discipline). */
export function regexLiteralTemplate(
host: LlvmEmitterContext,
e: IrExpr & { kind: "regexLit" },
): string {
const key = `${e.flags}/${e.pattern}`;
let re = host.regexInstances.get(key);
if (!re) {
re = {
sym: `sc_re_${host.regexInstances.size}`,
src: host.internLiteral(e.pattern),
fl: host.internLiteral(e.flags),
};
host.regexInstances.set(key, re);
}
return `@${re.sym}`;
}
export function emitStringExpr(
host: LlvmEmitterContext,
e: ExprOf<
@@ -666,23 +686,12 @@ export function emitStringExpr(
case "strIntrinsic":
return host.emitStrIntrinsic(e);
case "regexLit": {
// One immortal static per (pattern, flags) pair; the +1 retain is
// a no-op on immortals but keeps the owned-temps discipline
// uniform. Pattern/flags strings intern NOW (the literal table is
// still open — the runtime ABI’s regex-literal discipline).
const key = `${e.flags}/${e.pattern}`;
let re = host.regexInstances.get(key);
if (!re) {
re = {
sym: `sc_re_${host.regexInstances.size}`,
src: host.internLiteral(e.pattern),
fl: host.internLiteral(e.flags),
};
host.regexInstances.set(key, re);
}
// Each evaluation is a fresh RegExp over the site's shared template
// (compiled bytecode and matcher); the +1 result is owned.
const template = regexLiteralTemplate(host, e);
host.declare("declare ptr @scr_regex_literal(ptr)");
const value = B.tmp();
B.line(`${value} = call ptr @scr_regex_literal(ptr @${re.sym})`);
B.line(`${value} = call ptr @scr_regex_literal(ptr ${template})`);
return host.own({ name: value, type: e.type });
}
case "templateStrings": {
@@ -5,7 +5,8 @@ import { type IrExpr, type IrType, isRefCounted, type SrcLoc, typeEquals } from
import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlvmEmitterContext, LlValue } from "./expr-context.js";
import { f64Lit, llvmCommentText } from "./common.js";
import { emitBorrowedInputs, emitBorrowedInput } from "./borrowed-inputs.js";
import { borrowableInputs, emitBorrowedInputs, emitBorrowedInput } from "./borrowed-inputs.js";
import { regexLiteralTemplate } from "./expr-primitives.js";
import { preservesRegexInputs } from "./reference-effects.js";
/** Widen one tagged read. The source is borrowed unless the overflow map
@@ -128,6 +129,24 @@ function rebuildRetype(
return result;
}
/** The matching operations whose args[0] is the regex they consume. */
function directRegexOperandMethod(method: string): boolean {
switch (method) {
case "exec":
case "execGroups":
case "match":
case "search":
case "matchAll":
case "matchAllInto":
case "replace":
case "replaceAll":
case "split":
return true;
default:
return false;
}
}
export function emitRegexIntrinsic(
host: LlvmEmitterContext,
e: IrExpr & { kind: "regexIntrinsic" },
@@ -135,11 +154,38 @@ export function emitRegexIntrinsic(
const B = host.B;
const method = e.method;
const inputs = [e.receiver, ...e.args];
const values = preservesRegexInputs(method)
? emitBorrowedInputs(host, inputs)
: method === "matchAllInto"
? inputs.map((a) => emitBorrowedInput(host, a))
: inputs.map((a) => host.emitExpr(a));
// A regex literal consumed directly by a matching operation cannot be
// observed afterwards: nothing else holds the object, so its identity is
// unobservable and its lastIndex is never read again. Such a site runs on
// the literal's immortal template instead of a fresh copy, resetting a
// global or sticky template's lastIndex to the fresh value right before
// the call (after every other operand, which may evaluate the same site).
const regexAt = method === "test" ? 0 : directRegexOperandMethod(method) ? 1 : -1;
const literal = regexAt >= 0 ? inputs[regexAt]! : null;
const template =
literal !== null && literal.kind === "regexLit" ? regexLiteralTemplate(host, literal) : null;
let values: LlValue[];
if (template !== null) {
const borrowed = borrowableInputs(host, inputs);
values = inputs.map((a, index) =>
index === regexAt
? { name: template, type: a.type }
: borrowed[index]
? host.emitReadReceiver(a)
: host.emitExpr(a),
);
if (/[gy]/.test((literal as IrExpr & { kind: "regexLit" }).flags)) {
const field = B.tmp();
B.line(`${field} = getelementptr inbounds %ScrRegex, ptr ${template}, i32 0, i32 4`);
B.line(`store double 0.0, ptr ${field}`);
}
} else {
values = preservesRegexInputs(method)
? emitBorrowedInputs(host, inputs)
: method === "matchAllInto"
? inputs.map((a) => emitBorrowedInput(host, a))
: inputs.map((a) => host.emitExpr(a));
}
const r = values[0]!;
const args = values.slice(1);
const fallible = (sym: string, argText: string): LlValue => {
@@ -190,7 +236,8 @@ export function emitRegexIntrinsic(
return { name: t, type: e.type };
}
case "match":
case "exec": {
case "exec":
case "execGroups": {
// +1 string[] or NULL from the runtime; the `string[] | null`
// union wraps type-directedly, the envGet convention.
if (e.type.kind !== "union")
@@ -200,10 +247,19 @@ export function emitRegexIntrinsic(
const nullTag = def ? def.arms.findIndex((a) => a.kind === "nullT") : -1;
if (arrTag < 0 || nullTag < 0 || !def)
throw new InternalCompilerError("llvm emitter bug: match union lacks its arms");
const symbol = e.method === "exec" ? "scr_regex_exec" : "scr_regex_match";
host.declare(`declare ptr @${symbol}(ptr, ptr)`);
const raw = B.tmp();
B.line(`${raw} = call ptr @${symbol}(ptr ${r.name}, ptr ${args[0]!.name})`);
if (e.method === "execGroups") {
// args[1] is the constant mask of the captures the result's
// readers use (see the IR method's contract).
host.declare(`declare ptr @scr_regex_exec_groups(ptr, ptr, double)`);
B.line(
`${raw} = call ptr @scr_regex_exec_groups(ptr ${r.name}, ptr ${args[0]!.name}, double ${args[1]!.name})`,
);
} else {
const symbol = e.method === "exec" ? "scr_regex_exec" : "scr_regex_match";
host.declare(`declare ptr @${symbol}(ptr, ptr)`);
B.line(`${raw} = call ptr @${symbol}(ptr ${r.name}, ptr ${args[0]!.name})`);
}
return host.wrapNullable(raw, raw, def.arms[arrTag]!, arrTag, e.type, nullTag);
}
case "search": {
@@ -77,6 +77,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"num.parseInt": "scr_parse_int",
"num.parseFloat": "scr_parse_float",
"str.localeCompare": "scr_str_locale_compare",
"str.replaceLiteral": "scr_str_replace_literal",
"num.fromString": "scr_string_to_number",
"math.round": "scr_math_round",
"math.sin": "sin",
@@ -23,6 +23,7 @@ export function preservesRegexInputs(method: string): boolean {
switch (method) {
case "test":
case "exec":
case "execGroups":
case "match":
case "search":
case "matchAll":
@@ -0,0 +1,97 @@
import * as ts from "../../ts7/adapter.js";
import type { Lowerer } from "../lowerer.js";
import { identifiersNamed } from "../identifier-uses.js";
/** The captures an `exec` result is read through, as a bit mask (bit k for
* capture k), when `const m = re.exec(s)` is observed only by null tests
* (`m === null`, `m != null`, ...) and constant-index element reads
* (`m[3]`, `m[3]!`). No other use can tell an unread capture, or the
* result's `index`/`input` properties, from a missing one, so the runtime
* leaves them out. Null when any use has another shape or the binding is
* exported, which keeps the complete result. */
export function execResultCaptureMask(lowerer: Lowerer, call: ts.CallExpression): number | null {
const decl = call.parent;
if (
!ts.isVariableDeclaration(decl) ||
decl.initializer !== call ||
!ts.isIdentifier(decl.name) ||
decl.type !== undefined ||
!ts.isVariableDeclarationList(decl.parent) ||
(decl.parent.flags & ts.NodeFlags.Const) === 0
)
return null;
const statement = decl.parent.parent;
if (
ts.isVariableStatement(statement) &&
statement.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
)
return null;
const symbol = lowerer.resolveValueSymbol(decl.name);
if (!symbol) return null;
const read = new Set<number>();
for (const use of identifiersNamed(lowerer, decl.getSourceFile(), decl.name.text)) {
if (use === decl.name || lowerer.resolveValueSymbol(use) !== symbol) continue;
const ref = outerValue(use);
const parent = ref.parent;
if (ts.isBinaryExpression(parent) && EQUALITY.has(parent.operatorToken.kind)) {
const other = parent.left === ref ? parent.right : parent.left;
if (other.kind === ts.SyntaxKind.NullKeyword) continue;
return null;
}
if (
ts.isElementAccessExpression(parent) &&
parent.expression === ref &&
parent.questionDotToken === undefined &&
ts.isNumericLiteral(parent.argumentExpression) &&
isValueRead(parent)
) {
const index = Number(parent.argumentExpression.text);
if (!Number.isInteger(index) || index < 0 || index >= 53) return null;
read.add(index);
continue;
}
return null;
}
let mask = 0;
for (const index of read) mask += 2 ** index;
return mask;
}
const EQUALITY = new Set<ts.SyntaxKind>([
ts.SyntaxKind.EqualsEqualsEqualsToken,
ts.SyntaxKind.ExclamationEqualsEqualsToken,
ts.SyntaxKind.EqualsEqualsToken,
ts.SyntaxKind.ExclamationEqualsToken,
]);
function outerValue(node: ts.Node): ts.Node {
let ref = node;
while (ts.isParenthesizedExpression(ref.parent) || ts.isNonNullExpression(ref.parent))
ref = ref.parent;
return ref;
}
/** An element read whose value is only consumed: not a write, update,
* delete or destructuring target. */
function isValueRead(access: ts.ElementAccessExpression): boolean {
const ref = outerValue(access);
const parent = ref.parent;
if (
ts.isBinaryExpression(parent) &&
parent.left === ref &&
parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment
)
return false;
return !(
(ts.isPrefixUnaryExpression(parent) &&
(parent.operator === ts.SyntaxKind.PlusPlusToken ||
parent.operator === ts.SyntaxKind.MinusMinusToken)) ||
ts.isPostfixUnaryExpression(parent) ||
ts.isDeleteExpression(parent) ||
ts.isArrayLiteralExpression(parent) ||
ts.isObjectLiteralElementLike(parent) ||
ts.isSpreadElement(parent) ||
((ts.isForOfStatement(parent) || ts.isForInStatement(parent)) && parent.initializer === ref)
);
}
@@ -20,6 +20,7 @@ import type { Lowerer } from "../lowerer.js";
import { own } from "../lowerer.js";
import { isRequireMainFilename } from "../expressions/optional-chains.js";
import { STRING_INDEX_METHODS, STRING_REPLACE_METHODS, STR_METHODS } from "../surfaces.js";
import { execResultCaptureMask } from "./exec-result-uses.js";
import { lowerStringReplacement } from "./string-replacement.js";
import {
coerceStringSearchValue,
@@ -767,17 +768,23 @@ export function lowerRegexMethodCall(
kind: "union",
unionId: lowerer.unions.intern([arrayOf(STRING), { kind: "nullT" }]),
};
// A result read only through null tests and constant indices skips
// the captures and properties no reader uses.
const mask = execResultCaptureMask(lowerer, call);
const execCall = (regex: IrExpr): IrExpr => ({
kind: "regexIntrinsic",
method: mask === null ? "exec" : "execGroups",
receiver: subject,
args: mask === null ? [regex] : [regex, numLit(mask, loc)],
type: resultT,
loc,
});
// A literal receiver has no evaluation to order against the subject.
if (re.kind === "regexLit") return execCall(re);
// The shared match intrinsic takes the string first; preserve exec's
// receiver-before-subject evaluation order before swapping operands.
const saved = lowerer.declareHiddenLocal("%execReceiver", re.type);
const result: IrExpr = {
kind: "regexIntrinsic",
method: "exec",
receiver: subject,
args: [varRef(saved.id, re.type, loc)],
type: resultT,
loc,
};
const result = execCall(varRef(saved.id, re.type, loc));
return {
kind: "seqExpr",
stmts: [{ kind: "varDecl", localId: saved.id, init: re, loc }],
@@ -9,12 +9,12 @@ import {
typeEquals,
typeKey,
type IrExpr,
type IrLocal,
type IrStmt,
type IrType,
type SrcLoc,
} from "../../../ir/ir.js";
import { numLit, strLit, varRef } from "../../../ir/build.js";
import { InternalCompilerError } from "../../../errors.js";
import { locOf } from "../../program.js";
import type { Lowerer } from "../lowerer.js";
import {
@@ -24,7 +24,8 @@ import {
} from "../optional-arguments.js";
/** String search does not interpret regex syntax or capture substitutions.
* The helper receives evaluated arguments and owns its chunks until join.
* A string replacement expands in one runtime pass; a callback runs through
* a helper that receives evaluated arguments and owns its chunks until join.
* Callbacks see the original immutable subject and UTF-16 match offset. */
export function lowerStringReplacement(
lowerer: Lowerer,
@@ -127,13 +128,30 @@ function lowerStringReplacementValues(
replacement: IrExpr,
): IrExpr {
const loc = locOf(call);
const callback = replacement.type.kind === "func" ? replacement.type : null;
if (replacement.type.kind === "string") {
// A replacement template expands in one runtime pass over the subject.
return {
kind: "libCall",
fn: "str.replaceLiteral",
args: [
receiver,
search,
replacement,
{ kind: "boolLit", value: method === "replaceAll", type: BOOL, loc },
],
type: STRING,
loc,
};
}
const callback = replacement.type;
if (callback.kind !== "func") {
throw new InternalCompilerError(`string .${method} replacement of type ${callback.kind}`);
}
const full = [STRING, F64, STRING];
if (
callback &&
(callback.ret.kind !== "string" ||
callback.params.length > full.length ||
!callback.params.every((type, index) => typeEquals(type, full[index]!)))
callback.ret.kind !== "string" ||
callback.params.length > full.length ||
!callback.params.every((type, index) => typeEquals(type, full[index]!))
) {
lowerer.noLowering(
`string .${method} callback signature`,
@@ -156,21 +174,13 @@ function lowerStringReplacementValues(
const chunksType = arrayOf(STRING);
const chunks = varRef("chunks.0", chunksType, loc);
const builder = stringBuilder(loc, chunks);
const value: IrExpr = callback
? {
kind: "callValue",
callee: replace,
args: [needle, match, subject].slice(0, callback.params.length),
type: STRING,
loc,
}
: {
kind: "call",
callee: replacementTemplateHelper(lowerer, loc),
args: [subject, needle, replace, match],
type: STRING,
loc,
};
const value: IrExpr = {
kind: "callValue",
callee: replace,
args: [needle, match, subject].slice(0, callback.params.length),
type: STRING,
loc,
};
const stop: IrStmt = { kind: "break", loc };
const body: IrStmt[] = [
builder.declare("chunks.0", { kind: "arrayLit", elems: [], type: chunksType, loc }),
@@ -251,121 +261,6 @@ function lowerStringReplacementValues(
return { kind: "call", callee: helper, args: [receiver, search, replacement], type: STRING, loc };
}
/** GetSubstitution with no captures: $$, $&, $`, and $' expand. Numeric
* and named capture spellings remain literal. Scan dollar signs rather
* than individual subject characters so ordinary Unicode text is intact. */
function replacementTemplateHelper(lowerer: Lowerer, loc: SrcLoc): string {
const key = "str.replacementTemplate";
const previous = lowerer.valueHelpers.get(key);
if (previous) return previous;
const name = `%str.template.${lowerer.valueHelpers.size}`;
lowerer.valueHelpers.set(key, name);
const subject = varRef("s.0", STRING, loc);
const needle = varRef("search.0", STRING, loc);
const template = varRef("template.0", STRING, loc);
const position = varRef("position.0", F64, loc);
const cursor = varRef("cursor.0", F64, loc);
const dollar = varRef("dollar.0", F64, loc);
const next = varRef("next.0", STRING, loc);
const chunksType = arrayOf(STRING);
const chunks = varRef("chunks.0", chunksType, loc);
const builder = stringBuilder(loc, chunks);
const expansions: [string, IrExpr][] = [
["$", strLit("$", loc)],
["&", needle],
["`", builder.slice(subject, numLit(0, loc), position)],
["'", builder.slice(subject, builder.add(position, builder.length(needle)), undefined)],
];
let selection: IrStmt[] = [builder.append(strLit("$", loc))];
for (let index = expansions.length - 1; index >= 0; index--) {
const [token, expansion] = expansions[index]!;
selection = [
{
kind: "if",
cond: {
kind: "strEq",
negated: false,
left: next,
right: strLit(token, loc),
type: BOOL,
loc,
},
then: [
builder.append(expansion),
builder.assign("cursor.0", builder.add(cursor, numLit(1, loc))),
],
else_: selection,
loc,
},
];
}
const params = [
{ localId: "s.0", name: "s", type: STRING },
{ localId: "search.0", name: "search", type: STRING },
{ localId: "template.0", name: "template", type: STRING },
{ localId: "position.0", name: "position", type: F64 },
];
const locals: IrLocal[] = [
...params.map((param) => ({
id: param.localId,
name: param.name,
type: param.type,
mutable: false,
})),
{ id: "chunks.0", name: "chunks", type: chunksType, mutable: false },
{ id: "cursor.0", name: "cursor", type: F64, mutable: true },
{ id: "dollar.0", name: "dollar", type: F64, mutable: false },
{ id: "next.0", name: "next", type: STRING, mutable: false },
];
lowerer.liftedFns.push({
name,
params,
returnType: STRING,
locals,
body: [
builder.declare("chunks.0", { kind: "arrayLit", elems: [], type: chunksType, loc }),
builder.declare("cursor.0", numLit(0, loc)),
{
kind: "while",
cond: builder.compare("<", cursor, builder.length(template)),
body: [
builder.declare("dollar.0", {
kind: "strIntrinsic",
method: "indexOf",
receiver: template,
args: [strLit("$", loc), cursor],
type: F64,
loc,
}),
{
kind: "if",
cond: builder.compare("<", dollar, numLit(0, loc)),
then: [{ kind: "break", loc }],
else_: null,
loc,
},
builder.append(builder.slice(template, cursor, dollar)),
builder.assign("cursor.0", builder.add(dollar, numLit(1, loc))),
builder.declare("next.0", {
kind: "strIntrinsic",
method: "charAt",
receiver: template,
args: [cursor],
type: STRING,
loc,
}),
...selection,
],
loc,
},
builder.append(builder.slice(template, cursor, undefined)),
{ kind: "return", value: builder.join(), loc },
],
loc,
});
return name;
}
function stringBuilder(loc: SrcLoc, chunks: IrExpr) {
return {
declare(localId: string, init: IrExpr): IrStmt {
@@ -0,0 +1,25 @@
import * as ts from "../ts7/adapter.js";
import type { Lowerer } from "./lowerer.js";
/** Every identifier spelled `text` in `file`, indexed once per file;
* callers resolve only the names of the binding they examine. Type
* positions and shorthand properties are included, so a caller that
* requires every use to have a known shape treats them conservatively. */
export function identifiersNamed(
lowerer: Lowerer,
file: ts.SourceFile,
text: string,
): readonly ts.Identifier[] {
let names = lowerer.splitIdentifierUses.get(file);
if (!names) {
names = new Map();
ts.walkPreorder(file, (node) => {
if (!ts.isIdentifier(node)) return;
const uses = names!.get(node.text);
if (uses) uses.push(node);
else names!.set(node.text, [node]);
});
lowerer.splitIdentifierUses.set(file, names);
}
return names.get(text) ?? [];
}
@@ -2,6 +2,7 @@ import * as ts from "../ts7/adapter.js";
import type { IrExpr } from "../../ir/ir.js";
import type { Lowerer } from "./lowerer.js";
import { stripValueWrappers } from "./binding-analysis.js";
import { identifiersNamed } from "./identifier-uses.js";
/** A fresh split array is dense. A stored result has the same guarantee
* only when this loop is its sole observation: even a const array can be
@@ -44,21 +45,7 @@ export function isPrivateSplitConsumer(
)
return false;
// Index spelling once per file; resolve only this binding's names. Type
// positions and shorthand properties conservatively count as uses too.
const file = decl.getSourceFile();
let names = lowerer.splitIdentifierUses.get(file);
if (!names) {
names = new Map();
ts.walkPreorder(file, (node) => {
if (!ts.isIdentifier(node)) return;
const uses = names!.get(node.text);
if (uses) uses.push(node);
else names!.set(node.text, [node]);
});
lowerer.splitIdentifierUses.set(file, names);
}
for (const use of names.get(name.text) ?? []) {
for (const use of identifiersNamed(lowerer, decl.getSourceFile(), name.text)) {
if (use === decl.name || use === name) continue;
if (lowerer.resolveValueSymbol(use) === symbol) return false;
}
@@ -526,6 +526,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
/** `a.localeCompare(b)`: -1/0/1 in Node's root-locale collation for
* Latin-script text (scr_str_locale_compare). Never throws. */
"str.localeCompare": { argTypes: [STRING, STRING], result: F64 },
"str.replaceLiteral": { argTypes: [STRING, STRING, STRING, BOOL], result: STRING },
/* ToNumber(string) — ECMA-262 7.1.4.1 StringToNumber (scr_string.c):
* trim the JS StrWhiteSpace set, empty/whitespace-only → +0, then the
* whole span must be one StrNumericLiteral — signed decimal (Infinity
@@ -48,6 +48,7 @@ export const REGEX_INTRINSIC_SIGS: Record<
// checks the union's arms.
match: { receiver: STRING, argTypes: [REGEX], result: VOID },
exec: { receiver: STRING, argTypes: [REGEX], result: VOID },
execGroups: { receiver: STRING, argTypes: [REGEX, F64], result: VOID },
lastIndex: { receiver: REGEX, argTypes: [], result: F64 },
matchAll: { receiver: STRING, argTypes: [REGEX], result: arrayOf(arrayOf(STRING)) },
matchAllInto: {
+6
View File
@@ -2183,6 +2183,12 @@ export type IrRegexIntrinsicMethod =
/** exec uses the same string-first operands but keeps the g/y
* lastIndex refusal, independently of global String.match iteration. */
| "exec"
/** exec whose result is read only by null tests and constant-index
* element reads: args[1] is a numeric literal whose bit k marks capture k
* as read (k < 53). Unmarked captures read as undefined and the result
* carries no index/input properties; the match, lastIndex transitions
* and null result are exec's. */
| "execGroups"
| "lastIndex"
/** `s.matchAll(re)` — every match as its honest string[] slice (match's
* rule), drained EAGERLY into a fresh string[][]: the lazy iterator is
+4 -1
View File
@@ -1949,7 +1949,10 @@ function validateFunction(
const want = sig.argTypes[i];
if (want) expectType(a, want, `regexIntrinsic ${e.method} arg ${i}`);
});
if (e.method === "match" || e.method === "exec") {
if (e.method === "execGroups" && e.args[1]?.kind !== "numLit") {
err("regexIntrinsic execGroups mask must be a numeric literal", e.loc);
}
if (e.method === "match" || e.method === "exec" || e.method === "execGroups") {
// The `string[] | null` union (program-dependent id) — checked
// by arms, like the libCall case checks process.envGet.
const def = e.type.kind === "union" ? unions.get(e.type.unionId) : undefined;
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/4890-regex-literal-sites.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/4891-regex-plain-text-patterns.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/4892-regex-exec-read-captures.ts"
],
"diags": []
}
+111 -12
View File
@@ -35,6 +35,19 @@ void *lre_realloc(void *opaque, void *ptr, size_t size) {
return realloc(ptr, size);
}
/* The plain-text form of a pattern rides on its matcher (see
* scr_regex_plain_text): NULL when there is none. */
static inline const ScrStr *scr_regex_lit(const ScrRegex *re) {
const ScrReNative *native = re->native;
return native ? (const ScrStr *)native->lit : NULL;
}
static void scr_regex_native_free(void *native) {
if (!native) return;
free(((ScrReNative *)native)->lit);
scr_re_native_free(native);
}
static SCR_TL ScrRegex **scr_compiled = NULL;
static SCR_TL size_t scr_compiled_len = 0, scr_compiled_cap = 0;
@@ -42,7 +55,7 @@ static void scr_regex_free_bytecodes(void) {
for (size_t i = 0; i < scr_compiled_len; i++) {
lre_realloc(lre_opaque(), scr_compiled[i]->bc, 0);
scr_compiled[i]->bc = NULL;
scr_re_native_free(scr_compiled[i]->native);
scr_regex_native_free(scr_compiled[i]->native);
scr_compiled[i]->native = NULL;
}
free(scr_compiled);
@@ -128,6 +141,54 @@ static char *scr_pattern_cesu8(const ScrStr *src, size_t *plen) {
return out;
}
/* A pattern that matches exactly its own text: printable ASCII with no
* syntax characters (an escaped syntax character or '/' stands for
* itself), no sticky anchoring, and no case folding unless it has no
* letters. Multiline, dotAll, global and unicode do not change what such a
* pattern matches. Every byte match of an ASCII needle in well-formed UTF-8
* is a code-unit match at the same position. Returns an immortal string
* owned by the matcher (freed with it), or NULL. */
static ScrStr *scr_regex_plain_text(const ScrRegex *re) {
const ScrStr *flags = re->flags;
bool fold = false;
for (size_t i = 0; i < flags->len; i++) {
if (flags->data[i] == 'y') return NULL;
if (flags->data[i] == 'i') fold = true;
}
const ScrStr *src = re->source;
if (src->len == 0 || src->len > 256) return NULL;
char text[256];
size_t n = 0;
for (size_t i = 0; i < src->len; i++) {
unsigned char c = (unsigned char)src->data[i];
if (c == '\\') {
if (i + 1 >= src->len) return NULL;
c = (unsigned char)src->data[++i];
if (!strchr("^$\\.*+?()[]{}|/", c) || c == '\0') return NULL;
} else if (c < 0x20 || c >= 0x7F || strchr("^$.*+?()[]{}|", c)) {
return NULL;
}
if (fold && ((c | 0x20) >= 'a' && (c | 0x20) <= 'z')) return NULL;
text[n++] = (char)c;
}
ScrStr *lit = malloc(sizeof(ScrStr) + n + 1);
if (!lit) scr_regex_oom();
memset(lit, 0, sizeof(ScrStr));
lit->rc = SIZE_MAX;
lit->len = n;
lit->cap = (uint32_t)n;
lit->ascii = 1;
memcpy(lit->data, text, n);
lit->data[n] = '\0';
return lit;
}
static void *scr_regex_native_build(const ScrRegex *re, const uint8_t *bc) {
ScrReNative *native = scr_re_native_build(bc);
if (native) native->lit = scr_regex_plain_text(re);
return native;
}
/* The compiled bytecode, compiling (and caching) on first use. */
static uint8_t *scr_regex_bc(ScrRegex *re) {
if (re->bc) return re->bc;
@@ -159,7 +220,7 @@ static uint8_t *scr_regex_bc(ScrRegex *re) {
re->source->data, re->flags->data, error_msg);
}
re->bc = bc;
re->native = scr_re_native_build(bc);
re->native = scr_regex_native_build(re, bc);
scr_note_compiled(re);
return bc;
}
@@ -175,7 +236,7 @@ void scr_regex_release(ScrRegex *re) {
scr_str_release(re->flags);
if (re->bc && !re->literal) {
lre_realloc(lre_opaque(), re->bc, 0);
scr_re_native_free(re->native);
scr_regex_native_free(re->native);
}
free(re);
}
@@ -525,9 +586,11 @@ static int scr_regex_start(ScrRegex *re, int len) {
/* A predicate needs only boundaries; it never materializes result strings. */
bool scr_regex_test(ScrRegex *re, ScrStr *s) {
uint8_t *bc = scr_regex_bc(re);
bool stateful = (lre_get_flags(bc) & (LRE_FLAG_GLOBAL | LRE_FLAG_STICKY)) != 0;
const ScrStr *lit = scr_regex_lit(re);
if (lit && !stateful) return scr_str_find_bytes(s->data, s->len, lit->data, lit->len) != NULL;
ScrRegexInput input;
scr_regex_input_init_cached(&input, re, bc, s);
bool stateful = (lre_get_flags(bc) & (LRE_FLAG_GLOBAL | LRE_FLAG_STICKY)) != 0;
int pos = stateful ? scr_regex_start(re, input.len) : 0;
int rc = pos < 0 ? 0 : scr_exec(&input, bc, pos);
if (stateful) re->last_index = rc == 1
@@ -543,7 +606,7 @@ bool scr_regex_test(ScrRegex *re, ScrStr *s) {
* and sticky regexes start at lastIndex, set it to the match end, and
* reset it on failure. Nonparticipating captures are present undefined
* elements, distinct from participating empty captures and array holes. */
ScrArr *scr_regex_exec(ScrStr *s, ScrRegex *re) {
static ScrArr *scr_regex_exec_core(ScrStr *s, ScrRegex *re, uint64_t mask, bool metadata) {
uint8_t *bc = scr_regex_bc(re);
bool stateful = (lre_get_flags(bc) & (LRE_FLAG_GLOBAL | LRE_FLAG_STICKY)) != 0;
ScrRegexInput input;
@@ -558,13 +621,15 @@ ScrArr *scr_regex_exec(ScrStr *s, ScrRegex *re) {
if (rc == 1) {
int count = lre_get_capture_count(bc);
out = scr_arr_new(SCR_ELEM_STR, (size_t)count);
ScrDyn *metadata = scr_dyn_new_obj();
scr_arr_ext(out)->metadata = metadata;
scr_dyn_obj_set(metadata, "index", 5, scr_dyn_new_num((double)scr_capture_index(&input, capture[0])));
scr_dyn_obj_set(metadata, "input", 5, scr_dyn_new_str(s));
if (metadata) {
ScrDyn *props = scr_dyn_new_obj();
scr_arr_ext(out)->metadata = props;
scr_dyn_obj_set(props, "index", 5, scr_dyn_new_num((double)scr_capture_index(&input, capture[0])));
scr_dyn_obj_set(props, "input", 5, scr_dyn_new_str(s));
}
for (int k = 0; k < count; k++) {
const uint8_t *cs = capture[2 * k], *ce = capture[2 * k + 1];
if (cs == NULL || ce == NULL) {
if (cs == NULL || ce == NULL || (k < 64 && !(mask >> k & 1))) {
scr_arr_set_undefined(out, (double)out->len);
} else {
scr_arr_push_ref(
@@ -576,6 +641,16 @@ ScrArr *scr_regex_exec(ScrStr *s, ScrRegex *re) {
return out; /* +1, or NULL (no match) */
}
ScrArr *scr_regex_exec(ScrStr *s, ScrRegex *re) {
return scr_regex_exec_core(s, re, UINT64_MAX, true);
}
/* Unselected captures are present undefined elements: nothing reads them. */
ScrArr *scr_regex_exec_groups(ScrStr *s, ScrRegex *re, double mask) {
uint64_t bits = mask >= 0 && mask < 18446744073709551616.0 ? (uint64_t)mask : UINT64_MAX;
return scr_regex_exec_core(s, re, bits, false);
}
/* Global Symbol.match always starts at zero and drains whole matches,
* ignoring capture groups. Its terminating failed exec resets lastIndex
* to zero, so no state escapes this internal iteration. Sticky globals
@@ -614,6 +689,19 @@ ScrArr *scr_regex_match(ScrStr *s, ScrRegex *re) {
* bytecode omits the implicit forward scan) — exactly Node. */
double scr_regex_search(ScrStr *s, ScrRegex *re) {
uint8_t *bc = scr_regex_bc(re);
const ScrStr *lit = scr_regex_lit(re);
if (lit) {
const char *hit = scr_str_find_bytes(s->data, s->len, lit->data, lit->len);
if (!hit) return -1;
/* UTF-16 units before the match: one per UTF-8 lead byte, two for an
* astral (4-byte) lead. */
double units = 0;
for (const char *p = s->data; p < hit; p++) {
unsigned char c = (unsigned char)*p;
if ((c & 0xC0) != 0x80) units += c >= 0xF0 ? 2 : 1;
}
return units;
}
ScrRegexInput input;
scr_regex_input_init(&input, re, bc, s);
uint8_t **capture = input.capture;
@@ -786,6 +874,13 @@ static ScrStr *scr_replace_impl(ScrStr *s, ScrRegex *re, ScrStr *rep) {
bool global = (re_flags & LRE_FLAG_GLOBAL) != 0;
bool sticky = (re_flags & LRE_FLAG_STICKY) != 0;
bool unicode = (re_flags & (LRE_FLAG_UNICODE | LRE_FLAG_UNICODE_SETS)) != 0;
const ScrStr *lit = scr_regex_lit(re);
if (lit && !sticky) {
/* A plain-text pattern has no captures, so its substitutions are the
* string-search ones. */
if (global) re->last_index = 0;
return scr_str_replace_bytes(s, lit->data, lit->len, rep, global);
}
int capture_count = lre_get_capture_count(bc);
const char *groupnames = lre_get_groupnames(bc);
ScrRegexInput input;
@@ -978,7 +1073,7 @@ ScrStr *scr_util_strip_vt(const ScrDyn *input) {
source = { SIZE_MAX, sizeof SCR_VT_PATTERN - 1, sizeof SCR_VT_PATTERN - 1, SCR_VT_PATTERN };
static const struct { size_t rc, len, cap; char data[2]; }
flags = { SIZE_MAX, 1, 1, "g" };
static SCR_TL ScrRegex template = { SIZE_MAX, (ScrStr *)&source, (ScrStr *)&flags, NULL, 0, NULL };
static SCR_TL ScrRegex template = { SIZE_MAX, (ScrStr *)&source, (ScrStr *)&flags, NULL, 0, NULL, NULL };
ScrRegex *regex = scr_regex_literal(&template);
ScrStr *empty = scr_str_new("", 0);
ScrStr *result = scr_regex_replace(subject, regex, empty);
@@ -1004,6 +1099,10 @@ ScrStr *scr_regex_replace_all(ScrStr *s, ScrRegex *re, ScrStr *rep) {
ScrArr *scr_regex_split_limit(ScrStr *s, ScrRegex *re, double limit_num) {
uint8_t *bc = scr_regex_bc(re);
/* Without captures or sticky probing, splitting on a plain-text pattern
* is splitting on its text. */
const ScrStr *lit = scr_regex_lit(re);
if (lit) return scr_str_split_limit(s, (ScrStr *)lit, limit_num);
uint32_t limit = scr_to_uint32(limit_num);
if (limit == 0) return scr_arr_new(SCR_ELEM_STR, 0);
int capture_count = lre_get_capture_count(bc);
@@ -1544,7 +1643,7 @@ ScrRegex *scr_regex_new(ScrStr *pattern, ScrStr *flags) {
return NULL;
}
re->bc = bc;
re->native = scr_re_native_build(bc);
re->native = scr_regex_native_build(re, bc);
scr_regex_ctor_remember(re);
return re;
}
+3
View File
@@ -107,6 +107,9 @@ typedef struct ScrReNative {
bool first_bol; /* some path is anchored at position 0 */
int first_byte; /* the only byte that can start a match, or -1 */
uint8_t first[128];
/* scr_regex.c: the ScrStr text of a pattern that matches only itself,
* or NULL. Owned by the matcher; released before scr_re_native_free. */
void *lit;
} ScrReNative;
static uint32_t rn_u16(const uint8_t *p) {
+16
View File
@@ -1336,6 +1336,17 @@ ScrStr *scr_str_trim_end(ScrStr *s);
* U+FFFD — divergence, see above); otherwise splits on every occurrence,
* empty pieces kept. Borrows both; returns a +1 string[] (SCR_ELEM_STR). */
struct ScrArr;
/* String.prototype.replace/replaceAll with a string search value and a
* string replacement: GetSubstitution without captures ($$, $&, $`, $';
* every other '$' spelling stays literal). An empty search value matches
* at 0, or at every UTF-16 position under replaceAll. Borrows; +1. */
ScrStr *scr_str_replace_literal(ScrStr *s, ScrStr *search, ScrStr *rep, bool all);
/* The same over a nonempty well-formed UTF-8 needle (literal regex
* patterns share it). Borrows; +1. */
ScrStr *scr_str_replace_bytes(ScrStr *s, const char *pat, size_t plen, ScrStr *rep, bool all);
/* First occurrence of a nonempty needle in a byte span, or NULL. */
const char *scr_str_find_bytes(const char *hay, size_t hay_len, const char *needle,
size_t needle_len);
struct ScrArr *scr_str_split(ScrStr *s, ScrStr *sep);
struct ScrArr *scr_str_split_limit(ScrStr *s, ScrStr *sep, double limit);
/* Internal dense split traversal. The cursor borrows input strings, returns
@@ -1913,6 +1924,11 @@ ScrArr *scr_regex_match(ScrStr *s, ScrRegex *re);
/* re.exec(s), string-first ABI: match slice with global/sticky lastIndex
* transitions. Borrows both; +1 or NULL. */
ScrArr *scr_regex_exec(ScrStr *s, ScrRegex *re);
/* exec for a result read only through constant indices and null tests:
* bit k of `mask` (k < 53) selects the captures that are materialized; the
* rest, and the index/input properties, are left out. Same lastIndex
* transitions and NULL result as scr_regex_exec. Borrows both; +1 or NULL. */
ScrArr *scr_regex_exec_groups(ScrStr *s, ScrRegex *re, double mask);
/* s.search(re): the first match's UTF-16 index, or -1 — a fresh exec from
* position 0 (Symbol.search never touches lastIndex, so no flag fence:
* /g is irrelevant, /y anchors at 0). Borrows both; never throws. */
+120
View File
@@ -1796,6 +1796,126 @@ ScrArr *scr_str_split(ScrStr *s, ScrStr *sep) {
return scr_str_split_limit(s, sep, 4294967295.0);
}
/* ── replace / replaceAll with a string search value ──────────────────
* One forward scan over the subject bytes. A nonempty needle is well-formed
* UTF-8, so every byte match starts on a character boundary and is exactly
* a UTF-16 match; the substitution spans are byte ranges of the subject. */
/* GetSubstitution with no captures: $$, $&, $` and $' expand. With no
* capture groups and no named groups, $n and $<name> stay literal, as does
* every other '$'. */
static void scr_replace_template(ScrJsonBuf *b, const char *before, size_t before_len,
const char *match, size_t match_len, const char *after,
size_t after_len, const ScrStr *rep) {
size_t i = 0;
while (i < rep->len) {
const char *dollar = memchr(rep->data + i, '$', rep->len - i);
size_t stop = dollar ? (size_t)(dollar - rep->data) : rep->len;
scr_jb_write(b, rep->data + i, stop - i);
if (!dollar) return;
i = stop;
char next = i + 1 < rep->len ? rep->data[i + 1] : '\0';
if (i + 1 < rep->len && (next == '$' || next == '&' || next == '`' || next == '\'')) {
if (next == '$') scr_jb_putc(b, '$');
else if (next == '&') scr_jb_write(b, match, match_len);
else if (next == '`') scr_jb_write(b, before, before_len);
else scr_jb_write(b, after, after_len);
i += 2;
} else {
scr_jb_putc(b, '$');
i += 1;
}
}
}
const char *scr_str_find_bytes(const char *hay, size_t hay_len, const char *needle,
size_t needle_len) {
return scr_byte_find(hay, hay_len, needle, needle_len);
}
ScrStr *scr_str_replace_bytes(ScrStr *s, const char *pat, size_t plen, ScrStr *rep, bool all) {
const char *end = s->data + s->len;
const char *hit = scr_byte_find(s->data, s->len, pat, plen);
if (!hit) return scr_str_retain(s);
if (memchr(rep->data, '$', rep->len) != NULL) {
ScrJsonBuf b;
scr_jb_init(&b);
const char *from = s->data;
while (hit) {
size_t pos = (size_t)(hit - s->data);
scr_jb_write(&b, from, (size_t)(hit - from));
scr_replace_template(&b, s->data, pos, hit, plen, hit + plen, s->len - pos - plen, rep);
from = hit + plen;
if (!all) break;
hit = scr_byte_find(from, (size_t)(end - from), pat, plen);
}
scr_jb_write(&b, from, (size_t)(end - from));
return scr_jb_finish(&b);
}
/* A plain replacement: count, size the result once, then copy. */
size_t count = 1;
if (all) {
for (const char *p = scr_byte_find(hit + plen, (size_t)(end - hit - plen), pat, plen); p;
p = scr_byte_find(p + plen, (size_t)(end - p - plen), pat, plen))
count++;
}
size_t kept = s->len - count * plen;
if (rep->len && count > (SCR_STR_MAX_CAP - kept) / rep->len) scr_oom();
size_t total = kept + count * rep->len;
ScrStr *out = scr_str_alloc_raw(total, total);
char *w = out->data;
const char *from = s->data;
for (size_t k = 0; k < count; k++) {
size_t gap = (size_t)(hit - from);
memcpy(w, from, gap);
w += gap;
memcpy(w, rep->data, rep->len);
w += rep->len;
from = hit + plen;
if (k + 1 < count) hit = scr_byte_find(from, (size_t)(end - from), pat, plen);
}
memcpy(w, from, (size_t)(end - from));
out->data[total] = '\0';
scr_str_hash_forget(out);
if (s->ascii && rep->ascii) scr_str_note_ascii(out);
return out;
}
ScrStr *scr_str_replace_literal(ScrStr *s, ScrStr *search, ScrStr *rep, bool all) {
if (search->len) return scr_str_replace_bytes(s, search->data, search->len, rep, all);
ScrJsonBuf b;
scr_jb_init(&b);
if (!all) {
scr_replace_template(&b, s->data, 0, s->data, 0, s->data, s->len, rep);
scr_jb_write(&b, s->data, s->len);
return scr_jb_finish(&b);
}
/* The empty needle matches at every UTF-16 position, including between
* the halves of a surrogate pair; those spans follow slice's contract. */
size_t len16 = (size_t)scr_str_utf16_len(s);
if (len16 == s->len) {
for (size_t k = 0; k <= s->len; k++) {
scr_replace_template(&b, s->data, k, s->data + k, 0, s->data + k, s->len - k, rep);
if (k < s->len) scr_jb_putc(&b, s->data[k]);
}
return scr_jb_finish(&b);
}
for (size_t k = 0; k <= len16; k++) {
ScrStr *before = scr_str_slice(s, 0, (double)k);
ScrStr *after = scr_str_slice(s, (double)k, (double)len16);
scr_replace_template(&b, before->data, before->len, after->data, 0, after->data, after->len,
rep);
scr_str_release(before);
scr_str_release(after);
if (k < len16) {
ScrStr *unit = scr_str_slice(s, (double)k, (double)(k + 1));
scr_jb_put_str(&b, unit);
scr_str_release(unit);
}
}
return scr_jb_finish(&b);
}
/* StringPad (ECMA-262 22.1.3.16/17): target length in UTF-16 units, the
* filler built from whole repetitions of `fill` plus a truncated prefix.
* A target at or below the length (or an empty fill) returns the receiver
+47
View File
@@ -324,6 +324,51 @@ static void test_capture_capacity(void) {
expect_str(capture, "\xF0\x9F\x98\x80", "capture owns its bytes after subject release");
}
static void test_exec_groups(void) {
/* Only the selected captures are materialized; the rest read undefined. */
ScrStr *s = S("xac");
ScrArr *full = scr_regex_exec(s, &re_opt_group);
ScrArr *lean = scr_regex_exec_groups(s, &re_opt_group, 2.0);
check(full && lean && full->len == lean->len && lean->len == 2, "exec_groups keeps the capture count");
check(lean->present[0] == SCR_ARR_UNDEFINED && lean->present[1] == SCR_ARR_UNDEFINED,
"exec_groups: unselected and nonparticipating captures are undefined");
check(lean->ext == NULL || lean->ext->metadata == NULL, "exec_groups: no index/input properties");
scr_arr_release(full);
scr_arr_release(lean);
ScrStr *ab = S("ab");
lean = scr_regex_exec_groups(ab, &re_group, 2.0);
ScrStr *group = lean ? scr_arr_get_ref(lean, 1) : NULL;
check(group && str_is(group, "b"), "exec_groups: a selected capture keeps its value");
scr_str_release(group);
scr_arr_release(lean);
check(scr_regex_exec_groups(s, &re_group, 1.0) == NULL, "exec_groups: no match is NULL");
scr_str_release(ab);
scr_str_release(s);
}
static void test_string_replace(void) {
/* String search values: one pass, GetSubstitution without captures. */
ScrStr *s = S("a.b.c");
ScrStr *dot = S(".");
ScrStr *empty = S("");
ScrStr *rep = S("<$&|$`|$'|$1|$$>");
ScrStr *plain = S("--");
expect_str(scr_str_replace_literal(s, dot, plain, false), "a--b.c", "string replace: first match");
expect_str(scr_str_replace_literal(s, dot, plain, true), "a--b--c", "string replaceAll: every match");
expect_str(scr_str_replace_literal(s, dot, rep, false), "a<.|a|b.c|$1|$>b.c",
"string replace: substitutions");
expect_str(scr_str_replace_literal(s, empty, plain, true), "--a--.--b--.--c--",
"string replaceAll: empty search at every position");
expect_str(scr_str_replace_literal(s, plain, dot, true), "a.b.c", "string replaceAll: no match");
expect_str(scr_regex_replace(s, &re_dot_g, plain), "a--b--c", "plain-text regex replace");
check(scr_regex_search(s, &re_dot_noglobal) == 1, "plain-text regex search");
scr_str_release(s);
scr_str_release(dot);
scr_str_release(empty);
scr_str_release(rep);
scr_str_release(plain);
}
int main(int argc, char **argv) {
(void)argc;
(void)argv;
@@ -341,6 +386,8 @@ int main(int argc, char **argv) {
test_split();
test_borrowed_results();
test_capture_capacity();
test_exec_groups();
test_string_replace();
#ifdef SCR_RC_AUDIT
check(scr_str_live_count() == 0, "no live heap strings at exit (RC audit)");
+76
View File
@@ -0,0 +1,76 @@
// Regex literals evaluated directly as the regex of test/exec/match/
// matchAll/search/replace/replaceAll/split: each evaluation starts from a
// fresh lastIndex, so global and sticky sites behave the same on every
// pass, including when another operand evaluates the same site first.
export {};
const subjects = ["a1b22c333", "", "xyz", "aaa", "é1😀22漢333"];
for (let pass = 0; pass < 3; pass++) {
const row: string[] = [];
for (const s of subjects) {
row.push(String(/\d+/g.test(s)));
row.push(String(/\d/y.test(s)));
row.push(String(/a/g.exec(s)?.[0]));
row.push(String(/\d+/y.exec(s)?.[0]));
row.push(JSON.stringify(s.match(/\d+/g)));
row.push(JSON.stringify(s.match(/[a-z]/y)));
row.push(String(s.search(/\d/g)));
row.push(String(s.search(/\d/y)));
row.push(s.replace(/\d/g, "#"));
row.push(s.replace(/\d/y, "#"));
row.push(s.replace(/a/, "<$&>"));
row.push(s.replaceAll(/\d+/g, "[$&]"));
row.push(JSON.stringify(s.split(/\d+/)));
row.push(JSON.stringify(s.split(/(\d)/, 3)));
row.push(JSON.stringify([...s.matchAll(/(\d)(\d)?/g)]));
}
console.log(pass, row.join(" | "));
}
// A loop over one global literal site: every evaluation is a new object.
let found = 0;
for (let i = 0; i < 5; i++) if (/b/g.test("abc")) found++;
console.log("found", found);
// Another operand evaluates the same site before the call itself runs.
let depth = 0;
function probe(s: string): string {
if (depth < 3) {
depth++;
console.log("inner", depth, check(s));
}
return s;
}
function check(s: string): boolean {
return /x/g.test(probe(s));
}
console.log("outer", check("xx"));
depth = 0;
function sticky(s: string): string | undefined {
return /x/y.exec(stickyProbe(s))?.[0];
}
function stickyProbe(s: string): string {
if (depth < 2) {
depth++;
console.log("sticky inner", sticky(s));
}
return s;
}
console.log("sticky outer", sticky("xx"));
// Stored literals keep their own identity and lastIndex.
const made: RegExp[] = [];
for (let i = 0; i < 3; i++) made.push(/q/g);
console.log(made[0] === made[1], made[1] === made[2]);
made[0]!.test("qq");
console.log(made[0]!.lastIndex, made[1]!.lastIndex);
const kept = /q/g;
kept.test("qq");
console.log(kept.lastIndex, kept.test("qq"), kept.lastIndex, kept.test("qq"), kept.lastIndex);
console.log(/q/g.lastIndex, /q/g.source, /q/g.flags, String(/q/gi));
// Callback replacements on literal sites.
console.log("a1a2".replace(/a(\d)/g, (m: string, d: string) => `<${m}:${d}>`));
console.log("a1a2".replace(/a/y, () => "Y"));
@@ -0,0 +1,64 @@
// Patterns that match only their own text: substitutions, flags, escapes,
// non-ASCII subjects, split limits and constructed regexes.
export {};
const subjects = ["", "a.b.a", "x$y$$z", "a/b//c", "aaaa", "é.漢.😀.", "...", "a\0.b", "Tab\tq"];
const templates = ["", "-", "$", "$$", "$&", "$`", "$'", "$1", "$0", "$<n>", "<$&|$`|$'>", "é😀$$"];
for (const s of subjects) {
const parts: string[] = [];
for (const t of templates) {
parts.push(s.replace(/\./, t), s.replace(/\./g, t), s.replaceAll(/\./g, t));
}
console.log(JSON.stringify(s), JSON.stringify(parts));
console.log(
s.replace(/\$/g, "D"),
s.replace(/\//g, "|"),
s.replace(/a/g, "A"),
s.replace(/aa/g, "b"),
s.replace(/\//, "!"),
s.replace(/\t/g, "T"),
);
console.log(
/\./.test(s),
/\$\$/.test(s),
/a\.b/.test(s),
s.search(/\./),
s.search(/a/),
s.search(/😀/u),
s.search(/漢/),
);
for (const limit of [undefined, 0, 1, 2, -1, 4294967297, 1.5, NaN]) {
console.log(limit, JSON.stringify(s.split(/\./, limit)), JSON.stringify(s.split(/\.\./, limit)));
}
}
// Flags that do not change a plain pattern, and ones that do.
const text = "1-2\n-3-A-a";
console.log(text.replace(/-/gi, "+"), text.replace(/-/gm, "+"), text.replace(/-/gs, "+"), text.replace(/-/gu, "+"));
console.log(text.replace(/a/gi, "+"), text.replace(/A/g, "+"), text.replace(/-/y, "+"), text.replace(/1/y, "+"));
console.log(text.split(/-/i), text.split(/-/y), text.search(/-/i), /\n/m.test(text), /3/i.test(text));
// Constructed regexes over the same patterns.
const dots = new RegExp("\\.", "g");
console.log("a.b.c".replace(dots, "!"), dots.lastIndex, dots.test("a.b"), dots.lastIndex);
console.log("a.b.c".split(new RegExp("\\.")), "a.b.c".search(new RegExp("b")), new RegExp("x", "g").test("yx"));
// Global test and exec keep lastIndex semantics on stored plain patterns.
const comma = /,/g;
const csv = "a,b,,c";
const ends: number[] = [];
while (comma.test(csv)) ends.push(comma.lastIndex);
console.log(ends.join(" "), comma.lastIndex);
let m: RegExpExecArray | null;
const plain = /,/g;
while ((m = plain.exec("é,😀,x")) !== null) console.log(m[0], m.index, plain.lastIndex);
const once = /b/;
once.lastIndex = 5;
console.log(once.test("abc"), once.lastIndex, "abc".replace(once, "B"), once.lastIndex);
// Long subjects and many matches.
const long = "<&>\"".repeat(2000) + "é".repeat(100) + "&";
const escaped = long.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
console.log(escaped.length, escaped.slice(0, 40), escaped.slice(-20));
console.log(long.split(/&/).length, long.split(/&/, 7).length);
@@ -0,0 +1,59 @@
// exec results read only through null tests and constant indices, beside
// results that are also read in other ways: captures that are read keep
// their values, nonparticipating captures stay undefined, and lastIndex
// moves exactly as with a full result.
export {};
const line = /^(\w+) (\d+)(?: (x))?(?: (y))?$/;
const inputs = ["get 12 x", "put 7", "del 3 x y", "nope", "post 99 y", ""];
for (const input of inputs) {
const m = line.exec(input);
if (m === null) {
console.log("no match", JSON.stringify(input));
continue;
}
console.log(m[1], m[2]!.length, m[3], m[4] ?? "-", m[9]);
}
for (const input of inputs) {
const m = /^(\w+) (\d+)/.exec(input);
if (m !== null) console.log("lit", m[2], m[1]!.toUpperCase());
}
// The same pattern with full results: length, index, input, spreading.
for (const input of inputs) {
const m = line.exec(input);
if (m !== null) console.log(m.length, m.index, m.input, JSON.stringify([...m]), m.join("/"));
}
// A global regex: lastIndex still follows each match.
const word = /([a-z])([a-z]*)/g;
const sentence = "ab cde é fgh";
for (let i = 0; i < 5; i++) {
const m = word.exec(sentence);
console.log(m === null ? "null" : m[1], word.lastIndex);
}
const sticky = /(\d)/y;
for (let i = 0; i < 4; i++) {
const m = sticky.exec("12a3");
console.log(m != null ? m[1] : "none", sticky.lastIndex);
}
// Results that escape keep everything.
function first(s: string): RegExpExecArray | null {
const m = /(\d)(\d)?/.exec(s);
return m;
}
const kept = first("a5");
console.log(kept !== null ? JSON.stringify([kept.index, kept[0], kept[1], kept[2]]) : "null");
const stored: (string | undefined)[] = [];
const all = /(a)|(b)/.exec("b");
if (all !== null) stored.push(all[0], all[1], all[2]);
console.log(JSON.stringify(stored), stored.length);
const late = /(z)(q)?/.exec("zz");
if (late !== null) {
const read = () => late[1] + String(late[2]);
console.log(read());
}
const groups = /(?<year>\d{4})-(?<month>\d{2})/.exec("on 2026-10");
if (groups !== null) console.log(groups[1], groups.groups?.year, groups.index);