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 8, 2024
1 parent dffd911 commit fadc07d
Show file tree
Hide file tree
Showing 9 changed files with 419 additions and 25 deletions.
192 changes: 192 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,192 @@
# frozen_string_literal: true

require "prism/visitor"

Check failure on line 3 in lib/i18n/tasks/scanners/prism_parsers/rails_controller_visitor.rb

View workflow job for this annotation

GitHub Actions / lint

[Correctable] Style/StringLiterals: Prefer single-quoted strings when you don't need string interpolation or special symbols.
require_relative "translation_node"

Check failure on line 4 in lib/i18n/tasks/scanners/prism_parsers/rails_controller_visitor.rb

View workflow job for this annotation

GitHub Actions / lint

[Correctable] Style/StringLiterals: Prefer single-quoted strings when you don't need string interpolation or special symbols.

module I18n::Tasks::Scanners::PrismParsers
class RailsControllerVisitor < Prism::Visitor

Check failure on line 7 in lib/i18n/tasks/scanners/prism_parsers/rails_controller_visitor.rb

View workflow job for this annotation

GitHub Actions / lint

Metrics/ClassLength: Class has too many lines. [153/125]
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

Check failure on line 25 in lib/i18n/tasks/scanners/prism_parsers/rails_controller_visitor.rb

View workflow job for this annotation

GitHub Actions / lint

[Correctable] Layout/MultilineOperationIndentation: Align the operands of a condition in an if statement spanning multiple lines.
@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

Check failure on line 42 in lib/i18n/tasks/scanners/prism_parsers/rails_controller_visitor.rb

View workflow job for this annotation

GitHub Actions / lint

[Correctable] Layout/MultilineMethodCallIndentation: Align .body with node on line 41.
.child_nodes

Check failure on line 43 in lib/i18n/tasks/scanners/prism_parsers/rails_controller_visitor.rb

View workflow job for this annotation

GitHub Actions / lint

[Correctable] Layout/MultilineMethodCallIndentation: Align .child_nodes with node on line 41.
.filter_map { |n| visit(n) }

Check failure on line 44 in lib/i18n/tasks/scanners/prism_parsers/rails_controller_visitor.rb

View workflow job for this annotation

GitHub Actions / lint

[Correctable] Layout/MultilineMethodCallIndentation: Align .filter_map with node on line 41.
.partition { |n| n.type == :translation_node }

Check failure on line 45 in lib/i18n/tasks/scanners/prism_parsers/rails_controller_visitor.rb

View workflow job for this annotation

GitHub Actions / lint

[Correctable] Layout/MultilineMethodCallIndentation: Align .partition with node on line 41.

@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!

Check failure on line 64 in lib/i18n/tasks/scanners/prism_parsers/rails_controller_visitor.rb

View workflow job for this annotation

GitHub Actions / lint

[Correctable] Style/QuotedSymbols: Prefer single-quoted symbols when you don't need string interpolation or special symbols.

Check failure on line 64 in lib/i18n/tasks/scanners/prism_parsers/rails_controller_visitor.rb

View workflow job for this annotation

GitHub Actions / lint

[Correctable] Style/QuotedSymbols: Prefer single-quoted symbols when you don't need string interpolation or special symbols.
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
node.constant_path.child_nodes.map do |node|
node.name.to_s.underscore.sub(/_controller\z/, "")
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)
binding.irb if method.nil?

Check warning on line 123 in lib/i18n/tasks/scanners/prism_parsers/rails_controller_visitor.rb

View workflow job for this annotation

GitHub Actions / lint

Lint/Debugger: Remove debugger entry point binding.irb.
return if method[:status] == :processed
if method[:status] == :processing
fail(ArgumentError, "Cannot handle cyclic method calls")
end

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])
options =
arguments_node.arguments.last if arguments_node.arguments.length > 1

@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
21 changes: 21 additions & 0 deletions lib/i18n/tasks/scanners/prism_scanner.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

module I18n::Tasks::Scanners
class PrismScanner < FileScanner
def process_path(path, visitor)
return unless Parser

parsed = Parser.parse_file(path).value
parsed.accept(visitor)
visitor.occurrences(path)
end

begin
require "prism"

Parser = Prism
rescue LoadError
Parser = nil
end
end
end
31 changes: 31 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,31 @@
require_relative "prism_scanner"

module I18n::Tasks::Scanners
class RailsControllerPrismScanner < PrismScanner
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)
unless VISITOR
raise ::I18n::Tasks::CommandError.new(
"Please make sure `prism` is available to use this feature."
)
end
process_path(path, VISITOR)
rescue Exception => e # rubocop:disable Lint/RescueException
raise ::I18n::Tasks::CommandError.new(
e,
"Error scanning #{path}: #{e.message}"
)
end

begin
require_relative "prism_parsers/rails_controller_visitor"
VISITOR = I18n::Tasks::Scanners::PrismParsers::RailsControllerVisitor.new
rescue LoadError
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 not_an_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
21 changes: 21 additions & 0 deletions spec/scanners/prism_parser_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# frozen_string_literal: true

require "spec_helper"
require "i18n/tasks/scanners/prism_parser"

RSpec.describe "PrismParser" do
describe "#process_rails_controller" do
it "finds translations" do
if I18n::Tasks::Scanners::PrismParser::Parser.nil?
skip("prism gem not available")
end

path = "spec/fixtures/used_keys/app/controllers/events_controller.rb"
processor = I18n::Tasks::Scanners::PrismParser.new
results = processor.process_rails_controller(path)

expect(results.size).to eq(8)
binding.irb

Check warning on line 18 in spec/scanners/prism_parser_spec.rb

View workflow job for this annotation

GitHub Actions / lint

Lint/Debugger: Remove debugger entry point binding.irb.
end
end
end
4 changes: 4 additions & 0 deletions spec/support/keys_and_occurrences.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ def make_occurrences(occurrences)
occurrences.map { |attr| make_occurrence(**attr) }
end

def leaves_to_hash(leaves)
leaves.to_h { |leaf| [leaf.full_key(root: false), leaf] }
end

def make_key_occurrences(key, occurrences)
::I18n::Tasks::Scanners::Results::KeyOccurrences.new(key: key, occurrences: make_occurrences(occurrences))
end
Expand Down
Loading

0 comments on commit fadc07d

Please sign in to comment.