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
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'parser/current'
4
+
5
+ module I18n::Tasks::Scanners
6
+ class LocalRubyParser
7
+ # ignore_blocks feature inspired by shopify/better-html
8
+ # https://github.com/Shopify/better-html/blob/087943ffd2a5877fa977d71532010b0c91239519/lib/better_html/test_helper/ruby_node.rb#L24
9
+ BLOCK_EXPR = /\s*((\s+|\))do|\{)(\s*\|[^|]*\|)?\s*\Z/.freeze
10
+
11
+ def initialize(ignore_blocks: false)
12
+ @parser = ::Parser::CurrentRuby.new
13
+ @ignore_blocks = ignore_blocks
14
+ end
15
+
16
+ # Parse string and normalize location
17
+ def parse(source, location: nil)
18
+ buffer = ::Parser::Source::Buffer.new('(string)')
19
+ buffer.source = if @ignore_blocks
20
+ source.sub(BLOCK_EXPR, '')
21
+ else
22
+ source
23
+ end
24
+
25
+ @parser.reset
26
+ ast, comments = @parser.parse_with_comments(buffer)
27
+ ast = normalize_location(ast, location)
28
+ comments = comments.map { |comment| normalize_comment_location(comment, location) }
29
+ [ast, comments]
30
+ end
31
+
32
+ # Normalize location for all parsed nodes
33
+
34
+ # @param node {Parser::AST::Node} Node in parsed code
35
+ # @param location {Parser::Source::Map} Global location for the parsed string
36
+ # @return {Parser::AST::Node}
37
+ def normalize_location(node, location)
38
+ return node.map { |child| normalize_location(child, location) } if node.is_a?(Array)
39
+
40
+ return node unless node.is_a?(::Parser::AST::Node)
41
+
42
+ node.updated(
43
+ nil,
44
+ node.children.map { |child| normalize_location(child, location) },
45
+ { location: updated_location(location, node.location) }
46
+ )
47
+ end
48
+
49
+ # Calculate location relative to a global location
50
+ #
51
+ # @param global_location {Parser::Source::Map} Global location where the code was parsed
52
+ # @param local_location {Parser::Source::Map} Local location in the parsed string
53
+ # @return {Parser::Source::Map}
54
+ def updated_location(global_location, local_location)
55
+ return global_location if local_location.expression.nil?
56
+
57
+ range = ::Parser::Source::Range.new(
58
+ global_location.expression.source_buffer,
59
+ global_location.expression.to_range.begin + local_location.expression.to_range.begin,
60
+ global_location.expression.to_range.begin + local_location.expression.to_range.end
61
+ )
62
+
63
+ ::Parser::Source::Map::Definition.new(
64
+ range.begin,
65
+ range.begin,
66
+ range.begin,
67
+ range.end
68
+ )
69
+ end
70
+
71
+ # Normalize location for comment
72
+ #
73
+ # @param comment {Parser::Source::Comment} A comment with local location
74
+ # @param location {Parser::Source::Map} Global location for the parsed string
75
+ # @return {Parser::Source::Comment}
76
+ def normalize_comment_location(comment, location)
77
+ range = ::Parser::Source::Range.new(
78
+ location.expression.source_buffer,
79
+ location.expression.to_range.begin + comment.location.expression.to_range.begin,
80
+ location.expression.to_range.begin + comment.location.expression.to_range.end
81
+ )
82
+ ::Parser::Source::Comment.new(range)
83
+ end
84
+ end
85
+ end
@@ -15,11 +15,11 @@ module I18n
15
15
  line_begin = contents.rindex(/^/, position - 1)
16
16
  line_end = contents.index(/.(?=\r?\n|$)/, position)
17
17
  Results::Occurrence.new(
18
- path: path,
19
- pos: position,
18
+ path: path,
19
+ pos: position,
20
20
  line_num: contents[0..position].count("\n") + 1,
21
21
  line_pos: position - line_begin + 1,
22
- line: contents[line_begin..line_end],
22
+ line: contents[line_begin..line_end],
23
23
  raw_key: raw_key
24
24
  )
25
25
  end
@@ -35,7 +35,7 @@ module I18n::Tasks::Scanners
35
35
  result = []
36
36
  text.scan(pattern) do |_|
37
37
  match = Regexp.last_match
38
- matches = Hash[match.names.map(&:to_sym).zip(match.captures)]
38
+ matches = match.names.map(&:to_sym).zip(match.captures).to_h
39
39
  if matches.key?(:key)
