Faster Refactoring with Comby

When was the last time you refactored code? Was it in the past week? The past day? Or even code you pushed five minutes ago? In this post I’ll show you how to perform large-scale refactors, regardless of the language, in just a few minutes.

A few weeks ago, I had to change literally hundreds of files due to a change in one of our shared libraries. I admit, it was tempting to only adjust the code I’m working on and leave everything else as it was. Then, I remembered a nifty tool I’d looked into a few months ago, and realised it was the perfect opportunity to try it out.

Code that rewrites your code

Did you know that you can write code that modifies your code? Tools like jscodeshift make refactoring with confidence possible. The way jscodeshift approaches this is by turning your code into an AST (Abstract Syntax Tree). It also exposes a jQuery-like API to query and modify the AST, which gives you superpower when it comes to large-scale refactors.

Facebook uses codemods internally to upgrade their tens of thousands of React components automatically. In the past few years I wrote a couple of codemods, but let me tell you: getting your head around jscodeshift takes time and effort. This is where Comby comes in handy.

Comby the better search and replace

Comby is a no brainer. It’s a very simple but very powerful parser with an elegant pattern matching API. You can think of it as a much better search and replace. It’s language agnostic, easy to learn, very quick to write and has saved me many hours already.

Let’s have a look at a really simple example, before jumping into my real-world problem. In the code below we want to flip the order of the params so we end up with foo(b, a).

foo(a, b)
Original source code

First, we have to define a match template. You can think of match templates as stencils where you define holes (e.g. :[whatever]). These holes are then used to extract substrings from the code - in our stencil analogy you can imagine cutting out the contents of the holes and then moving them around as you see fit. Comby is language agnostic but understands the relation between delimiters, strings, and comments. Our match template consists of two holes arg1 and arg2, but you can name them however you want.

foo(:[arg1], :[arg2])
Comby match template

Now it’s time to define our rewrite template to make the flip of the params happen. In this case, it’s almost the same as our match template, but we swap the params order.

foo(:[arg2], :[arg1])
Comby rewrite template

Running Comby will produce the following output:

foo(b, a)
Output

Comby provides a CLI, but for the purpose of this blog post we focus on the core principles of Comby. I recommend to checkout out the online playground where you can create and test your rewrite patterns.

A real-world example

Let’s have a look at one of my recent refactoring tasks. The API in one of our shared libraries to perform a request changed from a single param string to an object. So we needed to go from this:

request.get('https://api.com/users')
Original source code

to this:

request({ url: 'https://api.com/users', method: 'get' })
Expected output

Let’s start with defining our match template again. As in the previous example, this match template consists of two holes: method and arg1. The leading string request. and the parentheses define our bounding boxes.

request.:[method](:[arg1])
Comby match template

The rewrite template puts the holes into the new format, so we end up with this new function call:

request({ url: :[arg1], method: ':[method]' })
Comby rewrite template

This works, but there is a caveat: It doesn’t inspect the value of :[method], which can also be request.config. These methods shouldn’t be changed. Let’s improve our rewrite.

Comby allows you to define rules, to control which holes get modified, and which get skipped. A rule can be a simple string comparison (like below), or you can compare holes with other holes.

where :[method] != "config"
Comby where clause

Running Comby again produces the desired output, where request.config is preserved, but request.get() and request.post() are updated:

request.config('./base.json')
request({ url: 'https://api.com/users', method: 'get' })
request({ url: 'https://api.com/users', method: 'post' })`}
Output

View this example in the online playground.

I build developer tools on top of GitHub. OctoLinker, a browser extension for GitHub trusted by over 25,000 developers is just the beginning. Be the first to hear about new developer tools I'm working on.
I promise to never sell your email address or spam you, and you can unsubscribe at any time.

Refactor HTML

Now on to another recent challenge. This time I had to refactor HTML files. Buttons with a class of primary and attribute of disabled should get an additional CSS class of shadow. A search and replace doesn’t work here, because the locations of the class attribute and the disabled attribute are not guaranteed.

<button class="primary">Submit</button>
<button disabled class="primary">Save</button>
<button disabled class="btn primary">Send</button>
<button disabled type="submit" class="btn primary" id="save">Apply</button>
Original source code

In order to decouple the match template from a specific order whilst preserving the additional attributes and classes, I defined a few placeholder holes. For simplicity I named them 1 to 5.

<button:[1]disabled:[2]class=":[3]primary:[4]":[5]>
Comby match template

Our rewrite template is again almost identical to the match template.

<button:[1]disabled:[2]class=":[3]primary:[4] shadow":[5]>
Comby rewrite template

That wasn’t too complicated right? Just imagine doing the same with a RegExp.

<button class="primary">Submit</button>
<button disabled class="shadow primary">Save</button>
<button disabled class="shadow btn primary">Send</button>
<button disabled type="submit" class="shadow btn primary" id="save">Apply</button>`}
Output

The attentive reader may have noticed that the order does still matter, as the disabled attribute needs to be in front of the class attribute. However, in my case, there were just a few places where this wasn’t the case so I fixed the order upfront by hand keep it simple. I’m pretty sure there is a Comby way of doing this as well, but we don’t want to overcomplicate things here. Life is already too complicated enough these days.

We just scratched the surface of what we can do with Comby, but hopefully the concept we covered make sense. Check out the documentation to learn more about what Comby has to offer.

Update: The creator of Comby (Rijnard van Tonder), shared another solution using a match and rewrite rule with me. Check out his solution.

Conclusion

As developers, we do our best to produce high quality and robust code. Refactoring is a necessary and ongoing process to sustain a healthy code base. We’re hesitant to touch code outside of our comfort zone for various reasons. But this should not prevent us from doing so. Tools like Comby and jscodeshift can help you to make confident refactors.

Hopefully this post was helpful to make you refactor code with confidence. If this post helped you out in one of your projects, share your story with me I’m @buckstefan on Twitter.

Written by
Stefan Buck is a Staff Software Engineer. In 2013, he created OctoLinker, a browser extension for GitHub trusted and used by over 25,000 developers. Recently he started Pull Request Badge, a GitHub App that brings badges to your Pull Request.