TopicsBabel.jsScope in Babel

Scope

See section /src/manipulating-ast-with-js/README.md#scope describing my experiences reproducing Tan Li Hau lessons in youtube video “Manipulating AST with JavaScript”. Tan starts to talk about scope at 38:40

Checking if a local variable is bound

You can check if a local variable is bound in the current scope using path.scope.hasBinding("n"). This is useful when you want to check if a variable is declared in the current scope or in an outer scope.

FunctionDeclaration(path) {
  if (path.scope.hasBinding("n")) {
    // ...
  }
}

This will walk up the scope tree and check for that particular binding. See the example at /src/scope/non-declared/.

/src/scope/non-declared/nondeclared.mjs
export default function () {
  return {
    visitor: {
      Program: {
        enter(path, state) {
          let varName = state.opts.varName;
          console.log(`Searching for variable "${varName}"`);
          state.nonDeclared = new Map();
          state.Declared = new Map();
        },
        exit(path, state) {
          state.Declared.forEach((value, key) => { console.log(key, value); });
          state.nonDeclared.forEach((value, key) => { console.log(key, value); });
          process.exit(0);
        }
      },
      Identifier(path, state) {
        let varName = state.opts.varName;
        let node = path.node;
        if (node.name !== varName) { return; }
        if (!path.scope.hasBinding(varName)) {
          state.nonDeclared.set(`${varName} at ${node.loc.start.line}`, `is not declared.`)
          return
        }
        state.Declared.set(`${varName} at ${node.loc.start.line}`, `is declared.`);
        return;
      },
    }
  };
}

When we enter the Program node, we initialize two maps: nonDeclared and Declared.

Attaching the Declared and nonDeclared maps to the state object allows us to access it later when visting Identifiers and in the exit method of the Program.

We also initialize the variable varName with the value of the option varName passed to the plugin.

Babel CLI allows you to override plugin options using the --env-name flag combined with a specific environment configuration in your Babel config file.

/src/scope/non-declared/babel.config.js
module.exports = function(api) {
  api.cache(true); // you are instructing Babel to cache the computed configuration and reuse it on subsequent builds. This can significantly improve build performance because Babel doesn't need to re-evaluate the configuration on every build.
 
  const defaultEnv = {
    plugins: [
      ["./nondeclared.mjs", { "varName": "m" }]
    ]
  };
 
  const customEnv = {
    plugins: [
      ["./nondeclared.mjs", { "varName": "n" }]
    ]
  };
 
  return {
    env: {
      development: defaultEnv,
      custom: customEnv
    }
  };
};

Consider the following input:

/src/scope/non-declared/input.js
let n = 4;
let f = m => m + n; // m is declared
m = 9;              // m is not declared
n = n * m;

Execution using --env-name development

➜  non-declared git:(main) npx babel input.js  --env-name development
 
Searching for variable "m"
m at 2 is declared.
m at 3 is not declared.
m at 4 is not declared.

Execution using --env-name custom

➜  non-declared git:(main) ✗ npx babel input.js  --env-name custom     
 
Searching for variable "n"
n at 1 is declared.
n at 2 is declared.
n at 4 is declared

path.scope.hasOwnBinding

You can also check if a scope has its own binding. The following example replaces all references to local variables n with x but not the global variable n.

/src/scope/replace-local-n-by-x/localreplace.mjs
const varName = process.env["VARNAME"] || "z";
const replace = process.env["REPLACE"] || "z";
 
export default function({ types: t }) {
  return {
    visitor: {
      FunctionDeclaration(path) {
        if (path.scope.hasOwnBinding(varName)) {
          path.traverse({
            Identifier(path) {
              if (path.node.name ===  varName) {
                path.node.name = replace;
              }
            }
          });
          return;
        }
      }
    }
  };
}

The execution shows how we pass the variable name n and the replacement name p to the plugin using environment variables.

➜ replace-local-n-by-x git:(main) ✗ VARNAME="n" REPLACE="p" npx babel input.js --plugins=./localreplace.mjs

let n = 5;
function square(p) {
  return p * p;
}
n = square(n) * n;

We can also use path.scope.rename to rename the variable as in the plugin. See
rename.mjs.

