From 8c969de28603366e406aa477ed518abc5b6961f2 Mon Sep 17 00:00:00 2001 From: Vladimir Date: Fri, 12 Jan 2024 14:44:27 +0100 Subject: [PATCH] feat: add syntax highlighting to error messages (#4813) --- examples/mocks/package.json | 4 +-- packages/utils/package.json | 3 +- packages/utils/rollup.config.js | 2 ++ packages/utils/src/colors.ts | 17 +++++----- packages/utils/src/highlight.ts | 43 ++++++++++++++++++++++++++ packages/utils/src/index.ts | 1 + packages/utils/src/types.ts | 1 + packages/vitest/src/node/core.ts | 2 ++ packages/vitest/src/node/error.ts | 5 ++- packages/vitest/src/node/hoistMocks.ts | 3 +- packages/vitest/src/node/logger.ts | 32 ++++++++++++++++++- packages/vitest/src/runtime/mocker.ts | 15 ++++----- pnpm-lock.yaml | 28 ++++++++++++++--- 13 files changed, 130 insertions(+), 26 deletions(-) create mode 100644 packages/utils/src/highlight.ts diff --git a/examples/mocks/package.json b/examples/mocks/package.json index 41166db4ee48..4c1e553acb0c 100644 --- a/examples/mocks/package.json +++ b/examples/mocks/package.json @@ -18,11 +18,11 @@ "tinyspy": "^0.3.2" }, "devDependencies": { - "@vitest/ui": "latest", + "@vitest/ui": "workspace:*", "react": "^18.0.0", "sweetalert2": "^11.6.16", "vite": "latest", - "vitest": "latest", + "vitest": "workspace:*", "vue": "^3.3.8", "zustand": "^4.1.1" }, diff --git a/packages/utils/package.json b/packages/utils/package.json index 890a6fd052cb..baa7bb26d971 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -69,6 +69,7 @@ }, "devDependencies": { "@jridgewell/trace-mapping": "^0.3.20", - "@types/estree": "^1.0.5" + "@types/estree": "^1.0.5", + "tinyhighlight": "^0.3.2" } } diff --git a/packages/utils/rollup.config.js b/packages/utils/rollup.config.js index 9c2b1b8e89a3..f2ee595ba3fe 100644 --- a/packages/utils/rollup.config.js +++ b/packages/utils/rollup.config.js @@ -4,6 +4,7 @@ import esbuild from 'rollup-plugin-esbuild' import dts from 'rollup-plugin-dts' import resolve from '@rollup/plugin-node-resolve' import json from '@rollup/plugin-json' +import commonjs from '@rollup/plugin-commonjs' const require = createRequire(import.meta.url) const pkg = require('./package.json') @@ -32,6 +33,7 @@ const plugins = [ esbuild({ target: 'node14', }), + commonjs(), ] export default defineConfig([ diff --git a/packages/utils/src/colors.ts b/packages/utils/src/colors.ts index 4cc3943db166..be258195c2e3 100644 --- a/packages/utils/src/colors.ts +++ b/packages/utils/src/colors.ts @@ -27,16 +27,17 @@ const colorsMap = { bgWhite: ['\x1B[47m', '\x1B[49m'], } as const -type ColorName = keyof typeof colorsMap -type ColorsMethods = { - [Key in ColorName]: { - (input: unknown): string - open: string - close: string - } +export type ColorName = keyof typeof colorsMap +export interface ColorMethod { + (input: unknown): string + open: string + close: string +} +export type ColorsMethods = { + [Key in ColorName]: ColorMethod } -type Colors = ColorsMethods & { +export type Colors = ColorsMethods & { isColorSupported: boolean reset: (input: unknown) => string } diff --git a/packages/utils/src/highlight.ts b/packages/utils/src/highlight.ts new file mode 100644 index 000000000000..78eed266f620 --- /dev/null +++ b/packages/utils/src/highlight.ts @@ -0,0 +1,43 @@ +import { type TokenColors, highlight as baseHighlight } from 'tinyhighlight' +import type { ColorName } from './colors' +import { getColors } from './colors' + +type Colors = Record string> + +function getDefs(c: Colors): TokenColors { + const Invalid = (text: string) => c.white(c.bgRed(c.bold(text))) + return { + Keyword: c.magenta, + IdentifierCapitalized: c.yellow, + Punctuator: c.yellow, + StringLiteral: c.green, + NoSubstitutionTemplate: c.green, + MultiLineComment: c.gray, + SingleLineComment: c.gray, + RegularExpressionLiteral: c.cyan, + NumericLiteral: c.blue, + TemplateHead: text => c.green(text.slice(0, text.length - 2)) + c.cyan(text.slice(-2)), + TemplateTail: text => c.cyan(text.slice(0, 1)) + c.green(text.slice(1)), + TemplateMiddle: text => c.cyan(text.slice(0, 1)) + c.green(text.slice(1, text.length - 2)) + c.cyan(text.slice(-2)), + IdentifierCallable: c.blue, + PrivateIdentifierCallable: text => `#${c.blue(text.slice(1))}`, + Invalid, + + JSXString: c.green, + JSXIdentifier: c.yellow, + JSXInvalid: Invalid, + JSXPunctuator: c.yellow, + } +} + +interface HighlightOptions { + jsx?: boolean + colors?: Colors +} + +export function highlight(code: string, options: HighlightOptions = { jsx: false }) { + return baseHighlight(code, { + jsx: options.jsx, + colors: getDefs(options.colors || getColors()), + }) +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 2af5b11602df..a95046cf4151 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -8,3 +8,4 @@ export * from './constants' export * from './colors' export * from './base' export * from './offset' +export * from './highlight' diff --git a/packages/utils/src/types.ts b/packages/utils/src/types.ts index 828dbc7c4201..19ba9ed27dcb 100644 --- a/packages/utils/src/types.ts +++ b/packages/utils/src/types.ts @@ -44,4 +44,5 @@ export interface ErrorWithDiff extends Error { type?: string frame?: string diff?: string + codeFrame?: string } diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 655a6a563006..ce2e403895f7 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -670,6 +670,7 @@ export class Vitest { const onChange = (id: string) => { id = slash(id) + this.logger.clearHighlightCache(id) updateLastChanged(id) const needsRerun = this.handleFileChanged(id) if (needsRerun.length) @@ -677,6 +678,7 @@ export class Vitest { } const onUnlink = (id: string) => { id = slash(id) + this.logger.clearHighlightCache(id) this.invalidates.add(id) if (this.state.filesMap.has(id)) { diff --git a/packages/vitest/src/node/error.ts b/packages/vitest/src/node/error.ts index 4c8425da4a8a..7b3749df5879 100644 --- a/packages/vitest/src/node/error.ts +++ b/packages/vitest/src/node/error.ts @@ -67,6 +67,8 @@ export async function printError(error: unknown, project: WorkspaceProject | und if (type) printErrorType(type, project.ctx) printErrorMessage(e, logger) + if (e.codeFrame) + logger.error(`${e.codeFrame}\n`) // E.g. AssertionError from assert does not set showDiff but has both actual and expected properties if (e.diff) @@ -80,7 +82,7 @@ export async function printError(error: unknown, project: WorkspaceProject | und printStack(project, stacks, nearest, errorProperties, (s) => { if (showCodeFrame && s === nearest && nearest) { const sourceCode = readFileSync(nearest.file, 'utf-8') - logger.error(generateCodeFrame(sourceCode, 4, s)) + logger.error(generateCodeFrame(sourceCode.length > 100_000 ? sourceCode : logger.highlight(nearest.file, sourceCode), 4, s)) } }) } @@ -123,6 +125,7 @@ const skipErrorProperties = new Set([ 'type', 'showDiff', 'diff', + 'codeFrame', 'actual', 'expected', 'diffOptions', diff --git a/packages/vitest/src/node/hoistMocks.ts b/packages/vitest/src/node/hoistMocks.ts index a64ceb5966d6..1980a630bf01 100644 --- a/packages/vitest/src/node/hoistMocks.ts +++ b/packages/vitest/src/node/hoistMocks.ts @@ -5,6 +5,7 @@ import type { AwaitExpression, CallExpression, Identifier, ImportDeclaration, Va import { findNodeAround } from 'acorn-walk' import type { PluginContext } from 'rollup' import { esmWalker } from '@vitest/utils/ast' +import { highlight } from '@vitest/utils' import { generateCodeFrame } from './error' export type Positioned = T & { @@ -256,7 +257,7 @@ export function hoistMocks(code: string, id: string, parse: PluginContext['parse name: 'SyntaxError', message: _error.message, stack: _error.stack, - frame: generateCodeFrame(code, 4, insideCall.start + 1), + frame: generateCodeFrame(highlight(code), 4, insideCall.start + 1), } throw error } diff --git a/packages/vitest/src/node/logger.ts b/packages/vitest/src/node/logger.ts index 8a3e736f1726..b433b53ed0b5 100644 --- a/packages/vitest/src/node/logger.ts +++ b/packages/vitest/src/node/logger.ts @@ -1,5 +1,7 @@ import { createLogUpdate } from 'log-update' import c from 'picocolors' +import { highlight } from '@vitest/utils' +import { extname } from 'pathe' import { version } from '../../../../package.json' import type { ErrorWithDiff } from '../types' import type { TypeCheckError } from '../typecheck/typechecker' @@ -21,6 +23,14 @@ const ERASE_DOWN = `${ESC}J` const ERASE_SCROLLBACK = `${ESC}3J` const CURSOR_TO_START = `${ESC}1;1H` const CLEAR_SCREEN = '\x1Bc' +const HIGHLIGHT_SUPPORTED_EXTS = new Set(['js', 'ts'].flatMap(lang => [ + `.${lang}`, + `.m${lang}`, + `.c${lang}`, + `.${lang}x`, + `.m${lang}x`, + `.c${lang}x`, +])) export class Logger { outputStream = process.stdout @@ -28,12 +38,13 @@ export class Logger { logUpdate = createLogUpdate(process.stdout) private _clearScreenPending: string | undefined + private _highlights = new Map() constructor( public ctx: Vitest, public console = globalThis.console, ) { - + this._highlights.clear() } log(...args: any[]) { @@ -91,6 +102,25 @@ export class Logger { }) } + clearHighlightCache(filename?: string) { + if (filename) + this._highlights.delete(filename) + else + this._highlights.clear() + } + + highlight(filename: string, source: string) { + if (this._highlights.has(filename)) + return this._highlights.get(filename)! + const ext = extname(filename) + if (!HIGHLIGHT_SUPPORTED_EXTS.has(ext)) + return source + const isJsx = ext.endsWith('x') + const code = highlight(source, { jsx: isJsx, colors: c }) + this._highlights.set(filename, code) + return code + } + printNoTestFound(filters?: string[]) { const config = this.ctx.config const comma = c.dim(', ') diff --git a/packages/vitest/src/runtime/mocker.ts b/packages/vitest/src/runtime/mocker.ts index 7608b76e9806..8864ae97fb3c 100644 --- a/packages/vitest/src/runtime/mocker.ts +++ b/packages/vitest/src/runtime/mocker.ts @@ -1,7 +1,7 @@ import { existsSync, readdirSync } from 'node:fs' import vm from 'node:vm' import { basename, dirname, extname, isAbsolute, join, relative, resolve } from 'pathe' -import { getColors, getType } from '@vitest/utils' +import { getType, highlight } from '@vitest/utils' import { isNodeBuiltin } from 'vite-node/utils' import { distDir } from '../paths' import { getAllMockableProperties } from '../utils/base' @@ -113,9 +113,11 @@ export class VitestMocker { return this.executor.state.filepath || 'global' } - private createError(message: string) { + private createError(message: string, codeFrame?: string) { const Error = this.primitives.Error - return new Error(message) + const error = new Error(message) + Object.assign(error, { codeFrame }) + return error } public getMocks() { @@ -208,18 +210,17 @@ export class VitestMocker { else if (!(prop in target)) { if (this.filterPublicKeys.includes(prop)) return undefined - const c = getColors() throw this.createError( `[vitest] No "${String(prop)}" export is defined on the "${mockpath}" mock. ` + 'Did you forget to return it from "vi.mock"?' - + '\nIf you need to partially mock a module, you can use "importOriginal" helper inside:\n\n' - + `${c.green(`vi.mock("${mockpath}", async (importOriginal) => { + + '\nIf you need to partially mock a module, you can use "importOriginal" helper inside:\n', + highlight(`vi.mock("${mockpath}", async (importOriginal) => { const actual = await importOriginal() return { ...actual, // your mocked methods } -})`)}\n`, +})`), ) } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1191d9c5cc8..cd6205391183 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -295,7 +295,7 @@ importers: version: 0.3.3 devDependencies: '@vitest/ui': - specifier: latest + specifier: workspace:* version: link:../../packages/ui react: specifier: ^18.0.0 @@ -1229,6 +1229,9 @@ importers: '@types/estree': specifier: ^1.0.5 version: 1.0.5 + tinyhighlight: + specifier: ^0.3.2 + version: 0.3.2 packages/vite-node: dependencies: @@ -2317,7 +2320,7 @@ packages: resolution: {integrity: sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w==} engines: {node: '>=6.9.0'} dependencies: - '@babel/highlight': 7.22.13 + '@babel/highlight': 7.23.4 chalk: 2.4.2 /@babel/compat-data@7.23.2: @@ -2710,10 +2713,9 @@ packages: transitivePeerDependencies: - supports-color - /@babel/highlight@7.22.13: - resolution: {integrity: sha512-C/BaXcnnvBCmHTpz/VGZ8jgtE2aYlW4hxDhseJAWZb7gqGM/qtCK6iZUb0TyKFf7BOUsBH7Q7fkRsDRhg1XklQ==} + /@babel/highlight@7.23.4: + resolution: {integrity: sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==} engines: {node: '>=6.9.0'} - requiresBuild: true dependencies: '@babel/helper-validator-identifier': 7.22.20 chalk: 2.4.2 @@ -19136,6 +19138,10 @@ packages: /js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + /js-tokens@8.0.2: + resolution: {integrity: sha512-Olnt+V7xYdvGze9YTbGFZIfQXuGV4R3nQwwl8BrtgaPE/wq8UFpUHWuTNc05saowhSr1ZO6tx+V6RjE9D5YQog==} + dev: true + /js-yaml@3.14.1: resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} hasBin: true @@ -25047,6 +25053,18 @@ packages: resolution: {integrity: sha512-65NKvSuAVDP/n4CqH+a9w2kTlLReS9vhsAP06MWx+/89nMinJyB2icyl58RIcqCmIggpojIGeuJGhjU1aGMBSg==} dev: false + /tinyhighlight@0.3.2: + resolution: {integrity: sha512-PaaMroXMDSO3X+UGsTfbL3MmBkTK4+Yycjg6gDAFXNf6lHvKWjXZcdmylJst7V+HFl6W1FijqLqyB07Ze60PZw==} + engines: {node: '>=14.0.0'} + peerDependencies: + picocolors: ^1.0.0 + peerDependenciesMeta: + picocolors: + optional: true + dependencies: + js-tokens: 8.0.2 + dev: true + /tinypool@0.8.1: resolution: {integrity: sha512-zBTCK0cCgRROxvs9c0CGK838sPkeokNGdQVUUwHAbynHFlmyJYj825f/oRs528HaIJ97lo0pLIlDUzwN+IorWg==} engines: {node: '>=14.0.0'}