/* 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 . */ import { generatedToOriginalId } from "devtools/client/shared/source-map-loader/index"; import assert from "../../utils/assert"; import { recordEvent } from "../../utils/telemetry"; import { updateBreakpointPositionsForNewPrettyPrintedSource, updateBreakpointsForNewPrettyPrintedSource, } from "../breakpoints/index"; import { getPrettySourceURL, isJavaScript, isMinified, } from "../../utils/source"; import { isFulfilled, fulfilled } from "../../utils/async-value"; import { getOriginalLocation, getGeneratedLocation, } from "../../utils/source-maps"; import { prefs } from "../../utils/prefs"; import { loadGeneratedSourceText, loadOriginalSourceText, } from "./loadSourceText"; import { removeSources } from "./removeSources"; import { mapFrames } from "../pause/index"; import { selectSpecificLocation } from "../sources/index"; import { createPrettyPrintOriginalSource } from "../../client/firefox/create"; import { getFirstSourceActorForGeneratedSource, getSource, getSelectedLocation, canPrettyPrintSource, getSourceTextContentForSource, } from "../../selectors/index"; import { selectSource } from "./select"; import { memoizeableAction } from "../../utils/memoizableAction"; import DevToolsUtils from "devtools/shared/DevToolsUtils"; /** * Replace all line breaks with standard \n line breaks for easier parsing. */ const LINE_BREAK_REGEX = /\r\n?|\n|\u2028|\u2029/g; function sanitizeLineBreaks(str) { return str.replace(LINE_BREAK_REGEX, "\n"); } /** * Retrieve all line breaks in the provided string. * Note: this assumes the line breaks were previously sanitized with * `sanitizeLineBreaks` defined above. */ const SIMPLE_LINE_BREAK_REGEX = /\n/g; function matchAllLineBreaks(str) { return Array.from(str.matchAll(SIMPLE_LINE_BREAK_REGEX)); } function getPrettyOriginalSourceURL(generatedSource) { return getPrettySourceURL(generatedSource.url || generatedSource.id); } export async function prettyPrintSourceTextContent( sourceMapLoader, prettyPrintWorker, generatedSource, content, actors ) { if (!content || !isFulfilled(content)) { throw new Error("Cannot pretty-print a file that has not loaded"); } const contentValue = content.value; if ( (!isJavaScript(generatedSource, contentValue) && !generatedSource.isHTML) || contentValue.type !== "text" ) { throw new Error( `Can't prettify ${contentValue.contentType} files, only HTML and Javascript.` ); } const url = getPrettyOriginalSourceURL(generatedSource); let prettyPrintWorkerResult; if (generatedSource.isHTML) { prettyPrintWorkerResult = await prettyPrintHtmlFile({ prettyPrintWorker, generatedSource, content, actors, }); } else { prettyPrintWorkerResult = await prettyPrintWorker.prettyPrint({ sourceText: contentValue.value, indent: " ".repeat(prefs.indentSize), url, }); } // The source map URL service used by other devtools listens to changes to // sources based on their actor IDs, so apply the sourceMap there too. const generatedSourceIds = [ generatedSource.id, ...actors.map(item => item.actor), ]; await sourceMapLoader.setSourceMapForGeneratedSources( generatedSourceIds, prettyPrintWorkerResult.sourceMap ); return { text: prettyPrintWorkerResult.code, contentType: contentValue.contentType, }; } /** * Pretty print inline script inside an HTML file * * @param {object} options * @param {PrettyPrintDispatcher} options.prettyPrintWorker: The prettyPrint worker * @param {object} options.generatedSource: The HTML source we want to pretty print * @param {object} options.content * @param {Array} options.actors: An array of the HTML file inline script sources data * * @returns Promise A promise that resolves with an object of the following shape: * - {String} code: The prettified HTML text * - {Object} sourceMap: The sourceMap object */ async function prettyPrintHtmlFile({ prettyPrintWorker, generatedSource, content, actors, }) { const url = getPrettyOriginalSourceURL(generatedSource); const contentValue = content.value; // Original source may contain unix-style & windows-style breaks. // SpiderMonkey works a sanitized version of the source using only \n (unix). // Sanitize before parsing the source to align with SpiderMonkey. const htmlFileText = sanitizeLineBreaks(contentValue.value); const prettyPrintWorkerResult = { code: htmlFileText }; const allLineBreaks = matchAllLineBreaks(htmlFileText); let lineCountDelta = 0; // Sort inline script actors so they are in the same order as in the html document. actors.sort((a, b) => { if (a.sourceStartLine === b.sourceStartLine) { return a.sourceStartColumn > b.sourceStartColumn; } return a.sourceStartLine > b.sourceStartLine; }); const prettyPrintTaskId = generatedSource.id; // We don't want to replace part of the HTML document in the loop since it would require // to account for modified lines for each iteration. // Instead, we'll put each sections to replace in this array, where elements will be // objects of the following shape: // {Integer} startIndex: The start index in htmlFileText of the section we want to replace // {Integer} endIndex: The end index in htmlFileText of the section we want to replace // {String} prettyText: The pretty text we'll replace the original section with // Once we iterated over all the inline scripts, we'll do the replacements (on the html // file text) in reverse order, so we don't need have to care about the modified lines // for each iteration. const replacements = []; const seenLocations = new Set(); for (const sourceInfo of actors) { // We can get duplicate source actors representing the same inline script which will // cause trouble in the pretty printing here. This should be fixed on the server (see // Bug 1824979), but in the meantime let's not handle the same location twice so the // pretty printing is not impacted. const location = `${sourceInfo.sourceStartLine}:${sourceInfo.sourceStartColumn}`; if (!sourceInfo.sourceLength || seenLocations.has(location)) { continue; } seenLocations.add(location); // Here we want to get the index of the last line break before the script tag. // In allLineBreaks, this would be the item at (script tag line - 1) // Since sourceInfo.sourceStartLine is 1-based, we need to get the item at (sourceStartLine - 2) const indexAfterPreviousLineBreakInHtml = sourceInfo.sourceStartLine > 1 ? allLineBreaks[sourceInfo.sourceStartLine - 2].index + 1 : 0; // The `sourceStartColumn` refers to final unicode characters column (including 16-bits characters), // not including any unicode characters encoded by surrogate pairs (two 16 bit code units) // i.e outside of the Basic Multiligual Plane. So calculate and add those characters to the looked-up start index. const startColumn = indexAfterPreviousLineBreakInHtml + sourceInfo.sourceStartColumn; const htmlBeforeStr = htmlFileText.substring(0, startColumn); const codeUnitLength = htmlBeforeStr.length, codePointLength = [...htmlBeforeStr].length; const extraCharsWithForStrTwoCodeUnits = codeUnitLength - codePointLength; const startIndex = startColumn + extraCharsWithForStrTwoCodeUnits; const endIndex = startIndex + sourceInfo.sourceLength; const scriptText = htmlFileText.substring(startIndex, endIndex); DevToolsUtils.assert( scriptText.length == sourceInfo.sourceLength, "script text has expected length" ); // Here we're going to pretty print each inline script content. // Since we want to have a sourceMap that we'll apply to the whole HTML file, // we'll only collect the sourceMap once we handled all inline scripts. // `taskId` allows us to signal to the worker that all those calls are part of the // same bigger file, and we'll use it later to get the sourceMap. const prettyText = await prettyPrintWorker.prettyPrintInlineScript({ taskId: prettyPrintTaskId, sourceText: scriptText, indent: " ".repeat(prefs.indentSize), url, originalStartLine: sourceInfo.sourceStartLine, originalStartColumn: sourceInfo.sourceStartColumn, // The generated line will be impacted by the previous inline scripts that were // pretty printed, which is why we offset with lineCountDelta generatedStartLine: sourceInfo.sourceStartLine + lineCountDelta, generatedStartColumn: sourceInfo.sourceStartColumn, lineCountDelta, }); // We need to keep track of the line added/removed in order to properly offset // the mapping of the pretty-print text lineCountDelta += matchAllLineBreaks(prettyText).length - matchAllLineBreaks(scriptText).length; replacements.push({ startIndex, endIndex, prettyText, }); } // `getSourceMap` allow us to collect the computed source map resulting of the calls // to `prettyPrint` with the same taskId. prettyPrintWorkerResult.sourceMap = await prettyPrintWorker.getSourceMap(prettyPrintTaskId); // Sort replacement in reverse order so we can replace code in the HTML file more easily replacements.sort((a, b) => a.startIndex < b.startIndex); for (const { startIndex, endIndex, prettyText } of replacements) { prettyPrintWorkerResult.code = prettyPrintWorkerResult.code.substring(0, startIndex) + prettyText + prettyPrintWorkerResult.code.substring(endIndex); } return prettyPrintWorkerResult; } function createPrettySource(source, sourceActor) { return async ({ dispatch }) => { const url = getPrettyOriginalSourceURL(source); const id = generatedToOriginalId(source.id, url); const prettySource = createPrettyPrintOriginalSource(id, url, source); dispatch({ type: "ADD_ORIGINAL_SOURCES", originalSources: [prettySource], generatedSourceActor: sourceActor, }); return prettySource; }; } function selectPrettyLocation(prettySource) { return async thunkArgs => { const { dispatch, getState } = thunkArgs; let location = getSelectedLocation(getState()); // If we were selecting a particular line in the minified/generated source, // try to select the matching line in the prettified/original source. if ( location && location.line >= 1 && getPrettySourceURL(location.source.url) == prettySource.url ) { // Note that it requires to have called `prettyPrintSourceTextContent` and `sourceMapLoader.setSourceMapForGeneratedSources` // to be functional and so to be called after `loadOriginalSourceText` completed. location = await getOriginalLocation(location, thunkArgs); // If the precise line/column correctly mapped to the pretty printed source, select that precise location. // Otherwise fallback to selectSource in order to select the first line instead of the current line within the bundle. if (location.source == prettySource) { return dispatch(selectSpecificLocation(location)); } } return dispatch(selectSource(prettySource)); }; } /** * Toggle the pretty printing of a source's text. * Nothing will happen for non-javascript, non-minified, or files that can't be pretty printed. * * @param Object source * The source object for the minified/generated source. * @param Boolean isAutoPrettyPrinting * Are we pretty printing this source because of auto-pretty printing preference? * @returns Promise * A promise that resolves to the Pretty print/original source object. */ export async function doPrettyPrintSource( source, isAutoPrettyPrinting, thunkArgs ) { const { dispatch, getState } = thunkArgs; recordEvent("pretty_print"); assert( !source.isOriginal, "Pretty-printing only allowed on generated sources" ); const sourceActor = getFirstSourceActorForGeneratedSource( getState(), source.id ); await dispatch(loadGeneratedSourceText(sourceActor)); // Just after having retrieved the minimized text content, // verify if the source can really be pretty printed. // In case it can't, revert the pretty printed status on the minimized source. // This is especially useful when automatic pretty printing is enabled. if ( isAutoPrettyPrinting && (!canPrettyPrintSource(getState(), source, sourceActor) || !isMinified( source, getSourceTextContentForSource(getState(), source, sourceActor) )) ) { dispatch({ type: "REMOVE_PRETTY_PRINTED_SOURCE", source, }); return null; } const newPrettySource = await dispatch( createPrettySource(source, sourceActor) ); // Force loading the pretty source/original text. // This will end up calling prettyPrintSourceTextContent() of this module, and // more importantly, will populate the sourceMapLoader, which is used by selectPrettyLocation. await dispatch(loadOriginalSourceText(newPrettySource)); // Update frames to the new pretty/original source (in case we were paused). // Map the frames before selecting the pretty source in order to avoid // having bundle/generated source for frames (we may compute scope things for the bundle). await dispatch(mapFrames(sourceActor.thread)); // The original locations of any stored breakpoint positions need to be updated // to point to the new pretty source. await dispatch(updateBreakpointPositionsForNewPrettyPrintedSource(source)); // Update breakpoints locations to the new pretty/original source await dispatch(updateBreakpointsForNewPrettyPrintedSource(source)); // A mutated flag, only meant to be used within this module // to know when we are done loading the pretty printed source. // This is important for the callsite in `selectLocation` // in order to ensure all action are completed and especially `mapFrames`. // Otherwise we may use generated frames there. newPrettySource._loaded = true; return fulfilled(newPrettySource); } // Use memoization in order to allow calling this actions many times // while ensuring creating the pretty source only once. export const prettyPrintSource = memoizeableAction("prettyPrintSource", { getValue: ({ source }, { getState }) => { // Lookup for an already existing pretty source const url = getPrettyOriginalSourceURL(source); const id = generatedToOriginalId(source.id, url); const s = getSource(getState(), id); // Avoid returning it if doTogglePrettyPrint isn't completed. if (!s || !s._loaded) { return undefined; } return fulfilled(s); }, createKey: ({ source }) => source.id, action: ({ source, isAutoPrettyPrinting = false }, thunkArgs) => doPrettyPrintSource(source, isAutoPrettyPrinting, thunkArgs), }); export function prettyPrintAndSelectSource(source) { return async ({ dispatch }) => { const prettySource = await dispatch(prettyPrintSource({ source })); // Select the pretty/original source based on the location we may // have had against the minified/generated source. // This uses source map to map locations. // Also note that selecting a location force many things: // * opening tabs // * fetching inline scope // * fetching breakable lines // // This isn't part of prettyPrintSource/doPrettyPrintSource // because if the source is already pretty printed, the memoization // would avoid trying to update to the mapped location based // on current location on the minified source. await dispatch(selectPrettyLocation(prettySource)); return prettySource; }; } export function removePrettyPrintedSource(source) { return async thunkArgs => { const { getState, dispatch } = thunkArgs; const { generatedSource } = source; let location = getSelectedLocation(getState()); // If we were selecting a particular line in the pretty printed source // try to select the matching line in the minimized source. // Map the original to generated location before removing the source as it would clear the mappings. if (location && location.line >= 1 && location.source == source) { // Note that it requires to have called `prettyPrintSourceTextContent` and `sourceMapLoader.setSourceMapForGeneratedSources` // to be functional and so to be called after `loadOriginalSourceText` completed. location = await getGeneratedLocation(location, thunkArgs); } dispatch({ type: "REMOVE_PRETTY_PRINTED_SOURCE", source, }); // Prevent resetting the currently selected source to avoid blinking. // The minimized source will be selected right after the reducers are cleaned up await dispatch( removeSources([source], [], { resetSelectedLocation: false }) ); const sourceActor = getFirstSourceActorForGeneratedSource( getState(), generatedSource.id ); // In case we are paused, update frames to remove references to the pretty printed sources await dispatch(mapFrames(sourceActor.thread)); // If the precise line/column correctly mapped to the minimized source, select that precise location. // Otherwise fallback to selectSource in order to select the first line instead of the current line within the pretty version. if (location.source == generatedSource) { await dispatch(selectSpecificLocation(location)); } else { await dispatch(selectSource(generatedSource)); } }; }