summaryrefslogtreecommitdiffstats
path: root/devtools/client/debugger/src/utils/pause/mapScopes/index.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/debugger/src/utils/pause/mapScopes/index.js')
-rw-r--r--devtools/client/debugger/src/utils/pause/mapScopes/index.js583
1 files changed, 583 insertions, 0 deletions
diff --git a/devtools/client/debugger/src/utils/pause/mapScopes/index.js b/devtools/client/debugger/src/utils/pause/mapScopes/index.js
new file mode 100644
index 0000000000..8736c42218
--- /dev/null
+++ b/devtools/client/debugger/src/utils/pause/mapScopes/index.js
@@ -0,0 +1,583 @@
+/* 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 <http://mozilla.org/MPL/2.0/>. */
+
+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,
+ };
+}