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:
Danila Poyarkov 2026-05-11 12:15:38 +03:00
parent 9e013e60e7
commit a1339e845b
4 changed files with 37 additions and 12 deletions

View file

@ -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",

View file

@ -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",

View file

@ -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} })()`

View file

@ -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 ()')
})