40
40
  matches[:key] = strip_literal(matches[:key])
41
41
  next unless valid_key?(matches[:key])
@@ -12,7 +12,7 @@ module I18n::Tasks::Scanners
12
12
  include OccurrenceFromPosition
13
13
  include RubyKeyLiterals
14
14
 
15
- TRANSLATE_CALL_RE = /(?<=^|[^\w'\-.]|[^\w'\-]I18n\.|I18n\.)t(?:!|ranslate!?)?/
15
+ TRANSLATE_CALL_RE = /(?<=^|[^\w'\-.]|[^\w'\-]I18n\.|I18n\.)t(?:!|ranslate!?)?/.freeze
16
16
  IGNORE_LINES = {
17
17
  'coffee' => /^\s*#(?!\si18n-tasks-use)/,
18
18
  'erb' => /^\s*<%\s*#(?!\si18n-tasks-use)/,
@@ -43,10 +43,13 @@ module I18n::Tasks::Scanners
43
43
  src_pos = Regexp.last_match.offset(0).first
44
44
  location = occurrence_from_position(path, text, src_pos, raw_key: strip_literal(match[0]))
45
45
  next if exclude_line?(location.line, path)
46
+
46
47
  key = match_to_key(match, path, location)
47
48
  next unless key
49
+
48
50
  key += ':' if key.end_with?('.')
49
51
  next unless valid_key?(key)
52
+
50
53
  keys << [key, location]
51
54
  end
52
55
  keys
@@ -63,11 +66,11 @@ module I18n::Tasks::Scanners
63
66
  end
64
67
 
65
68
  def exclude_line?(line, path)
66
- re = @ignore_lines_res[File.extname(path)[1..-1]]
69
+ re = @ignore_lines_res[File.extname(path)[1..]]
67
70
  re && re =~ line
68
71
  end
69
72
 
70
- VALID_KEY_RE_DYNAMIC = /^(#{VALID_KEY_CHARS}|[:\#{@}\[\]])+$/
73
+ VALID_KEY_RE_DYNAMIC = /^(#{VALID_KEY_CHARS}|[:\#{@}\[\]])+$/.freeze
71
74
 
72
75
  def valid_key?(key)
73
76
  if @config[:strict]
@@ -84,7 +87,7 @@ module I18n::Tasks::Scanners
84
87
  def closest_method(occurrence)
85
88
  method = File.readlines(occurrence.path, encoding: 'UTF-8')
86
89
  .first(occurrence.line_num - 1).reverse_each.find { |x| x =~ /\bdef\b/ }
87
- method && method.strip.sub(/^def\s*/, '').sub(/[\(\s;].*$/, '')
90
+ method && method.strip.sub(/^def\s*/, '').sub(/[(\s;].*$/, '')
88
91
  end
89
92
 
90
93
  # This method only exists for backwards compatibility with monkey-patches and plugins
@@ -93,7 +96,7 @@ module I18n::Tasks::Scanners
93
96
  def default_pattern
94
97
  # capture only the first argument
95
98
  /
96
- #{translate_call_re} [\( ] \s* (?# fn call begin )
99
+ #{translate_call_re} [( ] \s* (?# fn call begin )
97
100
  (#{first_argument_re}) (?# capture the first argument)
98
101
  /x
99
102
  end
@@ -26,6 +26,7 @@ module I18n::Tasks::Scanners
26
26
  if scope
27
27
  scope_parts = extract_literal_or_array_of_literals(scope)
28
28
  return nil if scope_parts.nil? || scope_parts.empty?
29
+
29
30
  "#{scope_parts.join('.')}.#{key}"
30
31
  else
31
32
  key unless match[0] =~ /\A\w/
@@ -61,7 +62,7 @@ module I18n::Tasks::Scanners
61
62
 
62
63
  # extract literal or array of literals
63
64
  # returns nil on any other input
64
- # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
65
+ # rubocop:disable Metrics/MethodLength,Metrics/PerceivedComplexity
65
66
  def extract_literal_or_array_of_literals(s)
66
67
  literals = []
67
68
  braces_stack = []
@@ -78,6 +79,7 @@ module I18n::Tasks::Scanners
78
79
  s.each_char.with_index do |c, i|
79
80
  if c == '['
80
81
  return nil unless braces_stack.empty?
82
+
81
83
  braces_stack.push(i)
82
84
  elsif c == ']'
83
85
  break
@@ -93,6 +95,6 @@ module I18n::Tasks::Scanners
93
95
  consume_literal.call unless acc.empty?
94
96
  literals
95
97
  end
96
- # rubocop:enable Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
98
+ # rubocop:enable Metrics/MethodLength,Metrics/PerceivedComplexity
97
99
  end
98
100
  end
@@ -9,15 +9,23 @@ module I18n
9
9
  # @param roots [Array<String>] paths to relative roots
10
10
  # @param calling_method [#call, Symbol, String, false, nil]
11
11
  # @return [String] absolute version of the key
12
- def absolute_key(key, path, roots: config[:relative_roots], calling_method: nil)
12
+ def absolute_key(key, path, roots: config[:relative_roots],
13
+ exclude_method_name_paths: config[:relative_exclude_method_name_paths],
14
+ calling_method: nil)
13
15
  return key unless key.start_with?(DOT)
14
16
  fail 'roots argument is required' unless roots.present?
17
+
15
18
  normalized_path = File.expand_path(path)
16
19
  (root = path_root(normalized_path, roots)) ||
17
20
  fail(CommandError, "Cannot resolve relative key \"#{key}\".\n" \
18
- "Set search.relative_roots in config/i18n-tasks.yml (currently #{roots.inspect})")
21
+ "Set search.relative_roots in config/i18n-tasks.yml (currently #{roots.inspect})")
19
22
  normalized_path.sub!(root, '')
20
- "#{prefix(normalized_path, calling_method: calling_method)}#{key}"
23
+
24
+ if (exclude_method_name_paths || []).map { |p| expand_path(p) }.include?(root)
25
+ "#{prefix(normalized_path)}#{key}"
26
+ else
27
+ "#{prefix(normalized_path, calling_method: calling_method)}#{key}"
28
+ end
21
29
  end
22
30
 
23
31
  private
@@ -30,12 +38,19 @@ module I18n
30
38
  # @return [String] the closest ancestor root for path, with a trailing {File::SEPARATOR}.
31
39
  def path_root(path, roots)
32
40
  roots.map do |p|
33
- File.expand_path(p) + File::SEPARATOR
41
+ expand_path(p)
34
42
  end.sort.reverse_each.detect do |root|
35
43
  path.start_with?(root)
36
44
  end
37
45
  end
38
46
 
47
+ # Expand a path and add a trailing {File::SEPARATOR}
48
+ # @param [String] path relative path
49
+ # @return [String] absolute path, with a trailing {File::SEPARATOR}.
50
+ def expand_path(path)
51
+ File.expand_path(path) + File::SEPARATOR
52
+ end
53
+
39
54
  # @param normalized_path [String] path/relative/to/a/root
40
55
  # @param calling_method [#call, Symbol, String, false, nil]
41
56
  def prefix(normalized_path, calling_method: nil)
@@ -48,7 +48,7 @@ module I18n::Tasks
48
48
  # rubocop:enable Metrics/ParameterLists
49
49
 
50
50
  def inspect
51
- "Occurrence(#{@path}:#{@line_num}:#{@line_pos}:#{@pos}:#{@raw_key}:#{@default_arg})"
51
+ "Occurrence(#{@path}:#{@line_num}, line_pos: #{@line_pos}, pos: #{@pos}, raw_key: #{@raw_key}, default_arg: #{@default_arg}, line: #{@line})" # rubocop:disable Layout/LineLength
52
52
  end
53
53
 
54
54
  def ==(other)
@@ -63,6 +63,22 @@ module I18n::Tasks
63
63
  def hash
64
64
  [@path, @pos, @line_num, @line_pos, @line, @default_arg].hash
65
65
  end
66
+
67
+ # @param raw_key [String]
68
+ # @param range [Parser::Source::Range]
69
+ # @param default_arg [String, nil]
70
+ # @return [Results::Occurrence]
71
+ def self.from_range(raw_key:, range:, default_arg: nil)
72
+ Occurrence.new(
73
+ path: range.source_buffer.name,
74
+ pos: range.begin_pos,
75
+ line_num: range.line,
76
+ line_pos: range.column,
77
+ line: range.source_line,
78
+ raw_key: raw_key,
79
+ default_arg: default_arg
80
+ )
81
+ end
66
82
  end
67
83
  end
68
84
  end
@@ -1,56 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'ast'
4
- require 'set'
4
+
5
5
  module I18n::Tasks::Scanners
6
6
  class RubyAstCallFinder
7
7
  include AST::Processor::Mixin
8
8
 
9
- # @param receiver_messages [Set<Pair<[nil, AST::Node>, Symbol>>] The receiver-message pairs to look for.
10
- def initialize(receiver_messages:)
11
- super()
12
- @message_receivers = receiver_messages.each_with_object({}) do |(receiver, message), t|
13
- (t[message] ||= []) << receiver
14
- end
15
- end
16
-
17
9
  # @param root_node [Parser::AST:Node]
18
- # @yieldparam send_node [Parser::AST:Node]
19
- # @yieldparam method_name [nil, String] the surrounding method's name.
20
- def find_calls(root_node, &block)
21
- @callback = block
22
- process root_node
23
- ensure
24
- @callback = nil
25
- end
26
-
27
- # @param root_node (see #find_calls)
28
- # @yieldparam (see #find_calls)
29
- # @return [Array<block return values excluding nils>]
10
+ # @return [Pair<Parser::AST::Node, method_name as string>] for all nodes with :send type
30
11
  def collect_calls(root_node)
31
- results = []
32
- find_calls root_node do |send_node, method_name|
33
- result = yield send_node, method_name
34
- results << result if result
35
- end
36
- results
12
+ @results = []
13
+ process(root_node)
14
+ @results
37
15
  end
38
16
 
39
17
  def on_def(node)
40
18
  @method_name = node.children[0]
41
- handler_missing node
19
+ handler_missing(node)
42
20
  ensure
43
21
  @method_name = nil
44
22
  end
45
23
 
46
24
  def on_send(send_node)
47
- receiver = send_node.children[0]
48
- message = send_node.children[1]
49
- valid_receivers = @message_receivers[message]
50
- # use `any?` because `include?` checks type equality, but the receiver is a Parser::AST::Node != AST::Node.
51
- @callback.call(send_node, @method_name) if valid_receivers&.any? { |r| r == receiver }
25
+ @results << [send_node, @method_name]
26
+
52
27
  # always invoke handler_missing to get nested translations in children
53
- handler_missing send_node
28
+ handler_missing(send_node)
54
29
  nil
55
30
  end
56
31
 
@@ -3,27 +3,22 @@
3
3
  require 'i18n/tasks/scanners/file_scanner'
4
4
  require 'i18n/tasks/scanners/relative_keys'
5
5
  require 'i18n/tasks/scanners/ruby_ast_call_finder'
6
+ require 'i18n/tasks/scanners/ast_matchers/message_receivers_matcher'
6
7
  require 'parser/current'
7
8
 
8
- # rubocop:disable Metrics/AbcSize,Metrics/BlockNesting,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
9
- # TODO: make this class more readable.
10
-
11
9
  module I18n::Tasks::Scanners
12
10
  # Scan for I18n.translate calls using whitequark/parser
13
- class RubyAstScanner < FileScanner # rubocop:disable Metrics/ClassLength
11
+ class RubyAstScanner < FileScanner
14
12
  include RelativeKeys
15
13
  include AST::Sexp
16
14
 
17
- MAGIC_COMMENT_PREFIX = /\A.\s*i18n-tasks-use\s+/
18
- RECEIVER_MESSAGES = [nil, AST::Node.new(:const, [nil, :I18n])].product(%i[t t! translate translate!])
15
+ MAGIC_COMMENT_PREFIX = /\A.\s*i18n-tasks-use\s+/.freeze
19
16
 
20
17
  def initialize(**args)
21
18
  super(**args)
22
19
  @parser = ::Parser::CurrentRuby.new
23
20
  @magic_comment_parser = ::Parser::CurrentRuby.new
24
- @call_finder = RubyAstCallFinder.new(
25
- receiver_messages: config[:receiver_messages] || RECEIVER_MESSAGES
26
- )
21
+ @matchers = setup_matchers
27
22
  end
28
23
 
29
24
  protected
@@ -32,175 +27,117 @@ module I18n::Tasks::Scanners
32
27
  #
33
28
  # @return [Array<[key, Results::KeyOccurrence]>] each occurrence found in the file
34
29
  def scan_file(path)
35
- @parser.reset
36
- ast, comments = @parser.parse_with_comments(make_buffer(path))
37
-
38
- results = @call_finder.collect_calls ast do |send_node, method_name|
39
- send_node_to_key_occurrence(send_node, method_name)
40
- end
30
+ ast, comments = path_to_ast_and_comments(path)
41
31
 
42
- magic_comments = comments.select { |comment| comment.text =~ MAGIC_COMMENT_PREFIX }
43
- comment_to_node = Parser::Source::Comment.associate_locations(ast, magic_comments).tap do |h|
44
- # transform_values is only available in ActiveSupport 4.2+
45
- h.each { |k, v| h[k] = v.first }
46
- end.invert
47
- results + (magic_comments.flat_map do |comment|
48
- @parser.reset
49
- associated_node = comment_to_node[comment]
50
- @call_finder.collect_calls(
51
- @parser.parse(make_buffer(path, comment.text.sub(MAGIC_COMMENT_PREFIX, '').split(/\s+(?=t)/).join('; ')))
52
- ) do |send_node, _method_name|
53
- # method_name is not available at this stage
54
- send_node_to_key_occurrence(send_node, nil, location: associated_node || comment.location)
55
- end
56
- end)
32
+ ast_to_occurences(ast) + comments_to_occurences(path, ast, comments)
57
33
  rescue Exception => e # rubocop:disable Lint/RescueException
58
34
  raise ::I18n::Tasks::CommandError.new(e, "Error scanning #{path}: #{e.message}")
59
35
  end
60
36
 
61
- # @param send_node [Parser::AST::Node]
62
- # @param method_name [Symbol, nil]
63
- # @param location [Parser::Source::Map]
64
- # @return [nil, [key, Occurrence]] full absolute key name and the occurrence.
65
- def send_node_to_key_occurrence(send_node, method_name, location: send_node.loc)
66
- if (first_arg_node = send_node.children[2]) &&
67
- (key = extract_string(first_arg_node))
68
- if (second_arg_node = send_node.children[3]) &&
69
- second_arg_node.type == :hash
70
- if (scope_node = extract_hash_pair(second_arg_node, 'scope'))
71
- scope = extract_string(scope_node.children[1],
72
- array_join_with: '.', array_flatten: true, array_reject_blank: true)
73
- return nil if scope.nil? && scope_node.type != :nil
74
- key = [scope, key].join('.') unless scope == ''
75
- end
76
- default_arg = if (default_arg_node = extract_hash_pair(second_arg_node, 'default'))
77
- extract_string(default_arg_node.children[1])
78
- end
79
- end
80
- full_key = if send_node.children[0].nil?
81
- # Relative keys only work if called via `t()` but not `I18n.t()`:
82
- absolute_key(key, location.expression.source_buffer.name, calling_method: method_name)
83
- else
84
- key
85
- end
86
- [full_key, range_to_occurrence(key, location.expression, default_arg: default_arg)]
87
- end
37
+ # Parse file on path and returns AST and comments.
38
+ #
39
+ # @param path Path to file to parse
40
+ # @return [{Parser::AST::Node}, [Parser::Source::Comment]]
41
+ def path_to_ast_and_comments(path)
42
+ @parser.reset
43
+ @parser.parse_with_comments(make_buffer(path))
88
44
  end
89
45
 
90
- # Extract a hash pair with a given literal key.
91
- #
92
- # @param node [AST::Node] a node of type `:hash`.
93
- # @param key [String] node key as a string (indifferent symbol-string matching).
94
- # @return [AST::Node, nil] a node of type `:pair` or nil.
95
- def extract_hash_pair(node, key)
96
- node.children.detect do |child|
97
- next unless child.type == :pair
98
- key_node = child.children[0]
99
- %i[sym str].include?(key_node.type) && key_node.children[0].to_s == key
100
- end
46
+ def keys_relative_to_calling_method?(path)
47
+ /controllers|mailers/.match(path)
101
48
  end
102
49
 
103
- # If the node type is of `%i(sym str int false true)`, return the value as a string.
104
- # Otherwise, if `config[:strict]` is `false` and the type is of `%i(dstr dsym)`,
105
- # return the source as if it were a string.
50
+ # Create an {Parser::Source::Buffer} with the given contents.
51
+ # The contents are assigned a {Parser::Source::Buffer#raw_source}.
106
52
  #
107
- # @param node [Parser::AST::Node]
108
- # @param array_join_with [String, nil] if set to a string, arrays will be processed and their elements joined.
109
- # @param array_flatten [Boolean] if true, nested arrays are flattened,
110
- # otherwise their source is copied and surrounded by #{}. No effect unless `array_join_with` is set.
111
- # @param array_reject_blank [Boolean] if true, empty strings and `nil`s are skipped.
112
- # No effect unless `array_join_with` is set.
113
- # @return [String, nil] `nil` is returned only when a dynamic value is encountered in strict mode
114
- # or the node type is not supported.
115
- def extract_string(node, array_join_with: nil, array_flatten: false, array_reject_blank: false)
116
- if %i[sym str int].include?(node.type)
117
- node.children[0].to_s
118
- elsif %i[true false].include?(node.type) # rubocop:disable Lint/BooleanSymbol
119
- node.type.to_s
120
- elsif node.type == :nil
121
- ''
122
- elsif node.type == :array && array_join_with
123
- extract_array_as_string(
124
- node,
125
- array_join_with: array_join_with,
126
- array_flatten: array_flatten,
127
- array_reject_blank: array_reject_blank
128
- ).tap do |str|
129
- # `nil` is returned when a dynamic value is encountered in strict mode. Propagate:
130
- return nil if str.nil?
131
- end
132
- elsif !config[:strict] && %i[dsym dstr].include?(node.type)
133
- node.children.map do |child|
134
- if %i[sym str].include?(child.type)
135
- child.children[0].to_s
136
- else
137
- child.loc.expression.source
138
- end
139
- end.join
53
+ # @param path [String] Path to assign as the buffer name.
54
+ # @param contents [String]
55
+ # @return [Parser::Source::Buffer] file contents
56
+ def make_buffer(path, contents = read_file(path))
57
+ Parser::Source::Buffer.new(path).tap do |buffer|
58
+ buffer.raw_source = contents
140
59
  end
141
60
  end
142
61
 
143
- # Extract an array as a single string.
62
+ # Convert an array of {Parser::Source::Comment} to occurrences.
144
63
  #
145
- # @param array_join_with [String] joiner of the array elements.
146
- # @param array_flatten [Boolean] if true, nested arrays are flattened,
147
- # otherwise their source is copied and surrounded by #{}.
148
- # @param array_reject_blank [Boolean] if true, empty strings and `nil`s are skipped.
149
- # @return [String, nil] `nil` is returned only when a dynamic value is encountered in strict mode.
150
- def extract_array_as_string(node, array_join_with:, array_flatten: false, array_reject_blank: false)
151
- children_strings = node.children.map do |child|
152
- if %i[sym str int true false].include?(child.type) # rubocop:disable Lint/BooleanSymbol
153
- extract_string child
154
- else
155
- # ignore dynamic argument in strict mode
156
- return nil if config[:strict]
157
- if %i[dsym dstr].include?(child.type) || (child.type == :array && array_flatten)
158
- extract_string(child, array_join_with: array_join_with)
159
- else
160
- "\#{#{child.loc.expression.source}}"
64
+ # @param path Path to file
65
+ # @param ast Parser::AST::Node
66
+ # @param comments [Parser::Source::Comment]
67
+ # @return [nil, [key, Occurrence]] full absolute key name and the occurrence.
68
+ def comments_to_occurences(path, ast, comments)
69
+ magic_comments = comments.select { |comment| comment.text =~ MAGIC_COMMENT_PREFIX }
70
+ comment_to_node = Parser::Source::Comment.associate_locations(ast, magic_comments).tap do |h|
71
+ h.transform_values!(&:first)
72
+ end.invert
73
+
74
+ magic_comments.flat_map do |comment|
75
+ @parser.reset
76
+ associated_node = comment_to_node[comment]
77
+ ast = @parser.parse(make_buffer(path, comment.text.sub(MAGIC_COMMENT_PREFIX, '').split(/\s+(?=t)/).join('; ')))
78
+ calls = RubyAstCallFinder.new.collect_calls(ast)
79
+ results = []
80
+
81
+ # method_name is not available at this stage
82
+ calls.each do |send_node, _method_name|
83
+ @matchers.each do |matcher|
84
+ result = matcher.convert_to_key_occurrences(
85
+ send_node,
86
+ nil,
87
+ location: associated_node || comment.location
88
+ )
89
+ results << result if result
161
90
  end
162
91
  end
92
+
93
+ results
163
94
  end
164
- if array_reject_blank
165
- children_strings.reject! do |x|
166
- # empty strings and nils in the scope argument are ignored by i18n
167
- x == ''
95
+ end
96
+
97
+ # Convert {Parser::AST::Node} to occurrences.
98
+ #
99
+ # @param ast {Parser::Source::Comment}
100
+ # @return [nil, [key, Occurrence]] full absolute key name and the occurrence.
101
+ def ast_to_occurences(ast)
102
+ calls = RubyAstCallFinder.new.collect_calls(ast)
103
+ results = []
104
+ calls.each do |send_node, method_name|
105
+ @matchers.each do |matcher|
106
+ result = matcher.convert_to_key_occurrences(send_node, method_name)
107
+ results << result if result
168
108
  end
169
109
  end
170
- children_strings.join(array_join_with)
171
- end
172
110
 
173
- def keys_relative_to_calling_method?(path)
174
- /controllers|mailers/.match(path)
111
+ results
175
112
  end
176
113
 
177
- # @param raw_key [String]
178
- # @param range [Parser::Source::Range]
179
- # @param default_arg [String, nil]
180
- # @return [Results::Occurrence]
181
- def range_to_occurrence(raw_key, range, default_arg: nil)
182
- Results::Occurrence.new(
183
- path: range.source_buffer.name,
184
- pos: range.begin_pos,
185
- line_num: range.line,
186
- line_pos: range.column,
187
- line: range.source_line,
188
- raw_key: raw_key,
189
- default_arg: default_arg
190
- )
191
- end
114
+ def setup_matchers
115
+ if config[:receiver_messages]
116
+ config[:receiver_messages].map do |receiver, message|
117
+ AstMatchers::MessageReceiversMatcher.new(
118
+ receivers: [receiver],
119
+ message: message,
120
+ scanner: self
121
+ )
122
+ end
123
+ else
124
+ matchers = %i[t t! translate translate!].map do |message|
125
+ AstMatchers::MessageReceiversMatcher.new(
126
+ receivers: [
127
+ AST::Node.new(:const, [nil, :I18n]),
128
+ nil
129
+ ],
130
+ message: message,
131
+ scanner: self
132
+ )
133
+ end
192
134
 
193
- # Create an {Parser::Source::Buffer} with the given contents.
194
- # The contents are assigned a {Parser::Source::Buffer#raw_source}.
195
- #
196
- # @param path [String] Path to assign as the buffer name.
197
- # @param contents [String]
198
- # @return [Parser::Source::Buffer] file contents
199
- def make_buffer(path, contents = read_file(path))
200
- Parser::Source::Buffer.new(path).tap do |buffer|
201
- buffer.raw_source = contents
135
+ Array(config[:ast_matchers]).each do |class_name|
136
+ matchers << ActiveSupport::Inflector.constantize(class_name).new(scanner: self)
137
+ end
138
+
139
+ matchers
202
140
  end
203
141
  end
204
142
  end
205
143
  end
206
- # rubocop:enable Metrics/AbcSize,Metrics/BlockNesting,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
@@ -2,7 +2,7 @@
2
2
 
3
3
  module I18n::Tasks::Scanners
4
4
  module RubyKeyLiterals
5
- LITERAL_RE = /:?".+?"|:?'.+?'|:\w+/
5
+ LITERAL_RE = /:?".+?"|:?'.+?'|:\w+/.freeze
6
6
 
7
7
  # Match literals:
8
8
  # * String: '', "#{}"
@@ -15,13 +15,13 @@ module I18n::Tasks::Scanners
15
15
  # @param literal [String] e.g: "key", 'key', or :key.
16
16
  # @return [String] key
17
17
  def strip_literal(literal)
18
- literal = literal[1..-1] if literal[0] == ':'
18
+ literal = literal[1..] if literal[0] == ':'
19
19
  literal = literal[1..-2] if literal[0] == "'" || literal[0] == '"'
20
20
  literal
21
21
  end
22
22
 
23
- VALID_KEY_CHARS = /(?:[[:word:]]|[-.?!:;À-ž])/
24
- VALID_KEY_RE = /^#{VALID_KEY_CHARS}+$/
23
+ VALID_KEY_CHARS = %r{(?:[[:word:]]|[-.?!:;À-ž/])}.freeze
24
+ VALID_KEY_RE = /^#{VALID_KEY_CHARS}+$/.freeze
25
25
 
26
26
  def valid_key?(key)
27
27
  key =~ VALID_KEY_RE && !key.end_with?('.')