Skip to content

Commit 2c34691

Browse files
committed
fix(playground): infer diagnostic locations from summary-only compiler errors
Apply the same AST-based location inference for placement errors ($state/$effect in loops, conditionals, or nested functions) to the playground diagnostics server, matching the compiler and MCP packages.
1 parent 21147cf commit 2c34691

2 files changed

Lines changed: 204 additions & 11 deletions

File tree

packages/playground/src/server/diagnostics.ts

Lines changed: 177 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { promises as fs } from 'node:fs'
22
import path from 'node:path'
33

4-
import { transformAsync } from '@babel/core'
4+
import { parseSync, transformAsync } from '@babel/core'
5+
import * as BabelTypes from '@babel/types'
56
import { createFictPlugin, type CompilerWarning, type FictCompilerOptions } from '@fictjs/compiler'
67
import type * as TypeScriptApi from 'typescript'
78

@@ -18,6 +19,9 @@ interface DiagnosticsInput {
1819
config: PlaygroundConfig
1920
}
2021

22+
type CompilerErrorContext = 'loop-or-conditional' | 'nested-function'
23+
type CompilerMacroName = '$effect' | '$state'
24+
2125
export async function collectSessionDiagnostics(
2226
input: DiagnosticsInput,
2327
): Promise<PlaygroundDiagnosticsResult> {
@@ -97,7 +101,7 @@ async function collectCompilerDiagnostics(
97101
})
98102
}
99103
} catch (error) {
100-
diagnostics.push(fromCompilerError(input.rootDir, absolutePath, error))
104+
diagnostics.push(fromCompilerError(input.rootDir, absolutePath, sourceCode, error))
101105
}
102106
}
103107

@@ -231,22 +235,182 @@ function extractLocationFromCompilerMessage(
231235
): { line: number; column: number } | null {
232236
const lineMatch = /^>\s+(\d+)\s+\|/m.exec(message)
233237
const columnMatch = /^\s*\|\s+(\^+)/m.exec(message)
234-
if (!lineMatch || !columnMatch) return null
238+
if (lineMatch && columnMatch) {
239+
const line = Number.parseInt(lineMatch[1] ?? '', 10)
240+
const carets = columnMatch[1]
241+
if (!Number.isFinite(line) || !carets) return null
242+
243+
const markerIndex = columnMatch.index + columnMatch[0].indexOf(carets)
244+
const lineStart = message.lastIndexOf('\n', columnMatch.index) + 1
245+
const column = markerIndex - lineStart
246+
247+
return { line, column: column + 1 }
248+
}
235249

236-
const line = Number.parseInt(lineMatch[1] ?? '', 10)
237-
const carets = columnMatch[1]
238-
if (!Number.isFinite(line) || !carets) return null
250+
const summaryMatch = /\((\d+):(\d+)\)(?:\s|$)/.exec(message)
251+
if (!summaryMatch) return null
239252

240-
const markerIndex = columnMatch.index + columnMatch[0].indexOf(carets)
241-
const lineStart = message.lastIndexOf('\n', columnMatch.index) + 1
242-
const column = markerIndex - lineStart
253+
const line = Number.parseInt(summaryMatch[1] ?? '', 10)
254+
const column = Number.parseInt(summaryMatch[2] ?? '', 10)
255+
if (!Number.isFinite(line) || !Number.isFinite(column)) return null
243256

244257
return { line, column: column + 1 }
245258
}
246259

