jonatas/fast

View on GitHub
lib/fast/experiment.rb

Summary

Maintainability
A
1 hr
Test Coverage
D
66%
# frozen_string_literal: true

require 'fast'

# Allow to replace code managing multiple replacements and combining replacements.
# Useful for large codebase refactor and multiple replacements in the same file.
module Fast
  class << self
    # Fast.experiment is a shortcut to define new experiments and allow them to
    # work together in experiment combinations.
    #
    # The following experiment look into `spec` folder and try to remove
    # `before` and `after` blocks on testing code. Sometimes they're not
    # effective and we can avoid the hard work of do it manually.
    #
    # If the spec does not fail, it keeps the change.
    #
    # @example Remove useless before and after block
    #   Fast.experiment("RSpec/RemoveUselessBeforeAfterHook") do
    #     lookup 'spec'
    #     search "(block (send nil {before after}))"
    #     edit { |node| remove(node.loc.expression) }
    #     policy { |new_file| system("rspec --fail-fast #{new_file}") }
    #   end
    def experiment(name, &block)
      @experiments ||= {}
      @experiments[name] = Experiment.new(name, &block)
    end

    attr_reader :experiments
  end

  # Fast experiment allow the user to combine single replacements and make multiple
  # changes at the same time. Defining a policy is possible to check if the
  # experiment was successfull and keep changing the file using a specific
  # search.
  #
  # The experiment have a combination algorithm that recursively check what
  # combinations work with what combinations. It can delay years and because of
  # that it tries a first replacement targeting all the cases in a single file.
  #
  # You can define experiments and build experimental files to improve some code in
  # an automated way. Let's create a hook to check if a `before` or `after` block
  # is useless in a specific spec:
  #
  # @example Remove useless before or after block RSpec hooks
  #   #  Let's say you want to experimentally remove some before or after block
  #   #  in specs to check if some of them are weak or useless:
  #   #    RSpec.describe "something" do
  #   #      before { @a = 1 }
  #   #      before { @b = 1 }
  #   #      it { expect(@b).to be_eq(1) }
  #   #    end
  #   #
  #   #  The variable `@a` is not useful for the test, if I remove the block it
  #   #  should continue passing.
  #   #
  #   #    RSpec.describe "something" do
  #   #      before { @b = 1 }
  #   #      it { expect(@b).to be_eq(1) }
  #   #    end
  #   #
  #   #  But removing the next `before` block will fail:
  #   #    RSpec.describe "something" do
  #   #      before { @a = 1 }
  #   #      it { expect(@b).to be_eq(1) }
  #   #    end
  #   #  And the experiments will have a policy to check if `rspec` run without
  #   #  fail and only execute successfull replacements.
  #   Fast.experiment("RSpec/RemoveUselessBeforeAfterHook") do
  #     lookup 'spec' # all files in the spec folder
  #     search "(block (send nil {before after}))"
  #     edit {|node| remove(node.loc.expression) }
  #     policy {|new_file| system("rspec --fail-fast #{new_file}") }
  #   end
  #
  # @example Replace FactoryBot create with build_stubbed method
  #   # Let's say you want to try to automate some replacement of
  #   # `FactoryBot.create` to use `FactoryBot.build_stubbed`.
  #   # For specs let's consider the example we want to refactor:
  #   #   let(:person) { create(:person, :with_email) }
  #   # And the intent is replace to use `build_stubbed` instead of `create`:
  #   #   let(:person) { build_stubbed(:person, :with_email) }
  #   Fast.experiment('RSpec/ReplaceCreateWithBuildStubbed') do
  #     lookup 'spec'
  #     search '(block (send nil let (sym _)) (args) $(send nil create))'
  #     edit { |_, (create)| replace(create.loc.selector, 'build_stubbed') }
  #     policy { |new_file| system("rspec --format progress --fail-fast #{new_file}") }
  #   end
  # @see https://asciinema.org/a/177283
  class Experiment
    attr_writer :files
    attr_reader :name, :replacement, :expression, :files_or_folders, :ok_if

    def initialize(name, &block)
      @name = name
      puts "\nStarting experiment: #{name}"
      instance_exec(&block)
    end

    # It combines current experiment with {ExperimentFile#run}
    # @param [String] file to be analyzed by the experiment
    def run_with(file)
      ExperimentFile.new(file, self).run
    end

    # @param [String] expression with the node pattern to target nodes
    def search(expression)
      @expression = expression
    end

    # @param block yields the node that matches and return the block in the
    # instance context of a [Fast::Rewriter]
    def edit(&block)
      @replacement = block
    end

    # @param [String] files_or_folders that will be combined to find the {#files}
    def lookup(files_or_folders)
      @files_or_folders = files_or_folders
    end

    # It calls the block after the replacement and use the result
    # to drive the {Fast::ExperimentFile#ok_experiments} and {Fast::ExperimentFile#fail_experiments}.
    # @param block yields a temporary file with the content replaced in the current round.
    def policy(&block)
      @ok_if = block
    end

    # @return [Array<String>] with files from {#lookup} expression.
    def files
      @files ||= Fast.ruby_files_from(@files_or_folders)
    end

    # Iterates over all {#files} to {#run_with} them.
    # @return [void]
    def run
      files.map(&method(:run_with))
    end
  end

  # Suggest possible combinations of occurrences to replace.
  #
  # Check for {#generate_combinations} to understand the strategy of each round.
  class ExperimentCombinations
    attr_reader :combinations

    def initialize(round:, occurrences_count:, ok_experiments:, fail_experiments:)
      @round = round
      @ok_experiments = ok_experiments
      @fail_experiments = fail_experiments
      @occurrences_count = occurrences_count
    end

    # Generate different combinations depending on the current round.
    # * Round 1: Use {#individual_replacements}
    # * Round 2: Tries {#all_ok_replacements_combined}
    # * Round 3+: Follow {#ok_replacements_pair_combinations}
    def generate_combinations
      case @round
      when 1
        individual_replacements
      when 2
        all_ok_replacements_combined
      else
        ok_replacements_pair_combinations
      end
    end

    # Replace a single occurrence at each iteration and identify which
    # individual replacements work.
    def individual_replacements
      (1..@occurrences_count).to_a
    end

    # After identifying all individual replacements that work, try combining all
    # of them.
    def all_ok_replacements_combined
      [@ok_experiments.uniq.sort]
    end

    # Divide and conquer combining all successful individual replacements.
    def ok_replacements_pair_combinations
      @ok_experiments
        .combination(2)
        .map { |e| e.flatten.uniq.sort }
        .uniq - @fail_experiments - @ok_experiments
    end
  end

  # Combines an {Fast::Experiment} with a specific file.
  # It coordinates and regulate multiple replacements in the same file.
  # Everytime it {#run} a file, it uses {#partial_replace} and generate a
  # new file with the new content.
  # It executes the {Fast::Experiment#policy} block yielding the new file. Depending on the
  # policy result, it adds the occurrence to {#fail_experiments} or {#ok_experiments}.
  # When all possible occurrences are replaced in isolated experiments, it
  # #{build_combinations} with the winner experiments going to a next round of experiments
  # with multiple partial replacements until find all possible combinations.
  # @note it can easily spend days handling multiple one to one combinations,
  #   because of that, after the first round of replacements the algorithm goes
  #   replacing all winner solutions in the same shot. If it fails, it goes
  #   combining one to one.
  # @see Fast::Experiment
  # @example Temporary spec to analyze
  #   tempfile = Tempfile.new('some_spec.rb')
  #   tempfile.write <<~RUBY
  #     let(:user) { create(:user) }
  #     let(:address) { create(:address) }
  #     let(:phone_number) { create(:phone_number) }
  #     let(:country) { create(:country) }
  #     let(:language) { create(:language) }
  #   RUBY
  #   tempfile.close
  # @example Temporary experiment to replace create with build stubbed
  #   experiment = Fast.experiment('RSpec/ReplaceCreateWithBuildStubbed') do
  #     lookup 'some_spec.rb'
  #     search '(send nil create)'
  #     edit { |node| replace(node.loc.selector, 'build_stubbed') }
  #     policy { |new_file| system("rspec --fail-fast #{new_file}") }
  #   end
  # @example ExperimentFile exploring combinations and failures
  #   experiment_file = Fast::ExperimentFile.new(tempfile.path, experiment)
  #   experiment_file.build_combinations # => [1, 2, 3, 4, 5]
  #   experiment_file.ok_with(1)
  #   experiment_file.failed_with(2)
  #   experiment_file.ok_with(3)
  #   experiment_file.ok_with(4)
  #   experiment_file.ok_with(5)
  #   # Try a combination of all OK individual replacements.
  #   experiment_file.build_combinations # => [[1, 3, 4, 5]]
  #   experiment_file.failed_with([1, 3, 4, 5])
  #   # If the above failed, divide and conquer.
  #   experiment_file.build_combinations # => [[1, 3], [1, 4], [1, 5], [3, 4], [3, 5], [4, 5]]
  #   experiment_file.ok_with([1, 3])
  #   experiment_file.failed_with([1, 4])
  #   experiment_file.build_combinations # => [[4, 5], [1, 3, 4], [1, 3, 5]]
  #   experiment_file.failed_with([1, 3, 4])
  #   experiment_file.build_combinations # => [[4, 5], [1, 3, 5]]
  #   experiment_file.failed_with([4, 5])
  #   experiment_file.build_combinations # => [[1, 3, 5]]
  #   experiment_file.ok_with([1, 3, 5])
  #   experiment_file.build_combinations # => []
  class ExperimentFile
    attr_reader :ok_experiments, :fail_experiments, :experiment

    def initialize(file, experiment)
      @file = file
      @ast = Fast.ast_from_file(file) if file
      @experiment = experiment
      @ok_experiments = []
      @fail_experiments = []
      @round = 0
    end

    # @return [String] from {Fast::Experiment#expression}.
    def search
      experiment.expression
    end

    # @return [String] with a derived name with the combination number.
    def experimental_filename(combination)
      parts = @file.split('/')
      dir = parts[0..-2]
      filename = "experiment_#{[*combination].join('_')}_#{parts[-1]}"
      File.join(*dir, filename)
    end

    # Keep track of ok experiments depending on the current combination.
    # It keep the combinations unique removing single replacements after the
    # first round.
    # @return void
    def ok_with(combination)
      @ok_experiments << combination
      return unless combination.is_a?(Array)

      combination.each do |element|
        @ok_experiments.delete(element)
      end
    end

    # Track failed experiments to avoid run them again.
    # @return [void]
    def failed_with(combination)
      @fail_experiments << combination
    end

    # @return [Array<Astrolabe::Node>]
    def search_cases
      Fast.search(experiment.expression, @ast) || []
    end

    # rubocop:disable Metrics/MethodLength
    #
    # Execute partial replacements generating new file with the
    # content replaced.
    # @return [void]
    def partial_replace(*indices)
      replacement = experiment.replacement
      new_content = Fast.replace_file experiment.expression, @file do |node, *captures|
        if indices.nil? || indices.empty? || indices.include?(match_index)
          if replacement.parameters.length == 1
            instance_exec node, &replacement
          else
            instance_exec node, *captures, &replacement
          end
        end
      end
      return unless new_content

      write_experiment_file(indices, new_content)
      new_content
    end

    # rubocop:enable Metrics/MethodLength

    # Write new file name depending on the combination
    # @param [Array<Integer>] combination
    # @param [String] new_content to be persisted
    def write_experiment_file(combination, new_content)
      filename = experimental_filename(combination)
      File.open(filename, 'w+') { |f| f.puts new_content }
      filename
    end

    def done!
      count_executed_combinations = @fail_experiments.size + @ok_experiments.size
      puts "Done with #{@file} after #{count_executed_combinations} combinations"
      return unless perfect_combination = @ok_experiments.last # rubocop:disable Lint/AssignmentInCondition

      puts 'The following changes were applied to the file:'
      `diff #{experimental_filename(perfect_combination)} #{@file}`
      puts "mv #{experimental_filename(perfect_combination)} #{@file}"
      `mv #{experimental_filename(perfect_combination)} #{@file}`
    end

    # Increase the `@round` by 1 to {ExperimentCombinations#generate_combinations}.
    def build_combinations
      @round += 1
      ExperimentCombinations.new(
        round: @round,
        occurrences_count: search_cases.size,
        ok_experiments: @ok_experiments,
        fail_experiments: @fail_experiments
      ).generate_combinations
    end

    def run
      while (combinations = build_combinations).any?
        if combinations.size > 1000
          puts "Ignoring #{@file} because it has #{combinations.size} possible combinations"
          break
        end
        puts "#{@file} - Round #{@round} - Possible combinations: #{combinations.inspect}"
        while combination = combinations.shift # rubocop:disable Lint/AssignmentInCondition
          run_partial_replacement_with(combination)
        end
      end
      done!
    end

    # Writes a new file with partial replacements based on the current combination.
    # Raise error if no changes was made with the given combination indices.
    # @param [Array<Integer>] combination to be replaced.
    def run_partial_replacement_with(combination)
      content = partial_replace(*combination)
      experimental_file = experimental_filename(combination)

      File.open(experimental_file, 'w+') { |f| f.puts content }

      raise 'No changes were made to the file.' if FileUtils.compare_file(@file, experimental_file)

      result = experiment.ok_if.call(experimental_file)

      if result
        ok_with(combination)
        puts "✅ #{experimental_file} - Combination: #{combination}"
      else
        failed_with(combination)
        puts "🔴 #{experimental_file} - Combination: #{combination}"
      end
    end
  end
end