const varName = process.env["VARNAME"] || "z"; // Get the variable name from the environment variable VARNAME, default to "z"
const replace = process.env["REPLACE"] || "z"; // Get the replacement name from the environment variable REPLACE, default to "z"
 
export default function({ types: t }) {
  return {
    visitor: {
      FunctionDeclaration(path) {
        path.scope.rename(varName, replace); // Rename the variable in the function scope 
      }
    }
  };
}

The result is the same as before:

➜  replace-local-n-by-x git:(main) ✗ VARNAME="n" REPLACE="z" npx babel input.js --plugins=./rename.mjs
let n = 5;
function square(z) {
  return z * z;
}
n = square(n) * n;

The global variable n is not renamed, only the local variable n in the function square.

Generating a UID

This will generate an identifier that doesn’t collide with any locally defined variables.

FunctionDeclaration(path) {
  path.scope.generateUidIdentifier("uid");
  // Node { type: "Identifier", name: "_uid" }
  path.scope.generateUidIdentifier("uid");
  // Node { type: "Identifier", name: "_uid2" }
}

See examples at:

/src/scope/scopepush/scopeparentpush.cjs
module.exports = function(babel) {
  const { types: t } = babel;
 
  return {
    name: "pushing-to-parent-plugin",
    visitor: {
      FunctionDeclaration(path) {
        const id = path.scope.generateUidIdentifierBasedOnNode(path.node.id);
        let node = t.toExpression(path.node);
        path.remove();
        path.scope.parent.push({ id, init: node, });
      }
    }
  };
};

Given the input:

/src/scope/scopepush/input.js
➜  scopepush git:(main) ✗ cat input.js 
function tutu(x) {
  return newVar;
}

When we run Babel using this plugin we get:

➜  scopepush git:(main) npx babel --plugins=./scopeparentpush.cjs input.js 
var _tutu = function tutu(x) {
  return newVar;
};

Example inside the pattern matching plugin

Here is another example at /src/awesome/tc39-pattern-matching/ in function function transformMatch (babel, referencePath)

module.exports = function transformMatch (babel, referencePath) {
  const $root = referencePath.parentPath.parentPath
  const $$uid = $root.scope.generateUidIdentifier('uid')
  const $matching = getMatching($root)
  const $$matching = $matching.node
  const $patterns = getPatterns($root)
  const $$blocks = transformPatterns(babel, $patterns, $$uid).filter(item => item)
 
  const $$IIFE = babel.template(`
    (v=> {
      const UID = EXP
      BLOCKS
      throw new Error("No matching pattern");
    })()
    `)({
    UID: $$uid,
    EXP: $$matching,
    BLOCKS: $$blocks
  })
  $root.replaceWith($$IIFE)
}

Example: the optional chaining plugin

Example at
/src/nicolo-how-to-talk for the optional chaining plugin optional-chaining-plugin.cjs and optional-chaining-plugin2.cjs

//const generate = require('@babel/generator').default;
module.exports = function myPlugin(babel, options) {
  const { types: t, template } = babel;
  return {
    name: "optional-chaining-plugin",
    manipulateOptions(opts) {
      opts.parserOpts.plugins.push("OptionalChaining")
    },
    visitor: {
      OptionalMemberExpression(path) {
 
        while (!path.node.optional) path = path.get("object");
 
        let { object, property, computed } = path.node;
        let tmp = path.scope.generateUidIdentifierBasedOnNode(property);
        path.scope.push({ id: tmp, kind: 'let', init: t.NullLiteral() });
 
        
        let memberExp = t.memberExpression(tmp, property, computed);
        let undef = path.scope.buildUndefinedNode();
        path.replaceWith(
          template.expression.ast`
            (${tmp} = ${object}) == null? ${undef} :
            ${memberExp}
          `
        )
 
      }
    }
  }
}

Pushing a variable declaration

The path.scope.push method has the following signature:

scope.push({
  id: t.identifier("myVar"),
  init: t.numericLiteral(42),
  kind: "const"
});

The method takes an object with the following properties:

  • id: The identifier node representing the variable name. This is typically created using t.identifier(name).
  • init: (Optional) The initial value of the variable. This should be an AST node representing the value, such as t.numericLiteral(42) for the number 42.
  • kind: (Optional) The kind of variable declaration. This can be "var", "let", or "const". If omitted, it defaults to "var".

See the examples in folder /src/scope/scopepush/.

