Skip to content

Commit

Permalink
PrismParser: Contextual parsing for Rails
Browse files Browse the repository at this point in the history
  • Loading branch information
davidwessman committed May 18, 2024
1 parent 80f9ab0 commit 5d7ae4e
Show file tree
Hide file tree
Showing 5 changed files with 760 additions and 0 deletions.
368 changes: 368 additions & 0 deletions lib/i18n/tasks/scanners/prism_parsers/nodes.rb
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 5d7ae4e

Please sign in to comment.