From 46dc9caea2074e736017cda245227df991f5ae50 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Wed, 20 Nov 2024 10:31:32 +0530 Subject: [PATCH] feat: add console printer --- example/console.ts | 10 +++ example/{index.ts => web.ts} | 0 src/component.ts | 9 ++- src/helpers.ts | 77 ++++++++++++++++++++++ src/templates.ts | 42 ++++++++++++ src/templates/error_cause/main.ts | 31 +++++++++ src/templates/error_info/main.ts | 30 +++++++++ src/templates/error_metadata/main.ts | 10 ++- src/templates/error_stack/main.ts | 84 +++++++++++++++++++----- src/templates/error_stack_source/main.ts | 57 ++++++++++++++++ src/templates/header/main.ts | 11 ++++ src/templates/layout/main.ts | 11 ++++ src/youch.ts | 12 ++++ 13 files changed, 366 insertions(+), 18 deletions(-) create mode 100644 example/console.ts rename example/{index.ts => web.ts} (100%) create mode 100644 src/helpers.ts diff --git a/example/console.ts b/example/console.ts new file mode 100644 index 0000000..99b9ae8 --- /dev/null +++ b/example/console.ts @@ -0,0 +1,10 @@ +import { run as axios } from './axios.js' +import { Youch } from '../src/youch.js' + +try { + await axios() +} catch (error) { + const youch = new Youch() + const output = await youch.print(error) + console.log(output) +} diff --git a/example/index.ts b/example/web.ts similarity index 100% rename from example/index.ts rename to example/web.ts diff --git a/src/component.ts b/src/component.ts index 5a62f8c..c99571a 100644 --- a/src/component.ts +++ b/src/component.ts @@ -80,8 +80,13 @@ export abstract class BaseComponent { } /** - * The extending class must implement the render method to - * return the HTML fragment + * The render method is used to output the HTML for the + * web view */ abstract render(props: Props): Promise + + /** + * The print method is used to output the text for the console + */ + abstract print(props: Props): Promise } diff --git a/src/helpers.ts b/src/helpers.ts new file mode 100644 index 0000000..6c03cc2 --- /dev/null +++ b/src/helpers.ts @@ -0,0 +1,77 @@ +/* + * youch + * + * (c) Poppinss + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import useColors from '@poppinss/colors' +import { Colors } from '@poppinss/colors/types' + +const ANSI_REGEX = new RegExp( + [ + `[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?(?:\\u0007|\\u001B\\u005C|\\u009C))`, + '(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))', + ].join('|'), + 'g' +) + +/** + * HTML escape string values so that they can be nested + * inside the pre-tags. + */ +export function htmlEscape(value: string): string { + return value + .replace(/&/g, '&') + .replace(/\\"/g, '\"') + .replace(//g, '>') +} + +/** + * Wraps a string value to be on multiple lines after + * a certain characters limit has been hit. + */ +export function wordWrap( + value: string, + options: { + width: number + indent: string + newLine: string + escape?: (value: string) => string + } +) { + const width = options.width + const indent = options.indent + const newLine = `${options.newLine}${indent}` + + let regexString = '.{1,' + width + '}' + regexString += '([\\s\u200B]+|$)|[^\\s\u200B]+?([\\s\u200B]+|$)' + + const re = new RegExp(regexString, 'g') + const lines = value.match(re) || [] + const result = lines + .map(function (line) { + if (line.slice(-1) === '\n') { + line = line.slice(0, line.length - 1) + } + return options.escape ? options.escape(line) : htmlEscape(line) + }) + .join(newLine) + + return result +} + +/** + * Strips ANSI sequences from the string + */ +export function stripAnsi(value: string) { + return value.replace(ANSI_REGEX, '') +} + +/** + * ANSI coloring library + */ +export const colors: Colors = useColors.ansi() diff --git a/src/templates.ts b/src/templates.ts index 475ff6c..bb9d7f2 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -132,6 +132,20 @@ export class Templates { return component.render(props) } + /** + * Print a known template by its name + */ + async #printTmpl( + templateName: K, + props: YouchTemplates[K]['$props'] + ): Promise { + const component: BaseComponent = this.#knownTemplates[templateName] + if (!component) { + throw new Error(`Invalid template "${templateName}"`) + } + return component.print(props) + } + /** * Define a custom component to be used in place of the default component. * Overriding components allows you control the HTML layout, styles and @@ -192,4 +206,32 @@ export class Templates { const { scripts, styles } = this.#getStylesAndScripts(props.cspNonce) return html.replace('', styles).replace('', scripts) } + + /** + * Returns the ANSI output to be printed on the terminal + */ + async print(props: { title: string; error: ParsedError; metadata: Metadata }) { + const ansiOutput = await this.#printTmpl('layout', { + title: props.title, + children: async () => { + const header = await this.#printTmpl('header', {}) + const info = await this.#printTmpl('errorInfo', props) + const stackTrace = await this.#printTmpl('errorStack', { + ide: process.env.EDITOR ?? 'vscode', + sourceCodeRenderer: (error, frame) => { + return this.#printTmpl('errorStackSource', { + error, + frame, + }) + }, + ...props, + }) + const cause = await this.#printTmpl('errorCause', props) + const metadata = await this.#printTmpl('errorMetadata', props) + return `${header}${info}${stackTrace}${cause}${metadata}` + }, + }) + + return ansiOutput + } } diff --git a/src/templates/error_cause/main.ts b/src/templates/error_cause/main.ts index a340b32..ff2ec40 100644 --- a/src/templates/error_cause/main.ts +++ b/src/templates/error_cause/main.ts @@ -8,7 +8,9 @@ */ import { dump, themes } from '@poppinss/dumper/html' +import { dump as dumpCli } from '@poppinss/dumper/console' +import { colors } from '../../helpers.js' import { BaseComponent } from '../../component.js' import { publicDirURL } from '../../public_dir.js' import type { ErrorCauseProps } from '../../types.js' @@ -19,6 +21,10 @@ import type { ErrorCauseProps } from '../../types.js' export class ErrorCause extends BaseComponent { cssFile = new URL('./error_cause/style.css', publicDirURL) + /** + * The render method is used to output the HTML for the + * web view + */ async render(props: ErrorCause['$props']): Promise { if (!props.error.cause) { return '' @@ -44,4 +50,29 @@ export class ErrorCause extends BaseComponent { ` } + + /** + * The print method is used to output the text for the console + */ + async print(props: ErrorCauseProps) { + if (!props.error.cause) { + return '' + } + + /** + * Relying on "YOUCH_CAUSE" environment variable to decide + * how deep the properties should be displayed + */ + let depth = process.env.YOUCH_CAUSE ? Number(process.env.YOUCH_CAUSE) : 2 + if (Number.isNaN(depth)) { + depth = 2 + } + + return `\n\n${colors.red('[CAUSE]')}\n${dumpCli(props.error.cause, { + depth, + inspectObjectPrototype: false, + inspectStaticMembers: false, + inspectArrayPrototype: false, + })}` + } } diff --git a/src/templates/error_info/main.ts b/src/templates/error_info/main.ts index 430f9a6..c6f2393 100644 --- a/src/templates/error_info/main.ts +++ b/src/templates/error_info/main.ts @@ -9,6 +9,7 @@ import { BaseComponent } from '../../component.js' import { publicDirURL } from '../../public_dir.js' +import { wordWrap, colors } from '../../helpers.js' import type { ErrorInfoProps } from '../../types.js' const ERROR_ICON_SVG = `` @@ -22,6 +23,10 @@ const HINT_ICON_SVG = `${props.error.name} @@ -46,4 +51,29 @@ export class ErrorInfo extends BaseComponent { ` } + + /** + * The print method is used to output the text for the console + */ + async print(props: ErrorInfoProps) { + const errorMessage = colors.red( + `ℹ ${wordWrap(`${props.error.name}: ${props.error.message}`, { + width: process.stdout.columns, + indent: ' ', + newLine: '\n', + })}` + ) + + const hint = props.error.hint + ? `\n\n${colors.blue('◉')} ${colors.dim().italic( + wordWrap(props.error.hint.replace(/(<([^>]+)>)/gi, ''), { + width: process.stdout.columns, + indent: ' ', + newLine: '\n', + }) + )}` + : '' + + return `${errorMessage}${hint}` + } } diff --git a/src/templates/error_metadata/main.ts b/src/templates/error_metadata/main.ts index 7a90449..19488ce 100644 --- a/src/templates/error_metadata/main.ts +++ b/src/templates/error_metadata/main.ts @@ -91,7 +91,8 @@ export class ErrorMetadata extends BaseComponent { } /** - * Renders erorr metadata groups + * The render method is used to output the HTML for the + * web view */ async render(props: ErrorMetadataProps): Promise { const groups = props.metadata.toJSON() @@ -105,4 +106,11 @@ export class ErrorMetadata extends BaseComponent { .map((group) => this.#renderGroup(group, groups[group], props.cspNonce)) .join('\n') } + + /** + * The print method is used to output the text for the console + */ + async print() { + return '' + } } diff --git a/src/templates/error_stack/main.ts b/src/templates/error_stack/main.ts index c435731..d6237e9 100644 --- a/src/templates/error_stack/main.ts +++ b/src/templates/error_stack/main.ts @@ -7,11 +7,13 @@ * file that was distributed with this source code. */ -import { dump, themes } from '@poppinss/dumper/html' import type { StackFrame } from 'youch-core/types' +import { dump, themes } from '@poppinss/dumper/html' +import { dump as dumpCli } from '@poppinss/dumper/console' -import { BaseComponent } from '../../component.js' import { publicDirURL } from '../../public_dir.js' +import { BaseComponent } from '../../component.js' +import { htmlEscape, colors } from '../../helpers.js' import type { ErrorStackProps } from '../../types.js' const CHEVIRON = ` @@ -40,17 +42,6 @@ export class ErrorStack extends BaseComponent { cssFile = new URL('./error_stack/style.css', publicDirURL) scriptFile = new URL('./error_stack/script.js', publicDirURL) - /** - * Light weight HTML escape helper - */ - #htmlEscape(value: string): string { - return value - .replace(/&/g, '&') - .replace(/\\"/g, '\"') - .replace(//g, '>') - } - /** * Returns the file's relative name from the CWD */ @@ -102,14 +93,17 @@ export class ErrorStack extends BaseComponent { */ #renderFrameLocation(frame: StackFrame, id: string, ide: string) { const { text, href } = this.#getEditorLink(ide, frame) + const fileName = ` - ${this.#htmlEscape(text)} + ${htmlEscape(text)} ` + const functionName = frame.functionName ? `in - ${this.#htmlEscape(frame.functionName)} + ${htmlEscape(frame.functionName)} ` : '' + const loc = `at line ${frame.lineNumber}:${frame.columnNumber}` if (frame.type !== 'native' && frame.source) { @@ -156,6 +150,36 @@ export class ErrorStack extends BaseComponent { ` } + /** + * Returns the ANSI output to print the stack frame on the + * terminal + */ + async #printStackFrame( + frame: StackFrame, + index: number, + expandAtIndex: number, + props: ErrorStackProps + ) { + const functionName = frame.functionName! + const fileName = this.#getRelativeFileName(frame.fileName!) + const loc = `${fileName}:${frame.lineNumber}:${frame.columnNumber}` + + if (index === expandAtIndex) { + const codeSnippet = await props.sourceCodeRenderer(props.error, frame) + return ` ⁃ at ${functionName} ${colors.yellow(`(${loc})`)}${codeSnippet}` + } + + if (frame.type === 'native') { + return colors.dim(` ⁃ at ${colors.italic(functionName)} (${colors.italic(loc)})`) + } + + return ` ⁃ at ${functionName} ${colors.yellow(`(${loc})`)}` + } + + /** + * The render method is used to output the HTML for the + * web view + */ async render(props: ErrorStackProps): Promise { const frames = await Promise.all( props.error.frames.map((frame, index) => { @@ -200,4 +224,34 @@ export class ErrorStack extends BaseComponent { ` } + + /** + * The print method is used to output the text for the console + */ + async print(props: ErrorStackProps) { + const displayRaw = process.env.YOUCH_RAW + if (displayRaw) { + const depth = Number.isNaN(Number(displayRaw)) ? 2 : Number(displayRaw) + return `\n\n${colors.red('[RAW]')}\n${dumpCli(props.error.raw, { + depth: depth, + inspectObjectPrototype: false, + inspectStaticMembers: false, + inspectArrayPrototype: false, + collapse: ['ClientRequest'], + })}` + } + + const frames = await Promise.all( + props.error.frames.map((frame, index) => { + return this.#printStackFrame( + frame, + index, + this.#getFirstExpandedFrameIndex(props.error.frames), + props + ) + }) + ) + + return `\n\n${frames.join('\n')}` + } } diff --git a/src/templates/error_stack_source/main.ts b/src/templates/error_stack_source/main.ts index 3687342..e81b28d 100644 --- a/src/templates/error_stack_source/main.ts +++ b/src/templates/error_stack_source/main.ts @@ -9,11 +9,16 @@ import { extname } from 'node:path' import { highlightText, type ShjLanguage } from '@speed-highlight/core' +import { highlightText as cliHighlightText } from '@speed-highlight/core/terminal' import { BaseComponent } from '../../component.js' import { publicDirURL } from '../../public_dir.js' +import { stripAnsi, colors } from '../../helpers.js' import type { ErrorStackSourceProps } from '../../types.js' +const GUTTER = '┃' +const POINTER = '❯' + /** * Known languages where we expect the errors to happen. All other source * files will be rendered as plain text with no color highlighting. @@ -37,6 +42,10 @@ const LANGS_MAP: Record = { export class ErrorStackSource extends BaseComponent { cssFile = new URL('./error_stack_source/style.css', publicDirURL) + /** + * The render method is used to output the HTML for the + * web view + */ async render(props: ErrorStackSourceProps): Promise { const frame = props.frame @@ -94,4 +103,52 @@ export class ErrorStackSource extends BaseComponent { return `
${highlight}${code}
` } + + /** + * The print method is used to output the text for the console + */ + async print(props: ErrorStackSourceProps) { + const frame = props.frame + + /** + * Do not render the source code when the frame type is + * native or we are missing the source/filename + */ + if (frame.type === 'native' || !frame.source || !frame.fileName) { + return '' + } + + /** + * Choose the language based on the file extension, or fallback + * to the plain language. + */ + const language = LANGS_MAP[extname(frame.fileName)] ?? 'plain' + + /** + * Finding the largest line number and its width so that we can + * right align all the line numbers + */ + const largestLineNumber = Math.max(...frame.source.map(({ lineNumber }) => lineNumber!)) + const lineNumberCols = String(largestLineNumber).length + + /** + * Highlighting the source code snippet + */ + const code = frame.source.map((chunk) => chunk.chunk).join('\n') + const highlighted = await cliHighlightText(code, language) + + return `\n\n${highlighted + .split('\n') + .map((line, index) => { + const lineNumber = frame.source![index].lineNumber + const alignedLineNumber = String(lineNumber).padStart(lineNumberCols, ' ') + + if (lineNumber === props.frame.lineNumber) { + return ` ${colors.bgRed(`${POINTER} ${alignedLineNumber} ${GUTTER} ${stripAnsi(line)}`)}` + } + + return ` ${colors.dim(alignedLineNumber)} ${colors.dim(GUTTER)} ${line}` + }) + .join('\n')}\n` + } } diff --git a/src/templates/header/main.ts b/src/templates/header/main.ts index 4b56d3e..1761159 100644 --- a/src/templates/header/main.ts +++ b/src/templates/header/main.ts @@ -23,6 +23,10 @@ export class Header extends BaseComponent { cssFile = new URL('./header/style.css', publicDirURL) scriptFile = new URL('./header/script.js', publicDirURL) + /** + * The render method is used to output the HTML for the + * web view + */ async render(): Promise { return `` } + + /** + * The print method is used to output the text for the console + */ + async print() { + return '' + } } diff --git a/src/templates/layout/main.ts b/src/templates/layout/main.ts index 28df4e7..a4b9222 100644 --- a/src/templates/layout/main.ts +++ b/src/templates/layout/main.ts @@ -22,6 +22,10 @@ export class Layout extends BaseComponent { cssFile = new URL('./layout/style.css', publicDirURL) scriptFile = new URL('./layout/script.js', publicDirURL) + /** + * The render method is used to output the HTML for the + * web view + */ async render(props: LayoutProps): Promise { return ` @@ -39,4 +43,11 @@ export class Layout extends BaseComponent { ` } + + /** + * The print method is used to output the text for the console + */ + async print(props: LayoutProps) { + return `\n${await props.children()}\n` + } } diff --git a/src/youch.ts b/src/youch.ts index 4158d10..6e7625f 100644 --- a/src/youch.ts +++ b/src/youch.ts @@ -78,4 +78,16 @@ export class Youch { metadata: this.metadata, }) } + + /** + * Prints error to the console + */ + async print(error: unknown) { + const parsedError = await new ErrorParser().parse(error) + return this.templates.print({ + title: this.#options.title ?? 'An error occurred', + error: parsedError, + metadata: this.metadata, + }) + } }