Rewriting Ruby with synvert

17 Nov 2016

In a previous post I wrote about some tactics for cleaning up a Rails routes file. A mere two years later I followed it up with a post about program that rewrite Ruby programs. A blistering pace! Now to bring both those topics together comes a nifty Ruby rewriting utility, synvert.

Richard Huang is the author of synvert (which is a portmanteau of "syntax" + "convert"), and he did a great presentation on synvert at Euruko 2015. In it he explains the motivations behind the utility and how it differs from other code analysis tools; short version is that (like Bozhidar Batsov's rubocop) it can actually fix issues as opposed to flagging them. That's not to say that flagging issues isn't valuable; if reek or flog finds problematic code a human needs to figure out what to do but finding it is half the battle. Anyhow, that presentation gives the backstory, explains how it uses the parser gem, dives into the AST representation, etc.

In my post on cleaning up a routes file I noted that through time and chance we'll sometimes end up with a resource declaration with an empty block:

resources :employees, only: [:index, :show] do
end

Naturally you can hop in and delete that block to tidy things up a bit. But, better still, how about a synvert rule to do it? For example:

Synvert::Rewriter.new 'rails', 'routes_cleanup' do
  description <<-EOF
1. It removes empty blocks from resource declarations.
  EOF
  within_file 'config/routes.rb' do
    within_node type: 'block', caller: {receiver: nil, message: 'resources'} do
      if node.children.last.nil?
        replace_with "resources #{node.caller.arguments.map {|a| a.to_source }.join(', ')}"
      end
    end
  end
end

Run that rule and bam! No more unnecessary empty blocks!

For me there were two challenges when writing this rule. The first was simply figuring out how to develop a rule with a reasonable workflow - that is, the process of editing the new rule, running it, oops what about only arguments, edit, rerun, etc. The second was sorting through the synvert DSL.

For the "dev workflow" portion what I did was:

  • Create a feature branch, add gem 'synvert' to the Gemfile, and run bundle
  • Start a new rule file in lib/synvert/routes_cleaner.rb and take a stab at it
  • Add an example of bad code to the project's config/routes.rb
  • Run bundle exec synvert --load lib/synvert/routes_cleaner.rb --run rails/routes_cleanup to execute the rule

I still did a fair bit of git checkout -- config/routes.rb and whatnot as I iterated on the rule, but this let me try things out locally with minimal hassle. It'd be interesting if there was a GUI dedicated to this. You could do a split pane thing; put the rule in the left pane and put the code sample on the right and a button underneath and iterate much faster. Someone should do that; there'd be dozens of dollars in it. Dozens!

The second challenge was learning enough of the synvert DSL to be effective, or at least passable. For this I mostly spent time poking around the synvert-snippets repository and looking at other examples. As I was doing that I mourned the manual search and replaces that I've done around RAILS_ENV and others. But, that train has sailed. I still don't quite have a solid handle on the DSL. For example, there's an arguments directive that can be interpolated into a replace_with statement and it seems like that could be used instead of the somewhat manual to_source stuff I'm doing. The DSL also includes a unless_exist_node directive that should be useful. If I can corrale any usage patterns I'll submit some documentation back to the project.

I encourage you to add synvert to your project and see if any rules would be immediately useful - like ruby/new_hash_syntax, which does what it sounds like. And if you find yourself doing any tedious code sweeps, take a crack at writing a rule. Like they say, why do by hand in an hour what you can automate in a week?