diff --git a/packages/compiler/src/backend/llvm/expr-primitives.ts b/packages/compiler/src/backend/llvm/expr-primitives.ts index 7469beb27..64eaf20b7 100644 --- a/packages/compiler/src/backend/llvm/expr-primitives.ts +++ b/packages/compiler/src/backend/llvm/expr-primitives.ts @@ -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": { diff --git a/packages/compiler/src/backend/llvm/expr-records.ts b/packages/compiler/src/backend/llvm/expr-records.ts index 96b1c89b5..6f36429c0 100644 --- a/packages/compiler/src/backend/llvm/expr-records.ts +++ b/packages/compiler/src/backend/llvm/expr-records.ts @@ -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": { diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 645e98514..667ddb7d5 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -77,6 +77,7 @@ export const LIB_FN_SYMS: Record = { "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", diff --git a/packages/compiler/src/backend/llvm/reference-effects.ts b/packages/compiler/src/backend/llvm/reference-effects.ts index a0a75fd0c..c2a9a0afa 100644 --- a/packages/compiler/src/backend/llvm/reference-effects.ts +++ b/packages/compiler/src/backend/llvm/reference-effects.ts @@ -23,6 +23,7 @@ export function preservesRegexInputs(method: string): boolean { switch (method) { case "test": case "exec": + case "execGroups": case "match": case "search": case "matchAll": diff --git a/packages/compiler/src/frontend/lowering/containers/exec-result-uses.ts b/packages/compiler/src/frontend/lowering/containers/exec-result-uses.ts new file mode 100644 index 000000000..cb1978e51 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/containers/exec-result-uses.ts @@ -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(); + 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.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) + ); +} diff --git a/packages/compiler/src/frontend/lowering/containers/string-and-regexp.ts b/packages/compiler/src/frontend/lowering/containers/string-and-regexp.ts index 608c0ed29..fea422068 100644 --- a/packages/compiler/src/frontend/lowering/containers/string-and-regexp.ts +++ b/packages/compiler/src/frontend/lowering/containers/string-and-regexp.ts @@ -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 }], diff --git a/packages/compiler/src/frontend/lowering/containers/string-replacement.ts b/packages/compiler/src/frontend/lowering/containers/string-replacement.ts index b29e0effc..1fdf09ef4 100644 --- a/packages/compiler/src/frontend/lowering/containers/string-replacement.ts +++ b/packages/compiler/src/frontend/lowering/containers/string-replacement.ts @@ -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 { diff --git a/packages/compiler/src/frontend/lowering/identifier-uses.ts b/packages/compiler/src/frontend/lowering/identifier-uses.ts new file mode 100644 index 000000000..8578b6bfc --- /dev/null +++ b/packages/compiler/src/frontend/lowering/identifier-uses.ts @@ -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) ?? []; +} diff --git a/packages/compiler/src/frontend/lowering/split-consumers.ts b/packages/compiler/src/frontend/lowering/split-consumers.ts index 7d8a6d8bd..b5d5e9200 100644 --- a/packages/compiler/src/frontend/lowering/split-consumers.ts +++ b/packages/compiler/src/frontend/lowering/split-consumers.ts @@ -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; } diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 00da9bb92..a88af4920 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -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 diff --git a/packages/compiler/src/ir/intrinsic-signatures.ts b/packages/compiler/src/ir/intrinsic-signatures.ts index f4f3cc2f5..c456a75bd 100644 --- a/packages/compiler/src/ir/intrinsic-signatures.ts +++ b/packages/compiler/src/ir/intrinsic-signatures.ts @@ -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: { diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 734f3230c..bdd7d3f3c 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -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 diff --git a/packages/compiler/src/ir/validate.ts b/packages/compiler/src/ir/validate.ts index 25bf41aa6..211594ec1 100644 --- a/packages/compiler/src/ir/validate.ts +++ b/packages/compiler/src/ir/validate.ts @@ -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; diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4890-regex-literal-sites.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4890-regex-literal-sites.ts.json new file mode 100644 index 000000000..bb9d1cbec --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4890-regex-literal-sites.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4890-regex-literal-sites.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4891-regex-plain-text-patterns.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4891-regex-plain-text-patterns.ts.json new file mode 100644 index 000000000..2e52bbd04 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4891-regex-plain-text-patterns.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4891-regex-plain-text-patterns.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4892-regex-exec-read-captures.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4892-regex-exec-read-captures.ts.json new file mode 100644 index 000000000..a5bcdfdc0 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/4892-regex-exec-read-captures.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/4892-regex-exec-read-captures.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_regex.c b/packages/runtime/src/scr_regex.c index a1458806b..a4b4138bd 100644 --- a/packages/runtime/src/scr_regex.c +++ b/packages/runtime/src/scr_regex.c @@ -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; } diff --git a/packages/runtime/src/scr_regex_native.h b/packages/runtime/src/scr_regex_native.h index 164579c44..4b99b8443 100644 --- a/packages/runtime/src/scr_regex_native.h +++ b/packages/runtime/src/scr_regex_native.h @@ -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) { diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 695dd2c5f..7ba451a32 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -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. */ diff --git a/packages/runtime/src/scr_string.c b/packages/runtime/src/scr_string.c index 99d56d8a7..1aafcfbcd 100644 --- a/packages/runtime/src/scr_string.c +++ b/packages/runtime/src/scr_string.c @@ -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 $ 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 diff --git a/packages/runtime/test/test_regex.c b/packages/runtime/test/test_regex.c index c933e56ba..49078c723 100644 --- a/packages/runtime/test/test_regex.c +++ b/packages/runtime/test/test_regex.c @@ -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)"); diff --git a/tests/corpus/4890-regex-literal-sites.ts b/tests/corpus/4890-regex-literal-sites.ts new file mode 100644 index 000000000..9e14789a9 --- /dev/null +++ b/tests/corpus/4890-regex-literal-sites.ts @@ -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")); diff --git a/tests/corpus/4891-regex-plain-text-patterns.ts b/tests/corpus/4891-regex-plain-text-patterns.ts new file mode 100644 index 000000000..89a36415e --- /dev/null +++ b/tests/corpus/4891-regex-plain-text-patterns.ts @@ -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", "$", "<$&|$`|$'>", "é😀$$"]; + +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, "&").replace(//g, ">").replace(/"/g, """); +console.log(escaped.length, escaped.slice(0, 40), escaped.slice(-20)); +console.log(long.split(/&/).length, long.split(/&/, 7).length); diff --git a/tests/corpus/4892-regex-exec-read-captures.ts b/tests/corpus/4892-regex-exec-read-captures.ts new file mode 100644 index 000000000..319ba3402 --- /dev/null +++ b/tests/corpus/4892-regex-exec-read-captures.ts @@ -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 = /(?\d{4})-(?\d{2})/.exec("on 2026-10"); +if (groups !== null) console.log(groups[1], groups.groups?.year, groups.index);