Synvert

Improve and refactor ruby code easier


http://xinminlabs.github.io/synvert-rubyconf-tw-2014

Richard Huang

Xinmin Labs

About Me

  • Founder at Xinmin Labs
  • Full Stacked Developer
  • Open Source Enthusiast


@flyerhzm

What's synvert

synvert = syntax + convert

Why synvert

Ruby world involves fast

FactoryGirl prefers short syntax

post = FactoryGirl.create(:post)

to

post = create(:post)

RSpec will deprecate should syntax in favor of expect

1.should == 1

to

expect(1).to eq 1

Rails 4 has deprecated dynamic finders

user = User.find_last_by_email_and_active('flyerhzm@xinminlabs.com', true)

to

user = User.where(email: 'flyerhzm@xinminlabs.com', active: true).last

Changes happen frequently in ruby community

We have to

Convert syntax for project 1

......

Convert syntax for project N

Other teams also have to

Convert syntax for their projects

DRY

Some tools can analyze and find out old deprecated code

rails_best_practices

But you still have to change code manually

Synvert can do more

As automaic as possible

How synvert works

Synvert + Snippets

Background

AST

Parser

Ruby code

post = FactoryGirl.create(:post)

AST

(lvasgn :post
  (send
    (const nil :FactoryGirl) :create
    (sym :post)))
  • Synvert can find specified files
  • Synvert can convert ruby source code to ast nodes
  • Synvert can find out specified ast nodes
  • Synvert can check if specified ast node exist or not
  • Synvert can add / replace / remove specifed code

Snippets tell synvert what to do

Snippet

Synvert::Rewriter.new 'synvert_name' do        # Rewriter
  if_gem 'gem_name', {gte: '1.0.0'}            # GemSpec

  within_files '**/*.rb' do                    # Instance
    within_node rules_to_find_node do          # Scope
      unless_exist_node rules_to_check_node do # Condition
        insert "what code to insert"           # Action
      end
    end
  end
end

DSL: http://xinminlabs.github.io/synvert/dsl/

Rules: http://xinminlabs.github.io/synvert/rules/

Example

use FactoryGirl short syntax

Adds

include FactoryGirl::Syntax::Methods
to
class Test::Unit::TestCase
in file
test/test_helper.rb

Synvert::Rewriter.new 'factory_girl_short_syntax' do
  within_file 'test/test_helper.rb' do
    with_node type: 'class', name: 'Test::Unit::TestCase' do
      insert 'include FactoryGirl::Syntax::Methods'
    end
  end
end

From factory_girl 2.0.0, so let's check the gem version.

Synvert::Rewriter.new 'factory_girl_short_syntax' do
  # check if factory_girl gem greater than or equal to 2.0.0 in Gemfile.lock
  if_gem 'factory_girl', {gte: '2.0.0'}

  within_file 'test/test_helper.rb' do
    with_node type: 'class', name: 'Test::Unit::TestCase' do
      insert 'include FactoryGirl::Syntax::Methods'
    end
  end
end

It works, but it inserts code every time even when

include FactoryGirl::Syntax::Methods
already exist in the class,
so let's check if code doesn't exist, then insert.

Synvert::Rewriter.new 'factory_girl_short_syntax' do
  if_gem 'factory_girl', {gte: '2.0.0'}

  within_file 'test/test_helper.rb' do
    with_node type: 'class', name: 'Test::Unit::TestCase' do
      # unless include FactoryGirl::Syntax::Methods exists
      unless_exist_node type: 'send', message: 'include',
                        arguments: ['FactoryGirl::Syntax::Methods'] do
        insert 'include FactoryGirl::Syntax::Methods'
      end
    end
  end
end

It should also work in minitest and activesupport testcase

Synvert::Rewriter.new 'factory_girl_short_syntax' do
  if_gem 'factory_girl', {gte: '2.0.0'}

  within_file 'test/test_helper.rb' do
    %w(Test::Unit::TestCase ActiveSupport::TestCase
        MiniTest::Unit::TestCase MiniTest::Spec
        MiniTest::Rails::ActiveSupport::TestCase).each do |class_name|
      with_node type: 'class', name: class_name do
        unless_exist_node type: 'send', message: 'include',
                          arguments: ['FactoryGirl::Syntax::Methods'] do
          insert 'include FactoryGirl::Syntax::Methods'
        end
      end
    end
  end
end

For RSpec, it does a bit different

RSpec.configure do |config|
  config.incldue FactoryGirl::Syntax::Methods
end
Synvert::Rewriter.new 'factory_girl_short_syntax' do
  if_gem 'factory_girl', {gte: '2.0.0'}

  within_file 'spec/spec_helper.rb' do
    # match code RSpec.configure do |config|; ... end
    within_node type: 'block', caller: {receiver: 'RSpec',
                                        message: 'configure'} do
      unless_exist_node type: 'send', message: 'include',
                        arguments: ['FactoryGirl::Syntax::Methods'] do
        # {{ }} is executed on current node, so we can add or
        # replace with some old code,
        # arguments are [config], arguments.first is config,
        insert "{{arguments.first}}.include FactoryGirl::Syntax::Methods"
      end
    end
  end
