Mutating an AST
Transforming code in place by mutating an AST
The script from the previous module read files from the codebase, traversed the source code within those files to find the relevant code (nodes in an AST), and logged that data. With the data, we were able to define and build a component. Now that we have the component, it's time to go through and replace all the button
elements with our new Button
component.
There are a few ways this could be approached:
Manually: go through all the files, look for
button
elements, and replace them with theButton
component. This is good for complex changes, but only works well in smaller codebases.Find/Replace: use a tool that can find then replace pieces of code based on text or a regular expression. This is reasonable for simple cases, and works well in large codebases.
ASTs: use AST-based tooling. This is ideal for larger codebases and can also handle complex transformations.
All approaches are valid. The scope and complexity of the change can help determine which is the most efficient.
The button transformation needed here is complex. For example, the className
string needs to be broken up into multiple props, but those props are only required if they're not the same as the default. Since the sample codebase only contains a handful of buttons, it would be realistic to manually replace them all. However, we're going to use AST-based tooling to understand how it can be applied to transform code.
Getting started#
Again, let's start by creating a new sibling directory to the Flash app, so initialize a new package.json
, and add the necessary dependencies.
# Create a new directory for the transform script
mkdir transform
# Change into the new transform directory
cd transform
# Initialize a new package.json file
npm init -y
# Add the necessary dependencies
npm i @babel/parser @babel/traverse @babel/types @babel/generator glob ts-node typescript
npm i -D @types/babel__traverse @types/babel__generator @types/glob
Some of these packages were used in the previous module (@babel/parser
, @babel/traverse
, glob
, ts-node
, and typescript
). The new babel packages (@babel/types
and @babel/generator
) will be covered shortly. For more details on the type packages, see the sidenote on typings at the end.
Creating a transform script#
Let's continue by creating a new transform.ts
file that is mostly a copy of the previous audit.ts
script, but with all the logging removed. This logging was only necessary for performing the initial audit.
import { parse } from "@babel/parser";
import traverse from "@babel/traverse";
import * as fs from "fs";
import * as glob from "glob";
const files = glob.sync("../flash-cards/src/components/**/*.js");
files.forEach((file) => {
const contents = fs.readFileSync(file).toString();
const ast = parse(contents, {
sourceType: "module",
plugins: ["jsx"],
});
traverse(ast, {
JSXOpeningElement({ node }) {
if (node.name.type === "JSXIdentifier" && node.name.name === "button") {
const hasButtonClassName = node.attributes.find((attribute) => {
return (
attribute.type === "JSXAttribute" &&
attribute.name.type === "JSXIdentifier" &&
attribute.name.name === "className" &&
attribute.value.type === "StringLiteral" &&
attribute.value.value.split(" ").includes("button")
);
});
if (!hasButtonClassName) return;
// TODO: transform...
}
},
});
});
Running this script won't do or log anything, but it retains the same logic for finding the button
elements. Currently, the node's name (node.name.name
) is button
, but now we need to change it to our Button
component. The first step is to reassign the name.
import { parse } from "@babel/parser";
import traverse from "@babel/traverse";
import generate from "@babel/generator";
import * as fs from "fs";
import * as glob from "glob";
const files = glob.sync("../flash-cards/src/components/**/*.js");
files.forEach((file) => {
const contents = fs.readFileSync(file).toString();
const ast = parse(contents, {
sourceType: "module",
plugins: ["jsx"],
});
traverse(ast, {
JSXOpeningElement({ node }) {
if (node.name.type === "JSXIdentifier" && node.name.name === "button") {
const hasButtonClassName = node.attributes.find((attribute) => {
return (
attribute.type === "JSXAttribute" &&
attribute.name.type === "JSXIdentifier" &&
attribute.name.name === "className" &&
attribute.value.type === "StringLiteral" &&
attribute.value.value.split(" ").includes("button")
);
});
if (!hasButtonClassName) return;
// Change the opening element's
// name from `button` to `Button`.
node.name.name = "Button";
}
},
});
// Convert the mutated AST back into
// a source code string.
const { code } = generate(ast);
// Overwrite the existing file with
// the mutated source code string.
fs.writeFileSync(file, code);
});
After updating the node's name, the tree can be turned back into source code using the @babel/generator
package's generate
method. This accepts an AST as the input, and will return a string of code that is equivalent to the AST. Finally, the script will overwrite the existing file with the new code string.
Running this should now make a transformation in the Flash app codebase.
npx ts-node ./transform.ts
Look at a file that contained a button
element that also had the button
class name. For example, the login component (src/components/LogIn/LogIn.js
). The opening element should now be an uppercase Button
.
Before#
<button type="submit" className="button button--secondary button--block">
<span role="img">🔐</span> Log In
</button>
After#
<Button type="submit" className="button button--secondary button--block">
<span role="img">🔐</span> Log In
</button>
You've created your first codemod! 🎉
The script converted the code into an AST, changed the AST, and then converted the mutated AST back into code.
However, there are still a number of issues:
Only the opening element was renamed so the closing element is still lowercase
button
.The props are no longer correct since
className
is no longer an option, and many have defaults that aren't required. Recall from implementing theButton
component that using thevariant
andblock
props instead was done for ease of maintenance and styling consistency.The
Button
component isn't imported.The formatting changed in some of the files.
Let's tackle each of these problems individually. The first three are requirements. The last is optional since the code is still correct, but formatting is lost.
Renaming closing elements#
Before updating the script, take a moment to paste the "After" code from above into AST Explorer and explore the tree. Can you see what the problem is?
The JSXOpeningElement
node is referenced by the openingElement
property of a JSXElement
. The JSXElement
also has a closingElement
property that references a JSXClosingElement
.
The problem here is only the JSXOpeningElement
node's name
was changed, and not the JSXClosingElement
node's name
. The script needs to be updated to also rename the closing element. However, the script is currently only traversing JSXOpeningElement
nodes. For the code audit, all the data that we cared about was on this node.
It is possible to reference parent nodes from the path object. Rather, instead of traversing up and down the tree, it's usually easiest to target the highest node in the tree, while also trying to be as specific (low) as possible in the tree. A more technical definition would be to target the closest shared ancestor of the two nodes.
For example, this script needs to modify both the opening and closing elements. They are both properties on the JSXElement
node. In other words, they are both child nodes of the JSXElement
node.

