From 6a46c6192f45c692352460a00bb744dd959f7a5f Mon Sep 17 00:00:00 2001 From: David Wessman Date: Mon, 10 Jun 2024 20:51:30 +0200 Subject: [PATCH] PrismScanner: Contextual parsing for Rails (#565) --- CHANGES.md | 2 + README.md | 24 + lib/i18n/tasks/scanners/prism_scanner.rb | 71 +++ .../tasks/scanners/prism_scanners/nodes.rb | 549 ++++++++++++++++++ .../scanners/prism_scanners/rails_visitor.rb | 134 +++++ .../tasks/scanners/prism_scanners/visitor.rb | 168 ++++++ lib/i18n/tasks/used_keys.rb | 1 + .../app/controllers/events_controller.rb | 8 + spec/prism_scanner_spec.rb | 420 ++++++++++++++ spec/used_keys_ruby_spec.rb | 14 +- 10 files changed, 1384 insertions(+), 7 deletions(-) create mode 100644 lib/i18n/tasks/scanners/prism_scanner.rb create mode 100644 lib/i18n/tasks/scanners/prism_scanners/nodes.rb create mode 100644 lib/i18n/tasks/scanners/prism_scanners/rails_visitor.rb create mode 100644 lib/i18n/tasks/scanners/prism_scanners/visitor.rb create mode 100644 spec/prism_scanner_spec.rb diff --git a/CHANGES.md b/CHANGES.md index 7c83090a..e1a3f793 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,8 @@ * Uses AST-parser for all ERB-files, not just `.html.erb` * [Fixed regex in `PatternScanner`] (https://github.com/glebm/i18n-tasks/issues/572) +* Adds contextual parser to support more Rails-translations + [#565](https://github.com/glebm/i18n-tasks/pull/565) ## v1.0.14 diff --git a/README.md b/README.md index 19fe1b7e..99bf2ae3 100644 --- a/README.md +++ b/README.md @@ -483,6 +483,30 @@ OPENAI_API_KEY= OPENAI_MODEL= ``` +### Contextual Rails Parser + +There is an experimental feature to parse Rails with more context. `i18n-tasks` will support: +- Translations called in `before_actions` +- Translations called in nested methods +- `Model.human_attribute_name` calls +- `Model.model_name.human` calls + +Enabled it by adding the scanner in your `config/i18n-tasks.yml`: + +```ruby +<% I18n::Tasks.add_scanner( + 'I18n::Tasks::Scanners::PrismScanner', + only: %w(*.rb) +) %> +``` + +To only enable Ruby-scanning and not any Rails support, please add config under the `search` section: + +```yaml +search: + prism_visitor: "ruby" # default "rails" +``` + ## Interactive console `i18n-tasks irb` starts an IRB session in i18n-tasks context. Type `guide` for more information. diff --git a/lib/i18n/tasks/scanners/prism_scanner.rb b/lib/i18n/tasks/scanners/prism_scanner.rb new file mode 100644 index 00000000..a563b65e --- /dev/null +++ b/lib/i18n/tasks/scanners/prism_scanner.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require_relative 'file_scanner' +require_relative 'ruby_ast_scanner' + +module I18n::Tasks::Scanners + class PrismScanner < FileScanner + def initialize(**args) + unless RAILS_VISITOR || RUBY_VISITOR + warn( + 'Please make sure `prism` is available to use this feature. Fallback to Ruby AST Scanner.' + ) + end + super + + @visitor_class = config[:prism_visitor] == 'ruby' ? RUBY_VISITOR : RAILS_VISITOR + @fallback = RubyAstScanner.new(**args) + 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 @visitor_class.nil? + + process_prism_parse_result( + path, + PARSER.parse_file(path).value, + PARSER.parse_file_comments(path) + ) + 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, comments = nil) + return @fallback.send(:scan_file, path) if RUBY_VISITOR.skip_prism_comment?(comments) + + visitor = @visitor_class.new(comments: comments) + nodes = parsed.accept(visitor) + + nodes + .filter_map do |node| + next node.occurrences(path) if node.is_a?(I18n::Tasks::Scanners::PrismScanners::TranslationNode) + next unless node.respond_to?(:translation_nodes) + + node.translation_nodes.flat_map { |n| n.occurrences(path) } + end + .flatten(1) + end + + # This block handles adding a fallback if the `prism` gem is not available. + begin + require 'prism' + require_relative 'prism_scanners/rails_visitor' + require_relative 'prism_scanners/visitor' + PARSER = Prism + RUBY_VISITOR = I18n::Tasks::Scanners::PrismScanners::Visitor + RAILS_VISITOR = I18n::Tasks::Scanners::PrismScanners::RailsVisitor + rescue LoadError + PARSER = nil + RUBY_VISITOR, RAILS_VISITOR = nil + end + end +end diff --git a/lib/i18n/tasks/scanners/prism_scanners/nodes.rb b/lib/i18n/tasks/scanners/prism_scanners/nodes.rb new file mode 100644 index 00000000..b5f3898a --- /dev/null +++ b/lib/i18n/tasks/scanners/prism_scanners/nodes.rb @@ -0,0 +1,549 @@ +# frozen_string_literal: true + +# This file defines the nodes that will be returned by the PrismScanners Visitor-class. +# All nodes inherit from BaseNode and all implement `translation_nodes` which returns the final nodes +# which can be used to extract all occurrences. + +# Ruby: +# - ModuleNode: Represents a Ruby module +# - ClassNode: Represents a Ruby class +# - DefNode: Represents a Ruby method +# - CallNode: Represents a Ruby method call + +# Rails: +# - ControllerNode: Represents a controller +# - BeforeActionNode: Represents a before_action in a controller +# - HumanAttributeNameNode: Represents a human_attribute_name call +# - ModelNameNode: Represents a model_name call + +module I18n::Tasks::Scanners::PrismScanners + class BaseNode + MAGIC_COMMENT_PREFIX = /\A.\s*i18n-tasks-use\s+/.freeze + MAGIC_COMMENT_SKIP_PRISM = 'i18n-tasks-skip-prism' + + attr_reader(:node) + + def initialize(node:) + @node = node + @prepared = false + end + + def support_relative_keys? + false + end + + def prepare + @prepared = true + end + end + + class ModuleNode < BaseNode + def initialize(node:, child_nodes:) + @node = node + @child_nodes = child_nodes + super(node: node) + end + + def inspect + "" + end + + def type + :module_node + end + + def name + @node.name.to_s + end + + def path_name + name.to_s.underscore + end + + def translation_nodes(path: nil, options: nil) + @child_nodes.flat_map do |child_node| + next unless child_node.respond_to?(:translation_nodes) + + child_node.translation_nodes(path: [*path, self], options: options) + end + end + end + + class ClassNode < BaseNode + attr_reader(:methods, :calls) + + def initialize(node:) + @def_nodes = [] + @calls = [] + + super + end + + def inspect + "" + end + + def add_child_node(child_node) + if child_node.instance_of?(DefNode) + @def_nodes << child_node + else + @calls << child_node + end + end + + def path_name + path = @node.constant_path.full_name_parts.map { |s| s.to_s.underscore } + + path.last.gsub!(/_controller\z/, '') if class_type == :controller + + path + end + + def translation_nodes(path: nil, options: nil) + prepare unless @prepared + options ||= {} + local_path = [*path, self] + @def_nodes + .filter_map do |method| + next unless method.respond_to?(:translation_nodes) + + method.translation_nodes( + path: local_path, + options: { + **options, + before_actions: @before_actions, + def_nodes: @def_nodes + } + ) + end + .flatten + 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 + end + + class ControllerNode < ClassNode + def initialize(node:) + @before_actions = [] + super + end + + def add_child_node(child_node) + if child_node.instance_of?(BeforeActionNode) + @before_actions << child_node + else + super + end + end + + def prepare + @before_actions.each do |before_action| + next if before_action.name.nil? + + before_action.add_method( + @def_nodes.find do |method| + method.name.to_s == before_action.name.to_s + end + ) + end + + super + end + + def class_type + :controller + end + + def support_relative_keys? + true + end + end + + class DefNode < BaseNode + attr_reader(:private_method) + + def initialize(node:, calls:, private_method:) + @node = node + @calls = calls + @private_method = private_method + @called_from = [] + super(node: node) + end + + def inspect + "" + 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 path_name + name unless private_method + end + + def name + @node.name.to_s + end + + def type + :def_node + end + + def translation_nodes(path: nil, options: nil) + local_path = [*path] + + local_path << self if !local_path.last.instance_of?(DefNode) && !private_method + + before_action_translation_nodes(path: local_path, options: options) + + translation_nodes_from_calls(path: local_path, options: options) + end + + def translation_nodes_from_calls(path: nil, options: nil) + other_def_nodes = options[:def_nodes] || [] + @calls + .filter_map do |call| + case call.type + when :translation_node + call.with_context(path: path, options: options) + else + other_method = + other_def_nodes&.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.translation_nodes(path: path, options: options) + end + end + .flatten(1) + end + + def before_action_translation_nodes(path: nil, options: nil) + before_actions = options[:before_actions] + return [] if private_method || before_actions.nil? + + before_actions + .select { |action| action.applies_to?(name) } + .flat_map do |action| + action.translation_nodes(path: path, options: options) + end + end + end + + class TranslationNode < BaseNode + attr_reader(:key, :node, :options) + + def initialize( # rubocop:disable Metrics/ParameterLists + node:, + key:, + receiver:, + options: nil, + comment_translations: nil, + path: nil, + context_options: nil + ) + @node = node + @key = key + @receiver = receiver + @options = options + @comment_translations = comment_translations + @path = path + @context_options = context_options || {} + + super(node: node) + end + + def inspect + "" + end + + def with_context(path: nil, options: nil) + TranslationNode.new( + node: @node, + key: @key, + receiver: @receiver, + options: @options, + path: path, + context_options: options, + comment_translations: @comment_translations + ) + end + + def type + :translation_node + end + + def relative_key? + @key&.start_with?('.') && @receiver.nil? + end + + def occurrences(file_path) + occurrences = occurrences_from_comments(file_path) + + main_occurrence = occurrence(file_path) + return occurrences if main_occurrence.nil? + + occurrences << main_occurrence + + occurrences.concat( + options + &.values + &.filter { |n| n.is_a?(TranslationNode) } + &.flat_map do |n| + n.with_context(path: @path, options: @context_options).occurrences( + file_path + ) + end || [] + ).compact + end + + def full_key(context_path:) + return nil if key.nil? + return nil unless key.is_a?(String) + return nil if relative_key? && !support_relative_keys?(context_path) + + parts = [scope] + + if relative_key? + path = Array(context_path).map(&:path_name) + parts.concat(path) + parts << key + + # TODO: Fallback to controller without action name + elsif key.start_with?('.') + parts << key[1..] + else + parts << key + end + + parts.compact.join('.').gsub('..', '.') + end + + private + + def scope + return nil if @options.nil? + return nil unless @options['scope'] + + Array(@options['scope']).compact.map(&:to_s).join('.') + end + + def occurrence(file_path) + local_node = @context_options[:comment_for_node] || @node + + location = local_node.location + + final_key = full_key(context_path: @path || []) + return nil if final_key.nil? + + [ + final_key, + ::I18n::Tasks::Scanners::Results::Occurrence.new( + path: file_path, + line: local_node.slice, + pos: location.start_offset, + line_pos: location.start_column, + line_num: location.start_line, + raw_key: key + ) + ] + end + + def occurrences_from_comments(file_path) + Array(@comment_translations).flat_map do |child_node| + child_node.with_context( + path: @path, + options: { + **@context_options, + comment_for_node: @node + } + ).occurrences(file_path) + end + end + + # Only public methods are added to the context path + # Only some classes supports relative keys + def support_relative_keys?(context_path) + context_path.any? { |node| node.instance_of?(DefNode) } && + context_path.any?(&:support_relative_keys?) + end + end + + class BeforeActionNode < BaseNode + attr_reader(:name) + + def initialize(node:, only:, except:, name: nil, translation_nodes: nil) + @node = node + @name = name + @only = only.present? ? Array(only).map(&:to_s) : nil + @except = except.present? ? Array(except).map(&:to_s) : nil + @translation_nodes = translation_nodes + @method = nil + + super(node: node) + end + + def inspect + "" + 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.present? + fail(ArgumentError, 'BeforeAction already has translations') if @translation_nodes + + @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 translation_nodes(path: nil, options: nil) + if @translation_nodes.present? + @translation_nodes.flat_map do |child_node| + child_node.with_context(path: path, options: options) + end + elsif @method.present? + @method.translation_nodes(path: path, options: options) + else + [] + end + end + end + + class HumanAttributeNameNode < BaseNode + def initialize(node:, model:, argument:) + @node = node + @model = model + @argument = argument + super(node: node) + end + + def type + :human_attribute_name_node + end + + def translation_nodes(path: nil, options: nil) + [ + TranslationNode.new( + node: @node, + receiver: nil, + key: key, + path: path, + options: options + ) + ] + end + + def key + ['activerecord.attributes', @model.to_s.underscore, @argument.to_s].join( + '.' + ) + end + end + + class ModelNameNode < BaseNode + attr_reader(:model) + + def initialize(node:, model:, count: nil) + @node = node + @model = model + @count = count + super(node: node) + end + + def type + :model_name_node + end + + def translation_nodes(path: nil, options: nil) + [ + TranslationNode.new( + node: @node, + receiver: nil, + key: key, + path: path, + options: options + ) + ] + end + + def count_key + if @count.nil? || @count <= 1 + 'one' + else + 'other' + end + end + + def key + ['activerecord.models', @model.to_s.underscore, count_key].join('.') + end + end + + class CallNode < BaseNode + def initialize(node:, comment_translations:) + @comment_translations = comment_translations || [] + @node = node + super(node: node) + end + + def type + :call_node + end + + def name + @node.name + end + + def translation_nodes(path: nil, options: nil) + options ||= {} + @comment_translations.map do |child_node| + child_node.with_context( + path: path, + options: { + **options, + comment_for_node: @node + } + ) + end + end + end +end diff --git a/lib/i18n/tasks/scanners/prism_scanners/rails_visitor.rb b/lib/i18n/tasks/scanners/prism_scanners/rails_visitor.rb new file mode 100644 index 00000000..46097278 --- /dev/null +++ b/lib/i18n/tasks/scanners/prism_scanners/rails_visitor.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require_relative 'visitor' +require_relative 'nodes' + +# Extends the PrismScanners::Visitor class to add Rails-specific processing. +# Supports: +# - ControllerNodes +# - BeforeActionNodes +# - TranslationNodes +# - ModelNameNodes +# - HumanAttributeNameNodes + +module I18n::Tasks::Scanners::PrismScanners + class RailsVisitor < Visitor + def visit_class_node(node) + class_name = node.name.to_s + class_object = + if class_name.end_with?('Controller') + ControllerNode.new(node: node) + else + ClassNode.new(node: node) + end + + node + .body + .body + .map { |n| visit(n) } + .each { |child_node| class_object.add_child_node(child_node) } + + class_object + end + + def visit_call_node(node) + # TODO: How to handle multiple comments for same row? + comment_translations = + @comment_translations_by_row[node.location.start_line - 1] + case node.name + when :private + @private_methods = true + node + when :before_action + handle_before_action(node) + when :t, :'I18n.t', :t!, :'I18n.t!', :translate, :translate! + handle_translation_call(node, comment_translations) + when :human_attribute_name + handle_human_attribute_name(node) + when :model_name + ModelNameNode.new(node: node, model: visit(node.receiver)) + when :human + handle_human_call(node, comment_translations) + else + CallNode.new(node: node, comment_translations: comment_translations) + end + end + + def handle_translation_call(node, comment_translations) + array_args, keywords = process_arguments(node) + key = array_args.first + + receiver = visit(node.receiver) if node.receiver + + TranslationNode.new( + node: node, + key: key, + receiver: receiver, + options: keywords, + comment_translations: comment_translations + ) + end + + def handle_before_action(node) # rubocop:disable Metrics/MethodLength + array_arguments, keywords = process_arguments(node) + if array_arguments.empty? || array_arguments.size > 2 + fail( + ArgumentError, + "Cannot handle before_action with these arguments #{node.slice}" + ) + end + first_argument = array_arguments.first + + if first_argument.is_a?(String) + BeforeActionNode.new( + node: node, + name: first_argument, + only: keywords['only'], + except: keywords['except'] + ) + elsif first_argument.is_a?(Prism::StatementsNode) + BeforeActionNode.new( + node: node, + translation_nodes: visit(first_argument), + only: keywords['only'], + except: keywords['except'] + ) + else + fail( + ArgumentError, + "Cannot handle before_action with this argument #{first_argument.type}" + ) + end + end + + def handle_human_call(node, comment_translations) + _array_args, keywords = process_arguments(node) + receiver = visit(node.receiver) + if receiver.type == :model_name_node + ModelNameNode.new( + node: node, + model: receiver.model, + count: keywords['count'] + ) + else + CallNode.new(node: node, comment_translations: comment_translations) + end + end + + def handle_human_attribute_name(node) + array_args, keywords = process_arguments(node) + unless array_args.size == 1 && keywords.empty? + fail( + ArgumentError, + 'human_attribute_name should have only one argument' + ) + end + + HumanAttributeNameNode.new( + node: node, + model: visit(node.receiver), + argument: array_args.first + ) + end + end +end diff --git a/lib/i18n/tasks/scanners/prism_scanners/visitor.rb b/lib/i18n/tasks/scanners/prism_scanners/visitor.rb new file mode 100644 index 00000000..afa4dc10 --- /dev/null +++ b/lib/i18n/tasks/scanners/prism_scanners/visitor.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require 'prism/visitor' +require_relative 'nodes' + +# Implementation of Prism::Visitor (https://ruby.github.io/prism/rb/Prism/Visitor.html) +# It processes the parsed AST from Prism and creates a new AST with the nodes defined in prism_scanners/nodes.rb +# The only argument it receives is comments, which can be used for magic comments. +# It defines processing of arguments in a way that is needed for the translation calls. +# Any Rails-specific processing is added in the RailsVisitor class. + +module I18n::Tasks::Scanners::PrismScanners + class Visitor < Prism::Visitor + def initialize(comments: nil) + @private_methods = false + @comment_translations_by_row = prepare_comments_by_line(comments) + + # Needs to have () because the Prism::Visitor has no arguments + super() + end + + def self.skip_prism_comment?(comments) + comments.any? do |comment| + content = + comment.respond_to?(:slice) ? comment.slice : comment.location.slice + content.include?(BaseNode::MAGIC_COMMENT_SKIP_PRISM) + end + end + + def prepare_comments_by_line(comments) + return {} if comments.nil? + + comments.each_with_object({}) do |comment, by_row| + content = + comment.respond_to?(:slice) ? comment.slice : comment.location.slice + next by_row unless content =~ BaseNode::MAGIC_COMMENT_PREFIX + + string = + content.gsub(BaseNode::MAGIC_COMMENT_PREFIX, '').gsub('#', '').strip + nodes = + Prism + .parse(string) + .value + .accept(RailsVisitor.new) + .filter { |node| node.is_a?(TranslationNode) } + + next by_row if nodes.empty? + + by_row[comment.location.start_line] = nodes + by_row + end + 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) + class_object = ClassNode.new(node: node) + + node + .body + .body + .map { |n| visit(n) } + .each { |child_node| class_object.add_child_node(child_node) } + + class_object + 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) + # TODO: How to handle multiple comments for same row? + comment_translations = + @comment_translations_by_row[node.location.start_line - 1] + + case node.name + when :private + @private_methods = true + node + when :t, :t!, :translate, :translate! + handle_translation_call(node, comment_translations) + else + CallNode.new(node: node, comment_translations: comment_translations) + end + end + + def visit_assoc_node(node) + [visit(node.key), visit(node.value)] + end + + def visit_symbol_node(node) + node.value + end + + def visit_string_node(node) + node.content + end + + def visit_integer_node(node) + node.value + end + + def visit_decimal_node(node) + node.value + end + + def visit_constant_read_node(node) + node.name + end + + def visit_arguments_node(node) + keywords, array = + node.arguments.partition { |n| n.type == :keyword_hash_node } + + array.map { |n| visit(n) }.flatten << visit(keywords.first) + end + + def visit_array_node(node) + node.child_nodes.map { |n| visit(n) } + end + + def visit_keyword_hash_node(node) + node.elements.to_h { |n| visit(n) } + end + + def handle_translation_call(node, comment_translations) + array_args, keywords = process_arguments(node) + key = array_args.first + + receiver = visit(node.receiver) if node.receiver + + TranslationNode.new( + node: node, + key: key, + receiver: receiver, + options: keywords, + comment_translations: comment_translations + ) + end + + def process_arguments(node) + return [], {} if node.nil? + return [], {} unless node.respond_to?(:arguments) + return [], {} if node.arguments.nil? + + keywords, other = + visit(node.arguments).partition { |value| value.is_a?(Hash) } + + [other.compact, keywords.first || {}] + end + end +end diff --git a/lib/i18n/tasks/used_keys.rb b/lib/i18n/tasks/used_keys.rb index 198ec8a0..dce9c334 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/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/fixtures/used_keys/app/controllers/events_controller.rb b/spec/fixtures/used_keys/app/controllers/events_controller.rb index 12faade6..519b4aff 100644 --- a/spec/fixtures/used_keys/app/controllers/events_controller.rb +++ b/spec/fixtures/used_keys/app/controllers/events_controller.rb @@ -1,7 +1,15 @@ +# i18n-tasks-skip-prism class EventsController < ApplicationController + before_action(:method_a) def create t(".relative_key") t("absolute_key") I18n.t("very_absolute_key") end + + private + + def method_a + t(".from_before_action") + end end diff --git a/spec/prism_scanner_spec.rb b/spec/prism_scanner_spec.rb new file mode 100644 index 00000000..2966ef81 --- /dev/null +++ b/spec/prism_scanner_spec.rb @@ -0,0 +1,420 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'PrismScanner' do + describe 'with prism', + if: I18n::Tasks::Scanners::PrismScanner::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') + I18n.t('.other_relative_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 + other_relative_key + ] + ) + end + + it 'handles before_action as lambda' do + source = <<~RUBY + class EventsController < ApplicationController + before_action -> { t('.before_action') }, only: :create + + def create + t('.relative_key') + end + end + RUBY + + occurrences = + process_string('app/controllers/events_controller.rb', source) + + expect(occurrences.map(&:first).uniq).to match_array( + %w[events.create.relative_key events.create.before_action] + ) + 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") + I18n.t(".relative_key_with_receiver") + 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 + relative_key_with_receiver + ] + ) + 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 + + it 'rails model translations' do # rubocop:disable RSpec/MultipleExpectations + source = <<~RUBY + Event.human_attribute_name(:title) + Event.model_name.human(count: 2) + Event.model_name.human + RUBY + + occurrences = process_string('app/models/event.rb', source) + + expect(occurrences.map(&:first)).to match_array( + %w[ + activerecord.attributes.event.title + activerecord.models.event.one + activerecord.models.event.other + ] + ) + + occurrence = occurrences.first.last + expect(occurrence.raw_key).to eq('activerecord.attributes.event.title') + expect(occurrence.path).to eq('app/models/event.rb') + expect(occurrence.line_num).to eq(1) + expect(occurrence.line).to eq('Event.human_attribute_name(:title)') + + occurrence = occurrences.second.last + expect(occurrence.raw_key).to eq('activerecord.models.event.other') + expect(occurrence.path).to eq('app/models/event.rb') + expect(occurrence.line_num).to eq(2) + expect(occurrence.line).to eq('Event.model_name.human(count: 2)') + + occurrence = occurrences.last.last + expect(occurrence.raw_key).to eq('activerecord.models.event.one') + expect(occurrence.path).to eq('app/models/event.rb') + expect(occurrence.line_num).to eq(3) + expect(occurrence.line).to eq('Event.model_name.human') + end + end + + describe 'magic comments' do + it 'i18n-tasks-use' do + source = <<~'RUBY' + # i18n-tasks-use t('translation.from.comment') + SpecialMethod.translate_it + # i18n-tasks-use t('scoped.translation.key1') + I18n.t("scoped.translation.#{variable}") + RUBY + + occurrences = + process_string('spec/fixtures/used_keys/app/controllers/a.rb', source) + + expect(occurrences.size).to eq(2) + + expect(occurrences.map(&:first)).to match_array( + %w[translation.from.comment scoped.translation.key1] + ) + + occurrence = occurrences.first.last + expect(occurrence.path).to eq( + 'spec/fixtures/used_keys/app/controllers/a.rb' + ) + expect(occurrence.line_num).to eq(2) + expect(occurrence.line).to eq('SpecialMethod.translate_it') + + occurrence = occurrences.last.last + + expect(occurrence.path).to eq( + 'spec/fixtures/used_keys/app/controllers/a.rb' + ) + expect(occurrence.line_num).to eq(4) + expect(occurrence.line).to eq( + "I18n.t(\"scoped.translation.\#{variable}\")" + ) + end + + it 'i18n-tasks-skip-prism' do + scanner = + I18n::Tasks::Scanners::PrismScanner.new( + config: { + relative_roots: ['spec/fixtures/used_keys/app/controllers'] + } + ) + + occurrences = + scanner.send( + :scan_file, + 'spec/fixtures/used_keys/app/controllers/events_controller.rb' + ) + # The `events.method_a.from_before_action` would not be detected by prism + expect(occurrences.map(&:first).uniq).to match_array( + %w[ + absolute_key + events.create.relative_key + events.method_a.from_before_action + very_absolute_key + ] + ) + end + end + + it 'class' do + source = <<~RUBY + class Event + def what + t('a') + t('.relative') + I18n.t('b') + end + end + RUBY + occurrences = process_string('app/models/event.rb', source) + + expect(occurrences.map(&:first)).to match_array(%w[a b]) + + occurrence = occurrences.first.last + expect(occurrence.path).to eq('app/models/event.rb') + expect(occurrence.line_num).to eq(3) + expect(occurrence.line).to eq('t(\'a\')') + + occurrence = occurrences.last.last + + expect(occurrence.path).to eq('app/models/event.rb') + expect(occurrence.line_num).to eq(5) + expect(occurrence.line).to eq("I18n.t('b')") + end + + it '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 + + describe 'translation options' do + it 'handles scope' do + source = <<~RUBY + t('scope_string', scope: 'events.descriptions') + I18n.t('scope_array', scope: ['events', 'titles']) + RUBY + + occurrences = process_string('scope.rb', source) + + expect(occurrences.map(&:first).uniq).to match_array( + %w[events.descriptions.scope_string events.titles.scope_array] + ) + end + end + + describe 'ruby visitor' do + it 'ignores controller behaviour' do + source = <<~RUBY + class EventsController + before_action(:method_in_before_action1, only: :create) + + def create + t('.relative_key') + I18n.t("absolute_key") + method_b + end + + def method_b + t('.error') + t("absolute_error") + end + + private + + def method_in_before_action1 + t('.before_action1') + t("absolute_before_action1") + end + RUBY + + occurrences = + process_string( + 'app/controllers/events_controller.rb', + source, + visitor: 'ruby' + ) + + expect(occurrences.map(&:first).uniq).to match_array( + %w[ + absolute_before_action1 + absolute_error + absolute_key + ] + ) + end + end + end + + describe 'without prism', + if: I18n::Tasks::Scanners::PrismScanner::PARSER.nil? do + it '#used_keys - fallback to ruby ast parser' do + scanner = + I18n::Tasks::Scanners::PrismScanner.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 events.method_a.from_before_action] + ) + end + end + + def process_path(path) + I18n::Tasks::Scanners::PrismScanner.new.send(:scan_file, path) + end + + def process_string(path, string, visitor: 'rails') + parsed = I18n::Tasks::Scanners::PrismScanner::PARSER.parse(string).value + comments = + I18n::Tasks::Scanners::PrismScanner::PARSER.parse_comments(string) + I18n::Tasks::Scanners::PrismScanner.new(config: { prism_visitor: visitor }).send( + :process_prism_parse_result, + path, + parsed, + comments + ) + end +end diff --git a/spec/used_keys_ruby_spec.rb b/spec/used_keys_ruby_spec.rb index efd874b7..efdd51ce 100644 --- a/spec/used_keys_ruby_spec.rb +++ b/spec/used_keys_ruby_spec.rb @@ -191,13 +191,13 @@ used_keys = task.used_tree expect(used_keys.size).to eq(1) leaves = leaves_to_hash(used_keys.leaves.to_a) - expect(leaves.size).to(eq(5)) expect(leaves.keys.sort).to( match_array( %w[ absolute_key event_component.key events.create.relative_key + events.method_a.from_before_action user_mailer.welcome_notification.subject very_absolute_key ] @@ -238,8 +238,8 @@ }, { path: 'app/controllers/events_controller.rb', - pos: 87, - line_num: 4, + pos: 138, + line_num: 6, line_pos: 4, line: ' t("absolute_key")', raw_key: 'absolute_key' @@ -256,8 +256,8 @@ [ { path: 'app/controllers/events_controller.rb', - pos: 64, - line_num: 3, + pos: 115, + line_num: 5, line_pos: 4, line: ' t(".relative_key")', raw_key: '.relative_key' @@ -274,8 +274,8 @@ [ { path: 'app/controllers/events_controller.rb', - pos: 109, - line_num: 5, + pos: 160, + line_num: 7, line_pos: 4, line: ' I18n.t("very_absolute_key")', raw_key: 'very_absolute_key'