Skip to content

Commit

Permalink
Contextual parsing of Rails controllers
Browse files Browse the repository at this point in the history
- Adding a Parser based on [Prism](https://github.com/ruby/prism).
- It handles all parsing and matching of the keys, currently it is not
  connected to the used_keys flow but only done as an experiment.
  • Loading branch information
davidwessman committed May 10, 2024
1 parent dffd911 commit 1fb6f4b
Show file tree
Hide file tree
Showing 8 changed files with 452 additions and 25 deletions.
196 changes: 196 additions & 0 deletions lib/i18n/tasks/scanners/prism_parsers/rails_controller_visitor.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# frozen_string_literal: true

require 'prism/visitor'
require_relative 'translation_node'

module I18n::Tasks::Scanners::PrismParsers
class RailsControllerVisitor < Prism::Visitor # rubocop:disable Metrics/ClassLength
def initialize
@controller_node_path = []
@private_methods = false
@methods = {}
@before_actions = {}

super
end

def occurrences(path)
process_before_actions
process_methods(path)
end

def visit_module_node(node)
previous_location = @controller_node_path.last&.location
if previous_location &&
previous_location.end_offset < node.location.start_offset
@controller_node_path = [node]
else
@controller_node_path << node
end

super
end

def visit_class_node(node)
@controller_node_path << node
super
end

def visit_def_node(node)
translation_calls, other_calls =
node
.body
.child_nodes
.filter_map { |n| visit(n) }
.partition { |n| n.type == :translation_node }

@methods[node.name] = {
name: node.name,
private: @private_methods,
translation_calls: translation_calls,
other_calls: other_calls,
status: :unprocessed
}

super
end

def visit_call_node(node)
case node.name
when :private
@private_methods = true
when :before_action
parse_before_action(node)
when :t, :'I18n.t', :t!, :'I18n.t!', :translate, :translate!
key_argument, options = node.arguments.arguments
TranslationNode.new(
node: node,
key: extract_value(key_argument),
options: options
)
else
node
end
end

private

def controller_key
@controller_node_path.flat_map do |node|
case node.type
when :module_node
node.name.to_s.underscore
when :class_node
case node.constant_path.type
when :constant_read_node
node.constant_path.name.to_s.underscore.sub(/_controller\z/, '')
else
node.constant_path.child_nodes.map do |node|
node.name.to_s.underscore.sub(/_controller\z/, '')
end
end
end
end
end

def process_before_actions
methods = @methods.values
private_methods = methods.select { |m| m[:private] }
non_private_methods = methods.reject { |m| m[:private] }

@before_actions.each do |name, before_action|
# We can only handle before_actions that are private methods
before_action_method =
private_methods.find { |m| m[:name].to_s == name }
next if before_action_method.nil?

methods_for_before_action(
non_private_methods,
before_action
).each do |method|
method[:translation_calls] += before_action_method[:translation_calls]
method[:other_calls] += before_action_method[:other_calls]
end
end
end

def process_methods(path)
@methods.each_value { |method| process_method(method) }

@methods.values.flat_map do |method|
method[:translation_calls]
.map { |node| node.to_occurrence(path, controller_key, method, node) }
.compact
end
end

def process_method(method)
return if method[:status] == :processed
fail(ArgumentError, 'Cannot handle cyclic method calls') if method[:status] == :processing

method[:status] = :processing

method[:other_calls].each do |call_node|
process_method(@methods[call_node.name])
method[:translation_calls] += Array(
@methods[call_node.name][:translation_calls]
)
end

method[:status] = :processed
end

def parse_before_action(node)
arguments_node = node.arguments
if arguments_node.arguments.empty? || arguments_node.arguments.size > 2
fail(ArgumentError, 'Cannot handle before_action with these arguments')
end

name = extract_value(arguments_node.arguments[0])
if arguments_node.arguments.length > 1
options =
arguments_node.arguments.last
end

@before_actions[name] = {
node: node,
only: Array(extract_hash_value(options, :only)),
except: Array(extract_hash_value(options, :except)),
calls: []
}
end

def 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_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 methods_for_before_action(methods, before_action)
if before_action[:only].present?
methods.select { |m| before_action[:only].include?(m[:name]) }
elsif before_action[:except].present?
methods.reject { |m| before_action[:except].include?(m[:name]) }
end
end
end
end
54 changes: 54 additions & 0 deletions lib/i18n/tasks/scanners/prism_parsers/translation_node.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# frozen_string_literal: true

require 'i18n/tasks/scanners/results/key_occurrences'

module I18n::Tasks::Scanners::PrismParsers
class TranslationNode
attr_reader(:key, :node)

def initialize(node:, key:, options: nil)
@node = node
@key = key
@options = options
end

def type
:translation_node
end

def location
@node.location
end

def relative_key?
@key.start_with?('.')
end

def to_occurrence(path, controller_key, method, node)
location = node.location

final_key = full_key(controller_key, method)
return nil if final_key.nil?

[
final_key,
::I18n::Tasks::Scanners::Results::Occurrence.new(
path: path,
line: node.node.slice,
pos: location.start_offset,
line_pos: location.start_column,
line_num: location.start_line,
raw_key: key
)
]
end

def full_key(controller_key, method)
return nil if relative_key? && method[:private]
return key unless relative_key?

# We should handle fallback to key without method name
[controller_key, method[:name], key].compact.join('.').gsub('..', '.')
end
end
end
45 changes: 45 additions & 0 deletions lib/i18n/tasks/scanners/rails_controller_prism_scanner.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# frozen_string_literal: true

require_relative 'file_scanner'
require_relative 'ruby_ast_scanner'

module I18n::Tasks::Scanners
class RailsControllerPrismScanner < FileScanner
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?

parsed = PARSER.parse_file(path).value
visitor = VISITOR.new
parsed.accept(visitor)
visitor.occurrences(path)
rescue Exception => e # rubocop:disable Lint/RescueException
raise ::I18n::Tasks::CommandError.new(
e,
"Error scanning #{path}: #{e.message}"
)
end

begin
require 'prism'
require_relative 'prism_parsers/rails_controller_visitor'
PARSER = Prism
VISITOR = I18n::Tasks::Scanners::PrismParsers::RailsControllerVisitor
rescue LoadError
PARSER = nil
VISITOR = nil
end
end
end
1 change: 1 addition & 0 deletions lib/i18n/tasks/used_keys.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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_controller_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'
Expand Down
31 changes: 28 additions & 3 deletions spec/fixtures/used_keys/app/controllers/events_controller.rb
Original file line number Diff line number Diff line change
@@ -1,7 +1,32 @@
# frozen_string_literal: true

class EventsController < ApplicationController
before_action(:method_in_before_action, only: :create)
before_action('method_in_before_action2', except: %i[create])

def create
t(".relative_key")
t("absolute_key")
I18n.t("very_absolute_key")
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_action
t('.before_action')
end

def method_in_before_action2
t('.before_action2')
end
end
57 changes: 57 additions & 0 deletions spec/scanners/rails_controller_prism_scanner_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true

require 'spec_helper'
require 'i18n/tasks/scanners/rails_controller_prism_scanner'
require 'i18n/tasks/scanners/ruby_ast_scanner'

RSpec.describe 'RailsControllerPrismScanner' do
describe '#perform' do
it 'finds translations' do
skip('prism gem not available') if I18n::Tasks::Scanners::RailsControllerPrismScanner::PARSER.nil?

path = 'spec/fixtures/used_keys/app/controllers/events_controller.rb'
processor = I18n::Tasks::Scanners::RailsControllerPrismScanner.new
results = processor.send(:scan_file, path)
grouped = results.group_by(&:key)

expect(results.map(&:key)).to match_array(%w[
events.create.relative_key
events.create.before_action2
events.create.success
events.custom_action.relative_key
events.custom_action.before_action2
events.custom_action.success
absolute_key
very_absolute_key
])

expect_node_key_data(
grouped['events.create.relative_key'],
'events.create.relative_key',
occurrences: make_occurrences(
[
{
path: 'app/controllers/events_controller.rb',
pos: 217,
line_num: 8,
line_pos: 4,
line: ' t(\'.relative_key\')',
raw_key: '.relative_key'
}
]
)
)
end

it 'falls back to Ruby AST scanner' do
skip('prism gem available') if I18n::Tasks::Scanners::RailsControllerPrismScanner::PARSER.present?

path = 'spec/fixtures/used_keys/app/controllers/events_controller.rb'
results = I18n::Tasks::Scanners::RailsControllerPrismScanner.new.send(:scan_file, path)
expected = I18n::Tasks::Scanners::RubyAstScanner.new.send(:scan_file, path)

expect(results).to eq(expected)
expect(results.size).to eq(5)
end
end
end
Loading

0 comments on commit 1fb6f4b

Please sign in to comment.