Source code
Revision control
Copy as Markdown
Other Tools
# mapScopes
The files in this directory manage the Devtools logic for taking the original
JS file from the sourcemap and using the content, combined with source
mappings, to provide an enhanced user experience for debugging the code.
In this document, we'll refer to the files as either:
* `original` - The code that the user wrote originally.
* `generated` - The code actually executing in the engine, which may have been
transformed from the `original`, and if a bundler was used, may contain the
final output for several different `original` files.
The enhancements implemented here break down into as two primary improvements:
* Rendering the scopes as the user expects them, using the position in the
original file, rather than the content of the generated file.
* Allowing users to interact with the code in the console as if it were the
original code, rather than the generated file.
## Overall Approach
The core goal of scope mapping is to parse the original and generated files,
and then infer a correlation between the bindings in the original file,
and the bindings in the generated file. This is correlation is done via the
source mappings provided alongside the original code in the sourcemap.
The overall steps break down into:
### 1. Parsing
First the generated and original files are parsed into ASTs, and then the ASTs
are each traversed in order to generate a full picture of the scopes that are
present in the file. This covers information for each binding in each scope,
including all metadata about the declarations themselves, and all references
to each of the bindings. The exact location of each binding reference is the
most important factor because these will be used when working with sourcemaps.
Importantly, this scope tree's structure also mirrors the structure of the
scope data that the engine itself returns when paused.
### 2. Generated Binding -> Grip Correlation
When the engine pauses, we get back a full description of the current scope,
as well as `grip` objects that can be used as proxy-like objects in order to
access the actual values in the engine itself.
With this data, along with the location where the engine is paused, we can
use the AST-based scope information from step 1 to attach a line/column
range to each `grip`. Using those mappings we have enough information to get
the real engine value of any variable based on its position in the generated
code, as long as it is in scope at the paused location in the generated file.
The generated and engine scopes are correlated depth-first, because in some
cases the scope data from the engine will be deeper that the data from the
parsed code, meaning there are additional unknown parent scopes. This can
happen, for instance, if the executing code is running inside of an `eval`
since the parser only knows about the scopes inside of `eval`, and cannot
know what context it is executed inside of.
### 3. Original Binding -> Generated Binding Correlation
Now that we can get the value of a generated location, we need to decide which
generated location correlates with which original location. For each of
the original bindings in each original scope, we iterate through the
individual references to the binding in the file and:
1. Use the sourcemap to convert the original location into a range on the
generated code.
2. Filter the available bindings in the generated file down to those that
overlap with this generated range.
3. Perform heuristics to decide if any of the bindings in the range appear
to be a valid and safe mapping.
These steps allow us to build a datastructure that describes the scope
as it is declared in the original file, while rendering the value using the
`grip` objects, as returned from the engine itself.
There is additional complexity here in the exact details of the heuristics
mentioned above. See the later Heuristics sections diving into these details.
During this phase, one additional task is performed, which is the construction
of expressions that relate back to the original binding. After matching each
binding in a range, we know the name of the binding in the original scope,
and we _also_ know the name of the generated binding, or more generally the
expression to evaluate in order to get the value of the original binding.
These expression values are important for ensuring that the developer console
is able to allow users to interact with the original code. If a user types
a variable into the console, it can be translated into the expression
correlated with that original variable, allowing the code to behave as it
would have, were it actually part of the original file.
### 4. Generate a Scope Tree
The structure generated in step 3 is converted into a structure compatible
with the standard scope data returned from the engine itself in order to allow
for consistent usage of scope data, irrespective of the scope data source.
This stage also re-attaches the global scope data, which is otherwise ignored
during the correlation process.
### 5. Validation
As a simple form of validation, we ensure that a large percentage of bindings
were actually resolved to a `grip` object. This offers some amount of
protection, if the sourcemap itself turned out to be somewhat low-quality.
This happens often with tooling that generates maps that simply map an original
line to a generated line. While line-to-line mappings still enable step
debugging, they do not provide enough information for original-scope mapping.
On the other hand, generally line-to-line mappings are usually generated by
tooling that performs minimal transformations on their own, so it is often
acceptable to fall back to the engine-only generated-file scope data in this
case.
## Range Mapping Heuristics
Since we know lots of information about the original bindings when performing
matches, it is possible to make educated guesses about how their generated
code will have likely been transformed. This allows us to perform more
aggressive matching logic what would normally be too false-positive-heavy
for generic use, when it is deemed safe enough.
### Standard Matching
In the general case, we iterate through the bindings that were found in the
generated range, and consider it a match if the binding overlaps with the
start of the range. One extra case here is that ranges at the start of
a line often point to the first binding in a range and do not overlap the
first binding, so there is special-casing for the first binding in each range.
### ES6 Import Reference Special Case
References to ES6 imports are often transformed into property accesses on objects
in order to emulate the "live-binding" behavior defined by ES6. The standard
logic here would end up always resolving the imported value to be the import
namespace object itself, rather than reading the value of the property.
To support this, specifically for ES6 imports, we walk outward from the matched
binding itself, taking property accesses into account, as long as the
property access itself is also within the mapped range.
While decently effective, there are currently two downsides to this approach:
* The "live" behavior of imports is often implemented using accessor
properties, which as of the time of this writing, cannot be evaluated to
retrieve their real value.
* The "live" behavior of imports is sometimes implemented with function calls,
which also also cannot be evaluated, causing their value to be
unknown.
### ES6 Import Declaration Special Case
If there are no references to an imported value, or matching based on the
reference failed, we fall back to a second case.
ES6 import declarations themselves often map back to the location of the
declaration of the imported module's namespace object. By getting the range for
the import declaration itself, we can infer which generated binding is the
namespace object. Alongside that, we already know the name of the property on
the namespace itself because it is statically knowable by analyzing the
import declaration.
By combining both of those pieces of information, we can access the namespace's
property to get the imported value.
### Typescript Classes
Typescript have several non-ideal ways in which they output source maps, most
of which center around classes that are either exported, or decorated. These
issues are currently tracked in [Typescript issue #22833][1].
To work around this issue, we use a method similar to the import declaration
case above. While the class name itself often maps to unhelpful locations,
the class declaration itself generally maps to the class's transformed binding,
so we make use of the class declaration location instead of the location of
the class's declared name in these cases.