UNPKG

chrome-devtools-frontend

Version:
541 lines (474 loc) • 19.9 kB
// Copyright 2021 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. /* * Copyright (C) 2012 Google Inc. All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are * met: * * * Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * * Redistributions in binary form must reproduce the above * copyright notice, this list of conditions and the following disclaimer * in the documentation and/or other materials provided with the * distribution. * * Neither the #name of Google Inc. nor the names of its * contributors may be used to endorse or promote products derived from * this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ import * as TextUtils from '../../models/text_utils/text_utils.js'; import * as Common from '../common/common.js'; import * as i18n from '../i18n/i18n.js'; import * as Platform from '../platform/platform.js'; import {CompilerSourceMappingContentProvider} from './CompilerSourceMappingContentProvider.js'; import type {PageResourceLoadInitiator} from './PageResourceLoader.js'; import {PageResourceLoader} from './PageResourceLoader.js'; const UIStrings = { /** *@description Error message when failing to load a source map text via the network *@example {https://example.com/sourcemap.map} PH1 *@example {A certificate error occurred} PH2 */ couldNotLoadContentForSS: 'Could not load content for {PH1}: {PH2}', /** *@description Error message when failing to load a script source text via the network *@example {https://example.com} PH1 *@example {Unexpected token} PH2 */ couldNotParseContentForSS: 'Could not parse content for {PH1}: {PH2}', }; const str_ = i18n.i18n.registerUIStrings('core/sdk/SourceMap.ts', UIStrings); const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_); export interface SourceMap { compiledURL(): string; url(): string; sourceURLs(): string[]; sourceContentProvider(sourceURL: string, contentType: Common.ResourceType.ResourceType): TextUtils.ContentProvider.ContentProvider; embeddedContentByURL(sourceURL: string): string|null; findEntry(lineNumber: number, columnNumber: number): SourceMapEntry|null; findReverseRanges(sourceURL: string, lineNumber: number, columnNumber: number): TextUtils.TextRange.TextRange[]; sourceLineMapping(sourceURL: string, lineNumber: number, columnNumber: number): SourceMapEntry|null; mappings(): SourceMapEntry[]; mapsOrigin(): boolean; } export class SourceMapV3 { version!: number; file!: string|undefined; sources!: string[]; sections!: Section[]|undefined; mappings!: string; sourceRoot!: string|undefined; names!: string[]|undefined; sourcesContent!: string|undefined; constructor() { } } export class Section { map!: SourceMapV3; offset!: Offset; url!: string|undefined; constructor() { } } export class Offset { line!: number; column!: number; constructor() { } } export class SourceMapEntry { lineNumber: number; columnNumber: number; sourceURL: string|undefined; sourceLineNumber: number; sourceColumnNumber: number; name: string|undefined; constructor( lineNumber: number, columnNumber: number, sourceURL?: string, sourceLineNumber?: number, sourceColumnNumber?: number, name?: string) { this.lineNumber = lineNumber; this.columnNumber = columnNumber; this.sourceURL = sourceURL; this.sourceLineNumber = (sourceLineNumber as number); this.sourceColumnNumber = (sourceColumnNumber as number); this.name = name; } static compare(entry1: SourceMapEntry, entry2: SourceMapEntry): number { if (entry1.lineNumber !== entry2.lineNumber) { return entry1.lineNumber - entry2.lineNumber; } return entry1.columnNumber - entry2.columnNumber; } } const base64Digits = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; const base64Map = new Map<string, number>(); for (let i = 0; i < base64Digits.length; ++i) { base64Map.set(base64Digits.charAt(i), i); } const sourceMapToSourceList = new WeakMap<SourceMapV3, string[]>(); export class TextSourceMap implements SourceMap { readonly #initiator: PageResourceLoadInitiator; #json: SourceMapV3|null; readonly #compiledURLInternal: string; readonly #sourceMappingURL: string; readonly #baseURL: string; #mappingsInternal: SourceMapEntry[]|null; readonly #sourceInfos: Map<string, TextSourceMap.SourceInfo>; /** * Implements Source Map V3 model. See https://github.com/google/closure-compiler/wiki/Source-Maps * for format description. */ constructor( compiledURL: string, sourceMappingURL: string, payload: SourceMapV3, initiator: PageResourceLoadInitiator) { this.#initiator = initiator; this.#json = payload; this.#compiledURLInternal = compiledURL; this.#sourceMappingURL = sourceMappingURL; this.#baseURL = sourceMappingURL.startsWith('data:') ? compiledURL : sourceMappingURL; this.#mappingsInternal = null; this.#sourceInfos = new Map(); if (this.#json.sections) { const sectionWithURL = Boolean(this.#json.sections.find(section => Boolean(section.url))); if (sectionWithURL) { Common.Console.Console.instance().warn( `SourceMap "${sourceMappingURL}" contains unsupported "URL" field in one of its sections.`); } } this.eachSection(this.parseSources.bind(this)); } /** * @throws {!Error} */ static async load(sourceMapURL: string, compiledURL: string, initiator: PageResourceLoadInitiator): Promise<TextSourceMap> { let updatedContent; try { const {content} = await PageResourceLoader.instance().loadResource(sourceMapURL, initiator); updatedContent = content; if (content.slice(0, 3) === ')]}') { updatedContent = content.substring(content.indexOf('\n')); } } catch (error) { throw new Error(i18nString(UIStrings.couldNotLoadContentForSS, {PH1: sourceMapURL, PH2: error.message})); } try { const payload = (JSON.parse(updatedContent) as SourceMapV3); return new TextSourceMap(compiledURL, sourceMapURL, payload, initiator); } catch (error) { throw new Error(i18nString(UIStrings.couldNotParseContentForSS, {PH1: sourceMapURL, PH2: error.message})); } } compiledURL(): string { return this.#compiledURLInternal; } url(): string { return this.#sourceMappingURL; } sourceURLs(): string[] { return [...this.#sourceInfos.keys()]; } sourceContentProvider(sourceURL: string, contentType: Common.ResourceType.ResourceType): TextUtils.ContentProvider.ContentProvider { const info = this.#sourceInfos.get(sourceURL); if (info && info.content) { return TextUtils.StaticContentProvider.StaticContentProvider.fromString(sourceURL, contentType, info.content); } return new CompilerSourceMappingContentProvider(sourceURL, contentType, this.#initiator); } embeddedContentByURL(sourceURL: string): string|null { const entry = this.#sourceInfos.get(sourceURL); if (!entry) { return null; } return entry.content; } findEntry(lineNumber: number, columnNumber: number): SourceMapEntry|null { const mappings = this.mappings(); const index = Platform.ArrayUtilities.upperBound( mappings, undefined, (unused, entry) => lineNumber - entry.lineNumber || columnNumber - entry.columnNumber); return index ? mappings[index - 1] : null; } sourceLineMapping(sourceURL: string, lineNumber: number, columnNumber: number): SourceMapEntry|null { const mappings = this.mappings(); const reverseMappings = this.reversedMappings(sourceURL); const first = Platform.ArrayUtilities.lowerBound(reverseMappings, lineNumber, lineComparator); const last = Platform.ArrayUtilities.upperBound(reverseMappings, lineNumber, lineComparator); if (first >= reverseMappings.length || mappings[reverseMappings[first]].sourceLineNumber !== lineNumber) { return null; } const columnMappings = reverseMappings.slice(first, last); if (!columnMappings.length) { return null; } const index = Platform.ArrayUtilities.lowerBound( columnMappings, columnNumber, (columnNumber, i) => columnNumber - mappings[i].sourceColumnNumber); return index >= columnMappings.length ? mappings[columnMappings[columnMappings.length - 1]] : mappings[columnMappings[index]]; function lineComparator(lineNumber: number, i: number): number { return lineNumber - mappings[i].sourceLineNumber; } } private findReverseIndices(sourceURL: string, lineNumber: number, columnNumber: number): number[] { const mappings = this.mappings(); const reverseMappings = this.reversedMappings(sourceURL); const endIndex = Platform.ArrayUtilities.upperBound( reverseMappings, undefined, (unused, i) => lineNumber - mappings[i].sourceLineNumber || columnNumber - mappings[i].sourceColumnNumber); let startIndex = endIndex; while (startIndex > 0 && mappings[reverseMappings[startIndex - 1]].sourceLineNumber === mappings[reverseMappings[endIndex - 1]].sourceLineNumber && mappings[reverseMappings[startIndex - 1]].sourceColumnNumber === mappings[reverseMappings[endIndex - 1]].sourceColumnNumber) { --startIndex; } return reverseMappings.slice(startIndex, endIndex); } findReverseEntries(sourceURL: string, lineNumber: number, columnNumber: number): SourceMapEntry[] { const mappings = this.mappings(); return this.findReverseIndices(sourceURL, lineNumber, columnNumber).map(i => mappings[i]); } findReverseRanges(sourceURL: string, lineNumber: number, columnNumber: number): TextUtils.TextRange.TextRange[] { const mappings = this.mappings(); const indices = this.findReverseIndices(sourceURL, lineNumber, columnNumber); const ranges: TextUtils.TextRange.TextRange[] = []; for (let i = 0; i < indices.length; ++i) { const startIndex = indices[i]; // Merge adjacent ranges. let endIndex = startIndex + 1; while (i + 1 < indices.length && endIndex === indices[i + 1]) { ++endIndex; ++i; } const endLine = endIndex < mappings.length ? mappings[endIndex].lineNumber : Infinity; const endColumn = endIndex < mappings.length ? mappings[endIndex].columnNumber : 0; ranges.push(new TextUtils.TextRange.TextRange( mappings[startIndex].lineNumber, mappings[startIndex].columnNumber, endLine, endColumn)); } return ranges; } mappings(): SourceMapEntry[] { if (this.#mappingsInternal === null) { this.#mappingsInternal = []; this.eachSection(this.parseMap.bind(this)); this.#json = null; } return /** @type {!Array<!SourceMapEntry>} */ this.#mappingsInternal as SourceMapEntry[]; } private reversedMappings(sourceURL: string): number[] { const info = this.#sourceInfos.get(sourceURL); if (!info) { return []; } const mappings = this.mappings(); if (info.reverseMappings === null) { const indexes = Array(mappings.length).fill(0).map((_, i) => i); info.reverseMappings = indexes.filter(i => mappings[i].sourceURL === sourceURL).sort(sourceMappingComparator); } return info.reverseMappings; function sourceMappingComparator(indexA: number, indexB: number): number { const a = mappings[indexA]; const b = mappings[indexB]; if (a.sourceLineNumber !== b.sourceLineNumber) { return a.sourceLineNumber - b.sourceLineNumber; } if (a.sourceColumnNumber !== b.sourceColumnNumber) { return a.sourceColumnNumber - b.sourceColumnNumber; } if (a.lineNumber !== b.lineNumber) { return a.lineNumber - b.lineNumber; } return a.columnNumber - b.columnNumber; } } private eachSection(callback: (arg0: SourceMapV3, arg1: number, arg2: number) => void): void { if (!this.#json) { return; } if (!this.#json.sections) { callback(this.#json, 0, 0); return; } for (const section of this.#json.sections) { callback(section.map, section.offset.line, section.offset.column); } } private parseSources(sourceMap: SourceMapV3): void { const sourcesList = []; let sourceRoot = sourceMap.sourceRoot || ''; if (sourceRoot && !sourceRoot.endsWith('/')) { sourceRoot += '/'; } for (let i = 0; i < sourceMap.sources.length; ++i) { const href = sourceRoot + sourceMap.sources[i]; let url = Common.ParsedURL.ParsedURL.completeURL(this.#baseURL, href) || href; const source = sourceMap.sourcesContent && sourceMap.sourcesContent[i]; if (url === this.#compiledURLInternal && source) { url += '? [sm]'; } this.#sourceInfos.set(url, new TextSourceMap.SourceInfo(source || null, null)); sourcesList.push(url); } sourceMapToSourceList.set(sourceMap, sourcesList); } private parseMap(map: SourceMapV3, lineNumber: number, columnNumber: number): void { let sourceIndex = 0; let sourceLineNumber = 0; let sourceColumnNumber = 0; let nameIndex = 0; // TODO(crbug.com/1011811): refactor away map. // `sources` can be undefined if it wasn't previously // processed and added to the list. However, that // is not WAI and we should make sure that we can // only reach this point when we are certain // we have the list available. const sources = sourceMapToSourceList.get(map); const names = map.names || []; const stringCharIterator = new TextSourceMap.StringCharIterator(map.mappings); let sourceURL: string|(string | undefined) = sources && sources[sourceIndex]; while (true) { if (stringCharIterator.peek() === ',') { stringCharIterator.next(); } else { while (stringCharIterator.peek() === ';') { lineNumber += 1; columnNumber = 0; stringCharIterator.next(); } if (!stringCharIterator.hasNext()) { break; } } columnNumber += this.decodeVLQ(stringCharIterator); if (!stringCharIterator.hasNext() || this.isSeparator(stringCharIterator.peek())) { this.mappings().push(new SourceMapEntry(lineNumber, columnNumber)); continue; } const sourceIndexDelta = this.decodeVLQ(stringCharIterator); if (sourceIndexDelta) { sourceIndex += sourceIndexDelta; if (sources) { sourceURL = sources[sourceIndex]; } } sourceLineNumber += this.decodeVLQ(stringCharIterator); sourceColumnNumber += this.decodeVLQ(stringCharIterator); if (!stringCharIterator.hasNext() || this.isSeparator(stringCharIterator.peek())) { this.mappings().push( new SourceMapEntry(lineNumber, columnNumber, sourceURL, sourceLineNumber, sourceColumnNumber)); continue; } nameIndex += this.decodeVLQ(stringCharIterator); this.mappings().push(new SourceMapEntry( lineNumber, columnNumber, sourceURL, sourceLineNumber, sourceColumnNumber, names[nameIndex])); } // As per spec, mappings are not necessarily sorted. this.mappings().sort(SourceMapEntry.compare); } private isSeparator(char: string): boolean { return char === ',' || char === ';'; } private decodeVLQ(stringCharIterator: TextSourceMap.StringCharIterator): number { // Read unsigned value. let result = 0; let shift = 0; let digit: number = TextSourceMap._VLQ_CONTINUATION_MASK; while (digit & TextSourceMap._VLQ_CONTINUATION_MASK) { digit = base64Map.get(stringCharIterator.next()) || 0; result += (digit & TextSourceMap._VLQ_BASE_MASK) << shift; shift += TextSourceMap._VLQ_BASE_SHIFT; } // Fix the sign. const negative = result & 1; result >>= 1; return negative ? -result : result; } reverseMapTextRange(url: string, textRange: TextUtils.TextRange.TextRange): TextUtils.TextRange.TextRange|null { function comparator( position: { lineNumber: number, columnNumber: number, }, mappingIndex: number): number { if (position.lineNumber !== mappings[mappingIndex].sourceLineNumber) { return position.lineNumber - mappings[mappingIndex].sourceLineNumber; } return position.columnNumber - mappings[mappingIndex].sourceColumnNumber; } const reverseMappings = this.reversedMappings(url); const mappings = this.mappings(); if (!reverseMappings.length) { return null; } const startIndex = Platform.ArrayUtilities.lowerBound( reverseMappings, {lineNumber: textRange.startLine, columnNumber: textRange.startColumn}, comparator); const endIndex = Platform.ArrayUtilities.upperBound( reverseMappings, {lineNumber: textRange.endLine, columnNumber: textRange.endColumn}, comparator); if (endIndex >= reverseMappings.length) { return null; } const startMapping = mappings[reverseMappings[startIndex]]; const endMapping = mappings[reverseMappings[endIndex]]; return new TextUtils.TextRange.TextRange( startMapping.lineNumber, startMapping.columnNumber, endMapping.lineNumber, endMapping.columnNumber); } mapsOrigin(): boolean { const mappings = this.mappings(); if (mappings.length > 0) { const firstEntry = mappings[0]; return firstEntry?.lineNumber === 0 || firstEntry.columnNumber === 0; } return false; } } export namespace TextSourceMap { // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration // eslint-disable-next-line @typescript-eslint/naming-convention export const _VLQ_BASE_SHIFT = 5; // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration // eslint-disable-next-line @typescript-eslint/naming-convention export const _VLQ_BASE_MASK = (1 << 5) - 1; // TODO(crbug.com/1172300) Ignored during the jsdoc to ts migration // eslint-disable-next-line @typescript-eslint/naming-convention export const _VLQ_CONTINUATION_MASK = 1 << 5; export class StringCharIterator { private readonly string: string; private position: number; constructor(string: string) { this.string = string; this.position = 0; } next(): string { return this.string.charAt(this.position++); } peek(): string { return this.string.charAt(this.position); } hasNext(): boolean { return this.position < this.string.length; } } export class SourceInfo { content: string|null; reverseMappings: number[]|null; constructor(content: string|null, reverseMappings: number[]|null) { this.content = content; this.reverseMappings = reverseMappings; } } }