fix(desktop): regex-literal state in runtime-loader codeRanges (#120208)
codeRanges() had only comment/string/template states: a `/` in code was checked for `//` and `/*` and otherwise treated as plain code, so the body of a regex literal was lexed as code. A backtick inside a pattern (the entities htmlReplacer character class, or a simple /`/) then pushed 'template' onto the stack and flipped template parity — every import after it was classified as string text and left un-rewritten, so the plugin failed with "Failed to resolve module specifier 'react'". Regression from #117610. Adopts open PR #120302 (credit: that author): a `/` that cannot follow a value opens a regex-literal region — isRegexStart() is the standard division-vs-regex heuristic (keyword/charclass/paren awareness, property division stays code), regexEnd() honors escapes and [...] classes and refuses to cross a newline, so a division that never closes on its line stays plain code and its imports still rewrite. The pattern itself is excluded from code ranges: import-looking text inside it is neither matched nor rewritten in place. Regression coverage: backtick-in-regex + import after it; the entities htmlReplacer line; divisions stay code (imports after real division still rewrite); `from 'react'` inside a pattern is never rewritten.
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user