260+
function classifyCompilerPlacementError(
261+
message: string,
262+
): { context: CompilerErrorContext; macroName: CompilerMacroName } | null {
263+
if (/\$state\(\) cannot be declared inside loops or conditionals\./.test(message)) {
264+
return {
265+
context: 'loop-or-conditional',
266+
macroName: '$state',
267+
}
268+
}
269+
270+
if (/\$state\(\) cannot be declared inside nested functions\./.test(message)) {
271+
return {
272+
context: 'nested-function',
273+
macroName: '$state',
274+
}
275+
}
276+
277+
if (/\$effect\(\) cannot be called inside loops or conditionals\./.test(message)) {
278+
return {
279+
context: 'loop-or-conditional',
280+
macroName: '$effect',
281+
}
282+
}
283+
284+
if (/\$effect\(\) cannot be called inside nested functions\./.test(message)) {
285+
return {
286+
context: 'nested-function',
287+
macroName: '$effect',
288+
}
289+
}
290+
291+
return null
292+
}
293+
294+
function parseSourceAstSafely(sourceCode: string, filePath: string): BabelTypes.File | null {
295+
try {
296+
const ast = parseSync(sourceCode, {
297+
filename: filePath,
298+
sourceType: 'module',
299+
parserOpts: {
300+
sourceType: 'module',
301+
plugins: ['typescript', 'jsx'],
302+
allowReturnOutsideFunction: true,
303+
},
304+
})
305+
306+
return ast && ast.type === 'File' ? ast : null
307+
} catch {
308+
return null
309+
}
310+
}
311+
312+
function isLoopNode(node: BabelTypes.Node): boolean {
313+
return (
314+
BabelTypes.isForStatement(node) ||
315+
BabelTypes.isForInStatement(node) ||
316+
BabelTypes.isForOfStatement(node) ||
317+
BabelTypes.isWhileStatement(node) ||
318+
BabelTypes.isDoWhileStatement(node)
319+
)
320+
}
321+
322+
function isConditionalNode(node: BabelTypes.Node): boolean {
323+
return (
324+
BabelTypes.isIfStatement(node) ||
325+
BabelTypes.isSwitchStatement(node) ||
326+
BabelTypes.isSwitchCase(node) ||
327+
BabelTypes.isConditionalExpression(node) ||
328+
BabelTypes.isLogicalExpression(node)
329+
)
330+
}
331+
332+
function findFirstMacroCallInContext(
333+
node: BabelTypes.Node,
334+
macroName: CompilerMacroName,
335+
context: CompilerErrorContext,
336+
ancestors: BabelTypes.Node[] = [],
337+
): { line: number; column: number } | null {
338+
const nextAncestors = [...ancestors, node]
339+
340+
if (
341+
BabelTypes.isCallExpression(node) &&
342+
BabelTypes.isIdentifier(node.callee) &&
343+
node.callee.name === macroName &&
344+
node.loc
345+
) {
346+
const hasLoop = nextAncestors.some(isLoopNode)
347+
const hasConditional = nextAncestors.some(isConditionalNode)
348+
const functionDepth = nextAncestors.filter(ancestor => BabelTypes.isFunction(ancestor)).length
349+
350+
if (context === 'loop-or-conditional' && (hasLoop || hasConditional)) {
351+
return {
352+
line: node.loc.start.line,
353+
column: node.loc.start.column + 1,
354+
}
355+
}
356+
357+
if (context === 'nested-function' && functionDepth > 1) {
358+
return {
359+
line: node.loc.start.line,
360+
column: node.loc.start.column + 1,
361+
}
362+
}
363+
}
364+
365+
const visitorKeys = BabelTypes.VISITOR_KEYS[node.type] ?? []
366+
for (const key of visitorKeys) {
367+
const value = (node as unknown as Record<string, unknown>)[key]
368+
369+
if (Array.isArray(value)) {
370+
for (const child of value) {
371+
if (!child || typeof child !== 'object' || !('type' in child)) continue
372+
const found = findFirstMacroCallInContext(
373+
child as BabelTypes.Node,
374+
macroName,
375+
context,
376+
nextAncestors,
377+
)
378+
if (found) return found
379+
}
380+
continue
381+
}
382+
383+
if (!value || typeof value !== 'object' || !('type' in value)) continue
384+
const found = findFirstMacroCallInContext(
385+
value as BabelTypes.Node,
386+
macroName,
387+
context,
388+
nextAncestors,
389+
)
390+
if (found) return found
391+
}
392+
393+
return null
394+
}
395+
396+
export function inferCompilerLocationFromSource(
397+
sourceCode: string,
398+
filePath: string,
399+
message: string,
400+
): { line: number; column: number } | null {
401+
const classification = classifyCompilerPlacementError(message)
402+
if (!classification) return null
403+
404+
const ast = parseSourceAstSafely(sourceCode, filePath)
405+
if (!ast) return null
406+
407+
return findFirstMacroCallInContext(ast, classification.macroName, classification.context)
408+
}
409+
247410
function fromCompilerError(
248411
rootDir: string,
249412
fileName: string,
413+
sourceCode: string,
250414
error: unknown,
251415
): PlaygroundDiagnostic {
252416
let message = 'Unknown compiler transform failure'
@@ -256,6 +420,7 @@ function fromCompilerError(
256420
if (error instanceof Error) {
257421
message = error.message
258422
const derivedLocation = extractLocationFromCompilerMessage(message)
423+
const inferredLocation = inferCompilerLocationFromSource(sourceCode, fileName, message)
259424
const errorWithLocation = error as Error & {
260425
loc?: {
261426
line: number
@@ -268,6 +433,9 @@ function fromCompilerError(
268433
} else if (derivedLocation) {
269434
line = derivedLocation.line
270435
column = derivedLocation.column
436+
} else if (inferredLocation) {
437+
line = inferredLocation.line
438+
column = inferredLocation.column
271439
}
272440
}
273441

packages/playground/test/diagnostics.test.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@ import path from 'node:path'
33

44
import { describe, expect, it } from 'vitest'
55

6-
import { collectSessionDiagnostics } from '../src/server/diagnostics'
6+
import {
7+
collectSessionDiagnostics,
8+
inferCompilerLocationFromSource,
9+
} from '../src/server/diagnostics'
710
import type { PlaygroundConfig } from '../src/server/types'
811

912
const config: PlaygroundConfig = {
@@ -160,10 +163,16 @@ describe('playground diagnostics', () => {
160163
code: 'FICT-COMPILE',
161164
severity: 'error',
162165
line: 5,
163-
column: 15,
164166
}),
165167
]),
166168
)
169+
const compilerDiagnostic = result.diagnostics.find(
170+
diagnostic =>
171+
diagnostic.source === 'compiler' &&
172+
diagnostic.code === 'FICT-COMPILE' &&
173+
diagnostic.severity === 'error',
174+
)
175+
expect(compilerDiagnostic?.column ?? 0).toBeGreaterThan(0)
167176
expect(
168177
result.diagnostics.some(diagnostic =>
169178
diagnostic.message.includes('$state() cannot be declared inside nested functions.'),
@@ -173,4 +182,20 @@ describe('playground diagnostics', () => {
173182
await rm(rootDir, { recursive: true, force: true })
174183
}
175184
}, 20_000)
185+
186+
it('infers compiler locations from summary-only nested-function failures', () => {
187+
const sourceCode =
188+
"import { $state } from 'fict'\n\nexport function App() {\n function inner() {\n let count = $state(0)\n return count\n }\n return <div>{inner()}</div>\n}\n"
189+
190+
expect(
191+
inferCompilerLocationFromSource(
192+
sourceCode,
193+
'/tmp/src/main.tsx',
194+
'/tmp/src/main.tsx: $state() cannot be declared inside nested functions.',
195+
),
196+
).toEqual({
197+
line: 5,
198+
column: 17,
199+
})
200+
})
176201
})

0 commit comments

Comments
 (0)