| 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 | } |