From 93bc2701de98e74bfa3c6ddbd41b0bbb7f6318ec Mon Sep 17 00:00:00 2001 From: Marvin Hagemeister Date: Sun, 5 Jul 2020 22:14:38 +0200 Subject: [PATCH] Add pretty stack and code-frame to error output (#50) --- package.json | 4 +- src/lib/util.js | 170 ++++++++++++++++++++++++++++++------------------ 2 files changed, 109 insertions(+), 65 deletions(-) diff --git a/package.json b/package.json index 354709f..3c1d188 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "babel-plugin-istanbul": "^6.0.0", "chalk": "^2.3.0", "dlv": "^1.1.3", + "errorstacks": "^1.3.0", "expect": "^24.9.0", "istanbul-instrumenter-loader": "^3.0.1", "jasmine-core": "^3.5.0", @@ -67,7 +68,8 @@ "minimatch": "^3.0.4", "puppeteer": "^4.0.1", "sade": "^1.7.3", - "script-loader": "^0.7.2" + "script-loader": "^0.7.2", + "simple-code-frame": "^1.0.0" }, "peerDependencies": { "webpack": ">=4" diff --git a/src/lib/util.js b/src/lib/util.js index e1d1339..72b5459 100644 --- a/src/lib/util.js +++ b/src/lib/util.js @@ -1,6 +1,8 @@ import fs from 'fs'; import path from 'path'; import chalk from 'chalk'; +import { createCodeFrame } from 'simple-code-frame'; +import { parseStackTrace } from 'errorstacks'; export function moduleDir(name) { let file = require.resolve(name), @@ -36,13 +38,40 @@ export function dedupe(value, index, arr) { return arr.indexOf(value) === index; } -let firstFile; +export function indent(str, level) { + const space = ' '.repeat(level); + return str + .split('\n') + .map((line) => space + line) + .join('\n'); +} + +/** + * Colorize a pre-formatted code frame + * @param {string} str + */ +export function highlightCodeFrame(str) { + return str + .split('\n') + .map((line) => { + if (/^>\s(.*)/.test(line)) { + return line.replace(/^>(.*)/, (_, content) => { + return chalk.bold.redBright('>') + chalk.white(content); + }); + } else if (/^\s+\|\s+\^/.test(line)) { + return line + .replace('|', chalk.dim('|')) + .replace('^', chalk.bold.redBright('^')); + } + return chalk.dim(line); + }) + .join('\n'); +} + export function cleanStack(str, cwd = process.cwd()) { str = str.replace(/^[\s\S]+\n\n([A-Za-z]*Error: )/g, '$1'); - firstFile = null; - - let clean = str.replace( + let stack = str.replace( new RegExp( `( |\\()(https?:\\/\\/localhost:\\d+\\/base\\/|webpack:///|${cwd.replace( /([\\/[\]()*+$!^.,?])/g, @@ -52,71 +81,84 @@ export function cleanStack(str, cwd = process.cwd()) { ), replacer ); - if (firstFile != null) { - let [filename, line, char] = firstFile; - if (line) { - let read; - try { - read = fs.readFileSync(path.resolve(cwd, filename), 'utf8'); - } catch (e) {} - if (read) { - let start = Math.max(0, char - 40); - let startLine = Math.max(0, line - 3); - read = read.split('\n'); - clean = clean.replace(/\n +/g, '\n '); - if (line < read.length) { - clean += - '\n\n' + - chalk.white( - highlight( - outdent( - [ - read.slice(startLine, line).join('\n'), - read[line].substr(start, char + 30), - new Array(char).join('-') + '^', - read.slice(line + 1, line + 4).join('\n'), - ].join('\n'), - ' ', - process.stdout.columns - 10 - ), - line - startLine, - 2 - ) - ); - } - } + + let frames = parseStackTrace(stack); + + // Some frameworks mess with the stack. Use a simple heuristic + // to find the beginning of the proper stack. + let message = stack; + if (frames.length) { + let lines = stack.split('\n'); + let stackStart = lines.indexOf(frames[0].raw); + if (stackStart > 0) { + message = lines + .slice(0, stackStart) + .map((s) => s.trim()) + .join('\n'); } } - return chalk.red(clean); -} -function replacer(str, before, root, filename, position) { - if (firstFile == null) { - let [line, char] = position.match(/\d+/g) || []; - firstFile = [filename, line - 1, char | 0]; + /** + * The nearest location where the user's code triggered the error. + * @type {import('errorstacks').StackFrame} + */ + let nearestFrame; + + stack = frames + .map((frame) => { + // Only show frame for errors in the user's code + if (!nearestFrame && !/node_modules/.test(frame.fileName)) { + nearestFrame = frame; + } + + // Native traces don't have an error location + if (!frame.name || frame.type === 'native') { + return chalk.gray(frame.raw.trim()); + } + + const { + sourceFileName, + column, + fileName, + line, + name, + sourceColumn, + sourceLine, + } = frame; + + const loc = chalk.cyanBright(`${fileName}:${line}:${column}`); + const originalLoc = + sourceFileName !== '' + ? chalk.gray(' <- ') + + chalk.gray(`${sourceFileName}:${sourceLine}:${sourceColumn}`) + : ''; + return chalk.gray(`at ${name} (${loc}${originalLoc})`); + }) + .join('\n'); + + let codeFrame = ''; + if (nearestFrame) { + try { + const { fileName, line, column } = nearestFrame; + if (fileName) { + const content = fs.readFileSync(fileName, 'utf-8'); + codeFrame = createCodeFrame(content, line, column - 1, { + before: 2, + after: 2, + }); + codeFrame = highlightCodeFrame(codeFrame); + codeFrame = indent(codeFrame, 2) + '\n'; + } + } catch (err) { + // eslint-disable-next-line no-console + console.log('INTERNAL WARNING: Failed to read stack frame code: ' + err); + } } - return before + chalk.blue('./' + filename + chalk.dim(position)); -} -function highlight(text, line, count) { - let lines = text.split('\n'); - return ( - chalk.dim(lines.slice(0, line).join('\n')) + - '\n' + - lines.slice(line, line + count).join('\n') + - '\n' + - chalk.dim(lines.slice(line + count).join('\n')) - ); + message = indent(chalk.reset(message), 2); + return `\n${message}\n\n${codeFrame}${indent(stack, 4)}\n`; } -function outdent(str, prefix = '', width = 80) { - str = str.replace(/(^\n+|\n+$)/g, '').replace(/\t/, ' '); - let indents = str.match(/^[ -]+/gm) || []; - let minLength = indents.reduce( - (indent, value) => Math.min(indent, value.length), - indents[0] ? indents[0].length : 0 - ); - str = str.replace(/^[ -]+/gm, (str) => prefix + str.substring(minLength)); - str = str.replace(/^.*$/gm, (str) => str.substring(0, width)); - return str; +function replacer(str, before, root, filename, position) { + return before + './' + filename + position; }