refactor(tools): use acorn AST parser for eval wrapping
Replace hand-rolled regex heuristic with proper acorn JS parsing. The AST correctly identifies ExpressionStatement as the last node regardless of trailing semicolons, comments, or complex syntax. Fixes edge cases like '42;' which the regex wrongly treated as a statement. Co-authored-by: stiff <v.stiff@gmail.com> Co-authored-by: Jais Pedersen <jais@pedersens.net>
This commit is contained in:
parent
9e013e60e7
commit
a1339e845b
1
bun.lock
1
bun.lock
|
|
@ -114,6 +114,7 @@
|
|||
"version": "0.11.8",
|
||||
"dependencies": {
|
||||
"@iconify/utils": "^3.1.0",
|
||||
"acorn": "^8.16.0",
|
||||
"canvaskit-wasm": "^0.40.0",
|
||||
"culori": "^4.0.2",
|
||||
"diff": "^8.0.3",
|
||||
|
|
|
|||
|
|
@ -315,6 +315,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@iconify/utils": "^3.1.0",
|
||||
"acorn": "^8.16.0",
|
||||
"canvaskit-wasm": "^0.40.0",
|
||||
"culori": "^4.0.2",
|
||||
"diff": "^8.0.3",
|
||||
|
|
|
|||
|
|
@ -1,25 +1,36 @@
|
|||
const STATEMENT_START =
|
||||
/^(const |let |var |if |else |for |while |do |switch |try |catch |throw |return |async function |function |class |\{|\}|\/\/|\/\*)/
|
||||
import { type Node, parse } from 'acorn'
|
||||
|
||||
/**
|
||||
* Wrap eval code so the last bare expression is returned (REPL-style).
|
||||
*
|
||||
* - Already starts with `return` -> used verbatim
|
||||
* - Last non-empty line looks like a statement -> wrap everything in async IIFE
|
||||
* - Otherwise -> promote the last expression line to `return (expr)`
|
||||
* Uses acorn to parse the code as a proper JS AST:
|
||||
* - Already starts with `return` -> used verbatim (inside async function body)
|
||||
* - Last statement is an ExpressionStatement -> replace it with `return (expr)`
|
||||
* - Otherwise -> wrap in async IIFE so side-effects still execute
|
||||
*/
|
||||
export function wrapEvalCode(code: string): string {
|
||||
const trimmed = code.trim()
|
||||
if (trimmed.startsWith('return')) return trimmed
|
||||
|
||||
const lines = trimmed.split('\n')
|
||||
let lastIdx = lines.length - 1
|
||||
while (lastIdx > 0 && !lines[lastIdx].trim()) lastIdx--
|
||||
const lastLine = lines[lastIdx].trim()
|
||||
let body: Node[]
|
||||
try {
|
||||
body = parse(trimmed, {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
allowAwaitOutsideFunction: true,
|
||||
allowReturnOutsideFunction: true
|
||||
}).body
|
||||
} catch {
|
||||
return `return (async () => { ${trimmed} })()`
|
||||
}
|
||||
|
||||
if (lastLine && !STATEMENT_START.test(lastLine) && !lastLine.endsWith('}')) {
|
||||
const body = lines.slice(0, lastIdx).join('\n')
|
||||
return body ? `${body}\nreturn (${lastLine})` : `return (${lastLine})`
|
||||
if (body.length === 0) return trimmed
|
||||
|
||||
const last = body[body.length - 1]
|
||||
if (last.type === 'ExpressionStatement') {
|
||||
const before = trimmed.slice(0, last.start)
|
||||
const expr = trimmed.slice(last.start, last.end).replace(/;$/, '')
|
||||
return `${before}return (${expr})`
|
||||
}
|
||||
|
||||
return `return (async () => { ${trimmed} })()`
|
||||
|
|
|
|||
|
|
@ -52,6 +52,18 @@ describe('wrapEvalCode', () => {
|
|||
expect(await run('JSON.stringify({ x: 1 })')).toBe('{"x":1}')
|
||||
})
|
||||
|
||||
test('expression with trailing semicolon is still returned', async () => {
|
||||
expect(await run('42;')).toBe(42)
|
||||
})
|
||||
|
||||
test('method chain result is returned', async () => {
|
||||
expect(await run('const arr = [3,1,2]\narr.sort()')).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test('syntax error falls back to IIFE', () => {
|
||||
expect(wrapEvalCode('{')).toContain('async ()')
|
||||
})
|
||||
|
||||
test('closing brace wraps in IIFE', () => {
|
||||
expect(wrapEvalCode('function f() {}')).toContain('async ()')
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue