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/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/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/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 new file mode 100644 index 0000000000..875a5bedc7 --- /dev/null +++ b/src/profile-logic/js-format/dialect.ts @@ -0,0 +1,63 @@ +/* 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 ''; +} + +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/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..7b71afd90b --- /dev/null +++ b/src/profile-logic/js-format/formatted-source.ts @@ -0,0 +1,222 @@ +/* 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. 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, + 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); + }, + }; +} + +/** + * 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/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/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/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/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/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/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 new file mode 100644 index 0000000000..f83568d4ff --- /dev/null +++ b/src/test/unit/js-format.test.ts @@ -0,0 +1,299 @@ +/* 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, + 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'; + +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); + }); +}); + +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/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; 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 = {