How many times have you used the find-and-replace functionality across a directory to make changes to JavaScript source files? If youโre good, youโve gotten fancy and used regular expressions with capturing groups, because itโs worth the effort if your code base is sizable. Regex has limits, though. For non-trivial changes you need a developer who understands the code in context and is also willing to take on the long, tedious, and error-prone process.
This is where โcodemodsโ come in.
Codemods are scripts used to rewrite other scripts. Think of them as a find and replace functionality that can read and write code. You can use them to update source code to fit a teamโs coding conventions, make widespread changes when an API is modified, or even auto-fix existing code when your public package makes a breaking change.
In this article, weโre going to explore a toolkit for codemods called โjscodeshiftโ while creating three codemods of increasing complexity. By the end you will have broad exposure to the important aspects ofย jscodeshiftย and will be ready to start writing your own codemods. We will go through three exercises that cover some basic, but awesome, uses of codemods, and you can view the source code for these exercises on myย github project.
What Is jscodeshift?
The jscodeshift toolkit allows you to pump a bunch of source files through a transform and replace them with what comes out the other end. Inside the transform, you parse the source into an abstract syntax tree (AST), poke around to make your changes, then regenerate the source from the altered AST.
The interface that jscodeshift provides is a wrapper aroundย recast
ย andย ast-types
ย packages.ย ย recast
handles the conversion from source to AST and back whileย ast-types
ย handles the low-level interaction with the AST nodes.
Setup
To get started, install jscodeshift globally from npm.
npm i -g jscodeshift
There are runner options you can use and an opinionated test setup that makes running a suite of tests via Jest (an open source JavaScript testing framework) really easy, but weโre going to bypass that for now in favor of simplicity:
jscodeshift -t some-transform.js input-file.js -d -p
This will runย input-file.js
ย through the transformย some-transform.js
ย and print the results without altering the file.
Before jumping in, though, it is important to understand three main object types that the jscodeshift API deals with: nodes, node-paths, and collections.
Nodes
Nodes are the basic building blocks of the AST, often referred to as โAST nodes.โ These are what you see when exploring your code with AST Explorer. They are simple objects and do not provide any methods.
Node-paths
Node-paths are wrappers around an AST node provided byย ast-types
ย as a way to traverse the abstract syntax tree (AST, remember?). In isolation, nodes do not have any information about their parent or scope, so node-paths take care of that. You can access the wrapped node via theย node
ย property and there are several methods available to change the underlying node. node-paths are often referred to as just โpaths.โ
Collections
Collections are groups of zero or more node-paths that the jscodeshift API returns when you query the AST. They have all sorts of useful methods, some of which we will explore.
Collections contain node-paths, node-paths contain nodes, and nodes are what the AST is made of. Keep that in mind and it will be easy to understand the jscodeshift query API.
It can be tough to keep track of the differences between these objects and their respective API capabilities, so thereโs a nifty tool calledย jscodeshift-helperย that logs the object type and provides other key information.
Exercise 1 – Remove Calls To console
To get our feet wet, letโs start with removing calls to all console methods in our code base. While you can do this with find and replace and a little regex, it starts to get tricky with multiline statements, template literals, and more complex calls, so itโs an ideal example to start with.
First, create two files,ย remove-consoles.js
ย andย remove-consoles.input.js
:
//remove-consoles.js
export default (fileInfo, api) => {
};
//remove-consoles.input.js
export const sum = (a, b) => {
console.log('calling sum with', arguments);
return a + b;
};
export const multiply = (a, b) => {
console.warn('calling multiply with',
arguments);
return a * b;
};
export const divide = (a, b) => {
console.error(`calling divide with ${ arguments }`);
return a / b;
};
export const average = (a, b) => {
console.log('calling average with ' + arguments);
return divide(sum(a, b), 2);
};
Hereโs the command weโll be using in the terminal to push it through jscodeshift:
jscodeshift -t remove-consoles.js remove-consoles.input.js -d -p
If everythingโs set up correctly, when you run it you should see something like this.
Processing 1 files...
Spawning 1 workers...
Running in dry mode, no files will be written!
Sending 1 files to free worker...
All done.
Results:
0 errors
0 unmodified
1 skipped
0 ok
Time elapsed: 0.514seconds
OK, that was a bit anticlimactic since our transform doesnโt actually do anything yet, but at least we know itโs all working. If it doesnโt run at all, make sure you installed jscodeshift globally. If the command to run the transform is incorrect, youโll either see an โERROR Transform file โฆ does not existโ message or โTypeError: path must be a string or Bufferโ if the input file cannot be found. If youโve fat-fingered something, it should be easy to spot with the very descriptive transformation errors.
Our end goal though, after a successful transform, is to see this source:
export const sum = (a, b) => {
return a + b;
};
export const multiply = (a, b) => {
return a * b;
};
export const divide = (a, b) => {
return a / b;
};
export const average = (a, b) => {
return divide(sum(a, b), 2);
};
To get there, we need to convert the source into an AST, find the consoles, remove them, and then convert the altered AST back into source. The first and last steps are easy, itโs just:
remove-consoles.js
export default (fileInfo, api) => {
const j = api.jscodeshift;
const root = j(fileInfo.source);
return root.toSource();
};
But how do we find the consoles and remove them? Unless you have some exceptional knowledge of the Mozilla Parser API, youโll probably need a tool to help understand what the AST looks like. For that you can use theย AST Explorer. Paste the contents ofย remove-consoles.input.js
ย into it and youโll see the AST. There is a lot of data even in the simplest code, so it helps to hide location data and methods. You can toggle the visibility of properties in AST Explorer with the checkboxes above the tree.
We can see that calls to console methods are referred to asย CallExpressions
, so how do we find them in our transform? We use jscodeshiftโs queries, remembering our earlier discussion on the differences between Collections, node-paths and nodes themselves:
//remove-consoles.js
export default (fileInfo, api) => {
const j = api.jscodeshift;
const root = j(fileInfo.source);
return root.toSource();
};
The lineย const root = j(fileInfo.source);
ย returns a collection of one node-path, which wraps the root AST node. We can use the collectionโsย find
ย method to search for descendant nodes of a certain type, like so:
const callExpressions = root.find(j.CallExpression);
This returns another collection of node-paths containing just the nodes that are CallExpressions. At first blush, this seems like what we want, but it is too broad. We might end up running hundreds or thousands of files through our transforms, so we have to be precise to have any confidence that it will work as intended. The naiveย find
ย above would not just find the console CallExpressions, it would find every CallExpression in the source, including
require('foo')
bar()
setTimeout(() => {}, 0)
To force greater specificity, we provide a second argument toย .find
: An object of additional parameters, each node needs to be included in the results. We can look at the AST Explorer to see that our console.* calls have the form of:
{
"type": "CallExpression",
"callee": {
"type": "MemberExpression",
"object": {
"type": "Identifier",
"name": "console"
}
}
}
With that knowledge, we know to refine our query with a specifier that will return only the type of CallExpressions weโre interested in:
const callExpressions = root.find(j.CallExpression, {
callee: {
type: 'MemberExpression',
object: { type: 'Identifier', name: 'console' },
},
});
Now that weโve got an accurate collection of the call sites, letโs remove them from the AST. Conveniently, the collection object type has aย remove
ย method that will do just that. Ourย remove-consoles.js
ย file will now look like this:
//remove-consoles.js
export default (fileInfo, api) => {
const j = api.jscodeshift;
const root = j(fileInfo.source)
const callExpressions = root.find(j.CallExpression, {
callee: {
type: 'MemberExpression',
object: { type: 'Identifier', name: 'console' },
},
}
);
callExpressions.remove();
return root.toSource();
};
Now, if we run our transform from the command line usingย jscodeshift -t remove-consoles.js remove-consoles.input.js -d -p
, we should see:
Processing 1 files...
Spawning 1 workers...
Running in dry mode, no files will be written!
Sending 1 files to free worker...
export const sum = (a, b) => {
return a + b;
};
export const multiply = (a, b) => {
return a * b;
};
export const divide = (a, b) => {
return a / b;
};
export const average = (a, b) => {
return divide(sum(a, b), 2);
};
All done.
Results:
0 errors
0 unmodified
0 skipped
1 ok
Time elapsed: 0.604seconds
It looks good. Now that our transform alters the underlying AST, usingย .toSource()
ย generates a different string from the original. The -p option from our command displays the result, and a tally of dispositions for each file processed is shown at the bottom. Removing the -d option from our command, would replace the content of remove-consoles.input.js with the output from the transform.
Our first exercise is completeโฆ almost. The code is bizarre looking and probably very offensive to any functional purists out there, and so to make transform code flow better, jscodeshift has made most things chainable. This allows us to rewrite our transform like so:
// remove-consoles.js
export default (fileInfo, api) => {
const j = api.jscodeshift;
return j(fileInfo.source)
.find(j.CallExpression, {
callee: {
type: 'MemberExpression',
object: { type: 'Identifier', name: 'console' },
},
}
)
.remove()
.toSource();
};
Much better. To recap exercise 1, we wrapped the source, queried for a collection of node-paths, change the AST, and then regenerated that source. Weโve gotten our feet wet with a pretty simple example and touched on the most important aspects. Now, letโs do something more interesting.
Exercise 2 – Replacing Imported Method Calls
For this scenario, weโve got a โgeometryโ module with a method named โcircleAreaโ that weโve deprecated in favor of โgetCircleArea.โ We could easily find and replace these withย /geometry\.circleArea/g
, but what if the user has imported the module and assigned it a different name? For example:
import g from 'geometry';
const area = g.circleArea(radius);
How would we know to replaceย g.circleArea
ย instead ofย geometry.circleArea
? We certainly cannot assume that allย circleArea
ย calls are the ones weโre looking for, we need some context. This is where codemods start showing their value. Letโs start by making two files,ย deprecated.js
ย andย deprecated.input.js
.
//deprecated.js
export default (fileInfo, api) => {
const j = api.jscodeshift;
const root = j(fileInfo.source);
return root.toSource();
};
deprecated.input.js
import g from 'geometry';
import otherModule from 'otherModule';
const radius = 20;
const area = g.circleArea(radius);
console.log(area === Math.pow(g.getPi(), 2) * radius);
console.log(area === otherModule.circleArea(radius));
Now run this command to run the codemod.
jscodeshift -t ./deprecated.js ./deprecated.input.js -d -p
You should see output indicating the transform ran, but has not changed anything yet.
Processing 1 files...
Spawning 1 workers...
Running in dry mode, no files will be written!
Sending 1 files to free worker...
All done.
Results:
0 errors
1 unmodified
0 skipped
0 ok
Time elapsed: 0.892seconds
We need to know what ourย geometry
ย module has been imported as. Letโs look at the AST Explorer and figure out what weโre looking for. Our import takes this form.
{
"type": "ImportDeclaration",
"specifiers": [
{
"type": "ImportDefaultSpecifier",
"local": {
"type": "Identifier",
"name": "g"
}
}
],
"source": {
"type": "Literal",
"value": "geometry"
}
}
We can specify an object type to find a collection of nodes like this:
const importDeclaration = root.find(j.ImportDeclaration, {
source: {
type: 'Literal',
value: 'geometry',
},
});
This gets us the ImportDeclaration used to import โgeometryโ. From there, dig down to find the local name used to hold the imported module. Since this is the first time weโve done it, letโs point out an important and confusing point when first starting.
Note: Itโs important to know thatย root.find()
ย returns a collection of node-paths. From there, theย .get(n)
method returns the node-path at indexย n
ย in that collection, and to get the actual node, we useย .node
. The node is basically what we see in AST Explorer. Remember, the node-path is mostly information about the scope and relationships of the node, not the node itself.
// find the Identifiers
const identifierCollection = importDeclaration.find(j.Identifier);
// get the first NodePath from the Collection
const nodePath = identifierCollection.get(0);
// get the Node in the NodePath and grab its "name"
const localName = nodePath.node.name;
This allows us to figure out dynamically what ourย geometry
ย module has been imported as. Next, we find the places it is being used and change them. By looking at AST Explorer, we can see that we need to find MemberExpressions that look like this:
{
"type": "MemberExpression",
"object": {
"name": "geometry"
},
"property": {
"name": "circleArea"
}
}
Remember, though, that our module may have been imported with a different name, so we have to account for that by making our query look like this instead:
j.MemberExpression, {
object: {
name: localName,
},
property: {
name: "circleArea",
},
})
Now that we have a query, we can get a collection of all the call sites to our old method and then use the collectionโsย replaceWith()
ย method to swap them out. Theย replaceWith()
ย method iterates through the collection, passing each node-path to a callback function. The AST Node is then replaced with whatever Node you return from the callback.
Once weโre done with the replacement, we generate the source as usual. Hereโs our finished transform:
//deprecated.js
export default (fileInfo, api) => {
const j = api.jscodeshift;
const root = j(fileInfo.source);
// find declaration for "geometry" import
const importDeclaration = root.find(j.ImportDeclaration, {
source: {
type: 'Literal',
value: 'geometry',
},
});
// get the local name for the imported module
const localName =
// find the Identifiers
importDeclaration.find(j.Identifier)
// get the first NodePath from the Collection
.get(0)
// get the Node in the NodePath and grab its "name"
.node.name;
return root.find(j.MemberExpression, {
object: {
name: localName,
},
property: {
name: 'circleArea',
},
})
.replaceWith(nodePath => {
// get the underlying Node
const { node } = nodePath;
// change to our new prop
node.property.name = 'getCircleArea';
// replaceWith should return a Node, not a NodePath
return node;
})
.toSource();
};
When we run the source through the transform, we see that the call to the deprecated method in theย geometry
ย module was changed, but the rest was left unaltered, like so:
import g from 'geometry';
import otherModule from 'otherModule';
const radius = 20;
const area = g.getCircleArea(radius);
console.log(area === Math.pow(g.getPi(), 2) * radius);
console.log(area === otherModule.circleArea(radius));
Exercise 3 – Changing A Method Signature
In the previous exercises we covered querying collections for specific types of nodes, removing nodes, and altering nodes, but what about creating altogether new nodes? Thatโs what weโll tackle in this exercise.
In this scenario, weโve got a method signature thatโs gotten out of control with individual arguments as the software has grown, and so it has been decided it would be better to accept an object containing those arguments instead.
Instead ofย car.factory('white', 'Kia', 'Sorento', 2010, 50000, null, true);
weโd like to see
const suv = car.factory({
color: 'white',
make: 'Kia',
model: 'Sorento',
year: 2010,
miles: 50000,
bedliner: null,
alarm: true,
});
Letโs start by making the transform and an input file to test with:
//signature-change.js
export default (fileInfo, api) => {
const j = api.jscodeshift;
const root = j(fileInfo.source);
return root.toSource();
};
//signature-change.input.js
import car from 'car';
const suv = car.factory('white', 'Kia', 'Sorento', 2010, 50000, null, true);
const truck = car.factory('silver', 'Toyota', 'Tacoma', 2006, 100000, true, true);
Our command to run the transform will beย jscodeshift -t signature-change.js signature-change.input.js -d -p
ย and the steps we need to perform this transformation are:
- Find the local name for the imported module
- Find all call sites to the .factory method
- Read all arguments being passed in
- Replace that call with a single argument which contains an object with the original values
Using the AST Explorer and the process we used in the previous exercises, the first two steps are easy:
//signature-change.js
export default (fileInfo, api) => {
const j = api.jscodeshift;
const root = j(fileInfo.source);
// find declaration for "car" import
const importDeclaration = root.find(j.ImportDeclaration, {
source: {
type: 'Literal',
value: 'car',
},
});
// get the local name for the imported module
const localName =
importDeclaration.find(j.Identifier)
.get(0)
.node.name;
// find where `.factory` is being called
return root.find(j.CallExpression, {
callee: {
type: 'MemberExpression',
object: {
name: localName,
},
property: {
name: 'factory',
},
}
})
.toSource();
};
For reading all the arguments currently being passed in, we use thereplaceWith()
ย method on our collection of CallExpressions to swap each of the nodes. The new nodes will replace node.arguments with a new single argument, an object.
Letโs give it a try with a simple object to make sure we know how this works before we use the proper values:
.replaceWith(nodePath => {
const { node } = nodePath;
node.arguments = [{ foo: 'bar' }];
return node;
})
When we run this (jscodeshift -t signature-change.js signature-change.input.js -d -p
), the transform will blow up with:
ERR signature-change.input.js Transformation error
Error: {foo: bar} does not match type Printable
Turns out we canโt just jam plain objects into our AST nodes. Instead, we need to use builders to create proper nodes.
Node Builders
Builders allow us to create new nodes properly; they are provided byย ast-types
ย and surfaced through jscodeshift. They rigidly check that the different types of nodes are created correctly, which can be frustrating when youโre hacking away on a roll, but ultimately, this is a good thing. To understand how to use builders, there are two things you should keep in mind:
All of the available AST node types are defined in theย def
folder of theย ast-types github project, mostly in core.js There are builders for all the AST node types, but they useย camel-casedย version of the node type, notย pascal-case. (This isnโt explicitly stated, but you can see this is the case in theย ast-types source
If we use AST Explorer with an example of what we want the result to be, we can piece this together pretty easily. In our case, we want the new single argument to be an ObjectExpression with a bunch of properties. Looking at the type definitions mentioned above, we can see what this entails:
def("ObjectExpression")
.bases("Expression")
.build("properties")
.field("properties", [def("Property")]);
def("Property")
.bases("Node")
.build("kind", "key", "value")
.field("kind", or("init", "get", "set"))
.field("key", or(def("Literal"), def("Identifier")))
.field("value", def("Expression"));
So, the code to build an AST node for { foo: โbarโ } would look like:
j.objectExpression([
j.property(
'init',
j.identifier('foo'),
j.literal('bar')
)
]);
Take that code and plug it into our transform like so:
.replaceWith(nodePath => {
const { node } = nodePath;
const object = j.objectExpression([
j.property(
'init',
j.identifier('foo'),
j.literal('bar')
)
]);
node.arguments = [object];
return node;
})
Running this gets us the result:
import car from 'car';
const suv = car.factory({
foo: "bar"
});
const truck = car.factory({
foo: "bar"
});
Now that we know how to create a proper AST node, itโs easy to loop through the old arguments and generate a new object to use, instead. Hereโs what ourย signature-change.js
ย file looks like now:
//signature-change.js
export default (fileInfo, api) => {
const j = api.jscodeshift;
const root = j(fileInfo.source);
// find declaration for "car" import
const importDeclaration = root.find(j.ImportDeclaration, {
source: {
type: 'Literal',
value: 'car',
},
});
// get the local name for the imported module
const localName =
importDeclaration.find(j.Identifier)
.get(0)
.node.name;
// current order of arguments
const argKeys = [
'color',
'make',
'model',
'year',
'miles',
'bedliner',
'alarm',
];
// find where `.factory` is being called
return root.find(j.CallExpression, {
callee: {
type: 'MemberExpression',
object: {
name: localName,
},
property: {
name: 'factory',
},
}
})
.replaceWith(nodePath => {
const { node } = nodePath;
// use a builder to create the ObjectExpression
const argumentsAsObject = j.objectExpression(
// map the arguments to an Array of Property Nodes
node.arguments.map((arg, i) =>
j.property(
'init',
j.identifier(argKeys[i]),
j.literal(arg.value)
)
)
);
// replace the arguments with our new ObjectExpression
node.arguments = [argumentsAsObject];
return node;
})
// specify print options for recast
.toSource({ quote: 'single', trailingComma: true });
};
Run the transform (jscodeshift -t signature-change.js signature-change.input.js -d -p
) and weโll see the signatures have been updated as expected:
import car from 'car';
const suv = car.factory({
color: 'white',
make: 'Kia',
model: 'Sorento',
year: 2010,
miles: 50000,
bedliner: null,
alarm: true,
});
const truck = car.factory({
color: 'silver',
make: 'Toyota',
model: 'Tacoma',
year: 2006,
miles: 100000,
bedliner: true,
alarm: true,
});
Codemods With jscodeshift Recap
It took a little time and effort to get to this point, but the benefits are huge when faced with mass refactoring. Distributing groups of files to different processes and running them in parallel is something jscodeshift excels at, allowing you to run complex transformations across a huge codebase in seconds. As you become more proficient with codemods, youโll start repurposing existing scripts (such as theย react-codemod github repositoryย or writing your own for all sorts of tasks, and that will make you, your team, and your package-users more efficient.
This post originally appeared in Toptal Engineering Blog, written by Jeremy Greer
Discover more from TechBooky
Subscribe to get the latest posts sent to your email.