From 9b18e6aed2f5e22e8d41a6f220dc81781778d970 Mon Sep 17 00:00:00 2001 From: Harminder Virk Date: Sat, 21 Sep 2024 09:31:43 +0530 Subject: [PATCH] feat: improve dd output and add edge plugin --- .github/workflows/test.yml | 60 ++++++++--------- index.ts | 5 ++ modules/dumper/dumper.ts | 115 ++++++++++++++++++++++++++++++--- modules/dumper/errors.ts | 61 ++++++----------- modules/dumper/plugins/edge.ts | 88 +++++++++++++++++++++++++ package.json | 16 +++-- providers/edge_provider.ts | 5 +- tests/dumper/dumper.spec.ts | 13 ++-- 8 files changed, 268 insertions(+), 95 deletions(-) create mode 100644 modules/dumper/plugins/edge.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0c508356..41db046a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -20,25 +20,25 @@ jobs: node-version: [20.10.0, 21.x] steps: - - name: Checkout code - uses: actions/checkout@v3 + - name: Checkout code + uses: actions/checkout@v3 - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} - - name: Install pnpm - if: ${{ inputs.install-pnpm }} - uses: pnpm/action-setup@v2 - with: - version: 8.6.3 + - name: Install pnpm + if: ${{ inputs.install-pnpm }} + uses: pnpm/action-setup@v2 + with: + version: 8.6.3 - - name: Install dependencies - run: npm install + - name: Install dependencies + run: npm install - - name: Run tests - run: npm test + - name: Run tests + run: npm test test_windows: if: ${{ !inputs.disable-windows }} @@ -48,22 +48,22 @@ jobs: node-version: [20.10.0, 21.x] steps: - - name: Checkout code - uses: actions/checkout@v3 + - name: Checkout code + uses: actions/checkout@v3 - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} + - name: Setup Node.js + uses: actions/setup-node@v3 + with: + node-version: ${{ matrix.node-version }} - - name: Install pnpm - if: ${{ inputs.install-pnpm }} - uses: pnpm/action-setup@v2 - with: - version: 8.6.3 + - name: Install pnpm + if: ${{ inputs.install-pnpm }} + uses: pnpm/action-setup@v2 + with: + version: 8.6.3 - - name: Install dependencies - run: npm install + - name: Install dependencies + run: npm install - - name: Run tests - run: npm test + - name: Run tests + run: npm test diff --git a/index.ts b/index.ts index fdd39152..5afcae24 100644 --- a/index.ts +++ b/index.ts @@ -38,6 +38,11 @@ export const errors: typeof encryptionErrors & * Youch terminal */ export async function prettyPrintError(error: any) { + if (error && typeof error === 'object' && error.code === 'E_DUMP_DIE_EXCEPTION') { + console.error(error) + return + } + // @ts-expect-error const { default: youchTerminal } = await import('youch-terminal') const { default: Youch } = await import('youch') diff --git a/modules/dumper/dumper.ts b/modules/dumper/dumper.ts index f264acb3..b3a61e26 100644 --- a/modules/dumper/dumper.ts +++ b/modules/dumper/dumper.ts @@ -7,6 +7,7 @@ * file that was distributed with this source code. */ +import { styleText } from 'node:util' import { dump as consoleDump } from '@poppinss/dumper/console' import type { HTMLDumpConfig } from '@poppinss/dumper/html/types' import type { ConsoleDumpConfig } from '@poppinss/dumper/console/types' @@ -43,12 +44,16 @@ const DUMP_TITLE_STYLES = ` border-top-right-radius: 0; }` +const IDE = process.env.ADONIS_IDE ?? process.env.EDITOR ?? '' + /** - * Dumper exposes the API to dump or die/dump values via your + * Dumper exposes the API to dump or die/dump values in your * AdonisJS application. An singleton instance of the Dumper * is shared as a service and may use it follows. * * ```ts + * const dumper = container.make('dumper') + * * dumper.configureHtmlOutput({ * // parser + html formatter config * }) @@ -62,20 +67,62 @@ const DUMP_TITLE_STYLES = ` * * // Returns style and script tags that must be * // injeted to the head of the HTML document + * * const head = dumper.getHeadElements() * ``` */ export class Dumper { #app: Application + + /** + * Configuration for the HTML formatter + */ #htmlConfig: HTMLDumpConfig = {} + + /** + * Configuration for the Console formatter + */ #consoleConfig: ConsoleDumpConfig = { collapse: ['DateTime', 'Date'], } + /** + * A collections of known editors to create URLs to open + * them + */ + #editors: Record = { + textmate: 'txmt://open?url=file://%f&line=%l', + macvim: 'mvim://open?url=file://%f&line=%l', + emacs: 'emacs://open?url=file://%f&line=%l', + sublime: 'subl://open?url=file://%f&line=%l', + phpstorm: 'phpstorm://open?file=%f&line=%l', + atom: 'atom://core/open/file?filename=%f&line=%l', + vscode: 'vscode://file/%f:%l', + } + constructor(app: Application) { this.#app = app } + /** + * Returns the link to open the file using dd inside one + * of the known code editors + */ + #getEditorLink(source?: { + location: string + line: number + }): { href: string; text: string } | undefined { + const editorURL = this.#editors[IDE] || IDE + if (!editorURL || !source) { + return + } + + return { + href: editorURL.replace('%f', source.location).replace('%l', String(source.line)), + text: `${this.#app.relativePath(source.location)}:${source.line}`, + } + } + /** * Configure the HTML formatter output */ @@ -98,7 +145,7 @@ export class Dumper { */ getHeadElements(cspNonce?: string): string { return ( - '' + @@ -111,28 +158,78 @@ export class Dumper { /** * Dump value to HTML ouput */ - dumpToHtml(value: unknown, cspNonce?: string) { - return dump(value, { cspNonce, ...this.#htmlConfig }) + dumpToHtml( + value: unknown, + options: { + cspNonce?: string + title?: string + source?: { + location: string + line: number + } + } = {} + ) { + const link = this.#getEditorLink(options.source) ?? null + const title = options.title || 'DUMP' + + return ( + '
' + + `${title}` + + (link ? `${link.text}` : '') + + '
' + + dump(value, { cspNonce: options.cspNonce, ...this.#htmlConfig }) + ) } /** * Dump value to ANSI output */ - dumpToAnsi(value: unknown) { - return consoleDump(value, this.#consoleConfig) + dumpToAnsi( + value: unknown, + options: { + title?: string + source?: { + location: string + line: number + } + } = {} + ) { + const columns = process.stdout.columns + + /** + * Link to the source file + */ + const link = `${this.#getEditorLink(options.source)?.text ?? ''} ` + + /** + * Dump title + */ + const title = ` ${options.title || 'DUMP'}` + + /** + * Whitespace between the title and the link to align them + * on each side of x axis + */ + const whiteSpace = new Array(columns - link.length - title.length - 4).join(' ') + + /** + * Styled heading with background color and bold text + */ + const heading = styleText('bgRed', styleText('bold', `${title}${whiteSpace}${link}`)) + + return `${heading}\n${consoleDump(value, this.#consoleConfig)}` } /** * Dump values and die. The formatter will be picked * based upon where your app is running. * - * - In CLI commands, the ANSI output will be printed - * to the console. * - During an HTTP request, the HTML output will be * sent to the server. + * - Otherwise the value will be logged in the console */ dd(value: unknown, traceSourceIndex: number = 1) { - const error = new E_DUMP_DIE_EXCEPTION(value, this, this.#app) + const error = new E_DUMP_DIE_EXCEPTION(value, this) error.setTraceSourceIndex(traceSourceIndex) throw error } diff --git a/modules/dumper/errors.ts b/modules/dumper/errors.ts index b99cacd0..870e206f 100644 --- a/modules/dumper/errors.ts +++ b/modules/dumper/errors.ts @@ -12,12 +12,9 @@ import { parse } from 'error-stack-parser-es' import type { Kernel } from '@adonisjs/core/ace' import { Exception } from '@poppinss/utils/exception' import type { HttpContext } from '@adonisjs/core/http' -import type { ApplicationService } from '@adonisjs/core/types' import type { Dumper } from './dumper.js' -const IDE = process.env.ADONIS_IDE ?? process.env.EDITOR ?? '' - /** * DumpDie exception is raised by the "dd" function. It will * result in dumping the value in response to an HTTP @@ -27,41 +24,28 @@ class DumpDieException extends Exception { static status: number = 500 static code: string = 'E_DUMP_DIE_EXCEPTION' - #app: ApplicationService + declare fileName: string + declare lineNumber: number + #dumper: Dumper #traceSourceIndex: number = 1 - - /** - * A collections of known editors to create URLs to open - * them - */ - #editors: Record = { - textmate: 'txmt://open?url=file://%f&line=%l', - macvim: 'mvim://open?url=file://%f&line=%l', - emacs: 'emacs://open?url=file://%f&line=%l', - sublime: 'subl://open?url=file://%f&line=%l', - phpstorm: 'phpstorm://open?file=%f&line=%l', - atom: 'atom://core/open/file?filename=%f&line=%l', - vscode: 'vscode://file/%f:%l', - } - value: unknown - constructor(value: unknown, dumper: Dumper, app: ApplicationService) { + constructor(value: unknown, dumper: Dumper) { super('Dump and Die exception') this.#dumper = dumper - this.#app = app this.value = value } /** - * Returns the link to open the file using dd inside one - * of the known code editors + * Returns the source file and line number location for the error */ - #getEditorLink(): { href: string; text: string } | undefined { - const editorURL = this.#editors[IDE] || IDE - if (!editorURL) { - return + #getErrorSource(): { location: string; line: number } | undefined { + if (this.fileName && this.lineNumber) { + return { + location: this.fileName, + line: this.lineNumber, + } } const source = parse(this)[this.#traceSourceIndex] @@ -70,8 +54,8 @@ class DumpDieException extends Exception { } return { - href: editorURL.replace('%f', source.fileName).replace('%l', String(source.lineNumber)), - text: `${this.#app.relativePath(source.fileName)}:${source.lineNumber}`, + location: source.fileName, + line: source.lineNumber, } } @@ -94,11 +78,12 @@ class DumpDieException extends Exception { * Handler called by the AdonisJS HTTP exception handler */ async handle(error: DumpDieException, ctx: HttpContext) { - const link = this.#getEditorLink() + const source = this.#getErrorSource() + /** * Comes from the shield package */ - const cspNonce = 'nonce' in ctx.response ? ctx.response.nonce : undefined + const cspNonce = 'nonce' in ctx.response ? (ctx.response.nonce as string) : undefined ctx.response .status(500) @@ -111,13 +96,7 @@ class DumpDieException extends Exception { `${this.#dumper.getHeadElements(cspNonce)}` + '' + '' + - '
' + - 'DUMP DIE' + - (link - ? `${link.text}` - : '') + - '
' + - `${this.#dumper.dumpToHtml(error.value, cspNonce)}` + + `${this.#dumper.dumpToHtml(error.value, { cspNonce, source, title: 'DUMP DIE' })}` + '' + '' ) @@ -127,14 +106,16 @@ class DumpDieException extends Exception { * Handler called by the AdonisJS Ace kernel */ async render(error: DumpDieException, kernel: Kernel) { - kernel.ui.logger.log(this.#dumper.dumpToAnsi(error.value)) + const source = this.#getErrorSource() + kernel.ui.logger.log(this.#dumper.dumpToAnsi(error.value, { source, title: 'DUMP DIE' })) } /** * Custom output for the Node.js util inspect */ [inspect.custom]() { - return this.#dumper.dumpToAnsi(this.value) + const source = this.#getErrorSource() + return this.#dumper.dumpToAnsi(this.value, { source, title: 'DUMP DIE' }) } } diff --git a/modules/dumper/plugins/edge.ts b/modules/dumper/plugins/edge.ts new file mode 100644 index 00000000..73dd5eb1 --- /dev/null +++ b/modules/dumper/plugins/edge.ts @@ -0,0 +1,88 @@ +/* + * @adonisjs/core + * + * (c) AdonisJS + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +import { type Edge, Template } from 'edge.js' +import { type Dumper } from '../dumper.js' + +/** + * Returns an edge plugin that integrates with a given + * dumper instance + */ +export function pluginEdgeDumper(dumper: Dumper) { + Template.macro('dumper' as any, dumper) + + return (edge: Edge) => { + edge.registerTag({ + tagName: 'dump', + block: false, + seekable: true, + noNewLine: true, + compile(parser, buffer, token) { + const parsed = parser.utils.transformAst( + parser.utils.generateAST(token.properties.jsArg, token.loc, token.filename), + token.filename, + parser + ) + + buffer.writeExpression( + `template.stacks.pushOnceTo('dumper', 'dumper_globals', template.dumper.getHeadElements(state.cspNonce))`, + token.filename, + token.loc.start.line + ) + + buffer.outputExpression( + `template.dumper.dumpToHtml(${parser.utils.stringify(parsed)}, { cspNonce: state.cspNonce, source: { location: $filename, line: $lineNumber } })`, + token.filename, + token.loc.start.line, + true + ) + }, + }) + + edge.registerTag({ + tagName: 'dd', + block: false, + seekable: true, + noNewLine: true, + compile(parser, buffer, token) { + const parsed = parser.utils.transformAst( + parser.utils.generateAST(token.properties.jsArg, token.loc, token.filename), + token.filename, + parser + ) + + /** + * Dump/Die statement to catch error and convert it into + * an Edge error + */ + const ddStatement = [ + 'try {', + ` template.dumper.dd(${parser.utils.stringify(parsed)})`, + '} catch (error) {', + ` if (error.code === 'E_DUMP_DIE_EXCEPTION') {`, + ' const edgeError = template.createError(error.message, $filename, $lineNumber)', + ' error.fileName = $filename', + ' error.lineNumber = $lineNumber', + ' edgeError.handle = function (_, ctx) {', + ' return error.handle(error, ctx)', + ' }', + ' edgeError.report = function () {', + ' return error.report(error)', + ' }', + ' throw edgeError', + ' }', + ' throw error', + '}', + ].join('\n') + + buffer.writeStatement(ddStatement, token.filename, token.loc.start.line) + }, + }) + } +} diff --git a/package.json b/package.json index 67e8d21f..77662405 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,8 @@ "./container": "./build/modules/container.js", "./encryption": "./build/modules/encryption.js", "./env": "./build/modules/env/main.js", + "./dumper": "./build/modules/dumper/main.js", + "./dumper/plugin_edge": "./build/modules/dumper/plugins/edge.js", "./env/editor": "./build/modules/env/editor.js", "./events": "./build/modules/events.js", "./http": "./build/modules/http/main.js", @@ -82,7 +84,7 @@ }, "devDependencies": { "@adonisjs/assembler": "^7.8.2", - "@adonisjs/eslint-config": "^2.0.0-beta.6", + "@adonisjs/eslint-config": "^2.0.0-beta.7", "@adonisjs/prettier-config": "^1.4.0", "@adonisjs/tsconfig": "^1.4.0", "@commitlint/cli": "^19.5.0", @@ -106,8 +108,8 @@ "copyfiles": "^2.4.1", "cross-env": "^7.0.3", "del-cli": "^5.1.0", - "edge.js": "^6.1.0", - "eslint": "^9.10.0", + "edge.js": "^6.2.0", + "eslint": "^9.11.0", "execa": "^9.4.0", "get-port": "^7.1.0", "github-label-sync": "^2.3.1", @@ -136,14 +138,14 @@ "@adonisjs/repl": "^4.0.1", "@antfu/install-pkg": "^0.4.1", "@paralleldrive/cuid2": "^2.2.2", - "@poppinss/dumper": "^0.4.0", + "@poppinss/dumper": "^0.4.1", "@poppinss/macroable": "^1.0.3", - "@poppinss/utils": "^6.8.1", + "@poppinss/utils": "^6.8.3", "@sindresorhus/is": "^7.0.1", "@types/he": "^1.2.3", "error-stack-parser-es": "^0.1.5", "he": "^1.2.0", - "parse-imports": "^2.1.1", + "parse-imports": "^2.2.1", "pretty-hrtime": "^1.0.3", "string-width": "^7.2.0", "youch": "^3.3.3", @@ -154,7 +156,7 @@ "@vinejs/vine": "^2.1.0", "argon2": "^0.31.2 || ^0.41.0", "bcrypt": "^5.1.1", - "edge.js": "^6.0.1" + "edge.js": "^6.2.0" }, "peerDependenciesMeta": { "argon2": { diff --git a/providers/edge_provider.ts b/providers/edge_provider.ts index 11f35ba2..787dc44a 100644 --- a/providers/edge_provider.ts +++ b/providers/edge_provider.ts @@ -9,6 +9,7 @@ import edge, { type Edge } from 'edge.js' import type { ApplicationService } from '../src/types.js' +import { pluginEdgeDumper } from '../modules/dumper/plugins/edge.js' import { BriskRoute, HttpContext, type Route, type Router } from '../modules/http/main.js' declare module '@adonisjs/core/http' { @@ -74,8 +75,6 @@ export default class EdgeServiceProvider { }) edge.global('app', app) edge.global('config', edgeConfigResolver) - edge.global('dd', (value: unknown) => dumper.dd(value)) - edge.global('dump', (value: unknown) => dumper.dumpToHtml(value)) /** * Creating a isolated instance of edge renderer @@ -99,5 +98,7 @@ export default class EdgeServiceProvider { return view.render(template, data) }) }) + + edge.use(pluginEdgeDumper(dumper)) } } diff --git a/tests/dumper/dumper.spec.ts b/tests/dumper/dumper.spec.ts index 1f6c92e4..8a89d17a 100644 --- a/tests/dumper/dumper.spec.ts +++ b/tests/dumper/dumper.spec.ts @@ -58,6 +58,7 @@ test.group('Dumper', () => { }) test('render dump as ansi output', async ({ fs, assert }) => { + assert.plan(2) const app = new AppFactory().create(fs.baseUrl) const ace = await new AceFactory().make(fs.baseUrl) ace.ui.switchMode('raw') @@ -67,13 +68,11 @@ test.group('Dumper', () => { dumper.dd({ hello: 'world' }) } catch (error) { await error.render(error, ace) - assert.deepEqual(ace.ui.logger.getLogs(), [ - { - message: - "\x1B[33m{\x1B[39m\n \x1B[34mhello\x1B[39m: \x1B[32m'world'\x1B[39m,\n\x1B[33m}\x1B[39m", - stream: 'stdout', - }, - ]) + assert.lengthOf(ace.ui.logger.getLogs(), 1) + assert.include( + ace.ui.logger.getLogs()[0].message, + "\x1B[33m{\x1B[39m\n \x1B[34mhello\x1B[39m: \x1B[32m'world'\x1B[39m,\n\x1B[33m}\x1B[39m" + ) } }) })