How SWC's Resolver Pass Handles Identifier Scope Resolution and Hygiene Tracking

SWC's resolver pass is a VisitMut transformation that annotates every identifier with a unique SyntaxContext (a chain of Marks) by building lexical scopes, enabling hygienic renaming of variables across different scopes.

The resolver is the foundation of SWC's hygienic macro system and transpilation pipeline. Implemented in crates/swc_ecma_transforms_base/src/resolver/mod.rs, this pass walks the AST to establish lexical scope boundaries and ensure that identically named variables in different scopes receive distinct unique identifiers. Understanding how the resolver handles identifier scope resolution and hygiene tracking is essential for anyone building custom transformations or debugging name collisions in the swc-project/swc ecosystem.

The Core Mechanism: SyntaxContext and Marks

At the heart of SWC's hygiene system is the SyntaxContext, a lightweight identifier that accompanies every Ident node. The resolver pass populates this field by creating a hierarchy of Mark values—unique integers that represent specific lexical scopes.

When the resolver encounters a binding (variable declaration, function parameter, etc.), it applies the current scope's mark to that identifier's SyntaxContext. When it encounters a reference, it searches upward through parent scopes to find the matching declaration, then applies that scope's mark to the reference. The result is that (Atom, SyntaxContext) pairs become globally unique identifiers, allowing downstream passes to distinguish between var a in an inner block versus var a at the top level.

Scope Construction and Hierarchy

The resolver builds a tree of Scope structures to track lexical boundaries. Each scope knows its parent, its kind (function, block, module, etc.), and the symbol bindings it contains.

Scope Structure and Kinds

According to the source in crates/swc_ecma_transforms_base/src/resolver/mod.rs, the Scope struct (lines 61-76) contains:

  • A parent pointer to the enclosing scope
  • ScopeKind indicating whether this is a function, block, or other scope type
  • The Mark associated with this specific scope
  • Hash maps for declared symbols and, in TypeScript mode, declared types

The resolver begins with a root function-scope carrying the user-provided top_level_mark, created in the Resolver::new constructor inside the resolver function (lines 44-46).

Entering Child Scopes with with_child

When the visitor enters a nested scope (like a block statement or function body), it invokes the with_child method (lines 36-55). This method:

  1. Generates a fresh mark using Mark::fresh
  2. Creates a new Scope with the current scope as its parent
  3. Executes the visitor callback within this new scope context
  4. Restores the previous scope upon completion

This ensures that bindings inside a nested block do not leak into the parent scope unless they are subject to JavaScript's hoisting rules.

Recording Bindings and Resolving References

The resolver distinguishes between binding identifiers (declarations) and referencing identifiers (usage). This distinction drives the hygiene annotation process.

Declaration Handling with modify

Every binding identifier passes through the modify function (lines 33-52). This method:

  • Inserts the symbol name into the current scope's declaration hash map
  • Applies the current scope's mark to the identifier's syntax context via id.ctxt = id.ctxt.apply_mark(mark) (lines 53-56)

This application creates the link between the lexical scope and the AST node. When the same name appears in a different scope later, it receives a different mark, ensuring the pair (name, context) remains unique.

Reference Resolution via mark_for_ref

When visiting an identifier in reference position, visit_mut_ident (lines 54-98) delegates to mark_for_ref to determine which scope's mark should apply. The algorithm in mark_for_ref_inner (lines 73-99) performs the following:

  1. Checks type-only scopes first if TypeScript mode is enabled
  2. Walks up the parent scope chain searching for a matching symbol
  3. Returns the found scope's mark, or falls back to unresolved_mark for global references
  4. Special-cases built-ins like undefined, NaN, and Infinity at the top level, mapping them to unresolved_mark

Once the mark is determined, visit_mut_ident applies it to the reference identifier's context (lines 72-78), creating the binding-reference link.

Hoisting with the Hoister Sub-Pass

Before the main resolution walk, the resolver executes a hoisting sub-pass using the Hoister struct (lines 78-85). This pre-scan records function declarations and var-style variable declarations into their respective scopes before processing actual statements.

In visit_mut_module_items (lines 68-87), the hoister visits function declarations first, ensuring that later references can resolve to these hoisted bindings even if they appear textually before the declaration in the source code.

TypeScript Support and Type-Only Scopes

When the typescript parameter is true, the resolver activates additional logic to prevent type identifiers from leaking into runtime code. The resolver tracks declared_types in a separate hash map within each scope.

