glebm/i18n-tasks

View on GitHub
lib/i18n/tasks/scanners/ruby_ast_scanner.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
96%
# frozen_string_literal: true

require 'i18n/tasks/scanners/file_scanner'
require 'i18n/tasks/scanners/relative_keys'
require 'i18n/tasks/scanners/ruby_ast_call_finder'
require 'i18n/tasks/scanners/ast_matchers/default_i18n_subject_matcher'
require 'i18n/tasks/scanners/ast_matchers/message_receivers_matcher'
require 'i18n/tasks/scanners/ast_matchers/rails_model_matcher'
require 'parser/current'

module I18n::Tasks::Scanners
  # Scan for I18n.translate calls using whitequark/parser
  class RubyAstScanner < FileScanner
    include RelativeKeys
    include AST::Sexp

    MAGIC_COMMENT_PREFIX = /\A.\s*i18n-tasks-use\s+/.freeze

    def initialize(**args)
      super(**args)
      @parser = ::Parser::CurrentRuby.new
      @magic_comment_parser = ::Parser::CurrentRuby.new
      @matchers = setup_matchers
    end

    protected

    # Extract all occurrences of translate calls from the file at the given path.
    #
    # @return [Array<[key, Results::KeyOccurrence]>] each occurrence found in the file
    def scan_file(path)
      ast, comments = path_to_ast_and_comments(path)

      ast_to_occurences(ast) + comments_to_occurences(path, ast, comments)
    rescue Exception => e # rubocop:disable Lint/RescueException
      raise ::I18n::Tasks::CommandError.new(e, "Error scanning #{path}: #{e.message}")
    end

    # Parse file on path and returns AST and comments.
    #
    # @param path Path to file to parse
    # @return [{Parser::AST::Node}, [Parser::Source::Comment]]
    def path_to_ast_and_comments(path)
      @parser.reset
      @parser.parse_with_comments(make_buffer(path))
    end

    def keys_relative_to_calling_method?(path)
      /controllers|mailers/.match(path)
    end

    # Create an {Parser::Source::Buffer} with the given contents.
    # The contents are assigned a {Parser::Source::Buffer#raw_source}.
    #
    # @param path [String] Path to assign as the buffer name.
    # @param contents [String]
    # @return [Parser::Source::Buffer] file contents
    def make_buffer(path, contents = read_file(path))
      Parser::Source::Buffer.new(path).tap do |buffer|
        buffer.raw_source = contents
      end
    end

    # Convert an array of {Parser::Source::Comment} to occurrences.
    #
    # @param path Path to file
    # @param ast Parser::AST::Node
    # @param comments [Parser::Source::Comment]
    # @return [nil, [key, Occurrence]] full absolute key name and the occurrence.
    def comments_to_occurences(path, ast, comments)
      magic_comments = comments.select { |comment| comment.text =~ MAGIC_COMMENT_PREFIX }
      comment_to_node = Parser::Source::Comment.associate_locations(ast, magic_comments).tap do |h|
        h.transform_values!(&:first)
      end.invert

      magic_comments.flat_map do |comment|
        @parser.reset
        associated_node = comment_to_node[comment]
        ast = @parser.parse(make_buffer(path, comment.text.sub(MAGIC_COMMENT_PREFIX, '').split(/\s+(?=t)/).join('; ')))
        calls = RubyAstCallFinder.new.collect_calls(ast)
        results = []

        # method_name is not available at this stage
        calls.each do |send_node, _method_name|
          @matchers.each do |matcher|
            result = matcher.convert_to_key_occurrences(
              send_node,
              nil,
              location: associated_node || comment.location
            )
            results << result if result
          end
        end

        results
      end
    end

    # Convert {Parser::AST::Node} to occurrences.
    #
    # @param ast {Parser::Source::Comment}
    # @return [nil, [key, Occurrence]] full absolute key name and the occurrence.
    def ast_to_occurences(ast)
      calls = RubyAstCallFinder.new.collect_calls(ast)
      results = []
      calls.each do |send_node, method_name|
        @matchers.each do |matcher|
          result = matcher.convert_to_key_occurrences(send_node, method_name)
          results << result if result
        end
      end

      results
    end

    def setup_matchers
      if config[:receiver_messages]
        config[:receiver_messages].map do |receiver, message|
          AstMatchers::MessageReceiversMatcher.new(
            receivers: [receiver],
            message: message,
            scanner: self
          )
        end
      else
        matchers = %i[t t! translate translate!].map do |message|
          AstMatchers::MessageReceiversMatcher.new(
            receivers: [
              AST::Node.new(:const, [nil, :I18n]),
              nil
            ],
            message: message,
            scanner: self
          )
        end

        Array(config[:ast_matchers]).each do |class_name|
          matchers << ActiveSupport::Inflector.constantize(class_name).new(scanner: self)
        end

        matchers
      end
    end
  end
end