In this case, the JSXElement
node is the closest shared ancestor and the parent of both nodes we need to transform. This is not always the case. The closest shared ancestor could be anywhere higher in the tree and often is not the parent node. For example, the closed shared ancestor node could be the grandparent of some nodes and the parent of others.

This is a general guideline that is usually a good starting point. The node to target depends on the exact case. For complex transformations, it may even require targeting multiple nodes.
Now the script can be updated to target the JSXElement
node, and update both the opening and closing element names.
import { parse } from "@babel/parser";
import traverse from "@babel/traverse";
import generate from "@babel/generator";
import * as fs from "fs";
import * as glob from "glob";
const files = glob.sync("../flash-cards/src/components/**/*.js");
files.forEach((file) => {
const contents = fs.readFileSync(file).toString();
const ast = parse(contents, {
sourceType: "module",
plugins: ["jsx"],
});
traverse(ast, {
// Find `JSXElement` nodes instead of `JSXOpeningElement` nodes...
JSXElement({ node }) {
// Grab the opening and closing elements nodes.
// `openingElement` is a `JSXOpeningElement` node,
// which was what we were working with earlier.
const { openingElement, closingElement } = node;
// The filtering for only `button` elements with a
// `button` class name can be updated to rely on
// the `openingElement` instead
// of `node` and reuse the identical logic.
if (
openingElement.name.type === "JSXIdentifier" &&
openingElement.name.name === "button"
) {
const hasButtonClassName = openingElement.attributes.find(
(attribute) => {
return (
attribute.type === "JSXAttribute" &&
attribute.name.type === "JSXIdentifier" &&
attribute.name.name === "className" &&
attribute.value.type === "StringLiteral" &&
attribute.value.value.split(" ").includes("button")
);
}
);
if (!hasButtonClassName) return;
openingElement.name.name = "Button";
// In addition to updating the opening element's
// name, the closing element can also be updated.
if (closingElement.name.type === "JSXIdentifier") {
closingElement.name.name = "Button";
}
}
},
});
const { code } = generate(ast);
fs.writeFileSync(file, code);
});
This page is a preview of Practical Abstract Syntax Trees