-
Notifications
You must be signed in to change notification settings - Fork 268
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Contextual parsing of Rails controllers
- 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
1 parent
dffd911
commit 1fb6f4b
Showing
8 changed files
with
452 additions
and
25 deletions.
There are no files selected for viewing
196 changes: 196 additions & 0 deletions
196
lib/i18n/tasks/scanners/prism_parsers/rails_controller_visitor.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
31 changes: 28 additions & 3 deletions
31
spec/fixtures/used_keys/app/controllers/events_controller.rb
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
Oops, something went wrong.