From 95b4599217692dbf8e28ea7a3061d1c9c7039a54 Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 18 Oct 2026 19:05:54 -0400 Subject: [PATCH 1/2] Add a JS pretty-printer that runs in a worker. This adds the code for formatting JS source code. Nothing uses it yet; the source views will use it to pretty-print minified code. The formatter is based on the Lezer syntax tree and only changes the whitespace between tokens. It describes the result as a buffer of whitespace edits instead of a string, which keeps the mapping between the original and the formatted positions simple. It parses the code incrementally and reports the edits after each step, so that a caller can show partially-formatted code while the formatting is in progress. The formatter runs in a new worker, format.worker.js, which is built like the source map worker. The worker sends back the edits, and applyFormatEdits builds the formatted code on the main thread as an array of lines, which are mostly slices of the original code. The Lezer dialect helpers move from scope-tree.ts to a new module that doesn't import the parser, so that the main bundle can use them. --- jest.config.js | 1 + package.json | 1 + scripts/build-profiler-cli.mjs | 6 +- scripts/build.mjs | 23 +- scripts/lib/esbuild-configs.mjs | 28 +- scripts/run-dev-server.mjs | 3 +- src/profile-logic/js-format/dialect.ts | 36 ++ src/profile-logic/js-format/format.worker.ts | 45 ++ .../js-format/formatted-source.ts | 182 ++++++ src/profile-logic/js-format/formatter.ts | 559 ++++++++++++++++++ .../js-format/run-format-worker.ts | 40 ++ src/profile-logic/js-format/types.ts | 56 ++ src/profile-logic/source-maps/scope-tree.ts | 39 +- src/test/fixtures/format.worker.stub.js | 15 + src/test/unit/js-format.test.ts | 267 +++++++++ src/types/globals/global.d.ts | 1 + 16 files changed, 1259 insertions(+), 43 deletions(-) create mode 100644 src/profile-logic/js-format/dialect.ts create mode 100644 src/profile-logic/js-format/format.worker.ts create mode 100644 src/profile-logic/js-format/formatted-source.ts create mode 100644 src/profile-logic/js-format/formatter.ts create mode 100644 src/profile-logic/js-format/run-format-worker.ts create mode 100644 src/profile-logic/js-format/types.ts create mode 100644 src/test/fixtures/format.worker.stub.js create mode 100644 src/test/unit/js-format.test.ts diff --git a/jest.config.js b/jest.config.js index 4eeb0d77be..d771f0f4fd 100644 --- a/jest.config.js +++ b/jest.config.js @@ -31,6 +31,7 @@ const browserEnvConfig = { globals: { AVAILABLE_STAGING_LOCALES: null, SOURCE_MAP_WORKER_PATH: 'src/test/fixtures/source-map.worker.stub.js', + FORMAT_WORKER_PATH: 'src/test/fixtures/format.worker.stub.js', }, snapshotFormat: { diff --git a/package.json b/package.json index 8eea33d0d8..ae36d156d2 100644 --- a/package.json +++ b/package.json @@ -77,6 +77,7 @@ "@fluent/bundle": "^0.19.1", "@fluent/langneg": "^0.7.0", "@fluent/react": "^0.15.2", + "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.2.5", "@lezer/javascript": "^1.5.6", "@streamparser/json": "^0.0.26", diff --git a/scripts/build-profiler-cli.mjs b/scripts/build-profiler-cli.mjs index f5fd7d688c..40e55f3ac4 100644 --- a/scripts/build-profiler-cli.mjs +++ b/scripts/build-profiler-cli.mjs @@ -27,9 +27,11 @@ const profilerCliConfig = { __BUILD_HASH__: JSON.stringify(BUILD_HASH), __PACKAGE_NAME__: JSON.stringify(name), __VERSION__: JSON.stringify(version), - // SOURCE_MAP_WORKER_PATH is injected by the browser build. The CLI doesn't - // use source map workers but the shared code references this constant. + // SOURCE_MAP_WORKER_PATH and FORMAT_WORKER_PATH are injected by the + // browser build. The CLI doesn't use these workers but the shared code + // references these constants. SOURCE_MAP_WORKER_PATH: JSON.stringify('/source-map.worker.js'), + FORMAT_WORKER_PATH: JSON.stringify('/format.worker.js'), }, external: [...nodeBaseConfig.external, 'gecko-profiler-demangle'], }; diff --git a/scripts/build.mjs b/scripts/build.mjs index b6650d147a..a06b0959c3 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -7,19 +7,23 @@ import { mainBundleConfig, sourceMapWorkerConfig, getSourceMapWorkerPath, + formatWorkerConfig, + getFormatWorkerPath, } from './lib/esbuild-configs.mjs'; import { cleanDist, saveMetafile } from './lib/build-utils.mjs'; async function build() { cleanDist(); - // Build the worker first so we can read its output path from the metafile - // and inject it into the main bundle via SOURCE_MAP_WORKER_PATH. - const workerResult = await esbuild.build({ - ...sourceMapWorkerConfig, - entryNames: - process.env.NODE_ENV === 'production' ? '[name]-[hash]' : '[name]', - }); + // Build the workers first so we can read their output paths from the + // metafiles and inject them into the main bundle via SOURCE_MAP_WORKER_PATH + // and FORMAT_WORKER_PATH. + const workerEntryNames = + process.env.NODE_ENV === 'production' ? '[name]-[hash]' : '[name]'; + const [workerResult, formatWorkerResult] = await Promise.all([ + esbuild.build({ ...sourceMapWorkerConfig, entryNames: workerEntryNames }), + esbuild.build({ ...formatWorkerConfig, entryNames: workerEntryNames }), + ]); const buildResult = await esbuild.build({ ...mainBundleConfig, @@ -28,11 +32,14 @@ async function build() { SOURCE_MAP_WORKER_PATH: JSON.stringify( getSourceMapWorkerPath(workerResult.metafile) ), + FORMAT_WORKER_PATH: JSON.stringify( + getFormatWorkerPath(formatWorkerResult.metafile) + ), }, }); saveMetafile(buildResult); - console.log('✅ Main browser build and source map worker completed'); + console.log('✅ Main browser build and workers completed'); } build().catch(console.error); diff --git a/scripts/lib/esbuild-configs.mjs b/scripts/lib/esbuild-configs.mjs index 227a4e3aad..0c89167645 100644 --- a/scripts/lib/esbuild-configs.mjs +++ b/scripts/lib/esbuild-configs.mjs @@ -84,6 +84,7 @@ export const mainBundleConfig = { // In dev, the worker is not hashed so the path is predictable. // In production, build.mjs overrides this after building the worker first. SOURCE_MAP_WORKER_PATH: JSON.stringify('/source-map.worker.js'), + FORMAT_WORKER_PATH: JSON.stringify('/format.worker.js'), 'process.env.ENABLE_SERVICE_WORKER': JSON.stringify( process.env.ENABLE_SERVICE_WORKER || 'true' ), @@ -139,14 +140,37 @@ export const sourceMapWorkerConfig = { plugins: [wasmLoader()], }; -export function getSourceMapWorkerPath(metafile) { - const [entryPoint] = sourceMapWorkerConfig.entryPoints; +// Format worker bundle configuration, for formatting JS code in the source +// view. Built in the same way as the source map worker, see above. +export const formatWorkerConfig = { + ...baseConfig, + entryPoints: ['src/profile-logic/js-format/format.worker.ts'], + outdir: 'dist', + format: 'iife', + platform: 'browser', + target: browserslistToEsbuild(), + sourcemap: true, + splitting: false, + entryNames: '[name]', + metafile: true, +}; + +function _getWorkerPath(workerConfig, metafile) { + const [entryPoint] = workerConfig.entryPoints; const [outputPath] = Object.entries(metafile.outputs).find( ([, output]) => output.entryPoint === entryPoint ); return '/' + path.basename(outputPath); } +export function getSourceMapWorkerPath(metafile) { + return _getWorkerPath(sourceMapWorkerConfig, metafile); +} + +export function getFormatWorkerPath(metafile) { + return _getWorkerPath(formatWorkerConfig, metafile); +} + // Photon styling build configuration const photonTemplateHTML = fs.readFileSync( path.join(projectRoot, 'res', 'photon', 'index.html'), diff --git a/scripts/run-dev-server.mjs b/scripts/run-dev-server.mjs index 2aaa4052cd..a3c02b4cef 100644 --- a/scripts/run-dev-server.mjs +++ b/scripts/run-dev-server.mjs @@ -5,6 +5,7 @@ import path from 'path'; import { mainBundleConfig, sourceMapWorkerConfig, + formatWorkerConfig, } from './lib/esbuild-configs.mjs'; import { startDevServer } from './lib/dev-server.mjs'; import { serveAndOpenProfile } from './lib/profile-server.mjs'; @@ -27,7 +28,7 @@ startDevServer(mainBundleConfig, { host, distDir: 'dist', cleanDist: true, - extraWatchConfigs: [sourceMapWorkerConfig], + extraWatchConfigs: [sourceMapWorkerConfig, formatWorkerConfig], onServerStart: (profilerUrl) => { const barAscii = '------------------------------------------------------------------------------------------'; diff --git a/src/profile-logic/js-format/dialect.ts b/src/profile-logic/js-format/dialect.ts new file mode 100644 index 0000000000..c0cd9cf27c --- /dev/null +++ b/src/profile-logic/js-format/dialect.ts @@ -0,0 +1,36 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Lezer dialect helpers. This module must not import @lezer/javascript, so + * that the main bundle can use it without pulling in the parser. + */ + +// @lezer/javascript ships TS and JSX as opt-in dialects. The base parser only +// understands plain JS, so a `.ts` or `.tsx` source produces Error nodes for +// every type annotation or JSX element unless the matching dialect is used. +export type JsDialect = '' | 'ts' | 'jsx' | 'ts jsx'; + +/** + * Map a source filename to the Lezer dialect needed to parse it. Looks at the + * file extension only. Accepts URL-style source paths (the typical content of + * a source map's `sources[]`). + */ +export function dialectForFilename(filename: string): JsDialect { + const lower = filename.toLowerCase(); + if (lower.endsWith('.tsx')) { + return 'ts jsx'; + } + if (lower.endsWith('.jsx')) { + return 'jsx'; + } + if ( + lower.endsWith('.ts') || + lower.endsWith('.mts') || + lower.endsWith('.cts') + ) { + return 'ts'; + } + return ''; +} diff --git a/src/profile-logic/js-format/format.worker.ts b/src/profile-logic/js-format/format.worker.ts new file mode 100644 index 0000000000..3d017d8e2d --- /dev/null +++ b/src/profile-logic/js-format/format.worker.ts @@ -0,0 +1,45 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Web Worker entry point for formatting JS source code. + * + * Receives a FormatWorkerInput and posts a FormatWorkerOutput message after + * each formatting step. The edits are transferred, not copied. The worker + * never sends strings back: the main thread builds the formatted code from + * the original code and the edits. + * + * Must be built as a separate esbuild bundle (see formatWorkerConfig) so + * that npm dependencies are bundled into the worker output file. + */ + +import { formatJsCodeIncrementally } from './formatter'; +import type { FormatWorkerInput, FormatWorkerOutput } from './types'; + +// Override the `self` type: in the browser this file runs as a DedicatedWorker, +// but TypeScript's DOM lib types `self` as `Window & typeof globalThis`. +interface WorkerScope { + onmessage: ((e: MessageEvent) => void) | null; + postMessage: (message: FormatWorkerOutput, transfer: Transferable[]) => void; +} +const scope = self as unknown as WorkerScope; + +scope.onmessage = (e: MessageEvent) => { + const { code, dialect } = e.data; + try { + formatJsCodeIncrementally(code, dialect, (edits, formattedUpTo, isDone) => { + scope.postMessage({ type: 'progress', edits, formattedUpTo, isDone }, [ + edits.buffer, + ]); + }); + } catch (error) { + scope.postMessage( + { + type: 'error', + message: error instanceof Error ? error.message : String(error), + }, + [] + ); + } +}; diff --git a/src/profile-logic/js-format/formatted-source.ts b/src/profile-logic/js-format/formatted-source.ts new file mode 100644 index 0000000000..782e934d6b --- /dev/null +++ b/src/profile-logic/js-format/formatted-source.ts @@ -0,0 +1,182 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Applies format edits on the main thread. The formatted code is never built + * as one string: it's built as an array of lines, and most lines are slices + * of the original code. JS engines can usually represent such slices without + * copying the characters. + */ + +import { bisectionRight } from 'firefox-profiler/utils/bisect'; +import type { FormatEdits } from './types'; +import { + FORMAT_INDENT, + FORMAT_WS_NONE, + FORMAT_WS_SPACE, + FORMAT_WS_NEWLINES_SHIFT, + FORMAT_WS_INDENT_MASK, +} from './types'; + +export type FormattedSource = { + // The lines of the formatted code, without the line break characters. + readonly lines: string[]; + // Returns the 1-based line in the formatted code for a 1-based line and + // column in the original code. The line and column are relative to the + // start of the code, like in the source view. If the column is null, this + // returns the formatted line for the start of the original line. + getFormattedLine(line: number, column: number | null): number; +}; + +/** + * Finds the line breaks in a string, in the same way as CodeMirror does: + * "\r\n", "\r" and "\n" are line breaks. The positions of the next "\r" and + * "\n" are cached, so that searching through the string from front to back + * is linear even if there are few line breaks. + */ +class LineBreakFinder { + _code: string; + _nextLF: number; + _nextCR: number; + + constructor(code: string) { + this._code = code; + this._nextLF = code.indexOf('\n'); + this._nextCR = code.indexOf('\r'); + } + + // Returns the position of the first line break at or after `pos`, or -1. + next(pos: number): number { + if (this._nextLF !== -1 && this._nextLF < pos) { + this._nextLF = this._code.indexOf('\n', pos); + } + if (this._nextCR !== -1 && this._nextCR < pos) { + this._nextCR = this._code.indexOf('\r', pos); + } + if (this._nextCR === -1) { + return this._nextLF; + } + if (this._nextLF === -1) { + return this._nextCR; + } + return Math.min(this._nextLF, this._nextCR); + } + + // Returns the length of the line break at `pos`. + lengthAt(pos: number): number { + return this._code.charCodeAt(pos) === 13 && + this._code.charCodeAt(pos + 1) === 10 + ? 2 + : 1; + } +} + +// The offsets of the line starts in `code`, including the first line. +function _getLineStarts(code: string): number[] { + const lineStarts = [0]; + const finder = new LineBreakFinder(code); + let pos = 0; + for (;;) { + const lineBreak = finder.next(pos); + if (lineBreak === -1) { + return lineStarts; + } + pos = lineBreak + finder.lengthAt(lineBreak); + lineStarts.push(pos); + } +} + +// Computing the line starts of the original code takes a few milliseconds for +// a large bundle. Cache the result for the most recent code, because the +// formatted source is recomputed for each progress update of the formatting. +let _lineStartsCache: { code: string; lineStarts: number[] } | null = null; + +function _getCachedLineStarts(code: string): number[] { + if (_lineStartsCache === null || _lineStartsCache.code !== code) { + _lineStartsCache = { code, lineStarts: _getLineStarts(code) }; + } + return _lineStartsCache.lineStarts; +} + +const _whitespaceStrings: Map = new Map(); + +function _getWhitespaceString(ws: number): string { + let str = _whitespaceStrings.get(ws); + if (str === undefined) { + str = FORMAT_INDENT.repeat(ws & FORMAT_WS_INDENT_MASK); + _whitespaceStrings.set(ws, str); + } + return str; +} + +export function applyFormatEdits( + code: string, + edits: FormatEdits +): FormattedSource { + const lines: string[] = []; + // The offset in the original code where each formatted line starts, for all + // lines after the first one. Lines which start with indentation from an + // edit start at the end of the edited gap. + const formattedLineStarts: number[] = []; + const finder = new LineBreakFinder(code); + let currentLine = ''; + + // Append code[from, to), which may contain original line breaks. + function appendOriginal(from: number, to: number) { + let pos = from; + for (;;) { + const lineBreak = finder.next(pos); + if (lineBreak === -1 || lineBreak >= to) { + currentLine += code.slice(pos, to); + return; + } + lines.push(currentLine + code.slice(pos, lineBreak)); + pos = lineBreak + finder.lengthAt(lineBreak); + formattedLineStarts.push(pos); + currentLine = ''; + } + } + + let pos = 0; + for (let i = 0; i < edits.length; i += 3) { + const from = edits[i]; + const to = edits[i + 1]; + const ws = edits[i + 2]; + appendOriginal(pos, from); + if (ws === FORMAT_WS_SPACE) { + currentLine += ' '; + } else if (ws !== FORMAT_WS_NONE) { + const lineBreaks = ws >> FORMAT_WS_NEWLINES_SHIFT; + for (let j = 0; j < lineBreaks; j++) { + lines.push(currentLine); + formattedLineStarts.push(to); + currentLine = ''; + } + currentLine = _getWhitespaceString(ws); + } + pos = to; + } + appendOriginal(pos, code.length); + lines.push(currentLine); + + const originalLineStarts = _getCachedLineStarts(code); + return { + lines, + getFormattedLine(line: number, column: number | null): number { + const lineIndex = Math.min( + Math.max(line - 1, 0), + originalLineStarts.length - 1 + ); + let offset = originalLineStarts[lineIndex]; + if (column !== null) { + const lineEnd = + lineIndex + 1 < originalLineStarts.length + ? originalLineStarts[lineIndex + 1] - 1 + : code.length; + offset = Math.min(offset + Math.max(column - 1, 0), lineEnd); + } + return 1 + bisectionRight(formattedLineStarts, offset); + }, + }; +} diff --git a/src/profile-logic/js-format/formatter.ts b/src/profile-logic/js-format/formatter.ts new file mode 100644 index 0000000000..15f5e97979 --- /dev/null +++ b/src/profile-logic/js-format/formatter.ts @@ -0,0 +1,559 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * A JS formatter ("pretty printer") for minified code, based on the Lezer + * syntax tree. + * + * The formatter never changes, adds or removes tokens. It only replaces the + * whitespace between tokens, and it describes the result as a list of format + * edits; see FormatEdits. This keeps the mapping between the original and the + * formatted code simple, so that we can attribute the samples, which refer + * to positions in the original code, to the lines of the formatted code. + * + * The rules: + * - Statements, class members, object properties and switch cases go on + * their own lines, indented by their nesting depth. + * - A statement body without braces, e.g. in `if (a) b();`, goes on its own + * line. + * - Declarations with multiple variables, and comma sequences used as + * statements, such as `a(), b(), c()`, get one line per item, because + * minifiers use these to replace multiple statements. + * - Existing line breaks are kept, with at most one blank line. + * - Spaces are added around binary operators, after commas, and after + * keywords. + * - Strings, template strings, regular expressions, comments and JSX are + * kept as they are. + * + * This module imports @lezer/javascript, so it should only be used in the + * format worker and in tests. + */ + +import { TreeFragment } from '@lezer/common'; +import type { Tree, TreeCursor } from '@lezer/common'; + +import { parserForDialect } from 'firefox-profiler/profile-logic/source-maps/scope-tree'; +import type { JsDialect } from './dialect'; +import type { FormatEdits } from './types'; +import { + FORMAT_INDENT, + FORMAT_WS_NONE, + FORMAT_WS_SPACE, + FORMAT_WS_NEWLINES_SHIFT, +} from './types'; + +// Nodes whose children each go on their own line. +const BLOCK_LIKE = new Set([ + 'Script', + 'Block', + 'ClassBody', + 'SwitchBody', + 'ObjectExpression', + 'EnumBody', + 'ObjectType', +]); + +// Statements with a body statement, which goes on its own line if it's not a +// block. +const BODY_PARENTS = new Set([ + 'IfStatement', + 'ForStatement', + 'WhileStatement', + 'DoStatement', + 'WithStatement', +]); + +const FOR_SPECS = new Set(['ForSpec', 'ForInSpec', 'ForOfSpec']); + +// Nodes which are copied verbatim, without looking at their children. +const ATOMIC = new Set([ + 'String', + 'TemplateString', + 'RegExp', + 'BlockComment', + 'LineComment', + 'JSXElement', + 'Hashbang', +]); + +const COMMENTS = new Set(['BlockComment', 'LineComment']); + +const OPERATORS = new Set([ + 'ArithOp', + 'LogicOp', + 'BitOp', + 'CompareOp', + 'Equals', + 'UpdateOp', + 'Arrow', +]); +const UNSPACED_OPERATOR_PARENTS = new Set([ + 'UnaryExpression', + 'PostfixExpression', +]); + +const SPACE_AFTER_KEYWORDS = new Set([ + 'if', + 'for', + 'while', + 'switch', + 'catch', + 'with', + 'return', + 'else', + 'try', + 'do', + 'finally', + 'typeof', + 'void', + 'delete', + 'throw', + 'case', + 'in', + 'of', + 'instanceof', + 'extends', + 'yield', + 'await', + 'const', + 'let', + 'var', + 'new', + 'export', + 'async', +]); +const SPACE_BEFORE_KEYWORDS = new Set(['in', 'of', 'instanceof', 'as']); +const NO_SPACE_BEFORE = new Set([';', ')', ']', '}', ',', '.', ':', '?.']); +const SPACE_AFTER_CLOSING_BRACE_KEYWORDS = new Set([ + 'else', + 'catch', + 'finally', + 'while', +]); +const BRACE_SPACE_PARENTS = new Set(['Block', 'ClassBody', 'SwitchBody']); +const NO_SPACE_AFTER = new Set(['(', '[', '{', '!', '.', '?.', 'Spread']); + +const NO_BREAK = -1; + +type Frame = { + name: string; + from: number; + // The indentation of the line on which this node starts. + lineIndent: number; + // For block-like nodes: whether the node has children other than + // punctuation and comments. The closing brace only goes on its own line if + // the node has content. + hasContent: boolean; + // The name of the previous child that was visited. + lastChild: string | null; +}; + +class FormatEditsBuilder { + _buffer: FormatEdits = new Int32Array(3 * 1024); + _length = 0; + + push(from: number, to: number, ws: number) { + if (this._length + 3 > this._buffer.length) { + const newBuffer = new Int32Array(this._buffer.length * 2); + newBuffer.set(this._buffer); + this._buffer = newBuffer; + } + this._buffer[this._length++] = from; + this._buffer[this._length++] = to; + this._buffer[this._length++] = ws; + } + + finish(): FormatEdits { + return this._buffer.slice(0, this._length); + } +} + +function _isWhitespace(charCode: number): boolean { + return ( + charCode <= 32 || + charCode === 0xa0 || + charCode === 0xfeff || + charCode === 0x1680 || + (charCode >= 0x2000 && charCode <= 0x200a) || + charCode === 0x2028 || + charCode === 0x2029 || + charCode === 0x202f || + charCode === 0x205f || + charCode === 0x3000 + ); +} + +/** + * Returns the number of line breaks in code[from, to), or -1 if this range + * contains anything other than whitespace. Line breaks are counted in the same + * way as CodeMirror does: "\r\n", "\r" and "\n" are line breaks. + */ +function _countLineBreaksInGap(code: string, from: number, to: number): number { + let lineBreaks = 0; + for (let i = from; i < to; i++) { + const charCode = code.charCodeAt(i); + if (charCode === 10) { + if (i === from || code.charCodeAt(i - 1) !== 13) { + lineBreaks++; + } + } else if (charCode === 13) { + lineBreaks++; + } else if (!_isWhitespace(charCode)) { + return -1; + } + } + return lineBreaks; +} + +function _isSpacedOperator(name: string, parentName: string): boolean { + return OPERATORS.has(name) && !UNSPACED_OPERATOR_PARENTS.has(parentName); +} + +// Whether a space should be added between two tokens on the same line, when +// there wasn't any whitespace between them in the original code. +function _needsSpace( + prevName: string, + prevParentName: string, + name: string, + parentName: string +): boolean { + if (prevName === ',') { + return name !== ')' && name !== ']' && name !== ','; + } + if (prevName === ';') { + // This is a `for (;;)` header, or a comment after a statement. + return name !== ')' && name !== ';'; + } + if ( + _isSpacedOperator(name, parentName) || + _isSpacedOperator(prevName, prevParentName) + ) { + return true; + } + if (prevName === ':') { + // Object properties, destructuring, labels and type annotations. + return true; + } + if (SPACE_AFTER_KEYWORDS.has(prevName)) { + return !NO_SPACE_BEFORE.has(name); + } + if (SPACE_BEFORE_KEYWORDS.has(name)) { + // For example `for (let [a, b] of c)`. + return !NO_SPACE_AFTER.has(prevName); + } + if (prevName === '}' && SPACE_AFTER_CLOSING_BRACE_KEYWORDS.has(name)) { + return true; + } + if (name === '{' && BRACE_SPACE_PARENTS.has(parentName)) { + return !NO_SPACE_AFTER.has(prevName); + } + return false; +} + +/** + * Computes the format edits for `code`, using the syntax tree `tree`. Only + * the edits for the gaps which end in [rangeStart, rangeEnd) are returned. + * The tree can be a partial tree which only covers the code up to some + * position; then rangeEnd needs to be a bit before the end of the tree, + * because the nodes at the end of a partial tree can be incomplete. + */ +export function computeFormatEdits( + code: string, + tree: Tree, + rangeStart: number, + rangeEnd: number +): FormatEdits { + const edits = new FormatEditsBuilder(); + const cursor: TreeCursor = tree.cursor(); + const stack: Frame[] = [ + { + name: cursor.name, + from: cursor.from, + lineIndent: 0, + hasContent: false, + lastChild: null, + }, + ]; + + // The end of the previous token. The gap between this position and the + // start of the next token is the whitespace that we may replace. + let prevEnd = 0; + let prevName = ''; + let prevParentName = ''; + let hasEmittedToken = false; + // Whether the whitespace in the current gap has already been decided, by + // an ancestor of the current node which starts at the same position. + let isGapDecided = false; + // The indentation of the current line of the formatted code. + let currentLineIndent = 0; + + function pushWhitespace(from: number, to: number, ws: number) { + if (to < rangeStart || to >= rangeEnd) { + return; + } + // Only push an edit if the whitespace changes. + if (ws === FORMAT_WS_NONE) { + if (to === from) { + return; + } + } else if (ws === FORMAT_WS_SPACE) { + if (to - from === 1 && code.charCodeAt(from) === 32) { + return; + } + } else { + const lineBreaks = ws >> FORMAT_WS_NEWLINES_SHIFT; + const indent = ws & 0xffff; + const expected = '\n'.repeat(lineBreaks) + FORMAT_INDENT.repeat(indent); + if (to - from === expected.length && code.slice(from, to) === expected) { + return; + } + } + edits.push(from, to, ws); + } + + // The indentation for the children of a block-like node. + function contentIndent(parent: Frame, name: string): number { + switch (parent.name) { + case 'Script': + return 0; + case 'SwitchBody': + return ( + parent.lineIndent + + (name === 'CaseLabel' || name === 'DefaultLabel' ? 1 : 2) + ); + default: + return parent.lineIndent + 1; + } + } + + // Returns the indentation if the node needs to start on a new line, or + // NO_BREAK if it doesn't. + function getBreakRule(name: string, parent: Frame): number { + if (COMMENTS.has(name)) { + return NO_BREAK; + } + const parentName = parent.name; + if (BLOCK_LIKE.has(parentName)) { + if (name === '{' || name === ',' || name === ';') { + return NO_BREAK; + } + if (name === '}') { + return parent.hasContent ? parent.lineIndent : NO_BREAK; + } + parent.hasContent = true; + return contentIndent(parent, name); + } + if (BODY_PARENTS.has(parentName)) { + if ( + (name === 'else' && parentName === 'IfStatement') || + (name === 'while' && parentName === 'DoStatement') + ) { + // Keep `} else` together, but put `else` on its own line after a + // body without braces. + return parent.lastChild !== 'Block' ? parent.lineIndent : NO_BREAK; + } + if ( + name !== 'Block' && + name !== ';' && + cursor.type.is('Statement') && + !(name === 'IfStatement' && parent.lastChild === 'else') + ) { + return parent.lineIndent + 1; + } + return NO_BREAK; + } + if (parent.lastChild === ',') { + const grandparentName = stack[stack.length - 2]?.name; + if ( + parentName === 'VariableDeclaration' && + !FOR_SPECS.has(grandparentName) + ) { + return parent.lineIndent + 1; + } + if (parentName === 'SequenceExpression') { + if ( + grandparentName === 'ExpressionStatement' || + grandparentName === 'ReturnStatement' || + grandparentName === 'ThrowStatement' || + (grandparentName === 'ParenthesizedExpression' && + stack[stack.length - 3]?.name === 'ArrowFunction') + ) { + return parent.lineIndent + 1; + } + } + } + return NO_BREAK; + } + + // The indentation for a line break which was in the original code. + function continuationIndent(name: string, parent: Frame): number { + if (name === ')' || name === ']' || name === '}') { + return parent.lineIndent; + } + if (BLOCK_LIKE.has(parent.name)) { + return contentIndent(parent, name); + } + return parent.lineIndent + 1; + } + + // Decide whether there's a line break before the node at the cursor. + function handleLineBreakBefore(name: string, from: number, parent: Frame) { + const breakIndent = getBreakRule(name, parent); + if (isGapDecided) { + return; + } + const lineBreaks = _countLineBreaksInGap(code, prevEnd, from); + if (lineBreaks === -1) { + // There's something other than whitespace in this gap. This happens + // for code that isn't covered by the syntax tree. Leave it alone. + isGapDecided = true; + return; + } + if (!hasEmittedToken) { + // Remove whitespace at the start of the code. + pushWhitespace(prevEnd, from, FORMAT_WS_NONE); + isGapDecided = true; + return; + } + let indent = breakIndent; + if (indent === NO_BREAK && lineBreaks > 0) { + indent = continuationIndent(name, parent); + } + if (indent === NO_BREAK) { + return; + } + const newLineBreaks = Math.min(Math.max(lineBreaks, 1), 2); + pushWhitespace( + prevEnd, + from, + (newLineBreaks << FORMAT_WS_NEWLINES_SHIFT) | indent + ); + isGapDecided = true; + currentLineIndent = indent; + // Ancestors which start at the same position start on this line, too. + for (let i = stack.length - 1; i >= 0 && stack[i].from === from; i--) { + stack[i].lineIndent = indent; + } + } + + function emitToken(name: string, from: number, to: number, parent: Frame) { + if (!isGapDecided) { + const ws = + from > prevEnd || + _needsSpace(prevName, prevParentName, name, parent.name) + ? FORMAT_WS_SPACE + : FORMAT_WS_NONE; + pushWhitespace(prevEnd, from, ws); + } + prevEnd = to; + prevName = name; + prevParentName = parent.name; + hasEmittedToken = true; + isGapDecided = false; + } + + if (!cursor.firstChild()) { + return edits.finish(); + } + outer: for (;;) { + const { name, from, to } = cursor; + if (from >= rangeEnd) { + break; + } + const parent = stack[stack.length - 1]; + // Skip empty nodes, such as the error nodes that Lezer inserts. + if (to > from) { + handleLineBreakBefore(name, from, parent); + if (!ATOMIC.has(name) && cursor.firstChild()) { + stack.push({ + name, + from, + lineIndent: currentLineIndent, + hasContent: false, + lastChild: null, + }); + continue; + } + emitToken(name, from, to, parent); + parent.lastChild = name; + } + while (!cursor.nextSibling()) { + cursor.parent(); + const frame = stack.pop(); + if (stack.length === 0 || frame === undefined) { + break outer; + } + stack[stack.length - 1].lastChild = frame.name; + } + } + return edits.finish(); +} + +/** + * Formats the code in one go. This is used by tests. + */ +export function formatJsCode(code: string, dialect: JsDialect): FormatEdits { + const tree = parserForDialect(dialect).parse(code); + return computeFormatEdits(code, tree, 0, code.length + 1); +} + +// How much code before the end of a partial syntax tree is considered unsafe +// to format, because the nodes there might still change once more code is +// parsed. +const PARTIAL_TREE_MARGIN = 2000; + +// The minimum time to parse before reporting progress, in milliseconds. +const MIN_STEP_DURATION = 50; + +/** + * Formats the code in steps, and calls onProgress after each step with the + * edits of that step. The code is parsed incrementally. After each step, the + * format edits are computed by walking the partial syntax tree from the + * start, because the formatting state depends on all the code before it. To + * keep this overhead low, each step parses for at least four times as long + * as the last walk took. + */ +export function formatJsCodeIncrementally( + code: string, + dialect: JsDialect, + onProgress: ( + edits: FormatEdits, + formattedUpTo: number, + isDone: boolean + ) => void +) { + const parser = parserForDialect(dialect); + let fragments: readonly TreeFragment[] = []; + let formattedUpTo = 0; + let stepDuration = MIN_STEP_DURATION; + for (;;) { + const parse = parser.startParse(code, fragments); + const stepStart = performance.now(); + let tree: Tree | null; + while ((tree = parse.advance()) === null) { + if ( + parse.stoppedAt === null && + performance.now() - stepStart > stepDuration + ) { + parse.stopAt(parse.parsedPos); + } + } + const isDone = parse.stoppedAt === null; + const rangeEnd = isDone + ? code.length + 1 + : Math.max(formattedUpTo, tree.length - PARTIAL_TREE_MARGIN); + if (isDone || rangeEnd > formattedUpTo) { + const walkStart = performance.now(); + const edits = computeFormatEdits(code, tree, formattedUpTo, rangeEnd); + const walkDuration = performance.now() - walkStart; + stepDuration = Math.max(MIN_STEP_DURATION, walkDuration * 4); + formattedUpTo = Math.min(rangeEnd, code.length); + onProgress(edits, formattedUpTo, isDone); + } + if (isDone) { + return; + } + fragments = TreeFragment.addTree(tree, fragments, true); + } +} diff --git a/src/profile-logic/js-format/run-format-worker.ts b/src/profile-logic/js-format/run-format-worker.ts new file mode 100644 index 0000000000..8d66e0942c --- /dev/null +++ b/src/profile-logic/js-format/run-format-worker.ts @@ -0,0 +1,40 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { FormatWorkerInput, FormatWorkerOutput } from './types'; + +/** + * Formats JS code in a worker. Calls onOutput for each message from the + * worker, and terminates the worker after the last message. + * + * Tests mock this module, because the worker needs to be bundled by esbuild. + */ +export function runFormatWorker( + input: FormatWorkerInput, + onOutput: (output: FormatWorkerOutput) => void +): void { + let worker: Worker; + try { + worker = new Worker(FORMAT_WORKER_PATH); + } catch (error) { + onOutput({ type: 'error', message: String(error) }); + return; + } + + worker.onmessage = (e: MessageEvent) => { + const output = e.data; + onOutput(output); + if (output.type === 'error' || output.isDone) { + worker.terminate(); + } + }; + worker.onerror = (e: ErrorEvent) => { + onOutput({ + type: 'error', + message: e.error?.message ?? e.message ?? 'The format worker failed', + }); + worker.terminate(); + }; + worker.postMessage(input); +} diff --git a/src/profile-logic/js-format/types.ts b/src/profile-logic/js-format/types.ts new file mode 100644 index 0000000000..cd674ec429 --- /dev/null +++ b/src/profile-logic/js-format/types.ts @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { JsDialect } from './dialect'; + +/** + * Formatting JS source code ("pretty printing") only ever changes the + * whitespace between tokens. So the result of formatting is described by a + * list of "format edits" rather than by a new string. Each edit replaces the + * whitespace gap [from, to) in the original code with new whitespace. + * + * The edits are stored in an Int32Array, with three consecutive entries per + * edit: from, to, whitespace. The edits are sorted by position and don't + * overlap. The whitespace value is encoded as follows: + * - FORMAT_WS_NONE: The gap is removed. + * - FORMAT_WS_SPACE: The gap is replaced with a single space. + * - Any positive value: The gap is replaced with + * `(value >> FORMAT_WS_NEWLINES_SHIFT)` newlines, followed by + * `(value & FORMAT_WS_INDENT_MASK)` levels of indentation. + * + * Gaps whose whitespace stays the same don't have an edit. + */ +export type FormatEdits = Int32Array; + +export const FORMAT_WS_NONE = 0; +export const FORMAT_WS_SPACE = -1; +export const FORMAT_WS_NEWLINES_SHIFT = 16; +export const FORMAT_WS_INDENT_MASK = 0xffff; + +// The string for one level of indentation. +export const FORMAT_INDENT = ' '; + +export type FormatWorkerInput = { + code: string; + dialect: JsDialect; +}; + +/** + * The worker formats the code from front to back and sends a message after + * each step, so that the source view can show partially-formatted code while + * the worker is busy. Each message contains only the edits of this step, and + * these edits come after all the edits of earlier messages. The code before + * `formattedUpTo` is final; the code after it is still unformatted. + */ +export type FormatWorkerOutput = + | { + type: 'progress'; + edits: FormatEdits; + formattedUpTo: number; + isDone: boolean; + } + | { + type: 'error'; + message: string; + }; diff --git a/src/profile-logic/source-maps/scope-tree.ts b/src/profile-logic/source-maps/scope-tree.ts index 3741fbc9b8..3f9befb173 100644 --- a/src/profile-logic/source-maps/scope-tree.ts +++ b/src/profile-logic/source-maps/scope-tree.ts @@ -83,6 +83,10 @@ import { parser as lezerJsParser } from '@lezer/javascript'; import { bisectionRightByKey } from 'firefox-profiler/utils/bisect'; +import { + type JsDialect, + dialectForFilename, +} from 'firefox-profiler/profile-logic/js-format/dialect'; // Derive SyntaxNode from the parser to avoid version conflicts with nested // @lezer/common copies in node_modules (they have incompatible private fields). @@ -633,15 +637,13 @@ function _walkChildren( // Public API // --------------------------------------------------------------------------- -// @lezer/javascript ships TS and JSX as opt-in dialects. The base parser only -// understands plain JS, so a `.ts` or `.tsx` source feeding back through -// `sourcesContent` produces Error nodes for every type annotation or JSX -// element. Always configure the matching dialect for original sources. -export type JsDialect = '' | 'ts' | 'jsx' | 'ts jsx'; +// Always configure the matching dialect for original sources, see JsDialect. +export type { JsDialect }; +export { dialectForFilename }; const _configuredParsers = new Map(); -function _parserForDialect(dialect: JsDialect): typeof lezerJsParser { +export function parserForDialect(dialect: JsDialect): typeof lezerJsParser { if (!dialect) { return lezerJsParser; } @@ -653,29 +655,6 @@ function _parserForDialect(dialect: JsDialect): typeof lezerJsParser { return parser; } -/** - * Map a source filename to the Lezer dialect needed to parse it. Looks at the - * file extension only. Accepts URL-style source paths (the typical content of - * a source map's `sources[]`). - */ -export function dialectForFilename(filename: string): JsDialect { - const lower = filename.toLowerCase(); - if (lower.endsWith('.tsx')) { - return 'ts jsx'; - } - if (lower.endsWith('.jsx')) { - return 'jsx'; - } - if ( - lower.endsWith('.ts') || - lower.endsWith('.mts') || - lower.endsWith('.cts') - ) { - return 'ts'; - } - return ''; -} - /** * Parse `sourceText` with Lezer and return the top-level function scopes. * @@ -688,7 +667,7 @@ export function parseJsScopeTree( sourceText: string, dialect: JsDialect = '' ): FunctionScope[] { - const tree = _parserForDialect(dialect).parse(sourceText); + const tree = parserForDialect(dialect).parse(sourceText); const topLevel: FunctionScope[] = []; _walkChildren(tree.topNode, sourceText, topLevel); return topLevel; diff --git a/src/test/fixtures/format.worker.stub.js b/src/test/fixtures/format.worker.stub.js new file mode 100644 index 0000000000..a1d7a88fd0 --- /dev/null +++ b/src/test/fixtures/format.worker.stub.js @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// Test-only stub for src/profile-logic/js-format/format.worker.ts. The real +// worker bundles npm dependencies (lezer) into an IIFE via esbuild, so it +// can't be loaded directly from source by the node-worker fixture. Tests that +// exercise formatting should mock the +// `profile-logic/js-format/run-format-worker` module. This stub is the +// fallback that keeps the Worker spawn from failing when a dispatch slips +// through. + +onmessage = () => { + postMessage({ type: 'error', message: 'The format worker is a stub.' }); +}; diff --git a/src/test/unit/js-format.test.ts b/src/test/unit/js-format.test.ts new file mode 100644 index 0000000000..9c2935d810 --- /dev/null +++ b/src/test/unit/js-format.test.ts @@ -0,0 +1,267 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { stripIndent } from 'common-tags'; +import { TreeFragment } from '@lezer/common'; + +import { + computeFormatEdits, + formatJsCode, + formatJsCodeIncrementally, +} from '../../profile-logic/js-format/formatter'; +import { applyFormatEdits } from '../../profile-logic/js-format/formatted-source'; +import { parserForDialect } from '../../profile-logic/source-maps/scope-tree'; +import type { JsDialect } from '../../profile-logic/js-format/dialect'; + +function format(code: string, dialect: JsDialect = ''): string { + return applyFormatEdits(code, formatJsCode(code, dialect)).lines.join('\n'); +} + +function removeWhitespace(code: string): string { + return code.replace(/\s+/g, ''); +} + +// Some minified code, which is repeated to get code that's long enough for +// partial parses. +const MINIFIED_CODE = + 'function f(a,b){if(a)return b;else{for(let i=0;i{await g()},k=x=>(a(x),b(x));/* comment */'; + +describe('formatJsCode', function () { + it('puts statements on their own lines and indents blocks', function () { + expect( + format( + 'function f(a,b){if(a)return b;else{for(let i=0;i(a:T):Array{return
{b}
}', + 'ts jsx' + ) + ).toBe(stripIndent` + function f(a: T): Array { + return
{b}
+ } + `); + }); + + it('only changes whitespace', function () { + const formatted = format(MINIFIED_CODE); + expect(formatted).not.toBe(MINIFIED_CODE); + expect(removeWhitespace(formatted)).toBe(removeWhitespace(MINIFIED_CODE)); + }); + + it('does not throw on invalid code', function () { + const code = 'this is not valid javascript !!! {{{'; + expect(removeWhitespace(format(code))).toBe(removeWhitespace(code)); + }); +}); + +describe('formatting partial syntax trees', function () { + const code = MINIFIED_CODE.repeat(200); + + it('computes the same edits from partial trees as from the full tree', function () { + const parser = parserForDialect(''); + const fullEdits = formatJsCode(code, ''); + + // Parse the code in steps, like formatJsCodeIncrementally does, but at + // fixed positions. + const margin = 2000; + const allEdits = []; + let fragments: readonly TreeFragment[] = []; + let formattedUpTo = 0; + for (let stop = 5000; formattedUpTo <= code.length; stop += 5000) { + const parse = parser.startParse(code, fragments); + const isLastStep = stop >= code.length; + if (!isLastStep) { + parse.stopAt(stop); + } + let tree; + while ((tree = parse.advance()) === null) { + // Keep parsing. + } + const rangeEnd = isLastStep ? code.length + 1 : stop - margin; + allEdits.push(...computeFormatEdits(code, tree, formattedUpTo, rangeEnd)); + formattedUpTo = rangeEnd; + fragments = TreeFragment.addTree(tree, fragments, true); + } + + expect(allEdits).toEqual([...fullEdits]); + }); + + it('reports the same edits incrementally as in one go', function () { + const allEdits: number[] = []; + const doneFlags: boolean[] = []; + formatJsCodeIncrementally(code, '', (edits, _formattedUpTo, isDone) => { + allEdits.push(...edits); + doneFlags.push(isDone); + }); + expect(allEdits).toEqual([...formatJsCode(code, '')]); + expect(doneFlags[doneFlags.length - 1]).toBeTrue(); + expect(doneFlags.slice(0, -1)).not.toContain(true); + }); +}); + +describe('applyFormatEdits', function () { + it('maps positions in the original code to formatted lines', function () { + const code = 'a();b();\nc({x:1})'; + const formatted = applyFormatEdits(code, formatJsCode(code, '')); + expect(formatted.lines).toEqual(['a();', 'b();', 'c({', ' x: 1', '})']); + // Positions on the first original line. + expect(formatted.getFormattedLine(1, 1)).toBe(1); + expect(formatted.getFormattedLine(1, 5)).toBe(2); + expect(formatted.getFormattedLine(1, null)).toBe(1); + // Positions on the second original line. + expect(formatted.getFormattedLine(2, null)).toBe(3); + expect(formatted.getFormattedLine(2, 4)).toBe(4); + expect(formatted.getFormattedLine(2, 7)).toBe(5); + // Columns past the end of the line are clamped to the end of the line, + // lines past the end are clamped to the last line. + expect(formatted.getFormattedLine(2, 100)).toBe(5); + expect(formatted.getFormattedLine(5, 1)).toBe(3); + }); + + it('handles all kinds of line breaks', function () { + const code = 'a();\r\nb();\rc();x=`1\r\n2`;d()'; + const formatted = applyFormatEdits(code, formatJsCode(code, '')); + expect(formatted.lines).toEqual([ + 'a();', + 'b();', + 'c();', + 'x = `1', + '2`;', + 'd()', + ]); + expect(formatted.getFormattedLine(2, 1)).toBe(2); + expect(formatted.getFormattedLine(3, 1)).toBe(3); + expect(formatted.getFormattedLine(3, 5)).toBe(4); + expect(formatted.getFormattedLine(4, 1)).toBe(5); + expect(formatted.getFormattedLine(4, 4)).toBe(6); + }); + + it('shows the unformatted rest of partially-formatted code', function () { + const code = 'a();b();c();d()'; + const edits = formatJsCode(code, ''); + // Only keep the first edit, as if the worker was still busy. + const formatted = applyFormatEdits(code, edits.slice(0, 3)); + expect(formatted.lines).toEqual(['a();', 'b();c();d()']); + expect(formatted.getFormattedLine(1, 12)).toBe(2); + }); +}); diff --git a/src/types/globals/global.d.ts b/src/types/globals/global.d.ts index 676f09189e..90284b2bb4 100644 --- a/src/types/globals/global.d.ts +++ b/src/types/globals/global.d.ts @@ -5,6 +5,7 @@ // Added by esbuild's define option declare const AVAILABLE_STAGING_LOCALES: string[] | null; declare const SOURCE_MAP_WORKER_PATH: string; +declare const FORMAT_WORKER_PATH: string; declare module '*.worker.js' { const content: string; From dda6c5c311176ac4316073ff8c9b05973bf6219c Mon Sep 17 00:00:00 2001 From: Markus Stange Date: Sun, 18 Oct 2026 18:26:51 -0400 Subject: [PATCH 2/2] Add a {} button to pretty-print JS code in the source views. Both the source view and the generated source view get a {} toggle button for JavaScript sources. The URL records it with the sourceViewPretty and generatedSourceViewPretty parameters. Once the code of a pretty-printed source is available, the source is formatted in the format worker. The edits of each formatting step go into a new cache in the code state, so the view shows partially-formatted code while the worker is busy. The source view passes the formatted lines to CodeMirror as an array instead of one string. The line timings use the columns of the frames to find the formatted line for each sample, because minified code often has very long lines. For the same reason, the bottom box now also records the column of the line to scroll to and of the highlighted line. --- locales/en-US/app.ftl | 10 + src/actions/code.ts | 45 +++ src/actions/profile-view.ts | 11 + src/app-logic/url-handling.ts | 7 + src/components/app/BottomBox.css | 11 + src/components/app/BottomBox.tsx | 97 ++++++- .../app/PrettyPrintToggleButton.tsx | 66 +++++ src/components/app/ProfileViewer.tsx | 2 + src/components/app/SourceFormatter.tsx | 55 ++++ .../shared/SourceView-codemirror.ts | 20 +- src/components/shared/SourceView.tsx | 38 ++- src/profile-logic/bottom-box.ts | 25 +- src/profile-logic/js-format/dialect.ts | 27 ++ .../js-format/formatted-source.ts | 42 ++- src/profile-logic/line-timings.ts | 106 ++++++- src/reducers/code.ts | 51 ++++ src/reducers/url-state.ts | 7 + src/selectors/code.tsx | 119 +++++++- src/selectors/per-thread/stack-sample.ts | 8 +- src/selectors/profile.ts | 7 + src/selectors/url-state.ts | 8 + .../fixtures/profiles/processed-profile.ts | 20 +- src/test/store/source-formatting.test.ts | 273 ++++++++++++++++++ src/test/unit/js-format.test.ts | 34 ++- src/test/url-handling.test.ts | 29 ++ src/types/actions.ts | 19 ++ src/types/profile-derived.ts | 5 + src/types/state.ts | 21 ++ 28 files changed, 1136 insertions(+), 27 deletions(-) create mode 100644 src/components/app/PrettyPrintToggleButton.tsx create mode 100644 src/components/app/SourceFormatter.tsx create mode 100644 src/test/store/source-formatting.test.ts diff --git a/locales/en-US/app.ftl b/locales/en-US/app.ftl index 5a1811bd72..53a37d452d 100644 --- a/locales/en-US/app.ftl +++ b/locales/en-US/app.ftl @@ -1420,6 +1420,16 @@ SourceView--not-in-browser-error-when-obtaining-js-source = ## Toggle buttons in the top right corner of the bottom box +# The {} toggle button for pretty-printing the JavaScript code in the source +# view, while the code is shown as it is. Pretty-printing adds line breaks and +# indentation to make minified code easier to read. +SourceView--pretty-print-show-button = + .title = Pretty-print the code + +# The {} toggle button for pretty-printing, while the code is pretty-printed. +SourceView--pretty-print-hide-button = + .title = Show the code without pretty-printing + # The toggle button for the assembly view, while the assembly view is hidden. # Assembly refers to the low-level programming language. AssemblyView--show-button = diff --git a/src/actions/code.ts b/src/actions/code.ts index aa235e2d1e..d176c8b8a6 100644 --- a/src/actions/code.ts +++ b/src/actions/code.ts @@ -3,11 +3,16 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import type { Action, + ThunkAction, SourceCodeLoadingError, ApiQueryError, DecodedInstruction, IndexIntoSourceTable, } from 'firefox-profiler/types'; +import type { SourceFormattingRequest } from 'firefox-profiler/selectors/code'; +import { getSourceFormattingCache } from 'firefox-profiler/selectors/code'; +import { runFormatWorker } from 'firefox-profiler/profile-logic/js-format/run-format-worker'; +import { assertExhaustiveCheck } from 'firefox-profiler/utils/types'; export function beginLoadingSourceCodeFromUrl( sourceIndex: IndexIntoSourceTable, @@ -69,3 +74,43 @@ export function failLoadingAssemblyCode( ): Action { return { type: 'ASSEMBLY_CODE_LOADING_ERROR', nativeSymbolKey, errors }; } + +/** + * Pretty-prints a source in the format worker. The worker reports its progress + * in steps, so that the source view can show partially-formatted code. + */ +export function startFormattingSource( + request: SourceFormattingRequest +): ThunkAction { + return (dispatch, getState) => { + const { sourceIndex, code, dialect } = request; + if (getSourceFormattingCache(getState()).has(sourceIndex)) { + return; + } + dispatch({ type: 'SOURCE_FORMATTING_BEGIN', sourceIndex }); + runFormatWorker({ code, dialect }, (output) => { + switch (output.type) { + case 'progress': + dispatch({ + type: 'SOURCE_FORMATTING_PROGRESS', + sourceIndex, + edits: output.edits, + isDone: output.isDone, + }); + break; + case 'error': + console.error('Formatting the source failed:', output.message); + // Keep the edits that were made so far. + dispatch({ + type: 'SOURCE_FORMATTING_PROGRESS', + sourceIndex, + edits: new Int32Array(0), + isDone: true, + }); + break; + default: + throw assertExhaustiveCheck(output); + } + }); + }; +} diff --git a/src/actions/profile-view.ts b/src/actions/profile-view.ts index 56cb1adc4b..f7690f77cd 100644 --- a/src/actions/profile-view.ts +++ b/src/actions/profile-view.ts @@ -1957,6 +1957,8 @@ export function updateBottomBoxContentsAndMaybeOpen( scrollToInstructionAddress, highlightedLineNumber, highlightedInstructionAddress, + scrollToColumnNumber, + highlightedColumnNumber, } = bottomBoxInfo; const haveSource = sourceIndex !== null; @@ -1980,6 +1982,15 @@ export function updateBottomBoxContentsAndMaybeOpen( scrollToInstructionAddress, highlightedLineNumber, highlightedInstructionAddress, + scrollToColumnNumber, + highlightedColumnNumber, + }; +} + +export function setSourceViewPrettyPrint(isPrettyPrinted: boolean): Action { + return { + type: 'SET_SOURCE_VIEW_PRETTY_PRINT', + isPrettyPrinted, }; } diff --git a/src/app-logic/url-handling.ts b/src/app-logic/url-handling.ts index 4f18c2037b..f2c4ae67e0 100644 --- a/src/app-logic/url-handling.ts +++ b/src/app-logic/url-handling.ts @@ -179,6 +179,7 @@ type BaseQuery = { implementation: string; timelineType: string; sourceViewIndex: number; + sourceViewPretty: null | undefined; assemblyView: string; }; @@ -224,6 +225,7 @@ type Query = BaseQuery & { ctSummary?: string; transforms?: string; sourceViewIndex?: number; + sourceViewPretty?: null | undefined; assemblyView?: string; bottomFullscreen?: boolean; @@ -381,6 +383,9 @@ export function getQueryStringFromUrlState(urlState: UrlState): string { if (isBottomBoxOpenPerPanel[selectedTab]) { if (sourceView.sourceIndex !== null) { query.sourceViewIndex = sourceView.sourceIndex; + query.sourceViewPretty = sourceView.isPrettyPrinted + ? null + : undefined; } if (assemblyView.isOpen && assemblyView.currentNativeSymbol !== null) { const { currentNativeSymbol, nativeSymbols } = assemblyView; @@ -583,6 +588,8 @@ export function stateFromLocation( scrollGeneration: 0, sourceIndex: null, highlightedLine: null, + highlightedColumn: null, + isPrettyPrinted: query.sourceViewPretty !== undefined, }; const assemblyView: AssemblyViewState = { isOpen: false, diff --git a/src/components/app/BottomBox.css b/src/components/app/BottomBox.css index edcd24b026..ce2e6f87d8 100644 --- a/src/components/app/BottomBox.css +++ b/src/components/app/BottomBox.css @@ -85,6 +85,7 @@ margin: 0; } +.bottom-box-header-buttons, .bottom-box-header-trailing-buttons { display: flex; height: 100%; @@ -106,6 +107,16 @@ background-size: 16px 16px; } +.bottom-pretty-print-button { + min-width: 24px; + height: 24px; + flex-shrink: 0; + padding: 0 4px; + font-family: monospace; + font-size: 12px; + font-weight: bold; +} + .bottom-prev-button, .bottom-next-button { width: 16px; diff --git a/src/components/app/BottomBox.tsx b/src/components/app/BottomBox.tsx index 669146ca48..916e79b2f8 100644 --- a/src/components/app/BottomBox.tsx +++ b/src/components/app/BottomBox.tsx @@ -10,6 +10,7 @@ import { AssemblyView } from '../shared/AssemblyView'; import { FullscreenToggleButton } from './FullscreenToggleButton'; import { ResizableWithSplitter } from '../shared/ResizableWithSplitter'; import { AssemblyViewToggleButton } from './AssemblyViewToggleButton'; +import { PrettyPrintToggleButton } from './PrettyPrintToggleButton'; import { AssemblyViewNativeSymbolNavigator } from './AssemblyViewNativeSymbolNavigator'; import { IonGraphView } from '../shared/IonGraphView'; import { CodeLoadingOverlay } from './CodeLoadingOverlay'; @@ -18,6 +19,9 @@ import { getSourceViewScrollGeneration, getSourceViewScrollToLineNumber, getSourceViewHighlightedLine, + getSourceViewScrollToColumnNumber, + getSourceViewHighlightedColumn, + getSourceViewIsPrettyPrinted, getAssemblyViewIsOpen, getAssemblyViewNativeSymbol, getAssemblyViewScrollGeneration, @@ -28,13 +32,17 @@ import { import { closeBottomBox, toggleBottomBoxFullscreen, + setSourceViewPrettyPrint, } from 'firefox-profiler/actions/profile-view'; import { selectedThreadSelectors } from 'firefox-profiler/selectors/per-thread'; import { parseFileNameFromSymbolication } from 'firefox-profiler/utils/special-paths'; import { getSourceViewCode, getAssemblyViewCode, + getSourceViewFormatDialect, + getSourceViewFormatting, } from 'firefox-profiler/selectors/code'; +import type { SourceViewFormatting } from 'firefox-profiler/profile-logic/js-format/formatted-source'; import { getSourceViewFile, getSourceViewStartLine, @@ -64,7 +72,12 @@ type StateProps = { readonly sourceViewScrollGeneration: number; readonly sourceViewScrollToLineNumber?: number; readonly sourceViewHighlightedLine: number | null; + readonly sourceViewScrollToColumnNumber?: number; + readonly sourceViewHighlightedColumn: number | null; readonly sourceViewStartLine: number; + readonly sourceViewCanPrettyPrint: boolean; + readonly sourceViewIsPrettyPrinted: boolean; + readonly sourceViewFormatting: SourceViewFormatting | null; readonly globalLineTimings: LineTimings; readonly assemblyViewIsOpen: boolean; readonly assemblyViewNativeSymbol: NativeSymbolInfo | null; @@ -78,10 +91,54 @@ type StateProps = { type DispatchProps = { readonly closeBottomBox: typeof closeBottomBox; readonly toggleBottomBoxFullscreen: typeof toggleBottomBoxFullscreen; + readonly setSourceViewPrettyPrint: typeof setSourceViewPrettyPrint; }; type Props = ConnectedProps<{}, StateProps, DispatchProps>; +type DisplayedLines = { + formattedLines: string[] | null; + startLine: number; + scrollToLineNumber?: number; + highlightedLine: number | null; +}; + +/** + * The line numbers in the state refer to the code as it is. If the code is + * pretty-printed, convert them to the lines of the formatted code. The + * formatted code starts at line 1, even for inline scripts. + */ +function _getDisplayedLines( + formatting: SourceViewFormatting | null, + startLine: number, + scrollToLineNumber: number | undefined, + scrollToColumnNumber: number | undefined, + highlightedLine: number | null, + highlightedColumn: number | null +): DisplayedLines { + if (formatting === null) { + return { + formattedLines: null, + startLine, + scrollToLineNumber, + highlightedLine, + }; + } + const { lines, mapPositionToLine } = formatting; + return { + formattedLines: lines, + startLine: 1, + scrollToLineNumber: + scrollToLineNumber !== undefined + ? mapPositionToLine(scrollToLineNumber, scrollToColumnNumber ?? null) + : undefined, + highlightedLine: + highlightedLine !== null + ? mapPositionToLine(highlightedLine, highlightedColumn) + : null, + }; +} + export function SourceCodeErrorOverlay({ errors }: CodeErrorOverlayProps) { return (
@@ -174,6 +231,10 @@ class BottomBoxImpl extends React.PureComponent { } }; + _onChangeSourceViewPrettyPrint = (isPrettyPrinted: boolean) => { + this.props.setSourceViewPrettyPrint(isPrettyPrinted); + }; + _onClickCloseButton = () => { this.props.closeBottomBox(); // Close the fullscreen if we're closing the bottom box @@ -191,7 +252,12 @@ class BottomBoxImpl extends React.PureComponent { sourceViewScrollGeneration, sourceViewScrollToLineNumber, sourceViewHighlightedLine, + sourceViewScrollToColumnNumber, + sourceViewHighlightedColumn, sourceViewStartLine, + sourceViewCanPrettyPrint, + sourceViewIsPrettyPrinted, + sourceViewFormatting, assemblyViewIsOpen, assemblyViewScrollGeneration, assemblyViewScrollToInstructionAddress, @@ -215,6 +281,14 @@ class BottomBoxImpl extends React.PureComponent { const sourceIsIonGraph = path !== null && path.endsWith('iongraph.json'); const displaySourceView = sourceViewFile !== null && !sourceIsIonGraph; const displayIonGraph = sourceViewFile !== null && sourceIsIonGraph; + const sourceViewLines = _getDisplayedLines( + sourceViewFormatting, + sourceViewStartLine, + sourceViewScrollToLineNumber, + sourceViewScrollToColumnNumber, + sourceViewHighlightedLine, + sourceViewHighlightedColumn + ); // The bottom box has one or more side-by-side panes. // At the moment it always has either one or two panes: @@ -252,7 +326,15 @@ class BottomBoxImpl extends React.PureComponent {

{path ?? '(no source file)'}

- {assemblyViewIsOpen ? null : trailingHeaderButtons} +
+ {displaySourceView && sourceViewCanPrettyPrint ? ( + + ) : null} + {assemblyViewIsOpen ? null : trailingHeaderButtons} +
{displayIonGraph ? ( @@ -265,11 +347,12 @@ class BottomBoxImpl extends React.PureComponent { ) : null} @@ -346,7 +429,12 @@ export const BottomBox = explicitConnect<{}, StateProps, DispatchProps>({ sourceViewScrollGeneration: getSourceViewScrollGeneration(state), sourceViewScrollToLineNumber: getSourceViewScrollToLineNumber(state), sourceViewHighlightedLine: getSourceViewHighlightedLine(state), + sourceViewScrollToColumnNumber: getSourceViewScrollToColumnNumber(state), + sourceViewHighlightedColumn: getSourceViewHighlightedColumn(state), sourceViewStartLine: getSourceViewStartLine(state), + sourceViewCanPrettyPrint: getSourceViewFormatDialect(state) !== null, + sourceViewIsPrettyPrinted: getSourceViewIsPrettyPrinted(state), + sourceViewFormatting: getSourceViewFormatting(state), assemblyViewNativeSymbol: getAssemblyViewNativeSymbol(state), assemblyViewCode: getAssemblyViewCode(state), globalAddressTimings: @@ -361,6 +449,7 @@ export const BottomBox = explicitConnect<{}, StateProps, DispatchProps>({ mapDispatchToProps: { closeBottomBox, toggleBottomBoxFullscreen, + setSourceViewPrettyPrint, }, component: BottomBoxImpl, }); diff --git a/src/components/app/PrettyPrintToggleButton.tsx b/src/components/app/PrettyPrintToggleButton.tsx new file mode 100644 index 0000000000..10e06530e7 --- /dev/null +++ b/src/components/app/PrettyPrintToggleButton.tsx @@ -0,0 +1,66 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from 'react'; +import classNames from 'classnames'; +import { Localized } from '@fluent/react'; + +type Props = { + readonly isPrettyPrinted: boolean; + readonly onChange: (isPrettyPrinted: boolean) => void; +}; + +/** + * The {} button in the header of a source view pane, which toggles whether + * the JS code in this pane is pretty-printed. + */ +export class PrettyPrintToggleButton extends React.PureComponent { + _onClick = () => { + this.props.onChange(!this.props.isPrettyPrinted); + }; + + override render() { + const { isPrettyPrinted } = this.props; + return isPrettyPrinted ? ( + + + + ) : ( + + + + ); + } +} diff --git a/src/components/app/ProfileViewer.tsx b/src/components/app/ProfileViewer.tsx index 098864fbb3..d5974ef017 100644 --- a/src/components/app/ProfileViewer.tsx +++ b/src/components/app/ProfileViewer.tsx @@ -8,6 +8,7 @@ import explicitConnect from 'firefox-profiler/utils/connect'; import { ResizableWithSplitter } from 'firefox-profiler/components/shared/ResizableWithSplitter'; import { DetailsContainer } from './DetailsContainer'; import { SourceCodeFetcher } from './SourceCodeFetcher'; +import { SourceFormatter } from './SourceFormatter'; import { AssemblyCodeFetcher } from './AssemblyCodeFetcher'; import { BottomBox } from './BottomBox'; import { ProfileFilterNavigator } from './ProfileFilterNavigator'; @@ -140,6 +141,7 @@ class ProfileViewerImpl extends PureComponent { +
diff --git a/src/components/app/SourceFormatter.tsx b/src/components/app/SourceFormatter.tsx new file mode 100644 index 0000000000..902117a4d3 --- /dev/null +++ b/src/components/app/SourceFormatter.tsx @@ -0,0 +1,55 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import React from 'react'; + +import { getSourceViewFormattingRequest } from 'firefox-profiler/selectors/code'; +import type { SourceFormattingRequest } from 'firefox-profiler/selectors/code'; +import { startFormattingSource } from 'firefox-profiler/actions/code'; +import explicitConnect from 'firefox-profiler/utils/connect'; + +import type { ConnectedProps } from 'firefox-profiler/utils/connect'; + +type StateProps = { + readonly formattingRequest: SourceFormattingRequest | null; +}; + +type DispatchProps = { + readonly startFormattingSource: typeof startFormattingSource; +}; + +type Props = ConnectedProps<{}, StateProps, DispatchProps>; + +/** + * Starts pretty-printing the source in the source view once the code is + * available, if the source view is pretty-printed. + */ +class SourceFormatterImpl extends React.PureComponent { + override componentDidMount() { + this._startFormattingIfNeeded(); + } + + override componentDidUpdate() { + this._startFormattingIfNeeded(); + } + + _startFormattingIfNeeded() { + const { formattingRequest, startFormattingSource } = this.props; + if (formattingRequest !== null) { + startFormattingSource(formattingRequest); + } + } + + override render() { + return null; + } +} + +export const SourceFormatter = explicitConnect<{}, StateProps, DispatchProps>({ + mapStateToProps: (state) => ({ + formattingRequest: getSourceViewFormattingRequest(state), + }), + mapDispatchToProps: { startFormattingSource }, + component: SourceFormatterImpl, +}); diff --git a/src/components/shared/SourceView-codemirror.ts b/src/components/shared/SourceView-codemirror.ts index ae88c8d4ff..7be9242ad4 100644 --- a/src/components/shared/SourceView-codemirror.ts +++ b/src/components/shared/SourceView-codemirror.ts @@ -19,7 +19,7 @@ * width of the editor, it covers both the gutter and the main area. */ import { EditorView, lineNumbers } from '@codemirror/view'; -import { EditorState, Compartment } from '@codemirror/state'; +import { EditorState, Compartment, Text } from '@codemirror/state'; import { type LanguageSupport, syntaxHighlighting } from '@codemirror/language'; import { classHighlighter } from '@lezer/highlight'; import { cpp } from '@codemirror/lang-cpp'; @@ -93,6 +93,15 @@ const codeViewerExtension = [ EditorView.contentAttributes.of({ tabindex: '0' }), ]; +// Pretty-printed code is passed as an array of lines, most of which are +// slices of the original code. Building the document from the lines avoids +// building the formatted code as one big string. It's also much faster than +// applying the formatting as many small changes, because CodeMirror copies the +// whole line for each change, and minified code has very long lines. +function _toDoc(text: string | string[]): string | Text { + return typeof text === 'string' ? text : Text.of(text); +} + // Creates a lineNumbers extension that displays line numbers offset by startLine - 1. function _lineNumbersForStartLine(startLine: number) { if (startLine <= 1) { @@ -107,8 +116,9 @@ export class SourceViewEditor { _view: EditorView; // Create a CodeMirror editor and add it as a child element of domParent. + // The text can be a string or an array of lines. constructor( - initialText: string, + initialText: string | string[], path: string, timings: LineTimings, highlightedLine: number | null, @@ -116,7 +126,7 @@ export class SourceViewEditor { domParent: Element ) { let state = EditorState.create({ - doc: initialText, + doc: _toDoc(initialText), extensions: [ timingsExtension, lineNumbersConf.of(_lineNumbersForStartLine(startLine)), @@ -141,13 +151,13 @@ export class SourceViewEditor { }); } - setContents(text: string) { + setContents(text: string | string[]) { // The CodeMirror way of replacing the entire contents is to insert new text // and overwrite the full range of existing text. this._view.dispatch( this._view.state.update({ changes: { - insert: text, + insert: _toDoc(text), from: 0, to: this._view.state.doc.length, }, diff --git a/src/components/shared/SourceView.tsx b/src/components/shared/SourceView.tsx index 0080e4e211..08beec5b28 100644 --- a/src/components/shared/SourceView.tsx +++ b/src/components/shared/SourceView.tsx @@ -39,6 +39,10 @@ for understanding where time was actually spent in a program." type SourceViewProps = { readonly timings: LineTimings; readonly sourceCode: string; + // The lines of the pretty-printed code, or null if the code is shown as it + // is. If this is non-null, it's displayed instead of sourceCode, and all + // line numbers in the other props refer to the pretty-printed lines. + readonly formattedLines: string[] | null; readonly filePath: string | null; readonly scrollGeneration: number; readonly scrollToLineNumber?: number; @@ -101,7 +105,10 @@ export class SourceView extends React.PureComponent { } _getMaxLineNumber() { - const { sourceCode, timings, startLine } = this.props; + const { sourceCode, formattedLines, timings, startLine } = this.props; + if (formattedLines !== null) { + return formattedLines.length; + } const sourceLines = sourceCode.split('\n'); let maxLineNumber = sourceLines.length; if (maxLineNumber <= 1) { @@ -119,8 +126,11 @@ export class SourceView extends React.PureComponent { return maxLineNumber; } - _getSourceCodeOrFallback() { - const { sourceCode } = this.props; + _getSourceCodeOrFallback(): string | string[] { + const { sourceCode, formattedLines } = this.props; + if (formattedLines !== null) { + return formattedLines; + } if (sourceCode !== '') { return sourceCode; } @@ -183,19 +193,29 @@ export class SourceView extends React.PureComponent { this._editor.setStartLine(this.props.startLine); } - let contentsChanged = false; - if ( + const sourceChanged = this.props.sourceCode !== prevProps.sourceCode || (this.props.sourceCode === '' && prevProps.sourceCode === '' && - this.props.timings !== prevProps.timings) - ) { + this.props.timings !== prevProps.timings); + const formattingChanged = + this.props.formattedLines !== prevProps.formattedLines; + if (sourceChanged || formattingChanged) { this._editor.setContents(this._getSourceCodeOrFallback()); - contentsChanged = true; } + // While the code is being pretty-printed, the formatted lines are updated + // a few times. Only scroll again if the scroll target moved, so that the + // user can scroll in the meantime. + const formattingToggled = + (this.props.formattedLines === null) !== + (prevProps.formattedLines === null); + const scrollTargetMoved = + this.props.scrollToLineNumber !== prevProps.scrollToLineNumber; if ( - contentsChanged || + sourceChanged || + formattingToggled || + (formattingChanged && scrollTargetMoved) || this.props.scrollGeneration !== prevProps.scrollGeneration ) { // If an explicit line number is provided, scroll to it. Otherwise, scroll to the hotspot. diff --git a/src/profile-logic/bottom-box.ts b/src/profile-logic/bottom-box.ts index f857ca95a2..e5fa6db6d4 100644 --- a/src/profile-logic/bottom-box.ts +++ b/src/profile-logic/bottom-box.ts @@ -22,7 +22,10 @@ import { getTotalNativeSymbolTimingsForCallNode, } from './profile-data'; import { mapGetKeyWithMaxValue } from 'firefox-profiler/utils'; -import { getTotalLineTimingsForCallNode } from './line-timings'; +import { + getTotalLineTimingsForCallNode, + getHottestColumnForLineInCallNode, +} from './line-timings'; import { getTotalAddressTimingsForCallNode } from './address-timings'; /** @@ -108,6 +111,17 @@ export function getBottomBoxInfoForCallNode( thread.sourceLocationTable ); const hottestLine = mapGetKeyWithMaxValue(lineTimings); + const hottestColumn = + hottestLine !== undefined + ? getHottestColumnForLineInCallNode( + samples, + callNodeFramePerStack, + frameTable, + funcTable, + hottestLine, + thread.sourceLocationTable + ) + : undefined; const addressTimings = getTotalAddressTimingsForCallNode( samples, callNodeFramePerStack, @@ -124,6 +138,7 @@ export function getBottomBoxInfoForCallNode( ? nativeSymbolsForCallNodeArr.indexOf(initialNativeSymbol) : null, scrollToLineNumber: hottestLine, + scrollToColumnNumber: hottestColumn, scrollToInstructionAddress: hottestInstructionAddress, highlightedLineNumber: null, highlightedInstructionAddress: null, @@ -143,7 +158,11 @@ export function getBottomBoxInfoForStackFrame( const frameIndex = stackTable.frame[stackIndex]; const funcIndex = frameTable.func[frameIndex]; - const { source: sourceIndex, line: lineNumber } = getOriginalPositionForFrame( + const { + source: sourceIndex, + line: lineNumber, + column: columnNumber, + } = getOriginalPositionForFrame( frameIndex, funcIndex, frameTable, @@ -173,7 +192,9 @@ export function getBottomBoxInfoForStackFrame( nativeSymbols: nativeSymbolInfos, initialNativeSymbol: 0, scrollToLineNumber: lineNumber ?? undefined, + scrollToColumnNumber: columnNumber ?? undefined, highlightedLineNumber: lineNumber, + highlightedColumnNumber: columnNumber ?? undefined, scrollToInstructionAddress: instructionAddress ?? undefined, highlightedInstructionAddress: instructionAddress, }; diff --git a/src/profile-logic/js-format/dialect.ts b/src/profile-logic/js-format/dialect.ts index c0cd9cf27c..875a5bedc7 100644 --- a/src/profile-logic/js-format/dialect.ts +++ b/src/profile-logic/js-format/dialect.ts @@ -34,3 +34,30 @@ export function dialectForFilename(filename: string): JsDialect { } return ''; } + +const NON_JS_EXTENSIONS = [ + '.rs', + '.c', + '.cc', + '.cpp', + '.cxx', + '.h', + '.hpp', + '.m', + '.mm', + '.json', +]; + +/** + * Returns the dialect to use for formatting the source at this path, or null + * if the source isn't JavaScript. Like the syntax highlighting in the source + * view, this treats unknown file types as JavaScript, because inline scripts + * share the page URL and don't have a .js extension. + */ +export function getFormatDialectForPath(path: string): JsDialect | null { + const lower = path.toLowerCase(); + if (NON_JS_EXTENSIONS.some((extension) => lower.endsWith(extension))) { + return null; + } + return dialectForFilename(lower); +} diff --git a/src/profile-logic/js-format/formatted-source.ts b/src/profile-logic/js-format/formatted-source.ts index 782e934d6b..7b71afd90b 100644 --- a/src/profile-logic/js-format/formatted-source.ts +++ b/src/profile-logic/js-format/formatted-source.ts @@ -6,10 +6,13 @@ * Applies format edits on the main thread. The formatted code is never built * as one string: it's built as an array of lines, and most lines are slices * of the original code. JS engines can usually represent such slices without - * copying the characters. + * copying the characters. The source view passes the lines to CodeMirror, + * which stores them in a tree of lines. */ import { bisectionRight } from 'firefox-profiler/utils/bisect'; +import type { PositionToLineMapper } from 'firefox-profiler/profile-logic/line-timings'; +import type { SourceFormattingStatus } from 'firefox-profiler/types'; import type { FormatEdits } from './types'; import { FORMAT_INDENT, @@ -180,3 +183,40 @@ export function applyFormatEdits( }, }; } + +/** + * What the source view needs to show pretty-printed code. + */ +export type SourceViewFormatting = { + // The lines of the formatted code. + readonly lines: string[]; + // False while the format worker is busy. Then only the start of the code is + // formatted. + readonly isDone: boolean; + // Maps a line and column from the profile to the line in the formatted + // code. Unlike FormattedSource.getFormattedLine, this accepts the line and + // column numbers from the profile, which are relative to the start of the + // file, not to the start of the source. This matters for inline scripts. + readonly mapPositionToLine: PositionToLineMapper; +}; + +export function createSourceViewFormatting( + code: string, + status: SourceFormattingStatus, + startLine: number, + startColumn: number +): SourceViewFormatting { + const formattedSource = applyFormatEdits(code, status.edits); + return { + lines: formattedSource.lines, + isDone: status.isDone, + mapPositionToLine: (line, column) => { + const relativeLine = line - startLine + 1; + const relativeColumn = + column !== null && relativeLine === 1 + ? column - startColumn + 1 + : column; + return formattedSource.getFormattedLine(relativeLine, relativeColumn); + }, + }; +} diff --git a/src/profile-logic/line-timings.ts b/src/profile-logic/line-timings.ts index f792c3b38d..7cf88f21c4 100644 --- a/src/profile-logic/line-timings.ts +++ b/src/profile-logic/line-timings.ts @@ -16,6 +16,14 @@ import type { } from 'firefox-profiler/types'; import { FrameFlag, FuncFlag } from 'firefox-profiler/types'; import { SetCollectionBuilder } from 'firefox-profiler/utils/set-collection'; +import { mapGetKeyWithMaxValue } from 'firefox-profiler/utils'; + +// Maps a line and column in a source to the line that should be used for the +// line timings. The column is null if it's unknown. +export type PositionToLineMapper = ( + line: LineNumber, + column: number | null +) => LineNumber; /** * For each stack in `stackTable`, and one specific source file, compute the @@ -44,13 +52,18 @@ import { SetCollectionBuilder } from 'firefox-profiler/utils/set-collection'; * This last line is the stack's "self line". * If there is recursion, and the same line is present in multiple frames in the * same stack, the line is only counted once - the lines are stored in a set. + * + * If mapPositionToLine is given, it maps each line and column to the line + * which is used in the result. This is used when the source view shows + * pretty-printed code. The column is null if it's unknown. */ export function getStackLineInfo( stackTable: StackTable, frameTable: FrameTable, funcTable: FuncTable, sourceViewSourceIndex: IndexIntoSourceTable, - sourceLocationTable: SourceLocationTable + sourceLocationTable: SourceLocationTable, + mapPositionToLine: PositionToLineMapper | null = null ): StackLineInfo { const builder = new SetCollectionBuilder(); const stackIndexToLineSetIndex = new Int32Array(stackTable.length); @@ -98,14 +111,26 @@ export function getStackLineInfo( } else { let selfLineOrNull: number | null = null; if (matchesSource) { + let column: number | null = null; if (frameHasOriginalLocation) { selfLineOrNull = sourceLocationTable.line[frameOriginalLocationIdx]; + column = sourceLocationTable.column[frameOriginalLocationIdx]; } else if (funcHasOriginalLocation) { selfLineOrNull = sourceLocationTable.line[funcOriginalLocationIdx]; + column = sourceLocationTable.column[funcOriginalLocationIdx]; } else if ((frameFlags & FrameFlag.HasLine) !== 0) { selfLineOrNull = frameTable.line[frame]; + if ((frameFlags & FrameFlag.HasColumn) !== 0) { + column = frameTable.column[frame]; + } } else if ((funcFlags & FuncFlag.HasLine) !== 0) { selfLineOrNull = funcTable.lineNumber[func]; + if ((funcFlags & FuncFlag.HasColumn) !== 0) { + column = funcTable.columnNumber[func]; + } + } + if (mapPositionToLine !== null && selfLineOrNull !== null) { + selfLineOrNull = mapPositionToLine(selfLineOrNull, column); } } @@ -258,3 +283,82 @@ export function getTotalLineTimingsForCallNode( return totalPerLine; } + +/** + * Returns the column with the highest sample weight on the given line of a + * call node, or undefined if no sample has a column on this line. The lines + * are resolved in the same way as in getTotalLineTimingsForCallNode. + * + * This is used to scroll to the right place in a pretty-printed source view: + * minified code often has very long lines, so the line alone doesn't say + * much. + */ +export function getHottestColumnForLineInCallNode( + samples: SamplesLikeTable, + callNodeFramePerStack: Int32Array, + frameTable: FrameTable, + funcTable: FuncTable, + line: LineNumber, + sourceLocationTable: SourceLocationTable | null +): number | undefined { + const totalPerColumn = new Map(); + for (let sampleIndex = 0; sampleIndex < samples.length; sampleIndex++) { + const stack = samples.stack[sampleIndex]; + if (stack === null) { + continue; + } + const callNodeFrame = callNodeFramePerStack[stack]; + if (callNodeFrame === -1) { + continue; + } + + const funcIndex = frameTable.func[callNodeFrame]; + const frameFlags = frameTable.flags[callNodeFrame]; + const funcFlags = funcTable.flags[funcIndex]; + let frameLine: number; + let frameColumn: number; + if ( + sourceLocationTable !== null && + (frameFlags & FrameFlag.HasOriginalLocation) !== 0 + ) { + const locationIndex = frameTable.originalLocation[callNodeFrame]; + frameLine = sourceLocationTable.line[locationIndex]; + frameColumn = sourceLocationTable.column[locationIndex]; + } else if ( + sourceLocationTable !== null && + (funcFlags & FuncFlag.HasOriginalLocation) !== 0 + ) { + const locationIndex = funcTable.originalLocation[funcIndex]; + frameLine = sourceLocationTable.line[locationIndex]; + frameColumn = sourceLocationTable.column[locationIndex]; + } else if ((frameFlags & FrameFlag.HasLine) !== 0) { + if ((frameFlags & FrameFlag.HasColumn) === 0) { + continue; + } + frameLine = frameTable.line[callNodeFrame]; + frameColumn = frameTable.column[callNodeFrame]; + } else if ( + (funcFlags & FuncFlag.HasLine) !== 0 && + (funcFlags & FuncFlag.HasColumn) !== 0 + ) { + // Like in getTotalLineTimingsForCallNode, frames without a line use the + // start of the function. + frameLine = funcTable.lineNumber[funcIndex]; + frameColumn = funcTable.columnNumber[funcIndex]; + } else { + continue; + } + if (frameLine !== line) { + continue; + } + + const sampleWeight = + samples.weight !== null ? samples.weight[sampleIndex] : 1; + totalPerColumn.set( + frameColumn, + (totalPerColumn.get(frameColumn) ?? 0) + sampleWeight + ); + } + + return mapGetKeyWithMaxValue(totalPerColumn); +} diff --git a/src/reducers/code.ts b/src/reducers/code.ts index a6e2a7c928..327c89c3c8 100644 --- a/src/reducers/code.ts +++ b/src/reducers/code.ts @@ -8,6 +8,7 @@ import type { AssemblyCodeStatus, CodeState, IndexIntoSourceTable, + SourceFormattingStatus, } from 'firefox-profiler/types'; import { combineReducers } from 'redux'; @@ -107,9 +108,59 @@ const assemblyCodeCache: Reducer> = ( } }; +const sourceFormattingCache: Reducer< + Map +> = (state = new Map(), action) => { + switch (action.type) { + case 'SOURCE_FORMATTING_BEGIN': { + const newState = new Map(state); + newState.set(action.sourceIndex, { + edits: new Int32Array(0), + isDone: false, + }); + return newState; + } + case 'SOURCE_FORMATTING_PROGRESS': { + const { sourceIndex, edits, isDone } = action; + const oldStatus = state.get(sourceIndex); + if (oldStatus === undefined) { + return state; + } + // Append the new edits to the edits of the earlier steps. + const oldEdits = oldStatus.edits; + let newEdits = edits; + if (oldEdits.length !== 0) { + newEdits = new Int32Array(oldEdits.length + edits.length); + newEdits.set(oldEdits); + newEdits.set(edits, oldEdits.length); + } + const newState = new Map(state); + newState.set(sourceIndex, { edits: newEdits, isDone }); + return newState; + } + case 'SANITIZED_PROFILE_PUBLISHED': { + if (!action.translationMaps) { + return state; + } + const { oldSourceToNewSourcePlusOne } = action.translationMaps; + const newState = new Map(); + for (const [sourceIndex, status] of state) { + const newSourceIndexPlusOne = oldSourceToNewSourcePlusOne[sourceIndex]; + if (newSourceIndexPlusOne !== 0) { + newState.set(newSourceIndexPlusOne - 1, status); + } + } + return newState; + } + default: + return state; + } +}; + const code: Reducer = combineReducers({ sourceCodeCache, assemblyCodeCache, + sourceFormattingCache, }); export default code; diff --git a/src/reducers/url-state.ts b/src/reducers/url-state.ts index 5374e6440f..7d5f8296a5 100644 --- a/src/reducers/url-state.ts +++ b/src/reducers/url-state.ts @@ -630,6 +630,8 @@ const sourceView: Reducer = ( scrollGeneration: 0, sourceIndex: null, highlightedLine: null, + highlightedColumn: null, + isPrettyPrinted: false, }, action ) => { @@ -642,9 +644,14 @@ const sourceView: Reducer = ( : state.scrollGeneration, sourceIndex: action.sourceIndex, scrollToLineNumber: action.scrollToLineNumber, + scrollToColumnNumber: action.scrollToColumnNumber, highlightedLine: action.highlightedLineNumber, + highlightedColumn: action.highlightedColumnNumber ?? null, + isPrettyPrinted: state.isPrettyPrinted, }; } + case 'SET_SOURCE_VIEW_PRETTY_PRINT': + return { ...state, isPrettyPrinted: action.isPrettyPrinted }; case 'SANITIZED_PROFILE_PUBLISHED': { if (!action.translationMaps || state.sourceIndex === null) { return state; diff --git a/src/selectors/code.tsx b/src/selectors/code.tsx index 0356388d7d..fdb6162df2 100644 --- a/src/selectors/code.tsx +++ b/src/selectors/code.tsx @@ -7,13 +7,29 @@ import type { IndexIntoSourceTable, Lib, SourceCodeStatus, + SourceFormattingStatus, Selector, } from 'firefox-profiler/types'; import { getSourceViewSourceIndex, + getSourceViewIsPrettyPrinted, getAssemblyViewNativeSymbol, } from './url-state'; -import { getProfileOrNull } from './profile'; +import { + getProfileOrNull, + getSourceViewFile, + getSourceViewStartLine, + getSourceViewStartColumn, +} from './profile'; +import { parseFileNameFromSymbolication } from 'firefox-profiler/utils/special-paths'; +import { + type JsDialect, + getFormatDialectForPath, +} from 'firefox-profiler/profile-logic/js-format/dialect'; +import { + type SourceViewFormatting, + createSourceViewFormatting, +} from 'firefox-profiler/profile-logic/js-format/formatted-source'; export const getSourceCodeCache: Selector< Map @@ -38,6 +54,107 @@ export const getSourceViewCode: Selector = } ); +export const getSourceFormattingCache: Selector< + Map +> = (state) => state.code.sourceFormattingCache; + +// The dialect for pretty-printing the source, or null if the source can't be +// pretty-printed because it isn't JavaScript. +function _getFormatDialectForFile(file: string | null): JsDialect | null { + if (file === null) { + return null; + } + return getFormatDialectForPath(parseFileNameFromSymbolication(file).path); +} + +export const getSourceViewFormatDialect: Selector = + createSelector(getSourceViewFile, _getFormatDialectForFile); + +function _getFormatting( + isPrettyPrinted: boolean, + dialect: JsDialect | null, + sourceIndex: IndexIntoSourceTable | null, + codeStatus: SourceCodeStatus | void, + formattingCache: Map, + startLine: number, + startColumn: number +): SourceViewFormatting | null { + if ( + !isPrettyPrinted || + dialect === null || + sourceIndex === null || + codeStatus === undefined || + codeStatus.type !== 'AVAILABLE' + ) { + return null; + } + const formattingStatus = formattingCache.get(sourceIndex); + if (formattingStatus === undefined) { + return null; + } + return createSourceViewFormatting( + codeStatus.code, + formattingStatus, + startLine, + startColumn + ); +} + +/** + * The pretty-printed code for the source view, or null if the source view + * shows the code as it is. While the format worker is busy, this contains + * partially-formatted code. + */ +export const getSourceViewFormatting: Selector = + createSelector( + getSourceViewIsPrettyPrinted, + getSourceViewFormatDialect, + getSourceViewSourceIndex, + getSourceViewCode, + getSourceFormattingCache, + getSourceViewStartLine, + getSourceViewStartColumn, + _getFormatting + ); + +export type SourceFormattingRequest = { + sourceIndex: IndexIntoSourceTable; + code: string; + dialect: JsDialect; +}; + +function _getFormattingRequest( + isPrettyPrinted: boolean, + dialect: JsDialect | null, + sourceIndex: IndexIntoSourceTable | null, + codeStatus: SourceCodeStatus | void, + formattingCache: Map +): SourceFormattingRequest | null { + if ( + !isPrettyPrinted || + dialect === null || + sourceIndex === null || + codeStatus === undefined || + codeStatus.type !== 'AVAILABLE' || + formattingCache.has(sourceIndex) + ) { + return null; + } + return { sourceIndex, code: codeStatus.code, dialect }; +} + +// The source that needs to be formatted for the source view, if the source +// view is pretty-printed and the formatting hasn't started yet. +export const getSourceViewFormattingRequest: Selector = + createSelector( + getSourceViewIsPrettyPrinted, + getSourceViewFormatDialect, + getSourceViewSourceIndex, + getSourceViewCode, + getSourceFormattingCache, + _getFormattingRequest + ); + export const getAssemblyCodeCache: Selector> = ( state ) => state.code.assemblyCodeCache; diff --git a/src/selectors/per-thread/stack-sample.ts b/src/selectors/per-thread/stack-sample.ts index 63347c19c1..baa94cd632 100644 --- a/src/selectors/per-thread/stack-sample.ts +++ b/src/selectors/per-thread/stack-sample.ts @@ -14,6 +14,7 @@ import * as CallTree from '../../profile-logic/call-tree'; import type { PathSet } from '../../utils/path'; import * as ProfileSelectors from '../profile'; import { getRightClickedCallNodeInfo } from '../right-clicked-call-node'; +import { getSourceViewFormatting } from '../code'; import { getStackLineInfo, getLineTimings, @@ -158,9 +159,11 @@ export function getStackAndSampleSelectorsPerThread( createSelector( threadSelectors.getFilteredThread, UrlState.getSourceViewSourceIndex, + getSourceViewFormatting, ( { stackTable, frameTable, funcTable, sourceLocationTable }: Thread, - sourceIndex + sourceIndex, + formatting ): StackLineInfo | null => { if (sourceIndex === null) { return null; @@ -170,7 +173,8 @@ export function getStackAndSampleSelectorsPerThread( frameTable, funcTable, sourceIndex, - sourceLocationTable + sourceLocationTable, + formatting !== null ? formatting.mapPositionToLine : null ); } ); diff --git a/src/selectors/profile.ts b/src/selectors/profile.ts index c2a1b891eb..8a23fa2914 100644 --- a/src/selectors/profile.ts +++ b/src/selectors/profile.ts @@ -1171,3 +1171,10 @@ export const getSourceViewStartLine: Selector = createSelector( (sources, sourceIndex) => sourceIndex !== null ? sources.startLine[sourceIndex] : 1 ); + +export const getSourceViewStartColumn: Selector = createSelector( + getSourceTable, + UrlState.getSourceViewSourceIndex, + (sources, sourceIndex) => + sourceIndex !== null ? sources.startColumn[sourceIndex] : 1 +); diff --git a/src/selectors/url-state.ts b/src/selectors/url-state.ts index 599b59a748..e84e4780b2 100644 --- a/src/selectors/url-state.ts +++ b/src/selectors/url-state.ts @@ -82,6 +82,14 @@ export const getSourceViewScrollToLineNumber: Selector = ( ) => getProfileSpecificState(state).sourceView.scrollToLineNumber; export const getSourceViewHighlightedLine: Selector = (state) => getProfileSpecificState(state).sourceView.highlightedLine; +export const getSourceViewScrollToColumnNumber: Selector = ( + state +) => getProfileSpecificState(state).sourceView.scrollToColumnNumber; +export const getSourceViewHighlightedColumn: Selector = ( + state +) => getProfileSpecificState(state).sourceView.highlightedColumn; +export const getSourceViewIsPrettyPrinted: Selector = (state) => + getProfileSpecificState(state).sourceView.isPrettyPrinted; export const getAssemblyViewIsOpen: Selector = (state) => getProfileSpecificState(state).assemblyView.isOpen; export const getAssemblyViewNativeSymbol: Selector = ( diff --git a/src/test/fixtures/profiles/processed-profile.ts b/src/test/fixtures/profiles/processed-profile.ts index 8612906e47..39c3c1220e 100644 --- a/src/test/fixtures/profiles/processed-profile.ts +++ b/src/test/fixtures/profiles/processed-profile.ts @@ -649,6 +649,7 @@ export type ProfileWithDicts = { * - [lib:*] - The library name, affects frameTable.lib + funcTable.resource + resourceTable + libs * - [file:*] - The filename, affects funcTable.source + sourceTable * - [line:*] - The line, affects frameTable.line + * - [col:*] - The column, affects frameTable.column * - [address:*] - The frame address, affects frameTable.address * - [inl:*] - A non-zero value marks the frame as inlined, i.e. it sets the IsInlined bit of frameTable.flags * - [sym:::] - The native symbol, affects frameTable.nativeSymbol (keyed on ) @@ -901,6 +902,17 @@ function _findLineNumberFromFuncName( return null; } +function _findColumnNumberFromFuncName( + funcNameWithModifier: string +): number | null { + const findColumnNumberResult = /\[col:([0-9]+)\]/.exec(funcNameWithModifier); + if (findColumnNumberResult) { + return +findColumnNumberResult[1]; + } + + return null; +} + function _findAddressFromFuncName( funcNameWithModifier: string ): Address | null { @@ -1005,6 +1017,7 @@ function _buildThreadFromTextOnlyStacks( categories ); const lineNumber = _findLineNumberFromFuncName(funcNameWithModifier); + const columnNumber = _findColumnNumberFromFuncName(funcNameWithModifier); const parsedAddress = _findAddressFromFuncName(funcNameWithModifier) ?? (funcName.startsWith('0x') ? parseInt(funcName.substr(2), 16) : -1); @@ -1053,6 +1066,9 @@ function _buildThreadFromTextOnlyStacks( if (lineNumber !== null) { flags |= FrameFlag.HasLine; } + if (columnNumber !== null) { + flags |= FrameFlag.HasColumn; + } const address = parsedAddress === -1 ? 0 : parsedAddress; // `lib` is gated by HasAddress, so a [lib:] without an address doesn't // make it onto the frame. This matches the real pipeline, which only ever @@ -1063,6 +1079,7 @@ function _buildThreadFromTextOnlyStacks( const storedCategory = category ?? 0; const storedNativeSymbol = nativeSymbol ?? 0; const storedLineNumber = lineNumber ?? 0; + const storedColumnNumber = columnNumber ?? 0; // Attempt to find a frame that satisfies the given funcIndex, // category, and line number. @@ -1073,6 +1090,7 @@ function _buildThreadFromTextOnlyStacks( flags === frameTable.flags[i] && storedCategory === frameTable.category[i] && storedLineNumber === frameTable.line[i] && + storedColumnNumber === frameTable.column[i] && address === frameTable.address[i] && storedNativeSymbol === frameTable.nativeSymbol[i] && storedLib === frameTable.lib[i] @@ -1092,7 +1110,7 @@ function _buildThreadFromTextOnlyStacks( frameTable.innerWindowID.push(0); frameTable.nativeSymbol.push(storedNativeSymbol); frameTable.line.push(storedLineNumber); - frameTable.column.push(0); + frameTable.column.push(storedColumnNumber); frameTable.originalLocation.push(0); frameIndex = frameTable.length++; } diff --git a/src/test/store/source-formatting.test.ts b/src/test/store/source-formatting.test.ts new file mode 100644 index 0000000000..64af108757 --- /dev/null +++ b/src/test/store/source-formatting.test.ts @@ -0,0 +1,273 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { getProfileFromTextSamples } from '../fixtures/profiles/processed-profile'; +import { storeWithProfile } from '../fixtures/stores'; +import * as UrlStateSelectors from '../../selectors/url-state'; +import * as CodeSelectors from '../../selectors/code'; +import { selectedThreadSelectors } from '../../selectors/per-thread'; +import { getBottomBoxInfoForCallNode } from '../../profile-logic/bottom-box'; +import { + updateBottomBoxContentsAndMaybeOpen, + setSourceViewPrettyPrint, +} from '../../actions/profile-view'; +import { startFormattingSource } from '../../actions/code'; +import { runFormatWorker } from '../../profile-logic/js-format/run-format-worker'; +import { StringTable } from '../../utils/string-table'; +import { ensureExists } from '../../utils/types'; +import { FuncFlag } from 'firefox-profiler/types'; +import type { FormatWorkerOutput } from '../../profile-logic/js-format/types'; + +// The real worker needs to be bundled by esbuild. Instead, run the formatter +// on the main thread, and report its edits in two steps, like the worker does +// for large code. +jest.mock('../../profile-logic/js-format/run-format-worker', () => ({ + runFormatWorker: jest.fn(), +})); + +function mockFormatWorker() { + const { formatJsCode } = jest.requireActual( + '../../profile-logic/js-format/formatter' + ); + let pendingSecondStep: (() => void) | null = null; + (runFormatWorker as jest.Mock).mockImplementation( + ( + { code, dialect }: { code: string; dialect: string }, + onOutput: (output: FormatWorkerOutput) => void + ) => { + const edits = formatJsCode(code, dialect); + const half = Math.floor(edits.length / 6) * 3; + onOutput({ + type: 'progress', + edits: edits.slice(0, half), + formattedUpTo: edits[half - 2], + isDone: false, + }); + pendingSecondStep = () => + onOutput({ + type: 'progress', + edits: edits.slice(half), + formattedUpTo: code.length, + isDone: true, + }); + } + ); + return { + finishFormatting: () => ensureExists(pendingSecondStep)(), + }; +} + +// Minified code with three functions on one line. +const CODE = 'function A(){B();C()}function B(){x()}function C(){y()}'; +// The formatted code: +// 1 function A() { +// 2 B(); +// 3 C() +// 4 } +// 5 function B() { +// 6 x() +// 7 } +// 8 function C() { +// 9 y() +// 10 } + +// The 1-based column of the first occurrence of `str` in CODE. +function col(str: string): number { + return CODE.indexOf(str) + 1; +} + +function setup() { + const { finishFormatting } = mockFormatWorker(); + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[file:bundle.js][line:1][col:${col('B()')}] A[file:bundle.js][line:1][col:${col('C()')}] A[file:bundle.js][line:1][col:${col('B()')}] + B[file:bundle.js][line:1][col:${col('x()')}] C[file:bundle.js][line:1][col:${col('y()')}] B[file:bundle.js][line:1][col:${col('x()')}] + `); + const stringTable = StringTable.withBackingArray(profile.shared.stringArray); + const sourceIndex = profile.shared.sources.filename.indexOf( + stringTable.indexForString('bundle.js') + ); + profile.shared.sources.content[sourceIndex] = CODE; + + const { dispatch, getState } = storeWithProfile(profile); + const { A } = funcNamesDictPerThread[0]; + const callNodeInfo = selectedThreadSelectors.getCallNodeInfo(getState()); + const bottomBoxInfo = getBottomBoxInfoForCallNode( + ensureExists(callNodeInfo.getCallNodeIndexFromPath([A])), + callNodeInfo, + selectedThreadSelectors.getFilteredThread(getState()), + selectedThreadSelectors.getPreviewFilteredCtssSamples(getState()) + ); + dispatch(updateBottomBoxContentsAndMaybeOpen('calltree', bottomBoxInfo)); + + function startFormatting() { + const request = ensureExists( + CodeSelectors.getSourceViewFormattingRequest(getState()) + ); + dispatch(startFormattingSource(request)); + } + + return { + dispatch, + getState, + sourceIndex, + startFormatting, + finishFormatting, + }; +} + +describe('pretty-printing the source view', function () { + it('only requests formatting when pretty-printing is enabled', function () { + const { dispatch, getState, sourceIndex } = setup(); + expect(CodeSelectors.getSourceViewFormatDialect(getState())).toBe(''); + expect(CodeSelectors.getSourceViewFormattingRequest(getState())).toBeNull(); + + dispatch(setSourceViewPrettyPrint(true)); + expect(CodeSelectors.getSourceViewFormattingRequest(getState())).toEqual({ + sourceIndex, + code: CODE, + dialect: '', + }); + }); + + it('attributes the samples to the formatted lines using the columns', function () { + const { dispatch, getState, startFormatting, finishFormatting } = setup(); + // Without pretty-printing, everything is on line 1. + expect( + selectedThreadSelectors.getSourceViewLineTimings(getState()) + ).toEqual({ + totalLineHits: new Map([[1, 3]]), + selfLineHits: new Map([[1, 3]]), + }); + + dispatch(setSourceViewPrettyPrint(true)); + startFormatting(); + finishFormatting(); + expect(CodeSelectors.getSourceViewFormattingRequest(getState())).toBeNull(); + const formatting = ensureExists( + CodeSelectors.getSourceViewFormatting(getState()) + ); + expect(formatting.isDone).toBeTrue(); + expect(formatting.lines).toHaveLength(10); + expect( + selectedThreadSelectors.getSourceViewLineTimings(getState()) + ).toEqual({ + totalLineHits: new Map([ + [2, 2], + [3, 1], + [6, 2], + [9, 1], + ]), + selfLineHits: new Map([ + [6, 2], + [9, 1], + ]), + }); + + // The bottom box scrolls to the hottest column on the hottest line. Map + // it to the formatted line, like the BottomBox component does. + const scrollToLine = ensureExists( + UrlStateSelectors.getSourceViewScrollToLineNumber(getState()) + ); + const scrollToColumn = ensureExists( + UrlStateSelectors.getSourceViewScrollToColumnNumber(getState()) + ); + expect(scrollToLine).toBe(1); + expect(scrollToColumn).toBe(col('B()')); + expect(formatting.mapPositionToLine(scrollToLine, scrollToColumn)).toBe(2); + + // Turning off pretty-printing goes back to the original lines, and keeps + // the formatting result for later. + dispatch(setSourceViewPrettyPrint(false)); + expect(CodeSelectors.getSourceViewFormatting(getState())).toBeNull(); + expect( + selectedThreadSelectors.getSourceViewLineTimings(getState()) + ).toEqual({ + totalLineHits: new Map([[1, 3]]), + selfLineHits: new Map([[1, 3]]), + }); + dispatch(setSourceViewPrettyPrint(true)); + expect(CodeSelectors.getSourceViewFormattingRequest(getState())).toBeNull(); + expect(CodeSelectors.getSourceViewFormatting(getState())).not.toBeNull(); + }); + + it('shows partially-formatted code while formatting is in progress', function () { + const { dispatch, getState, startFormatting, finishFormatting } = setup(); + dispatch(setSourceViewPrettyPrint(true)); + startFormatting(); + + const partialFormatting = ensureExists( + CodeSelectors.getSourceViewFormatting(getState()) + ); + expect(partialFormatting.isDone).toBeFalse(); + expect(partialFormatting.lines.length).toBeGreaterThan(1); + expect(partialFormatting.lines.length).toBeLessThan(10); + // The line timings are consistent with the partially-formatted lines. + const { totalLineHits } = + selectedThreadSelectors.getSourceViewLineTimings(getState()); + for (const line of totalLineHits.keys()) { + expect(line).toBeLessThanOrEqual(partialFormatting.lines.length); + } + + finishFormatting(); + expect( + ensureExists(CodeSelectors.getSourceViewFormatting(getState())).lines + ).toHaveLength(10); + }); + + it('scrolls to the start of the function if the frames have no position', function () { + // Some JS frames only have the position of their function, which is on + // the very long minified line. + const { finishFormatting } = mockFormatWorker(); + const { profile, funcNamesDictPerThread } = getProfileFromTextSamples(` + A[file:bundle.js] + C[file:bundle.js] + `); + const stringTable = StringTable.withBackingArray( + profile.shared.stringArray + ); + const sourceIndex = profile.shared.sources.filename.indexOf( + stringTable.indexForString('bundle.js') + ); + profile.shared.sources.content[sourceIndex] = CODE; + const { funcTable } = profile.shared; + const { A, C } = funcNamesDictPerThread[0]; + for (const [func, name] of [ + [A, 'function A'], + [C, 'function C'], + ] as const) { + funcTable.flags[func] |= FuncFlag.HasLine | FuncFlag.HasColumn; + funcTable.lineNumber[func] = 1; + funcTable.columnNumber[func] = col(name); + } + + const { dispatch, getState } = storeWithProfile(profile); + const callNodeInfo = selectedThreadSelectors.getCallNodeInfo(getState()); + const bottomBoxInfo = getBottomBoxInfoForCallNode( + ensureExists(callNodeInfo.getCallNodeIndexFromPath([A, C])), + callNodeInfo, + selectedThreadSelectors.getFilteredThread(getState()), + selectedThreadSelectors.getPreviewFilteredCtssSamples(getState()) + ); + expect(bottomBoxInfo.scrollToLineNumber).toBe(1); + expect(bottomBoxInfo.scrollToColumnNumber).toBe(col('function C')); + dispatch(updateBottomBoxContentsAndMaybeOpen('calltree', bottomBoxInfo)); + + dispatch(setSourceViewPrettyPrint(true)); + dispatch( + startFormattingSource( + ensureExists(CodeSelectors.getSourceViewFormattingRequest(getState())) + ) + ); + finishFormatting(); + const formatting = ensureExists( + CodeSelectors.getSourceViewFormatting(getState()) + ); + expect( + formatting.mapPositionToLine( + 1, + ensureExists(bottomBoxInfo.scrollToColumnNumber) + ) + ).toBe(8); + }); +}); diff --git a/src/test/unit/js-format.test.ts b/src/test/unit/js-format.test.ts index 9c2935d810..f83568d4ff 100644 --- a/src/test/unit/js-format.test.ts +++ b/src/test/unit/js-format.test.ts @@ -10,7 +10,11 @@ import { formatJsCode, formatJsCodeIncrementally, } from '../../profile-logic/js-format/formatter'; -import { applyFormatEdits } from '../../profile-logic/js-format/formatted-source'; +import { + applyFormatEdits, + createSourceViewFormatting, +} from '../../profile-logic/js-format/formatted-source'; +import { getFormatDialectForPath } from '../../profile-logic/js-format/dialect'; import { parserForDialect } from '../../profile-logic/source-maps/scope-tree'; import type { JsDialect } from '../../profile-logic/js-format/dialect'; @@ -265,3 +269,31 @@ describe('applyFormatEdits', function () { expect(formatted.getFormattedLine(1, 12)).toBe(2); }); }); + +describe('createSourceViewFormatting', function () { + it('accepts positions that are relative to the start of the file', function () { + // An inline script which starts at line 10, column 9 of the HTML page. + const code = 'a();b();\nc()'; + const formatting = createSourceViewFormatting( + code, + { edits: formatJsCode(code, ''), isDone: true }, + 10, + 9 + ); + expect(formatting.lines).toEqual(['a();', 'b();', 'c()']); + expect(formatting.mapPositionToLine(10, 9)).toBe(1); + expect(formatting.mapPositionToLine(10, 13)).toBe(2); + expect(formatting.mapPositionToLine(11, 1)).toBe(3); + }); +}); + +describe('getFormatDialectForPath', function () { + it('returns the dialect for JS files, and null for other files', function () { + expect(getFormatDialectForPath('https://example.com/bundle.js')).toBe(''); + expect(getFormatDialectForPath('https://example.com/')).toBe(''); + expect(getFormatDialectForPath('src/app.tsx')).toBe('ts jsx'); + expect(getFormatDialectForPath('src/main.cpp')).toBeNull(); + expect(getFormatDialectForPath('src/lib.rs')).toBeNull(); + expect(getFormatDialectForPath('iongraph.json')).toBeNull(); + }); +}); diff --git a/src/test/url-handling.test.ts b/src/test/url-handling.test.ts index c5779cff8d..fb55c5819d 100644 --- a/src/test/url-handling.test.ts +++ b/src/test/url-handling.test.ts @@ -18,6 +18,7 @@ import { setDataSource, updateBottomBoxContentsAndMaybeOpen, closeBottomBox, + setSourceViewPrettyPrint, changeShowUserTimings, changeStackChartSameWidths, changeIncludeIdleSamples, @@ -2053,6 +2054,34 @@ describe('URL persistence of bottom box (source view and assembly view)', functi ).toBeNull(); }); + it('persists pretty-printing of the source view', function () { + const { dispatch, getState, getSourceIndex } = setup(); + const sourceFile = + 'hg:hg.mozilla.org/mozilla-central:xpcom/threads/nsThread.cpp:5bb3e281dc9ec8a619c781d52882adb1cacf20bb'; + const sourceIndex = getSourceIndex(sourceFile); + const bottomBoxInfo: BottomBoxInfo = { + sourceIndex, + nativeSymbols: [], + initialNativeSymbol: null, + highlightedLineNumber: null, + highlightedInstructionAddress: null, + }; + dispatch(updateBottomBoxContentsAndMaybeOpen('calltree', bottomBoxInfo)); + expect(getQueryStringFromState(getState())).not.toContain('Pretty'); + + dispatch(setSourceViewPrettyPrint(true)); + let newStore = _getStoreFromStateAfterUrlRoundtrip(getState()); + expect( + urlStateSelectors.getSourceViewIsPrettyPrinted(newStore.getState()) + ).toBeTrue(); + + dispatch(setSourceViewPrettyPrint(false)); + newStore = _getStoreFromStateAfterUrlRoundtrip(getState()); + expect( + urlStateSelectors.getSourceViewIsPrettyPrinted(newStore.getState()) + ).toBeFalse(); + }); + it('keeps a closed bottom box closed, even if a source file was loaded before', function () { const { dispatch, getState, getSourceIndex } = setup(); expect(urlStateSelectors.getSelectedTab(getState())).toBe('calltree'); diff --git a/src/types/actions.ts b/src/types/actions.ts index 01d57310b3..0f9a5f3a86 100644 --- a/src/types/actions.ts +++ b/src/types/actions.ts @@ -34,6 +34,7 @@ import type { TemporaryError } from '../utils/errors'; import type { Transform, TransformStacksPerThread } from './transforms'; import type { IndexIntoZipFileTable } from '../profile-logic/zip-files'; import type { CallNodeInfo } from '../profile-logic/call-node-info'; +import type { FormatEdits } from '../profile-logic/js-format/types'; import type { SingleColumnSortState } from '../components/shared/TreeView'; import type { TabSlug } from '../app-logic/tabs-handling'; import type { @@ -356,6 +357,12 @@ type ProfileAction = readonly scrollToInstructionAddress?: number; readonly highlightedLineNumber: number | null; readonly highlightedInstructionAddress: number | null; + readonly scrollToColumnNumber?: number; + readonly highlightedColumnNumber?: number; + } + | { + readonly type: 'SET_SOURCE_VIEW_PRETTY_PRINT'; + readonly isPrettyPrinted: boolean; } | { readonly type: 'OPEN_ASSEMBLY_VIEW'; @@ -738,6 +745,18 @@ type SourcesAction = readonly type: 'SOURCE_CODE_LOADING_ERROR'; readonly sourceIndex: IndexIntoSourceTable; readonly errors: SourceCodeLoadingError[]; + } + | { + readonly type: 'SOURCE_FORMATTING_BEGIN'; + readonly sourceIndex: IndexIntoSourceTable; + } + | { + // The edits of one formatting step. They come after the edits of + // earlier steps. + readonly type: 'SOURCE_FORMATTING_PROGRESS'; + readonly sourceIndex: IndexIntoSourceTable; + readonly edits: FormatEdits; + readonly isDone: boolean; }; // nativeSymbolKey == `${lib.debugName}/${lib.breakpadID}/${nativeSymbolInfo.address.toString(16)}` diff --git a/src/types/profile-derived.ts b/src/types/profile-derived.ts index e681b80c98..4a7e29a50a 100644 --- a/src/types/profile-derived.ts +++ b/src/types/profile-derived.ts @@ -946,6 +946,11 @@ export type BottomBoxInfo = { // a stack frame in a marker stack. highlightedLineNumber: number | null; highlightedInstructionAddress: number | null; + // Optional column numbers for the lines above. These are used to find the + // right line when the source is pretty-printed, because minified code often + // has very long lines. + scrollToColumnNumber?: number; + highlightedColumnNumber?: number; }; /** diff --git a/src/types/state.ts b/src/types/state.ts index 5583e72181..11d06fc05f 100644 --- a/src/types/state.ts +++ b/src/types/state.ts @@ -38,6 +38,7 @@ import type { Attempt } from '../utils/errors'; import type { TransformStacksPerThread } from './transforms'; import type { SingleColumnSortState } from '../components/shared/TreeView'; import type JSZip from 'jszip'; +import type { FormatEdits } from '../profile-logic/js-format/types'; import type { IndexIntoZipFileTable } from '../profile-logic/zip-files'; import type { PathSet } from '../utils/path'; import type { UploadedProfileInformation as ImportedUploadedProfileInformation } from '../app-logic/uploaded-profiles-db'; @@ -259,11 +260,19 @@ export type SourceViewState = { scrollGeneration: number; // Optional line number to scroll to in the source view. scrollToLineNumber?: number; + // Optional column number on scrollToLineNumber. This is used to find the + // right line when the source view is pretty-printed. + scrollToColumnNumber?: number; // Index into source table. Contains information (filename and uuid) about the // source. Null if a function without a file path was double clicked. sourceIndex: IndexIntoSourceTable | null; // The line number of the highlighted line in the source view. highlightedLine: number | null; + // The column number on the highlighted line, if known. + highlightedColumn: number | null; + // Whether JS code is pretty-printed in the source view. This stays the same + // when a different source is shown. + isPrettyPrinted: boolean; }; export type AssemblyViewState = { @@ -414,9 +423,21 @@ export type PseudoStrategy = null | 'bidi' | 'accented'; */ export type IconsWithClassNames = Map; +// The result of pretty-printing a source, see FormatEdits. While the format +// worker is busy, isDone is false and the edits only cover the start of the +// code. +export type SourceFormattingStatus = { + readonly edits: FormatEdits; + readonly isDone: boolean; +}; + export type CodeState = { readonly sourceCodeCache: Map; readonly assemblyCodeCache: Map; + readonly sourceFormattingCache: Map< + IndexIntoSourceTable, + SourceFormattingStatus + >; }; export type State = {