prettyPrint.js (17861B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at <http://mozilla.org/MPL/2.0/>. */ 4 5 import { generatedToOriginalId } from "devtools/client/shared/source-map-loader/index"; 6 7 import assert from "../../utils/assert"; 8 import { recordEvent } from "../../utils/telemetry"; 9 import { 10 updateBreakpointPositionsForNewPrettyPrintedSource, 11 updateBreakpointsForNewPrettyPrintedSource, 12 } from "../breakpoints/index"; 13 14 import { 15 getPrettySourceURL, 16 isJavaScript, 17 isMinified, 18 } from "../../utils/source"; 19 import { isFulfilled, fulfilled } from "../../utils/async-value"; 20 import { 21 getOriginalLocation, 22 getGeneratedLocation, 23 } from "../../utils/source-maps"; 24 import { prefs } from "../../utils/prefs"; 25 import { 26 loadGeneratedSourceText, 27 loadOriginalSourceText, 28 } from "./loadSourceText"; 29 import { removeSources } from "./removeSources"; 30 import { mapFrames } from "../pause/index"; 31 import { selectSpecificLocation } from "../sources/index"; 32 import { createPrettyPrintOriginalSource } from "../../client/firefox/create"; 33 34 import { 35 getFirstSourceActorForGeneratedSource, 36 getSource, 37 getSelectedLocation, 38 canPrettyPrintSource, 39 getSourceTextContentForSource, 40 } from "../../selectors/index"; 41 42 import { selectSource } from "./select"; 43 import { memoizeableAction } from "../../utils/memoizableAction"; 44 45 import DevToolsUtils from "devtools/shared/DevToolsUtils"; 46 47 /** 48 * Replace all line breaks with standard \n line breaks for easier parsing. 49 */ 50 const LINE_BREAK_REGEX = /\r\n?|\n|\u2028|\u2029/g; 51 function sanitizeLineBreaks(str) { 52 return str.replace(LINE_BREAK_REGEX, "\n"); 53 } 54 55 /** 56 * Retrieve all line breaks in the provided string. 57 * Note: this assumes the line breaks were previously sanitized with 58 * `sanitizeLineBreaks` defined above. 59 */ 60 const SIMPLE_LINE_BREAK_REGEX = /\n/g; 61 function matchAllLineBreaks(str) { 62 return Array.from(str.matchAll(SIMPLE_LINE_BREAK_REGEX)); 63 } 64 65 function getPrettyOriginalSourceURL(generatedSource) { 66 return getPrettySourceURL(generatedSource.url || generatedSource.id); 67 } 68 69 export async function prettyPrintSourceTextContent( 70 sourceMapLoader, 71 prettyPrintWorker, 72 generatedSource, 73 content, 74 actors 75 ) { 76 if (!content || !isFulfilled(content)) { 77 throw new Error("Cannot pretty-print a file that has not loaded"); 78 } 79 80 const contentValue = content.value; 81 if ( 82 (!isJavaScript(generatedSource, contentValue) && !generatedSource.isHTML) || 83 contentValue.type !== "text" 84 ) { 85 throw new Error( 86 `Can't prettify ${contentValue.contentType} files, only HTML and Javascript.` 87 ); 88 } 89 90 const url = getPrettyOriginalSourceURL(generatedSource); 91 92 let prettyPrintWorkerResult; 93 if (generatedSource.isHTML) { 94 prettyPrintWorkerResult = await prettyPrintHtmlFile({ 95 prettyPrintWorker, 96 generatedSource, 97 content, 98 actors, 99 }); 100 } else { 101 prettyPrintWorkerResult = await prettyPrintWorker.prettyPrint({ 102 sourceText: contentValue.value, 103 indent: " ".repeat(prefs.indentSize), 104 url, 105 }); 106 } 107 108 // The source map URL service used by other devtools listens to changes to 109 // sources based on their actor IDs, so apply the sourceMap there too. 110 const generatedSourceIds = [ 111 generatedSource.id, 112 ...actors.map(item => item.actor), 113 ]; 114 await sourceMapLoader.setSourceMapForGeneratedSources( 115 generatedSourceIds, 116 prettyPrintWorkerResult.sourceMap 117 ); 118 119 return { 120 text: prettyPrintWorkerResult.code, 121 contentType: contentValue.contentType, 122 }; 123 } 124 125 /** 126 * Pretty print inline script inside an HTML file 127 * 128 * @param {object} options 129 * @param {PrettyPrintDispatcher} options.prettyPrintWorker: The prettyPrint worker 130 * @param {object} options.generatedSource: The HTML source we want to pretty print 131 * @param {object} options.content 132 * @param {Array} options.actors: An array of the HTML file inline script sources data 133 * 134 * @returns Promise<Object> A promise that resolves with an object of the following shape: 135 * - {String} code: The prettified HTML text 136 * - {Object} sourceMap: The sourceMap object 137 */ 138 async function prettyPrintHtmlFile({ 139 prettyPrintWorker, 140 generatedSource, 141 content, 142 actors, 143 }) { 144 const url = getPrettyOriginalSourceURL(generatedSource); 145 const contentValue = content.value; 146 147 // Original source may contain unix-style & windows-style breaks. 148 // SpiderMonkey works a sanitized version of the source using only \n (unix). 149 // Sanitize before parsing the source to align with SpiderMonkey. 150 const htmlFileText = sanitizeLineBreaks(contentValue.value); 151 const prettyPrintWorkerResult = { code: htmlFileText }; 152 153 const allLineBreaks = matchAllLineBreaks(htmlFileText); 154 let lineCountDelta = 0; 155 156 // Sort inline script actors so they are in the same order as in the html document. 157 actors.sort((a, b) => { 158 if (a.sourceStartLine === b.sourceStartLine) { 159 return a.sourceStartColumn > b.sourceStartColumn; 160 } 161 return a.sourceStartLine > b.sourceStartLine; 162 }); 163 164 const prettyPrintTaskId = generatedSource.id; 165 166 // We don't want to replace part of the HTML document in the loop since it would require 167 // to account for modified lines for each iteration. 168 // Instead, we'll put each sections to replace in this array, where elements will be 169 // objects of the following shape: 170 // {Integer} startIndex: The start index in htmlFileText of the section we want to replace 171 // {Integer} endIndex: The end index in htmlFileText of the section we want to replace 172 // {String} prettyText: The pretty text we'll replace the original section with 173 // Once we iterated over all the inline scripts, we'll do the replacements (on the html 174 // file text) in reverse order, so we don't need have to care about the modified lines 175 // for each iteration. 176 const replacements = []; 177 178 const seenLocations = new Set(); 179 180 for (const sourceInfo of actors) { 181 // We can get duplicate source actors representing the same inline script which will 182 // cause trouble in the pretty printing here. This should be fixed on the server (see 183 // Bug 1824979), but in the meantime let's not handle the same location twice so the 184 // pretty printing is not impacted. 185 const location = `${sourceInfo.sourceStartLine}:${sourceInfo.sourceStartColumn}`; 186 if (!sourceInfo.sourceLength || seenLocations.has(location)) { 187 continue; 188 } 189 seenLocations.add(location); 190 // Here we want to get the index of the last line break before the script tag. 191 // In allLineBreaks, this would be the item at (script tag line - 1) 192 // Since sourceInfo.sourceStartLine is 1-based, we need to get the item at (sourceStartLine - 2) 193 const indexAfterPreviousLineBreakInHtml = 194 sourceInfo.sourceStartLine > 1 195 ? allLineBreaks[sourceInfo.sourceStartLine - 2].index + 1 196 : 0; 197 198 // The `sourceStartColumn` refers to final unicode characters column (including 16-bits characters), 199 // not including any unicode characters encoded by surrogate pairs (two 16 bit code units) 200 // i.e outside of the Basic Multiligual Plane. So calculate and add those characters to the looked-up start index. 201 const startColumn = 202 indexAfterPreviousLineBreakInHtml + sourceInfo.sourceStartColumn; 203 const htmlBeforeStr = htmlFileText.substring(0, startColumn); 204 const codeUnitLength = htmlBeforeStr.length, 205 codePointLength = [...htmlBeforeStr].length; 206 const extraCharsWithForStrTwoCodeUnits = codeUnitLength - codePointLength; 207 208 const startIndex = startColumn + extraCharsWithForStrTwoCodeUnits; 209 const endIndex = startIndex + sourceInfo.sourceLength; 210 const scriptText = htmlFileText.substring(startIndex, endIndex); 211 DevToolsUtils.assert( 212 scriptText.length == sourceInfo.sourceLength, 213 "script text has expected length" 214 ); 215 216 // Here we're going to pretty print each inline script content. 217 // Since we want to have a sourceMap that we'll apply to the whole HTML file, 218 // we'll only collect the sourceMap once we handled all inline scripts. 219 // `taskId` allows us to signal to the worker that all those calls are part of the 220 // same bigger file, and we'll use it later to get the sourceMap. 221 const prettyText = await prettyPrintWorker.prettyPrintInlineScript({ 222 taskId: prettyPrintTaskId, 223 sourceText: scriptText, 224 indent: " ".repeat(prefs.indentSize), 225 url, 226 originalStartLine: sourceInfo.sourceStartLine, 227 originalStartColumn: sourceInfo.sourceStartColumn, 228 // The generated line will be impacted by the previous inline scripts that were 229 // pretty printed, which is why we offset with lineCountDelta 230 generatedStartLine: sourceInfo.sourceStartLine + lineCountDelta, 231 generatedStartColumn: sourceInfo.sourceStartColumn, 232 lineCountDelta, 233 }); 234 235 // We need to keep track of the line added/removed in order to properly offset 236 // the mapping of the pretty-print text 237 lineCountDelta += 238 matchAllLineBreaks(prettyText).length - 239 matchAllLineBreaks(scriptText).length; 240 241 replacements.push({ 242 startIndex, 243 endIndex, 244 prettyText, 245 }); 246 } 247 248 // `getSourceMap` allow us to collect the computed source map resulting of the calls 249 // to `prettyPrint` with the same taskId. 250 prettyPrintWorkerResult.sourceMap = 251 await prettyPrintWorker.getSourceMap(prettyPrintTaskId); 252 253 // Sort replacement in reverse order so we can replace code in the HTML file more easily 254 replacements.sort((a, b) => a.startIndex < b.startIndex); 255 for (const { startIndex, endIndex, prettyText } of replacements) { 256 prettyPrintWorkerResult.code = 257 prettyPrintWorkerResult.code.substring(0, startIndex) + 258 prettyText + 259 prettyPrintWorkerResult.code.substring(endIndex); 260 } 261 262 return prettyPrintWorkerResult; 263 } 264 265 function createPrettySource(source, sourceActor) { 266 return async ({ dispatch }) => { 267 const url = getPrettyOriginalSourceURL(source); 268 const id = generatedToOriginalId(source.id, url); 269 const prettySource = createPrettyPrintOriginalSource(id, url, source); 270 271 dispatch({ 272 type: "ADD_ORIGINAL_SOURCES", 273 originalSources: [prettySource], 274 generatedSourceActor: sourceActor, 275 }); 276 return prettySource; 277 }; 278 } 279 280 function selectPrettyLocation(prettySource) { 281 return async thunkArgs => { 282 const { dispatch, getState } = thunkArgs; 283 let location = getSelectedLocation(getState()); 284 285 // If we were selecting a particular line in the minified/generated source, 286 // try to select the matching line in the prettified/original source. 287 if ( 288 location && 289 location.line >= 1 && 290 getPrettySourceURL(location.source.url) == prettySource.url 291 ) { 292 // Note that it requires to have called `prettyPrintSourceTextContent` and `sourceMapLoader.setSourceMapForGeneratedSources` 293 // to be functional and so to be called after `loadOriginalSourceText` completed. 294 location = await getOriginalLocation(location, thunkArgs); 295 296 // If the precise line/column correctly mapped to the pretty printed source, select that precise location. 297 // Otherwise fallback to selectSource in order to select the first line instead of the current line within the bundle. 298 if (location.source == prettySource) { 299 return dispatch(selectSpecificLocation(location)); 300 } 301 } 302 303 return dispatch(selectSource(prettySource)); 304 }; 305 } 306 307 /** 308 * Toggle the pretty printing of a source's text. 309 * Nothing will happen for non-javascript, non-minified, or files that can't be pretty printed. 310 * 311 * @param Object source 312 * The source object for the minified/generated source. 313 * @param Boolean isAutoPrettyPrinting 314 * Are we pretty printing this source because of auto-pretty printing preference? 315 * @returns Promise 316 * A promise that resolves to the Pretty print/original source object. 317 */ 318 export async function doPrettyPrintSource( 319 source, 320 isAutoPrettyPrinting, 321 thunkArgs 322 ) { 323 const { dispatch, getState } = thunkArgs; 324 recordEvent("pretty_print"); 325 326 assert( 327 !source.isOriginal, 328 "Pretty-printing only allowed on generated sources" 329 ); 330 331 const sourceActor = getFirstSourceActorForGeneratedSource( 332 getState(), 333 source.id 334 ); 335 336 await dispatch(loadGeneratedSourceText(sourceActor)); 337 338 // Just after having retrieved the minimized text content, 339 // verify if the source can really be pretty printed. 340 // In case it can't, revert the pretty printed status on the minimized source. 341 // This is especially useful when automatic pretty printing is enabled. 342 if ( 343 isAutoPrettyPrinting && 344 (!canPrettyPrintSource(getState(), source, sourceActor) || 345 !isMinified( 346 source, 347 getSourceTextContentForSource(getState(), source, sourceActor) 348 )) 349 ) { 350 dispatch({ 351 type: "REMOVE_PRETTY_PRINTED_SOURCE", 352 source, 353 }); 354 return null; 355 } 356 357 const newPrettySource = await dispatch( 358 createPrettySource(source, sourceActor) 359 ); 360 361 // Force loading the pretty source/original text. 362 // This will end up calling prettyPrintSourceTextContent() of this module, and 363 // more importantly, will populate the sourceMapLoader, which is used by selectPrettyLocation. 364 await dispatch(loadOriginalSourceText(newPrettySource)); 365 366 // Update frames to the new pretty/original source (in case we were paused). 367 // Map the frames before selecting the pretty source in order to avoid 368 // having bundle/generated source for frames (we may compute scope things for the bundle). 369 await dispatch(mapFrames(sourceActor.thread)); 370 371 // The original locations of any stored breakpoint positions need to be updated 372 // to point to the new pretty source. 373 await dispatch(updateBreakpointPositionsForNewPrettyPrintedSource(source)); 374 375 // Update breakpoints locations to the new pretty/original source 376 await dispatch(updateBreakpointsForNewPrettyPrintedSource(source)); 377 378 // A mutated flag, only meant to be used within this module 379 // to know when we are done loading the pretty printed source. 380 // This is important for the callsite in `selectLocation` 381 // in order to ensure all action are completed and especially `mapFrames`. 382 // Otherwise we may use generated frames there. 383 newPrettySource._loaded = true; 384 385 return fulfilled(newPrettySource); 386 } 387 388 // Use memoization in order to allow calling this actions many times 389 // while ensuring creating the pretty source only once. 390 export const prettyPrintSource = memoizeableAction("prettyPrintSource", { 391 getValue: ({ source }, { getState }) => { 392 // Lookup for an already existing pretty source 393 const url = getPrettyOriginalSourceURL(source); 394 const id = generatedToOriginalId(source.id, url); 395 const s = getSource(getState(), id); 396 // Avoid returning it if doTogglePrettyPrint isn't completed. 397 if (!s || !s._loaded) { 398 return undefined; 399 } 400 return fulfilled(s); 401 }, 402 createKey: ({ source }) => source.id, 403 action: ({ source, isAutoPrettyPrinting = false }, thunkArgs) => 404 doPrettyPrintSource(source, isAutoPrettyPrinting, thunkArgs), 405 }); 406 407 export function prettyPrintAndSelectSource(source) { 408 return async ({ dispatch }) => { 409 const prettySource = await dispatch(prettyPrintSource({ source })); 410 411 // Select the pretty/original source based on the location we may 412 // have had against the minified/generated source. 413 // This uses source map to map locations. 414 // Also note that selecting a location force many things: 415 // * opening tabs 416 // * fetching inline scope 417 // * fetching breakable lines 418 // 419 // This isn't part of prettyPrintSource/doPrettyPrintSource 420 // because if the source is already pretty printed, the memoization 421 // would avoid trying to update to the mapped location based 422 // on current location on the minified source. 423 await dispatch(selectPrettyLocation(prettySource)); 424 425 return prettySource; 426 }; 427 } 428 429 export function removePrettyPrintedSource(source) { 430 return async thunkArgs => { 431 const { getState, dispatch } = thunkArgs; 432 const { generatedSource } = source; 433 434 let location = getSelectedLocation(getState()); 435 // If we were selecting a particular line in the pretty printed source 436 // try to select the matching line in the minimized source. 437 // Map the original to generated location before removing the source as it would clear the mappings. 438 if (location && location.line >= 1 && location.source == source) { 439 // Note that it requires to have called `prettyPrintSourceTextContent` and `sourceMapLoader.setSourceMapForGeneratedSources` 440 // to be functional and so to be called after `loadOriginalSourceText` completed. 441 location = await getGeneratedLocation(location, thunkArgs); 442 } 443 444 dispatch({ 445 type: "REMOVE_PRETTY_PRINTED_SOURCE", 446 source, 447 }); 448 449 // Prevent resetting the currently selected source to avoid blinking. 450 // The minimized source will be selected right after the reducers are cleaned up 451 await dispatch( 452 removeSources([source], [], { resetSelectedLocation: false }) 453 ); 454 455 const sourceActor = getFirstSourceActorForGeneratedSource( 456 getState(), 457 generatedSource.id 458 ); 459 // In case we are paused, update frames to remove references to the pretty printed sources 460 await dispatch(mapFrames(sourceActor.thread)); 461 462 // If the precise line/column correctly mapped to the minimized source, select that precise location. 463 // Otherwise fallback to selectSource in order to select the first line instead of the current line within the pretty version. 464 if (location.source == generatedSource) { 465 await dispatch(selectSpecificLocation(location)); 466 } else { 467 await dispatch(selectSource(generatedSource)); 468 } 469 }; 470 }