fix: avoid lookbehind in application regexes for older WebKit

Application regex lookbehind prevented older WebKit from loading the startup module graph. Replace lookbehind with boundary checks across run/terminal links, SQL aliases, debugger targets and refactoring; preserve Unicode boundaries, UTF-16 offsets and empty-match progress. Add compatibility and regression tests.

Fixes #224
Refs #208
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-29 15:01:01 +08:00
1 джерело 364bb5ef50
коміт d37b96002b
10 files changed
+87 -10

No files matched your search

+33
Переглянути файл
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { findTextOccurrences } from "./occurrences";
describe("text occurrence boundaries", () => {
it("preserves whole-word matching, whitespace normalization and adjacent matches", () => {
const text = "ab + c $b+c b + cd b+c b + c";
expect(findTextOccurrences(text, 0, text.length, "b + c")).toEqual([
{ from: 19, to: 22 },
{ from: 23, to: 28 },
]);
});
it("finds valid occurrences overlapping a rejected suffix match", () => {
const text = "ab + b + b";
expect(findTextOccurrences(text, 0, text.length, "b + b")).toEqual([{ from: 5, to: 10 }]);
});
it("advances through zero-length matches", () => {
expect(findTextOccurrences("ab", 0, 2, "")).toEqual([
{ from: 0, to: 0 },
{ from: 1, to: 1 },
{ from: 2, to: 2 },
]);
});
it("keeps punctuation-led expressions and scope-relative boundaries", () => {
expect(findTextOccurrences("x(a) x(a)", 0, 9, "(a)")).toEqual([
{ from: 1, to: 4 },
{ from: 6, to: 9 },
]);
expect(findTextOccurrences("ab", 1, 2, "b")).toEqual([{ from: 1, to: 2 }]);
});
});
+7 -3
Переглянути файл
@@ -69,9 +69,8 @@ export function expressionPattern(expr: string): string {
if (k > 0 && t !== "\\s+" && tokens[k - 1] !== "\\s+") src += "\\s*";
src += t;
});
const head = WORD.test(norm[0] ?? "") ? "(?<![\\w$])" : "";
const tail = WORD.test(norm[norm.length - 1] ?? "") ? "(?![\\w$])" : "";
return head + src + tail;
return src + tail;
}
/** Spans in `text[from, to)` matching `expr` (absolute offsets), at most `limit`. */
@@ -86,7 +85,12 @@ export function findTextOccurrences(
const scope = text.slice(from, to);
const want = normalizeExpression(expr.trim());
const out: { from: number; to: number }[] = [];
for (const m of scope.matchAll(re)) {
for (let m = re.exec(scope); m; m = re.exec(scope)) {
if (!m[0].length) re.lastIndex = m.index + 1;
if (WORD.test(want[0] ?? "") && m.index > 0 && WORD.test(scope[m.index - 1]!)) {
re.lastIndex = m.index + 1;
continue;
}
if (normalizeExpression(m[0]) !== want) continue;
out.push({ from: from + m.index!, to: from + m.index! + m[0].length });
if (out.length >= limit) break;
+6
Переглянути файл
@@ -231,6 +231,12 @@ describe("localDeclaration", () => {
});
describe("textOccurrences", () => {
it("checks Unicode identifier boundaries without shifting UTF-16 offsets", () => {
const text = "𐐀foo αfoo $foo _foo foo 𐐀 foo foo𐐀";
const hits = textOccurrences(text, "foo", [], { from: -1, to: -1 });
expect(hits).toEqual([21, 28].map((from) => ({ from, to: from + 3 })));
});
it("finds whole-word mentions that are not identifier tokens, outside the declaration", () => {
const text = 'foo(); // foo and food\nlet s = "foo";\nfn foo() {}';
const ids = [0, text.lastIndexOf("foo")].map((from) => ({ from, to: from + 3 }));
+2 -1
Переглянути файл
@@ -360,11 +360,12 @@ export function textOccurrences(
exclude: { from: number; to: number },
): { from: number; to: number }[] {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`(?<![\\p{L}\\p{N}_$])${escaped}(?![\\p{L}\\p{N}_$])`, "gu");
const re = new RegExp(`${escaped}(?![\\p{L}\\p{N}_$])`, "gu");
const ids = new Set(identifiers.map((o) => o.from));
const out: { from: number; to: number }[] = [];
for (let m = re.exec(text); m; m = re.exec(text)) {
const from = m.index;
if (/[\p{L}\p{N}_$]$/u.test(text.slice(Math.max(0, from - 2), from))) continue;
if (ids.has(from) || (from >= exclude.from && from < exclude.to)) continue;
out.push({ from, to: from + name.length });
}
+1 -1
Переглянути файл
@@ -68,7 +68,7 @@ export function quoteIdent(name: string, driver: DbDriver): string {
/** A short alias for `table` not in `taken`: `order_items` → `oi`, `users` → `u`. */
export function aliasFor(table: string, taken: ReadonlySet<string>): string {
const words = table.split(/[_\s.$-]+|(?<=[a-z])(?=[A-Z])/).filter(Boolean);
const words = table.replace(/([a-z])([A-Z])/g, "$1 $2").split(/[_\s.$-]+/).filter(Boolean);
let base =
words
.map((w) => w[0]!)
+10
Переглянути файл
@@ -56,6 +56,16 @@ describe("locating Smart Step Into targets", () => {
expect(locateStepTargets(line, 3, [target(1, "missing()")])).toBeNull();
});
it("rejects identifier suffixes, preserving UTF-16 offsets and nested calls", () => {
const line = "𐐀foo() αfoo() $foo() _foo() foo(foo())";
const from = line.indexOf("foo(foo");
expect(locateStepTargets(line, 0, [target(1, "foo()"), target(2, "foo()")])).toEqual([
{ id: 1, from, to: from + 3 },
{ id: 2, from: from + 4, to: from + 7 },
]);
expect(targetsOnLine("𐐀foo() bar()", 0, [target(1, "foo()"), target(2, "bar()")]).map((t) => t.id)).toEqual([2]);
});
it("keeps only the paused line's calls when the adapter lists the whole function", () => {
const line = " total = add(total, square(i))";
const all = [target(0, "square(i)"), target(1, "add(total, square(i))"), target(2, 'print("total", total)')];
+7 -3
Переглянути файл
@@ -25,7 +25,11 @@ export function calleeName(label: string): string | null {
/** A call of `name`: `name(` not preceded by an identifier character. */
function callPattern(name: string, flags: string): RegExp {
return new RegExp(`(?<![\\p{L}\\p{N}_$])${name.replace(/\$/g, "\\$")}\\s*\\(`, flags);
return new RegExp(`${name.replace(/\$/g, "\\$")}\\s*\\(`, flags);
}
function isCallBoundary(text: string, from: number): boolean {
return !/[\p{L}\p{N}_$]$/u.test(text.slice(Math.max(0, from - 2), from));
}
function identifierEnd(text: string, from: number): number {
@@ -55,7 +59,7 @@ export function locateStepTargets(lineText: string, line: number, targets: StepI
const name = calleeName(t.label);
if (!name) return null;
for (const m of lineText.matchAll(callPattern(name, "gu"))) {
if (used.has(m.index!)) continue;
if (used.has(m.index!) || !isCallBoundary(lineText, m.index!)) continue;
from = m.index!;
to = from + name.length;
break;
@@ -78,7 +82,7 @@ export function targetsOnLine(lineText: string, line: number, targets: StepInTar
if (t.line != null) return t.line === line;
if (t.column != null) return true;
const name = calleeName(t.label);
return !name || callPattern(name, "u").test(lineText);
return !name || [...lineText.matchAll(callPattern(name, "gu"))].some((m) => isCallBoundary(lineText, m.index));
});
return kept.length ? kept : targets;
}
+2 -1
Переглянути файл
@@ -89,8 +89,9 @@ const PATTERNS: Pattern[] = [
},
// Generic `path:line` (Go `file.go:12`, Rust `--> src/main.rs:10`, gcc, eslint…) and `path(line,col)`.
{
re: new RegExp(String.raw`(?<![\w/.~\-])(${PATH})(?::(\d+)(?::(\d+))?|\((\d+)(?:,(\d+))?\))`, "dg"),
re: new RegExp(String.raw`(${PATH})(?::(\d+)(?::(\d+))?|\((\d+)(?:,(\d+))?\))`, "dg"),
build: (m) => {
if (m.index! > 0 && /[\w/.~-]/.test(m.input![m.index! - 1]!)) return null;
const [start, end] = [m.index!, m.index! + m[0].length];
return {
kind: "file",
+2 -1
Переглянути файл
@@ -8,7 +8,7 @@ const URL_RE = /\b(?:https?|file):\/\/[^\s<>"'`]+/g;
// `src/main.rs:10:5`, `./a/b.ts:3`, `/abs/x.py:12`, `foo.go(12,3)`, `Foo.kt:22`.
const FILE_RE =
/(?<![\w/.~-])((?:~|\.{1,2})?\/?(?:[\w.@+-]+\/)*[\w@+-][\w.@+-]*\.[A-Za-z][\w]{0,9})(?::(\d+)(?::(\d+))?|\((\d+)(?:[,:](\d+))?\))/g;
/((?:~|\.{1,2})?\/?(?:[\w.@+-]+\/)*[\w@+-][\w.@+-]*\.[A-Za-z][\w]{0,9})(?::(\d+)(?::(\d+))?|\((\d+)(?:[,:](\d+))?\))/g;
/** Strips trailing punctuation that is almost never part of a URL (keeps balanced parens). */
export function trimUrl(url: string): string {
@@ -42,6 +42,7 @@ export function findTerminalLinks(text: string): TerminalLink[] {
const overlaps = (s: number, e: number) => links.some((l) => s < l.end && e > l.start);
for (const m of text.matchAll(FILE_RE)) {
const start = m.index;
if (start > 0 && /[\w/.~-]/.test(text[start - 1]!)) continue;
const end = start + m[0].length;
if (overlaps(start, end)) continue;
const line = Number(m[2] ?? m[4]);
+17
Переглянути файл
@@ -0,0 +1,17 @@
// @vitest-environment node
import { readdirSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { expect, it } from "vitest";
it("keeps application regexes parseable in pre-16.4 WebKit", () => {
const root = resolve(import.meta.dirname, "../..");
const offenders: string[] = [];
for (const dir of ["src", "public"]) {
for (const file of readdirSync(resolve(root, dir), { recursive: true, encoding: "utf8" })) {
if (!/\.[cm]?[jt]sx?$/.test(file) || /\.test\.[jt]sx?$/.test(file)) continue;
const source = readFileSync(resolve(root, dir, file), "utf8");
if (source.includes("(?<" + "=") || source.includes("(?<" + "!")) offenders.push(`${dir}/${file}`);
}
}
expect(offenders).toEqual([]);
});