diff --git a/bun.lock b/bun.lock index 1cf3d66d6..5c1d138fc 100644 --- a/bun.lock +++ b/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", diff --git a/packages/core/package.json b/packages/core/package.json index b1f29de0a..7bde5820c 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/tools/analyze/eval-wrap.ts b/packages/core/src/tools/analyze/eval-wrap.ts index a511481bc..0ce4d731b 100644 --- a/packages/core/src/tools/analyze/eval-wrap.ts +++ b/packages/core/src/tools/analyze/eval-wrap.ts @@ -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} })()` diff --git a/tests/engine/tools/eval-wrap.test.ts b/tests/engine/tools/eval-wrap.test.ts index c5f273f02..e8138f1b5 100644 --- a/tests/engine/tools/eval-wrap.test.ts +++ b/tests/engine/tools/eval-wrap.test.ts @@ -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 ()') })