/* 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 { debuggerToSourceMapLocation, sourceMapToDebuggerLocation, } from "../../location"; import { locColumn } from "./locColumn"; import { loadRangeMetadata, findMatchingRange } from "./rangeMetadata"; // eslint-disable-next-line max-len import { findGeneratedReference, findGeneratedImportReference, findGeneratedImportDeclaration, } from "./findGeneratedBindingFromPosition"; import { buildGeneratedBindingList, buildFakeBindingList, } from "./buildGeneratedBindingList"; import { originalRangeStartsInside, getApplicableBindingsForOriginalPosition, } from "./getApplicableBindingsForOriginalPosition"; import { getOptimizedOutGrip } from "./optimizedOut"; import { log } from "../../log"; // Create real location objects for all location start and end. // // Parser worker returns scopes with location having a sourceId // instead of a source object as it doesn't know about main thread source objects. function updateLocationsInScopes(state, scopes) { for (const item of scopes) { for (const name of Object.keys(item.bindings)) { for (const ref of item.bindings[name].refs) { const locs = [ref]; if (ref.type !== "ref") { locs.push(ref.declaration); } for (const loc of locs) { loc.start = sourceMapToDebuggerLocation(state, loc.start); loc.end = sourceMapToDebuggerLocation(state, loc.end); } } } } } export async function buildMappedScopes( source, content, frame, scopes, thunkArgs ) { const { getState, parserWorker } = thunkArgs; if (!parserWorker.isLocationSupported(frame.location)) { return null; } const originalAstScopes = await parserWorker.getScopes(frame.location); updateLocationsInScopes(getState(), originalAstScopes); const generatedAstScopes = await parserWorker.getScopes( frame.generatedLocation ); updateLocationsInScopes(getState(), generatedAstScopes); if (!originalAstScopes || !generatedAstScopes) { return null; } const originalRanges = await loadRangeMetadata( frame.location, originalAstScopes, thunkArgs ); if (hasLineMappings(originalRanges)) { return null; } let generatedAstBindings; if (scopes) { generatedAstBindings = buildGeneratedBindingList( scopes, generatedAstScopes, frame.this ); } else { generatedAstBindings = buildFakeBindingList(generatedAstScopes); } const { mappedOriginalScopes, expressionLookup } = await mapOriginalBindingsToGenerated( source, content, originalRanges, originalAstScopes, generatedAstBindings, thunkArgs ); const globalLexicalScope = scopes ? getGlobalFromScope(scopes) : generateGlobalFromAst(generatedAstScopes); const mappedGeneratedScopes = generateClientScope( globalLexicalScope, mappedOriginalScopes ); return isReliableScope(mappedGeneratedScopes) ? { mappings: expressionLookup, scope: mappedGeneratedScopes } : null; } async function mapOriginalBindingsToGenerated( source, content, originalRanges, originalAstScopes, generatedAstBindings, thunkArgs ) { const expressionLookup = {}; const mappedOriginalScopes = []; const cachedSourceMaps = batchScopeMappings( originalAstScopes, source, thunkArgs ); // Override sourceMapLoader attribute with the special cached SourceMapLoader instance // in order to make it used by all functions used in this method. thunkArgs = { ...thunkArgs, sourceMapLoader: cachedSourceMaps }; for (const item of originalAstScopes) { const generatedBindings = {}; for (const name of Object.keys(item.bindings)) { const binding = item.bindings[name]; const result = await findGeneratedBinding( source, content, name, binding, originalRanges, generatedAstBindings, thunkArgs ); if (result) { generatedBindings[name] = result.grip; if ( binding.refs.length !== 0 && // These are assigned depth-first, so we don't want shadowed // bindings in parent scopes overwriting the expression. !Object.prototype.hasOwnProperty.call(expressionLookup, name) ) { expressionLookup[name] = result.expression; } } } mappedOriginalScopes.push({ ...item, generatedBindings, }); } return { mappedOriginalScopes, expressionLookup, }; } /** * Consider a scope and its parents reliable if the vast majority of its * bindings were successfully mapped to generated scope bindings. */ function isReliableScope(scope) { let totalBindings = 0; let unknownBindings = 0; for (let s = scope; s; s = s.parent) { const vars = s.bindings?.variables || {}; for (const key of Object.keys(vars)) { const binding = vars[key]; totalBindings += 1; if ( binding.value && typeof binding.value === "object" && (binding.value.type === "unscoped" || binding.value.type === "unmapped") ) { unknownBindings += 1; } } } // As determined by fair dice roll. return totalBindings === 0 || unknownBindings / totalBindings < 0.25; } function hasLineMappings(ranges) { return ranges.every( range => range.columnStart === 0 && range.columnEnd === Infinity ); } /** * Build a special SourceMapLoader instance, based on the one passed in thunkArgs, * which will both: * - preload generated ranges/locations for original locations mentioned * in originalAstScopes * - cache the requests to fetch these genereated ranges/locations */ function batchScopeMappings(originalAstScopes, source, thunkArgs) { const { sourceMapLoader } = thunkArgs; const precalculatedRanges = new Map(); const precalculatedLocations = new Map(); // Explicitly dispatch all of the sourcemap requests synchronously up front so // that they will be batched into a single request for the worker to process. for (const item of originalAstScopes) { for (const name of Object.keys(item.bindings)) { for (const ref of item.bindings[name].refs) { const locs = [ref]; if (ref.type !== "ref") { locs.push(ref.declaration); } for (const loc of locs) { precalculatedRanges.set( buildLocationKey(loc.start), sourceMapLoader.getGeneratedRanges( debuggerToSourceMapLocation(loc.start) ) ); precalculatedLocations.set( buildLocationKey(loc.start), sourceMapLoader.getGeneratedLocation( debuggerToSourceMapLocation(loc.start) ) ); precalculatedLocations.set( buildLocationKey(loc.end), sourceMapLoader.getGeneratedLocation( debuggerToSourceMapLocation(loc.end) ) ); } } } } return { async getGeneratedRanges(pos) { const key = buildLocationKey(pos); if (!precalculatedRanges.has(key)) { log("Bad precalculated mapping"); return sourceMapLoader.getGeneratedRanges( debuggerToSourceMapLocation(pos) ); } return precalculatedRanges.get(key); }, async getGeneratedLocation(pos) { const key = buildLocationKey(pos); if (!precalculatedLocations.has(key)) { log("Bad precalculated mapping"); return sourceMapLoader.getGeneratedLocation( debuggerToSourceMapLocation(pos) ); } return precalculatedLocations.get(key); }, }; } function buildLocationKey(loc) { return `${loc.line}:${locColumn(loc)}`; } function generateClientScope(globalLexicalScope, originalScopes) { // Build a structure similar to the client's linked scope object using // the original AST scopes, but pulling in the generated bindings // linked to each scope. const result = originalScopes .slice(0, -2) .reverse() .reduce((acc, orig, i) => { const { // The 'this' binding data we have is handled independently, so // the binding data is not included here. // eslint-disable-next-line no-unused-vars this: _this, ...variables } = orig.generatedBindings; return { parent: acc, actor: `originalActor${i}`, type: orig.type, scopeKind: orig.scopeKind, bindings: { arguments: [], variables, }, ...(orig.type === "function" ? { function: { displayName: orig.displayName, }, } : null), ...(orig.type === "block" ? { block: { displayName: orig.displayName, }, } : null), }; }, globalLexicalScope); // The rendering logic in getScope 'this' bindings only runs on the current // selected frame scope, so we pluck out the 'this' binding that was mapped, // and put it in a special location const thisScope = originalScopes.find(scope => scope.bindings.this); if (result.bindings && thisScope) { result.bindings.this = thisScope.generatedBindings.this || null; } return result; } function getGlobalFromScope(scopes) { // Pull the root object scope and root lexical scope to reuse them in // our mapped scopes. This assumes that file being processed is // a CommonJS or ES6 module, which might not be ideal. Potentially // should add some logic to try to detect those cases? let globalLexicalScope = null; for (let s = scopes; s.parent; s = s.parent) { globalLexicalScope = s; } if (!globalLexicalScope) { throw new Error("Assertion failure - there should always be a scope"); } return globalLexicalScope; } function generateGlobalFromAst(generatedScopes) { const globalLexicalAst = generatedScopes[generatedScopes.length - 2]; if (!globalLexicalAst) { throw new Error("Assertion failure - there should always be a scope"); } return { actor: "generatedActor1", type: "block", scopeKind: "", bindings: { arguments: [], variables: Object.fromEntries( Object.keys(globalLexicalAst).map(key => [key, getOptimizedOutGrip()]) ), }, parent: { actor: "generatedActor0", object: getOptimizedOutGrip(), scopeKind: "", type: "object", }, }; } function hasValidIdent(range, pos) { return ( range.type === "match" || // For declarations, we allow the range on the identifier to be a // more general "contains" to increase the chances of a match. (pos.type !== "ref" && range.type === "contains") ); } // eslint-disable-next-line complexity async function findGeneratedBinding( source, content, name, originalBinding, originalRanges, generatedAstBindings, thunkArgs ) { // If there are no references to the implicits, then we have no way to // even attempt to map it back to the original since there is no location // data to use. Bail out instead of just showing it as unmapped. if ( originalBinding.type === "implicit" && !originalBinding.refs.some(item => item.type === "ref") ) { return null; } const loadApplicableBindings = async (pos, locationType) => { let applicableBindings = await getApplicableBindingsForOriginalPosition( generatedAstBindings, source, pos, originalBinding.type, locationType, thunkArgs ); if (applicableBindings.length) { hadApplicableBindings = true; } if (locationType === "ref") { // Some tooling creates ranges that map a line as a whole, which is useful // for step-debugging, but can easily lead to finding the wrong binding. // To avoid these false-positives, we entirely ignore bindings matched // by ranges that cover full lines. applicableBindings = applicableBindings.filter( ({ range }) => !(range.start.column === 0 && range.end.column === Infinity) ); } if ( locationType !== "ref" && !(await originalRangeStartsInside(pos, thunkArgs)) ) { applicableBindings = []; } return applicableBindings; }; const { refs } = originalBinding; let hadApplicableBindings = false; let genContent = null; for (const pos of refs) { const applicableBindings = await loadApplicableBindings(pos, pos.type); const range = findMatchingRange(originalRanges, pos); if (range && hasValidIdent(range, pos)) { if (originalBinding.type === "import") { genContent = await findGeneratedImportReference(applicableBindings); } else { genContent = await findGeneratedReference(applicableBindings); } } if ( (pos.type === "class-decl" || pos.type === "class-inner") && content.contentType && content.contentType.match(/\/typescript/) ) { const declRange = findMatchingRange(originalRanges, pos.declaration); if (declRange && declRange.type !== "multiple") { const applicableDeclBindings = await loadApplicableBindings( pos.declaration, pos.type ); // Resolve to first binding in the range const declContent = await findGeneratedReference( applicableDeclBindings ); if (declContent) { // Prefer the declaration mapping in this case because TS sometimes // maps class declaration names to "export.Foo = Foo;" or to // the decorator logic itself genContent = declContent; } } } if ( !genContent && pos.type === "import-decl" && typeof pos.importName === "string" ) { const { importName } = pos; const declRange = findMatchingRange(originalRanges, pos.declaration); // The import declaration should have an original position mapping, // but otherwise we don't really have preferences on the range type // because it can have multiple bindings, but we do want to make sure // that all of the bindings that match the range are part of the same // import declaration. if (declRange?.singleDeclaration) { const applicableDeclBindings = await loadApplicableBindings( pos.declaration, pos.type ); // match the import declaration location genContent = await findGeneratedImportDeclaration( applicableDeclBindings, importName ); } } if (genContent) { break; } } if (genContent && genContent.desc) { return { grip: genContent.desc, expression: genContent.expression, }; } else if (genContent) { // If there is no descriptor for 'this', then this is not the top-level // 'this' that the server gave us a binding for, and we can just ignore it. if (name === "this") { return null; } // If the location is found but the descriptor is not, then it // means that the server scope information didn't match the scope // information from the DevTools parsed scopes. return { grip: { configurable: false, enumerable: true, writable: false, value: { type: "unscoped", unscoped: true, // HACK: Until support for "unscoped" lands in devtools-reps, // this will make these show as (unavailable). missingArguments: true, }, }, expression: null, }; } else if (!hadApplicableBindings && name !== "this") { // If there were no applicable bindings to consider while searching for // matching bindings, then the source map for this file didn't make any // attempt to map the binding, and that most likely means that the // code was entirely emitted from the output code. return { grip: getOptimizedOutGrip(), expression: ` (() => { throw new Error('"' + ${JSON.stringify( name )} + '" has been optimized out.'); })() `, }; } // If no location mapping is found, then the map is bad, or // the map is okay but it original location is inside // of some scope, but the generated location is outside, leading // us to search for bindings that don't technically exist. return { grip: { configurable: false, enumerable: true, writable: false, value: { type: "unmapped", unmapped: true, // HACK: Until support for "unmapped" lands in devtools-reps, // this will make these show as (unavailable). missingArguments: true, }, }, expression: null, }; }