During resolution, mark_for_ref_inner checks the handle_types flag to determine whether to search type-only scopes. The visit_mut_import_named_specifier method (lines 10-17) distinguishes between value imports and type imports, ensuring that import type statements receive appropriate scope treatment without polluting the runtime namespace.

Practical Implementation Example

To use the resolver in a transformation pipeline, you must provide two distinct marks: unresolved_mark for global references and top_level_mark for module-level bindings.

Here is a complete example demonstrating the resolver pass:

use swc_ecma_parser::{Parser, StringInput, Syntax};
use swc_ecma_transforms_base::resolver;
use swc_ecma_visit::FoldWith;
use swc_common::{Mark, FileName, GLOBAL};

fn main() {
    // Parse some JavaScript
    let src = r#"
        let a = 1;
        {
            let a = 2;
            console.log(a);
        }
        console.log(a);
    "#;
    let cm = GLOBAL.clone();
    let fm = cm.new_source_file(FileName::Custom("example.js".into()), src.into());

    let mut parser = Parser::new(
        Syntax::Es(Default::default()),
        StringInput::from(&*fm),
        None,
    );
    let mut module = parser.parse_module().expect("parse error");

    // Create fresh marks for the pass
    let unresolved_mark = Mark::new();
    let top_level_mark   = Mark::new();

    // Apply the resolver (this also adds hygiene information)
    module = module.fold_with(&mut resolver(unresolved_mark, top_level_mark, false));

    // The identifiers now carry proper SyntaxContext marks.
    println!("{:#?}", module);
}

After this pass, you can inspect the resolved context of any identifier:

use swc_ecma_ast::Ident;

fn print_ident_context(ident: &Ident) {
    // The `ctxt` field is a SyntaxContext that stores the Mark chain.
    println!("{} → context = {:?}", ident.sym, ident.ctxt);
}

For complete hygiene processing, chain the resolver with the hygiene pass:

use swc_ecma_transforms_base::{resolver, hygiene};

let mut program = program
    .fold_with(&mut resolver(unresolved_mark, top_level_mark, false))
    .fold_with(&mut hygiene(unresolved_mark));

The hygiene pass consumes the marks generated by the resolver, removing unresolved_mark from bound identifiers and ensuring that only truly global references retain the unresolved context.

Summary

  • The resolver pass in crates/swc_ecma_transforms_base/src/resolver/mod.rs implements identifier scope resolution and hygiene tracking by annotating every Ident with a unique SyntaxContext.
  • It constructs a hierarchy of Scope objects linked by parent pointers, using Mark::fresh to generate unique scope identifiers.
  • The modify function records bindings by applying the current scope's mark to declaration identifiers.
  • The mark_for_ref algorithm walks the scope chain to resolve references, applying the found scope's mark or falling back to unresolved_mark for globals.
  • A hoisting pre-pass ensures function and var declarations are recorded before references are processed.
  • TypeScript support distinguishes value and type bindings via separate hash maps in each scope.
  • Downstream passes like hygiene rely on these marks to perform safe renaming without name collisions.

Frequently Asked Questions

What is a SyntaxContext in SWC?

A SyntaxContext is a data structure attached to every identifier in SWC's AST that represents the lexical scope an identifier belongs to. It is implemented as a chain of Marks—unique integers generated by Mark::fresh. When the resolver pass runs, it populates this field so that two variables with the same name but different scopes receive different contexts, enabling the compiler to distinguish them during transformation.

How does the resolver handle variable hoisting?

The resolver handles hoisting through a dedicated Hoister struct that runs as a sub-pass before the main resolution walk. As implemented in visit_mut_module_items, this pre-scan visits function declarations and var statements first, recording them in their appropriate scopes. This ensures that references appearing before declarations in the source code can still resolve correctly to their hoisted bindings.

What is the difference between top_level_mark and unresolved_mark?

The top_level_mark identifies bindings introduced at the module or script's top level (such as imports and top-level declarations), while the unresolved_mark flags identifiers that could not be resolved to any local binding—typically global variables like console or user-defined globals. The resolver function accepts both marks as parameters (lines 30-34 of mod.rs) and applies them according to the resolution results, allowing downstream hygiene passes to differentiate between module-local and global names.

Why is the resolver pass necessary before hygiene transformations?

The resolver pass is necessary because it establishes the canonical identity of every identifier by assigning it a unique SyntaxContext. Without this pass, the hygiene transformation cannot determine which occurrences of a variable name refer to the same lexical binding versus different ones. By generating and attaching these marks, the resolver enables the hygiene pass to safely rename variables and eliminate name collisions without changing program semantics.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →