diff --git a/src/editor/refactor/occurrences.test.ts b/src/editor/refactor/occurrences.test.ts new file mode 100644 index 000000000..2ed1d18e4 --- /dev/null +++ b/src/editor/refactor/occurrences.test.ts @@ -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 }]); + }); +}); diff --git a/src/editor/refactor/occurrences.ts b/src/editor/refactor/occurrences.ts index 54d02bff6..bd200bcf9 100644 --- a/src/editor/refactor/occurrences.ts +++ b/src/editor/refactor/occurrences.ts @@ -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] ?? "") ? "(? 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; diff --git a/src/editor/refactor/safeDelete.test.ts b/src/editor/refactor/safeDelete.test.ts index c66ee85de..a596e7160 100644 --- a/src/editor/refactor/safeDelete.test.ts +++ b/src/editor/refactor/safeDelete.test.ts @@ -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 })); diff --git a/src/editor/refactor/safeDelete.ts b/src/editor/refactor/safeDelete.ts index 3538b0dc0..81b94be0a 100644 --- a/src/editor/refactor/safeDelete.ts +++ b/src/editor/refactor/safeDelete.ts @@ -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(`(? 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 }); } diff --git a/src/features/database/sql/complete.ts b/src/features/database/sql/complete.ts index d52a9ee5f..b566024f7 100644 --- a/src/features/database/sql/complete.ts +++ b/src/features/database/sql/complete.ts @@ -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 { - 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]!) diff --git a/src/features/debugger/__tests__/smartStep.test.ts b/src/features/debugger/__tests__/smartStep.test.ts index 2cab3f4b0..84eb48fd2 100644 --- a/src/features/debugger/__tests__/smartStep.test.ts +++ b/src/features/debugger/__tests__/smartStep.test.ts @@ -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)')]; diff --git a/src/features/debugger/editor/smartStep.ts b/src/features/debugger/editor/smartStep.ts index ec44dfcb7..6eec156e5 100644 --- a/src/features/debugger/editor/smartStep.ts +++ b/src/features/debugger/editor/smartStep.ts @@ -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(`(? isCallBoundary(lineText, m.index)); }); return kept.length ? kept : targets; } diff --git a/src/features/run/links.ts b/src/features/run/links.ts index e299098e9..563118bb2 100644 --- a/src/features/run/links.ts +++ b/src/features/run/links.ts @@ -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`(? { + 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", diff --git a/src/features/terminal/links.ts b/src/features/terminal/links.ts index f6d7e3d08..ca2a4acdc 100644 --- a/src/features/terminal/links.ts +++ b/src/features/terminal/links.ts @@ -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 = - /(? 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]); diff --git a/src/platform/webkitCompatibility.test.ts b/src/platform/webkitCompatibility.test.ts new file mode 100644 index 000000000..0e884f4a1 --- /dev/null +++ b/src/platform/webkitCompatibility.test.ts @@ -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([]); +});