main
ts 467 lines 14.4 KB
Raw
1 /**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8 import type {NodePath} from '@babel/core';
9 import type * as t from '@babel/types';
10
11 export interface ScopeData {
12 id: number;
13 parent: number | null;
14 kind: string;
15 bindings: Record<string, number>;
16 }
17
18 export interface BindingData {
19 id: number;
20 name: string;
21 kind: string;
22 scope: number;
23 declarationType: string;
24 declarationStart?: number;
25 declarationNodeId?: number;
26 import?: ImportBindingData;
27 }
28
29 export interface ImportBindingData {
30 source: string;
31 kind: string;
32 imported?: string;
33 }
34
35 export interface ScopeInfo {
36 scopes: Array<ScopeData>;
37 bindings: Array<BindingData>;
38 nodeToScope: Record<number, number>;
39 nodeToScopeEnd: Record<number, number>;
40 referenceToBinding: Record<number, number>;
41 refNodeIdToBinding: Record<number, number>;
42 nodeIdToScope: Record<number, number>;
43 programScope: number;
44 }
45
46 /**
47 * Recursively map identifier references inside a pattern (including destructuring)
48 * to a binding. Only maps identifiers that match the binding name.
49 */
50 function mapPatternIdentifiers(
51 path: NodePath,
52 bindingId: number,
53 bindingName: string,
54 mapRef: (start: number, bindingId: number, node: t.Node) => void,
55 ): void {
56 if (path.isIdentifier()) {
57 if (path.node.name === bindingName) {
58 const start = path.node.start;
59 if (start != null) {
60 mapRef(start, bindingId, path.node);
61 }
62 }
63 } else if (path.isArrayPattern()) {
64 for (const element of path.get('elements')) {
65 if (element.node != null) {
66 mapPatternIdentifiers(
67 element as NodePath,
68 bindingId,
69 bindingName,
70 mapRef,
71 );
72 }
73 }
74 } else if (path.isObjectPattern()) {
75 for (const prop of path.get('properties')) {
76 if (prop.isRestElement()) {
77 mapPatternIdentifiers(
78 prop.get('argument'),
79 bindingId,
80 bindingName,
81 mapRef,
82 );
83 } else if (prop.isObjectProperty()) {
84 mapPatternIdentifiers(
85 prop.get('value') as NodePath,
86 bindingId,
87 bindingName,
88 mapRef,
89 );
90 }
91 }
92 } else if (path.isAssignmentPattern()) {
93 mapPatternIdentifiers(
94 path.get('left') as NodePath,
95 bindingId,
96 bindingName,
97 mapRef,
98 );
99 } else if (path.isRestElement()) {
100 mapPatternIdentifiers(path.get('argument'), bindingId, bindingName, mapRef);
101 } else if (path.isMemberExpression()) {
102 // MemberExpression in LVal position (e.g., a.b = ...)
103 const obj = path.get('object');
104 if (obj.isIdentifier() && obj.node.name === bindingName) {
105 const start = obj.node.start;
106 if (start != null) {
107 mapRef(start, bindingId, obj.node);
108 }
109 }
110 }
111 }
112
113 /**
114 * Extract scope information from a Babel Program path.
115 *
116 * The goal here is to serialize only the core scope data structure — scopes,
117 * bindings, and the mappings that link AST positions to them — and leave all
118 * interesting analysis to the Rust side. Babel already computes scope/binding
119 * resolution during parsing, so we extract that work rather than re-implement
120 * it. But any *derived* information (source locations of identifiers, whether
121 * a reference is a JSXIdentifier, which variables are captured across function
122 * boundaries, etc.) is intentionally omitted: the Rust compiler can recover it
123 * by walking the parsed AST it already has.
124 *
125 * Keeping this serialization layer thin makes the JS/Rust boundary easier to
126 * reason about and avoids shipping redundant data across FFI.
127 */
128 export function extractScopeInfo(program: NodePath<t.Program>): ScopeInfo {
129 const scopes: Array<ScopeData> = [];
130 const bindings: Array<BindingData> = [];
131 const nodeToScope: Record<number, number> = {};
132 const nodeToScopeEnd: Record<number, number> = {};
133 const referenceToBinding: Record<number, number> = {};
134 const refNodeIdToBinding: Record<number, number> = {};
135 const nodeIdToScope: Record<number, number> = {};
136
137 let nextNodeId = 1;
138 function getOrAssignNodeId(node: t.Node): number {
139 const n = node as any;
140 if (n._nodeId == null) {
141 n._nodeId = nextNodeId++;
142 }
143 return n._nodeId;
144 }
145
146 function mapRef(start: number, bindingId: number, node: t.Node): void {
147 referenceToBinding[start] = bindingId;
148 const nodeId = getOrAssignNodeId(node);
149 refNodeIdToBinding[nodeId] = bindingId;
150 }
151
152 // Map from Babel scope uid to our scope id
153 const scopeUidToId = new Map<string, number>();
154
155 // Helper to register a scope and its bindings
156 function registerScope(
157 babelScope: {
158 uid: number;
159 parent: {uid: number} | null;
160 bindings: Record<string, any>;
161 },
162 path: NodePath | null,
163 ): void {
164 const uid = String(babelScope.uid);
165 if (scopeUidToId.has(uid)) return;
166
167 const scopeId = scopes.length;
168 scopeUidToId.set(uid, scopeId);
169
170 // Determine parent scope id
171 let parentId: number | null = null;
172 if (babelScope.parent) {
173 const parentUid = String(babelScope.parent.uid);
174 if (scopeUidToId.has(parentUid)) {
175 parentId = scopeUidToId.get(parentUid)!;
176 }
177 }
178
179 // Determine scope kind
180 const kind = path != null ? getScopeKind(path) : 'program';
181
182 // Collect bindings declared in this scope
183 const scopeBindings: Record<string, number> = {};
184 const ownBindings = babelScope.bindings;
185 for (const name of Object.keys(ownBindings)) {
186 const babelBinding = ownBindings[name];
187 if (!babelBinding) continue;
188
189 const bindingId = bindings.length;
190 scopeBindings[name] = bindingId;
191
192 const bindingData: BindingData = {
193 id: bindingId,
194 name,
195 kind: getBindingKind(babelBinding),
196 scope: scopeId,
197 declarationType: babelBinding.path.node.type,
198 declarationStart: babelBinding.identifier.start ?? undefined,
199 declarationNodeId: getOrAssignNodeId(babelBinding.identifier),
200 };
201
202 // Check for import bindings
203 if (babelBinding.kind === 'module') {
204 const importData = getImportData(babelBinding);
205 if (importData) {
206 bindingData.import = importData;
207 }
208 }
209
210 bindings.push(bindingData);
211
212 // Map identifier references to bindings.
213 // Position-0 entries are handled by mapRef's collision detection —
214 // see the comment above pos0BindingId for details.
215 for (const ref of babelBinding.referencePaths) {
216 const start = ref.node.start;
217 if (start != null) {
218 mapRef(start, bindingId, ref.node);
219 }
220 }
221
222 // Map constant violations (LHS of assignments like `a = b`, `a++`, `for (a of ...)`)
223 for (const violation of babelBinding.constantViolations) {
224 if (violation.isAssignmentExpression()) {
225 const left = violation.get('left');
226 mapPatternIdentifiers(
227 left,
228 bindingId,
229 babelBinding.identifier.name,
230 mapRef,
231 );
232 } else if (violation.isUpdateExpression()) {
233 const arg = violation.get('argument');
234 if (arg.isIdentifier()) {
235 const start = arg.node.start;
236 if (start != null) {
237 mapRef(start, bindingId, arg.node);
238 }
239 }
240 } else if (
241 violation.isForOfStatement() ||
242 violation.isForInStatement()
243 ) {
244 const left = violation.get('left');
245 mapPatternIdentifiers(
246 left,
247 bindingId,
248 babelBinding.identifier.name,
249 mapRef,
250 );
251 } else if (violation.isFunctionDeclaration()) {
252 // Function redeclarations: `function x() {} function x() {}`
253 // Map the function name identifier to the binding
254 const funcId = (violation.node as any).id;
255 if (funcId?.start != null) {
256 mapRef(funcId.start, bindingId, funcId);
257 }
258 }
259 }
260
261 // Map the binding identifier itself
262 const bindingStart = babelBinding.identifier.start;
263 if (bindingStart != null) {
264 mapRef(bindingStart, bindingId, babelBinding.identifier);
265 }
266 }
267
268 // Map AST node to scope.
269 // Skip zero-width nodes (e.g., synthetic IIFEs from Hermes match desugar
270 // where start === end === 0) — they would collide with real scopes at position 0.
271 // The Rust compiler handles missing entries via parent-based scope lookup.
272 if (path != null) {
273 const nodeStart = path.node.start;
274 const nodeEnd = path.node.end;
275 if (nodeStart != null && nodeEnd != null && nodeEnd > nodeStart) {
276 nodeToScope[nodeStart] = scopeId;
277 nodeToScopeEnd[nodeStart] = nodeEnd;
278 }
279 const scopeNodeId = getOrAssignNodeId(path.node);
280 nodeIdToScope[scopeNodeId] = scopeId;
281 }
282
283 scopes.push({
284 id: scopeId,
285 parent: parentId,
286 kind,
287 bindings: scopeBindings,
288 });
289 }
290
291 // Register the program scope first (program.traverse doesn't visit the Program node itself)
292 registerScope(program.scope as any, program);
293
294 // Collect all child scopes by traversing the program
295 program.traverse({
296 enter(path) {
297 registerScope(path.scope as any, path);
298 },
299 });
300
301 // Add JSX intrinsic element names to referenceToBinding when they match a
302 // local binding. The TS compiler's gatherCapturedContext explicitly traverses
303 // JSX elements and looks up bindings for their tag names, but Babel does NOT
304 // include JSX intrinsic tag names in binding.referencePaths. We replicate the
305 // TS behavior by adding these references here so the Rust compiler's context
306 // capture correctly detects them.
307 program.traverse({
308 JSXOpeningElement(path) {
309 const name = path.get('name');
310 if (!name.isJSXIdentifier()) {
311 return;
312 }
313 const tagName = name.node.name;
314 const binding = path.scope.getBinding(tagName);
315 if (binding != null) {
316 const bindingScopeUid = String((binding.scope as any).uid);
317 const bindingScopeId = scopeUidToId.get(bindingScopeUid);
318 if (bindingScopeId != null) {
319 const scopeData = scopes.find(s => s.id === bindingScopeId);
320 if (scopeData != null && tagName in scopeData.bindings) {
321 const start = name.node.start;
322 if (start != null) {
323 // mapRef also populates refNodeIdToBinding, the only map the
324 // Rust side consumes (referenceToBinding is deprecated there).
325 mapRef(start, scopeData.bindings[tagName], name.node);
326 }
327 }
328 }
329 }
330 },
331 });
332
333 // Babel's scope crawl does not collect every identifier that
334 // isReferencedIdentifier() classifies as a reference (observed: Flow
335 // FunctionTypeParam names resolving to value bindings are missing from
336 // binding.referencePaths under @babel/core's traversal). The TS compiler's
337 // FindContextIdentifiers and BuildHIR hoisting don't use referencePaths —
338 // they re-traverse Identifier nodes and call isReferencedIdentifier()
339 // directly, so they DO see these references. Replicate that view by mapping
340 // any referenced identifier the crawl missed.
341 program.traverse({
342 Identifier(path: NodePath<t.Identifier>) {
343 if (!path.isReferencedIdentifier()) {
344 return;
345 }
346 const node = path.node as any;
347 const start = node.start;
348 if (start == null) {
349 return;
350 }
351 if (node._nodeId != null && refNodeIdToBinding[node._nodeId] != null) {
352 return;
353 }
354 const binding = path.scope.getBinding(path.node.name);
355 if (binding == null) {
356 return;
357 }
358 const bindingScopeUid = String((binding.scope as any).uid);
359 const bindingScopeId = scopeUidToId.get(bindingScopeUid);
360 if (bindingScopeId == null) {
361 return;
362 }
363 const scopeData = scopes.find(s => s.id === bindingScopeId);
364 if (scopeData != null && path.node.name in scopeData.bindings) {
365 mapRef(start, scopeData.bindings[path.node.name], node);
366 }
367 },
368 });
369
370 // Assign _nodeId to ALL Identifier and JSXIdentifier nodes in the AST,
371 // not just those that resolve to bindings. This ensures global references
372 // (Array, Error, etc.) also have _nodeId set, letting the Rust compiler
373 // distinguish "no binding found via node-ID = global" from "no node-ID at all".
374 program.traverse({
375 Identifier(path: NodePath<t.Identifier>) {
376 getOrAssignNodeId(path.node);
377 },
378 JSXIdentifier(path: NodePath<t.JSXIdentifier>) {
379 getOrAssignNodeId(path.node);
380 },
381 });
382
383 // Program scope should always be id 0
384 const programScopeUid = String((program.scope as any).uid);
385 const programScopeId = scopeUidToId.get(programScopeUid) ?? 0;
386
387 return {
388 scopes,
389 bindings,
390 nodeToScope,
391 nodeToScopeEnd,
392 referenceToBinding,
393 refNodeIdToBinding,
394 nodeIdToScope,
395 programScope: programScopeId,
396 };
397 }
398
399 function getScopeKind(path: NodePath): string {
400 if (path.isProgram()) return 'program';
401 if (path.isFunction()) return 'function';
402 if (
403 path.isForStatement() ||
404 path.isForInStatement() ||
405 path.isForOfStatement()
406 )
407 return 'for';
408 if (path.isClassDeclaration() || path.isClassExpression()) return 'class';
409 if (path.isSwitchStatement()) return 'switch';
410 if (path.isCatchClause()) return 'catch';
411 return 'block';
412 }
413
414 function getBindingKind(binding: {kind: string; path: NodePath}): string {
415 switch (binding.kind) {
416 case 'var':
417 return 'var';
418 case 'let':
419 return 'let';
420 case 'const':
421 return 'const';
422 case 'param':
423 return 'param';
424 case 'module':
425 return 'module';
426 case 'hoisted':
427 return 'hoisted';
428 case 'local':
429 return 'local';
430 default:
431 return 'unknown';
432 }
433 }
434
435 function getImportData(binding: {
436 path: NodePath;
437 }): ImportBindingData | undefined {
438 const decl = binding.path;
439 if (
440 !decl.isImportSpecifier() &&
441 !decl.isImportDefaultSpecifier() &&
442 !decl.isImportNamespaceSpecifier()
443 ) {
444 return undefined;
445 }
446
447 const importDecl = decl.parentPath;
448 if (!importDecl?.isImportDeclaration()) {
449 return undefined;
450 }
451
452 const source = importDecl.node.source.value;
453
454 if (decl.isImportDefaultSpecifier()) {
455 return {source, kind: 'default'};
456 }
457 if (decl.isImportNamespaceSpecifier()) {
458 return {source, kind: 'namespace'};
459 }
460 if (decl.isImportSpecifier()) {
461 const imported = decl.node.imported;
462 const importedName =
463 imported.type === 'Identifier' ? imported.name : imported.value;
464 return {source, kind: 'named', imported: importedName};
465 }
466 return undefined;
467 }