Synvert
Table of Contents
Install CLI
First, instal synvert gem locally:
$ gem install synvert
Second, sync the official snippets: (this is optional, synvert is completely working with remote snippets)
$ synvert-ruby --sync
Use CLI
You can rewrite your code by running a snippet
$ synvert-ruby --run rspec/use_new_syntax
Specify the repository path
$ synvert-ruby --run rspec/use_new_syntax ~/Sites/xinminlabs/awesomecode.io
Run a snippet from remote url
$ synvert-ruby --run https://raw.githubusercontent.com/synvert-hq/synvert-snippets-ruby/master/lib/rspec/use_new_syntax.rb ~/Sites/xinminlabs/awesomecode.io
Run a snippet from local file path
$ synvert-ruby --run ~/.synvert-ruby/lib/rspec/use_new_syntax.rb ~/Sites/xinminlabs/awesomecode.io
Skip paths
$ synvert-ruby --run rspec/use_new_syntax --skip-paths vendor/ ~/Sites/xinminlabs/awesomecode.io
Only paths
$ synvert-ruby --run rspec/use_new_syntax --only-paths app/models/ ~/Sites/xinminlabs/awesomecode.io
Show processing files when running a snippet.
$ synvert-ruby --run rspec/use_new_syntax --show-run-process ~/Sites/synvert-hq/synvert-core-ruby
Synvert Snippet
The following is a typical synvert snippet:
Synvert::Rewriter.new 'rails', 'convert_head_response' do
if_gem 'actionpack', '>= 5.0'
within_file Synvert::RAILS_CONTROLLER_FILES do
# render nothing: true
# =>
# head :ok
find_node '.send[receiver=nil][message=render][arguments.size=1]
[arguments.0=.hash[nothing_value=true][status_value=nil]]' do
replace :message, with: 'head'
replace :arguments, with: ':ok'
end
# render nothing: true, status: :created
# =>
# head :created
find_node '.send[receiver=nil][message=render][arguments.size=1]
[arguments.0=.hash[nothing_value=true][status_value!=nil]]' do
replace_with '{{message}} {{arguments.0.status_source}}'
end
end
end
The snippet only works when actionpack
gem is greater than or equal to 5.0, it finds all rails controller files,
for each controller file, it finds the render nothing: true
code and replace with head
code.
APIs
synvert-core provides a set of APIs to query and mutate code based on AST nodes.
General APIs
- description - describe what the snippet does
description 'describe what the snippet does'
- if_ruby - check if ruby version is greater than or equal to the specified ruby version
if_ruby '3.0.0'
- if_gem - compare version of specified gem
if_gem 'rails', '~> 6.0.0'
- within_files - find specified files
within_files 'spec/**/*_spec.rb' do
end
- within_file - alias to within_files
within_file 'spec/spec_helper.rb' do
end
- add_file - add a new file
add_file 'app/models/application_record.rb', <<~EOS
class ApplicationRecord < ActiveRecord::Base
self.abstract_class = true
end
EOS
- remove_file - remove a file
remove_file 'config/initializers/secret_token.rb'
- add_snippet - call another rewriter
add_snippet 'minitest', 'assert_empty'
add_snippet 'minitest/assert_instance_of'
add_snippet '/Users/flyerhzm/.synvert-ruby/lib/minitest/assert_match.rb'
add_snippet 'https://github.com/synvert-hq/synvert-snippets-ruby/blob/main/lib/minitest/assert_silent.rb'
- helper_method - define a helper method
helper_method :extract_controller_action_name do |hash_node|
controller_name = hash_node.hash_value(:controller).to_value
action_name = hash_node.hash_value(:action).to_value
"#{controller_name}##{action_name}"
end
within_file Synvert::RAILS_ROUTE_FILES do
within_node type: 'send', receiver: 'map', message: 'connect' do
# ...
controller_action_name = extract_controller_action_name(hash_node)
# ...
end
end
- redo_until_no_change - run the snippet until no change
Scope APIs
You can use scope apis to find the matching nodes or move to the specified node.
- within_node - recursively find matching ast nodes
# head status: 406
with_node type: 'send', receiver: nil, message: 'head', arguments: { size: 1, '0': { type: 'hash' } } do
end
# head status: 406
find_node '.send[receiver=nil][message=head][arguments.size=1][arguments.0=.hash]]' do
end
- goto_node - go to a child node
# head status: 406
with_node type: 'send', receiver: nil, message: 'head', arguments: { size: 1, '0': { type: 'hash' } } do
goto_node 'arguments.0' do
end
end
Condition APIs
You can use condition apis to check if the current node matches the rules.
- if_exist_node - check if matching node exist in the child nodes
# Klass.any_instance.should_receive(:message)
with_node type: 'send', message: 'should_receive' do
if_exist_node type: 'send', message: 'any_instance' do
end
end
- unless_exist_node - check if matching node doesn’t exist in the child nodes
# obj.should_receive(:message)
with_node type: 'send', message: 'should_receive' do
unless_exist_node type: 'send', message: 'any_instance' do
end
end
- if_only_exist_node - check if current node has only one child node and the child node matches
# it { should matcher }
with_node type: 'block', caller: { message: 'it' } do
if_only_exist_node type: 'send', receiver: nil, message: 'should' do
end
end
Action APIs
You can use action apis to rewrite the source code.
- append - append the code to the bottom of current node body
# def teardown
# clean_something
# end
# =>
# def teardown
# clean_something
# super
# end
find_node '.class[parent_class=Minitest::Test] .def[name=teardown]:not_has(> .super)' do
append 'super'
end
- prepend - prepend the code to the bottom of current node body
# def setup
# do_something
# end
# =>
# def setup
# super
# do_something
# end
find_node '.class[parent_class=Minitest::Test] .def[name=setup]:not_has(> .super)' do
prepend 'super'
end
- indent - indent code
- option
tab_size
, default is 1
- option
# class Foobar
# end
# =>
# class Foobar
# end
find_node '.class' do
indent
end
- insert - insert code
- option
at
,beginning
orend
(default), insert code at the beginning or end of the current node - option
to
, insert code to the child node of the current node - option
add_comma
,true
orfalse
(default), add extra comma
- option
# open('http://test.com')
# =>
# URI.open('http://test.com')
find_node '.send[receiver=nil][message=open]' do
insert 'URI.', at: 'beginning'
end
# user.name
# =>
# user&.name
find_node '.send[receiver=user][message=name]' do
insert '&', to: 'receiver'
end
# test(foo)
# =>
# test(foo, bar)
find_node '.send[message=test][arguments.size=1][arguments.first=foo]' do
insert 'bar', to: 'arguments.0', and_comma: true
end
- insert_after - insert the code next to the current node
- option
add_comma
,true
orfalse
(default), add extra comma
- option
# Synvert::Application.config.secret_token = "0447aa931d42918bfb934750bb78257088fb671186b5d1b6f9fddf126fc8a14d34f1d045cefab3900751c3da121a8dd929aec9bafe975f1cabb48232b4002e4e"
# =>
# Synvert::Application.config.secret_token = "0447aa931d42918bfb934750bb78257088fb671186b5d1b6f9fddf126fc8a14d34f1d045cefab3900751c3da121a8dd929aec9bafe975f1cabb48232b4002e4e"
# Synvert::Application.config.secret_key_base = "bf4f3f46924ecd9adcb6515681c78144545bba454420973a274d7021ff946b8ef043a95ca1a15a9d1b75f9fbdf85d1a3afaf22f4e3c2f3f78e24a0a188b581df"
with_node type: 'send', message: 'secret_token=' do
insert_after "{{receiver}}.secret_key_base = \"#{SecureRandom.hex(64)}\""
end
- insert_before - insert the code previous to the current node
- option
add_comma
,true
orfalse
(default), add extra comma
- option
# Synvert::Application.config.secret_token = "0447aa931d42918bfb934750bb78257088fb671186b5d1b6f9fddf126fc8a14d34f1d045cefab3900751c3da121a8dd929aec9bafe975f1cabb48232b4002e4e"
# =>
# Synvert::Application.config.secret_key_base = "bf4f3f46924ecd9adcb6515681c78144545bba454420973a274d7021ff946b8ef043a95ca1a15a9d1b75f9fbdf85d1a3afaf22f4e3c2f3f78e24a0a188b581df"
# Synvert::Application.config.secret_token = "0447aa931d42918bfb934750bb78257088fb671186b5d1b6f9fddf126fc8a14d34f1d045cefab3900751c3da121a8dd929aec9bafe975f1cabb48232b4002e4e"
with_node type: 'send', message: 'secret_token=' do
insert_before "{{receiver}}.secret_key_base = \"#{SecureRandom.hex(64)}\""
end
- replace - replace the code of specified child nodes
# Post.paginated_each do |post|
# end
# =>
# Post.find_each do |post|
# end
find_node '.send[message=paginated_each][arguments.size=0]' do
replace :message, with: 'find_each'
end
# expect(1.0 / 3.0).to be_close(0.333, 0.001)
# =>
# expect(1.0 / 3.0).to be_within(0.001).of(0.333)
with_node type: 'send', message: 'to', arguments: { first: { type: 'send', message: 'be_close' } } do
replace :arguments, with: "be_within({{arguments.first.arguments.last}}).of({{arguments.first.arguments.first}})"
end
- delete - delete the code in the specified child nodes
- *selectors, selector names of child node.
- option
add_comma
,true
orfalse
(default), delete extra comma
# FactoryBot.create(...)
# FactoryBot.build(...)
# =>
# create(...)
# build(...)
find_node '.send[receiver=FactoryBot][message IN (create build)]' do
delete :receiver, :dot
end
- remove - remove the whole code of current node.
- option
add_comma
,true
orfalse
(default), delete extra comma
- option
# removes puts and p methods
find_node '.send[receiver=nil][message IN (puts p)]' do
remove
end
- wrap - wrap the current node with prefix and suffix code.
# class Bar < Base
# end
# =>
# module Foo
# class Bar < Base
# end
# end
find_node '.class[name=Bar]' do
wrap prefix: 'module Foo', suffix: 'end', newline: true
end
- replace_with - replace the whole code of current node
# errors[:base] = "author not present"
# =>
# errors.add(:base, "author not present")
with_node type: 'send', receiver: 'errors', message: '[]=' do
replace_with 'errors.add({{arguments.first}}, {{arguments.last}})'
end
-
warn - warn message
-
replace_erb_stmt_with_expr - replace erb stmt code to expr code
# <% form_for post do |f| %>
# <% end %>
# =>
# <%= form_for post do |f| %>
# <% end %>
with_node type: 'block', caller: { type: 'send', receiver: nil, message: { in: %w[form_for form_tag] } } do
replace_erb_stmt_with_expr
end
- noop - no operation
Attributes
- file_path - current file path
- node - current ast node
- mutation_adapter - mutation adapter to get some helper methods
Query Nodes
Synvert uses node_query gem to query nodes, so that you can use NQL (node query language) or node rules to query AST nodes.
Node Query Language
nql matches node type
.class
It matches class node
nql matches attribute
.class[name=User]
It matches class node whose name is User
nql matches nested attribute
.class[parent_class.name=Base]
It matches class node whose parent class name is Base
nql matches evaluated value
.ivasgn[left_value="@{{right_value}}"]
It matches ivasgn node whose left value equals ‘@’ plus the evaluated value of right value.
nql matches nested selector
.def[body.0=.ivasgn]
It matches def node whose first child node is an ivasgn node.
nql matches method result
.def[arguments.size=2]
It matches def node whose arguments size is 2.
nql matches operators
.class[name=User]
Value of name is equal to User
.class[name^=User]
Value of name starts with User
.class[name$=User]
Value of name ends with User
.class[name*=User]
Value of name contains User
.def[arguments.size!=2]
Size of arguments is not equal to 2
.def[arguments.size>=2]
Size of arguments is greater than or equal to 2
.def[arguments.size>2]
Size of arguments is greater than 2
.def[arguments.size<=2]
Size of arguments is less than or equal to 2
.def[arguments.size<2]
Size of arguments is less than 2
.class[name IN (User Account)]
Value of name is either User or Account
.class[name NOT IN (User Account)]
Value of name is neither User nor Account
.def[arguments INCLUDES id]
Value of arguments includes id
.def[arguments NOT INCLUDES id]
Value of arguments not includes id
.class[name=~/User/]
Value of name matches User
.class[name!~/User/]
Value of name does not match User
.class[name IN (/User/ /Account/)]
Value of name matches either /User/ or /Account/
nql matches array node attribute
.def[arguments=(id name)]
It matches def node whose arguments are id and name.
nql matches * in attribute key
.def[arguments.*.name IN (id name)]
It matches def node whose arguments are either id or name.
nql matches multiple selectors
Descendant combinator
.class .send
It matches send node whose ansestor is class node.
Child combinator
.def > .send
It matches send node whose parent is def node.
Adjacent sibling combinator
.send[left_value=@id] + .send
It matches send node only if it is immediately follows the send node whose left value is @id.
General sibling combinator
.send[left_value=@id] ~ .send
It matches send node only if it is follows the send node whose left value is @id.
nql matches goto scope
.def body .send
It matches send node who is in the body of def node.
nql matches :has and :not_has pseudo selector
.class:has(.def[name=initialize])
It matches class node who has an initialize def node.
.class:not_has(.def[name=initialize])
It matches class node who does not have an initialize def node.
nql matches :first-child and :last-child pseudo selector
.def:first-child
It matches the first def node.
.def:last-child
It matches the last def node.
nql matches multiple expressions
.ivasgn[left_value=@id], .ivasgn[left_value=@name]
It matches ivasgn node whose left value is either @id or @name.
Node Rules
rules matches node type
{ node_type: 'class' }
It matches class node
rules matches attribute
{ node_type: 'def', name: 'initialize' }
It matches def node whose name is initialize
{ node_type: 'def', arguments: { "0": 1, "1": "Murphy" } }
It matches def node whose arguments are 1 and Murphy.
rules matches nested attribute
{ node_type: 'class', parent_class: { name: 'Base' } }
It matches class node whose parent class name is Base
rules matches evaluated value
{ node_type: 'ivasgn', left_value: '@{{right_value}}' }
It matches ivasgn node whose left value equals ‘@’ plus the evaluated value of right value.
rules matches nested selector
{ node_type: 'def', body: { "0": { node_type: 'ivasgn' } } }
It matches def node whose first child node is an ivasgn node.
rules matches method result
{ node_type: 'def', arguments: { size: 2 } }
It matches def node whose arguments size is 2.
rules matches operators
{ node_type: 'class', name: 'User' }
Value of name is equal to User
{ node_type: 'def', arguments: { size { not: 2 } }
Size of arguments is not equal to 2
{ node_type: 'def', arguments: { size { gte: 2 } }
Size of arguments is greater than or equal to 2
{ node_type: 'def', arguments: { size { gt: 2 } }
Size of arguments is greater than 2
{ node_type: 'def', arguments: { size { lte: 2 } }
Size of arguments is less than or equal to 2
{ node_type: 'def', arguments: { size { lt: 2 } }
Size of arguments is less than 2
{ node_type: 'class', name: { in: ['User', 'Account'] } }
Value of name is either User or Account
{ node_type: 'class', name: { not_in: ['User', 'Account'] } }
Value of name is neither User nor Account
{ node_type: 'def', arguments: { includes: 'id' } }
Value of arguments includes id
{ node_type: 'def', arguments: { not_includes: 'id' } }
Value of arguments not includes id
{ node_type: 'class', name: /User/ }
Value of name matches User
{ node_type: 'class', name: { not: /User/ } }
Value of name does not match User
{ node_type: 'class', name: { in: [/User/, /Account/] } }
Value of name matches either /User/ or /Account/
rules matches array nodes attribute
{ node_type: 'def', arguments: ['id', 'name'] }
It matches def node whose arguments are id and name.
Evaluated Value
Evaluated value uses syntax {{ ... }}
to fetch child node or value, e.g.
# assert_equal(actual, "rubocop-minitest")
# =>
# assert_equal("rubocop-minitest", actual)
replace :arguments, with: '{{arguments.1}}, {{arguments.first}}'
# { class: User }
# =>
# { class: 'User' }
replace 'class_value', with: "'{{class_source}}'"