diff --git a/lib/i18n/tasks/scanners/prism_parsers/nodes.rb b/lib/i18n/tasks/scanners/prism_parsers/nodes.rb new file mode 100644 index 00000000..1f2aea87 --- /dev/null +++ b/lib/i18n/tasks/scanners/prism_parsers/nodes.rb @@ -0,0 +1,368 @@ +# frozen_string_literal: true + +module I18n::Tasks::Scanners::PrismParsers + class BaseNode + attr_reader(:node) + + def initialize(node:) + @node = node + end + + def self.extract_value(node) + case node.type + when :symbol_node + node.value.to_s + when :string_node + node.content + when :array_node + node.child_nodes.map { |child| extract_value(child) } + else + fail(ArgumentError, "Cannot handle node type: #{node.type}") + end + end + + def extract_value(node) + self.class.extract_value(node) + end + + def self.extract_hash_value(node, key) + return unless %i[keyword_hash_node hash_node].include?(node.type) + + node.elements.each do |element| + next unless key.to_s == element.key.value.to_s + + return extract_value(element.value) + end + + nil + end + + def extract_hash_value(node, key) + self.class.extract_hash_value(node, key) + end + end + + class ModuleNode < BaseNode + def initialize(node:, child_nodes:) + @node = node + @child_nodes = child_nodes + super(node: node) + end + + def type + :module_node + end + + def name + @node.name.to_s + end + + def occurrences(path:, context: nil) + context ||= [] + context << name + @child_nodes.flat_map do |child_node| + next unless child_node.respond_to?(:occurrences) + + child_node.occurrences(path: path, context: context) + end + end + end + + class ClassNode < BaseNode + attr_reader(:methods, :calls) + + def initialize(node:, methods:, calls:, before_actions:) + @node = node + @methods = methods + @calls = calls + @before_actions = before_actions + @before_actions.each do |before_action| + before_action.add_method( + @methods.find { |method| method.name.to_s == before_action.name.to_s } + ) + end + + super(node: node) + end + + def occurrences(path:, context:) + context ||= [] + context += controller_context if class_type == :controller + + # TODO: Process non method translations as well + @methods + .filter_map do |method| + next unless method.respond_to?(:occurrences) + + method.occurrences( + path: path, + context: context, + before_actions: @before_actions, + methods: @methods + ) + end + .flatten(1) + end + + def type + :class_node + end + + def class_type + class_name = @node.name.to_s + if class_name.end_with?('Controller') + :controller + elsif class_name.end_with?('Helper') + :helper + elsif class_name.end_with?('Mailer') + :mailer + elsif class_name.end_with?('Job') + :job + elsif class_name.end_with?('Component') + :component + else + :ruby_class + end + end + + def controller_context + context = + @node.constant_path.full_name_parts.map { |s| s.to_s.underscore } + context.last.gsub!(/_controller\z/, '') + context + end + end + + class DefNode < BaseNode + attr_reader(:private_method) + + def initialize(node:, calls:, private_method:) + @node = node + @methods = methods + @calls = calls + @private_method = private_method + @called_from = [] + super(node: node) + end + + def add_call_from(method_name) + fail(ArgumentError, "Cyclic call detected: #{method_name} -> #{name}") if @called_from.include?(method_name) + + @called_from << method_name + end + + def name + @node.name.to_s + end + + def type + :def_node + end + + def occurrences( + path:, + context: nil, + method_name: @node.name.to_s, + before_actions: nil, + methods: nil + ) + context ||= [] + before_action_occurrences( + before_actions: before_actions, + path: path, + context: context + ) + + occurrences_from_calls( + path: path, + context: context, + method_name: method_name, + methods: methods + ) + end + + def occurrences_from_calls( + path:, + context: nil, + method_name: @node.name.to_s, + methods: nil + ) + context ||= [] + @calls + .filter_map do |call| + case call.type + when :translation_node + call.occurrences( + path: path, + private_method: @private_method && method_name == @node.name.to_s, + context: context + [method_name] + ) + else + other_method = methods&.find { |m| m.name.to_s == call.name.to_s } + next if other_method.nil? + + other_method.add_call_from(@node.name.to_s) + + other_method.occurrences( + path: path, + context: context, + method_name: name, + methods: methods + ) + end + end + .flatten(1) + end + + def before_action_occurrences(before_actions:, path:, context:) + return [] if private_method || before_actions.nil? + + before_actions + .filter_map do |before_action| + next unless before_action.applies_to?(name) + + before_action.occurrences( + path: path, + context: context, + method_name: name + ) + end + .flatten(1) + end + end + + class TranslationNode < BaseNode + attr_reader(:key, :node, :options) + + def initialize(node:, key:, options: nil) + @node = node + @key = key + @options = options + super(node: node) + end + + def type + :translation_node + end + + def relative_key? + @key.start_with?('.') + end + + def occurrences(path:, private_method: false, context: nil) + main_occurrence = + occurrence(path: path, private_method: private_method, context: context) + return nil if main_occurrence.nil? + + option_translations = + options + &.values + &.filter { |n| n.is_a?(TranslationNode) } + &.map do |value| + value.occurrences( + path: path, + private_method: private_method, + context: context + ) + end + &.flatten(1) || [] + + [main_occurrence, *option_translations].compact + end + + def full_key(private_method:, context: nil) + return nil if relative_key? && private_method + return key unless relative_key? + + translation_context = Array(context).flatten.map { |s| s.to_s.underscore } + + # We should handle fallback to key without method name + [*translation_context, key].compact.join('.').gsub('..', '.') + end + + private + + def occurrence(path:, private_method:, context:) + location = @node.location + + final_key = full_key(private_method: private_method, context: context) + return nil if final_key.nil? + + [ + final_key, + ::I18n::Tasks::Scanners::Results::Occurrence.new( + path: path, + line: @node.slice, + pos: location.start_offset, + line_pos: location.start_column, + line_num: location.start_line, + raw_key: key + ) + ] + end + end + + class BeforeActionNode < BaseNode + attr_reader(:name) + + def initialize(node:) + arguments_node = node.arguments + if arguments_node.arguments.empty? || arguments_node.arguments.size > 2 + fail( + ArgumentError, + "Cannot handle before_action with these arguments #{node.slice}" + ) + end + + name = extract_value(arguments_node.arguments[0]) + if arguments_node.arguments.length > 1 + options = + arguments_node.arguments.last + end + + @name = name + @node = node + only = extract_hash_value(options, :only) + except = extract_hash_value(options, :except) + @only = only.present? ? Array(only).map(&:to_s) : nil + @except = except.present? ? Array(except).map(&:to_s) : nil + + super(node: node) + end + + def type + :before_action_node + end + + def calls + @method&.calls || [] + end + + def add_method(method) + fail(ArgumentError, 'BeforeAction already has a method') if @method + + @method = method + end + + def applies_to?(method_name) + if @only.nil? && @except.nil? + true + elsif @only.nil? + !@except.include?(method_name.to_s) + elsif @except.nil? + @only.include?(method_name.to_s) + else + false + end + end + + def occurrences(path:, context: nil, method_name: nil) + return [] if @method.nil? + + @method.occurrences( + path: path, + context: context, + method_name: method_name + ) + end + end +end diff --git a/lib/i18n/tasks/scanners/prism_parsers/rails_visitor.rb b/lib/i18n/tasks/scanners/prism_parsers/rails_visitor.rb new file mode 100644 index 00000000..1dcbecd6 --- /dev/null +++ b/lib/i18n/tasks/scanners/prism_parsers/rails_visitor.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require 'prism/visitor' +require_relative 'nodes' + +module I18n::Tasks::Scanners::PrismParsers + class RailsVisitor < Prism::Visitor + def initialize + @private_methods = false + + super + end + + def visit_statements_node(node) + node.body.map { |child| visit(child) } + end + + def visit_program_node(node) + node.statements.body.map { |child| child.accept(self) } + end + + def visit_module_node(node) + ModuleNode.new( + node: node, + child_nodes: node.body.body.map { |n| visit(n) } + ) + end + + def visit_class_node(node) + child_nodes = node.body.body.map { |n| visit(n) } + methods = [] + calls = [] + before_actions = [] + + child_nodes.each do |child_node| + case child_node.class.to_s + when 'I18n::Tasks::Scanners::PrismParsers::DefNode' + methods << child_node + when 'I18n::Tasks::Scanners::PrismParsers::BeforeActionNode' + before_actions << child_node + else + calls << child_node + end + end + + ClassNode.new( + node: node, + methods: methods, + calls: calls, + before_actions: before_actions + ) + end + + def visit_def_node(node) + calls = node.body.child_nodes.filter_map { |n| visit(n) } + + DefNode.new(node: node, calls: calls, private_method: @private_methods) + end + + def visit_call_node(node) + case node.name + when :private + @private_methods = true + node + when :before_action + BeforeActionNode.new(node: node) + when :t, :'I18n.t', :t!, :'I18n.t!', :translate, :translate! + key_argument, options = node.arguments.arguments + options = + visit(options)&.to_h do |n| + [BaseNode.extract_value(n.key), visit(n.value)] + end + TranslationNode.new( + node: node, + key: BaseNode.extract_value(key_argument), + options: options + ) + else + node + end + end + end +end diff --git a/lib/i18n/tasks/scanners/rails_prism_scanner.rb b/lib/i18n/tasks/scanners/rails_prism_scanner.rb new file mode 100644 index 00000000..3a1d8d54 --- /dev/null +++ b/lib/i18n/tasks/scanners/rails_prism_scanner.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require_relative 'file_scanner' +require_relative 'ruby_ast_scanner' + +module I18n::Tasks::Scanners + class RailsPrismScanner < FileScanner + attr_reader(:type) + + def initialize(**args) + unless VISITOR + warn( + 'Please make sure `prism` is available to use this feature. Fallback to Ruby AST Scanner.' + ) + @fallback = RubyAstScanner.new(**args) + end + super + 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) + return @fallback.send(:scan_file, path) if @fallback.present? + + process_prism_parse_result(path, PARSER.parse_file(path).value) + rescue Exception => e # rubocop:disable Lint/RescueException + raise( + ::I18n::Tasks::CommandError.new( + e, + "Error scanning #{path}: #{e.message}" + ) + ) + end + + def process_prism_parse_result(path, parsed) + visitor = VISITOR.new + nodes = parsed.accept(visitor) + nodes.flat_map { |result| result.occurrences(path: path, context: nil) } + end + + begin + require 'prism' + require_relative 'prism_parsers/rails_visitor' + PARSER = Prism + VISITOR = I18n::Tasks::Scanners::PrismParsers::RailsVisitor + rescue LoadError + PARSER = nil + VISITOR = nil + end + end +end diff --git a/lib/i18n/tasks/used_keys.rb b/lib/i18n/tasks/used_keys.rb index 198ec8a0..a4f7fdca 100644 --- a/lib/i18n/tasks/used_keys.rb +++ b/lib/i18n/tasks/used_keys.rb @@ -4,6 +4,7 @@ require 'i18n/tasks/scanners/pattern_with_scope_scanner' require 'i18n/tasks/scanners/ruby_ast_scanner' require 'i18n/tasks/scanners/erb_ast_scanner' +require 'i18n/tasks/scanners/rails_prism_scanner' require 'i18n/tasks/scanners/scanner_multiplexer' require 'i18n/tasks/scanners/files/caching_file_finder_provider' require 'i18n/tasks/scanners/files/caching_file_reader' diff --git a/spec/rails_prism_scanner_spec.rb b/spec/rails_prism_scanner_spec.rb new file mode 100644 index 00000000..a99c774d --- /dev/null +++ b/spec/rails_prism_scanner_spec.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'RailsPrismScanner' do + describe 'with prism available', + if: I18n::Tasks::Scanners::RailsPrismScanner::PARSER.present? do + describe 'controllers' do + it 'detects controller' do + source = <<~RUBY + class EventsController < ApplicationController + before_action(:method_in_before_action1, only: :create) + before_action('method_in_before_action2', except: %i[create]) + + def create + t('.relative_key') + t('absolute_key') + I18n.t('very_absolute_key') + method_a + end + + def custom_action + t('.relative_key') + method_a + end + + private + + def method_a + t('.success') + end + + def method_in_before_action1 + t('.before_action1') + end + + def method_in_before_action2 + t('.before_action2') + end + end + RUBY + occurrences = + process_string('app/controllers/events_controller.rb', source) + expect(occurrences.map(&:first).uniq).to match_array( + %w[ + absolute_key + events.create.relative_key + events.create.success + events.create.before_action1 + very_absolute_key + events.custom_action.relative_key + events.custom_action.success + events.custom_action.before_action2 + ] + ) + end + + it 'errors on cyclic calls' do + source = <<~RUBY + class CyclicCallController + def method_a + method_b + end + + def method_b + method_a + end + end + RUBY + + expect do + process_string('spec/fixtures/cyclic_call_controller.rb', source) + end.to raise_error( + ArgumentError, + /Cyclic call detected: method_a -> method_b/ + ) + end + + it 'returns nothing if only relative keys and private methods' do + source = <<~RUBY + class EventsController + private + + def method_b + t('.relative_key') + end + end + RUBY + + expect( + process_string('app/controllers/events_controller.rb', source) + ).to be_empty + end + + it 'detects calls in methods' do + source = <<~RUBY + class EventsController + def create + t('.relative_key') + I18n.t("absolute_key") + method_b + end + + def method_b + t('.error') + t("absolute_error") + end + end + RUBY + + occurrences = + process_string('app/controllers/events_controller.rb', source) + + expect(occurrences.map(&:first).uniq).to match_array( + %w[ + absolute_key + absolute_error + events.create.relative_key + events.create.error + events.method_b.error + ] + ) + end + + it 'handles controller nested in modules' do + source = <<~RUBY + module Admin + class EventsController + def create + t('.relative_key') + I18n.t("absolute_key") + end + end + end + RUBY + + occurrences = + process_string('app/controllers/admin/events_controller.rb', source) + + expect(occurrences.map(&:first).uniq).to match_array( + %w[absolute_key admin.events.create.relative_key] + ) + end + + it 'handles controller with namespaced class name' do + source = <<~RUBY + class Admins::TestScopes::EventsController + def create + t('.relative_key') + I18n.t("absolute_key") + end + end + RUBY + + occurrences = + process_string('app/controllers/admin/events_controller.rb', source) + + expect(occurrences.map(&:first).uniq).to match_array( + %w[absolute_key admins.test_scopes.events.create.relative_key] + ) + end + end + + it 'detects mailer' do + occurrences = + process_path('spec/fixtures/used_keys/app/mailers/user_mailer.rb') + expect(occurrences.map(&:first).uniq).to be_empty + end + + it 'detects component' do + occurrences = + process_path( + 'spec/fixtures/used_keys/app/components/event_component.rb' + ) + expect(occurrences.map(&:first).uniq).not_to be_empty + end + + it 'detects class' do + occurrences = process_path('spec/fixtures/used_keys/a.rb') + expect(occurrences.map(&:first).uniq).not_to be_empty + end + + it 'detects file without class' do + source = <<~RUBY + t("what.is.this", parameter: I18n.translate("other.thing")) + RUBY + + occurrences = + process_string('spec/fixtures/file_without_class.rb', source) + + expect(occurrences.map(&:first).uniq).to match_array( + %w[what.is.this other.thing] + ) + end + end + + describe 'without prism available', + if: I18n::Tasks::Scanners::RailsPrismScanner::PARSER.nil? do + it '#used_keys - fallback to ruby ast parser' do + scanner = + I18n::Tasks::Scanners::RailsPrismScanner.new( + config: { + relative_roots: ['spec/fixtures/used_keys/app/controllers'] + } + ) + + occurrences = + scanner.send( + :scan_file, + 'spec/fixtures/used_keys/app/controllers/events_controller.rb' + ) + expect(occurrences.map(&:first).uniq).to match_array( + %w[absolute_key events.create.relative_key very_absolute_key] + ) + end + end + + def process_path(path) + I18n::Tasks::Scanners::RailsPrismScanner.new.send(:scan_file, path) + end + + def process_string(path, string) + parsed = + I18n::Tasks::Scanners::RailsPrismScanner::PARSER.parse(string).value + I18n::Tasks::Scanners::RailsPrismScanner.new.send( + :process_prism_parse_result, + path, + parsed + ) + end +end