/** * @license * Copyright Google LLC All Rights Reserved. * * Use of this source code is governed by an MIT-style license that can be * found in the LICENSE file at https://angular.io/license */ import {commentRegex, fromComment, mapFileCommentRegex} from 'convert-source-map'; import {absoluteFrom, AbsoluteFsPath, FileSystem} from '../../../src/ngtsc/file_system'; import {Logger} from '../logging/logger'; import {RawSourceMap} from './raw_source_map'; import {SourceFile} from './source_file'; /** * This class can be used to load a source file, its associated source map and any upstream sources. * * Since a source file might reference (or include) a source map, this class can load those too. * Since a source map might reference other source files, these are also loaded as needed. * * This is done recursively. The result is a "tree" of `SourceFile` objects, each containing * mappings to other `SourceFile` objects as necessary. */ export class SourceFileLoader { private currentPaths: AbsoluteFsPath[] = []; constructor(private fs: FileSystem, private logger: Logger) {} /** * Load a source file, compute its source map, and recursively load any referenced source files. * * @param sourcePath The path to the source file to load. * @param contents The contents of the source file to load. * @param mapAndPath The raw source-map and the path to the source-map file. * @returns a SourceFile object created from the `contents` and provided source-map info. */ loadSourceFile(sourcePath: AbsoluteFsPath, contents: string, mapAndPath: MapAndPath): SourceFile; /** * The overload used internally to load source files referenced in a source-map. * * In this case there is no guarantee that it will return a non-null SourceMap. * * @param sourcePath The path to the source file to load. * @param contents The contents of the source file to load, if provided inline. * If it is not known the contents will be read from the file at the `sourcePath`. * @param mapAndPath The raw source-map and the path to the source-map file. * * @returns a SourceFile if the content for one was provided or able to be loaded from disk, * `null` otherwise. */ loadSourceFile(sourcePath: AbsoluteFsPath, contents?: string|null, mapAndPath?: null): SourceFile |null; loadSourceFile( sourcePath: AbsoluteFsPath, contents: string|null = null, mapAndPath: MapAndPath|null = null): SourceFile|null { const previousPaths = this.currentPaths.slice(); try { if (contents === null) { if (!this.fs.exists(sourcePath)) { return null; } contents = this.readSourceFile(sourcePath); } // If not provided try to load the source map based on the source itself if (mapAndPath === null) { mapAndPath = this.loadSourceMap(sourcePath, contents); } let map: RawSourceMap|null = null; let inline = true; let sources: (SourceFile|null)[] = []; if (mapAndPath !== null) { const basePath = mapAndPath.mapPath || sourcePath; sources = this.processSources(basePath, mapAndPath.map); map = mapAndPath.map; inline = mapAndPath.mapPath === null; } return new SourceFile(sourcePath, contents, map, inline, sources); } catch (e) { this.logger.warn( `Unable to fully load ${sourcePath} for source-map flattening: ${e.message}`); return null; } finally { // We are finished with this recursion so revert the paths being tracked this.currentPaths = previousPaths; } } /** * Find the source map associated with the source file whose `sourcePath` and `contents` are * provided. * * Source maps can be inline, as part of a base64 encoded comment, or external as a separate file * whose path is indicated in a comment or implied from the name of the source file itself. */ private loadSourceMap(sourcePath: AbsoluteFsPath, contents: string): MapAndPath|null { const inline = commentRegex.exec(contents); if (inline !== null) { return {map: fromComment(inline.pop()!).sourcemap, mapPath: null}; } const external = mapFileCommentRegex.exec(contents); if (external) { try { const fileName = external[1] || external[2]; const externalMapPath = this.fs.resolve(this.fs.dirname(sourcePath), fileName); return {map: this.readRawSourceMap(externalMapPath), mapPath: externalMapPath}; } catch (e) { this.logger.warn( `Unable to fully load ${sourcePath} for source-map flattening: ${e.message}`); return null; } } const impliedMapPath = absoluteFrom(sourcePath + '.map'); if (this.fs.exists(impliedMapPath)) { return {map: this.readRawSourceMap(impliedMapPath), mapPath: impliedMapPath}; } return null; } /** * Iterate over each of the "sources" for this source file's source map, recursively loading each * source file and its associated source map. */ private processSources(basePath: AbsoluteFsPath, map: RawSourceMap): (SourceFile|null)[] { const sourceRoot = this.fs.resolve(this.fs.dirname(basePath), map.sourceRoot || ''); return map.sources.map((source, index) => { const path = this.fs.resolve(sourceRoot, source); const content = map.sourcesContent && map.sourcesContent[index] || null; return this.loadSourceFile(path, content, null); }); } /** * Load the contents of the source file from disk. * * @param sourcePath The path to the source file. */ private readSourceFile(sourcePath: AbsoluteFsPath): string { this.trackPath(sourcePath); return this.fs.readFile(sourcePath); } /** * Load the source map from the file at `mapPath`, parsing its JSON contents into a `RawSourceMap` * object. * * @param mapPath The path to the source-map file. */ private readRawSourceMap(mapPath: AbsoluteFsPath): RawSourceMap { this.trackPath(mapPath); return JSON.parse(this.fs.readFile(mapPath)); } /** * Track source file paths if we have loaded them from disk so that we don't get into an infinite * recursion. */ private trackPath(path: AbsoluteFsPath): void { if (this.currentPaths.includes(path)) { throw new Error( `Circular source file mapping dependency: ${this.currentPaths.join(' -> ')} -> ${path}`); } this.currentPaths.push(path); } } /** A small helper structure that is returned from `loadSourceMap()`. */ interface MapAndPath { /** The path to the source map if it was external or `null` if it was inline. */ mapPath: AbsoluteFsPath|null; /** The raw source map itself. */ map: RawSourceMap; }