end

Finally, apply it for cucumber as well.

World(FactoryGirl::Syntax::Methods)
Synvert::Rewriter.new 'factory_girl_short_syntax' do
  if_gem 'factory_girl', {gte: '2.0.0'}

  within_file 'features/support/env.rb' do
    # current node is the root node of env.rb file
    # we don't need with_node / within_node here
    unless_exist_node type: 'send', message: 'World',
                      arguments: ['FactoryGirl::Syntax::Methods'] do
      insert "World(FactoryGirl::Syntax::Methods)"
    end
  end
end

Now we can replace

FactoryGirl.create(:post)

with

create(:post)
Synvert::Rewriter.new 'factory_girl_short_syntax' do
  if_gem 'factory_girl', {gte: '2.0.0'}

  %w(test/**/*.rb spec/**/*.rb features/**/*.rb).each do |file_pattern|
    # find all files in test/, spec/ and features/
    within_files file_pattern do
      with_node type: 'send', receiver: 'FactoryGirl', message: 'create' do
        # for FactoryGirl.create(:post, title: 'post'),
        # arguments are :post, title: 'post'
        replace_with "create({{arguments}})"
      end
    end
  end
end

There are other short syntaxes,
e.g. build, create_list, build_list, etc.

Synvert::Rewriter.new 'factory_girl_short_syntax' do
  if_gem 'factory_girl', {gte: '2.0.0'}

  %w(test/**/*.rb spec/**/*.rb features/**/*.rb).each do |file_pattern|
    within_files file_pattern do
      %w(create build attributes_for build_stubbed create_list build_list
          create_pair build_pair).each do |message|
        with_node type: 'send', receiver: 'FactoryGirl', message: message do
          replace_with "#{message}({{arguments}})"
        end
      end
    end
  end
end

Finally write description for the rewriter

Synvert::Rewriter.new 'factory_girl_short_syntax' do
  description <<-EOF
1. it adds FactoryGirl::Syntax::methods module to RSpec, Test::Unit,
   Cucumber, Spainach, MiniTest, MiniTest::Spec, minitest-rails.

2. it converts to short syntax.

    FactoryGirl.create(...) => create(...)
    FactoryGirl.build(...) => build(...)
    ......
  EOF

  if_gem 'factory_girl', {gte: '2.0.0'}

  ......
end

Complex Example

Convert rails dynamic finders

From rails 4, dynamic finders are deprecated

User.find_last_by_email_and_active('flyerhzm@xinminlabs.com', true)

to

User.where(email: 'flyerhzm@xinminlabs.com', active: true).last
Synvert::Rewriter.new "convert_dynamic_finders" do
  within_files '**/*.rb' do
    with_node type: 'send', message: /^find_last_by_(.*)/ do
      # node is current matching ast node
      # you can add any ruby code in the block
      # here we convert dynamic finder message to hash params, e.g.
      # email_and_active('email', active) to email: 'email', active: active
      #
      # node.source(self) is used to get original ruby source code
      # {{receiver}} gets the receiver of current ast node
      fields = node.message.to_s["find_last_by_".length..-1].split("_and_")
      hash_params = fields.length.times.map { |i|
        fields[i] + ": " + node.arguments[i].source(self)
      }.join(", ")
      replace_with "{{receiver}}.where(#{hash_params}).last"
    end
  end
end

Let's use helper_method for other dynamic finders.

Synvert::Rewriter.new "convert_dynamic_finders" do
  helper_method 'dynamic_finder_to_hash' do |prefix|
    fields = node.message.to_s[prefix.length..-1].split("_and_")
    fields.length.times.map { |i|
      fields[i] + ": " + node.arguments[i].source(self)
    }.join(", ")
  end
  within_files '**/*.rb' do
    with_node type: 'send', message: /^find_all_by_(.*)/ do
      hash_params = dynamic_finder_to_hash("find_all_by_")
      replace_with "{{receiver}}.where(#{hash_params})"
    end
    with_node type: 'send', message: /^find_last_by_(.*)/ do
      hash_params = dynamic_finder_to_hash("find_last_by_")
      replace_with "{{receiver}}.where(#{hash_params}).last"
    end
    ......
  end
end

Available Snippets

  • factory_girl_short_syntax
  • upgrade_rails_3_0_to_3_1
  • upgrade_rails_3_1_to_3_2
  • upgrade_rails_3_2_to_4_0
  • rspec_new_syntax
  • ruby_new_hash_syntax
  • ruby_new_lambda_syntax
  • ......

Still in alpha stage

Any issue and pull request are welcome

References

Q & A

Thank you