i18n-tasks 0.9.33 → 1.0.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +28 -11
  3. data/config/locales/en.yml +5 -3
  4. data/config/locales/ru.yml +1 -0
  5. data/i18n-tasks.gemspec +12 -6
  6. data/lib/i18n/tasks/base_task.rb +2 -1
  7. data/lib/i18n/tasks/cli.rb +27 -17
  8. data/lib/i18n/tasks/command/commander.rb +1 -0
  9. data/lib/i18n/tasks/command/commands/data.rb +8 -6
  10. data/lib/i18n/tasks/command/commands/eq_base.rb +2 -2
  11. data/lib/i18n/tasks/command/commands/health.rb +4 -3
  12. data/lib/i18n/tasks/command/commands/interpolations.rb +1 -1
  13. data/lib/i18n/tasks/command/commands/meta.rb +1 -1
  14. data/lib/i18n/tasks/command/commands/missing.rb +22 -9
  15. data/lib/i18n/tasks/command/commands/tree.rb +8 -6
  16. data/lib/i18n/tasks/command/commands/usages.rb +5 -4
  17. data/lib/i18n/tasks/command/dsl.rb +4 -4
  18. data/lib/i18n/tasks/command/option_parsers/enum.rb +2 -0
  19. data/lib/i18n/tasks/command/option_parsers/locale.rb +2 -1
  20. data/lib/i18n/tasks/command/options/common.rb +5 -0
  21. data/lib/i18n/tasks/command/options/data.rb +4 -1
  22. data/lib/i18n/tasks/command/options/locales.rb +5 -5
  23. data/lib/i18n/tasks/concurrent/cached_value.rb +2 -2
  24. data/lib/i18n/tasks/configuration.rb +17 -10
  25. data/lib/i18n/tasks/console_context.rb +1 -1
  26. data/lib/i18n/tasks/data/adapter/yaml_adapter.rb +9 -2
  27. data/lib/i18n/tasks/data/file_formats.rb +3 -1
  28. data/lib/i18n/tasks/data/file_system_base.rb +7 -6
  29. data/lib/i18n/tasks/data/router/conservative_router.rb +2 -1
  30. data/lib/i18n/tasks/data/router/pattern_router.rb +3 -1
  31. data/lib/i18n/tasks/data/tree/node.rb +6 -3
  32. data/lib/i18n/tasks/data/tree/nodes.rb +6 -7
  33. data/lib/i18n/tasks/data/tree/siblings.rb +10 -4
  34. data/lib/i18n/tasks/data/tree/traversal.rb +34 -11
  35. data/lib/i18n/tasks/html_keys.rb +4 -6
  36. data/lib/i18n/tasks/ignore_keys.rb +4 -3
  37. data/lib/i18n/tasks/interpolations.rb +10 -4
  38. data/lib/i18n/tasks/key_pattern_matching.rb +3 -2
  39. data/lib/i18n/tasks/locale_pathname.rb +1 -1
  40. data/lib/i18n/tasks/missing_keys.rb +4 -0
  41. data/lib/i18n/tasks/plural_keys.rb +5 -6
  42. data/lib/i18n/tasks/references.rb +4 -2
  43. data/lib/i18n/tasks/reports/base.rb +4 -3
  44. data/lib/i18n/tasks/reports/terminal.rb +8 -6
  45. data/lib/i18n/tasks/scanners/ast_matchers/base_matcher.rb +118 -0
  46. data/lib/i18n/tasks/scanners/ast_matchers/message_receivers_matcher.rb +91 -0
  47. data/lib/i18n/tasks/scanners/ast_matchers/rails_model_matcher.rb +69 -0
  48. data/lib/i18n/tasks/scanners/erb_ast_processor.rb +74 -0
  49. data/lib/i18n/tasks/scanners/erb_ast_scanner.rb +48 -0
  50. data/lib/i18n/tasks/scanners/file_scanner.rb +4 -3
  51. data/lib/i18n/tasks/scanners/files/caching_file_finder.rb +0 -3
  52. data/lib/i18n/tasks/scanners/files/file_finder.rb +3 -2
  53. data/lib/i18n/tasks/scanners/local_ruby_parser.rb +85 -0
  54. data/lib/i18n/tasks/scanners/occurrence_from_position.rb +3 -3
  55. data/lib/i18n/tasks/scanners/pattern_mapper.rb +1 -1
  56. data/lib/i18n/tasks/scanners/pattern_scanner.rb +8 -5
  57. data/lib/i18n/tasks/scanners/pattern_with_scope_scanner.rb +4 -2
  58. data/lib/i18n/tasks/scanners/relative_keys.rb +19 -4
  59. data/lib/i18n/tasks/scanners/results/occurrence.rb +17 -1
  60. data/lib/i18n/tasks/scanners/ruby_ast_call_finder.rb +9 -34
  61. data/lib/i18n/tasks/scanners/ruby_ast_scanner.rb +91 -154
  62. data/lib/i18n/tasks/scanners/ruby_key_literals.rb +4 -4
  63. data/lib/i18n/tasks/scanners/scanner_multiplexer.rb +2 -0
  64. data/lib/i18n/tasks/split_key.rb +3 -1
  65. data/lib/i18n/tasks/string_interpolation.rb +1 -0
  66. data/lib/i18n/tasks/translation.rb +3 -3
  67. data/lib/i18n/tasks/translators/base_translator.rb +5 -3
  68. data/lib/i18n/tasks/translators/deepl_translator.rb +10 -2
  69. data/lib/i18n/tasks/translators/google_translator.rb +2 -0
  70. data/lib/i18n/tasks/translators/yandex_translator.rb +2 -0
  71. data/lib/i18n/tasks/used_keys.rb +21 -14
  72. data/lib/i18n/tasks/version.rb +1 -1
  73. data/lib/i18n/tasks.rb +17 -7
  74. data/templates/config/i18n-tasks.yml +21 -1
  75. metadata +44 -15
