├── .rspec ├── .yardopts ├── lib └── synvert │ ├── core │ ├── version.rb │ ├── strategy.rb │ ├── errors.rb │ ├── rewriter │ │ ├── scope.rb │ │ ├── condition │ │ │ ├── if_exist_condition.rb │ │ │ └── unless_exist_condition.rb │ │ ├── warning.rb │ │ ├── condition.rb │ │ ├── ruby_version.rb │ │ ├── action │ │ │ └── replace_erb_stmt_with_expr_action.rb │ │ ├── scope │ │ │ ├── goto_scope.rb │ │ │ └── within_scope.rb │ │ ├── gem_spec.rb │ │ ├── helper.rb │ │ └── instance.rb │ ├── engine │ │ ├── erb.rb │ │ ├── haml.rb │ │ ├── slim.rb │ │ └── elegant.rb │ ├── engine.rb │ ├── helper.rb │ ├── configuration.rb │ ├── utils.rb │ └── rewriter.rb │ └── core.rb ├── Rakefile ├── spec ├── synvert │ └── core │ │ ├── rewriter │ │ ├── scope_spec.rb │ │ ├── condition_spec.rb │ │ ├── warning_spec.rb │ │ ├── ruby_version_spec.rb │ │ ├── action │ │ │ └── replace_erb_stmt_with_expr_action_spec.rb │ │ ├── condition │ │ │ ├── if_exist_condition_spec.rb │ │ │ └── unless_exist_condition_spec.rb │ │ ├── scope │ │ │ ├── goto_scope_spec.rb │ │ │ └── within_scope_spec.rb │ │ ├── helper_spec.rb │ │ ├── gem_spec_spec.rb │ │ └── instance_spec.rb │ │ ├── configuration_spec.rb │ │ ├── helper_spec.rb │ │ ├── engine │ │ ├── erb_spec.rb │ │ ├── slim_spec.rb │ │ └── haml_spec.rb │ │ ├── utils_spec.rb │ │ └── rewriter_spec.rb ├── support │ └── parser_helper.rb └── spec_helper.rb ├── .gitignore ├── Guardfile ├── Gemfile ├── .vscode └── settings.json ├── LICENSE.txt ├── .github └── workflows │ ├── stale.yml │ └── main.yml ├── synvert-core-ruby.gemspec ├── Gemfile.lock ├── README.md └── CHANGELOG.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format documentation 3 | -------------------------------------------------------------------------------- /.yardopts: -------------------------------------------------------------------------------- 1 | --markup-provider=redcarpet 2 | --markup=markdown 3 | -------------------------------------------------------------------------------- /lib/synvert/core/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Synvert 4 | module Core 5 | VERSION = '2.2.2' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require 'rspec/core/rake_task' 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | task :default => :spec 9 | -------------------------------------------------------------------------------- /spec/synvert/core/rewriter/scope_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Synvert::Core 6 | describe Rewriter::Scope do 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /spec/synvert/core/rewriter/condition_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Synvert::Core 6 | describe Rewriter::Condition do 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/synvert/core/strategy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Synvert::Core 4 | class Strategy 5 | ALLOW_INSERT_AT_SAME_POSITION = "allow_insert_at_same_position" 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | InstalledFiles 7 | _yardoc 8 | coverage 9 | doc/ 10 | lib/bundler/man 11 | pkg 12 | rdoc 13 | spec/reports 14 | test/tmp 15 | test/version_tmp 16 | tmp 17 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | guard :rspec, cmd: 'bundle exec rspec' do 4 | watch(%r{^spec/.+_spec\.rb$}) 5 | watch(%r{^lib/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } 6 | watch('spec/spec_helper.rb') { "spec" } 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/parser_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'parser/current' 4 | require 'parser_node_ext' 5 | 6 | module ParserHelper 7 | def parser_parse(code) 8 | Parser::CurrentRuby.parse code 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/synvert/core/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Synvert 4 | module Core 5 | module Errors 6 | class SnippetNotFound < StandardError; end 7 | class ParserNotSupported < StandardError; end 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | # Specify your gem's dependencies in synvert.gemspec 6 | gemspec 7 | 8 | gem "activesupport", "7.1.3.4" # support ruby 2.7 9 | gem "fakefs", require: "fakefs/safe" 10 | gem "guard" 11 | gem "guard-rspec" 12 | gem "rake" 13 | gem "rspec" 14 | gem "rspec-mocks" 15 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "asgn", 4 | "autoindent", 5 | "blockarg", 6 | "casgn", 7 | "csend", 8 | "cvar", 9 | "erange", 10 | "fileutils", 11 | "haml", 12 | "ivar", 13 | "lvar", 14 | "mlhs", 15 | "restarg", 16 | "rvmrc", 17 | "synvert", 18 | "Yardoc", 19 | "zsuper" 20 | ] 21 | } -------------------------------------------------------------------------------- /lib/synvert/core/rewriter/scope.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Synvert::Core 4 | # Scope finds out nodes which match rules. 5 | class Rewriter::Scope 6 | # Initialize a Scope 7 | # 8 | # @param instance [Synvert::Core::Rewriter::Instance] 9 | # @yield run on a scope 10 | def initialize(instance, &block) 11 | @instance = instance 12 | @block = block 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/synvert/core/rewriter/warning_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Synvert::Core 6 | describe Rewriter::Warning do 7 | subject { 8 | Rewriter::Warning.new('app/test.rb', 2, 'remove debugger') 9 | } 10 | 11 | it 'gets message with filename and line number' do 12 | expect(subject.message).to eq 'app/test.rb#2: remove debugger' 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/synvert/core/rewriter/condition/if_exist_condition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Synvert::Core 4 | # IfExistCondition checks if matching node exists in the node children. 5 | class Rewriter::IfExistCondition < Rewriter::Condition 6 | private 7 | 8 | # check if any child node matches. 9 | # 10 | # @return [Boolean] 11 | def match? 12 | @node_query.query_nodes(target_node, including_self: false, stop_at_first_match: true).size > 0 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/synvert/core/rewriter/condition/unless_exist_condition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Synvert::Core 4 | # UnlessExistCondition checks if matching node doesn't exist in the node children. 5 | class Rewriter::UnlessExistCondition < Rewriter::Condition 6 | private 7 | 8 | # check if none of child node matches. 9 | # 10 | # return [Boolean] 11 | def match? 12 | @node_query.query_nodes(target_node, including_self: false, stop_at_first_match: true).size == 0 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/synvert/core/rewriter/warning.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Synvert::Core 4 | # Warning is used to save warning message. 5 | class Rewriter::Warning 6 | # Initialize a Warning. 7 | # 8 | # @param file_path [String] file path. 9 | # @param line [Integer] file line. 10 | # @param message [String] warning message. 11 | def initialize(file_path, line, message) 12 | @file_path = file_path 13 | @line = line 14 | @message = message 15 | end 16 | 17 | # Warning message. 18 | # 19 | # @return [String] warning message. 20 | def message 21 | "#{@file_path}##{@line}: #{@message}" 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'simplecov' 4 | SimpleCov.start do 5 | add_filter '/spec/' 6 | 7 | add_group 'Core', 'lib/synvert/core' 8 | end 9 | 10 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 11 | 12 | require 'pp' # rubocop:disable Lint/RedundantRequireStatement 13 | require 'synvert/core' 14 | require 'fakefs/spec_helpers' 15 | 16 | Dir[File.join(File.dirname(__FILE__), 'support', '*')].each do |path| 17 | require path 18 | end 19 | 20 | RSpec.configure do |config| 21 | config.include ParserHelper 22 | config.include FakeFS::SpecHelpers, fakefs: true 23 | 24 | config.run_all_when_everything_filtered = true 25 | config.filter_run :focus 26 | 27 | config.order = 'random' 28 | end 29 | -------------------------------------------------------------------------------- /spec/synvert/core/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Synvert::Core 4 | RSpec.describe Configuration do 5 | after do 6 | Configuration.number_of_workers = nil 7 | Configuration.strict = nil 8 | end 9 | 10 | describe '.with_temporary_configurations' do 11 | it 'temporarily sets instance variables and restores them after block execution' do 12 | Configuration.number_of_workers = 4 13 | Configuration.strict = true 14 | 15 | Configuration.with_temporary_configurations(number_of_workers: 1, strict: false) do 16 | expect(Configuration.number_of_workers).to eq(1) 17 | expect(Configuration.strict).to eq(false) 18 | end 19 | 20 | expect(Configuration.number_of_workers).to eq(4) 21 | expect(Configuration.strict).to eq(true) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/synvert/core/helper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Synvert::Core 6 | RSpec.describe Helper do 7 | describe 'class methods' do 8 | before :each do 9 | described_class.clear 10 | end 11 | 12 | it 'registers and fetches' do 13 | helper = described_class.new 'helper' do; end 14 | expect(described_class.fetch('helper')).to eq helper 15 | end 16 | 17 | context 'available' do 18 | it 'lists empty helpers' do 19 | expect(described_class.availables).to eq({}) 20 | end 21 | 22 | it 'registers and lists all available helpers' do 23 | helper1 = Helper.new 'helper1' do; end 24 | helper2 = Helper.new 'helper2' do; end 25 | expect(Helper.availables).to eq({ 'helper1' => helper1, 'helper2' => helper2 }) 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/synvert/core/rewriter/condition.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Synvert::Core 4 | # Condition checks if nql or rules matches. 5 | class Rewriter::Condition 6 | # Initialize a Condition. 7 | # 8 | # @param instance [Synvert::Core::Rewriter::Instance] 9 | # @param nql_or_rules [String|Hash] 10 | # @yield run when condition matches 11 | def initialize(instance, nql_or_rules, &block) 12 | @instance = instance 13 | @node_query = NodeQuery.new(nql_or_rules, adapter: instance.current_parser) 14 | @block = block 15 | end 16 | 17 | # If condition matches, run the block code. 18 | def process 19 | @instance.instance_eval(&@block) if match? 20 | end 21 | 22 | protected 23 | 24 | # Check if condition matches 25 | # 26 | # @abstract 27 | def match? 28 | raise NotImplementedError, 'must be implemented by subclasses' 29 | end 30 | 31 | def target_node 32 | @instance.current_node 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/synvert/core/engine/erb.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Synvert::Core 4 | module Engine 5 | class Erb 6 | class << self 7 | # Encode erb string, leave only ruby code, replace other erb code with whitespace. 8 | # 9 | # @param source [String] erb code. 10 | # @return [String] encoded ruby code. 11 | def encode(source) 12 | source.gsub(/%>.*?<%=?/m) { |str| ';' + replace_all_code_but_white_space_characters(str[1..-1]) } 13 | .sub(/^.*<%=?/m) { |str| replace_all_code_but_white_space_characters(str) } 14 | .sub(/%>.*$/m) { |str| ';' + replace_all_code_but_white_space_characters(str[1..-1]) } 15 | end 16 | 17 | # Generate an empty proc. 18 | def generate_transform_proc(_encoded_source) 19 | proc {} 20 | end 21 | 22 | private 23 | 24 | def replace_all_code_but_white_space_characters(source) 25 | source.gsub(/\S/, ' ') 26 | end 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/synvert/core/rewriter/ruby_version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Synvert::Core 4 | # GemSpec checks and compares ruby version. 5 | class Rewriter::RubyVersion 6 | attr_reader :version 7 | 8 | # Initialize a ruby_version. 9 | # 10 | # @param version [String] ruby version 11 | def initialize(version) 12 | @version = version 13 | end 14 | 15 | # Check if the specified ruby version matches current ruby version. 16 | # 17 | # @return [Boolean] true if matches, otherwise false. 18 | def match? 19 | return true unless Configuration.strict 20 | 21 | if File.exist?(File.join(Configuration.root_path, '.ruby-version')) 22 | version_file = '.ruby-version' 23 | elsif File.exist?(File.join(Configuration.root_path, '.rvmrc')) 24 | version_file = '.rvmrc' 25 | end 26 | return true unless version_file 27 | 28 | version = File.read(File.join(Configuration.root_path, version_file)) 29 | version = version.match(/(\d+\.\d+\.\d+)/)[0] 30 | Gem::Version.new(version) >= Gem::Version.new(@version) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 Xinmin Labs 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /lib/synvert/core/rewriter/action/replace_erb_stmt_with_expr_action.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Synvert::Core 4 | # ReplaceErbStmtWithExprAction to replace erb stmt code to expr, 5 | # @example 6 | # e.g. <% form_for ... %> => <%= form_for ... %>. 7 | class Rewriter::ReplaceErbStmtWithExprAction < NodeMutation::Action 8 | # Initialize a ReplaceErbStmtWithExprAction. 9 | # 10 | # @param node [Synvert::Core::Rewriter::Node] 11 | # @param erb_source [String] 12 | # @param adapter [NodeMutation::Adapter] 13 | def initialize(node, erb_source, adapter:) 14 | super(node, nil, adapter: adapter) 15 | @erb_source = erb_source 16 | @type = :insert 17 | end 18 | 19 | # The new erb expr code. 20 | # 21 | # @return [String] new code. 22 | def new_code 23 | '=' 24 | end 25 | 26 | private 27 | 28 | # Calculate the begin the end positions. 29 | def calculate_position 30 | @start = @adapter.get_start(@node) 31 | loop do 32 | @start -= 1 33 | break if @erb_source[@start] == '%' 34 | end 35 | @start += 1 36 | @end = @start 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/synvert/core/rewriter/ruby_version_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Synvert::Core 6 | describe Rewriter::RubyVersion do 7 | before do 8 | allow(File).to receive(:exist?).with('./.ruby-version').and_return(true) 9 | allow(File).to receive(:read).with('./.ruby-version').and_return('3.0.0') 10 | end 11 | 12 | it 'returns true if ruby version is greater than 1.9' do 13 | ruby_version = Rewriter::RubyVersion.new('1.9') 14 | expect(ruby_version).to be_match 15 | end 16 | 17 | it 'returns false if ruby version is less than 19.0' do 18 | ruby_version = Rewriter::RubyVersion.new('19.0') 19 | expect(ruby_version).not_to be_match 20 | end 21 | 22 | it 'returns true if strict Configuration is false' do 23 | Configuration.strict = false 24 | ruby_version = Rewriter::RubyVersion.new('19.0') 25 | expect(ruby_version).to be_match 26 | Configuration.strict = true 27 | end 28 | 29 | it 'returns true if ruby version is ruby-3.0.0\n' do 30 | allow(File).to receive(:read).with('./.ruby-version').and_return("ruby-3.0.0\n") 31 | ruby_version = Rewriter::RubyVersion.new('1.9') 32 | expect(ruby_version).to be_match 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close stale issues 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' # Runs at 00:00 UTC every day 6 | 7 | jobs: 8 | stale: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/stale@v9 12 | with: 13 | # Issues 14 | stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.' 15 | close-issue-message: 'This issue was closed because it has been stale for 30 days with no activity.' 16 | days-before-issue-stale: 60 17 | days-before-issue-close: 30 18 | 19 | # Pull Requests 20 | stale-pr-message: 'This pull request has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.' 21 | close-pr-message: 'This pull request was closed because it has been stale for 30 days with no activity.' 22 | days-before-pr-stale: 60 23 | days-before-pr-close: 30 24 | 25 | # General settings 26 | exempt-issue-labels: 'pinned,security' 27 | exempt-pr-labels: 'pinned,security' 28 | stale-issue-label: 'stale' 29 | stale-pr-label: 'stale' -------------------------------------------------------------------------------- /lib/synvert/core/rewriter/scope/goto_scope.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Synvert::Core 4 | # Go to and change its scope to a child node. 5 | class Rewriter::GotoScope < Rewriter::Scope 6 | # Initialize a GotoScope. 7 | # 8 | # @param instance [Synvert::Core::Rewriter::Instance] 9 | # @param child_node_name [Symbol|String] name of child node 10 | # @yield run on the child node 11 | def initialize(instance, child_node_name, &block) 12 | super(instance, &block) 13 | @child_node_name = child_node_name 14 | end 15 | 16 | # Go to a child now, then run the block code on the the child node. 17 | def process 18 | current_node = @instance.current_node 19 | return unless current_node 20 | 21 | child_node = current_node 22 | @child_node_name.to_s.split('.').each do |child_node_name| 23 | child_node = child_node.is_a?(Array) && child_node_name =~ /-?\d+/ ? child_node[child_node_name.to_i] : child_node.send(child_node_name) 24 | end 25 | if child_node.is_a?(Array) 26 | child_node.each do |child_child_node| 27 | @instance.process_with_other_node child_child_node do 28 | @instance.instance_eval(&@block) 29 | end 30 | end 31 | else 32 | @instance.process_with_other_node child_node do 33 | @instance.instance_eval(&@block) 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/synvert/core/engine.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'strscan' 4 | 5 | module Synvert::Core 6 | # Engine defines how to encode / decode other files (like erb). 7 | module Engine 8 | autoload :Elegant, 'synvert/core/engine/elegant' 9 | autoload :Erb, 'synvert/core/engine/erb' 10 | autoload :Haml, 'synvert/core/engine/haml' 11 | autoload :Slim, 'synvert/core/engine/slim' 12 | 13 | # Register an engine 14 | # @param [String] extension 15 | # @param [Class] engine 16 | def self.register(extension, engine) 17 | @engines ||= {} 18 | @engines[extension] = engine 19 | end 20 | 21 | # Encode source code by registered engine. 22 | # @param [String] extension 23 | # @param [String] source 24 | # @return [String] encoded source 25 | def self.encode(extension, source) 26 | engine = @engines[extension] 27 | engine ? engine.encode(source) : source 28 | end 29 | 30 | # Generate a transform_proc by registered engine, 31 | # which is used to adjust start and end position of actions. 32 | # @param [String] extension 33 | # @param [String] encoded_source 34 | # @return [Proc] transform_proc 35 | def self.generate_transform_proc(extension, encoded_source) 36 | engine = @engines[extension] 37 | engine ? engine.generate_transform_proc(encoded_source) : proc {} 38 | end 39 | end 40 | 41 | Engine.register('.erb', Engine::Erb) 42 | Engine.register('.haml', Engine::Haml) 43 | Engine.register('.slim', Engine::Slim) 44 | end 45 | -------------------------------------------------------------------------------- /lib/synvert/core/rewriter/scope/within_scope.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Synvert::Core 4 | # WithinScope finds out nodes which match nql or rules, then changes its scope to matching node. 5 | class Rewriter::WithinScope < Rewriter::Scope 6 | # Initialize a WithinScope. 7 | # 8 | # @param instance [Synvert::Core::Rewriter::Instance] 9 | # @param nql_or_rules [String|Hash] 10 | # @param options [Hash] 11 | # @yield run on all matching nodes 12 | # @raise [Synvert::Core::NodeQuery::Compiler::ParseError] if the query string is invalid. 13 | def initialize(instance, nql_or_rules, options = {}, &block) 14 | super(instance, &block) 15 | 16 | @options = { including_self: true, stop_at_first_match: false, recursive: true }.merge(options) 17 | @node_query = NodeQuery.new(nql_or_rules, adapter: instance.current_parser) 18 | end 19 | 20 | # Find out the matching nodes. 21 | # 22 | # It checks the current node and iterates all child nodes, 23 | # then run the block code on each matching node. 24 | def process 25 | current_node = @instance.current_node 26 | return unless current_node 27 | 28 | matching_nodes = @node_query.query_nodes(current_node, @options) 29 | @instance.process_with_node current_node do 30 | matching_nodes.each do |matching_node| 31 | @instance.process_with_node matching_node do 32 | @instance.instance_eval(&@block) 33 | end 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/synvert/core/rewriter/action/replace_erb_stmt_with_expr_action_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Synvert::Core 6 | RSpec.describe Rewriter::ReplaceErbStmtWithExprAction do 7 | context 'replace with whitespace' do 8 | subject { 9 | erb_source = "<% form_for post do |f| %>\n<% end %>" 10 | source = Engine::Erb.encode(erb_source) 11 | node = parser_parse(source).children.first 12 | described_class.new(node, erb_source, adapter: NodeMutation::ParserAdapter.new).process 13 | } 14 | 15 | it 'gets start' do 16 | expect(subject.start).to eq '<%'.length 17 | end 18 | 19 | it 'gets end' do 20 | expect(subject.end).to eq '<%'.length 21 | end 22 | 23 | it 'gets new_code' do 24 | expect(subject.new_code).to eq '=' 25 | end 26 | end 27 | 28 | context 'replace without whitespace' do 29 | subject { 30 | erb_source = "<%form_for post do |f|%>\n<%end%>" 31 | source = Engine::Erb.encode(erb_source) 32 | node = parser_parse(source).children.first 33 | described_class.new(node, erb_source, adapter: NodeMutation::ParserAdapter.new).process 34 | } 35 | 36 | it 'gets start' do 37 | expect(subject.start).to eq '<%'.length 38 | end 39 | 40 | it 'gets end' do 41 | expect(subject.end).to eq '<%'.length 42 | end 43 | 44 | it 'gets new_code' do 45 | expect(subject.new_code).to eq '=' 46 | end 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/synvert/core/rewriter/condition/if_exist_condition_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Synvert::Core 6 | RSpec.describe Rewriter::IfExistCondition do 7 | let(:source) { <<~EOS } 8 | RSpec.configure do |config| 9 | config.include EmailSpec::Helpers 10 | config.include EmailSpec::Methods 11 | end 12 | EOS 13 | let(:node) { Parser::CurrentRuby.parse(source) } 14 | let(:instance) { double(current_node: node, current_parser: :parser) } 15 | 16 | describe '#process' do 17 | it 'call block if match anything' do 18 | run = false 19 | condition = 20 | Rewriter::IfExistCondition.new instance, 21 | type: 'send', 22 | message: 'include', 23 | arguments: ['EmailSpec::Helpers'] do 24 | run = true 25 | end 26 | condition.process 27 | expect(run).to be_truthy 28 | end 29 | 30 | it 'not call block if not match anything' do 31 | run = false 32 | condition = 33 | Rewriter::IfExistCondition.new instance, 34 | type: 'send', 35 | message: 'include', 36 | arguments: ['FactoryGirl::SyntaxMethods'] do 37 | run = true 38 | end 39 | condition.process 40 | expect(run).to be_falsey 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /synvert-core-ruby.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | lib = File.expand_path('lib', __dir__) 4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 5 | require 'synvert/core/version' 6 | 7 | Gem::Specification.new do |spec| 8 | spec.name = "synvert-core" 9 | spec.version = Synvert::Core::VERSION 10 | spec.authors = ["Richard Huang"] 11 | spec.email = ["flyerhzm@gmail.com"] 12 | spec.summary = 'convert ruby code to better syntax.' 13 | spec.description = 'convert ruby code to better syntax automatically.' 14 | spec.homepage = "https://github.com/synvert-hq/synvert-core-ruby" 15 | spec.license = "MIT" 16 | 17 | spec.files = `git ls-files -z`.split("\x0") 18 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 19 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 20 | spec.require_paths = ["lib"] 21 | 22 | spec.add_runtime_dependency "activesupport" 23 | spec.add_runtime_dependency "node_query", ">= 1.15.4" 24 | spec.add_runtime_dependency "node_mutation", ">= 1.24.4" 25 | spec.add_runtime_dependency "node_visitor", ">= 1.1.0" 26 | spec.add_runtime_dependency "parser" 27 | spec.add_runtime_dependency "parser_node_ext", ">= 1.4.2" 28 | spec.add_runtime_dependency "syntax_tree" 29 | spec.add_runtime_dependency "syntax_tree_ext", ">= 0.9.2" 30 | spec.add_runtime_dependency "prism" 31 | spec.add_runtime_dependency "prism_ext", ">= 0.4.2" 32 | spec.add_runtime_dependency "parallel" 33 | 34 | spec.add_development_dependency "simplecov" 35 | end 36 | -------------------------------------------------------------------------------- /lib/synvert/core/helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Synvert::Core 4 | # Helper is used to define shared snippet. 5 | class Helper 6 | attr_reader :name, :block 7 | 8 | class << self 9 | # Register a helper with its name. 10 | # 11 | # @param name [String] the unique rewriter name. 12 | # @param helper [Synvert::Core::Helper] the helper to register. 13 | def register(name, helper) 14 | name = name.to_s 15 | helpers[name] = helper 16 | end 17 | 18 | # Fetch a helper by name. 19 | # 20 | # @param name [String] rewrtier name. 21 | # @return [Synvert::Core::Helper] the matching helper. 22 | def fetch(name) 23 | name = name.to_s 24 | helpers[name] 25 | end 26 | 27 | # Get all available helpers 28 | # 29 | # @return [Hash] 30 | def availables 31 | helpers 32 | end 33 | 34 | # Clear all registered helpers. 35 | def clear 36 | helpers.clear 37 | end 38 | 39 | private 40 | 41 | def helpers 42 | @helpers ||= {} 43 | end 44 | end 45 | 46 | # Initialize a Helper. 47 | # When a helper is initialized, it is already registered. 48 | # 49 | # @param name [String] name of the helper. 50 | # @yield defines the behaviors of the helper, block code won't be called when initialization. 51 | def initialize(name, &block) 52 | @name = name 53 | @block = block 54 | self.class.register(name, self) 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/synvert/core/rewriter/condition/unless_exist_condition_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Synvert::Core 6 | describe Rewriter::UnlessExistCondition do 7 | let(:source) { <<~EOS } 8 | RSpec.configure do |config| 9 | config.include EmailSpec::Helpers 10 | config.include EmailSpec::Methods 11 | end 12 | EOS 13 | let(:node) { Parser::CurrentRuby.parse(source) } 14 | let(:instance) { double(current_node: node, current_parser: :parser) } 15 | 16 | describe '#process' do 17 | it 'call block if match anything' do 18 | run = false 19 | condition = 20 | Rewriter::UnlessExistCondition.new instance, 21 | type: 'send', 22 | message: 'include', 23 | arguments: ['FactoryGirl::Syntax::Methods'] do 24 | run = true 25 | end 26 | condition.process 27 | expect(run).to be_truthy 28 | end 29 | 30 | it 'not call block if not match anything' do 31 | run = false 32 | condition = 33 | Rewriter::UnlessExistCondition.new instance, 34 | type: 'send', 35 | message: 'include', 36 | arguments: ['EmailSpec::Helpers'] do 37 | run = true 38 | end 39 | condition.process 40 | expect(run).to be_falsey 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/synvert/core/rewriter/gem_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler' 4 | 5 | module Synvert::Core 6 | # GemSpec checks and compares gem version. 7 | class Rewriter::GemSpec 8 | # @!attribute [r] name 9 | # @return [String] the name of gem_spec 10 | # @!attribute [r] version 11 | # @return [String] the version of gem_spec 12 | attr_reader :name, :version 13 | 14 | # Initialize a GemSpec. 15 | # 16 | # @param name [String] gem name 17 | # @param version [String] gem version, e.g. '~> 2.0.0' 18 | def initialize(name, version) 19 | @name = name 20 | @version = version 21 | end 22 | 23 | # Check if the specified gem version in Gemfile.lock matches gem_spec comparator. 24 | # 25 | # @return [Boolean] true if matches, otherwise false. 26 | def match? 27 | return true unless Configuration.strict 28 | 29 | gemfile_lock_path = File.expand_path(File.join(Configuration.root_path, gemfile_lock_name)) 30 | 31 | # if Gemfile.lock does not exist, just ignore this check 32 | return true unless File.exist?(gemfile_lock_path) 33 | 34 | ENV['BUNDLE_GEMFILE'] = Configuration.root_path # make sure bundler reads Gemfile.lock in the correct path 35 | parser = Bundler::LockfileParser.new(File.read(gemfile_lock_path)) 36 | parser.specs.any? { |spec| Gem::Dependency.new(@name, @version).match?(spec) } 37 | end 38 | 39 | private 40 | 41 | def gemfile_lock_name 42 | return "#{File.basename(ENV['BUNDLE_GEMFILE'])}.lock" if File.exist?(ENV['BUNDLE_GEMFILE']) 43 | 44 | 'Gemfile.lock' 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Test & deploy documentation 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | ruby-version: ['2.7', '3.0', '3.1', '3.2', '3.3', '3.4'] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | - name: Set up Ruby 19 | uses: ruby/setup-ruby@v1 20 | with: 21 | ruby-version: ${{ matrix.ruby-version }} 22 | bundler-cache: true 23 | - name: Run tests 24 | run: bundle exec rake 25 | 26 | deploy: 27 | runs-on: ubuntu-latest 28 | needs: test 29 | if: github.ref == 'refs/heads/main' && github.event_name == 'push' 30 | name: Update gh-pages to docs 31 | steps: 32 | - uses: actions/checkout@v4 33 | - uses: ruby/setup-ruby@v1 34 | with: 35 | ruby-version: '3.4' 36 | bundler-cache: true 37 | - name: Cache YARD gems 38 | uses: actions/cache@v3 39 | with: 40 | path: vendor/bundle 41 | key: ${{ runner.os }}-yard-gems-${{ hashFiles('**/Gemfile.lock') }} 42 | restore-keys: | 43 | ${{ runner.os }}-yard-gems- 44 | - name: Install required gem dependencies 45 | run: | 46 | gem install yard redcarpet github-markup --no-document 47 | - name: Build YARD Ruby Documentation 48 | run: yardoc --output-dir docs 49 | - name: Deploy 50 | uses: peaceiris/actions-gh-pages@v3 51 | with: 52 | deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }} 53 | publish_dir: ./docs 54 | -------------------------------------------------------------------------------- /spec/synvert/core/engine/erb_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Synvert::Core 6 | describe Engine::Erb do 7 | it 'encodes / decodes' do 8 | source = <<~EOF 9 | <% content_for :head do %> 10 | 15 | <% end %> 16 | 17 | <% 18 | foo = 'bar' 19 | post = Post.find(:first) 20 | bar = 'foo' 21 | %> 22 | 23 | <%= user.first_name %> <%= user.last_name %> 24 | 25 | <% if User.current && 26 | User.current.admin %> 27 | <%= rounded_content("page") do %> 28 |
29 | <% if post %> 30 |
<%= foo %>
31 | <% form_for post do |f| %> 32 | 33 | <%= f.text_field 'bar' %> 34 | <% end %> 35 | <% end %>
36 | <% end %> 37 | <% end %> 38 | EOF 39 | encoded_source = Engine::Erb.encode(source) 40 | expect(encoded_source).to be_include 'content_for :head do' 41 | expect(encoded_source).to be_include " asset_path('bg.png')" 42 | expect(encoded_source).to be_include 'post = Post.find(:first)' 43 | expect(encoded_source).to be_include "link_to_function 'test', \"confirm('test');\"" 44 | expect(encoded_source).to be_include 'end' 45 | expect(encoded_source).to be_include 'user.first_name ;' 46 | expect(encoded_source).to be_include 'user.last_name ;' 47 | expect(encoded_source).not_to be_include 'style' 48 | expect(encoded_source).not_to be_include 'div' 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/synvert/core/rewriter/scope/goto_scope_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Synvert::Core 6 | describe Rewriter::GotoScope do 7 | let(:instance) { 8 | rewriter = Rewriter.new('foo', 'bar') 9 | Rewriter::Instance.new(rewriter, 'file pattern') 10 | } 11 | let(:source) { <<~EOS } 12 | Factory.define :user do |user| 13 | user.first_name 'First' 14 | user.last_name 'Last' 15 | end 16 | EOS 17 | 18 | let(:node) { Parser::CurrentRuby.parse(source) } 19 | before { instance.current_node = node } 20 | 21 | describe '#process' do 22 | it 'calls block with child node' do 23 | run = false 24 | type_in_scope = nil 25 | scope = 26 | Rewriter::GotoScope.new instance, 'caller.receiver' do 27 | run = true 28 | type_in_scope = node.type 29 | end 30 | scope.process 31 | expect(run).to be_truthy 32 | expect(type_in_scope).to eq :const 33 | expect(instance.current_node.type).to eq :block 34 | end 35 | 36 | it 'call block with child node in array' do 37 | run = false 38 | type_in_scope = nil 39 | scope = 40 | Rewriter::GotoScope.new instance, 'body.1' do 41 | run = true 42 | type_in_scope = node.type 43 | end 44 | scope.process 45 | expect(run).to be_truthy 46 | expect(type_in_scope).to eq :send 47 | expect(instance.current_node.type).to eq :block 48 | end 49 | 50 | it 'calls block multiple times with block body' do 51 | count = 0 52 | scope = 53 | Rewriter::GotoScope.new instance, 'body' do 54 | count += 1 55 | end 56 | scope.process 57 | expect(count).to eq 2 58 | expect(instance.current_node.type).to eq :block 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/synvert/core/rewriter/helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Synvert::Core 4 | # Rewriter::Helper provides some helper methods to make it easier to write a snippet. 5 | module Rewriter::Helper 6 | # Add receiver to code if necessary. 7 | # 8 | # @param code [String] old code 9 | # @return [String] new code 10 | # 11 | # @example 12 | # 13 | # add_receiver_if_necessary("{{message}} {{arguments}}") 14 | # 15 | # if current_node doesn't have a receiver, it returns "{{message}} {{arguments}}" 16 | # if current_node has a receiver, it returns "{{receiver}}.{{message}} {{arguments}}" 17 | def add_receiver_if_necessary(code) 18 | if node.receiver 19 | "{{receiver}}.#{code}" 20 | else 21 | code 22 | end 23 | end 24 | 25 | # Add arguments with parenthesis if necessary. 26 | # 27 | # @return [String] return (!{{arguments}}) if node.arguments present, otherwise return nothing. 28 | # 29 | # @example 30 | # 31 | # add_arguments_with_parenthesis_if_necessary 32 | # 33 | # if current_node doesn't have an argument, it returns "" 34 | # if current_node has argument, it returns "({{arguments}})" 35 | def add_arguments_with_parenthesis_if_necessary 36 | if node.arguments.size > 0 37 | '({{arguments}})' 38 | else 39 | '' 40 | end 41 | end 42 | 43 | # Add curly brackets to code if necessary. 44 | # 45 | # @param code [String] old code 46 | # @return [String] new code 47 | # 48 | # @example 49 | # 50 | # add_curly_brackets_if_necessary("{{arguments}}") 51 | def add_curly_brackets_if_necessary(code) 52 | if code.start_with?('{') && code.end_with?('}') 53 | code 54 | else 55 | "{ #{code} }" 56 | end 57 | end 58 | 59 | # Remove leading and trailing brackets. 60 | # 61 | # @param code [String] old code 62 | # @return [String] new code 63 | # 64 | # @example 65 | # 66 | # strip_brackets("(1..100)") #=> "1..100" 67 | def strip_brackets(code) 68 | code.sub(/^\((.*)\)$/) { Regexp.last_match(1) } 69 | .sub(/^\[(.*)\]$/) { Regexp.last_match(1) } 70 | .sub(/^{(.*)}$/) { 71 | Regexp.last_match(1).strip 72 | } 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/synvert/core/engine/haml.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Synvert::Core 4 | module Engine 5 | class Haml 6 | class << self 7 | include Elegant 8 | 9 | # Encode haml string, leave only ruby code, replace other haml code with whitespace. 10 | # And insert `end\n` for each if, unless, begin, case to make it a valid ruby code. 11 | # 12 | # @param source [String] haml code. 13 | # @return [String] encoded ruby code. 14 | def encode(source) 15 | leading_spaces_counts = [] 16 | new_code = [] 17 | scanner = StringScanner.new(source) 18 | loop do 19 | new_code << scanner.scan(/\s*/) 20 | leading_spaces_count = scanner.matched.size 21 | if scanner.scan('-') # it matches ruby statement " - current_user" 22 | new_code << WHITESPACE 23 | scan_ruby_statement(scanner, new_code, leading_spaces_counts, leading_spaces_count) 24 | elsif scanner.scan('=') # it matches ruby expression " = current_user.login" 25 | new_code << WHITESPACE 26 | scan_ruby_expression(scanner, new_code, leading_spaces_counts, leading_spaces_count) 27 | elsif scanner.scan(/[%#\.][a-zA-Z0-9\-_%#\.]+/) # it matches element, id and class " %span.user" 28 | new_code << (WHITESPACE * scanner.matched.size) 29 | scan_matching_wrapper(scanner, new_code, '{', '}') 30 | if scanner.scan('=') 31 | new_code << ';' 32 | scan_ruby_expression(scanner, new_code, leading_spaces_counts, leading_spaces_count) 33 | else 34 | scan_ruby_interpolation_and_plain_text(scanner, new_code, leading_spaces_counts, leading_spaces_count) 35 | end 36 | else 37 | scan_ruby_interpolation_and_plain_text(scanner, new_code, leading_spaces_counts, leading_spaces_count) 38 | end 39 | 40 | break if scanner.eos? 41 | end 42 | 43 | while leading_spaces_counts.pop 44 | new_code << END_LINE 45 | end 46 | new_code.join 47 | end 48 | 49 | private 50 | 51 | def scan_matching_wrapper(scanner, new_code, start, ending) 52 | if scanner.scan(start) # it matches attributes " %span{:class => 'user'}" 53 | new_code << start 54 | count = 1 55 | while scanner.scan(/.*?[#{Regexp.quote(start)}#{Regexp.quote(ending)}]/m) 56 | new_code << scanner.matched 57 | if scanner.matched[-1] == ending 58 | count -= 1 59 | break if count == 0 60 | else 61 | count += 1 62 | end 63 | end 64 | end 65 | end 66 | end 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /spec/synvert/core/rewriter/helper_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Synvert::Core 6 | describe Rewriter::Helper do 7 | let(:dummy_instance) { 8 | Class.new { include Rewriter::Helper } 9 | .new 10 | } 11 | let(:instance) do 12 | rewriter = Rewriter.new('foo', 'bar') 13 | Rewriter::Instance.new rewriter, 'spec/**/*_spec.rb' do 14 | end 15 | end 16 | 17 | describe 'add_receiver_if_necessary' do 18 | context 'with receiver' do 19 | let(:node) { parser_parse('User.save(false)') } 20 | 21 | it 'adds reciever' do 22 | allow(dummy_instance).to receive(:node).and_return(node) 23 | expect( 24 | dummy_instance.add_receiver_if_necessary('save(validate: false)') 25 | ).to eq '{{receiver}}.save(validate: false)' 26 | end 27 | end 28 | 29 | context 'without receiver' do 30 | let(:node) { parser_parse('save(false)') } 31 | 32 | it "doesn't add reciever" do 33 | allow(dummy_instance).to receive(:node).and_return(node) 34 | expect(dummy_instance.add_receiver_if_necessary('save(validate: false)')).to eq 'save(validate: false)' 35 | end 36 | end 37 | end 38 | 39 | describe 'add_arguments_with_parenthesis_if_necessary' do 40 | context 'with arguments' do 41 | let(:node) { parser_parse('user.save(false)') } 42 | 43 | it 'gets arguments with parenthesis' do 44 | allow(dummy_instance).to receive(:node).and_return(node) 45 | expect(dummy_instance.add_arguments_with_parenthesis_if_necessary).to eq '({{arguments}})' 46 | end 47 | end 48 | 49 | context 'without argument' do 50 | let(:node) { parser_parse('user.save') } 51 | 52 | it 'gets nothing' do 53 | allow(dummy_instance).to receive(:node).and_return(node) 54 | expect(dummy_instance.add_arguments_with_parenthesis_if_necessary).to eq '' 55 | end 56 | end 57 | end 58 | 59 | describe 'add_curly_brackets_if_necessary' do 60 | it 'add {} if code does not have' do 61 | expect(dummy_instance.add_curly_brackets_if_necessary('foo: bar')).to eq '{ foo: bar }' 62 | end 63 | 64 | it "doesn't add {} if code already has" do 65 | expect(dummy_instance.add_curly_brackets_if_necessary('{foo: bar}')).to eq '{foo: bar}' 66 | end 67 | end 68 | 69 | describe 'strip_brackets' do 70 | it 'strip ()' do 71 | expect(dummy_instance.strip_brackets('(123)')).to eq '123' 72 | end 73 | 74 | it 'strip {}' do 75 | expect(dummy_instance.strip_brackets('{123}')).to eq '123' 76 | end 77 | 78 | it 'strip []' do 79 | expect(dummy_instance.strip_brackets('[123]')).to eq '123' 80 | end 81 | 82 | it 'not strip unmatched (]' do 83 | expect(dummy_instance.strip_brackets('(123]')).to eq '(123]' 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/synvert/core.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'synvert/core/version' 4 | require 'active_support' 5 | require 'active_support/core_ext' 6 | require 'node_query' 7 | require 'node_mutation' 8 | require 'node_visitor' 9 | 10 | module Synvert 11 | module Core 12 | autoload :Configuration, 'synvert/core/configuration' 13 | autoload :Rewriter, 'synvert/core/rewriter' 14 | autoload :Helper, 'synvert/core/helper' 15 | autoload :Engine, 'synvert/core/engine' 16 | autoload :Utils, 'synvert/core/utils' 17 | autoload :Strategy, 'synvert/core/strategy' 18 | autoload :Errors, 'synvert/core/errors' 19 | end 20 | end 21 | 22 | module Synvert 23 | Rewriter = Core::Rewriter 24 | Helper = Core::Helper 25 | Strategy = Core::Strategy 26 | 27 | ALL_RUBY_FILES = %w[**/*.rb] 28 | ALL_ERB_FILES = %w[**/*.erb] 29 | ALL_HAML_FILES = %w[**/*.haml] 30 | ALL_SLIM_FILES = %w[**/*.slim] 31 | ALL_RAKE_FILES = %w[**/*.rake] 32 | ALL_FILES = ALL_RUBY_FILES + ALL_ERB_FILES + ALL_HAML_FILES + ALL_SLIM_FILES + ALL_RAKE_FILES 33 | 34 | RAILS_APP_FILES = %w[app/**/*.rb engines/*/app/**/*.rb] 35 | RAILS_CONTROLLER_FILES = %w[app/controllers/**/*.rb engines/*/app/controllers/**/*.rb] 36 | RAILS_JOB_FILES = %w[app/jobs/**/*.rb engines/*/app/jobs/**/*.rb] 37 | RAILS_OBSERVER_FILES = %w[app/observers/**/*.rb engines/*/app/observers/**/*.rb] 38 | RAILS_HELPER_FILES = %w[app/helpers/**/*.rb] 39 | RAILS_LIB_FILES = %w[lib/**/*.rb engines/*/lib/**/*.rb] 40 | RAILS_MAILER_FILES = %w[app/mailers/**/*.rb engines/*/app/mailers/**/*.rb] 41 | RAILS_MIGRATION_FILES = %w[db/migrate/**/*.rb engines/*/db/migrate/**/*.rb] 42 | RAILS_MODEL_FILES = %w[app/models/**/*.rb engines/*/app/models/**/*.rb] 43 | RAILS_ROUTE_FILES = %w[ 44 | config/routes.rb 45 | config/routes/**/*.rb 46 | engines/*/config/routes.rb 47 | engines/*/config/routes/**/*.rb 48 | ] 49 | RAILS_VIEW_FILES = ALL_ERB_FILES + ALL_HAML_FILES + ALL_SLIM_FILES 50 | 51 | RAILS_CONTROLLER_TEST_FILES = %w[ 52 | test/functional/**/*.rb 53 | test/controllers/**/*.rb 54 | engines/*/test/functional/**/*.rb 55 | engines/*/test/controllers/**/*.rb 56 | spec/functional/**/*.rb 57 | spec/controllers/**/*.rb 58 | engines/*/spec/functional/**/*.rb 59 | engines/*/spec/controllers/**/*.rb 60 | ] 61 | RAILS_INTEGRATION_TEST_FILES = %w[test/integration/**/*.rb spec/integration/**/*.rb] 62 | RAILS_MODEL_TEST_FILES = %w[ 63 | test/unit/**/*.rb 64 | engines/*/test/unit/**/*.rb 65 | test/models/**/*.rb 66 | engines/*/test/models/**/*.rb 67 | spec/models/**/*.rb 68 | engines/*/spec/models/**/*.rb 69 | ] 70 | 71 | RAILS_FACTORY_FILES = %w[test/factories/**/*.rb spec/factories/**/*.rb] 72 | RAILS_RSPEC_FILES = %w[spec/**/*.rb engines/*/spec/**/*.rb] 73 | RAILS_MINITEST_FILES = %w[test/**/*.rb engines/*/test/**/*.rb] 74 | RAILS_CUCUMBER_FILES = %w[features/**/*.rb] 75 | RAILS_TEST_FILES = RAILS_MINITEST_FILES + RAILS_RSPEC_FILES + RAILS_CUCUMBER_FILES 76 | 77 | PARSER_PARSER = 'parser' 78 | SYNTAX_TREE_PARSER = 'syntax_tree' 79 | PRISM_PARSER = 'prism' 80 | ALL_PARSERS = [PARSER_PARSER, SYNTAX_TREE_PARSER, PRISM_PARSER].freeze 81 | end 82 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | synvert-core (2.2.2) 5 | activesupport 6 | node_mutation (>= 1.24.4) 7 | node_query (>= 1.15.4) 8 | node_visitor (>= 1.1.0) 9 | parallel 10 | parser 11 | parser_node_ext (>= 1.4.2) 12 | prism 13 | prism_ext (>= 0.4.2) 14 | syntax_tree 15 | syntax_tree_ext (>= 0.9.2) 16 | 17 | GEM 18 | remote: https://rubygems.org/ 19 | specs: 20 | activesupport (7.1.3.4) 21 | base64 22 | bigdecimal 23 | concurrent-ruby (~> 1.0, >= 1.0.2) 24 | connection_pool (>= 2.2.5) 25 | drb 26 | i18n (>= 1.6, < 2) 27 | minitest (>= 5.1) 28 | mutex_m 29 | tzinfo (~> 2.0) 30 | ast (2.4.2) 31 | base64 (0.2.0) 32 | bigdecimal (3.1.9) 33 | coderay (1.1.3) 34 | concurrent-ruby (1.3.4) 35 | connection_pool (2.4.1) 36 | diff-lcs (1.5.1) 37 | docile (1.4.1) 38 | drb (2.2.1) 39 | fakefs (2.4.0) 40 | ffi (1.16.3) 41 | formatador (1.1.0) 42 | guard (2.18.1) 43 | formatador (>= 0.2.4) 44 | listen (>= 2.7, < 4.0) 45 | lumberjack (>= 1.0.12, < 2.0) 46 | nenv (~> 0.1) 47 | notiffany (~> 0.0) 48 | pry (>= 0.13.0) 49 | shellany (~> 0.0) 50 | thor (>= 0.18.1) 51 | guard-compat (1.2.1) 52 | guard-rspec (4.7.3) 53 | guard (~> 2.1) 54 | guard-compat (~> 1.1) 55 | rspec (>= 2.99.0, < 4.0) 56 | i18n (1.14.6) 57 | concurrent-ruby (~> 1.0) 58 | listen (3.9.0) 59 | rb-fsevent (~> 0.10, >= 0.10.3) 60 | rb-inotify (~> 0.9, >= 0.9.10) 61 | lumberjack (1.2.10) 62 | method_source (1.1.0) 63 | minitest (5.25.4) 64 | mutex_m (0.3.0) 65 | nenv (0.3.0) 66 | node_mutation (1.24.4) 67 | node_query (1.16.0) 68 | node_visitor (1.1.0) 69 | notiffany (0.1.3) 70 | nenv (~> 0.1) 71 | shellany (~> 0.0) 72 | parallel (1.26.3) 73 | parser (3.3.6.0) 74 | ast (~> 2.4.1) 75 | racc 76 | parser_node_ext (1.4.2) 77 | parser 78 | prettier_print (1.2.1) 79 | prism (1.3.0) 80 | prism_ext (0.4.2) 81 | prism 82 | pry (0.14.2) 83 | coderay (~> 1.1) 84 | method_source (~> 1.0) 85 | racc (1.8.1) 86 | rake (13.0.6) 87 | rb-fsevent (0.11.2) 88 | rb-inotify (0.10.1) 89 | ffi (~> 1.0) 90 | rspec (3.13.0) 91 | rspec-core (~> 3.13.0) 92 | rspec-expectations (~> 3.13.0) 93 | rspec-mocks (~> 3.13.0) 94 | rspec-core (3.13.0) 95 | rspec-support (~> 3.13.0) 96 | rspec-expectations (3.13.0) 97 | diff-lcs (>= 1.2.0, < 2.0) 98 | rspec-support (~> 3.13.0) 99 | rspec-mocks (3.13.0) 100 | diff-lcs (>= 1.2.0, < 2.0) 101 | rspec-support (~> 3.13.0) 102 | rspec-support (3.13.1) 103 | shellany (0.0.1) 104 | simplecov (0.22.0) 105 | docile (~> 1.1) 106 | simplecov-html (~> 0.11) 107 | simplecov_json_formatter (~> 0.1) 108 | simplecov-html (0.13.1) 109 | simplecov_json_formatter (0.1.4) 110 | syntax_tree (6.2.0) 111 | prettier_print (>= 1.2.0) 112 | syntax_tree_ext (0.9.2) 113 | syntax_tree 114 | thor (1.3.1) 115 | tzinfo (2.0.6) 116 | concurrent-ruby (~> 1.0) 117 | 118 | PLATFORMS 119 | ruby 120 | 121 | DEPENDENCIES 122 | activesupport (= 7.1.3.4) 123 | fakefs 124 | guard 125 | guard-rspec 126 | rake 127 | rspec 128 | rspec-mocks 129 | simplecov 130 | synvert-core! 131 | 132 | BUNDLED WITH 133 | 2.4.22 134 | -------------------------------------------------------------------------------- /spec/synvert/core/rewriter/gem_spec_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Synvert::Core 6 | describe Rewriter::GemSpec do 7 | let(:gemfile_lock_content) { <<~EOS } 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | ast (1.1.0) 12 | parser (2.1.7) 13 | ast (~> 1.1) 14 | slop (~> 3.4, >= 3.4.5) 15 | rake (10.1.1) 16 | slop (3.4.7) 17 | EOS 18 | let(:gemfile_lock_path) { File.absolute_path('./Gemfile.lock') } 19 | let(:gemfile_path) { File.absolute_path('./Gemfile') } 20 | 21 | before do 22 | Configuration.root_path = File.dirname(gemfile_lock_path) 23 | @original_bundle_gemfile = ENV['BUNDLE_GEMFILE'] 24 | ENV['BUNDLE_GEMFILE'] = gemfile_path 25 | end 26 | 27 | after do 28 | Configuration.root_path = nil 29 | ENV['BUNDLE_GEMFILE'] = @original_bundle_gemfile 30 | end 31 | 32 | def mock_gemfile_and_lock(gemfile_path, gemfile_lock_path, exists: true) 33 | if gemfile_path 34 | expect(File).to receive(:exist?).with(gemfile_path).and_return(true) 35 | else 36 | expect(File).to receive(:exist?).with(nil).and_return(false) 37 | end 38 | expect(File).to receive(:exist?).with(gemfile_lock_path).and_return(exists) 39 | expect(File).to receive(:read).with(gemfile_lock_path).and_return(gemfile_lock_content) if exists 40 | end 41 | 42 | shared_examples 'gem version matching' do |version, expected_match| 43 | it "returns #{expected_match} for version #{version}" do 44 | mock_gemfile_and_lock(gemfile_path, gemfile_lock_path) 45 | gem_spec = Rewriter::GemSpec.new('ast', version) 46 | expect(gem_spec).send(expected_match ? :to : :not_to, be_match) 47 | end 48 | end 49 | 50 | context 'when checking gem versions' do 51 | include_examples 'gem version matching', '~> 1.1', true 52 | include_examples 'gem version matching', '1.1.0', true 53 | include_examples 'gem version matching', '> 1.2.0', false 54 | end 55 | 56 | it 'returns false if gem does not exist in Gemfile.lock' do 57 | mock_gemfile_and_lock(gemfile_path, gemfile_lock_path) 58 | gem_spec = Rewriter::GemSpec.new('synvert', '1.0.0') 59 | expect(gem_spec).not_to be_match 60 | end 61 | 62 | it 'returns true if Gemfile.lock does not exist' do 63 | mock_gemfile_and_lock(gemfile_path, gemfile_lock_path, exists: false) 64 | gem_spec = Rewriter::GemSpec.new('ast', '1.1.0') 65 | expect(gem_spec).to be_match 66 | end 67 | 68 | it 'returns true if Configuration.strict is false' do 69 | Configuration.strict = false 70 | gem_spec = Rewriter::GemSpec.new('synvert', '1.0.0') 71 | expect(gem_spec).to be_match 72 | Configuration.strict = true 73 | end 74 | 75 | describe 'gemfile lock name behavior' do 76 | it 'uses default Gemfile.lock when BUNDLE_GEMFILE is not set' do 77 | ENV['BUNDLE_GEMFILE'] = nil 78 | mock_gemfile_and_lock(nil, gemfile_lock_path) 79 | gem_spec = Rewriter::GemSpec.new('ast', '1.1.0') 80 | expect(gem_spec).to be_match 81 | end 82 | 83 | it 'uses custom Gemfile lock name when BUNDLE_GEMFILE is set' do 84 | custom_gemfile = File.absolute_path('./Gemfile.next') 85 | custom_gemfile_lock_path = File.absolute_path('./Gemfile.next.lock') 86 | ENV['BUNDLE_GEMFILE'] = custom_gemfile 87 | mock_gemfile_and_lock(custom_gemfile, custom_gemfile_lock_path) 88 | gem_spec = Rewriter::GemSpec.new('ast', '1.1.0') 89 | expect(gem_spec).to be_match 90 | end 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /lib/synvert/core/engine/slim.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Synvert::Core 4 | module Engine 5 | class Slim 6 | class << self 7 | include Elegant 8 | 9 | ATTRIBUTES_PAIR = { '{' => '}', '[' => ']', '(' => ')' } 10 | 11 | # Encode haml string, leave only ruby code, replace other haml code with whitespace. 12 | # And insert `end\n` for each if, unless, begin, case to make it a valid ruby code. 13 | # 14 | # @param source [String] haml code. 15 | # @return [String] encoded ruby code. 16 | def encode(source) 17 | leading_spaces_counts = [] 18 | new_code = [] 19 | scanner = StringScanner.new(source) 20 | loop do 21 | new_code << scanner.scan(/\s*/) 22 | leading_spaces_count = scanner.matched.size 23 | if scanner.scan('-') # it matches ruby statement " - current_user" 24 | new_code << WHITESPACE 25 | scan_ruby_statement(scanner, new_code, leading_spaces_counts, leading_spaces_count) 26 | elsif scanner.scan(/==?/) # it matches ruby expression " = current_user.login" 27 | new_code << (WHITESPACE * scanner.matched.size) 28 | scan_ruby_expression(scanner, new_code, leading_spaces_counts, leading_spaces_count) 29 | elsif scanner.scan(/[a-z#\.][a-zA-Z0-9\-_%#\.]*/) # it matches element, id and class " span.user" 30 | new_code << (WHITESPACE * scanner.matched.size) 31 | ATTRIBUTES_PAIR.each do |start, ending| # it matches attributes in brackets " span[ class='user' ]" 32 | scan_matching_wrapper(scanner, new_code, start, ending) 33 | end 34 | scan_attributes_between_whitespace(scanner, new_code) 35 | if scanner.scan(/ ?==?/) # it matches ruby expression " span= current_user.login" 36 | new_code << ((WHITESPACE * (scanner.matched.size - 1)) + ';') 37 | scan_ruby_expression(scanner, new_code, leading_spaces_counts, leading_spaces_count) 38 | else 39 | scan_ruby_interpolation_and_plain_text(scanner, new_code, leading_spaces_counts, leading_spaces_count) 40 | end 41 | else 42 | scan_ruby_interpolation_and_plain_text(scanner, new_code, leading_spaces_counts, leading_spaces_count) 43 | end 44 | 45 | break if scanner.eos? 46 | end 47 | 48 | while leading_spaces_counts.pop 49 | new_code << END_LINE 50 | end 51 | new_code.join 52 | end 53 | 54 | private 55 | 56 | def scan_matching_wrapper(scanner, new_code, start, ending) 57 | if scanner.scan(start) # it matches attributes " %span[ class='user' ]" 58 | new_code << WHITESPACE 59 | count = 1 60 | while scanner.scan(/.*?[#{Regexp.quote(start)}#{Regexp.quote(ending)}]/m) 61 | matched = scanner.matched.gsub(/(\A| ).*?=/) { |key| WHITESPACE * key.size } 62 | if scanner.matched[-1] == ending 63 | new_code << (matched[0..-2] + ';') 64 | count -= 1 65 | break if count == 0 66 | else 67 | new_code << matched 68 | count += 1 69 | end 70 | end 71 | end 72 | end 73 | 74 | def scan_attributes_between_whitespace(scanner, new_code) 75 | while scanner.scan(/ ?[\w\-_]+==?/) # it matches attributes split by space " span class='user'" 76 | new_code << (WHITESPACE * scanner.matched.size) 77 | stack = [] 78 | while scanner.scan(/(.*?['"\(\)\[\]\{\}]|.+?\b)/) 79 | matched = scanner.matched 80 | new_code << matched 81 | if ['(', '[', '{'].include?(matched[-1]) 82 | stack << matched[-1] 83 | elsif [')', ']', '}'].include?(matched[-1]) 84 | stack.pop 85 | elsif %w[' "].include?(matched[-1]) 86 | stack.last == matched[-1] ? stack.pop : stack << matched[-1] 87 | end 88 | break if stack.empty? 89 | end 90 | if scanner.scan(' ') 91 | new_code << ';' 92 | end 93 | end 94 | end 95 | end 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /lib/synvert/core/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Synvert::Core 4 | # Synvert global configuration. 5 | class Configuration 6 | class << self 7 | # @!attribute [w] root_path 8 | # @!attribute [w] skip_paths 9 | # @!attribute [w] only_paths 10 | # @!attribute [w] respect_gitignore 11 | # @!attribute [w] show_run_process 12 | # @!attribute [w] number_of_workers 13 | # @!attribute [w] single_quote 14 | # @!attribute [w] tab_width 15 | # @!attribute [w] strict, if strict is false, it will ignore ruby version and gem version check. 16 | # @!attribute [w] test_result, default is 'actions', it can be 'actions' or 'new_source'. 17 | attr_writer :root_path, 18 | :skip_paths, 19 | :only_paths, 20 | :respect_gitignore, 21 | :show_run_process, 22 | :number_of_workers, 23 | :single_quote, 24 | :tab_width, 25 | :strict, 26 | :test_result 27 | 28 | # Get the path. 29 | # 30 | # @return [String] default is '.' 31 | def root_path 32 | @root_path || '.' 33 | end 34 | 35 | # Get a list of skip paths. 36 | # 37 | # @return [Array] default is []. 38 | def skip_paths 39 | @skip_paths || [] 40 | end 41 | 42 | # Get a list of only paths. 43 | # 44 | # @return [Array] default is []. 45 | def only_paths 46 | @only_paths || [] 47 | end 48 | 49 | # Check if respect .gitignore 50 | # 51 | # @return [Boolean] default is true 52 | def respect_gitignore 53 | @respect_gitignore.nil? ? true : @respect_gitignore 54 | end 55 | 56 | # Check if show run process. 57 | # 58 | # @return [Boolean] default is false 59 | def show_run_process 60 | @show_run_process || false 61 | end 62 | 63 | # Number of workers 64 | # 65 | # @return [Integer] default is 1 66 | def number_of_workers 67 | @number_of_workers || 1 68 | end 69 | 70 | # Use single quote or double quote. 71 | # 72 | # @return [Boolean] true if use single quote, default is true 73 | def single_quote 74 | @single_quote.nil? ? true : @single_quote 75 | end 76 | 77 | # Returns the tab width used for indentation. 78 | # 79 | # If the tab width is not explicitly set, it defaults to 2. 80 | # 81 | # @return [Integer] The tab width. 82 | def tab_width 83 | @tab_width || 2 84 | end 85 | 86 | # Returns the value of the strict flag. 87 | # 88 | # If the strict flag is not set, it returns true by default. 89 | # 90 | # @return [Boolean] the value of the strict flag 91 | def strict 92 | @strict.nil? ? true : @strict 93 | end 94 | 95 | # Returns the value of the test_result flag. 96 | # 97 | # If the test_result flag is not set, it returns 'actions' by default. 98 | # 99 | # @return [String] the value of the test_result flag 100 | def test_result 101 | @test_result || 'actions' 102 | end 103 | 104 | # Temporarily sets the specified configurations, executes the given block, and then restores the original configurations. 105 | # 106 | # @param configurations [Hash] The configurations to be set temporarily. 107 | # @yield The block of code to be executed. 108 | # 109 | # @example 110 | # with_temporary_configurations({ number_of_workers: 1 }) do 111 | # # Code to be executed with temporary configurations 112 | # end 113 | def with_temporary_configurations(configurations, &block) 114 | old_instance_variables = 115 | instance_variables.reduce({}) do |hash, var| 116 | hash[var] = instance_variable_get(var) 117 | hash 118 | end 119 | 120 | configurations.each do |variable, value| 121 | instance_variable_set("@#{variable}", value) 122 | end 123 | 124 | block.call 125 | 126 | old_instance_variables.each do |var, value| 127 | instance_variable_set(var, value) 128 | end 129 | end 130 | end 131 | end 132 | end 133 | -------------------------------------------------------------------------------- /lib/synvert/core/utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'net/http' 4 | require 'uri' 5 | require 'open-uri' 6 | require 'open3' 7 | 8 | module Synvert::Core 9 | class Utils 10 | class << self 11 | def eval_snippet(snippet_name) 12 | eval(load_snippet(snippet_name), binding, "(eval #{snippet_name})") 13 | end 14 | 15 | def load_snippet(snippet_name) 16 | if is_valid_url?(snippet_name) 17 | uri = URI.parse(format_url(snippet_name)) 18 | return uri.open.read if remote_snippet_exists?(uri) 19 | 20 | raise Errors::SnippetNotFound.new("#{snippet_name} not found") 21 | elsif is_valid_file?(snippet_name) 22 | return File.read(snippet_name, encoding: 'UTF-8') 23 | else 24 | snippet_path = snippet_expand_path(snippet_name) 25 | return File.read(snippet_path, encoding: 'UTF-8') if File.exist?(snippet_path) 26 | 27 | snippet_uri = URI.parse(format_url(remote_snippet_url(snippet_name))) 28 | return snippet_uri.open.read if remote_snippet_exists?(snippet_uri) 29 | 30 | raise Errors::SnippetNotFound.new("#{snippet_name} not found") 31 | end 32 | end 33 | 34 | # Glob file paths. 35 | # @param file_patterns [Array] file patterns 36 | # @return [Array] file paths 37 | def glob(file_patterns) 38 | Dir.chdir(Configuration.root_path) do 39 | all_files = file_patterns.flat_map { |pattern| Dir.glob(pattern) } 40 | ignored_files = [] 41 | 42 | if Configuration.respect_gitignore 43 | Open3.popen3('git check-ignore --stdin') do |stdin, stdout, _stderr, _wait_thr| 44 | stdin.puts(all_files.join("\n")) 45 | stdin.close 46 | 47 | ignored_files = stdout.read.split("\n") 48 | end 49 | end 50 | 51 | filtered_files = filter_only_paths(all_files - ignored_files) 52 | 53 | filtered_files -= get_explicitly_skipped_files 54 | end 55 | end 56 | 57 | private 58 | 59 | def is_valid_url?(url) 60 | /^http/.match?(url) 61 | end 62 | 63 | def is_valid_file?(path) 64 | File.exist?(path) 65 | end 66 | 67 | def remote_snippet_exists?(uri) 68 | req = Net::HTTP.new(uri.host, uri.port) 69 | req.use_ssl = uri.scheme == 'https' 70 | res = req.request_head(uri.path) 71 | res.code == "200" 72 | end 73 | 74 | def snippet_expand_path(snippet_name) 75 | File.join(default_snippets_home(), 'lib', "#{snippet_name}.rb") 76 | end 77 | 78 | def default_snippets_home 79 | ENV['SYNVERT_SNIPPETS_HOME'] || File.join(ENV['HOME'], '.synvert-ruby') 80 | end 81 | 82 | def remote_snippet_url(snippet_name) 83 | "https://github.com/synvert-hq/synvert-snippets-ruby/blob/main/lib/#{snippet_name}.rb" 84 | end 85 | 86 | def format_url(url) 87 | convert_to_github_raw_url(url) 88 | end 89 | 90 | def convert_to_github_raw_url(url) 91 | if url.starts_with?('https://github.com') 92 | return url.sub('//github.com/', '//raw.githubusercontent.com/').sub('/blob/', '/') 93 | end 94 | 95 | if url.starts_with?('https://gist.github.com') 96 | return url.sub('gist.github.com/', 'gist.githubusercontent.com/') + '/raw' 97 | end 98 | 99 | url 100 | end 101 | 102 | # Filter only paths with `Configuration.only_paths`. 103 | # @return [Array] filtered file paths 104 | def filter_only_paths(all_files) 105 | return all_files if Configuration.only_paths.empty? 106 | 107 | Configuration.only_paths.flat_map do |only_path| 108 | all_files.filter { |file_path| file_path.starts_with?(only_path) } 109 | end 110 | end 111 | 112 | # Get skipped files. 113 | # @return [Array] skipped files 114 | def get_explicitly_skipped_files 115 | Configuration.skip_paths.flat_map do |skip_path| 116 | if File.directory?(skip_path) 117 | Dir.glob(File.join(skip_path, "**/*")) 118 | elsif File.file?(skip_path) 119 | [skip_path] 120 | elsif skip_path.end_with?("**") || skip_path.end_with?("**/") 121 | Dir.glob(File.join(skip_path, "*")) 122 | else 123 | Dir.glob(skip_path) 124 | end 125 | end 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /spec/synvert/core/rewriter/scope/within_scope_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Synvert::Core 6 | describe Rewriter::WithinScope do 7 | let(:instance) { 8 | rewriter = Rewriter.new('foo', 'bar') { configure(parser: PARSER_PARSER) } 9 | Rewriter::Instance.new(rewriter, 'file pattern') 10 | } 11 | let(:source) { <<~EOS } 12 | describe User do 13 | describe 'create' do 14 | it 'creates user' do 15 | FactoryGirl.create :user 16 | end 17 | end 18 | end 19 | EOS 20 | 21 | let(:node) { Parser::CurrentRuby.parse(source) } 22 | 23 | before { instance.current_node = node } 24 | 25 | describe '#process' do 26 | context 'rules' do 27 | it 'not call block if no matching node' do 28 | run = false 29 | scope = 30 | Rewriter::WithinScope.new instance, type: 'send', message: 'missing' do 31 | run = true 32 | end 33 | scope.process 34 | expect(run).to be_falsey 35 | end 36 | 37 | it 'call block if there is matching node' do 38 | run = false 39 | type_in_scope = nil 40 | scope = 41 | Rewriter::WithinScope.new instance, 42 | type: 'send', 43 | receiver: 'FactoryGirl', 44 | message: 'create', 45 | arguments: [':user'] do 46 | run = true 47 | type_in_scope = node.type 48 | end 49 | scope.process 50 | expect(run).to be_truthy 51 | expect(type_in_scope).to eq :send 52 | expect(instance.current_node.type).to eq :block 53 | end 54 | 55 | it 'matches multiple block nodes' do 56 | block_nodes = [] 57 | scope = 58 | Rewriter::WithinScope.new(instance, { type: 'block' }) do 59 | block_nodes << node 60 | end 61 | scope.process 62 | expect(block_nodes.size).to eq 3 63 | end 64 | 65 | it 'matches only 2 block nodes if including_self is false' do 66 | block_nodes = [] 67 | scope = 68 | Rewriter::WithinScope.new(instance, { type: 'block' }, { including_self: false }) do 69 | block_nodes << node 70 | end 71 | scope.process 72 | expect(block_nodes.size).to eq 2 73 | end 74 | 75 | it 'matches only one block node if recursive is false' do 76 | block_nodes = [] 77 | scope = 78 | Rewriter::WithinScope.new(instance, { type: 'block' }, { recursive: false }) do 79 | block_nodes << node 80 | end 81 | scope.process 82 | expect(block_nodes.size).to eq 1 83 | end 84 | 85 | it 'matches only one block node if stop_at_first_match is true' do 86 | block_nodes = [] 87 | scope = 88 | Rewriter::WithinScope.new(instance, { type: 'block' }, { stop_at_first_match: true }) do 89 | block_nodes << node 90 | end 91 | scope.process 92 | expect(block_nodes.size).to eq 1 93 | end 94 | end 95 | 96 | context 'nql' do 97 | it 'not call block if no matching node' do 98 | run = false 99 | scope = 100 | described_class.new instance, '.send[message=missing]' do 101 | run = true 102 | end 103 | scope.process 104 | expect(run).to be_falsey 105 | end 106 | 107 | it 'call block if there is matching node' do 108 | run = false 109 | type_in_scope = nil 110 | scope = 111 | described_class.new instance, '.send[receiver=FactoryGirl][message=create][arguments=(:user)]' do 112 | run = true 113 | type_in_scope = node.type 114 | end 115 | scope.process 116 | expect(run).to be_truthy 117 | expect(type_in_scope).to eq :send 118 | expect(instance.current_node.type).to eq :block 119 | end 120 | 121 | it 'matches multiple block nodes' do 122 | block_nodes = [] 123 | scope = 124 | described_class.new(instance, '.block') do 125 | block_nodes << node 126 | end 127 | scope.process 128 | expect(block_nodes.size).to eq 3 129 | end 130 | 131 | it 'raises InvalidOperatorError' do 132 | scope = described_class.new(instance, '.send[receiver IN FactoryGirl]') {} 133 | expect { 134 | scope.process 135 | }.to raise_error(NodeQuery::Compiler::InvalidOperatorError) 136 | end 137 | end 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /lib/synvert/core/engine/elegant.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Synvert::Core 4 | module Engine 5 | # Engine::Elegant provides some helper methods for engines 6 | # who read block code without end. 7 | # For those engines, it will try to insert `end\n` when encode, 8 | # while delete `end\n` when generate_transform_proc. 9 | module Elegant 10 | END_LINE = "end\n" 11 | WHITESPACE = ' ' 12 | DO_BLOCK_REGEX = / do(\s|\z|\n)/ 13 | 14 | IF_KEYWORDS = %w[if unless begin case for while until] 15 | ELSE_KEYWORDS = %w[else elsif when in rescue ensure] 16 | 17 | # Generate transform proc, it's used to adjust start and end position of actions. 18 | # Due to the fact that we insert `end\n` when encode the source code, we need to adjust 19 | # start and end position of actions to match the original source code. 20 | # e.g. if end\n exists in position 10, action start position is 20 and end position is 30, 21 | # then action start position should be 16 and end position should be 26. 22 | # 23 | # @param encoded_source [String] encoded source. 24 | # @return [Proc] transform proc. 25 | def generate_transform_proc(encoded_source) 26 | proc do |actions| 27 | start = 0 28 | indices = [] 29 | loop do 30 | index = encoded_source[start..-1].index(END_LINE) 31 | break unless index 32 | 33 | indices << (start + index) 34 | start += index + END_LINE.length 35 | end 36 | indices.each do |index| 37 | NodeMutation::Helper.iterate_actions(actions) do |action| 38 | action.start -= END_LINE.length if action.start > index 39 | action.end -= END_LINE.length if action.end > index 40 | end 41 | end 42 | end 43 | end 44 | 45 | # Check if the current leading_spaces_count is less than or equal to the last leading_spaces_count in leading_spaces_counts. 46 | # If so, pop the last leading_spaces_count and return true. 47 | def insert_end?(leading_spaces_counts, current_leading_spaces_count, equal = true) 48 | operation = equal ? :<= : :< 49 | if !leading_spaces_counts.empty? && current_leading_spaces_count.send(operation, leading_spaces_counts.last) 50 | leading_spaces_counts.pop 51 | true 52 | else 53 | false 54 | end 55 | end 56 | 57 | def scan_ruby_statement(scanner, new_code, leading_spaces_counts, leading_spaces_count) 58 | new_code << scanner.scan(/\s*/) 59 | keyword = scanner.scan(/\w+/) 60 | rest = scanner.scan(/.*?(\z|\n)/) 61 | if insert_end?(leading_spaces_counts, leading_spaces_count, !ELSE_KEYWORDS.include?(keyword)) 62 | new_code << END_LINE 63 | end 64 | if IF_KEYWORDS.include?(keyword) || rest =~ DO_BLOCK_REGEX 65 | leading_spaces_counts << leading_spaces_count 66 | end 67 | new_code << keyword 68 | new_code << rest 69 | 70 | while rest.rstrip.end_with?(',') 71 | rest = scanner.scan(/.*?(\z|\n)/) 72 | if IF_KEYWORDS.include?(keyword) || rest =~ DO_BLOCK_REGEX 73 | leading_spaces_counts << leading_spaces_count 74 | end 75 | new_code << rest 76 | end 77 | end 78 | 79 | def scan_ruby_expression(scanner, new_code, leading_spaces_counts, leading_spaces_count) 80 | if insert_end?(leading_spaces_counts, leading_spaces_count) 81 | new_code << END_LINE 82 | end 83 | rest = scanner.scan(/.*?(\z|\n)/) 84 | if rest =~ DO_BLOCK_REGEX 85 | leading_spaces_counts << leading_spaces_count 86 | end 87 | new_code << rest 88 | 89 | while rest.rstrip.end_with?(',') 90 | rest = scanner.scan(/.*?(\z|\n)/) 91 | if rest =~ DO_BLOCK_REGEX 92 | leading_spaces_counts << leading_spaces_count 93 | end 94 | new_code << rest 95 | end 96 | end 97 | 98 | def scan_ruby_interpolation_and_plain_text(scanner, new_code, leading_spaces_counts, leading_spaces_count) 99 | if insert_end?(leading_spaces_counts, leading_spaces_count) 100 | new_code << END_LINE 101 | end 102 | while scanner.scan(/(.*?)(\\*)#\{/) # it matches interpolation " #{current_user.login}" 103 | new_code << (WHITESPACE * scanner.matched.size) 104 | unless scanner.matched[-3] == '\\' 105 | count = 1 106 | while scanner.scan(/.*?([\{\}])/) 107 | if scanner.matched[-1] == '}' 108 | count -= 1 109 | if count == 0 110 | new_code << (scanner.matched[0..-2] + ';') 111 | break 112 | else 113 | new_code << scanner.matched 114 | end 115 | else 116 | count += 1 117 | new_code << scanner.matched 118 | end 119 | end 120 | end 121 | end 122 | if scanner.scan(/.*?\z/) 123 | new_code << (WHITESPACE * scanner.matched.size) 124 | end 125 | if scanner.scan(/.*?\n/) 126 | new_code << ((WHITESPACE * (scanner.matched.size - 1)) + "\n") 127 | end 128 | end 129 | end 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /spec/synvert/core/utils_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | module Synvert::Core 4 | RSpec.describe Utils do 5 | describe '.eval_snippet' do 6 | context "by http url" do 7 | it 'evals snippet' do 8 | expect(described_class).to receive(:remote_snippet_exists?).with(URI.parse('http://example.com/rewriter.rb')).and_return(true) 9 | expect_any_instance_of(URI::HTTP).to receive(:open).and_return(StringIO.new("Rewriter.new 'group', 'name' do\nend")) 10 | rewriter = described_class.eval_snippet('http://example.com/rewriter.rb') 11 | expect(rewriter.group).to eq 'group' 12 | expect(rewriter.name).to eq 'name' 13 | end 14 | 15 | it 'evals github url' do 16 | expect(described_class).to receive(:remote_snippet_exists?).with(URI.parse('https://raw.githubusercontent.com/synvert-hq/synvert-snippets-ruby/main/lib/bundler/use_shortcut_git_source.rb')).and_return(true) 17 | expect_any_instance_of(URI::HTTP).to receive(:open).and_return(StringIO.new("Rewriter.new 'group', 'name' do\nend")) 18 | rewriter = described_class.eval_snippet('https://github.com/synvert-hq/synvert-snippets-ruby/blob/main/lib/bundler/use_shortcut_git_source.rb') 19 | expect(rewriter.group).to eq 'group' 20 | expect(rewriter.name).to eq 'name' 21 | end 22 | 23 | it 'evals gist url' do 24 | expect(described_class).to receive(:remote_snippet_exists?).with(URI.parse('https://gist.githubusercontent.com/flyerhzm/6b868cf6cceba0e2fa253f1936acf1f6/raw')).and_return(true) 25 | expect_any_instance_of(URI::HTTP).to receive(:open).and_return(StringIO.new("Rewriter.new 'group', 'name' do\nend")) 26 | rewriter = described_class.eval_snippet('https://gist.github.com/flyerhzm/6b868cf6cceba0e2fa253f1936acf1f6') 27 | expect(rewriter.group).to eq 'group' 28 | expect(rewriter.name).to eq 'name' 29 | end 30 | 31 | it 'raises error' do 32 | expect(described_class).to receive(:remote_snippet_exists?).and_return(false) 33 | expect do 34 | described_class.eval_snippet('http://example.com/rewriter.rb') 35 | end.to raise_error(Errors::SnippetNotFound) 36 | end 37 | end 38 | 39 | context 'by file path' do 40 | it 'evals snippet' do 41 | expect(File).to receive(:exist?).and_return(true) 42 | expect(File).to receive(:read).and_return("Rewriter.new 'group', 'name' do\nend") 43 | rewriter = described_class.eval_snippet('/home/richard/foo/bar.rb') 44 | expect(rewriter.group).to eq 'group' 45 | expect(rewriter.name).to eq 'name' 46 | end 47 | end 48 | 49 | context 'by snippet name' do 50 | it 'evals snippet as file' do 51 | expect(File).to receive(:exist?).with("group/name").and_return(false) 52 | expect(described_class).to receive(:default_snippets_home).and_return('/home/richard/.synvert-ruby') 53 | expect(File).to receive(:exist?).with("/home/richard/.synvert-ruby/lib/group/name.rb").and_return(true) 54 | expect(File).to receive(:read).and_return("Rewriter.new 'group', 'name' do\nend") 55 | rewriter = described_class.eval_snippet('group/name') 56 | expect(rewriter.group).to eq 'group' 57 | expect(rewriter.name).to eq 'name' 58 | end 59 | 60 | it 'evals snippet as github url' do 61 | expect(File).to receive(:exist?).with("group/name").and_return(false) 62 | expect(described_class).to receive(:default_snippets_home).and_return('/home/richard/.synvert-ruby') 63 | expect(File).to receive(:exist?).with("/home/richard/.synvert-ruby/lib/group/name.rb").and_return(false) 64 | expect(described_class).to receive(:remote_snippet_exists?).with(URI.parse("https://raw.githubusercontent.com/synvert-hq/synvert-snippets-ruby/main/lib/group/name.rb")).and_return(true) 65 | expect_any_instance_of(URI::HTTP).to receive(:open).and_return(StringIO.new("Rewriter.new 'group', 'name' do\nend")) 66 | rewriter = described_class.eval_snippet('group/name') 67 | expect(rewriter.group).to eq 'group' 68 | expect(rewriter.name).to eq 'name' 69 | end 70 | end 71 | end 72 | 73 | describe '.glob' do 74 | before do 75 | Configuration.only_paths = [] 76 | Configuration.skip_paths = [] 77 | Configuration.respect_gitignore = false 78 | end 79 | 80 | context 'Configuration.respect_gitignore is false' do 81 | it 'gets all files' do 82 | expect(Dir).to receive(:glob).with('**/*.rb').and_return( 83 | [ 84 | 'app/models/post.rb', 85 | 'app/controllers/posts_controller.rb' 86 | ] 87 | ) 88 | expect(described_class.glob(['**/*.rb'])).to eq(['app/models/post.rb', 'app/controllers/posts_controller.rb']) 89 | end 90 | end 91 | 92 | context 'Configuration.respect_gitignore is true' do 93 | before do 94 | Configuration.respect_gitignore = true 95 | end 96 | 97 | it 'correctly filters out ignored files' do 98 | file_patterns = ["**/*.rb"] 99 | all_files = ["app/models/post.rb", "app/controllers/posts_controller.rb", "app/controllers/temp.tmp"] 100 | not_ignored_files = ["app/models/post.rb", "app/controllers/posts_controller.rb"] 101 | command_output = "app/controllers/temp.tmp\n" 102 | 103 | allow(Open3).to receive(:popen3).with('git check-ignore --stdin').and_yield( 104 | instance_double(IO, puts: nil, close: nil), 105 | StringIO.new(command_output), 106 | StringIO.new(''), 107 | instance_double(Process::Waiter, value: instance_double(Process::Status, success?: true)) 108 | ) 109 | allow(Dir).to receive(:glob).and_return(all_files) 110 | 111 | expect(described_class.glob(file_patterns)).to match_array(not_ignored_files) 112 | end 113 | end 114 | 115 | it 'filters only paths' do 116 | Configuration.only_paths = ['app/models'] 117 | expect(Dir).to receive(:glob).with('**/*.rb').and_return( 118 | [ 119 | 'app/models/post.rb', 120 | 'app/controllers/posts_controller.rb' 121 | ] 122 | ) 123 | expect(described_class.glob(['**/*.rb'])).to eq(['app/models/post.rb']) 124 | end 125 | 126 | it 'skip files' do 127 | Configuration.skip_paths = ['app/controllers/**/*'] 128 | expect(Dir).to receive(:glob).with('**/*.rb').and_return( 129 | [ 130 | 'app/models/post.rb', 131 | 'app/controllers/posts_controller.rb' 132 | ] 133 | ) 134 | expect(Dir).to receive(:glob).with('app/controllers/**/*').and_return(['app/controllers/posts_controller.rb']) 135 | expect(described_class.glob(['**/*.rb'])).to eq(['app/models/post.rb']) 136 | end 137 | end 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # synvert-core-ruby 2 | 3 | logo 4 | 5 | [![AwesomeCode Status for synvert-hq/synvert-core-ruby](https://awesomecode.io/projects/033f7f02-7b22-41c3-a902-fca37f1ec72a/status)](https://awesomecode.io/repos/synvert-hq/synvert-core-ruby) 6 | [![Build Status](https://github.com/synvert-hq/synvert-core-ruby/actions/workflows/main.yml/badge.svg)](https://github.com/synvert-hq/synvert-core-ruby/actions/workflows/main.yml) 7 | [![Gem Version](https://img.shields.io/gem/v/synvert-core.svg)](https://rubygems.org/gems/synvert-core) 8 | 9 | Synvert core provides a set of DSLs to rewrite (find and replace) ruby code. e.g. 10 | 11 | ```ruby 12 | Synvert::Rewriter.new 'ruby', 'map_and_flatten_to_flat_map' do 13 | configure(parser: Synvert::PARSER_PARSER) 14 | 15 | description <<~EOS 16 | It converts `map` and `flatten` to `flat_map` 17 | 18 | ```ruby 19 | enum.map do 20 | # do something 21 | end.flatten 22 | ``` 23 | 24 | => 25 | 26 | ```ruby 27 | enum.flat_map do 28 | # do something 29 | end 30 | ``` 31 | EOS 32 | 33 | within_files Synvert::ALL_RUBY_FILES + Synvert::ALL_RAKE_FILES do 34 | find_node '.send [receiver=.block [caller=.send[message=map]]] [message=flatten] [arguments.size=0]' do 35 | group do 36 | delete :message, :dot 37 | replace 'receiver.caller.message', with: 'flat_map' 38 | end 39 | end 40 | end 41 | end 42 | ``` 43 | 44 | It also supports to add callbacks to visit ast nodes. 45 | 46 | ```ruby 47 | Synvert::Helper.new 'ruby/parse' do |options| 48 | configure(parser: Synvert::PRISM_PARSER) 49 | 50 | with_configurations(number_of_workers: 1) do 51 | class_names = [] 52 | within_file Synvert::ALL_RUBY_FILES do 53 | add_callback :class_node, at: 'start' do |node| 54 | class_names << node.name.to_source 55 | end 56 | end 57 | # class_names is an array of class names 58 | end 59 | end 60 | 61 | ``` 62 | 63 | Want to see more examples, check out [synvert-snippets-ruby](https://github.com/synvert-hq/synvert-snippets-ruby). 64 | 65 | Want to use the CLI, check out [synvert-ruby](https://github.com/synvert-hq/synvert-ruby). 66 | 67 | DSLs are as follows 68 | 69 | * [configure](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter.html#configure-instance_method) - configure the rewriter 70 | * [description](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter.html#description-instance_method) - describe what the rewriter does 71 | * [if_ruby](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter.html#if_ruby-instance_method) - check if ruby version is greater than or equal to the specified ruby version 72 | * [if_gem](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter.html#if_gem-instance_method) - compare version of specified gem 73 | * [within_files](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter.html#within_files-instance_method) - find specified files 74 | * [within_file](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter.html#within_file-instance_method) - alias to within_files 75 | * [add_file](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter.html#add_file-instance_method) - add a new file 76 | * [remove_file](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter.html#remove_file-instance_method) - remove a file 77 | * [helper_method](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter.html#helper_method-instance_method) - define a helper method 78 | * [add_snippet](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter.html#add_snippet-instance_method) - call another rewriter 79 | * [call_helper](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter.html#call_helper-instance_method) - call a shared rewriter 80 | * [with_configurations](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter.html#with_configurations-instance_method) - execute a block of code with temporary configurations 81 | 82 | Scopes: 83 | 84 | * [within_node](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#within_node-instance_method) - recursively find matching ast nodes 85 | * [with_node](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#with_node-instance_method) - alias to within_node 86 | * [find_node](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#find_node-instance_method) - alias to within_node 87 | * [goto_node](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#goto_node-instance_method) - go to a child node 88 | 89 | Conditions: 90 | 91 | * [if_exist_node](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#if_exist_node-instance_method) - check if matching node exist in the child nodes 92 | * [unless_exist_node](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#unless_exist_node-instance_method) - check if matching node doesn't exist in the child nodes 93 | 94 | Actions: 95 | 96 | * [append](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#append-instance_method) - append the code to the bottom of current node body 97 | * [prepend](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#prepend-instance_method) - prepend the code to the bottom of current node body 98 | * [insert](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#insert-instance_method) - insert code 99 | * [insert_after](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#insert_after-instance_method) - insert the code next to the current node 100 | * [insert_before](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#insert_before-instance_method) - insert the code previous to the current node 101 | * [replace](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#replace-instance_method) - replace the code of specified child nodes 102 | * [delete](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#delete-instance_method) - delete the code in specified child nodes 103 | * [remove](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#remove-instance_method) - remove the whole code of current node 104 | * [wrap](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#wrap-instance_method) - wrap the current node with prefix and suffix code 105 | * [replace_with](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#replace_with-instance_method) - replace the whole code of current node 106 | * [warn](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#warn-instance_method) - warn message 107 | * [replace_erb_stmt_with_expr](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#replace_erb_stmt_with_expr-instance_method) - replace erb stmt code to expr code 108 | * [noop](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#noop-instance_method) - no operation 109 | * [group](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#group-instance_method) - group actions 110 | * [add_action](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#add_action-instance_method) - add custom action 111 | 112 | Callbacks: 113 | 114 | * [add_callback](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#add_callback-instance_method) - add callback when visiting ast nodes 115 | 116 | Others: 117 | 118 | * [wrap_with_quotes](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#wrap_with_quotes-instance_method) - wrap string code with single or double quotes 119 | * [add_leading_spaces](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#add_leading_spaces-instance_method) - add leading spaces before the string code 120 | 121 | 122 | Attributes: 123 | 124 | * [file_path](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#file_path-instance_method) - current file path 125 | * [node](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#node-instance_method) - current ast node 126 | * [mutation_adapter](https://synvert-hq.github.io/synvert-core-ruby/Synvert/Core/Rewriter/Instance.html#mutation_adapter-instance_method) - [mutation adapter](https://synvert-hq.github.io/node-mutation-ruby/NodeMutation/Adapter.html) to get some helper methods 127 | -------------------------------------------------------------------------------- /spec/synvert/core/engine/slim_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Synvert::Core 6 | describe Engine::Slim do 7 | describe '#encode' do 8 | it 'encodes source' do 9 | source = <<~EOS 10 | doctype html 11 | html 12 | head 13 | title Slim Examples 14 | meta name="keywords" content="template language" 15 | meta name="author" content=author 16 | javascript: 17 | alert('Slim supports embedded javascript!') 18 | 19 | body 20 | h1 Markup examples 21 | 22 | #content 23 | p This example shows you what a basic Slim file looks like. 24 | 25 | == yield 26 | 27 | - unless items.empty? 28 | table 29 | - items.each do |item| 30 | tr 31 | td.name = item.name 32 | td.price = item.price 33 | - else 34 | p 35 | | No items found. Please add some inventory. 36 | Thank you! 37 | 38 | div id="footer" 39 | = render 'footer' 40 | | Copyright © \#{year} \#{author} 41 | EOS 42 | encoded_source = Engine::Slim.encode(source) 43 | expect(encoded_source).to be_include 'yield' 44 | expect(encoded_source).to be_include 'unless items.empty?' 45 | expect(encoded_source).to be_include 'items.each do |item|' 46 | expect(encoded_source).to be_include 'item.name' 47 | expect(encoded_source).to be_include 'item.price' 48 | expect(encoded_source).to be_include "render 'footer'" 49 | expect(encoded_source).to be_include 'year' 50 | expect(encoded_source).to be_include 'author' 51 | expect(encoded_source.scan("end\n").length).to eq 2 52 | expect(encoded_source).not_to be_include 'html' 53 | expect(encoded_source).not_to be_include 'meta' 54 | expect(encoded_source).not_to be_include 'javascript:' 55 | expect(encoded_source).not_to be_include 'Markup examples' 56 | expect(encoded_source).not_to be_include 'td.name' 57 | expect(encoded_source).not_to be_include 'td.price' 58 | expect(encoded_source).not_to be_include 'Copyright' 59 | end 60 | 61 | it 'encodes control code' do 62 | source = <<~EOS 63 | body 64 | - if articles.empty? 65 | | No inventory 66 | EOS 67 | encoded_source = Engine::Slim.encode(source) 68 | expect(encoded_source).to be_include 'if articles.empty?' 69 | expect(encoded_source).to be_include 'end' 70 | expect(encoded_source).not_to be_include 'No inventory' 71 | end 72 | 73 | it 'encodes output' do 74 | source = <<~EOS 75 | = javascript_include_tag \ 76 | "jquery", 77 | "application" 78 | EOS 79 | encoded_source = Engine::Slim.encode(source) 80 | expect(encoded_source).to be_include 'javascript_include_tag' 81 | expect(encoded_source).to be_include '"jquery",' 82 | expect(encoded_source).to be_include '"application"' 83 | end 84 | 85 | it 'encodes output without html escaping' do 86 | source = <<~EOS 87 | == yield 88 | EOS 89 | encoded_source = Engine::Slim.encode(source) 90 | expect(encoded_source).to be_include 'yield' 91 | end 92 | 93 | it 'encodes tags' do 94 | source = <<~EOS 95 | ul 96 | li.first: a href="/a" A link 97 | li: a href="/b" B link 98 | EOS 99 | encoded_source = Engine::Slim.encode(source) 100 | expect(encoded_source).not_to be_include 'ul' 101 | expect(encoded_source).not_to be_include 'li' 102 | expect(encoded_source).not_to be_include 'first' 103 | expect(encoded_source).not_to be_include 'link' 104 | end 105 | 106 | it 'encodes text content' do 107 | source = <<~EOS 108 | body 109 | h1 id="headline" Welcome to my site. 110 | EOS 111 | encoded_source = Engine::Slim.encode(source) 112 | expect(encoded_source).not_to be_include 'h1' 113 | expect(encoded_source).not_to be_include 'id' 114 | expect(encoded_source).not_to be_include 'Welcome' 115 | end 116 | 117 | it 'encodes dynamic content' do 118 | source = <<~EOS 119 | body 120 | h1 id="headline" = page_headline 121 | EOS 122 | encoded_source = Engine::Slim.encode(source) 123 | expect(encoded_source).to be_include 'page_headline' 124 | expect(encoded_source).not_to be_include 'h1' 125 | end 126 | 127 | it 'encodes attributes wrapper' do 128 | source = <<~EOS 129 | body 130 | h1(id="logo") = page_logo 131 | h2[id="tagline" class="small tagline"] = page_tagline 132 | EOS 133 | encoded_source = Engine::Slim.encode(source) 134 | expect(encoded_source).to be_include '; page_logo' 135 | expect(encoded_source).to be_include '; page_tagline' 136 | expect(encoded_source).not_to be_include 'h1' 137 | expect(encoded_source).not_to be_include 'h2' 138 | expect(encoded_source).not_to be_include 'id' 139 | expect(encoded_source).not_to be_include 'class' 140 | end 141 | 142 | it 'encodes multiple lines attributes wrapper' do 143 | source = <<~EOS 144 | h2[id="tagline" 145 | class="small tagline"] = page_tagline 146 | EOS 147 | encoded_source = Engine::Slim.encode(source) 148 | expect(encoded_source).to be_include 'page_tagline' 149 | expect(encoded_source).not_to be_include 'h2' 150 | expect(encoded_source).not_to be_include 'id' 151 | expect(encoded_source).not_to be_include 'class' 152 | end 153 | 154 | it 'encodes ruby attributes' do 155 | source = <<~EOS 156 | body 157 | table 158 | - for user in users 159 | td id="user_\#{user.id}" class=user.role 160 | a href=user_action(user, :edit) Edit \#{user.first_name} 161 | a href=(path_to_user user) = user.last_name 162 | EOS 163 | encoded_source = Engine::Slim.encode(source) 164 | expect(encoded_source).to be_include 'for user in users' 165 | expect(encoded_source).to be_include 'user.id' 166 | expect(encoded_source).to be_include 'user_action(user, :edit)' 167 | expect(encoded_source).to be_include 'user.first_name' 168 | expect(encoded_source).to be_include '(path_to_user user)' 169 | expect(encoded_source).to be_include 'user.last_name' 170 | expect(encoded_source).to be_include 'end' 171 | expect(encoded_source).not_to be_include 'href' 172 | end 173 | 174 | it 'encodes ruby attributes with ==' do 175 | source = 'a href==action_path(:start)' 176 | encoded_source = Engine::Slim.encode(source) 177 | expect(encoded_source).to be_include 'action_path(:start)' 178 | expect(encoded_source).not_to be_include 'href' 179 | expect(encoded_source).not_to be_include '=' 180 | end 181 | 182 | it 'encodes text interpolation' do 183 | source = <<~EOS 184 | body 185 | h1 Welcome \#{current_user.name} to the show. 186 | EOS 187 | encoded_source = Engine::Slim.encode(source) 188 | expect(encoded_source).to be_include 'current_user.name;' 189 | expect(encoded_source).not_to be_include 'Welcome' 190 | end 191 | end 192 | 193 | describe '#generate_transform_proc' do 194 | it 'generates transform proc' do 195 | encoded_source = <<~EOS 196 | now = DateTime.now 197 | if now > DateTime.parse("December 31, 2006") 198 | else 199 | end 200 | if current_admin? 201 | elsif current_user? 202 | end 203 | DateTime.now - now 204 | EOS 205 | proc = Engine::Slim.generate_transform_proc(encoded_source) 206 | actions = [ 207 | NodeMutation::Struct::Action.new(:delete, 50, 55, ''), 208 | # first end position is 69 209 | NodeMutation::Struct::Action.new(:delete, 100, 105, ''), 210 | # second end position is 111 211 | NodeMutation::Struct::Action.new(:delete, 120, 125, '') 212 | ] 213 | proc.call(actions) 214 | expect(actions.first.start).to eq 50 215 | expect(actions.first.end).to eq 55 216 | expect(actions.second.start).to eq 96 217 | expect(actions.second.end).to eq 101 218 | expect(actions.third.start).to eq 112 219 | expect(actions.third.end).to eq 117 220 | end 221 | end 222 | end 223 | end 224 | -------------------------------------------------------------------------------- /spec/synvert/core/engine/haml_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Synvert::Core 6 | describe Engine::Haml do 7 | describe '#encode' do 8 | it 'encodes source' do 9 | source = <<~EOS 10 | %p 11 | Date/Time: 12 | - now = DateTime.now 13 | %strong= now 14 | - if now > DateTime.parse("December 31, 2006") 15 | = "Happy new " + "year!" 16 | - else 17 | = "Hello!" 18 | - if current_admin? 19 | %strong= "Admin" 20 | #error= error_message 21 | .form-actions 22 | = form_for @user do |f| 23 | - elsif current_user? 24 | %span= "User" 25 | Test 26 | EOS 27 | encoded_source = Engine::Haml.encode(source) 28 | expect(encoded_source).to be_include 'now = DateTime.now' 29 | expect(encoded_source).to be_include 'now' 30 | expect(encoded_source).to be_include 'if now > DateTime.parse("December 31, 2006")' 31 | expect(encoded_source).to be_include '"Happy new " + "year!"' 32 | expect(encoded_source).to be_include '"Hello!"' 33 | expect(encoded_source).to be_include 'if current_admin?' 34 | expect(encoded_source).to be_include '"Admin"' 35 | expect(encoded_source).to be_include 'if current_user?' 36 | expect(encoded_source).to be_include '"User"' 37 | expect(encoded_source).to be_include 'error_message' 38 | expect(encoded_source).to be_include 'form_for @user do |f|' 39 | expect(encoded_source.scan("end\n").length).to eq 3 40 | expect(encoded_source).not_to be_include '%p' 41 | expect(encoded_source).not_to be_include 'strong' 42 | expect(encoded_source).not_to be_include '#error' 43 | expect(encoded_source).not_to be_include 'Test' 44 | expect(encoded_source).not_to be_include '.form-actions' 45 | end 46 | 47 | it 'encodes plain text' do 48 | source = <<~EOS 49 | %gee 50 | %whiz 51 | Wow this is cool! 52 | EOS 53 | encoded_source = Engine::Haml.encode(source) 54 | expect(encoded_source).not_to be_include '%gee' 55 | expect(encoded_source).not_to be_include '%whiz' 56 | expect(encoded_source).not_to be_include 'Wow this is cool!' 57 | end 58 | 59 | it 'encodes escaping' do 60 | source = <<~EOS 61 | %title 62 | = @title 63 | \\= @title 64 | EOS 65 | encoded_source = Engine::Haml.encode(source) 66 | expect(encoded_source.scan('@title').length).to eq 1 67 | end 68 | 69 | it 'encodes attributes' do 70 | source = <<~EOS 71 | %html{:xmlns => "http://www.w3.org/1999/xhtml", "xml:lang" => "en", :lang => "en"} 72 | EOS 73 | encoded_source = Engine::Haml.encode(source) 74 | expect(encoded_source).to be_include '{:xmlns => "http://www.w3.org/1999/xhtml", "xml:lang" => "en", :lang => "en"}' 75 | end 76 | 77 | it 'encodes multiple lines attributes' do 78 | source = <<~EOS 79 | %script{ 80 | "type": "text/javascript", 81 | "src": "javascripts/script_9", 82 | "data": { 83 | "controller": "reporter", 84 | }, 85 | } 86 | EOS 87 | encoded_source = Engine::Haml.encode(source) 88 | expect(encoded_source).to be_include '"type": "text/javascript",' 89 | expect(encoded_source).to be_include '"src": "javascripts/script_9",' 90 | expect(encoded_source).to be_include '"controller": "reporter",' 91 | end 92 | 93 | it 'encodes prefixed attributes' do 94 | source = <<~EOS 95 | %a{:href=>"/posts", :data => {:author_id => 123, :category => 7}} Posts By Author 96 | EOS 97 | encoded_source = Engine::Haml.encode(source) 98 | expect(encoded_source).to be_include '{:href=>"/posts", :data => {:author_id => 123, :category => 7}}' 99 | end 100 | 101 | it 'encodes attributes with ruby evaluation' do 102 | source = <<~EOS 103 | %a{:href=>"/posts", :data => {:author_id => 123, :category => 7}}= current_user.name 104 | EOS 105 | encoded_source = Engine::Haml.encode(source) 106 | expect(encoded_source).to be_include '{:href=>"/posts", :data => {:author_id => 123, :category => 7}}; current_user.name' 107 | end 108 | 109 | it 'encodes class and ID' do 110 | source = <<~EOS 111 | %div#things 112 | %span#rice Chicken Fried 113 | %p.beans{ :food => 'true' } The magical fruit 114 | %h1.class.otherclass#id La La La 115 | EOS 116 | encoded_source = Engine::Haml.encode(source) 117 | expect(encoded_source).not_to be_include '%div' 118 | expect(encoded_source).not_to be_include '#things' 119 | expect(encoded_source).not_to be_include '.beans' 120 | expect(encoded_source).not_to be_include '.class' 121 | expect(encoded_source).not_to be_include '#id' 122 | expect(encoded_source).to be_include "{ :food => 'true' }" 123 | end 124 | 125 | it 'encodes haml comments' do 126 | source = <<~EOS 127 | %p foo 128 | -# This is a comment 129 | %p bar 130 | EOS 131 | encoded_source = Engine::Haml.encode(source) 132 | expect(encoded_source).not_to be_include 'foo' 133 | expect(encoded_source).not_to be_include 'bar' 134 | expect(encoded_source).to be_include '# This is a comment' 135 | end 136 | 137 | it 'encodes ruby evaluation' do 138 | source = <<~EOS 139 | %p 140 | = ['hi', 'there', 'reader!'].join " " 141 | = "yo" 142 | EOS 143 | encoded_source = Engine::Haml.encode(source) 144 | expect(encoded_source).to be_include "['hi', 'there', 'reader!'].join \" \"" 145 | expect(encoded_source).to be_include '"yo"' 146 | end 147 | 148 | it 'encodes ruby evaluation in the same line' do 149 | source = <<~EOS 150 | %p= "hello" 151 | EOS 152 | encoded_source = Engine::Haml.encode(source) 153 | expect(encoded_source).not_to be_include '%p' 154 | expect(encoded_source).to be_include '"hello"' 155 | end 156 | 157 | it 'encodes multiple lines ruby evaluation' do 158 | source = <<~EOS 159 | = link_to_remote "Add to cart", 160 | :url => { :action => "add", :id => product.id }, 161 | :update => { :success => "cart", :failure => "error" } 162 | EOS 163 | encoded_source = Engine::Haml.encode(source) 164 | expect(encoded_source).to be_include 'link_to_remote "Add to cart",' 165 | expect(encoded_source).to be_include ':url => { :action => "add", :id => product.id },' 166 | expect(encoded_source).to be_include ':update => { :success => "cart", :failure => "error" }' 167 | end 168 | 169 | it 'encodes running ruby' do 170 | source = <<~EOS 171 | - foo = "hello" 172 | - foo << " there" 173 | - foo << " you!" 174 | EOS 175 | encoded_source = Engine::Haml.encode(source) 176 | expect(encoded_source).to be_include 'foo = "hello"' 177 | expect(encoded_source).to be_include 'foo << " there"' 178 | expect(encoded_source).to be_include 'foo << " you!"' 179 | end 180 | 181 | it 'encodes multiple line running ruby' do 182 | source = <<~EOS 183 | - links = {:home => "/", 184 | :docs => "/docs", 185 | :about => "/about"} 186 | EOS 187 | encoded_source = Engine::Haml.encode(source) 188 | expect(encoded_source).to be_include 'links = {:home => "/",' 189 | expect(encoded_source).to be_include ':docs => "/docs",' 190 | expect(encoded_source).to be_include ':about => "/about"}' 191 | end 192 | 193 | it 'encodes ruby block' do 194 | source = <<~EOS 195 | - (42...47).each do |i| 196 | %p= i 197 | %p See, I can count! 198 | EOS 199 | encoded_source = Engine::Haml.encode(source) 200 | expect(encoded_source).to be_include '(42...47).each do |i|' 201 | expect(encoded_source).to be_include 'end' 202 | expect(encoded_source).not_to be_include 'See, I can count!' 203 | end 204 | 205 | it 'encodes ruby block in multi lines' do 206 | source = <<~EOS 207 | = form_for(:document, builder: BootstrapForm, 208 | html: {id: 'new_document_form', class: 'form-vertical'}) do |f| 209 | EOS 210 | encoded_source = Engine::Haml.encode(source) 211 | expect(encoded_source).to be_include 'form_for' 212 | expect(encoded_source).to be_include 'do |f|' 213 | expect(encoded_source).to be_include 'end' 214 | end 215 | 216 | it 'encodes ruby interpolation' do 217 | source = 'Look at #{h word} lack of backslash: \#{foo}' 218 | encoded_source = Engine::Haml.encode(source) 219 | expect(encoded_source).to be_include 'h word;' 220 | expect(encoded_source).not_to be_include 'foo' 221 | expect(encoded_source).not_to be_include 'lack of backslash:' 222 | end 223 | end 224 | 225 | describe '#generate_transform_proc' do 226 | it 'generates transform proc' do 227 | encoded_source = <<~EOS 228 | now = DateTime.now 229 | if now > DateTime.parse("December 31, 2006") 230 | else 231 | end 232 | if current_admin? 233 | elsif current_user? 234 | end 235 | DateTime.now - now 236 | EOS 237 | proc = Engine::Haml.generate_transform_proc(encoded_source) 238 | actions = [ 239 | NodeMutation::Struct::Action.new(:delete, 50, 55, ''), 240 | # first end position is 69 241 | NodeMutation::Struct::Action.new(:delete, 100, 105, ''), 242 | # second end position is 111 243 | NodeMutation::Struct::Action.new(:delete, 120, 125, '') 244 | ] 245 | proc.call(actions) 246 | expect(actions.first.start).to eq 50 247 | expect(actions.first.end).to eq 55 248 | expect(actions.second.start).to eq 96 249 | expect(actions.second.end).to eq 101 250 | expect(actions.third.start).to eq 112 251 | expect(actions.third.end).to eq 117 252 | end 253 | end 254 | end 255 | end 256 | -------------------------------------------------------------------------------- /spec/synvert/core/rewriter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Synvert::Core 6 | RSpec.describe Rewriter do 7 | describe '#reset' do 8 | it 'resets warnings, affected_files, and test_results' do 9 | rewriter = Rewriter.new 'group', 'name' 10 | rewriter.instance_variable_set(:@warnings, ['warnings']) 11 | rewriter.instance_variable_set(:@affected_files, ['files']) 12 | rewriter.instance_variable_set(:@test_results, { foo: 'bar' }) 13 | rewriter.reset 14 | expect(rewriter.warnings).to eq [] 15 | expect(rewriter.affected_files).to eq Set.new 16 | expect(rewriter.test_results).to eq Hash.new 17 | end 18 | end 19 | 20 | describe '#configure' do 21 | it 'parses parser' do 22 | running_mutation_adapter = nil 23 | rewriter = 24 | Rewriter.new 'group', 'name' do 25 | configure parser: 'syntax_tree' 26 | 27 | within_files '**/*.rb' do 28 | running_mutation_adapter = mutation_adapter 29 | end 30 | end 31 | input = "class Foobar\nend" 32 | FakeFS do 33 | File.write("code.rb", input) 34 | rewriter.process 35 | expect(running_mutation_adapter).to be_instance_of(NodeMutation::SyntaxTreeAdapter) 36 | end 37 | end 38 | 39 | it 'parses unkown parser' do 40 | rewriter = 41 | Rewriter.new 'group', 'name' do 42 | configure parser: 'unknown' 43 | end 44 | expect { rewriter.process } 45 | .to raise_error(Errors::ParserNotSupported) 46 | end 47 | end 48 | 49 | it 'parses description' do 50 | rewriter = 51 | Rewriter.new 'group', 'name' do 52 | description 'rewriter description' 53 | end 54 | rewriter.process 55 | expect(rewriter.description).to eq 'rewriter description' 56 | end 57 | 58 | it 'parses if_ruby' do 59 | expect(Rewriter::RubyVersion).to receive(:new).with('2.0.0') 60 | rewriter = 61 | Rewriter.new 'group', 'name' do 62 | if_ruby '2.0.0' 63 | end 64 | rewriter.process 65 | end 66 | 67 | it 'parses if_gem' do 68 | expect(Rewriter::GemSpec).to receive(:new).with('synvert', '>= 1.0.0') 69 | rewriter = 70 | Rewriter.new 'group', 'name' do 71 | if_gem 'synvert', '>= 1.0.0' 72 | end 73 | rewriter.process 74 | end 75 | 76 | describe '#process' do 77 | it 'rewrites the file' do 78 | rewriter = 79 | Rewriter.new('group', 'name') do 80 | within_files '**/*.rb' do 81 | with_node node_type: 'class', name: 'Foobar' do 82 | replace :name, with: 'Synvert' 83 | end 84 | end 85 | end 86 | input = "class Foobar\nend" 87 | output = "class Synvert\nend" 88 | FakeFS do 89 | File.write("code.rb", input) 90 | rewriter.process 91 | expect(File.read("code.rb")).to eq output 92 | end 93 | end 94 | 95 | it 'does not rewrite the file if within_files is not called' do 96 | rewriter = 97 | Rewriter.new('group', 'name') do 98 | with_node node_type: 'class', name: 'Foobar' do 99 | replace :name, with: 'Synvert' 100 | end 101 | end 102 | input = "class Foobar\nend" 103 | output = "class Synvert\nend" 104 | FakeFS do 105 | File.write("code.rb", input) 106 | expect { rewriter.process }.to raise_error(NoMethodError, 'with_node must be called within within_files or within_file block.') 107 | end 108 | end 109 | end 110 | 111 | describe '#test' do 112 | it 'gets test results' do 113 | rewriter = 114 | Rewriter.new('group', 'name') do 115 | within_files '**/*.rb' do 116 | with_node node_type: 'class', name: 'Foobar' do 117 | replace :name, with: 'Synvert' 118 | end 119 | end 120 | end 121 | input = "class Foobar\nend" 122 | FakeFS do 123 | File.write("code.rb", input) 124 | results = rewriter.test 125 | expect(results[0].file_path).to eq '/code.rb' 126 | expect(results[0].affected?).to be_truthy 127 | expect(results[0].conflicted?).to be_falsey 128 | expect(results[0].actions).to eq [NodeMutation::Struct::Action.new(:replace, 6, 12, 'Synvert')] 129 | end 130 | end 131 | 132 | context 'Configuration.test_result is new_source' do 133 | before { Configuration.test_result = 'new_source' } 134 | after { Configuration.test_result = nil } 135 | 136 | it 'gets test results with new_source' do 137 | rewriter = 138 | Rewriter.new('group', 'name') do 139 | within_files '**/*.rb' do 140 | with_node node_type: 'class', name: 'Foobar' do 141 | replace :name, with: 'Synvert' 142 | end 143 | end 144 | end 145 | input = "class Foobar\nend" 146 | FakeFS do 147 | File.write("code.rb", input) 148 | results = rewriter.test 149 | expect(results[0].file_path).to eq '/code.rb' 150 | expect(results[0].affected?).to be_truthy 151 | expect(results[0].conflicted?).to be_falsey 152 | expect(results[0].new_source).to eq "class Synvert\nend" 153 | end 154 | end 155 | end 156 | end 157 | 158 | describe 'parses within_file' do 159 | it 'does nothing if if_ruby does not match' do 160 | expect(File).to receive(:exist?).with('./.ruby-version').and_return(true) 161 | expect(File).to receive(:read).with('./.ruby-version').and_return('2.0.0') 162 | expect_any_instance_of(Rewriter::Instance).not_to receive(:process) 163 | rewriter = 164 | Rewriter.new 'group', 'name' do 165 | if_ruby '2.2.3' 166 | within_file 'config/routes.rb' do 167 | end 168 | end 169 | rewriter.process 170 | end 171 | 172 | it 'delegates process to instances if if_ruby matches' do 173 | expect(Utils).to receive(:glob).with(['config/routes.rb']).and_return(['config/routes.rb']) 174 | expect(File).to receive(:exist?).with('./.ruby-version').and_return(true) 175 | expect(File).to receive(:read).with('./.ruby-version').and_return('2.0.0') 176 | expect_any_instance_of(Rewriter::Instance).to receive(:process) 177 | rewriter = 178 | Rewriter.new 'group', 'name' do 179 | if_ruby '1.9.3' 180 | within_file 'config/routes.rb' do 181 | end 182 | end 183 | rewriter.process 184 | end 185 | 186 | it 'does nothing if if_gem does not match' do 187 | expect_any_instance_of(Rewriter::GemSpec).to receive(:match?).and_return(false) 188 | expect_any_instance_of(Rewriter::Instance).not_to receive(:process) 189 | rewriter = 190 | Rewriter.new 'group', 'name' do 191 | if_gem 'synvert', '1.0.0' 192 | within_file 'config/routes.rb' do 193 | end 194 | end 195 | rewriter.process 196 | end 197 | 198 | it 'delegates process to instances if if_gem matches' do 199 | expect(Utils).to receive(:glob).with(['config/routes.rb']).and_return(['config/routes.rb']) 200 | expect_any_instance_of(Rewriter::GemSpec).to receive(:match?).and_return(true) 201 | expect_any_instance_of(Rewriter::Instance).to receive(:process) 202 | rewriter = 203 | Rewriter.new 'group', 'name' do 204 | if_gem 'synvert', '1.0.0' 205 | within_file 'config/routes.rb' do 206 | end 207 | end 208 | rewriter.process 209 | end 210 | 211 | it 'delegates process to instances if if_ruby and if_gem do not exist' do 212 | expect(Utils).to receive(:glob).with(['config/routes.rb']).and_return(['config/routes.rb']) 213 | expect_any_instance_of(Rewriter::Instance).to receive(:process) 214 | rewriter = 215 | Rewriter.new 'group', 'name' do 216 | within_file 'config/routes.rb' do 217 | end 218 | end 219 | rewriter.process 220 | end 221 | 222 | it 'does nothing in sandbox mode' do 223 | expect_any_instance_of(Rewriter::GemSpec).not_to receive(:match?) 224 | expect_any_instance_of(Rewriter::Instance).not_to receive(:process) 225 | rewriter = 226 | Rewriter.new 'group', 'name' do 227 | if_gem 'synvert', '1.0.0' 228 | within_file 'config/routes.rb' do 229 | end 230 | end 231 | rewriter.process_with_sandbox 232 | end 233 | end 234 | 235 | describe 'parses add_file' do 236 | it 'creates a new file' do 237 | rewriter = 238 | Rewriter.new 'group', 'rewriter2' do 239 | add_file 'foo.bar', 'FooBar' 240 | end 241 | rewriter.process 242 | expect(File.read('./foo.bar')).to eq 'FooBar' 243 | FileUtils.rm './foo.bar' 244 | end 245 | 246 | it 'does nothing in sandbox mode' do 247 | rewriter = 248 | Rewriter.new 'group', 'rewriter2' do 249 | add_file 'foo.bar', 'FooBar' 250 | end 251 | rewriter.process_with_sandbox 252 | expect(File.exist?('./foo.bar')).to be_falsey 253 | end 254 | 255 | it 'returns test result' do 256 | rewriter = 257 | Rewriter.new 'group', 'rewriter2' do 258 | add_file 'foo.bar', 'FooBar' 259 | end 260 | result = rewriter.test 261 | expect(result[0].file_path).to eq 'foo.bar' 262 | expect(result[0].affected?).to be_truthy 263 | expect(result[0].conflicted?).to be_falsey 264 | expect(result[0].actions).to eq [NodeMutation::Struct::Action.new(:add_file, 0, 0, 'FooBar')] 265 | end 266 | end 267 | 268 | describe 'parses remove_file' do 269 | it 'removes a file' do 270 | FileUtils.touch './foo.bar' 271 | rewriter = 272 | Rewriter.new 'group', 'rewriter2' do 273 | remove_file 'foo.bar' 274 | end 275 | rewriter.process 276 | expect(File.exist?('./foo.bar')).to be_falsey 277 | end 278 | 279 | it 'does nothing if file not exist' do 280 | rewriter = 281 | Rewriter.new 'group', 'rewriter2' do 282 | remove_file 'foo.bar' 283 | end 284 | rewriter.process 285 | expect(File.exist?('./foo.bar')).to be_falsey 286 | end 287 | 288 | it 'does nothing in sandbox mode' do 289 | FileUtils.touch './foo.bar' 290 | rewriter = 291 | Rewriter.new 'group', 'rewriter2' do 292 | add_file 'foo.bar', 'FooBar' 293 | end 294 | rewriter.process_with_sandbox 295 | expect(File.exist?('./foo.bar')).to be_truthy 296 | FileUtils.rm './foo.bar' 297 | end 298 | 299 | it 'returns test result' do 300 | File.write './foo.bar', 'FooBar' 301 | rewriter = 302 | Rewriter.new 'group', 'rewriter2' do 303 | remove_file 'foo.bar' 304 | end 305 | result = rewriter.test 306 | expect(result[0].file_path).to eq 'foo.bar' 307 | expect(result[0].affected?).to be_truthy 308 | expect(result[0].conflicted?).to be_falsey 309 | expect(result[0].actions).to eq [NodeMutation::Struct::Action.new(:remove_file, 0, -1, nil)] 310 | expect(File.exist?('./foo.bar')).to be_truthy 311 | FileUtils.rm './foo.bar' 312 | end 313 | end 314 | 315 | describe 'parses add_snippet' do 316 | it 'processes the rewritter' do 317 | rewriter1 = Rewriter.new 'group', 'rewriter1' 318 | rewriter2 = 319 | Rewriter.new 'group', 'rewriter2' do 320 | add_snippet 'group', 'rewriter1' 321 | end 322 | expect(rewriter1).to receive(:process) 323 | rewriter2.process 324 | end 325 | 326 | it 'adds sub_snippets' do 327 | rewriter1 = Rewriter.new 'group', 'rewriter1' 328 | rewriter2 = 329 | Rewriter.new 'group', 'rewriter2' do 330 | add_snippet 'group', 'rewriter1' 331 | end 332 | expect(rewriter1).to receive(:process) 333 | rewriter2.process 334 | expect(rewriter2.sub_snippets).to eq [rewriter1] 335 | end 336 | 337 | it 'adds snippet by http url' do 338 | expect(Utils).to receive(:remote_snippet_exists?).with(URI.parse('http://synvert.net/foo/bar.rb')).and_return(true) 339 | expect_any_instance_of(URI::HTTP).to receive(:open).and_return(StringIO.new("Rewriter.new 'group', 'sub_rewriter' do\nend")) 340 | rewriter = 341 | Rewriter.new 'group', 'rewriter' do 342 | add_snippet 'http://synvert.net/foo/bar.rb' 343 | end 344 | rewriter.process 345 | expect(Rewriter.fetch('group', 'sub_rewriter')).not_to be_nil 346 | end 347 | 348 | it 'adds snippet by file path' do 349 | expect(File).to receive(:exist?).and_return(true) 350 | expect(File).to receive(:read).and_return("Rewriter.new 'group', 'sub_rewriter' do\nend") 351 | rewriter = 352 | Rewriter.new 'group', 'rewriter' do 353 | add_snippet '/home/richard/foo/bar.rb' 354 | end 355 | rewriter.process 356 | expect(Rewriter.fetch('group', 'sub_rewriter')).not_to be_nil 357 | end 358 | end 359 | 360 | describe 'parses call_helper' do 361 | it 'eval helper by name' do 362 | block_receiver = nil 363 | block_options = {} 364 | Synvert::Helper.new 'helper' do |options| 365 | block_receiver = self.class.name 366 | block_options = options 367 | end 368 | rewriter = 369 | Rewriter.new 'group', 'rewriter' do 370 | call_helper('helper', foo: 'bar') 371 | end 372 | rewriter.process 373 | expect(block_receiver).to eq 'Synvert::Core::Rewriter' 374 | expect(block_options).to eq(foo: 'bar') 375 | end 376 | 377 | it 'adds snippet by http url' do 378 | expect(Utils).to receive(:remote_snippet_exists?).with(URI.parse('http://synvert.net/foo/bar.rb')).and_return(true) 379 | expect_any_instance_of(URI::HTTP).to receive(:open).and_return(StringIO.new("Synvert::Helper.new 'helper' do\nend")) 380 | rewriter = 381 | Rewriter.new 'group', 'rewriter' do 382 | call_helper 'http://synvert.net/foo/bar.rb' 383 | end 384 | rewriter.process 385 | expect(Synvert::Helper.fetch('helper')).not_to be_nil 386 | end 387 | 388 | it 'adds snippet by file path' do 389 | expect(File).to receive(:exist?).and_return(true) 390 | expect(File).to receive(:read).and_return("Synvert::Helper.new 'helper' do\nend") 391 | rewriter = 392 | Rewriter.new 'group', 'rewriter' do 393 | call_helper '/home/richard/foo/bar.rb' 394 | end 395 | rewriter.process 396 | expect(Synvert::Helper.fetch('helper')).not_to be_nil 397 | end 398 | end 399 | 400 | it 'parses helper_method' do 401 | rewriter = 402 | Rewriter.new 'group', 'name' do 403 | helper_method 'dynamic_helper' do |_arg1, _arg2| 404 | 'dynamic result' 405 | end 406 | end 407 | rewriter.process 408 | instance = Rewriter::Instance.new(rewriter, '*.rb') 409 | expect(instance.dynamic_helper('arg1', 'arg2')).to eq 'dynamic result' 410 | end 411 | 412 | describe 'class methods' do 413 | before :each do 414 | Rewriter.clear 415 | end 416 | 417 | it 'registers and fetches' do 418 | rewriter = Rewriter.new 'group', 'rewriter' 419 | expect(Rewriter.fetch('group', 'rewriter')).to eq rewriter 420 | end 421 | 422 | context 'available' do 423 | it 'lists empty rewriters' do 424 | expect(Rewriter.availables).to eq({}) 425 | end 426 | 427 | it 'registers and lists all available rewriters' do 428 | rewriter1 = Rewriter.new 'group', 'rewriter1' 429 | rewriter2 = Rewriter.new 'group', 'rewriter2' 430 | expect(Rewriter.availables).to eq({ 'group' => { 'rewriter1' => rewriter1, 'rewriter2' => rewriter2 } }) 431 | end 432 | end 433 | end 434 | end 435 | end 436 | -------------------------------------------------------------------------------- /lib/synvert/core/rewriter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'parallel' 4 | require 'fileutils' 5 | 6 | module Synvert::Core 7 | # Rewriter is the top level namespace in a snippet. 8 | # 9 | # One Rewriter checks if the depndency version matches, and it can contain one or many {Synvert::Core::Rewriter::Instance}, 10 | # which define the behavior what files and what codes to detect and rewrite to what code. 11 | class Rewriter 12 | DEFAULT_OPTIONS = { run_instance: true, write_to_file: true, parser: 'parser' }.freeze 13 | 14 | autoload :ReplaceErbStmtWithExprAction, 'synvert/core/rewriter/action/replace_erb_stmt_with_expr_action' 15 | 16 | autoload :Warning, 'synvert/core/rewriter/warning' 17 | 18 | autoload :Instance, 'synvert/core/rewriter/instance' 19 | 20 | autoload :Scope, 'synvert/core/rewriter/scope' 21 | autoload :WithinScope, 'synvert/core/rewriter/scope/within_scope' 22 | autoload :GotoScope, 'synvert/core/rewriter/scope/goto_scope' 23 | 24 | autoload :Condition, 'synvert/core/rewriter/condition' 25 | autoload :IfExistCondition, 'synvert/core/rewriter/condition/if_exist_condition' 26 | autoload :UnlessExistCondition, 'synvert/core/rewriter/condition/unless_exist_condition' 27 | 28 | autoload :Helper, 'synvert/core/rewriter/helper' 29 | 30 | autoload :RubyVersion, 'synvert/core/rewriter/ruby_version' 31 | autoload :GemSpec, 'synvert/core/rewriter/gem_spec' 32 | 33 | class << self 34 | # Register a rewriter with its group and name. 35 | # 36 | # @param group [String] the rewriter group. 37 | # @param name [String] the unique rewriter name. 38 | # @param rewriter [Synvert::Core::Rewriter] the rewriter to register. 39 | def register(group, name, rewriter) 40 | group = group.to_s 41 | name = name.to_s 42 | rewriters[group] ||= {} 43 | rewriters[group][name] = rewriter 44 | end 45 | 46 | # Fetch a rewriter by group and name. 47 | # 48 | # @param group [String] rewrtier group. 49 | # @param name [String] rewrtier name. 50 | # @return [Synvert::Core::Rewriter] the matching rewriter. 51 | def fetch(group, name) 52 | group = group.to_s 53 | name = name.to_s 54 | rewriters.dig(group, name) 55 | end 56 | 57 | # Get all available rewriters 58 | # 59 | # @return [Hash>] 60 | def availables 61 | rewriters 62 | end 63 | 64 | # Clear all registered rewriters. 65 | def clear 66 | rewriters.clear 67 | end 68 | 69 | private 70 | 71 | def rewriters 72 | @rewriters ||= {} 73 | end 74 | end 75 | 76 | # @!attribute [r] group 77 | # @return [String] the group of rewriter 78 | # @!attribute [r] name 79 | # @return [String] the unique name of rewriter 80 | # @!attribute [r] sub_snippets 81 | # @return [Array] all rewriters this rewiter calls. 82 | # @!attribute [r] helper 83 | # @return [Array] helper methods. 84 | # @!attribute [r] warnings 85 | # @return [Array] warning messages. 86 | # @!attribute [r] affected_files 87 | # @return [Set] affected fileds 88 | # @!attribute [r] ruby_version 89 | # @return [Rewriter::RubyVersion] the ruby version 90 | # @!attribute [r] gem_spec 91 | # @return [Rewriter::GemSpec] the gem spec 92 | # @!attribute [r] test_results 93 | # @return [Hash] the test results 94 | # @!attribute [rw] options 95 | # @return [Hash] the rewriter options 96 | attr_reader :group, 97 | :name, 98 | :sub_snippets, 99 | :helpers, 100 | :warnings, 101 | :affected_files, 102 | :ruby_version, 103 | :gem_spec, 104 | :test_results 105 | attr_accessor :options 106 | 107 | # Initialize a Rewriter. 108 | # When a rewriter is initialized, it is already registered. 109 | # 110 | # @param group [String] group of the rewriter. 111 | # @param name [String] name of the rewriter. 112 | # @yield defines the behaviors of the rewriter, block code won't be called when initialization. 113 | def initialize(group, name, &block) 114 | @group = group 115 | @name = name 116 | @block = block 117 | @helpers = [] 118 | @sub_snippets = [] 119 | @options = DEFAULT_OPTIONS.dup 120 | reset 121 | self.class.register(@group, @name, self) 122 | end 123 | 124 | # Process the rewriter. 125 | # It will call the block. 126 | def process 127 | @affected_files = Set.new 128 | instance_eval(&@block) 129 | end 130 | 131 | # Process rewriter with sandbox mode. 132 | # It will call the block but doesn't change any file. 133 | def process_with_sandbox 134 | @options[:run_instance] = false 135 | process 136 | end 137 | 138 | def test 139 | @options[:write_to_file] = false 140 | @affected_files = Set.new 141 | instance_eval(&@block) 142 | 143 | if Configuration.test_result == 'new_source' 144 | @test_results.values.flatten 145 | else 146 | @test_results.map do |filename, test_results| 147 | new_actions = test_results.map(&:actions).flatten.sort_by(&:end) 148 | last_start = -1 149 | conflicted = 150 | new_actions.any? do |action| 151 | if last_start > action.end 152 | true 153 | else 154 | last_start = action.start 155 | false 156 | end 157 | end 158 | result = NodeMutation::Result.new(affected: true, conflicted: conflicted) 159 | result.actions = new_actions 160 | result.file_path = filename 161 | result 162 | end 163 | end 164 | end 165 | 166 | # Add a warning. 167 | # 168 | # @param warning [Synvert::Core::Rewriter::Warning] 169 | def add_warning(warning) 170 | @warnings << warning 171 | end 172 | 173 | # Add an affected file. 174 | # 175 | # @param file_path [String] 176 | def add_affected_file(file_path) 177 | @affected_files.add(file_path) 178 | end 179 | 180 | def parser 181 | @options[:parser] 182 | end 183 | 184 | # Reset @warnings, @affected_files, and @test_results. 185 | def reset 186 | @warnings = [] 187 | @affected_files = Set.new 188 | @test_results = Hash.new { |h, k| h[k] = [] } 189 | end 190 | 191 | ####### 192 | # DSL # 193 | ####### 194 | 195 | # Configure the rewriter 196 | # @example 197 | # configure({ parser: Synvert::PARSER_PARSER }) 198 | # configure({ strategy: 'allow_insert_at_same_position' }) 199 | # @param options [Hash] 200 | # @option parser [String] Synvert::PARSER_PARSER, Synvert::SYNTAX_TREE_PARSER, or Synvert::PRISM_PARSER 201 | # @option strategy [String] 'allow_insert_at_same_position' 202 | def configure(options) 203 | @options = @options.merge(options) 204 | if options[:parser] && !Synvert::ALL_PARSERS.include?(options[:parser]) 205 | raise Errors::ParserNotSupported.new("Parser #{options[:parser]} not supported") 206 | end 207 | end 208 | 209 | # It sets description of the rewrite or get description. 210 | # @example 211 | # Synvert::Rewriter.new 'rspec', 'use_new_syntax' do 212 | # description 'It converts rspec code to new syntax, it calls all rspec sub snippets.' 213 | # end 214 | # @param description [String] rewriter description. 215 | # @return rewriter description. 216 | def description(description = nil) 217 | if description 218 | @description = description 219 | else 220 | @description 221 | end 222 | end 223 | 224 | # It checks if ruby version is greater than or equal to the specified ruby version. 225 | # @example 226 | # Synvert::Rewriter.new 'ruby', 'new_safe_navigation_operator' do 227 | # if_ruby '2.3.0' 228 | # end 229 | # @param version [String] specified ruby version. 230 | def if_ruby(version) 231 | @ruby_version = Rewriter::RubyVersion.new(version) 232 | end 233 | 234 | # It compares version of the specified gem. 235 | # @example 236 | # Synvert::Rewriter.new 'rails', 'upgrade_5_2_to_6_0' do 237 | # if_gem 'rails', '>= 6.0' 238 | # end 239 | # @param name [String] gem name. 240 | # @param version [String] equal, less than or greater than specified version, e.g. '>= 2.0.0', 241 | def if_gem(name, version) 242 | @gem_spec = Rewriter::GemSpec.new(name, version) 243 | end 244 | 245 | # It finds specified files, and for each file, it will delegate to {Synvert::Core::Rewriter::Instance} to rewrite code. 246 | # It creates a {Synvert::Core::Rewriter::Instance} to rewrite code. 247 | # @example 248 | # Synvert::Rewriter.new 'rspec', 'be_close_to_be_within' do 249 | # within_files '**/*.rb' do 250 | # end 251 | # end 252 | # @param file_patterns [String|Array] string pattern or list of string pattern to find files, e.g. ['spec/**/*_spec.rb'] 253 | # @param block [Block] the block to rewrite code in the matching files. 254 | def within_files(file_patterns, &block) 255 | return unless @options[:run_instance] 256 | 257 | return if @ruby_version && !@ruby_version.match? 258 | return if @gem_spec && !@gem_spec.match? 259 | 260 | if @options[:write_to_file] 261 | handle_one_file(Array(file_patterns)) do |file_path| 262 | instance = Instance.new(self, file_path, &block) 263 | instance.process 264 | end 265 | else 266 | results = 267 | handle_one_file(Array(file_patterns)) do |file_path| 268 | instance = Instance.new(self, file_path, &block) 269 | instance.test 270 | end 271 | merge_test_results(results) 272 | end 273 | end 274 | 275 | # It finds a specifiled file. 276 | alias within_file within_files 277 | 278 | # It adds a new file. 279 | # @example 280 | # Synvert::Rewriter.new 'rails', 'add_application_record' do 281 | # add_file 'app/models/application_record.rb', <<~EOS 282 | # class ApplicationRecord < ActiveRecord::Base 283 | # self.abstract_class = true 284 | # end 285 | # EOS 286 | # end 287 | # @param filename [String] file name of newly created file. 288 | # @param content [String] file body of newly created file. 289 | def add_file(filename, content) 290 | return unless @options[:run_instance] 291 | 292 | unless @options[:write_to_file] 293 | result = NodeMutation::Result.new(affected: true, conflicted: false) 294 | if Configuration.test_result == 'new_source' 295 | result.new_source = content 296 | else 297 | result.actions = [NodeMutation::Struct::Action.new(:add_file, 0, 0, content)] 298 | end 299 | result.file_path = filename 300 | merge_test_result(result) 301 | return 302 | end 303 | 304 | filepath = File.join(Configuration.root_path, filename) 305 | if File.exist?(filepath) 306 | puts "File #{filepath} already exists." 307 | return 308 | end 309 | 310 | FileUtils.mkdir_p File.dirname(filepath) 311 | File.write(filepath, content) 312 | end 313 | 314 | # It removes a file. 315 | # @example 316 | # Synvert::Rewriter.new 'rails', 'upgrade_4_0_to_4_1' do 317 | # remove_file 'config/initializers/secret_token.rb' 318 | # end 319 | # @param filename [String] file name. 320 | def remove_file(filename) 321 | return unless @options[:run_instance] 322 | 323 | unless @options[:write_to_file] 324 | result = NodeMutation::Result.new(affected: true, conflicted: false) 325 | if Configuration.test_result == 'new_source' 326 | result.new_source = nil 327 | else 328 | result.actions = [NodeMutation::Struct::Action.new(:remove_file, 0, -1)] 329 | end 330 | result.file_path = filename 331 | merge_test_result(result) 332 | return 333 | end 334 | 335 | file_path = File.join(Configuration.root_path, filename) 336 | File.delete(file_path) if File.exist?(file_path) 337 | end 338 | 339 | # It calls anther rewriter. 340 | # @example 341 | # Synvert::Rewriter.new 'minitest', 'better_syntax' do 342 | # add_snippet 'minitest', 'assert_empty' 343 | # add_snippet 'minitest', 'assert_equal_arguments_order' 344 | # add_snippet 'minitest/assert_instance_of' 345 | # add_snippet 'minitest/assert_kind_of' 346 | # add_snippet '/Users/flyerhzm/.synvert-ruby/lib/minitest/assert_match.rb' 347 | # add_snippet '/Users/flyerhzm/.synvert-ruby/lib/minitest/assert_nil.rb' 348 | # add_snippet 'https://github.com/synvert-hq/synvert-snippets-ruby/blob/main/lib/minitest/assert_silent.rb' 349 | # add_snippet 'https://github.com/synvert-hq/synvert-snippets-ruby/blob/main/lib/minitest/assert_truthy.rb' 350 | # end 351 | # @param group [String] group of another rewriter, if there's no name parameter, the group can be http url, file path or snippet name. 352 | # @param name [String] name of another rewriter. 353 | def add_snippet(group, name = nil) 354 | rewriter = 355 | if name 356 | Rewriter.fetch(group, name) || Utils.eval_snippet([group, name].join('/')) 357 | else 358 | Utils.eval_snippet(group) 359 | end 360 | return unless rewriter && rewriter.is_a?(Rewriter) 361 | 362 | rewriter.options = @options 363 | preserve_current_parser do 364 | if !rewriter.options[:write_to_file] 365 | results = rewriter.test 366 | merge_test_results(results) 367 | elsif rewriter.options[:run_instance] 368 | rewriter.process 369 | else 370 | rewriter.process_with_sandbox 371 | end 372 | end 373 | @sub_snippets << rewriter 374 | end 375 | 376 | # It calls a shared rewriter. 377 | # @example 378 | # Synvert::Rewriter.new 'rails', 'upgrade_6_0_to_6_1' do 379 | # call_helper 'rails/set_load_defaults', rails_version: '6.1' 380 | # add_snippet '/Users/flyerhzm/.synvert-ruby/lib/rails/set_load_defaults.rb', rails_version: '6.1' 381 | # add_snippet 'https://github.com/synvert-hq/synvert-snippets-ruby/blob/main/lib/rails/set_load_defaults.rb', rails_version: '6.1' 382 | # end 383 | # @param name [String] name of helper. 384 | # @param options [Hash] options to pass to helper. 385 | def call_helper(name, options = {}) 386 | helper = Synvert::Core::Helper.fetch(name) || Utils.eval_snippet(name) 387 | return unless helper && helper.is_a?(Synvert::Core::Helper) 388 | 389 | preserve_current_parser do 390 | instance_exec(options, &helper.block) 391 | end 392 | end 393 | 394 | # It defines helper method for {Synvert::Core::Rewriter::Instance}. 395 | # @example 396 | # Synvert::Rewriter.new 'rails', 'convert_active_record_dirty_5_0_to_5_1' do 397 | # helper_method :find_callbacks_and_convert do |callback_names, callback_changes| 398 | # # do anything, method find_callbacks_and_convert can be reused later. 399 | # end 400 | # within_files Synvert::RAILS_MODEL_FILES + Synvert::RAILS_OBSERVER_FILES do 401 | # find_callbacks_and_convert(before_callback_names, before_callback_changes) 402 | # find_callbacks_and_convert(after_callback_names, after_callback_changes) 403 | # end 404 | # end 405 | # @param name [String] helper method name. 406 | # @yield helper method block. 407 | def helper_method(name, &block) 408 | @helpers << { name: name, block: block } 409 | end 410 | 411 | # Executes a block of code with temporary configurations. 412 | # 413 | # @param configurations [Hash] The temporary configurations to apply. 414 | # @yield The block of code to execute. 415 | def with_configurations(configurations, &block) 416 | Configuration.with_temporary_configurations(configurations, &block) 417 | end 418 | 419 | # Raise error to notify user instance method should not be called in Rewriter. 420 | (Rewriter::Instance::DSL_METHODS - %i[group]).each do |method| 421 | define_method(method) do |**| 422 | raise NoMethodError, "#{method} must be called within within_files or within_file block." 423 | end 424 | end 425 | 426 | def group 427 | if block_given? 428 | raise NoMethodError, 'group must be called within rewriter block.' 429 | else 430 | @group 431 | end 432 | end 433 | 434 | private 435 | 436 | # Handle one file. 437 | # @param file_patterns [String] file patterns to find files. 438 | # @yield [file_path] block to handle file. 439 | # @yieldparam file_path [String] file path. 440 | def handle_one_file(file_patterns) 441 | if Configuration.number_of_workers > 1 442 | Parallel.map(Utils.glob(file_patterns), in_processes: Configuration.number_of_workers) do |file_path| 443 | yield(file_path) 444 | end 445 | else 446 | Utils.glob(file_patterns).map do |file_path| 447 | yield(file_path) 448 | end 449 | end 450 | end 451 | 452 | def merge_test_results(results) 453 | results.compact.select(&:affected?).each do |result| 454 | merge_test_result(result) 455 | end 456 | end 457 | 458 | def merge_test_result(result) 459 | @test_results[result.file_path] << result 460 | end 461 | 462 | def preserve_current_parser 463 | current_parser = @options[:parser] 464 | yield 465 | ensure 466 | @options[:parser] = current_parser 467 | end 468 | end 469 | end 470 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## 2.2.2 (2024-12-27) 4 | 5 | * Do not restrict activesupport version 6 | 7 | ## 2.2.1 (2024-12-27) 8 | 9 | * Raise error to notify user instance method should not be called in rewriter 10 | 11 | ## 2.2.0 (2024-07-20) 12 | 13 | * Remove `save_data` and `load_data` dsls 14 | 15 | ## 2.1.2 (2024-07-17) 16 | 17 | * Update `parser_node_ext` to 1.4.2 18 | * Update `prism_ext` to 0.4.2 19 | * Update `syntax_tree_ext` to 0.9.2 20 | * Update `node_query` to 1.15.4 21 | 22 | ## 2.1.1 (2024-07-01) 23 | 24 | * Allow to parse ruby version `ruby-3.3.0\n` 25 | * Update `node_visitor` to 1.1.0 26 | 27 | ## 2.1.0 (2024-06-24) 28 | 29 | * Remove `add_leading_spaces` method 30 | * Add `indent` and `dedent` helper methods 31 | 32 | ## 2.0.3 (2024-06-16) 33 | 34 | * Fix `gem_spec_spec` test failures 35 | * Use `NodeMutation` to fetch file line 36 | 37 | ## 2.0.2 (2024-05-30) 38 | 39 | * Do not raise error for prism 40 | * Update `node_query` to 1.15.3 41 | 42 | ## 2.0.1 (2024-05-04) 43 | 44 | * Rescue Prism and SyntaxTree parser error 45 | * Unlock activesupport version 46 | 47 | ## 2.0.0 (2024-04-28) 48 | 49 | * Add instance variable `Instance#current_parser` 50 | * Add callback and visit ast nodes 51 | 52 | ## 1.35.4 (2024-04-25) 53 | 54 | * Eval snippet on gist.github.com 55 | 56 | ## 1.35.3 (2024-04-18) 57 | 58 | * Update `prism_ext` to 0.3.2 59 | * Update `syntax_tree_ext` to 0.8.2 60 | * Update `parser_node_ext` to 1.3.2 61 | * Remove `reject_keys_from_hash` helper method 62 | 63 | ## 1.35.2 (2024-04-16) 64 | 65 | * Explicitly read file as UTF-8 encoding 66 | * Update `node_mutation` to 1.24.4 67 | 68 | ## 1.35.1 (2024-04-15) 69 | 70 | * Fix `wrap` dsl comments 71 | * Update `prism_ext` to 0.3.1 72 | 73 | ## 1.35.0 (2024-04-07) 74 | 75 | * Update `prism_ext` to 0.3.0 76 | * Update `syntax_tree_ext` to 0.8.1 77 | * Update `parser_node_ext` to 1.3.0 78 | * Update `node_query` to 1.15.2 79 | * Update `node_mutation` to 1.24.3 80 | * Remove `if_only_exist_node` dsl 81 | 82 | ## 1.34.0 (2024-02-29) 83 | 84 | * Add `with_configurations` dsl 85 | * Add `save_data` dsl 86 | * Add `load_data` dsl 87 | 88 | ## 1.33.3 (2024-02-28) 89 | 90 | * Preserve current parser 91 | 92 | ## 1.33.2 (2024-02-24) 93 | 94 | * Reset `@warnings`, `@affected_files`, and `@test_results` 95 | * Puts `Parser::SyntaxError` message only when `ENV[DEBUG]` is true 96 | * Raise `ParserNotSupported` if parser is not supported 97 | 98 | ## 1.33.1 (2024-02-23) 99 | 100 | * Try 10 times at maximum when process 101 | 102 | ## 1.33.0 (2024-02-18) 103 | 104 | * Add `Configuration.respect_gitignore` 105 | * Glob files with `git check-ignore` if `Configuration.respect_gitignore` is true 106 | 107 | ## 1.32.1 (2024-02-17) 108 | 109 | * Update `node_mutation` to 1.23.2 110 | * Update `node_query` to 1.15.1 111 | 112 | ## 1.32.0 (2024-02-11) 113 | 114 | * Support `prism` 115 | * Update `node_mutation` to 1.23.0 116 | * Update `node_query` to 1.15.0 117 | 118 | ## 1.31.1 (2024-01-30) 119 | 120 | * Update `node_mutation` to 1.22.4 121 | * Remove `redo_until_no_change` dsl 122 | 123 | ## 1.31.0 (2024-01-14) 124 | 125 | * Support `Configuration.test_result` in `add_file` and `remove_file` dsl 126 | * Get new source in `Rewriter#test` 127 | 128 | ## 1.30.3 (2024-01-13) 129 | 130 | * Add `Configuration.test_result` option 131 | 132 | ## 1.30.2 (2024-01-09) 133 | 134 | * Add `Configuration.strict` option 135 | 136 | ## 1.30.1 (2023-12-01) 137 | 138 | * It is `parser` option instead of `adapter` 139 | 140 | ## 1.30.0 (2023-11-27) 141 | 142 | * Update `node_mutation` to 1.22.0 143 | * Update `node_query` to 1.14.1 144 | * Add `adapter` as `Rewriter::ReplaceErbStmtWithExprAction` parameter 145 | * Initialize `NodeQuery` and `NodeMutation` with `adapter` 146 | * Remove `ensure_current_adapter` as adapter is an instance variable of Rewriter 147 | 148 | ## 1.29.4 (2023-11-24) 149 | 150 | * Update `node_mutation` to 1.21.4 151 | 152 | ## 1.29.3 (2023-10-28) 153 | 154 | * Update `node_mutation` to 1.21.3 155 | * Add `__FILE__` and `__LINE__` to eval 156 | 157 | ## 1.29.2 (2023-10-03) 158 | 159 | * Update `parser_node_ext` to 1.2.1 160 | * Update `node_mutation` to 1.21.3 161 | 162 | ## 1.29.1 (2023-09-29) 163 | 164 | * Update `node-query` to 1.13.12 165 | 166 | ## 1.29.0 (2023-09-26) 167 | 168 | * Update `node_mutation` to 1.21.0 169 | * Revert "Combine actions within scope" 170 | * Add `group` dsl 171 | 172 | ## 1.28.5 (2023-09-25) 173 | 174 | * Update `node-query` to 1.13.11 175 | * Update `node_mutation` to 1.20.0 176 | * Combine actions within scope 177 | * Iterate actions in engine 178 | 179 | ## 1.28.4 (2023-08-16) 180 | 181 | * Merge `test_results` based on file_path 182 | * Update `node-query` to 1.13.9 183 | * Update `node_mutation` to 1.19.3 184 | 185 | ## 1.28.3 (2023-06-22) 186 | 187 | * Update `node_mutation` to 1.19.1 188 | * Remove `node_ext` 189 | 190 | ## 1.28.2 (2023-06-17) 191 | 192 | * Update `node_query` to 1.13.5 193 | 194 | ## 1.28.1 (2023-06-14) 195 | 196 | * Do not use keyword options in `call_helper` 197 | * Update `node_query` to 1.13.3 and `node_mutation` to 1.18.3 198 | * Update `parser_node_ext` to 1.2.0 and `syntax_tree_ext` to 0.6.0 199 | 200 | ## 1.28.0 (2023-06-01) 201 | 202 | * Add `Synvert::Core::Helper` 203 | * Add `call_helper` dsl in rewriter 204 | 205 | ## 1.27.1 (2023-05-30) 206 | 207 | * Puts error message when rescue `Parser::SyntaxError` 208 | * Update `node_mutation` to 1.18.2 209 | 210 | ## 1.27.0 (2023-05-16) 211 | 212 | * Configure `parser`, can be `parser` or `syntax_tree` 213 | * Add `Errors` module 214 | * Require `parser_node_ext` and `synvert_tree_ext` properly 215 | 216 | ## 1.26.3 (2023-05-13) 217 | 218 | * Fix haml and slim engine to support attribute and ruby evaluation in the same line 219 | * Fix ruby block in multi lins in haml and slim 220 | 221 | ## 1.26.2 (2023-05-10) 222 | 223 | * Update `node_mutation` to 1.16.0 224 | 225 | ## 1.26.1 (2023-05-06) 226 | 227 | * Compact test results 228 | * Fix erb regex 229 | 230 | ## 1.26.0 (2023-04-18) 231 | 232 | * `add_file` and `remove_file` work for test 233 | * Add `indent` dsl 234 | * Add `type` to Action 235 | * Update `node_mutation` to 1.15.3 236 | 237 | ## 1.25.0 (2023-04-17) 238 | 239 | * Update `wrap` dsl 240 | * Update `node_mutation` to 1.15.1 241 | * Fix `erb` encoded source 242 | 243 | ## 1.24.0 (2023-04-16) 244 | 245 | * Add `Slim` engine 246 | * Abstract `Engine/Elegant` module 247 | * Rewrite `Haml` and `Slim` engine by StringScanner 248 | 249 | ## 1.23.1 (2023-04-06) 250 | 251 | * Fix array index name in `GotoScope` 252 | * Update `node_query` to 1.12.1 253 | 254 | ## 1.23.0 (2023-04-05) 255 | 256 | * Add `Haml` engine 257 | * Add `Engine.register` and `Engine.encode` and `Engine.generate_transform_proc` 258 | * Set NodeMutation `transform_proc` 259 | * Update `node_mutation` to 1.14.0 260 | 261 | ## 1.22.2 (2023-03-31) 262 | 263 | * Do not replace white space characters in erb engine 264 | * Check erb source in `ReplaceErbStmtWithExprAction` 265 | * Update `node_mutation` to 1.13.1 266 | 267 | ## 1.22.1 (2023-03-31) 268 | 269 | * Fix indent when `insert_after` or `insert_before` to a child node 270 | * Update `node_mutation` to 1.13.0 271 | 272 | ## 1.22.0 (2023-03-24) 273 | 274 | * Add `and_comma` option to `insert`, `insert_before`, `insert_after` api 275 | 276 | ## 1.21.7 (2023-03-23) 277 | 278 | * Polish `erb` engine 279 | * Update `node_mutation` to 1.12.1 280 | 281 | ## 1.21.6 (2023-02-16) 282 | 283 | * `RAILS_VIEW_FILES` do not check `haml` and `slim` files until we support them 284 | * Add constants `ALL_FILES` and `ALL_ERB_FILES` 285 | 286 | ## 1.21.5 (2023-02-15) 287 | 288 | * Rewrite erb engine, no need to decode 289 | * Rewrite `ReplaceErbStmtWithExprAction` to insert "=" instead 290 | * Erb encoded source passed to `node_query`, but not `node_mutation` 291 | 292 | ## 1.21.4 (2023-02-13) 293 | 294 | * Do not rescue `NoMethodError` and output debug info 295 | 296 | ## 1.21.3 (2023-02-12) 297 | 298 | * Update `parser_node_ext` to 1.0.0 299 | 300 | ## 1.21.2 (2023-02-11) 301 | 302 | * Call block multiple times when goto node array 303 | * Update `node_mutation` to 1.9.2 304 | 305 | ## 1.21.1 (2023-02-10) 306 | 307 | * Add `tab_size` option to `add_leading_spaces` 308 | 309 | ## 1.21.0 (2023-02-08) 310 | 311 | * Add `Configuration#tab_width` 312 | * Add `Instance#add_leading_spaces` 313 | 314 | ## 1.20.0 (2023-02-08) 315 | 316 | * Add `Configuration#single_quote` 317 | * Add `Instance#wrap_with_quotes` 318 | 319 | ## 1.19.0 (2023-02-06) 320 | 321 | * Remove `Instance#query_adapter` 322 | 323 | ## 1.18.1 (2023-02-05) 324 | 325 | * Fix glob with only paths 326 | 327 | ## 1.18.0 (2023-02-01) 328 | 329 | * Remove `todo` dsl 330 | * Update `parser_node_ext` to 0.9.0 331 | 332 | ## 1.17.0 (2023-01-21) 333 | 334 | * Add `add_action` dsl 335 | * Remove `any_value` 336 | * No access to `Instance#current_mutation` 337 | 338 | ## 1.16.0 (2022-12-29) 339 | 340 | * Add `Instance#query_adapter` and `Instance#mutation_adapter` 341 | * One instance handle only one file 342 | * Use `instance.file_path` instead of `instance.current_file` 343 | 344 | ## 1.15.0 (2022-11-30) 345 | 346 | * Load snippet from github 347 | 348 | ## 1.14.2 (2022-11-19) 349 | 350 | * Specify `node_query`, `node_mutation` and `parser_node_ext` versions 351 | 352 | ## 1.14.1 (2022-10-26) 353 | 354 | * Abstract `AnyValue` to NodeQuery 355 | 356 | ## 1.14.0 (2022-10-25) 357 | 358 | * `insert_after` and `insert_before` accepts `to` option 359 | * Add `configure` dsl to configure the strategy 360 | 361 | ## 1.13.1 (2022-10-17) 362 | 363 | * Do not send hash to keyword arguments 364 | 365 | ## 1.13.0 (2022-10-17) 366 | 367 | * Add `insert_before` dsl 368 | * Update `insert_after` to reuse `NodeMutation#insert` 369 | 370 | ## 1.12.0 (2022-10-14) 371 | 372 | * Condition accepts both nql and rules 373 | * Make `find_node` as an alias to `within_node` 374 | * Remove skip files only once 375 | 376 | ## 1.11.0 (2022-10-11) 377 | 378 | * Add `Configuration.number_of_workers` 379 | * Test rewriter in parallel 380 | 381 | ## 1.10.1 (2022-10-09) 382 | 383 | * Do not reset `@options` 384 | * `Rewriter.fetch` does not raise error if rewriter not found 385 | 386 | ## 1.10.0 (2022-10-09) 387 | 388 | * Eval snippet by http url, file path or snippet name 389 | * Remove `execute_command` option 390 | * Remove `Rewriter.call` 391 | * Remove `Rewriter.execute` 392 | 393 | ## 1.9.2 (2022-10-03) 394 | 395 | * Fix `test` in sub_snippets 396 | 397 | ## 1.9.1 (2022-09-23) 398 | 399 | * Read / write absolute path 400 | 401 | ## 1.9.0 (2022-09-20) 402 | 403 | * Add `noop` dsl 404 | 405 | ## 1.8.1 (2022-09-17) 406 | 407 | * Fix test snippet, return test results 408 | 409 | ## 1.8.0 (2022-09-17) 410 | 411 | * Rename config `path` to `root_path` 412 | * Rename config `skip_files` to `skip_paths` 413 | * Add config `only_paths` 414 | * Change dir to `root_path` 415 | 416 | ## 1.7.0 (2022-09-16) 417 | 418 | * Add `Rewriter#test` 419 | * Use option `run_instance` instead of `sandbox` 420 | 421 | ## 1.6.0 (2022-09-15) 422 | 423 | * Make use of `NodeQuery` to query nodes 424 | * Remove `Node#to_hash` 425 | * Downgrade `activesupport` to < 7.0.0 426 | 427 | ## 1.5.0 (2022-07-02) 428 | 429 | * Abstract `node_query` 430 | * Abstract `node_mutation` 431 | * Abstract `parser_node_ext` 432 | 433 | ## 1.4.0 (2022-05-21) 434 | 435 | * Drop support `:first-child` and `:last-child` 436 | * Redefine goto scope in nql 437 | * Fix shift/reduce conflict 438 | 439 | ## 1.3.1 (2022-05-14) 440 | 441 | * Add `add_comma` option to remove extra comma 442 | 443 | ## 1.3.0 (2022-05-12) 444 | 445 | * Support `*=`, `^=` and `$=` operators 446 | * Simplify RELATIONSHIP parser 447 | * Rewrite compiler, let the selector to query nodes 448 | 449 | ## 1.2.1 (2022-05-01) 450 | 451 | * Selector always after a node type in NQL 452 | * Define `pairs` method for `hash` node 453 | 454 | ## 1.2.0 (2022-04-29) 455 | 456 | * Remove comma in NQL array value 457 | * Parse pseudo class without selector in NQL 458 | * Parse multiple goto scope in NQL 459 | * Parse `nil?` in NQL 460 | 461 | ## 1.1.1 (2022-04-27) 462 | 463 | * Parse empty string properly in node query language 464 | * Parse `[]` and `[]=` properly in node query language 465 | 466 | ## 1.1.0 (2022-04-26) 467 | 468 | * Dynamic define Node methods by `TYPE_CHILDREN` const 469 | * Add `Node#to_hash` 470 | * Parse empty string in node query language 471 | * Identifier value can contain `?`, `<`, `=`, `>` in node query language 472 | 473 | ## 1.0.0 (2022-04-25) 474 | 475 | * Introduce new node query language 476 | * Drop ruby 2.5 support 477 | 478 | ## 0.64.0 (2022-04-02) 479 | 480 | * Read absolute path of Gemfile.lock 481 | * Remove unused `Node#to_s` 482 | * Yardoc comments 483 | * Drop `within_direct_node(rules)`, use `within_node(rules, { direct: true })` instead 484 | 485 | ## 0.63.0 (2022-02-26) 486 | 487 | * Add `to` option to `InsertAction` 488 | * Add `gt`, `gte`, `lt` and `lte` rules 489 | 490 | ## 0.62.0 (2021-12-24) 491 | 492 | * Support `csend` node 493 | * Restrict `activesupport` version to `~> 6` 494 | * Fix `prepend` action for `def` and `defs` nodes 495 | * Fix `append` action for `def` and `defs` nodes 496 | 497 | ## 0.61.0 (2021-12-10) 498 | 499 | * Add `Node#child_node_by_name` 500 | * Fix `Node#child_node_range` for array 501 | 502 | ## 0.60.0 (2021-12-02) 503 | 504 | * Add `to_string` to `sym` node 505 | 506 | ## 0.59.0 (2021-11-17) 507 | 508 | * Use option `stop_when_match` instead of `recursive` 509 | * Add file pattern constants 510 | * Instance supports array of file patterns 511 | * Return block value by `next` 512 | * Add `Node#filename` method 513 | 514 | ## 0.58.0 (2021-10-23) 515 | 516 | * Support `left_value` and `right_value` for `and` and `or` node 517 | * Rewrite `within_node` and `within_direct_node`, `WithinScope` accepts `recursive` and `direct` options 518 | 519 | ## 0.57.0 (2021-10-02) 520 | 521 | * Compare ruby version in `.ruby-version` or `.rvmrc` 522 | * Support `left_value` and `right_value` for `casgn` node 523 | * Refactor - `calculate_position` to set `begin_pos` and `end_pos` 524 | * Abstract `squeeze_spaces` and `squeeze_lines` 525 | * Remove unused comma after delete/remove action 526 | * Handle array child node in `childNodeRange` 527 | 528 | ## 0.56.0 (2021-09-14) 529 | 530 | * Support `name` for `:lvar`, `:ivar`, `:cvar` 531 | * Delete one more space if two spaces left 532 | 533 | ## 0.55.0 (2021-09-11) 534 | 535 | * Add `Configuration.show_run_process` 536 | * Fix remove action `begin_pos` and `end_pos` 537 | * Fix `nil` match 538 | * Rewrite `remove` action 539 | 540 | ## 0.54.0 (2021-08-28) 541 | 542 | * Change `goto_scope` param from array to string 543 | 544 | ## 0.53.0 (2021-08-22) 545 | 546 | * Fix nested child in Node#child_node_range 547 | * Rename synvert-core to synvert-core-ruby 548 | 549 | ## 0.52.0 (2021-08-21) 550 | 551 | * ``Node#child_node_range`` supports nested child 552 | * Require `fileutils` 553 | * Rename `Node#indent` to `Node#column` 554 | 555 | ## 0.51.0 (2021-08-12) 556 | 557 | * Add `wrap` action 558 | * Add new dsl `redo_until_no_change` 559 | 560 | ## 0.50.0 (2021-08-11) 561 | 562 | * Support `:module` in `body` 563 | * Fix symbol match 564 | 565 | ## 0.49.0 (2021-08-04) 566 | 567 | * Support :erange in to_value 568 | * Do not use to_value in match_value? 569 | 570 | ## 0.48.0 (2021-08-01) 571 | 572 | * Force to read file as utf-8 573 | * Add logo 574 | 575 | ## 0.47.0 (2021-07-28) 576 | 577 | * Add `to_single_quote` to `str` node 578 | * Add `to_symbol` to `str` node 579 | * Add `to_lambda_literal` to `lambda` node 580 | 581 | ## 0.46.0 (2021-07-25) 582 | 583 | * Add `strip_curly_braces` and `wrap_curly_braces` for `hash` node 584 | * Simplify symbol `match_value?` 585 | * Unwrap quote when matching string value 586 | 587 | ## 0.45.0 (2021-07-22) 588 | 589 | * Handle `nil` child node for `begin_pos` and `end_pos` 590 | * Remove `Rewriter::Instance` options 591 | 592 | ## 0.44.0 (2021-07-19) 593 | 594 | * Return rewriter after executing snippet 595 | * `left_value` and `right_value` support `or_asgn` node 596 | * `child_node_range` supports send `parentheses` 597 | 598 | ## 0.42.0 (2021-07-11) 599 | 600 | * Match string with quote 601 | * `match_value?` returns true if actual and expected are the same 602 | 603 | ## 0.41.0 (2021-06-24) 604 | 605 | * Remove unused autoindent option 606 | * Add `insert 'xxx', at: 'beginning'` 607 | 608 | ## 0.40.0 (2021-06-23) 609 | 610 | * Rewrite `insert` action 611 | 612 | ## 0.39.0 (2021-06-23) 613 | 614 | * Add `prepend` action instead of `insert` 615 | 616 | ## 0.38.0 (2021-06-21) 617 | 618 | * Add `xxx_source` for `hash` node 619 | 620 | ## 0.36.0 (2021-06-21) 621 | 622 | * Require `active_support/core_ext/array` 623 | 624 | ## 0.35.0 (2021-05-17) 625 | 626 | * Add `contain` rule 627 | 628 | ## 0.34.0 (2021-05-16) 629 | 630 | * `child_node_name` supports [:def, :parentheses] and [:defs, :parentheses] 631 | * Rename `pipe` to `pipes` 632 | 633 | ## 0.33.0 (2021-05-10) 634 | 635 | * Add `body` for `class` node 636 | 637 | ## 0.32.0 (2021-05-07) 638 | 639 | * Remove `ArgumentsNode` 640 | 641 | ## 0.31.0 (2021-04-27) 642 | 643 | * Add `in` and `not_in` rules 644 | 645 | ## 0.30.0 (2021-04-26) 646 | 647 | * `goto_node` accepts multiple child node names 648 | * Match any_value 649 | 650 | ## 0.29.0 (2021-04-25) 651 | 652 | * Make `child_name_range` support [:block, :pipe] 653 | * Get key value for hash node 654 | 655 | ## 0.28.0 (2021-04-07) 656 | 657 | * Make `child_name_range` support all dsl nodes 658 | * Make `replace` action support multi child names 659 | * Fix `delete` action arguments 660 | 661 | ## 0.27.0 (2021-03-31) 662 | 663 | * Support `:class` in `child_node_range` 664 | * Add `delete` action 665 | 666 | ## 0.26.0 (2021-03-30) 667 | 668 | * attr_reader ruby_version and gem_spec 669 | * Add `replace` action 670 | 671 | ## 0.25.0 (2021-03-23) 672 | 673 | * Use `Gem::Dependency#match?` to check gem version 674 | 675 | ## 0.24.0 (2021-03-17) 676 | 677 | * Rename helper method `has_key?` to `key?` 678 | 679 | ## 0.23.0 (2021-03-14) 680 | 681 | * Accept a node as goto_node argument 682 | 683 | ## 0.22.0 (2021-03-13) 684 | 685 | * Track `affected_files` for rewriter 686 | * Fix `find_matching_nodes` for `current_node` 687 | 688 | ## 0.21.0 (2021-02-25) 689 | 690 | * Set `env['BUNDLE_GEMFILE']` before parsing `Gemfile.lock` 691 | * Add `Rewriter::RubyVersion` test 692 | * Add `reject_keys_from_hash` helper method 693 | 694 | ## 0.20.0 (2021-02-15) 695 | 696 | * Call snippet in sandbox mode 697 | 698 | ## 0.19.0 (2021-02-07) 699 | 700 | * Simplify `Configuration` 701 | 702 | ## 0.18.0 (2021-02-07) 703 | 704 | * Add `Rewriter.execute` 705 | 706 | ## 0.17.0 (2021-01-29) 707 | 708 | * Ignore `gem_spec` check if `Gemfile.lock` does not exist 709 | 710 | ## 0.16.0 (2021-01-17) 711 | 712 | * Use parser 3.0.0 713 | * Fix magic number 714 | * Add `within_direct_node` scope 715 | 716 | ## 0.15.0 (2018-05-23) 717 | 718 | * Use parser 2.5.1.1 719 | 720 | ## 0.14.0 (2017-05-10) 721 | 722 | * Add helper add_curly_brackets_if_necessary 723 | * Add name for restarg node 724 | * Add message for zsuper node 725 | 726 | ## 0.13.0 (2017-04-15) 727 | 728 | * Add message for super node 729 | * Add name and to_s for mlhs node 730 | 731 | ## 0.12.0 (2017-02-18) 732 | 733 | * Use parser 2.4.0.0 734 | * Add parent_const accessor for constant nodes with a namespace 735 | * Warning if add_file already exists and make sure directory exists for add_file 736 | 737 | ## 0.11.0 (2016-07-31) 738 | 739 | * Add options to Rewriter::Instance 740 | * Add sort_by option to Rewriter::Instance 741 | 742 | ## 0.10.0 (2016-07-31) 743 | 744 | * Use parser 2.3.1.2 745 | * Add options to Rewriter::Action 746 | * Add autoindent option to Rewriter::Action 747 | 748 | ## 0.9.0 (2015-12-23) 749 | 750 | * Add if_ruby dsl 751 | * Fix rewritten indent when number of lines and arguments are equal 752 | * Fix match_value between symbol and string 753 | 754 | ## 0.8.0 (2014-10-26) 755 | 756 | * Add line method to ast node 757 | * Add add_arguments_with_parenthesis_if_necessary helper method 758 | * Fix left_value and right_value node attribute 759 | * Print warn when file was not parsed correctly 760 | * Handle indent for node array source 761 | * Rescue NoMethodError and output node debug info 762 | 763 | ## 0.7.0 (2014-09-29) 764 | 765 | * Add debug info for MethodNotSupported error. 766 | * Add left_value and right_value ext to ast node 767 | * Add arguments for def and defs nodes 768 | * Add name for arg and blockarg nodes 769 | * Remove trailing whitespace in rewritten code 770 | * Rewriter.available always returns a hash 771 | * Support ArgumentsNode in rewritten_source 772 | 773 | ## 0.6.0 (2014-09-01) 774 | 775 | * Add goto_node dsl 776 | * Add ArgumentsNode to handle args both as a node and as an array 777 | * Add body for :defs node 778 | * Raise RewriterNotFound if rewriter not found 779 | * Remove Rewriter::Instance class methods current and current_source 780 | 781 | ## 0.5.0 (2014-08-21) 782 | 783 | * Add group to rewriter 784 | * Add parent_class for :class node 785 | * Add Rewriter::Helper module to provide common helper methods. 786 | * Fix indent for append and replace_with action 787 | * Cache file source and ast 788 | 789 | ## 0.4.0 (2014-07-26) 790 | 791 | * Add erb support 792 | * Add replace_erb_stmt_with_expr dsl 793 | * Improve Parser::AST::Node#to_value 794 | 795 | ## 0.3.0 (2014-07-12) 796 | 797 | * Rename node.source(instance) to node.to_source 798 | * Add has_key? and hash_value helper methods for hash node 799 | * Fix Instance#check_conflict_actions 800 | 801 | ## 0.2.0 (2014-05-16) 802 | 803 | * Add remove_file dsl 804 | * Add warn dsl 805 | * Return empty array if no available rewriters 806 | 807 | ## 0.1.0 (2014-05-04) 808 | 809 | * Abstract from synvert 810 | -------------------------------------------------------------------------------- /lib/synvert/core/rewriter/instance.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'parser/current' 4 | require 'parser_node_ext' 5 | require 'syntax_tree' 6 | require 'syntax_tree_ext' 7 | require 'prism' 8 | require 'prism_ext' 9 | 10 | module Synvert::Core 11 | # Instance is an execution unit, it finds specified ast nodes, 12 | # checks if the nodes match some conditions, then add, replace or remove code. 13 | # 14 | # One instance can contain one or many {Synvert::Core::Rewriter::Scope} and {Synvert::Rewriter::Condition}. 15 | class Rewriter::Instance 16 | include Rewriter::Helper 17 | 18 | DSL_METHODS = %i[ 19 | within_node with_node find_node goto_node 20 | if_exist_node unless_exist_node 21 | append prepend insert insert_after insert_before replace delete remove wrap replace_with warn replace_erb_stmt_with_expr noop group add_action 22 | add_callback 23 | wrap_with_quotes add_leading_spaces 24 | file_path node mutation_adapter 25 | ].freeze 26 | 27 | # Initialize an Instance. 28 | # 29 | # @param rewriter [Synvert::Core::Rewriter] 30 | # @param file_path [Array] 31 | # @yield block code to find nodes, match conditions and rewrite code. 32 | def initialize(rewriter, file_path, &block) 33 | @rewriter = rewriter 34 | @current_parser = @rewriter.parser 35 | @current_visitor = NodeVisitor.new(adapter: @current_parser) 36 | @actions = [] 37 | @file_path = file_path 38 | @block = block 39 | strategy = NodeMutation::Strategy::KEEP_RUNNING 40 | if rewriter.options[:strategy] == Strategy::ALLOW_INSERT_AT_SAME_POSITION 41 | strategy |= NodeMutation::Strategy::ALLOW_INSERT_AT_SAME_POSITION 42 | end 43 | NodeMutation.configure({ strategy: strategy, tab_width: Configuration.tab_width }) 44 | rewriter.helpers.each { |helper| singleton_class.send(:define_method, helper[:name], &helper[:block]) } 45 | end 46 | 47 | # @!attribute [r] file_path 48 | # @return file path 49 | # @!attribute [r] current_parser 50 | # @return current parser 51 | # @!attribute [rw] current_node 52 | # @return current ast node 53 | attr_reader :file_path, :current_parser 54 | attr_accessor :current_node 55 | 56 | # Process the instance. 57 | # It executes the block code, rewrites the original code, 58 | # then writes the code back to the original file. 59 | def process 60 | puts @file_path if Configuration.show_run_process 61 | 62 | absolute_file_path = File.join(Configuration.root_path, @file_path) 63 | # It keeps running until no conflict, 64 | # it will try 5 times at maximum. 65 | 5.times do 66 | source = read_source(absolute_file_path) 67 | encoded_source = Engine.encode(File.extname(file_path), source) 68 | @current_mutation = NodeMutation.new(source, adapter: @current_parser) 69 | @current_mutation.transform_proc = Engine.generate_transform_proc(File.extname(file_path), encoded_source) 70 | begin 71 | node = parse_code(@file_path, encoded_source) 72 | 73 | process_with_node(node) do 74 | instance_eval(&@block) 75 | end 76 | 77 | @current_visitor.visit(node, self) 78 | 79 | result = @current_mutation.process 80 | if result.affected? 81 | @rewriter.add_affected_file(file_path) 82 | write_source(absolute_file_path, result.new_source) 83 | end 84 | break unless result.conflicted? 85 | rescue Parser::SyntaxError, Prism::ParseError, SyntaxTree::Parser::ParseError => e 86 | if ENV['DEBUG'] == 'true' 87 | puts "[Warn] file #{file_path} was not parsed correctly." 88 | puts e.message 89 | end 90 | break 91 | end 92 | end 93 | end 94 | 95 | # Test the instance. 96 | # It executes the block code, tests the original code, 97 | # then returns the actions. 98 | def test 99 | absolute_file_path = File.join(Configuration.root_path, file_path) 100 | source = read_source(absolute_file_path) 101 | @current_mutation = NodeMutation.new(source, adapter: @current_parser) 102 | encoded_source = Engine.encode(File.extname(file_path), source) 103 | @current_mutation.transform_proc = Engine.generate_transform_proc(File.extname(file_path), encoded_source) 104 | begin 105 | node = parse_code(file_path, encoded_source) 106 | 107 | process_with_node(node) do 108 | instance_eval(&@block) 109 | end 110 | 111 | @current_visitor.visit(node, self) 112 | 113 | result = Configuration.test_result == 'new_source' ? @current_mutation.process : @current_mutation.test 114 | result.file_path = file_path 115 | result 116 | rescue Parser::SyntaxError, Prism::ParseError, SyntaxTree::Parser::ParseError => e 117 | if ENV['DEBUG'] == 'true' 118 | puts "[Warn] file #{file_path} was not parsed correctly." 119 | puts e.message 120 | end 121 | end 122 | end 123 | 124 | # Gets current node, it allows to get current node in block code. 125 | # 126 | # @return [Node] 127 | def node 128 | @current_node 129 | end 130 | 131 | # Get current_mutation's adapter. 132 | # 133 | # @return [NodeMutation::Adapter] 134 | def mutation_adapter 135 | @current_mutation.adapter 136 | end 137 | 138 | # Set current_node to node and process. 139 | # 140 | # @param node [Node] node set to current_node 141 | # @yield process 142 | def process_with_node(node) 143 | self.current_node = node 144 | yield 145 | self.current_node = node 146 | end 147 | 148 | # Set current_node properly, process and set current_node back to original current_node. 149 | # 150 | # @param node [Node] node set to other_node 151 | # @yield process 152 | def process_with_other_node(node) 153 | original_node = current_node 154 | self.current_node = node 155 | yield 156 | self.current_node = original_node 157 | end 158 | 159 | ####### 160 | # DSL # 161 | ####### 162 | 163 | # It creates a {Synvert::Core::Rewriter::WithinScope} to recursively find matching ast nodes, 164 | # then continue operating on each matching ast node. 165 | # @example 166 | # # matches User.find_by_login('test') 167 | # with_node type: 'send', message: /^find_by_/ do 168 | # end 169 | # # matches FactoryBot.create(:user) 170 | # with_node '.send[receiver=FactoryBot][message=create][arguments.size=1]' do 171 | # end 172 | # @param nql_or_rules [String|Hash] nql or rules to find mathing ast nodes. 173 | # @param options [Hash] optional 174 | # @option including_self [Boolean] set if query the current node, default is true 175 | # @option stop_at_first_match [Boolean] set if stop at first match, default is false 176 | # @option recursive [Boolean] set if recursively query child nodes, default is true 177 | # @yield run on the matching nodes. 178 | def within_node(nql_or_rules, options = {}, &block) 179 | Rewriter::WithinScope.new(self, nql_or_rules, options, &block).process 180 | rescue NodeQueryLexer::ScanError, Racc::ParseError 181 | raise NodeQuery::Compiler::ParseError, "Invalid query string: #{nql_or_rules}" 182 | end 183 | 184 | alias with_node within_node 185 | alias find_node within_node 186 | 187 | # It creates a {Synvert::Core::Rewriter::GotoScope} to go to a child node, 188 | # then continue operating on the child node. 189 | # @example 190 | # # head status: 406 191 | # with_node type: 'send', receiver: nil, message: 'head', arguments: { size: 1, first: { type: 'hash' } } do 192 | # goto_node 'arguments.first' do 193 | # end 194 | # end 195 | # @param child_node_name [Symbol|String] the name of the child nodes. 196 | # @param block [Block] block code to continue operating on the matching nodes. 197 | def goto_node(child_node_name, &block) 198 | Rewriter::GotoScope.new(self, child_node_name, &block).process 199 | end 200 | 201 | # It creates a {Synvert::Core::Rewriter::IfExistCondition} to check 202 | # if matching nodes exist in the child nodes, if so, then continue operating on each matching ast node. 203 | # @example 204 | # # Klass.any_instance.stub(:message) 205 | # with_node type: 'send', message: 'stub', arguments: { first: { type: { not: 'hash' } } } do 206 | # if_exist_node type: 'send', message: 'any_instance' do 207 | # end 208 | # end 209 | # @param nql_or_rules [String|Hash] nql or rules to check mathing ast nodes. 210 | # @param block [Block] block code to continue operating on the matching nodes. 211 | def if_exist_node(nql_or_rules, &block) 212 | Rewriter::IfExistCondition.new(self, nql_or_rules, &block).process 213 | end 214 | 215 | # It creates a {Synvert::Core::Rewriter::UnlessExistCondition} to check 216 | # if matching nodes doesn't exist in the child nodes, if so, then continue operating on each matching ast node. 217 | # @example 218 | # # obj.stub(:message) 219 | # with_node type: 'send', message: 'stub', arguments: { first: { type: { not: 'hash' } } } do 220 | # unless_exist_node type: 'send', message: 'any_instance' do 221 | # end 222 | # end 223 | # @param nql_or_rules [String|Hash] nql or rules to check mathing ast nodes. 224 | # @param block [Block] block code to continue operating on the matching nodes. 225 | def unless_exist_node(nql_or_rules, &block) 226 | Rewriter::UnlessExistCondition.new(self, nql_or_rules, &block).process 227 | end 228 | 229 | # It appends the code to the bottom of current node body. 230 | # @example 231 | # # def teardown 232 | # # clean_something 233 | # # end 234 | # # => 235 | # # def teardown 236 | # # clean_something 237 | # # super 238 | # # end 239 | # with_node type: 'def', name: 'steardown' do 240 | # append 'super' 241 | # end 242 | # @param code [String] code need to be appended. 243 | def append(code) 244 | @current_mutation.append(@current_node, code) 245 | end 246 | 247 | # It prepends the code to the top of current node body. 248 | # @example 249 | # # def setup 250 | # # do_something 251 | # # end 252 | # # => 253 | # # def setup 254 | # # super 255 | # # do_something 256 | # # end 257 | # with_node type: 'def', name: 'setup' do 258 | # prepend 'super' 259 | # end 260 | # @param code [String] code need to be prepended. 261 | def prepend(code) 262 | @current_mutation.prepend(@current_node, code) 263 | end 264 | 265 | # It inserts code. 266 | # @example 267 | # # open('http://test.com') 268 | # # => 269 | # # URI.open('http://test.com') 270 | # with_node type: 'send', receiver: nil, message: 'open' do 271 | # insert 'URI.', at: 'beginning' 272 | # end 273 | # @param code [String] code need to be inserted. 274 | # @param at [String] insert position, beginning or end 275 | # @param to [String] where to insert, if it is nil, will insert to current node. 276 | # @param and_comma [Boolean] insert extra comma. 277 | def insert(code, at: 'end', to: nil, and_comma: false) 278 | @current_mutation.insert(@current_node, code, at: at, to: to, and_comma: and_comma) 279 | end 280 | 281 | # It inserts the code next to the current node. 282 | # @example 283 | # # Synvert::Application.config.secret_token = "0447aa931d42918bfb934750bb78257088fb671186b5d1b6f9fddf126fc8a14d34f1d045cefab3900751c3da121a8dd929aec9bafe975f1cabb48232b4002e4e" 284 | # # => 285 | # # Synvert::Application.config.secret_token = "0447aa931d42918bfb934750bb78257088fb671186b5d1b6f9fddf126fc8a14d34f1d045cefab3900751c3da121a8dd929aec9bafe975f1cabb48232b4002e4e" 286 | # # Synvert::Application.config.secret_key_base = "bf4f3f46924ecd9adcb6515681c78144545bba454420973a274d7021ff946b8ef043a95ca1a15a9d1b75f9fbdf85d1a3afaf22f4e3c2f3f78e24a0a188b581df" 287 | # with_node type: 'send', message: 'secret_token=' do 288 | # insert_after "{{receiver}}.secret_key_base = \"#{SecureRandom.hex(64)}\"" 289 | # end 290 | # @param code [String] code need to be inserted. 291 | # @param to [String] where to insert, if it is nil, will insert to current node. 292 | # @param and_comma [Boolean] insert extra comma. 293 | def insert_after(code, to: nil, and_comma: false) 294 | column = ' ' * @current_mutation.adapter.get_start_loc(@current_node, to).column 295 | @current_mutation.insert(@current_node, "\n#{column}#{code}", at: 'end', to: to, and_comma: and_comma) 296 | end 297 | 298 | # It inserts the code previous to the current node. 299 | # @example 300 | # # Synvert::Application.config.secret_token = "0447aa931d42918bfb934750bb78257088fb671186b5d1b6f9fddf126fc8a14d34f1d045cefab3900751c3da121a8dd929aec9bafe975f1cabb48232b4002e4e" 301 | # # => 302 | # # Synvert::Application.config.secret_key_base = "bf4f3f46924ecd9adcb6515681c78144545bba454420973a274d7021ff946b8ef043a95ca1a15a9d1b75f9fbdf85d1a3afaf22f4e3c2f3f78e24a0a188b581df" 303 | # # Synvert::Application.config.secret_token = "0447aa931d42918bfb934750bb78257088fb671186b5d1b6f9fddf126fc8a14d34f1d045cefab3900751c3da121a8dd929aec9bafe975f1cabb48232b4002e4e" 304 | # with_node type: 'send', message: 'secret_token=' do 305 | # insert_before "{{receiver}}.secret_key_base = \"#{SecureRandom.hex(64)}\"" 306 | # end 307 | # @param code [String] code need to be inserted. 308 | # @param to [String] where to insert, if it is nil, will insert to current node. 309 | # @param and_comma [Boolean] insert extra comma. 310 | def insert_before(code, to: nil, and_comma: false) 311 | column = ' ' * @current_mutation.adapter.get_start_loc(@current_node, to).column 312 | @current_mutation.insert(@current_node, "#{code}\n#{column}", at: 'beginning', to: to, and_comma: and_comma) 313 | end 314 | 315 | # It replaces erb stmt code to expr code. 316 | # @example 317 | # # <% form_for post do |f| %> 318 | # # <% end %> 319 | # # => 320 | # # <%= form_for post do |f| %> 321 | # # <% end %> 322 | # with_node type: 'block', caller: { type: 'send', receiver: nil, message: 'form_for' } do 323 | # replace_erb_stmt_with_expr 324 | # end 325 | def replace_erb_stmt_with_expr 326 | absolute_file_path = File.join(Configuration.root_path, @file_path) 327 | erb_source = read_source(absolute_file_path) 328 | action = Rewriter::ReplaceErbStmtWithExprAction.new(@current_node, erb_source, adapter: @current_mutation.adapter) 329 | add_action(action) 330 | end 331 | 332 | # It replaces the whole code of current node. 333 | # @example 334 | # # obj.stub(:foo => 1, :bar => 2) 335 | # # => 336 | # # allow(obj).to receive_messages(:foo => 1, :bar => 2) 337 | # with_node type: 'send', message: 'stub', arguments: { first: { type: 'hash' } } do 338 | # replace_with 'allow({{receiver}}).to receive_messages({{arguments}})' 339 | # end 340 | # @param code [String] code need to be replaced with. 341 | def replace_with(code) 342 | @current_mutation.replace_with(@current_node, code) 343 | end 344 | 345 | # It replaces the code of specified child nodes. 346 | # @example 347 | # # assert(object.empty?) 348 | # # => 349 | # # assert_empty(object) 350 | # with_node type: 'send', receiver: nil, message: 'assert', arguments: { size: 1, first: { type: 'send', message: 'empty?', arguments: { size: 0 } } } do 351 | # replace :message, with: 'assert_empty' 352 | # replace :arguments, with: '{{arguments.first.receiver}}' 353 | # end 354 | # @param selectors [Array] selector names of child node. 355 | # @param with [String] code need to be replaced with. 356 | def replace(*selectors, with:) 357 | @current_mutation.replace(@current_node, *selectors, with: with) 358 | end 359 | 360 | # It removes current node. 361 | # @example 362 | # with_node type: 'send', message: { in: %w[puts p] } do 363 | # remove 364 | # end 365 | # @option and_comma [Boolean] delete extra comma. 366 | def remove(and_comma: false) 367 | @current_mutation.remove(@current_node, and_comma: and_comma) 368 | end 369 | 370 | # It deletes child nodes. 371 | # @example 372 | # # FactoryBot.create(...) 373 | # # => 374 | # # create(...) 375 | # with_node type: 'send', receiver: 'FactoryBot', message: 'create' do 376 | # delete :receiver, :dot 377 | # end 378 | # @param selectors [Array] selector names of child node. 379 | # @option and_comma [Boolean] delete extra comma. 380 | def delete(*selectors, and_comma: false) 381 | @current_mutation.delete(@current_node, *selectors, and_comma: and_comma) 382 | end 383 | 384 | # It wraps current node with prefix and suffix code. 385 | # @example 386 | # # class Foobar 387 | # # end 388 | # # => 389 | # # module Synvert 390 | # # class Foobar 391 | # # end 392 | # # end 393 | # within_node type: 'class' do 394 | # wrap prefix: 'module Synvert', suffix: 'end', newline: true 395 | # end 396 | # @param prefix [String] prefix code need to be wrapped with. 397 | # @param suffix [String] suffix code need to be wrapped with. 398 | # @param newline [Boolean] if wrap code in newline, default is false 399 | def wrap(prefix:, suffix:, newline: false) 400 | @current_mutation.wrap(@current_node, prefix: prefix, suffix: suffix, newline: newline) 401 | end 402 | 403 | # No operation. 404 | def noop 405 | @current_mutation.noop(@current_node) 406 | end 407 | 408 | # Group actions. 409 | # @example 410 | # group do 411 | # delete :message, :dot 412 | # replace 'receiver.caller.message', with: 'flat_map' 413 | # end 414 | def group(&block) 415 | @current_mutation.group(&block) 416 | end 417 | 418 | # Add a custom action. 419 | # @example 420 | # remover_action = NodeMutation::RemoveAction.new(node) 421 | # add_action(remover_action) 422 | # @param action [Synvert::Core::Rewriter::Action] action 423 | def add_action(action) 424 | @current_mutation.actions << action.process 425 | end 426 | 427 | # It creates a {Synvert::Core::Rewriter::Warning} to save warning message. 428 | # @example 429 | # within_files 'vendor/plugins' do 430 | # warn 'Rails::Plugin is deprecated and will be removed in Rails 4.0. Instead of adding plugins to vendor/plugins use gems or bundler with path or git dependencies.' 431 | # end 432 | # @param message [String] warning message. 433 | def warn(message) 434 | line = @current_mutation.adapter.get_start_loc(@current_node).line 435 | @rewriter.add_warning Rewriter::Warning.new(@file_path, line, message) 436 | end 437 | 438 | # It adds a callback when visiting an ast node. 439 | # @example 440 | # add_callback :class, at: 'start' do |node| 441 | # # do something when visiting class node 442 | # end 443 | # @param node_type [Symbol] node type 444 | # @param at [String] at start or end 445 | # @yield block code to run when visiting the node 446 | def add_callback(node_type, at: 'start', &block) 447 | @current_visitor.add_callback(node_type, at: at, &block) 448 | end 449 | 450 | # Wrap str string with single or doulbe quotes based on Configuration.single_quote. 451 | # @param str [String] 452 | # @return [String] quoted string 453 | def wrap_with_quotes(str) 454 | quote = Configuration.single_quote ? "'" : '"'; 455 | another_quote = Configuration.single_quote ? '"' : "'"; 456 | if str.include?(quote) && !str.include?(another_quote) 457 | return "#{another_quote}#{str}#{another_quote}" 458 | end 459 | 460 | escaped_str = str.gsub(quote) { |_char| '\\' + quote } 461 | quote + escaped_str + quote 462 | end 463 | 464 | # Indents the given source code by the specified tab size. 465 | # 466 | # @param source [String] The source code to be indented. 467 | # @param tab_size [Integer] The number of spaces per tab. 468 | # @return [String] The indented source code. 469 | def indent(source, tab_size: 1) 470 | source.each_line.map { |line| (' ' * NodeMutation.tab_width * tab_size) + line }.join 471 | end 472 | 473 | # Dedents the given source code by removing leading spaces or tabs. 474 | # 475 | # @param source [String] The source code to dedent. 476 | # @param tab_size [Integer] The number of spaces per tab (default is 1). 477 | # @return [String] The dedented source code. 478 | def dedent(source, tab_size: 1) 479 | source.each_line.map { |line| line.sub(/^ {#{NodeMutation.tab_width * tab_size}}/, '') }.join 480 | end 481 | 482 | private 483 | 484 | # Read file source. 485 | # @param file_path [String] file path 486 | # @return [String] file source 487 | def read_source(file_path) 488 | File.read(file_path, encoding: 'UTF-8') 489 | end 490 | 491 | # Write file source to file. 492 | # @param file_path [String] file path 493 | # @param source [String] file source 494 | def write_source(file_path, source) 495 | File.write(file_path, source.gsub(/ +\n/, "\n")) 496 | end 497 | 498 | # Parse code ast node. 499 | # 500 | # @param file_path [String] file path 501 | # @param encoded_source [String] encoded source code 502 | # @return [Node] ast node for file 503 | def parse_code(file_path, encoded_source) 504 | case @current_parser 505 | when Synvert::SYNTAX_TREE_PARSER 506 | parse_code_by_syntax_tree(file_path, encoded_source) 507 | when Synvert::PRISM_PARSER 508 | parse_code_by_prism(file_path, encoded_source) 509 | when Synvert::PARSER_PARSER 510 | parse_code_by_parser(file_path, encoded_source) 511 | else 512 | raise Errors::ParserNotSupported.new("Parser #{@current_parser} not supported") 513 | end 514 | end 515 | 516 | # Parse code ast node by parser. 517 | # 518 | # @param file_path [String] file path 519 | # @param encoded_source [String] encoded source code 520 | # @return [Node] ast node for file 521 | def parse_code_by_parser(file_path, encoded_source) 522 | buffer = Parser::Source::Buffer.new file_path 523 | buffer.source = encoded_source 524 | 525 | parser = Parser::CurrentRuby.new 526 | parser.reset 527 | parser.parse buffer 528 | end 529 | 530 | # Parse code ast node by syntax_tree. 531 | # 532 | # @param file_path [String] file path 533 | # @param encoded_source [String] encoded source code 534 | # @return [Node] ast node for file 535 | def parse_code_by_syntax_tree(_file_path, encoded_source) 536 | SyntaxTree.parse(encoded_source).statements 537 | end 538 | 539 | # Parse code ast node by prism. 540 | # 541 | # @param file_path [String] file path 542 | # @param encoded_source [String] encoded source code 543 | # @return [Node] ast node for file 544 | def parse_code_by_prism(_file_path, encoded_source) 545 | result = Prism.parse(encoded_source) 546 | result.value.statements 547 | end 548 | end 549 | end 550 | -------------------------------------------------------------------------------- /spec/synvert/core/rewriter/instance_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | module Synvert::Core 6 | describe Rewriter::Instance do 7 | let(:instance) { 8 | rewriter = Rewriter.new('foo', 'bar') 9 | Rewriter::Instance.new(rewriter, 'code.rb') 10 | } 11 | 12 | it 'parses find_node' do 13 | scope = double 14 | block = proc {} 15 | expect(Rewriter::WithinScope).to receive(:new).with( 16 | instance, 17 | '.send[message=create]', 18 | {}, 19 | &block 20 | ).and_return(scope) 21 | expect(scope).to receive(:process) 22 | instance.find_node('.send[message=create]', &block) 23 | end 24 | 25 | it 'raises ParseError when parsing invalid nql' do 26 | block = proc {} 27 | expect { 28 | instance.find_node('synvert', &block) 29 | }.to raise_error(NodeQuery::Compiler::ParseError) 30 | 31 | expect { 32 | instance.find_node('.block .hash', &block) 33 | }.to raise_error(NodeQuery::Compiler::ParseError) 34 | end 35 | 36 | it 'parses within_node' do 37 | scope = double 38 | block = proc {} 39 | expect(Rewriter::WithinScope).to receive(:new) 40 | .with(instance, { type: 'send', message: 'create' }, {}, &block) 41 | .and_return(scope) 42 | expect(scope).to receive(:process) 43 | instance.within_node(type: 'send', message: 'create', &block) 44 | end 45 | 46 | it 'parses with_node' do 47 | scope = double 48 | block = proc {} 49 | expect(Rewriter::WithinScope).to receive(:new) 50 | .with(instance, { type: 'send', message: 'create' }, {}, &block) 51 | .and_return(scope) 52 | expect(scope).to receive(:process) 53 | instance.with_node(type: 'send', message: 'create', &block) 54 | end 55 | 56 | it 'parses within_node with stop_at_first_match true' do 57 | scope = double 58 | block = proc {} 59 | expect(Rewriter::WithinScope).to receive(:new) 60 | .with(instance, { type: 'send', message: 'create' }, { stop_at_first_match: true }, &block) 61 | .and_return(scope) 62 | expect(scope).to receive(:process) 63 | instance.within_node({ type: 'send', message: 'create' }, { stop_at_first_match: true }, &block) 64 | end 65 | 66 | it 'parses goto_node' do 67 | scope = double 68 | block = proc {} 69 | expect(Rewriter::GotoScope).to receive(:new).with(instance, 'caller.receiver', &block).and_return(scope) 70 | expect(scope).to receive(:process) 71 | instance.goto_node('caller.receiver', &block) 72 | end 73 | 74 | it 'parses if_exist_node' do 75 | condition = double 76 | block = proc {} 77 | expect(Rewriter::IfExistCondition).to receive(:new) 78 | .with(instance, { type: 'send', message: 'create' }, &block) 79 | .and_return(condition) 80 | expect(condition).to receive(:process) 81 | instance.if_exist_node(type: 'send', message: 'create', &block) 82 | end 83 | 84 | it 'parses unless_exist_node' do 85 | condition = double 86 | block = proc {} 87 | expect(Rewriter::UnlessExistCondition).to receive(:new) 88 | .with(instance, { type: 'send', message: 'create' }, &block) 89 | .and_return(condition) 90 | expect(condition).to receive(:process) 91 | instance.unless_exist_node(type: 'send', message: 'create', &block) 92 | end 93 | 94 | it 'parses append' do 95 | instance.instance_variable_set(:@current_mutation, double) 96 | instance.current_node = double 97 | expect(instance.instance_variable_get(:@current_mutation)).to receive(:append).with( 98 | instance.current_node, 99 | 'Foobar' 100 | ) 101 | instance.append 'Foobar' 102 | end 103 | 104 | it 'parses prepend' do 105 | instance.instance_variable_set(:@current_mutation, double) 106 | instance.current_node = double 107 | expect(instance.instance_variable_get(:@current_mutation)).to receive(:prepend).with( 108 | instance.current_node, 109 | 'Foobar' 110 | ) 111 | instance.prepend 'Foobar' 112 | end 113 | 114 | it 'parses insert at end' do 115 | instance.instance_variable_set(:@current_mutation, double) 116 | instance.current_node = double 117 | expect(instance.instance_variable_get(:@current_mutation)).to receive(:insert).with( 118 | instance.current_node, 119 | 'Foobar', 120 | at: 'end', 121 | to: 'receiver', 122 | and_comma: false 123 | ) 124 | instance.insert 'Foobar', to: 'receiver' 125 | end 126 | 127 | it 'parses insert at beginning' do 128 | instance.instance_variable_set(:@current_mutation, double) 129 | instance.current_node = double 130 | expect(instance.instance_variable_get(:@current_mutation)).to receive(:insert).with( 131 | instance.current_node, 132 | 'Foobar', 133 | at: 'beginning', 134 | to: nil, 135 | and_comma: false 136 | ) 137 | instance.insert 'Foobar', at: 'beginning' 138 | end 139 | 140 | it 'parses insert_after' do 141 | expect(@current_mutation).to receive_message_chain(:adapter, :get_start_loc, :column).and_return(2) 142 | instance.current_node = double 143 | expect(instance.instance_variable_get(:@current_mutation)).to receive(:insert).with( 144 | instance.current_node, 145 | "\n Foobar", 146 | at: 'end', 147 | to: 'caller', 148 | and_comma: false 149 | ) 150 | instance.insert_after 'Foobar', to: 'caller' 151 | end 152 | 153 | it 'parses insert_before' do 154 | expect(@current_mutation).to receive_message_chain(:adapter, :get_start_loc, :column).and_return(2) 155 | instance.current_node = double 156 | expect(instance.instance_variable_get(:@current_mutation)).to receive(:insert).with( 157 | instance.current_node, 158 | "Foobar\n ", 159 | at: 'beginning', 160 | to: 'caller', 161 | and_comma: false 162 | ) 163 | instance.insert_before 'Foobar', to: 'caller' 164 | end 165 | 166 | it 'parses replace_erb_stmt_with_expr' do 167 | adapter = NodeMutation::ParserAdapter.new 168 | instance.instance_variable_set(:@current_mutation, double(adapter: adapter)) 169 | instance.current_node = double 170 | action = double 171 | erb_source = '<% form_for @post do |f| %><% end %>' 172 | allow(File).to receive(:read).and_return(erb_source) 173 | expect(instance.instance_variable_get(:@current_mutation)).to receive(:actions).and_return([]) 174 | expect(Rewriter::ReplaceErbStmtWithExprAction).to receive(:new).with( 175 | instance.current_node, 176 | erb_source, 177 | adapter: adapter 178 | ).and_return(action) 179 | expect(action).to receive(:process) 180 | instance.replace_erb_stmt_with_expr 181 | end 182 | 183 | it 'parses replace_with' do 184 | instance.instance_variable_set(:@current_mutation, double) 185 | instance.current_node = double 186 | expect(instance.instance_variable_get(:@current_mutation)).to receive(:replace_with).with( 187 | instance.current_node, 188 | 'Foobar' 189 | ) 190 | instance.replace_with 'Foobar' 191 | end 192 | 193 | it 'parses replace with' do 194 | instance.instance_variable_set(:@current_mutation, double) 195 | instance.current_node = double 196 | expect(instance.instance_variable_get(:@current_mutation)).to receive(:replace).with( 197 | instance.current_node, 198 | :message, 199 | with: 'Foobar' 200 | ) 201 | instance.replace :message, with: 'Foobar' 202 | end 203 | 204 | it 'parses remove' do 205 | instance.instance_variable_set(:@current_mutation, double) 206 | instance.current_node = double 207 | expect(instance.instance_variable_get(:@current_mutation)).to receive(:remove).with( 208 | instance.current_node, 209 | and_comma: true 210 | ) 211 | instance.remove and_comma: true 212 | end 213 | 214 | it 'parses delete' do 215 | instance.instance_variable_set(:@current_mutation, double) 216 | instance.current_node = double 217 | expect(instance.instance_variable_get(:@current_mutation)).to receive(:delete).with( 218 | instance.current_node, 219 | :dot, 220 | :message, 221 | and_comma: true 222 | ) 223 | instance.delete :dot, :message, and_comma: true 224 | end 225 | 226 | it 'parses wrap' do 227 | instance.instance_variable_set(:@current_mutation, double) 228 | instance.current_node = double 229 | expect(instance.instance_variable_get(:@current_mutation)).to receive(:wrap).with( 230 | instance.current_node, 231 | prefix: 'module Foobar', 232 | suffix: 'end', 233 | newline: true 234 | ) 235 | instance.wrap prefix: 'module Foobar', suffix: 'end', newline: true 236 | end 237 | 238 | it 'parses noop' do 239 | instance.instance_variable_set(:@current_mutation, double) 240 | instance.current_node = double 241 | expect(instance.instance_variable_get(:@current_mutation)).to receive(:noop).with(instance.current_node) 242 | instance.noop 243 | end 244 | 245 | it 'parses group' do 246 | instance.instance_variable_set(:@current_mutation, double) 247 | instance.current_node = double 248 | expect(instance.instance_variable_get(:@current_mutation)).to receive(:group) 249 | instance.group {} 250 | end 251 | 252 | it 'parses warn' do 253 | instance.instance_variable_set(:@file_path, 'app/test.rb') 254 | expect(@current_mutation).to receive_message_chain(:adapter, :get_start_loc, :line).and_return(2) 255 | expect(Rewriter::Warning).to receive(:new).with('app/test.rb', 2, 'foobar') 256 | instance.warn 'foobar' 257 | end 258 | 259 | it 'parsers add_callback' do 260 | instance.instance_variable_set(:@current_visitor, double) 261 | block = proc {} 262 | expect(instance.instance_variable_get(:@current_visitor)).to receive(:add_callback).with( 263 | :class_node, 264 | at: 'start', 265 | &block 266 | ) 267 | instance.add_callback(:class_node, at: 'start', &block) 268 | end 269 | 270 | it 'adds action' do 271 | mutation = NodeMutation.new("", adapter: :parser) 272 | instance.instance_variable_set(:@current_mutation, mutation) 273 | action = double 274 | expect(action).to receive(:process).and_return(action) 275 | instance.add_action(action) 276 | expect(mutation.actions).to eq [action] 277 | end 278 | 279 | describe '#process' do 280 | let(:rewriter) { Rewriter.new('foo', 'bar') } 281 | 282 | it 'writes new code to file' do 283 | instance = 284 | Rewriter::Instance.new rewriter, 'spec/models/post_spec.rb' do 285 | with_node type: 'send', receiver: 'FactoryGirl', message: 'create' do 286 | replace_with 'create {{arguments}}' 287 | end 288 | end 289 | input = <<~EOS 290 | it 'uses factory_girl' do 291 | user = FactoryGirl.create :user 292 | post = FactoryGirl.create :post, user: user 293 | assert post.valid? 294 | end 295 | EOS 296 | output = <<~EOS 297 | it 'uses factory_girl' do 298 | user = create :user 299 | post = create :post, user: user 300 | assert post.valid? 301 | end 302 | EOS 303 | expect(File).to receive(:read).with('./spec/models/post_spec.rb', encoding: 'UTF-8').and_return(input) 304 | expect(File).to receive(:write).with('./spec/models/post_spec.rb', output) 305 | instance.process 306 | end 307 | 308 | it 'does not write if file content is not changed' do 309 | instance = 310 | Rewriter::Instance.new rewriter, 'spec/spec_helper.rb' do 311 | with_node type: 'block', caller: { receiver: 'RSpec', message: 'configure' } do 312 | unless_exist_node type: 'send', message: 'include', arguments: ['FactoryGirl::Syntax::Methods'] do 313 | insert '{{arguments.first}}.include FactoryGirl::Syntax::Methods' 314 | end 315 | end 316 | end 317 | input = <<~EOS 318 | RSpec.configure do |config| 319 | config.include FactoryGirl::Syntax::Methods 320 | end 321 | EOS 322 | output = <<~EOS 323 | RSpec.configure do |config| 324 | config.include FactoryGirl::Syntax::Methods 325 | end 326 | EOS 327 | expect(File).to receive(:read).with('./spec/spec_helper.rb', encoding: 'UTF-8').and_return(input) 328 | expect(File).not_to receive(:write).with('./spec/spec_helper.rb', output) 329 | instance.process 330 | end 331 | 332 | it 'updates file_source and file_ast when writing a file' do 333 | instance = 334 | Rewriter::Instance.new rewriter, 'spec/models/post_spec.rb' do 335 | with_node type: 'send', receiver: 'FactoryGirl', message: 'create' do 336 | replace_with 'create {{arguments}}' 337 | end 338 | end 339 | input = <<~EOS 340 | it 'uses factory_girl' do 341 | user = FactoryGirl.create :user 342 | post = FactoryGirl.create :post, user: user 343 | assert post.valid? 344 | end 345 | EOS 346 | output = <<~EOS 347 | it 'uses factory_girl' do 348 | user = create :user 349 | post = create :post, user: user 350 | assert post.valid? 351 | end 352 | EOS 353 | expect(File).to receive(:read).with('./spec/models/post_spec.rb', encoding: 'UTF-8').and_return(input) 354 | expect(File).to receive(:write).with('./spec/models/post_spec.rb', output) 355 | expect(File).to receive(:read).with('./spec/models/post_spec.rb', encoding: 'UTF-8').and_return(output) 356 | instance.process 357 | instance.process 358 | expect(rewriter.affected_files).to be_include('spec/models/post_spec.rb') 359 | end 360 | 361 | it 'updates erb file' do 362 | instance = 363 | Rewriter::Instance.new rewriter, 'app/views/posts/_form.html.erb' do 364 | with_node type: 'send', receiver: nil, message: 'form_for' do 365 | replace_erb_stmt_with_expr 366 | end 367 | end 368 | input = <<~EOS 369 | <% form_for @post do |f| %> 370 | <% end %> 371 | EOS 372 | output = <<~EOS 373 | <%= form_for @post do |f| %> 374 | <% end %> 375 | EOS 376 | allow(File).to receive(:read).with('./app/views/posts/_form.html.erb', encoding: 'UTF-8').and_return(input) 377 | expect(File).to receive(:write).with('./app/views/posts/_form.html.erb', output) 378 | instance.process 379 | end 380 | 381 | it 'updates haml file' do 382 | instance = 383 | Rewriter::Instance.new rewriter, 'app/views/posts/_form.html.haml' do 384 | with_node node_type: 'ivar' do 385 | replace_with 'post' 386 | end 387 | end 388 | input = <<~EOS 389 | = form_for @post do |f| 390 | = form_for @post do |f| 391 | EOS 392 | output = <<~EOS 393 | = form_for post do |f| 394 | = form_for post do |f| 395 | EOS 396 | allow(File).to receive(:read).with('./app/views/posts/_form.html.haml', encoding: 'UTF-8').and_return(input) 397 | expect(File).to receive(:write).with('./app/views/posts/_form.html.haml', output) 398 | instance.process 399 | end 400 | 401 | it 'updates slim file' do 402 | instance = 403 | Rewriter::Instance.new rewriter, 'app/views/posts/_form.html.slim' do 404 | with_node node_type: 'ivar' do 405 | replace_with 'post' 406 | end 407 | end 408 | input = <<~EOS 409 | = form_for @post do |f| 410 | = form_for @post do |f| 411 | EOS 412 | output = <<~EOS 413 | = form_for post do |f| 414 | = form_for post do |f| 415 | EOS 416 | allow(File).to receive(:read).with('./app/views/posts/_form.html.slim', encoding: 'UTF-8').and_return(input) 417 | expect(File).to receive(:write).with('./app/views/posts/_form.html.slim', output) 418 | instance.process 419 | end 420 | 421 | it 'visits with callbacks' do 422 | names = [] 423 | instance = 424 | Rewriter::Instance.new rewriter, 'app/models/synvert/user.rb' do 425 | add_callback :module do |node| 426 | names << node.name.to_source 427 | end 428 | add_callback :class do |node| 429 | names << node.name.to_source 430 | end 431 | end 432 | expect(File).to receive(:read).with('./app/models/synvert/user.rb', encoding: 'UTF-8').and_return(<<~EOF) 433 | module Synvert 434 | class User 435 | end 436 | end 437 | EOF 438 | instance.process 439 | expect(names).to eq ['Synvert', 'User'] 440 | end 441 | end 442 | 443 | describe '#test' do 444 | let(:rewriter) { Rewriter.new('foo', 'bar') } 445 | 446 | it 'gets actions if affected' do 447 | instance = 448 | Rewriter::Instance.new rewriter, 'spec/models/post_spec.rb' do 449 | with_node type: 'send', receiver: 'FactoryGirl', message: 'create' do 450 | replace_with 'create {{arguments}}' 451 | end 452 | end 453 | input = <<~EOS 454 | it 'uses factory_girl' do 455 | user = FactoryGirl.create :user 456 | post = FactoryGirl.create :post, user: user 457 | assert post.valid? 458 | end 459 | EOS 460 | expect(File).to receive(:read).with('./spec/models/post_spec.rb', encoding: 'UTF-8').and_return(input) 461 | results = instance.test 462 | expect(results.file_path).to eq 'spec/models/post_spec.rb' 463 | expect(results.actions).to eq [ 464 | NodeMutation::Struct::Action.new(:replace, 35, 59, 'create :user'), 465 | NodeMutation::Struct::Action.new(:replace, 69, 105, 'create :post, user: user') 466 | ] 467 | end 468 | 469 | context 'Configuration.test_result is new_source' do 470 | before { Configuration.test_result = 'new_source' } 471 | after { Configuration.test_result = nil } 472 | 473 | it 'gets new_source' do 474 | instance = 475 | Rewriter::Instance.new rewriter, 'spec/models/post_spec.rb' do 476 | with_node type: 'send', receiver: 'FactoryGirl', message: 'create' do 477 | replace_with 'create {{arguments}}' 478 | end 479 | end 480 | input = <<~EOS 481 | it 'uses factory_girl' do 482 | user = FactoryGirl.create :user 483 | post = FactoryGirl.create :post, user: user 484 | assert post.valid? 485 | end 486 | EOS 487 | expect(File).to receive(:read).with('./spec/models/post_spec.rb', encoding: 'UTF-8').and_return(input) 488 | results = instance.test 489 | expect(results.file_path).to eq 'spec/models/post_spec.rb' 490 | expect(results.new_source).to eq <<~EOS 491 | it 'uses factory_girl' do 492 | user = create :user 493 | post = create :post, user: user 494 | assert post.valid? 495 | end 496 | EOS 497 | end 498 | end 499 | 500 | it 'gets nothing if not affected' do 501 | instance = 502 | Rewriter::Instance.new rewriter, 'spec/spec_helper.rb' do 503 | with_node type: 'block', caller: { receiver: 'RSpec', message: 'configure' } do 504 | unless_exist_node type: 'send', message: 'include', arguments: ['FactoryGirl::Syntax::Methods'] do 505 | insert '{{arguments.first}}.include FactoryGirl::Syntax::Methods' 506 | end 507 | end 508 | end 509 | input = <<~EOS 510 | RSpec.configure do |config| 511 | config.include FactoryGirl::Syntax::Methods 512 | end 513 | EOS 514 | expect(File).to receive(:read).with('./spec/spec_helper.rb', encoding: 'UTF-8').and_return(input) 515 | result = instance.test 516 | expect(result.file_path).to eq 'spec/spec_helper.rb' 517 | expect(result.actions).to eq [] 518 | end 519 | 520 | it 'updates erb file' do 521 | instance = 522 | Rewriter::Instance.new rewriter, 'app/views/posts/_form.html.erb' do 523 | with_node type: 'send', receiver: nil, message: 'form_for' do 524 | replace_erb_stmt_with_expr 525 | end 526 | end 527 | input = <<~EOS 528 | <% form_for @post do |f| %> 529 | <% end %> 530 | EOS 531 | allow(File).to receive(:read).with('./app/views/posts/_form.html.erb', encoding: 'UTF-8').and_return(input) 532 | result = instance.test 533 | expect(result.file_path).to eq 'app/views/posts/_form.html.erb' 534 | expect(result.actions).to eq [NodeMutation::Struct::Action.new(:insert, 2, 2, '=')] 535 | end 536 | 537 | it 'updates haml file' do 538 | instance = 539 | Rewriter::Instance.new rewriter, 'app/views/posts/_form.html.haml' do 540 | with_node node_type: 'ivar' do 541 | replace_with 'post' 542 | end 543 | end 544 | input = <<~EOS 545 | = form_for @post do |f| 546 | = form_for @post do |f| 547 | EOS 548 | allow(File).to receive(:read).with('./app/views/posts/_form.html.haml', encoding: 'UTF-8').and_return(input) 549 | result = instance.test 550 | expect(result.file_path).to eq 'app/views/posts/_form.html.haml' 551 | expect(result.actions).to eq [ 552 | NodeMutation::Struct::Action.new(:replace, "= form_for ".length, "= form_for @post".length, 'post'), 553 | NodeMutation::Struct::Action.new( 554 | :replace, 555 | "= form_for @post do |f|\n= form_for ".length, 556 | "= form_for @post do |f|\n= form_for @post".length, 557 | 'post' 558 | ), 559 | ] 560 | end 561 | 562 | it 'updates slim file' do 563 | instance = 564 | Rewriter::Instance.new rewriter, 'app/views/posts/_form.html.slim' do 565 | with_node node_type: 'ivar' do 566 | replace_with 'post' 567 | end 568 | end 569 | input = <<~EOS 570 | = form_for @post do |f| 571 | = form_for @post do |f| 572 | EOS 573 | allow(File).to receive(:read).with('./app/views/posts/_form.html.slim', encoding: 'UTF-8').and_return(input) 574 | result = instance.test 575 | expect(result.file_path).to eq 'app/views/posts/_form.html.slim' 576 | expect(result.actions).to eq [ 577 | NodeMutation::Struct::Action.new(:replace, "= form_for ".length, "= form_for @post".length, 'post'), 578 | NodeMutation::Struct::Action.new( 579 | :replace, 580 | "= form_for @post do |f|\n= form_for ".length, 581 | "= form_for @post do |f|\n= form_for @post".length, 582 | 'post' 583 | ), 584 | ] 585 | end 586 | 587 | it 'visits with callbacks' do 588 | names = [] 589 | instance = 590 | Rewriter::Instance.new rewriter, 'app/models/synvert/user.rb' do 591 | add_callback :module do |node| 592 | names << node.name.to_source 593 | end 594 | add_callback :class do |node| 595 | names << node.name.to_source 596 | end 597 | end 598 | expect(File).to receive(:read).with('./app/models/synvert/user.rb', encoding: 'UTF-8').and_return(<<~EOF) 599 | module Synvert 600 | class User 601 | end 602 | end 603 | EOF 604 | instance.test 605 | expect(names).to eq ['Synvert', 'User'] 606 | end 607 | end 608 | 609 | describe '#process_with_node' do 610 | it 'resets current_node' do 611 | node1 = double 612 | node2 = double 613 | instance.process_with_node(node1) do 614 | instance.current_node = node2 615 | expect(instance.current_node).to eq node2 616 | end 617 | expect(instance.current_node).to eq node1 618 | end 619 | end 620 | 621 | describe '#process_with_other_node' do 622 | it 'resets current_node' do 623 | node1 = double 624 | node2 = double 625 | node3 = double 626 | instance.current_node = node1 627 | instance.process_with_other_node(node2) do 628 | instance.current_node = node3 629 | expect(instance.current_node).to eq node3 630 | end 631 | expect(instance.current_node).to eq node1 632 | end 633 | end 634 | 635 | describe '#wrap_with_quotes' do 636 | context 'Configuration.single_quote = true' do 637 | it 'wraps with single quotes' do 638 | expect(instance.wrap_with_quotes('foobar')).to eq "'foobar'" 639 | end 640 | 641 | it 'wraps with double quotes if it contains single quote' do 642 | expect(instance.wrap_with_quotes("foo'bar")).to eq '"foo\'bar"' 643 | end 644 | 645 | it 'wraps with signle quotes and escapes single quote' do 646 | expect(instance.wrap_with_quotes("foo'\"bar")).to eq "'foo\\'\"bar'" 647 | end 648 | end 649 | 650 | context 'Configuration.single_quote = false' do 651 | before { Configuration.single_quote = false } 652 | after { Configuration.single_quote = nil } 653 | 654 | it 'wraps with double quotes' do 655 | expect(instance.wrap_with_quotes('foobar')).to eq '"foobar"' 656 | end 657 | 658 | it 'wraps with single quotes if it contains double quote' do 659 | expect(instance.wrap_with_quotes('foo"bar')).to eq "'foo\"bar'" 660 | end 661 | 662 | it 'wraps with double quotes and escapes double quote' do 663 | expect(instance.wrap_with_quotes("foo'\"bar")).to eq '"foo\'\\"bar"' 664 | end 665 | end 666 | end 667 | 668 | describe '#indent' do 669 | it 'adds white spaces' do 670 | old_code = "def foo\n bar\nend" 671 | new_code = instance.indent(old_code, tab_size: 2) 672 | expect(new_code).to eq " def foo\n bar\n end" 673 | end 674 | end 675 | 676 | describe '#dedent' do 677 | it 'removes white spaces' do 678 | old_code = " def foo\n bar\n end" 679 | new_code = instance.dedent(old_code, tab_size: 2) 680 | expect(new_code).to eq "def foo\n bar\nend" 681 | end 682 | end 683 | end 684 | end 685 | --------------------------------------------------------------------------------