This video is available to students only

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 the Button 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.

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.

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.

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.

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#

After#

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:

  1. Only the opening element was renamed so the closing element is still lowercase button.

  2. The props are no longer correct since className is no longer an option, and many have defaults that aren't required. Recall from implementing the Button component that using the variant and block props instead was done for ease of maintenance and styling consistency.

  3. The Button component isn't imported.

  4. 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.

example of targeting two nodes' parent

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.

example of more complex node targeting

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.

 

This page is a preview of Practical Abstract Syntax Trees

Start a new discussion. All notification go to the author.