diff --git a/apps/desktop/src/lib/naming/namingStyleTransformers.ts b/apps/desktop/src/lib/naming/namingStyleTransformers.ts index 7756d12a41..e834db97ec 100644 --- a/apps/desktop/src/lib/naming/namingStyleTransformers.ts +++ b/apps/desktop/src/lib/naming/namingStyleTransformers.ts @@ -10,11 +10,44 @@ export const SINGLE_IDENTIFIER_PATTERN = /^[A-Za-z0-9_$-]+$/; const LEADING_SEPARATOR_PATTERN = /^[_$-]+/; const TRAILING_SEPARATOR_PATTERN = /[_$-]+$/; const SEPARATOR_RUN_PATTERN = /[_$-]+/; -// Word boundaries inside an identifier segment: lower→Upper (userName), -// UPPER→Upper+lower (HTTPServer), and digit→letter (user2Name / sha256Hash). -// Letter→digit is deliberately NOT a boundary so digit runs stay attached to -// the preceding word (ipv4, sha256, field1). -const WORD_BOUNDARY_PATTERN = /(?<=[a-z])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])|(?<=[0-9])(?=[A-Za-z])/; + +function isLower(letter: string): boolean { + return letter >= "a" && letter <= "z"; +} + +function isUpper(letter: string): boolean { + return letter >= "A" && letter <= "Z"; +} + +function isDigit(letter: string): boolean { + return letter >= "0" && letter <= "9"; +} + +/** + * Split a separator-free segment on its internal word boundaries: lower→Upper + * (userName), UPPER→Upper+lower (HTTPServer), and digit→letter (user2Name / + * sha256Hash). Letter→digit is deliberately NOT a boundary so digit runs stay + * attached to the preceding word (ipv4, sha256, field1). + * + * Implemented as a character scan instead of a zero-width lookbehind regex + * because lookbehind throws at module-parse time on WebViews older than + * Safari 16.4 and crashed the whole app there (issue #8202 / #6521 class). + */ +function splitSegmentOnWordBoundaries(segment: string): string[] { + const words: string[] = []; + let start = 0; + for (let index = 1; index < segment.length; index++) { + const previous = segment[index - 1]; + const current = segment[index]; + const next = segment[index + 1]; + const boundary = (isLower(previous) && isUpper(current)) || (isUpper(previous) && isUpper(current) && isLower(next)) || (isDigit(previous) && (isLower(current) || isUpper(current))); + if (!boundary) continue; + words.push(segment.slice(start, index)); + start = index; + } + words.push(segment.slice(start)); + return words; +} interface IdentifierParts { leading: string; @@ -30,7 +63,7 @@ function splitIntoWords(core: string): IdentifierParts { const inner = core.slice(leading.length, core.length - trailing.length); const words = inner .split(SEPARATOR_RUN_PATTERN) - .flatMap((segment) => segment.split(WORD_BOUNDARY_PATTERN)) + .flatMap(splitSegmentOnWordBoundaries) .filter((word) => word.length > 0); return { leading, words, trailing }; } diff --git a/apps/desktop/src/lib/ui/__tests__/legacyWebViewRegexCompat.spec.ts b/apps/desktop/src/lib/ui/__tests__/legacyWebViewRegexCompat.spec.ts new file mode 100644 index 0000000000..70b3bb83b9 --- /dev/null +++ b/apps/desktop/src/lib/ui/__tests__/legacyWebViewRegexCompat.spec.ts @@ -0,0 +1,50 @@ +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const SOURCE_EXTENSIONS = new Set([".ts", ".vue"]); +// Workspace sources that ship in the WebView bundle. Named capture groups +// (`(?…)`) are fine — Safari 10.1+ supports them; lookbehind +// (`(?<=…)` / `(? collectSourceFiles(path.join(workspaceRoot, relativeRoot))); + +// The runtime feature probe in legacyWebView.ts constructs its lookbehind +// from a string inside try/catch (module still parses on old WebKit; the +// engine that throws is the one being detected), so it is the sole allowed +// exception. +const LOOKBEHIND_ALLOWLIST = new Set(["apps/desktop/src/lib/ui/legacyWebView.ts"]); + +describe("WebView startup sources stay compatible with old WebKit regex syntax", () => { + it("ships no lookbehind assertions in bundled sources", () => { + expect(bundledSources.length).toBeGreaterThan(1000); + + const offenders = bundledSources + .filter((file) => { + const source = readFileSync(file, "utf8"); + return source.includes("(?<=") || source.includes("(? path.relative(workspaceRoot, file)) + .filter((relative) => !LOOKBEHIND_ALLOWLIST.has(relative)); + expect(offenders).toEqual([]); + }); +}); diff --git a/apps/desktop/src/lib/ui/legacyWebView.ts b/apps/desktop/src/lib/ui/legacyWebView.ts index 7527588019..dcb3f4bc88 100644 --- a/apps/desktop/src/lib/ui/legacyWebView.ts +++ b/apps/desktop/src/lib/ui/legacyWebView.ts @@ -48,9 +48,15 @@ export function missingLegacyWebViewCapabilities(): string[] { * expressions with lookbehind or the named-group shapes Shiki's JavaScript * engine generates from TextMate grammars. Callers use this to pick a regex * engine (e.g. Shiki's Oniguruma WASM engine) that old WebKit can run. + * + * The pattern below is the only permitted lookbehind in bundled sources: it + * is constructed inside a try/catch at call time (not at module parse time), + * so the engine that cannot compile it is exactly the one being detected. + * legacyWebViewRegexCompat.spec.ts allows this file by name. */ export function supportsRegExpLookbehind(): boolean { try { + // Probe compiled from a string so this module still parses on old WebKit. new RegExp("(?<=a)b"); return true; } catch {