Here is an example of how you might use path.scope.push within a Babel plugin to add a new constant variable to the current scope:

module.exports = function(babel) {
  const { types: t } = babel;
 
  return {
    name: "add-variable-plugin",
    visitor: {
      FunctionDeclaration(path) {
        path.scope.push({
          id: t.identifier("newVar"),
          init: t.numericLiteral(42),
          kind: "const"
        });
      }
    }
  };
};

In this example:

  • The plugin defines a visitor for FunctionDeclaration nodes.
  • When a function declaration is encountered, the plugin adds a new constant variable newVar with an initial value of 42 to the scope of that function.

Given the input:

➜ babel-learning git:(main) ✗ cat src/scope/scopepush/input.js

function tutu(x) {
  return newVar;
}

When we run Babel using this plugin we get:

➜ babel-learning git:(main) npx babel src/scope/scopepush/input.js --plugins=./src/scope/scopepush/scopepush.cjs

"use strict";
 
function tutu(x) {
  const newVar = 42;
  return newVar;
}

Pushing a variable declaration to a parent scope

This example shows how to

  • The path.scope.generateUidIdentifierBasedOnNode(path.node.id) generates a unique identifier based on the node id
  • The function declaration is converted to an expression t.toExpression(path.node) and
  • remove a function declaration from its current scope path.remove()
  • push it to the parent scope.
  • the expression is pushed to the parent scope.

➜ src git:(main) cat scope/scopepush/scopeparentpush.cjs

module.exports = function(babel) {
  const { types: t } = babel;
 
  return {
    name: "pushing-to-parent-plugin",
    visitor: {
      FunctionDeclaration(path) {
        const id = path.scope.generateUidIdentifierBasedOnNode(path.node.id);
        let node = t.toExpression(path.node);
        path.remove();
        path.scope.parent.push({ id, init: node });
      }
    }
  };
};

When we run Babel using this plugin and pipe the output to diff -y we get:

➜  scopepush git:(main) ✗ npx babel square.js --plugins=./scopeparentpush.cjs | diff -y - square.js
var _square = function square(n) {                              |       function square(n) {
  return n * n;                                                           return n * n;
};                                                              |       }
\ No newline at end of file

Notice that since kind was not specified in path.scope.parent.push({ id, init: node }); the variable is declared as var.

Rename a binding and its references