@@ -10,7 +10,7 @@ module I18n::Tasks
10
10
  private
11
11
 
12
12
  def path_locale_re(locale)
13
- (@path_locale_res ||= {})[locale] ||= %r{(?<=^|[/.])#{locale}(?=[/.])}
13
+ (@path_locale_res ||= {})[locale] ||= %r{(?<=^|[/.-])#{locale}(?=[/.])}
14
14
  end
15
15
  end
16
16
  end
@@ -60,11 +60,14 @@ module I18n::Tasks
60
60
  locales.each_with_object(empty_forest) do |locale, forest|
61
61
  required_keys = required_plural_keys_for_locale(locale)
62
62
  next if required_keys.empty?
63
+
63
64
  tree = empty_forest
64
65
  plural_nodes data[locale] do |node|
65
66
  children = node.children
66
67
  present_keys = Set.new(children.map { |c| c.key.to_sym })
68
+ next if ignore_key?(node.full_key(root: false), :missing)
67
69
  next if present_keys.superset?(required_keys)
70
+
68
71
  tree[node.full_key] = node.derive(
69
72
  value: children.to_hash,
70
73
  children: nil,
@@ -132,6 +135,7 @@ module I18n::Tasks
132
135
  locale = root.key
133
136
  root.keys do |key, node|
134
137
  next unless yield node
138
+
135
139
  if locales_and_node_by_key.key?(key)
136
140
  locales_and_node_by_key[key][0] << locale
137
141
  else
@@ -5,12 +5,13 @@ module I18n::Tasks::PluralKeys
5
5
  # Ref: http://cldr.unicode.org/index/cldr-spec/plural-rules
6
6
  CLDR_CATEGORY_KEYS = %w[zero one two few many other].freeze
7
7
  PLURAL_KEY_SUFFIXES = Set.new CLDR_CATEGORY_KEYS
8
- PLURAL_KEY_RE = /\.(?:#{CLDR_CATEGORY_KEYS * '|'})$/
8
+ PLURAL_KEY_RE = /\.(?:#{CLDR_CATEGORY_KEYS * '|'})$/.freeze
9
9
 
10
10
  def collapse_plural_nodes!(tree)
11
11
  tree.leaves.map(&:parent).compact.uniq.each do |node|
12
12
  children = node.children
13
13
  next unless plural_forms?(children)
14
+
14
15
  node.value = children.to_hash
15
16
  node.children = nil
16
17
  node.data.merge! children.first.data
@@ -23,6 +24,7 @@ module I18n::Tasks::PluralKeys
23
24
  # @return [String] the base form if the key is a specific plural form (e.g. apple for apple.many), the key otherwise.
24
25
  def depluralize_key(key, locale = base_locale)
25
26
  return key if key !~ PLURAL_KEY_RE
27
+
26
28
  key_name = last_key_part(key)
27
29
  parent_key = key[0..- (key_name.length + 2)]
28
30
  nodes = tree("#{locale}.#{parent_key}").presence || (locale != base_locale && tree("#{base_locale}.#{parent_key}"))
@@ -37,10 +39,12 @@ module I18n::Tasks::PluralKeys
37
39
  # @yieldparam node [::I18n::Tasks::Data::Tree::Node] plural node
38
40
  def plural_nodes(tree)
39
41
  return to_enum(:plural_nodes, tree) unless block_given?
42
+
40
43
  visited = Set.new
41
44
  tree.leaves do |node|
42
45
  parent = node.parent
43
46
  next if !parent || visited.include?(parent)
47
+
44
48
  yield parent if plural_forms?(parent.children)
45
49
  visited.add(parent)
46
50
  end
@@ -48,14 +52,9 @@ module I18n::Tasks::PluralKeys
48
52
  end
49
53
 
50
54
  def plural_forms?(s)
51
- return false if non_plural_other?(s)
52
55
  s.present? && s.all? { |node| node.leaf? && plural_suffix?(node.key) }
53
56
  end
54
57
 
55
- def non_plural_other?(s)
56
- s.size == 1 && s.first.leaf? && (!s.first.value.is_a?(String) || !s.first.value.include?('%{count}'))
57
- end
58
-
59
58
  def plural_suffix?(key)
60
59
  PLURAL_KEY_SUFFIXES.include?(key)
61
60
  end
@@ -10,12 +10,14 @@ module I18n::Tasks
10
10
  data_refs = merge_reference_trees(data_forest.select_keys { |_, node| node.reference? }))
11
11
  fail ArgumentError, 'usages must be a Data::Tree::Instance' unless usages.is_a?(Data::Tree::Siblings)
12
12
  fail ArgumentError, 'all_references must be a Data::Tree::Instance' unless data_refs.is_a?(Data::Tree::Siblings)
13
+
13
14
  raw_refs = empty_forest
14
15
  resolved_refs = empty_forest
15
16
  refs = empty_forest
16
17
  data_refs.key_to_node.each do |ref_key_part, ref_node|
17
18
  usages.each do |usage_node|
18
19
  next unless usage_node.key == ref_key_part
20
+
19
21
  if ref_node.leaf?
20
22
  process_leaf!(ref_node, usage_node, raw_refs, resolved_refs, refs)
21
23
  else
@@ -89,8 +91,8 @@ module I18n::Tasks
89
91
  if node.value != other.value
90
92
  log_warn(
91
93
  'Conflicting references: '\
92
- "#{node.full_key(root: false)} ⮕ #{node.value} in #{node.data[:locale]},"\
93
- " but ⮕ #{other.value} in #{other.data[:locale]}"
94
+ "#{node.full_key(root: false)} ⮕ #{node.value} in #{node.data[:locale]},"\
95
+ " but ⮕ #{other.value} in #{other.data[:locale]}"
94
96
  )
95
97
  end
96
98
  end
@@ -9,6 +9,7 @@ module I18n::Tasks::Reports
9
9
  end
10
10
 
11
11
  attr_reader :task
12
+
12
13
  delegate :base_locale, :locales, to: :task
13
14
 
14
15
  protected
@@ -36,7 +37,7 @@ module I18n::Tasks::Reports
36
37
  def used_title(keys_nodes, filter)
37
38
  used_n = keys_nodes.map { |_k, node| node.data[:occurrences].size }.reduce(:+).to_i
38
39
  "#{keys_nodes.size} key#{'s' if keys_nodes.size != 1}#{" matching '#{filter}'" if filter}"\
39
- "#{" (#{used_n} usage#{'s' if used_n != 1})" if used_n.positive?}"
40
+ "#{" (#{used_n} usage#{'s' if used_n != 1})" if used_n.positive?}"
40
41
  end
41
42
 
42
43
  # Sort keys by their attributes in order
@@ -58,6 +59,7 @@ module I18n::Tasks::Reports
58
59
 
59
60
  def format_locale(locale)
60
61
  return '' unless locale
62
+
61
63
  if locale.split('+') == task.locales.sort
62
64
  'all'
63
65
  else
@@ -67,8 +69,7 @@ module I18n::Tasks::Reports
67
69
 
68
70
  def collapse_missing_tree!(forest)
69
71
  forest = task.collapse_plural_nodes!(forest)
70
- forest = task.collapse_same_key_in_locales!(forest) { |node| node.data[:type] == :missing_used }
71
- forest
72
+ task.collapse_same_key_in_locales!(forest) { |node| node.data[:type] == :missing_used }
72
73
  end
73
74
  end
74
75
  end
@@ -106,9 +106,10 @@ module I18n
106
106
  private
107
107
 
108
108
  def missing_key_info(leaf)
109
- if leaf[:type] == :missing_used
109
+ case leaf[:type]
110
+ when :missing_used
110
111
  first_occurrence leaf
111
- elsif leaf[:type] == :missing_plural
112
+ when :missing_plural
112
113
  leaf[:data][:missing_keys].join(', ')
113
114
  else
114
115
  "#{Rainbow(leaf[:data][:missing_diff_locale]).cyan} "\
@@ -120,9 +121,9 @@ module I18n
120
121
  if data[:ref_info]
121
122
  from, to = data[:ref_info]
122
123
  resolved = key[0...to.length]
123
- after = key[to.length..-1]
124
+ after = key[to.length..]
124
125
  " #{Rainbow(from).yellow}#{Rainbow(after).cyan}\n" \
125
- "#{Rainbow('⮕').yellow.bright} #{Rainbow(resolved).yellow.bright}"
126
+ "#{Rainbow('⮕').yellow.bright} #{Rainbow(resolved).yellow.bright}"
126
127
  else
127
128
  Rainbow(key).cyan
128
129
  end
@@ -134,6 +135,7 @@ module I18n
134
135
 
135
136
  def format_reference_desc(node_data)
136
137
  return nil unless node_data
138
+
137
139
  case node_data[:ref_type]
138
140
  when :reference_usage
139
141
  Rainbow('(ref)').yellow.bright
@@ -159,9 +161,9 @@ module I18n
159
161
  print_table headings: [Rainbow(I18n.t('i18n_tasks.common.locale')).cyan.bright,
160
162
  Rainbow(I18n.t('i18n_tasks.common.key')).cyan.bright,
161
163
  I18n.t('i18n_tasks.common.value')] do |t|
162
- t.rows = locale_key_value_datas.map { |(locale, k, v, data)|
164
+ t.rows = locale_key_value_datas.map do |(locale, k, v, data)|
163
165
  [{ value: Rainbow(locale).cyan, alignment: :center }, format_key(k, data), format_value(v)]
164
- }
166
+ end
165
167
  end
166
168
  else
167
169
  puts 'ø'
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18n::Tasks::Scanners::AstMatchers
4
+ class BaseMatcher
5
+ def initialize(scanner:)
6
+ @scanner = scanner
7
+ end
8
+
9
+ def convert_to_key_occurrences(send_node, _method_name, location: send_node.loc)
10
+ fail('Not implemented')
11
+ end
12
+
13
+ protected
14
+
15
+ # If the node type is of `%i(sym str int false true)`, return the value as a string.
16
+ # Otherwise, if `config[:strict]` is `false` and the type is of `%i(dstr dsym)`,
17
+ # return the source as if it were a string.
18
+ #
19
+ # @param node [Parser::AST::Node]
20
+ # @param array_join_with [String, nil] if set to a string, arrays will be processed and their elements joined.
21
+ # @param array_flatten [Boolean] if true, nested arrays are flattened,
22
+ # otherwise their source is copied and surrounded by #{}. No effect unless `array_join_with` is set.
23
+ # @param array_reject_blank [Boolean] if true, empty strings and `nil`s are skipped.
24
+ # No effect unless `array_join_with` is set.
25
+ # @return [String, nil] `nil` is returned only when a dynamic value is encountered in strict mode
26
+ # or the node type is not supported.
27
+ def extract_string(node, array_join_with: nil, array_flatten: false, array_reject_blank: false) # rubocop:disable Metrics/AbcSize, Metrics/PerceivedComplexity
28
+ return if node.nil?
29
+
30
+ if %i[sym str int].include?(node.type)
31
+ node.children[0].to_s
32
+ elsif %i[true false].include?(node.type)
33
+ node.type.to_s
34
+ elsif node.type == :nil
35
+ ''
36
+ elsif node.type == :array && array_join_with
37
+ extract_array_as_string(
38
+ node,
39
+ array_join_with: array_join_with,
40
+ array_flatten: array_flatten,
41
+ array_reject_blank: array_reject_blank
42
+ ).tap do |str|
43
+ # `nil` is returned when a dynamic value is encountered in strict mode. Propagate:
44
+ return nil if str.nil?
45
+ end
46
+ elsif !@scanner.config[:strict] && %i[dsym dstr].include?(node.type)
47
+ node.children.map do |child|
48
+ if %i[sym str].include?(child.type)
49
+ child.children[0].to_s
50
+ else
51
+ child.loc.expression.source
52
+ end
53
+ end.join
54
+ end
55
+ end
56
+
57
+ # Extract the whole hash from a node of type `:hash`
58
+ #
59
+ # @param node [AST::Node] a node of type `:hash`.
60
+ # @return [Hash] the whole hash from the node
61
+ def extract_hash(node)
62
+ return {} if node.nil?
63
+
64
+ if node.type == :hash
65
+ node.children.each_with_object({}) do |pair, h|
66
+ key = pair.children[0].children[0].to_s
67
+ value = pair.children[1].children[0]
68
+ h[key] = value
69
+ end
70
+ end
71
+ end
72
+
73
+ # Extract a hash pair with a given literal key.
74
+ #
75
+ # @param node [AST::Node] a node of type `:hash`.
76
+ # @param key [String] node key as a string (indifferent symbol-string matching).
77
+ # @return [AST::Node, nil] a node of type `:pair` or nil.
78
+ def extract_hash_pair(node, key)
79
+ node.children.detect do |child|
80
+ next unless child.type == :pair
81
+
82
+ key_node = child.children[0]
83
+ %i[sym str].include?(key_node.type) && key_node.children[0].to_s == key
84
+ end
85
+ end
86
+
87
+ # Extract an array as a single string.
88
+ #
89
+ # @param array_join_with [String] joiner of the array elements.
90
+ # @param array_flatten [Boolean] if true, nested arrays are flattened,
91
+ # otherwise their source is copied and surrounded by #{}.
92
+ # @param array_reject_blank [Boolean] if true, empty strings and `nil`s are skipped.
93
+ # @return [String, nil] `nil` is returned only when a dynamic value is encountered in strict mode.
94
+ def extract_array_as_string(node, array_join_with:, array_flatten: false, array_reject_blank: false)
95
+ children_strings = node.children.map do |child|
96
+ if %i[sym str int true false].include?(child.type)
97
+ extract_string child
98
+ else
99
+ # ignore dynamic argument in strict mode
100
+ return nil if @scanner.config[:strict]
101
+
102
+ if %i[dsym dstr].include?(child.type) || (child.type == :array && array_flatten)
103
+ extract_string(child, array_join_with: array_join_with)
104
+ else
105
+ "\#{#{child.loc.expression.source}}"
106
+ end
107
+ end
108
+ end
109
+ if array_reject_blank
110
+ children_strings.reject! do |x|
111
+ # empty strings and nils in the scope argument are ignored by i18n
112
+ x == ''
113
+ end
114
+ end
115
+ children_strings.join(array_join_with)
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/tasks/scanners/ast_matchers/base_matcher'
4
+ require 'i18n/tasks/scanners/results/occurrence'
5
+
6
+ module I18n::Tasks::Scanners::AstMatchers
7
+ class MessageReceiversMatcher < BaseMatcher
8
+ def initialize(scanner:, receivers:, message:)
9
+ super(scanner: scanner)
10
+ @receivers = Array(receivers)
11
+ @message = message
12
+ end
13
+
14
+ # @param send_node [Parser::AST::Node]
15
+ # @param method_name [Symbol, nil]
16
+ # @param location [Parser::Source::Map]
17
+ # @return [nil, [key, Occurrence]] full absolute key name and the occurrence.
18
+ def convert_to_key_occurrences(send_node, method_name, location: send_node.loc)
19
+ return unless node_match?(send_node)
20
+
21
+ receiver = send_node.children[0]
22
+ first_arg_node = send_node.children[2]
23
+ second_arg_node = send_node.children[3]
24
+
25
+ key = extract_string(first_arg_node)
26
+ return if key.nil?
27
+
28
+ key, default_arg = process_options(node: second_arg_node, key: key)
29
+
30
+ return if key.nil?
31
+
32
+ [
33
+ full_key(receiver: receiver, key: key, location: location, calling_method: method_name),
34
+ I18n::Tasks::Scanners::Results::Occurrence.from_range(
35
+ raw_key: key,
36
+ range: location.expression,
37
+ default_arg: default_arg
38
+ )
39
+ ]
40
+ end
41
+
42
+ private
43
+
44
+ def node_match?(node)
45
+ receiver = node.children[0]
46
+ message = node.children[1]
47
+
48
+ @message == message && @receivers.any? { |r| r == receiver }
49
+ end
50
+
51
+ def full_key(receiver:, key:, location:, calling_method:)
52
+ if receiver.nil?
53
+ # Relative keys only work if called via `t()` but not `I18n.t()`:
54
+ @scanner.absolute_key(
55
+ key,
56
+ location.expression.source_buffer.name,
57
+ calling_method: calling_method
58
+ )
59
+ else
60
+ key
61
+ end
62
+ end
63
+
64
+ def process_options(node:, key:)
65
+ return [key, nil] if node&.type != :hash
66
+
67
+ scope_node = extract_hash_pair(node, 'scope')
68
+
69
+ if scope_node
70
+ scope = extract_string(
71
+ scope_node.children[1],
72
+ array_join_with: '.',
73
+ array_flatten: true,
74
+ array_reject_blank: true
75
+ )
76
+ return nil if scope.nil? && scope_node.type != :nil
77
+
78
+ key = [scope, key].join('.') unless scope == ''
79
+ end
80
+ if default_arg_node = extract_hash_pair(node, 'default')
81
+ default_arg = if default_arg_node.children[1]&.type == :hash
82
+ extract_hash(default_arg_node.children[1])
83
+ else
84
+ extract_string(default_arg_node.children[1])
85
+ end
86
+ end
87
+
88
+ [key, default_arg]
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/tasks/scanners/results/occurrence'
4
+
5
+ module I18n::Tasks::Scanners::AstMatchers
6
+ class RailsModelMatcher < BaseMatcher
7
+ def convert_to_key_occurrences(send_node, _method_name, location: send_node.loc)
8
+ human_attribute_name_to_key_occurences(send_node: send_node, location: location) ||
9
+ model_name_human_to_key_occurences(send_node: send_node, location: location)
10
+ end
11
+
12
+ private
13
+
14
+ def human_attribute_name_to_key_occurences(send_node:, location:)
15
+ children = Array(send_node&.children)
16
+ receiver = children[0]
17
+ method_name = children[1]
18
+
19
+ return unless method_name == :human_attribute_name && receiver.type == :const
20
+
21
+ value = children[2]
22
+
23
+ model_name = underscore(receiver.to_a.last)
24
+ attribute = extract_string(value)
25
+ key = "activerecord.attributes.#{model_name}.#{attribute}"
26
+ [
27
+ key,
28
+ I18n::Tasks::Scanners::Results::Occurrence.from_range(
29
+ raw_key: key,
30
+ range: location.expression
31
+ )
32
+ ]
33
+ end
34
+
35
+ # User.model_name.human(count: 2)
36
+ # s(:send,
37
+ # s(:send,
38
+ # s(:const, nil, :User), :model_name), :human,
39
+ # s(:hash,
40
+ # s(:pair,
41
+ # s(:sym, :count),
42
+ # s(:int, 2))))
43
+ def model_name_human_to_key_occurences(send_node:, location:)
44
+ children = Array(send_node&.children)
45
+ return unless children[1] == :human
46
+
47
+ base_children = Array(children[0]&.children)
48
+ class_node = base_children[0]
49
+
50
+ return unless class_node&.type == :const && base_children[1] == :model_name
51
+
52
+ model_name = underscore(class_node.to_a.last)
53
+ key = "activerecord.models.#{model_name}"
54
+ [
55
+ key,
56
+ I18n::Tasks::Scanners::Results::Occurrence.from_range(
57
+ raw_key: key,
58
+ range: location.expression
59
+ )
60
+ ]
61
+ end
62
+
63
+ def underscore(value)
64
+ value = value.dup.to_s
65
+ value.gsub!(/(.)([A-Z])/, '\1_\2')
66
+ value.downcase!
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ast'
4
+ require 'set'
5
+ require 'i18n/tasks/scanners/local_ruby_parser'
6
+
7
+ module I18n::Tasks::Scanners
8
+ class ErbAstProcessor
9
+ include AST::Processor::Mixin
10
+ def initialize
11
+ super()
12
+ @ruby_parser = LocalRubyParser.new(ignore_blocks: true)
13
+ @comments = []
14
+ end
15
+
16
+ def process_and_extract_comments(ast)
17
+ result = process(ast)
18
+ [result, @comments]
19
+ end
20
+
21
+ def on_code(node)
22
+ parsed, comments = @ruby_parser.parse(
23
+ node.children[0],
24
+ location: node.location
25
+ )
26
+ @comments.concat(comments)
27
+
28
+ unless parsed.nil?
29
+ parsed = parsed.updated(
30
+ nil,
31
+ parsed.children.map { |child| node?(child) ? process(child) : child }
32
+ )
33
+ node = node.updated(:send, parsed)
34
+ end
35
+ node
36
+ end
37
+
38
+ # @param node [::Parser::AST::Node]
39
+ # @return [::Parser::AST::Node]
40
+ def handler_missing(node)
41
+ node = handle_comment(node)
42
+ return if node.nil?
43
+
44
+ node.updated(
45
+ nil,
46
+ node.children.map { |child| node?(child) ? process(child) : child }
47
+ )
48
+ end
49
+
50
+ private
51
+
52
+ # Convert ERB-comments to ::Parser::Source::Comment and skip processing node
53
+ #
54
+ # @param node Parser::AST::Node Potential comment node
55
+ # @return Parser::AST::Node or nil
56
+ def handle_comment(node)
57
+ if node.type == :erb && node.children.size == 4 &&
58
+ node.children[0]&.type == :indicator && node.children[0].children[0] == '#' &&
59
+ node.children[2]&.type == :code
60
+
61
+ # Do not continue parsing this node
62
+ comment = node.children[2]
63
+ @comments << ::Parser::Source::Comment.new(comment.location.expression)
64
+ return
65
+ end
66
+
67
+ node
68
+ end
69
+
70
+ def node?(node)
71
+ node.is_a?(::Parser::AST::Node)
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/tasks/scanners/ruby_ast_scanner'
4
+ require 'i18n/tasks/scanners/erb_ast_processor'
5
+ require 'better_html/errors'
6
+ require 'better_html/parser'
7
+
8
+ module I18n::Tasks::Scanners
9
+ # Scan for I18n.translate calls in ERB-file better-html and ASTs
10
+ class ErbAstScanner < RubyAstScanner
11
+ def initialize(**args)
12
+ super(**args)
13
+ @erb_ast_processor = ErbAstProcessor.new
14
+ end
15
+
16
+ private
17
+
18
+ # Parse file on path and returns AST and comments.
19
+ #
20
+ # @param path Path to file to parse
21
+ # @return [{Parser::AST::Node}, [Parser::Source::Comment]]
22
+ def path_to_ast_and_comments(path)
23
+ parser = BetterHtml::Parser.new(make_buffer(path))
24
+ ast = convert_better_html(parser.ast)
25
+ @erb_ast_processor.process_and_extract_comments(ast)
26
+ end
27
+
28
+ # Convert BetterHtml nodes to Parser::AST::Node
29
+ #
30
+ # @param node BetterHtml::Parser::AST::Node
31
+ # @return Parser::AST::Node
32
+ def convert_better_html(node)
33
+ definition = Parser::Source::Map::Definition.new(
34
+ node.location.begin,
35
+ node.location.begin,
36
+ node.location.begin,
37
+ node.location.end
38
+ )
39
+ Parser::AST::Node.new(
40
+ node.type,
41
+ node.children.map { |child| child.is_a?(BetterHtml::AST::Node) ? convert_better_html(child) : child },
42
+ {
43
+ location: definition
44
+ }
45
+ )
46
+ end
47
+ end
48
+ end
@@ -11,10 +11,11 @@ module I18n::Tasks::Scanners
11
11
  attr_reader :config
12
12
 
13
13
  def initialize(
14
- config: {},
15
- file_finder_provider: Files::CachingFileFinderProvider.new,
16
- file_reader: Files::CachingFileReader.new
14
+ config: {},
15
+ file_finder_provider: Files::CachingFileFinderProvider.new,
16
+ file_reader: Files::CachingFileReader.new
17
17
  )
18
+ super()
18
19
  @config = config
19
20
  @file_reader = file_reader
20
21
  @file_finder = file_finder_provider.get(**config.slice(:paths, :only, :exclude))
@@ -22,9 +22,6 @@ module I18n::Tasks::Scanners::Files
22
22
  # @param (see FileFinder#traverse_files)
23
23
  # @yieldparam (see FileFinder#traverse_files)
24
24
  # @return (see FileFinder#traverse_files)
25
- def traverse_files
26
- super
27
- end
28
25
 
29
26
  alias uncached_find_files find_files
30
27
  private :uncached_find_files
@@ -15,6 +15,7 @@ module I18n::Tasks::Scanners::Files
15
15
  # Files matching any of the exclusion patterns will be excluded even if they match an inclusion pattern.
16
16
  def initialize(paths: ['.'], only: nil, exclude: [])
17
17
  fail 'paths argument is required' if paths.nil?
18
+
18
19
  @paths = paths
19
20
  @include = only
20
21
  @exclude = exclude || []
@@ -25,8 +26,8 @@ module I18n::Tasks::Scanners::Files
25
26
  # @yield [path]
26
27
  # @yieldparam path [String] the path of the found file.
27
28
  # @return [Array<of block results>]
28
- def traverse_files
29
- find_files.map { |path| yield path }
29
+ def traverse_files(&block)
30
+ find_files.map(&block)
30
31
  end
31
32
 
32
33
  # @return [Array<String>] found files