From 03ebdc3387b2b184e2767de26234e6ad4aea20c9 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Wed, 16 Jun 2021 16:15:25 -0500 Subject: [PATCH] feat(#455): improve error logging inside of expressions (#476) --- packages/astro/src/compiler/codegen/index.ts | 49 +++++++++--- packages/astro/src/compiler/utils.ts | 84 ++++++++++++++++++++ 2 files changed, 123 insertions(+), 10 deletions(-) diff --git a/packages/astro/src/compiler/codegen/index.ts b/packages/astro/src/compiler/codegen/index.ts index b31fbf4a0..fe0905f8c 100644 --- a/packages/astro/src/compiler/codegen/index.ts +++ b/packages/astro/src/compiler/codegen/index.ts @@ -13,7 +13,7 @@ import _babelGenerator from '@babel/generator'; import babelParser from '@babel/parser'; import { codeFrameColumns } from '@babel/code-frame'; import * as babelTraverse from '@babel/traverse'; -import { error, warn } from '../../logger.js'; +import { error, warn, parseError } from '../../logger.js'; import { fetchContent } from './content.js'; import { isFetchContent } from './utils.js'; import { yellow } from 'kleur/colors'; @@ -21,6 +21,8 @@ import { isComponentTag } from '../utils'; import { renderMarkdown } from '@astrojs/markdown-support'; import { transform } from '../transform/index.js'; import { PRISM_IMPORT } from '../transform/prism.js'; +import { positionAt } from '../utils'; +import { readFileSync } from 'fs'; const traverse: typeof babelTraverse.default = (babelTraverse.default as any).default; @@ -170,14 +172,38 @@ function getComponentWrapper(_name: string, { url, importSpecifier }: ComponentI } /** Evaluate expression (safely) */ -function compileExpressionSafe(raw: string): string { - let { code } = transformSync(raw, { - loader: 'tsx', - jsxFactory: 'h', - jsxFragment: 'Fragment', - charset: 'utf8', - }); - return code; +function compileExpressionSafe(raw: string, { state, compileOptions, location }: { state: CodegenState, compileOptions: CompileOptions, location: { start: number, end: number } }): string|null { + try { + let { code } = transformSync(raw, { + loader: 'tsx', + jsxFactory: 'h', + jsxFragment: 'Fragment', + charset: 'utf8' + }); + return code; + } catch ({ errors }) { + const err = new Error() as any; + const e = errors[0]; + err.filename = state.filename; + const text = readFileSync(state.filename).toString(); + const start = positionAt(location.start, text); + start.line += e.location.line; + start.character += e.location.column + 1; + err.start = { line: start.line, column: start.character }; + + const end = { ...start }; + end.character += e.location.length; + + const frame = codeFrameColumns(text, { + start: { line: start.line, column: start.character }, + end: { line: end.line, column: end.character }, + }) + + err.frame = frame; + err.message = e.text; + parseError(compileOptions.logging, err); + return null; + } } interface CompileResult { @@ -473,8 +499,11 @@ async function compileHtml(enterNode: TemplateNode, state: CodegenState, compile raw += children[nextChildIndex++]; } } + const location = { start: node.start, end: node.end }; // TODO Do we need to compile this now, or should we compile the entire module at the end? - let code = compileExpressionSafe(raw).trim().replace(/\;$/, ''); + let code = compileExpressionSafe(raw, { state, compileOptions, location }); + if (code === null) throw new Error(`Unable to compile expression`); + code = code.trim().replace(/\;$/, ''); if (!FALSY_EXPRESSIONS.has(code)) { if (state.markers.insideMarkdown) { buffers[curr] += `{${code}}`; diff --git a/packages/astro/src/compiler/utils.ts b/packages/astro/src/compiler/utils.ts index acbdf9c96..232f1b747 100644 --- a/packages/astro/src/compiler/utils.ts +++ b/packages/astro/src/compiler/utils.ts @@ -2,3 +2,87 @@ export function isComponentTag(tag: string) { return /^[A-Z]/.test(tag) || /^[a-z]+\./.test(tag); } + +export interface Position { + line: number; + character: number; +} + +/** Clamps a number between min and max */ +export function clamp(num: number, min: number, max: number): number { + return Math.max(min, Math.min(max, num)); +} + +/** + * Get the line and character based on the offset + * @param offset The index of the position + * @param text The text for which the position should be retrived + */ +export function positionAt(offset: number, text: string): Position { + offset = clamp(offset, 0, text.length); + + const lineOffsets = getLineOffsets(text); + let low = 0; + let high = lineOffsets.length; + if (high === 0) { + return { line: 0, character: offset }; + } + + while (low < high) { + const mid = Math.floor((low + high) / 2); + if (lineOffsets[mid] > offset) { + high = mid; + } else { + low = mid + 1; + } + } + + // low is the least x for which the line offset is larger than the current offset + // or array.length if no line offset is larger than the current offset + const line = low - 1; + return { line, character: offset - lineOffsets[line] }; +} + +/** + * Get the offset of the line and character position + * @param position Line and character position + * @param text The text for which the offset should be retrived + */ +export function offsetAt(position: Position, text: string): number { + const lineOffsets = getLineOffsets(text); + + if (position.line >= lineOffsets.length) { + return text.length; + } else if (position.line < 0) { + return 0; + } + + const lineOffset = lineOffsets[position.line]; + const nextLineOffset = position.line + 1 < lineOffsets.length ? lineOffsets[position.line + 1] : text.length; + + return clamp(nextLineOffset, lineOffset, lineOffset + position.character); +} + +/** Get the offset of all lines */ +function getLineOffsets(text: string) { + const lineOffsets = []; + let isLineStart = true; + + for (let i = 0; i < text.length; i++) { + if (isLineStart) { + lineOffsets.push(i); + isLineStart = false; + } + const ch = text.charAt(i); + isLineStart = ch === '\r' || ch === '\n'; + if (ch === '\r' && i + 1 < text.length && text.charAt(i + 1) === '\n') { + i++; + } + } + + if (isLineStart && text.length > 0) { + lineOffsets.push(text.length); + } + + return lineOffsets; +}