# frozen_string_literal: true require 'parallel' require 'fileutils' module Synvert::Core # Rewriter is the top level namespace in a snippet. # # One Rewriter checks if the depndency version matches, and it can contain one or many {Synvert::Core::Rewriter::Instance}, # which define the behavior what files and what codes to detect and rewrite to what code. class Rewriter DEFAULT_OPTIONS = { run_instance: true, write_to_file: true }.freeze autoload :ReplaceErbStmtWithExprAction, 'synvert/core/rewriter/action/replace_erb_stmt_with_expr_action' autoload :Warning, 'synvert/core/rewriter/warning' autoload :Instance, 'synvert/core/rewriter/instance' autoload :Scope, 'synvert/core/rewriter/scope' autoload :WithinScope, 'synvert/core/rewriter/scope/within_scope' autoload :GotoScope, 'synvert/core/rewriter/scope/goto_scope' autoload :Condition, 'synvert/core/rewriter/condition' autoload :IfExistCondition, 'synvert/core/rewriter/condition/if_exist_condition' autoload :UnlessExistCondition, 'synvert/core/rewriter/condition/unless_exist_condition' autoload :IfOnlyExistCondition, 'synvert/core/rewriter/condition/if_only_exist_condition' autoload :Helper, 'synvert/core/rewriter/helper' autoload :RubyVersion, 'synvert/core/rewriter/ruby_version' autoload :GemSpec, 'synvert/core/rewriter/gem_spec' class << self # Register a rewriter with its group and name. # # @param group [String] the rewriter group. # @param name [String] the unique rewriter name. # @param rewriter [Synvert::Core::Rewriter] the rewriter to register. def register(group, name, rewriter) group = group.to_s name = name.to_s rewriters[group] ||= {} rewriters[group][name] = rewriter end # Fetch a rewriter by group and name. # # @param group [String] rewrtier group. # @param name [String] rewrtier name. # @return [Synvert::Core::Rewriter] the matching rewriter. def fetch(group, name) group = group.to_s name = name.to_s rewriters.dig(group, name) end # Get all available rewriters # # @return [Hash<String, Hash<String, Rewriter>>] def availables rewriters end # Clear all registered rewriters. def clear rewriters.clear end private def rewriters @rewriters ||= {} end end # @!attribute [r] group # @return [String] the group of rewriter # @!attribute [r] name # @return [String] the unique name of rewriter # @!attribute [r] sub_snippets # @return [Array<Synvert::Core::Rewriter>] all rewriters this rewiter calls. # @!attribute [r] helper # @return [Array] helper methods. # @!attribute [r] warnings # @return [Array<Synvert::Core::Rewriter::Warning>] warning messages. # @!attribute [r] affected_files # @return [Set] affected fileds # @!attribute [r] ruby_version # @return [Rewriter::RubyVersion] the ruby version # @!attribute [r] gem_spec # @return [Rewriter::GemSpec] the gem spec # @!attribute [r] test_results # @return [Array<Object>] the test results # @!attribute [rw] options # @return [Hash] the rewriter options attr_reader :group, :name, :sub_snippets, :helpers, :warnings, :affected_files, :ruby_version, :gem_spec, :test_results attr_accessor :options # Initialize a Rewriter. # When a rewriter is initialized, it is already registered. # # @param group [String] group of the rewriter. # @param name [String] name of the rewriter. # @yield defines the behaviors of the rewriter, block code won't be called when initialization. def initialize(group, name, &block) @group = group @name = name @block = block @helpers = [] @sub_snippets = [] @warnings = [] @affected_files = Set.new @redo_until_no_change = false @options = DEFAULT_OPTIONS.dup @test_results = [] self.class.register(@group, @name, self) end # Process the rewriter. # It will call the block. def process @affected_files = Set.new instance_eval(&@block) process if !@affected_files.empty? && @redo_until_no_change # redo end # Process rewriter with sandbox mode. # It will call the block but doesn't change any file. def process_with_sandbox @options[:run_instance] = false process end def test @options[:write_to_file] = false @affected_files = Set.new instance_eval(&@block) if !@affected_files.empty? && @redo_until_no_change # redo test end @test_results end # Add a warning. # # @param warning [Synvert::Core::Rewriter::Warning] def add_warning(warning) @warnings << warning end # Add an affected file. # # @param file_path [String] def add_affected_file(file_path) @affected_files.add(file_path) end ####### # DSL # ####### # Configure the rewriter # @example # configure({ strategy: 'allow_insert_at_same_position' }) # @param options [Hash] # @option strategy [String] allow_insert_at_same_position def configure(options) if options[:strategy] @options[:strategy] = options[:strategy] end end # It sets description of the rewrite or get description. # @example # Synvert::Rewriter.new 'rspec', 'use_new_syntax' do # description 'It converts rspec code to new syntax, it calls all rspec sub snippets.' # end # @param description [String] rewriter description. # @return rewriter description. def description(description = nil) if description @description = description else @description end end # It checks if ruby version is greater than or equal to the specified ruby version. # @example # Synvert::Rewriter.new 'ruby', 'new_safe_navigation_operator' do # if_ruby '2.3.0' # end # @param version [String] specified ruby version. def if_ruby(version) @ruby_version = Rewriter::RubyVersion.new(version) end # It compares version of the specified gem. # @example # Synvert::Rewriter.new 'rails', 'upgrade_5_2_to_6_0' do # if_gem 'rails', '>= 6.0' # end # @param name [String] gem name. # @param version [String] equal, less than or greater than specified version, e.g. '>= 2.0.0', def if_gem(name, version) @gem_spec = Rewriter::GemSpec.new(name, version) end # It finds specified files. # It creates a {Synvert::Core::Rewriter::Instance} to rewrite code. # @example # Synvert::Rewriter.new 'rspec', 'be_close_to_be_within' do # within_files '**/*.rb' do # end # end # @param file_patterns [String|Array<String>] string pattern or list of string pattern to find files, e.g. ['spec/**/*_spec.rb'] # @param block [Block] the block to rewrite code in the matching files. def within_files(file_patterns, &block) return unless @options[:run_instance] return if @ruby_version && !@ruby_version.match? return if @gem_spec && !@gem_spec.match? if @options[:write_to_file] handle_one_file(Array(file_patterns)) do |file_path| instance = Rewriter::Instance.new(self, file_path, &block) instance.process end else results = handle_one_file(Array(file_patterns)) do |file_path| instance = Rewriter::Instance.new(self, file_path, &block) instance.test end merge_test_results(results) end end # It finds a specifiled file. alias within_file within_files # It adds a new file. # @example # Synvert::Rewriter.new 'rails', 'add_application_record' do # add_file 'app/models/application_record.rb', <<~EOS # class ApplicationRecord < ActiveRecord::Base # self.abstract_class = true # end # EOS # end # @param filename [String] file name of newly created file. # @param content [String] file body of newly created file. def add_file(filename, content) return unless @options[:run_instance] filepath = File.join(Configuration.root_path, filename) if File.exist?(filepath) puts "File #{filepath} already exists." return end FileUtils.mkdir_p File.dirname(filepath) File.write(filepath, content) end # It removes a file. # @example # Synvert::Rewriter.new 'rails', 'upgrade_4_0_to_4_1' do # remove_file 'config/initializers/secret_token.rb' # end # @param filename [String] file name. def remove_file(filename) return unless @options[:run_instance] file_path = File.join(Configuration.root_path, filename) File.delete(file_path) if File.exist?(file_path) end # It calls anther rewriter. # @example # Synvert::Rewriter.new 'minitest', 'better_syntax' do # add_snippet 'minitest', 'assert_empty' # add_snippet 'minitest', 'assert_equal_arguments_order' # add_snippet 'minitest/assert_instance_of' # add_snippet 'minitest/assert_kind_of' # add_snippet '/Users/flyerhzm/.synvert-ruby/lib/minitest/assert_match.rb' # add_snippet '/Users/flyerhzm/.synvert-ruby/lib/minitest/assert_nil.rb' # add_snippet 'https://github.com/xinminlabs/synvert-snippets-ruby/blob/main/lib/minitest/assert_silent.rb' # add_snippet 'https://github.com/xinminlabs/synvert-snippets-ruby/blob/main/lib/minitest/assert_truthy.rb' # end # @param group [String] group of another rewriter, if there's no name parameter, the group can be http url, file path or snippet name. # @param name [String] name of another rewriter. def add_snippet(group, name = nil) rewriter = if name Rewriter.fetch(group, name) || Utils.eval_snippet([group, name].join('/')) else Utils.eval_snippet(group) end return unless rewriter && rewriter.is_a?(Rewriter) rewriter.options = @options if !rewriter.options[:write_to_file] results = rewriter.test merge_test_results(results) elsif rewriter.options[:run_instance] rewriter.process else rewriter.process_with_sandbox end @sub_snippets << rewriter end # It defines helper method for {Synvert::Core::Rewriter::Instance}. # @example # Synvert::Rewriter.new 'rails', 'convert_active_record_dirty_5_0_to_5_1' do # helper_method :find_callbacks_and_convert do |callback_names, callback_changes| # # do anything, method find_callbacks_and_convert can be reused later. # end # within_files Synvert::RAILS_MODEL_FILES + Synvert::RAILS_OBSERVER_FILES do # find_callbacks_and_convert(before_callback_names, before_callback_changes) # find_callbacks_and_convert(after_callback_names, after_callback_changes) # end # end # @param name [String] helper method name. # @yield helper method block. def helper_method(name, &block) @helpers << { name: name, block: block } end # Rerun the snippet until no change. # @example # Synvert::Rewriter.new 'ruby', 'nested_class_definition' do # redo_until_no_change # end def redo_until_no_change @redo_until_no_change = true end private # Handle one file. # @param file_patterns [String] file patterns to find files. # @yield [file_path] block to handle file. # @yieldparam file_path [String] file path. def handle_one_file(file_patterns) if Configuration.number_of_workers > 1 Parallel.map(get_file_paths(file_patterns), in_processes: Configuration.number_of_workers) do |file_path| yield(file_path) end else get_file_paths(file_patterns).map do |file_path| yield(file_path) end end end # Get file paths. # @return [Array<String>] file paths def get_file_paths(file_patterns) Dir.chdir(Configuration.root_path) do only_paths = Configuration.only_paths.size > 0 ? Configuration.only_paths : ["."] only_paths.flat_map do |only_path| file_patterns.flat_map do |file_pattern| pattern = only_path == "." ? file_pattern : File.join(only_path, file_pattern) Dir.glob(pattern) end end - get_skip_files end end # Get skip files. # @return [Array<String>] skip files def get_skip_files Configuration.skip_paths.flat_map do |skip_path| if File.directory?(skip_path) Dir.glob(File.join(skip_path, "**/*")) elsif File.file?(skip_path) [skip_path] elsif skip_path.end_with?("**") || skip_path.end_with?("**/") Dir.glob(File.join(skip_path, "*")) else Dir.glob(skip_path) end end end def merge_test_results(results) @test_results += results.select { |result| result.affected? } end end end