diff --git a/apps/desktop/src/contrib/runtime-loader.test.ts b/apps/desktop/src/contrib/runtime-loader.test.ts index ec26123936..fd2b118343 100644 --- a/apps/desktop/src/contrib/runtime-loader.test.ts +++ b/apps/desktop/src/contrib/runtime-loader.test.ts @@ -650,6 +650,85 @@ export default { id: 'quoted-spec', register: () => { globalThis.__captured = do } }) + it('a backtick inside a regex literal does not flip code/string classification (#120208)', async () => { + // The regex backtick must not open a template: the react import after it + // stays code and is rewritten to the shim, not left bare. + const restore = withBlobReroute() + + try { + const id = await loadRuntimePlugin( + "const backtick = /`/\nimport { useState } from 'react'\nconst label = `ok`\nexport default { id: 'repro', register() { void useState; void label; void backtick } }", + 'repro' + ) + + expect(id).toBe('repro') + expect($pluginRecords.get()['repro']).toMatchObject({ status: 'loaded' }) + } finally { + unloadRuntimePlugin('repro') + restore() + } + }) + + it('rewrites imports after the entities htmlReplacer regex (#120208)', async () => { + // entities 6.0.1 encode.js (via the hermes-toolsmith bundle): the + // character class contains a backtick, then SDK/react imports follow. + const restore = withBlobReroute() + + try { + const id = await loadRuntimePlugin( + "var htmlReplacer = /[\\t\\n\\f!-,./:-@[-`{-}\\^@-\\uFFFF]/g;\nimport { host } from '@hermes/plugin-sdk'\nconst label = `ok`\nexport default { id: 'entities-re', register() { void host; void label; void htmlReplacer } }", + 'entities-re' + ) + + expect(id).toBe('entities-re') + expect($pluginRecords.get()['entities-re']).toMatchObject({ status: 'loaded' }) + } finally { + unloadRuntimePlugin('entities-re') + restore() + } + }) + + it('divisions stay code: imports after real division still rewrite (#120208)', async () => { + // The other direction: `/` between two values is a division, not a + // pattern, so the import below must still be seen and rewritten. + const restore = withBlobReroute() + + try { + const id = await loadRuntimePlugin( + "const total = 10, count = 4, earned = 6\nconst half = total / count + earned / 2\nimport { host } from '@hermes/plugin-sdk'\nconst label = `n=${half}`\nexport default { id: 'division', register() { void host; void label } }", + 'division' + ) + + expect(id).toBe('division') + expect($pluginRecords.get()['division']).toMatchObject({ status: 'loaded' }) + } finally { + unloadRuntimePlugin('division') + restore() + } + }) + + it('does not read import syntax inside a regex literal (#120208)', async () => { + // A regex body is not code: `from 'react'` inside it must not be + // rewritten in place (which would corrupt the pattern). + const restore = withBlobReroute() + + try { + ;(globalThis as unknown as { __capturedRe?: unknown }).__capturedRe = undefined + + const id = await loadRuntimePlugin( + "const re = /from 'react'/\nexport default { id: 'regex-spec', register: () => { globalThis.__capturedRe = re.source } }", + 'regex-spec' + ) + + expect(id).toBe('regex-spec') + expect((globalThis as unknown as { __capturedRe?: string }).__capturedRe).toBe("from 'react'") + } finally { + unloadRuntimePlugin('regex-spec') + delete (globalThis as unknown as { __capturedRe?: unknown }).__capturedRe + restore() + } + }) + it('still rewrites a real mapped import', async () => { // The fix must not swing the other way: the SDK import is the load path. const restore = withBlobReroute() diff --git a/apps/desktop/src/contrib/runtime-loader.ts b/apps/desktop/src/contrib/runtime-loader.ts index 3ad0bd719a..1bd2810c7b 100644 --- a/apps/desktop/src/contrib/runtime-loader.ts +++ b/apps/desktop/src/contrib/runtime-loader.ts @@ -75,7 +75,101 @@ const importSpecifierRe = () => /(from\s*|import\s*\(\s*|import\s+)(['"])([^'"]+ * specifier regex is not syntax-aware, so this is what keeps a plugin's own * copy and comments — `const label = 'Copy keys from'`, `// import 'x'` — * from being read as import syntax (rejected as "unsupported import") or - * rewritten in place (a mapped specifier inside a string must stay verbatim). */ + * rewritten in place (a mapped specifier inside a string must stay verbatim). + * Regex literals are excluded too: a quote or backtick inside a pattern + * (#120208) must not open a string/template state. */ + +/** Keywords after which a `/` opens a regex literal, never a division. */ +const regexKeywordRe = /^(?:await|case|delete|do|else|in|instanceof|new|of|return|throw|typeof|void|yield)$/ + +/** True when the `/` at `slash` (already known not to start `//` or `/*`) + * opens a regex literal: the previous significant char cannot end a value. + * Standard division-vs-regex heuristic. */ +function isRegexStart(source: string, slash: number): boolean { + let j = slash - 1 + + while (j >= 0 && /\s/.test(source[j])) { + j -= 1 + } + + if (j < 0) { + return true + } + + const prev = source[j] + + // Postfix `++`/`--` ends a value (division); a lone `+`/`-` cannot. + if (prev === '+' || prev === '-') { + return source[j - 1] !== prev + } + + // Identifier, number, string/template end, `)` or `]` end a value. + if (prev === ')' || prev === ']' || prev === "'" || prev === '"' || prev === '`') { + return false + } + + // Block-end `}` resolves toward regex — `} /re/` (statement-start + // pattern) is real code, `} / 2` (dividing a block) is not. Revisit if a + // plugin ever divides a block result. + if (prev === '}') { + return true + } + + if (/[A-Za-z0-9_$]/.test(prev)) { + let k = j + + while (k >= 0 && /[A-Za-z0-9_$]/.test(source[k])) { + k -= 1 + } + + // `x.return / 2` divides a property, it is not `return /re/`. + if (source[k] === '.') { + return false + } + + return regexKeywordRe.test(source.slice(k + 1, j + 1)) + } + + return true +} + +/** End offset (exclusive) of the regex literal opened at `slash`, or -1 when + * the pattern never closes on this line (so the `/` was a division). + * Escapes and `[...]` classes are honored so a quote or backtick inside the + * pattern (#120208) cannot leak into the surrounding lex. */ +function regexEnd(source: string, slash: number): number { + let j = slash + 1 + let inClass = false + + while (j < source.length) { + const c = source[j] + + if (c === '\\') { + j += 2 + } else if (c === '\n') { + return -1 + } else if (c === '[') { + inClass = true + j += 1 + } else if (c === ']') { + inClass = false + j += 1 + } else if (c === '/' && !inClass) { + j += 1 + + while (j < source.length && /[A-Za-z]/.test(source[j])) { + j += 1 + } + + return j + } else { + j += 1 + } + } + + return -1 +} + function codeRanges(source: string): Array<[number, number]> { const ranges: Array<[number, number]> = [] const stack: Array<'expr' | 'template'> = [] @@ -115,6 +209,21 @@ function codeRanges(source: string): Array<[number, number]> { stack.push('template') state = 'template' i += 1 + } else if (ch === '/') { + // A lone `/` (not `//` or `/*`, handled above) opens a regex literal + // when the previous significant token cannot end a value (#120208). + // Otherwise it is a division and stays plain code. + const end = isRegexStart(source, i) ? regexEnd(source, i) : -1 + + if (end > 0) { + // The pattern is not code: import-looking text inside it must + // neither match nor be rewritten in place. + closeCode(i) + i = end + codeStart = i + } else { + i += 1 + } } else if (ch === '}' && stack[stack.length - 1] === 'expr') { closeCode(i) stack.pop()