FunctionDeclaration(path) {
  path.scope.rename("n", "x");
}
- function square(n) {
-   return n * n;
+ function square(x) {
+   return x * x;
  }

Alternatively, you can rename a binding to a generated unique identifier:

FunctionDeclaration(path) {
  path.scope.rename("n");
}
- function square(n) {
-   return n * n;
+ function square(_n) {
+   return _n * _n;
  }

referencePaths of a binding

During a traversing the path.scope.bindings object contains all the bindings in the current scope. The bindings are stored in an object where the keys are the names of the bindings and the values are objects with information about the binding.

The referencePaths property of the binding object is an array of paths that reference the usages of the binding.

This can be confirmed by the code in example src/scope/referencepaths.mjs.

import { parse } from "@babel/parser";
import _traverse from "@babel/traverse";
const traverse = _traverse.default || _traverse;
 
/* Return the path of the first Identifier node in the AST of the code */
function getIdentifierPath(code) {
  const ast = parse(code);
  let nodePath;
  traverse(ast, {
    Identifier: function (path) {
      nodePath = path;
      path.stop(); // Stop traversing
    },
  });
 
  return nodePath;
}
 
function testReferencePaths() { //0123456789012345678901234567890123456
  const path = getIdentifierPath("function square(n) { return n * n}"); 
  console.log(path.node.loc.start); // { line: 1, column: 9, index: 9 }
  const referencePaths = path.context.scope.bindings.n.referencePaths;
  console.log(referencePaths.length); // 2
  console.log(referencePaths[0].node.loc.start) /* { line: 1, column: 28, index: 28, } */
  console.log(referencePaths[1].node.loc.start) /* { line: 1, column: 32, index: 32, } */
}
 
testReferencePaths();

Notice that the array referencePaths does not contain the declaration as a parameter at column 26 of the binding n.

Stack StackOverflow “How do I traverse the scope of a Path in a babel plugin”

See the question at Stack StackOverflow How do I traverse the scope of a Path in a babel plugin

To illustrate this with a contrived example I’d like to transform source code like:

const f = require('foo-bar');
const result = f() * 2;

into something like:

const result = 99 * 2; // as i "know" that calling f will always return 99

I decided to slightly modify the input example to have at least two scopes:

➜  manipulating-ast-with-js git:(main) cat example-scope-input.js 
const f = require('foo-bar');
const result = f() * 2;
let a = f();
function h() {
  let f = 2;
  return f;
}

The key point is that during a traversing the path.scope.bindings object contains all the bindings in the current scope. The bindings are stored in an object where the keys are the names of the bindings and the values are objects with information about the binding. The referencePaths property of the binding object is an array of paths that reference the usages of the binding.

In the following code, we simple traverse the usages of the binding localIdentifier replacing the references to the parent node (the CallExpression) with a NumericLiteral(99):

➜  manipulating-ast-with-js git:(main) ✗ cat example-scope-plugin.js 
module.exports = ({ types: t }) => {
  return {
    visitor: {
      CallExpression(path) {
        const { scope, node } = path;
        if (node.callee.name === 'require'
          && node.arguments.length === 1
          && t.isStringLiteral(node.arguments[0])
          && node.arguments[0].value === 'foo-bar'
        ) {
          const localIdentifier = path.parent.id.name; // f
          scope.bindings[localIdentifier].referencePaths.forEach(p => {
            p.parentPath.replaceWith(t.NumericLiteral(99));
          }); 
        }
      }
    }
  }
};

When we run Babel using this plugin we get:

➜  manipulating-ast-with-js git:(main) ✗ npx babel example-scope-input.js --plugins=./example-scope-plugin.js
const f = require('foo-bar');
const result = 99 * 2;
let a = 99;
function h() {
  let f = 2;
  return f;
}

Transforming a generator declaration on a constant function declaration and hoisting it

See examples in folder /src/scope/generator/-transform/.

We want to write a transformation so that a generator declaration function* xxx(...) { ...} is hoisted to a constant declaration with the same name of the generator function. The constant must be initialized to a call to the function with name buildGeneratorwith argument the bare function. const xxx = buildGenerator(function(...) { ... }). The transformed declaration must be hoisted at the top of the scope where the generator is. For instance, given this input:

➜ src git:(main) ✗ cat scope/generator-transform/input-generator-declaration-local.js

function chuchu() {
  function* add(a,b,c) { return a+b+c; }
  add(2,3,4)
}

It has to be transformed to:

➜ generator-transform git:(main) ✗ npx babel input-generator-declaration-local.js --plugins=./generator-transform-plugin.cjs

function chuchu() {
  const add = buildGenerator(function (a, b, c) {
    return a + b + c;
  });
  add(2, 3, 4);
}
chuchu();

If the generator is in the global scope, it has to work also:

➜ generator-transform git:(main) ✗ cat input-generator-declaration-global.js

function* add(a, b, c) { return a + b + c; }
add(2, 3, 4)
 
chuchu();

Has to be transformed into:

➜ generator-transform git:(main) ✗ npx babel input-generator-declaration-global.js --plugins=./generator-transform-plugin.cjs

const add = buildGenerator(function (a, b, c) {
  return a + b + c;
});
add(2, 3, 4);
chuchu();

Here is the plugin generator-transform-plugin.cjs:

/src/scope/generator-transform/generator-transform-plugin.cjs

module.exports = function (babel) {
  const { types: t } = babel;
 
  return {
    name: "generator-transform", 
    visitor: {
      FunctionDeclaration(path) {
        if (path.get("generator").node) { 
          const functionName = path.get("id.name").node;
          path.node.id = undefined;
          path.node.generator = false; // avoid infinite loop
 
          path.replaceWith(
            t.variableDeclaration("const", [
              t.variableDeclarator(
                t.identifier(functionName),
                t.callExpression(t.identifier("buildGenerator"), [ 
                  t.toExpression(path.node),
                ]),
              ), 
            ]),
          );
 
          // hoist it
          const node = path.node;
          const currentScope = path.scope.path.node;
          path.remove();
          if (currentScope.body.body) {
            currentScope.body.body.unshift(node);
          } else {
            currentScope.body.unshift(node);
          }
        }
      },
    },
  }
}