i18n-processes 0.1.0

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 (132) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile.lock +102 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +46 -0
  5. data/Rakefile +12 -0
  6. data/bin/i18n-processes +28 -0
  7. data/bin/i18n-processes.cmd +2 -0
  8. data/config/locales/en.yml +2 -0
  9. data/config/locales/zh-CN.yml +2 -0
  10. data/i18n-processes.gemspec +64 -0
  11. data/lib/i18n/processes/base_process.rb +47 -0
  12. data/lib/i18n/processes/cli.rb +208 -0
  13. data/lib/i18n/processes/command/collection.rb +21 -0
  14. data/lib/i18n/processes/command/commander.rb +43 -0
  15. data/lib/i18n/processes/command/commands/data.rb +107 -0
  16. data/lib/i18n/processes/command/commands/eq_base.rb +21 -0
  17. data/lib/i18n/processes/command/commands/health.rb +26 -0
  18. data/lib/i18n/processes/command/commands/meta.rb +38 -0
  19. data/lib/i18n/processes/command/commands/missing.rb +86 -0
  20. data/lib/i18n/processes/command/commands/preprocessing.rb +90 -0
  21. data/lib/i18n/processes/command/commands/tree.rb +119 -0
  22. data/lib/i18n/processes/command/commands/usages.rb +69 -0
  23. data/lib/i18n/processes/command/commands/xlsx.rb +29 -0
  24. data/lib/i18n/processes/command/dsl.rb +56 -0
  25. data/lib/i18n/processes/command/option_parsers/enum.rb +55 -0
  26. data/lib/i18n/processes/command/option_parsers/locale.rb +60 -0
  27. data/lib/i18n/processes/command/options/common.rb +41 -0
  28. data/lib/i18n/processes/command/options/data.rb +95 -0
  29. data/lib/i18n/processes/command/options/locales.rb +36 -0
  30. data/lib/i18n/processes/command_error.rb +13 -0
  31. data/lib/i18n/processes/commands.rb +31 -0
  32. data/lib/i18n/processes/configuration.rb +132 -0
  33. data/lib/i18n/processes/console_context.rb +76 -0
  34. data/lib/i18n/processes/data/adapter/json_adapter.rb +29 -0
  35. data/lib/i18n/processes/data/adapter/yaml_adapter.rb +27 -0
  36. data/lib/i18n/processes/data/file_formats.rb +111 -0
  37. data/lib/i18n/processes/data/file_system.rb +14 -0
  38. data/lib/i18n/processes/data/file_system_base.rb +205 -0
  39. data/lib/i18n/processes/data/router/conservative_router.rb +66 -0
  40. data/lib/i18n/processes/data/router/pattern_router.rb +60 -0
  41. data/lib/i18n/processes/data/tree/node.rb +204 -0
  42. data/lib/i18n/processes/data/tree/nodes.rb +97 -0
  43. data/lib/i18n/processes/data/tree/siblings.rb +333 -0
  44. data/lib/i18n/processes/data/tree/traversal.rb +190 -0
  45. data/lib/i18n/processes/data.rb +87 -0
  46. data/lib/i18n/processes/google_translation.rb +125 -0
  47. data/lib/i18n/processes/html_keys.rb +16 -0
  48. data/lib/i18n/processes/ignore_keys.rb +30 -0
  49. data/lib/i18n/processes/key_pattern_matching.rb +37 -0
  50. data/lib/i18n/processes/locale_list.rb +19 -0
  51. data/lib/i18n/processes/locale_pathname.rb +17 -0
  52. data/lib/i18n/processes/logging.rb +37 -0
  53. data/lib/i18n/processes/missing_keys.rb +122 -0
  54. data/lib/i18n/processes/path.rb +42 -0
  55. data/lib/i18n/processes/plural_keys.rb +41 -0
  56. data/lib/i18n/processes/rainbow_utils.rb +13 -0
  57. data/lib/i18n/processes/references.rb +101 -0
  58. data/lib/i18n/processes/reports/base.rb +71 -0
  59. data/lib/i18n/processes/reports/spreadsheet.rb +72 -0
  60. data/lib/i18n/processes/reports/terminal.rb +252 -0
  61. data/lib/i18n/processes/scanners/file_scanner.rb +65 -0
  62. data/lib/i18n/processes/scanners/files/caching_file_finder.rb +34 -0
  63. data/lib/i18n/processes/scanners/files/caching_file_finder_provider.rb +33 -0
  64. data/lib/i18n/processes/scanners/files/caching_file_reader.rb +28 -0
  65. data/lib/i18n/processes/scanners/files/file_finder.rb +60 -0
  66. data/lib/i18n/processes/scanners/files/file_reader.rb +19 -0
  67. data/lib/i18n/processes/scanners/occurrence_from_position.rb +27 -0
  68. data/lib/i18n/processes/scanners/pattern_mapper.rb +60 -0
  69. data/lib/i18n/processes/scanners/pattern_scanner.rb +103 -0
  70. data/lib/i18n/processes/scanners/pattern_with_scope_scanner.rb +98 -0
  71. data/lib/i18n/processes/scanners/relative_keys.rb +53 -0
  72. data/lib/i18n/processes/scanners/results/key_occurrences.rb +54 -0
  73. data/lib/i18n/processes/scanners/results/occurrence.rb +69 -0
  74. data/lib/i18n/processes/scanners/ruby_ast_call_finder.rb +62 -0
  75. data/lib/i18n/processes/scanners/ruby_ast_scanner.rb +206 -0
  76. data/lib/i18n/processes/scanners/ruby_key_literals.rb +30 -0
  77. data/lib/i18n/processes/scanners/scanner.rb +17 -0
  78. data/lib/i18n/processes/scanners/scanner_multiplexer.rb +41 -0
  79. data/lib/i18n/processes/split_key.rb +68 -0
  80. data/lib/i18n/processes/stats.rb +24 -0
  81. data/lib/i18n/processes/string_interpolation.rb +16 -0
  82. data/lib/i18n/processes/unused_keys.rb +23 -0
  83. data/lib/i18n/processes/used_keys.rb +177 -0
  84. data/lib/i18n/processes/version.rb +7 -0
  85. data/lib/i18n/processes.rb +69 -0
  86. data/source/p1/_messages/zh/article.properties +9 -0
  87. data/source/p1/_messages/zh/company.properties +62 -0
  88. data/source/p1/_messages/zh/devices.properties +40 -0
  89. data/source/p1/_messages/zh/meeting-rooms.properties +99 -0
  90. data/source/p1/_messages/zh/meetingBooking.properties +18 -0
  91. data/source/p1/_messages/zh/office-areas.properties +64 -0
  92. data/source/p1/_messages/zh/orders.properties +25 -0
  93. data/source/p1/_messages/zh/schedulings.properties +7 -0
  94. data/source/p1/_messages/zh/tag.properties +2 -0
  95. data/source/p1/_messages/zh/ticket.properties +9 -0
  96. data/source/p1/_messages/zh/visitor.properties +5 -0
  97. data/source/p1/messages +586 -0
  98. data/source/p2/orders.properties +25 -0
  99. data/source/p2/schedulings.properties +7 -0
  100. data/source/p2/tag.properties +2 -0
  101. data/source/p2/ticket.properties +9 -0
  102. data/source/p2/visitor.properties +5 -0
  103. data/source/zh.messages.ts +30 -0
  104. data/translated/en/p1/_messages/zh/article.properties +9 -0
  105. data/translated/en/p1/_messages/zh/company.properties +62 -0
  106. data/translated/en/p1/_messages/zh/devices.properties +40 -0
  107. data/translated/en/p1/_messages/zh/meeting-rooms.properties +99 -0
  108. data/translated/en/p1/_messages/zh/meetingBooking.properties +18 -0
  109. data/translated/en/p1/_messages/zh/office-areas.properties +64 -0
  110. data/translated/en/p1/_messages/zh/orders.properties +25 -0
  111. data/translated/en/p1/_messages/zh/schedulings.properties +7 -0
  112. data/translated/en/p1/_messages/zh/tag.properties +2 -0
  113. data/translated/en/p1/_messages/zh/ticket.properties +9 -0
  114. data/translated/en/p1/_messages/zh/visitor.properties +5 -0
  115. data/translated/en/p1/messages +586 -0
  116. data/translated/en/p2/orders.properties +25 -0
  117. data/translated/en/p2/schedulings.properties +7 -0
  118. data/translated/en/p2/tag.properties +2 -0
  119. data/translated/en/p2/ticket.properties +9 -0
  120. data/translated/en/p2/visitor.properties +5 -0
  121. data/translated/en/zh.messages.ts +30 -0
  122. data/translation/en/article.properties +9 -0
  123. data/translation/en/company.properties +56 -0
  124. data/translation/en/meeting-rooms.properties +87 -0
  125. data/translation/en/meetingBooking.properties +14 -0
  126. data/translation/en/messages.en +164 -0
  127. data/translation/en/office-areas.properties +51 -0
  128. data/translation/en/orders.properties +26 -0
  129. data/translation/en/tag.properties +2 -0
  130. data/translation/en/translated +1263 -0
  131. data/translation/en/visitor.properties +4 -0
  132. metadata +408 -0
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/processes/scanners/file_scanner'
4
+ require 'i18n/processes/scanners/relative_keys'
5
+ require 'i18n/processes/scanners/occurrence_from_position'
6
+ require 'i18n/processes/scanners/ruby_key_literals'
7
+
8
+ module I18n::Processes::Scanners
9
+ # Scan for I18n.t usages using a simple regular expression.
10
+ class PatternScanner < FileScanner
11
+ include RelativeKeys
12
+ include OccurrenceFromPosition
13
+ include RubyKeyLiterals
14
+
15
+ TRANSLATE_CALL_RE = /(?<=^|[^\w'\-.]|[^\w'\-]I18n\.|I18n\.)t(?:ranslate)?/
16
+ IGNORE_LINES = {
17
+ 'opal' => /^\s*#(?!\si18n-tasks-use)/,
18
+ 'haml' => /^\s*-\s*#(?!\si18n-tasks-use)/,
19
+ 'slim' => %r{^\s*(?:-#|/)(?!\si18n-tasks-use)},
20
+ 'coffee' => /^\s*#(?!\si18n-tasks-use)/,
21
+ 'erb' => /^\s*<%\s*#(?!\si18n-tasks-use)/
22
+ }.freeze
23
+
24
+ def initialize(**args)
25
+ super
26
+ @translate_call_re = config[:translate_call].present? ? Regexp.new(config[:translate_call]) : TRANSLATE_CALL_RE
27
+ @pattern = config[:pattern].present? ? Regexp.new(config[:pattern]) : default_pattern
28
+ @ignore_lines_res = (config[:ignore_lines] || IGNORE_LINES).each_with_object({}) do |(ext, re), h|
29
+ h[ext.to_s] = Regexp.new(re)
30
+ end
31
+ end
32
+
33
+ protected
34
+
35
+ # Extract i18n keys from file based on the pattern which must capture the key literal.
36
+ # @return [Array<[key, Results::Occurrence]>] each occurrence found in the file
37
+ def scan_file(path)
38
+ keys = []
39
+ text = read_file(path)
40
+ text.scan(@pattern) do |match|
41
+ src_pos = Regexp.last_match.offset(0).first
42
+ location = occurrence_from_position(path, text, src_pos, raw_key: strip_literal(match[0]))
43
+ next if exclude_line?(location.line, path)
44
+ key = match_to_key(match, path, location)
45
+ next unless key
46
+ key += ':' if key.end_with?('.')
47
+ next unless valid_key?(key)
48
+ keys << [key, location]
49
+ end
50
+ keys
51
+ rescue Exception => e # rubocop:disable Lint/RescueException
52
+ raise ::I18n::Processes::CommandError.new(e, "Error scanning #{path}: #{e.message}")
53
+ end
54
+
55
+ # @param [MatchData] match
56
+ # @param [String] path
57
+ # @return [String] full absolute key name
58
+ def match_to_key(match, path, location)
59
+ absolute_key(strip_literal(match[0]), path,
60
+ calling_method: -> { closest_method(location) if key_relative_to_method?(path) })
61
+ end
62
+
63
+ def exclude_line?(line, path)
64
+ re = @ignore_lines_res[File.extname(path)[1..-1]]
65
+ re && re =~ line
66
+ end
67
+
68
+ VALID_KEY_RE_DYNAMIC = /^(#{VALID_KEY_CHARS}|[:\#{@}\[\]])+$/
69
+
70
+ def valid_key?(key)
71
+ if @config[:strict]
72
+ super(key)
73
+ else
74
+ key =~ VALID_KEY_RE_DYNAMIC
75
+ end
76
+ end
77
+
78
+ def key_relative_to_method?(path)
79
+ /controllers|mailers/ =~ path
80
+ end
81
+
82
+ def closest_method(occurrence)
83
+ method = File.readlines(occurrence.path, encoding: 'UTF-8')
84
+ .first(occurrence.line_num - 1).reverse_each.find { |x| x =~ /\bdef\b/ }
85
+ method && method.strip.sub(/^def\s*/, '').sub(/[\(\s;].*$/, '')
86
+ end
87
+
88
+ # This method only exists for backwards compatibility with monkey-patches and plugins
89
+ attr_reader :translate_call_re
90
+
91
+ def default_pattern
92
+ # capture only the first argument
93
+ /
94
+ #{translate_call_re} [\( ] \s* (?# fn call begin )
95
+ (#{first_argument_re}) (?# capture the first argument)
96
+ /x
97
+ end
98
+
99
+ def first_argument_re
100
+ literal_re
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/processes/scanners/pattern_scanner'
4
+
5
+ module I18n::Processes::Scanners
6
+ # Scans for I18n.t(key, scope: ...) usages
7
+ # both scope: "literal", and scope: [:array, :of, 'literals'] forms are supported
8
+ # Caveat: scope is only detected when it is the first argument
9
+ class PatternWithScopeScanner < PatternScanner
10
+ protected
11
+
12
+ def default_pattern
13
+ # capture the first argument and scope argument if present
14
+ /#{super}
15
+ (?: \s*,\s* #{scope_arg_re} )? (?# capture scope in second argument )
16
+ /x
17
+ end
18
+
19
+ # Given
20
+ # @param [MatchData] match
21
+ # @param [String] path
22
+ # @return [String] full absolute key name with scope resolved if any
23
+ def match_to_key(match, path, location)
24
+ key = super
25
+ scope = match[1]
26
+ if scope
27
+ scope_parts = extract_literal_or_array_of_literals(scope)
28
+ return nil if scope_parts.nil? || scope_parts.empty?
29
+ "#{scope_parts.join('.')}.#{key}"
30
+ else
31
+ key unless match[0] =~ /\A\w/
32
+ end
33
+ end
34
+
35
+ # parse expressions with literals and variable
36
+ def first_argument_re
37
+ /(?: (?: #{literal_re} ) | #{expr_re} )/x
38
+ end
39
+
40
+ # strip literals, convert expressions to #{interpolations}
41
+ def strip_literal(val)
42
+ if val =~ /\A[\w@]/
43
+ "\#{#{val}}"
44
+ else
45
+ super(val)
46
+ end
47
+ end
48
+
49
+ # scope: literal or code expression or an array of these
50
+ def scope_arg_re
51
+ /(?:
52
+ :scope\s*=>\s* | (?# :scope => :home )
53
+ scope:\s* (?# scope: :home )
54
+ ) (\[[^\n)%#]*\]|[^\n)%#,]*)/x
55
+ end
56
+
57
+ # match a limited subset of code expressions (no parenthesis, commas, etc)
58
+ def expr_re
59
+ /[\w@.&|\s?!]+/
60
+ end
61
+
62
+ # extract literal or array of literals
63
+ # returns nil on any other input
64
+ # rubocop:disable Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
65
+ def extract_literal_or_array_of_literals(s)
66
+ literals = []
67
+ braces_stack = []
68
+ acc = []
69
+ consume_literal = proc do
70
+ acc_str = acc.join
71
+ if acc_str =~ literal_re
72
+ literals << strip_literal(acc_str)
73
+ acc = []
74
+ else
75
+ return nil
76
+ end
77
+ end
78
+ s.each_char.with_index do |c, i|
79
+ if c == '['
80
+ return nil unless braces_stack.empty?
81
+ braces_stack.push(i)
82
+ elsif c == ']'
83
+ break
84
+ elsif c == ','
85
+ consume_literal.call
86
+ break if braces_stack.empty?
87
+ elsif c =~ VALID_KEY_CHARS || /['":]/ =~ c
88
+ acc << c
89
+ elsif c != ' '
90
+ return nil
91
+ end
92
+ end
93
+ consume_literal.call unless acc.empty?
94
+ literals
95
+ end
96
+ # rubocop:enable Metrics/CyclomaticComplexity,Metrics/MethodLength,Metrics/PerceivedComplexity
97
+ end
98
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18n::Processes
4
+ module Scanners
5
+ module RelativeKeys
6
+ # @param key [String] relative i18n key (starts with a .)
7
+ # @param path [String] path to the file containing the key
8
+ # @param roots [Array<String>] paths to relative roots
9
+ # @param calling_method [#call, Symbol, String, false, nil]
10
+ # @return [String] absolute version of the key
11
+ def absolute_key(key, path, roots: config[:relative_roots], calling_method: nil)
12
+ return key unless key.start_with?(DOT)
13
+ fail 'roots argument is required' unless roots.present?
14
+ normalized_path = File.expand_path(path)
15
+ (root = path_root(normalized_path, roots)) ||
16
+ fail(CommandError, "Cannot resolve relative key \"#{key}\".\n" \
17
+ "Set search.relative_roots in config/i18n-processes.yml (currently #{roots.inspect})")
18
+ normalized_path.sub!(root, '')
19
+ "#{prefix(normalized_path, calling_method: calling_method)}#{key}"
20
+ end
21
+
22
+ private
23
+
24
+ DOT = '.'
25
+
26
+ # Detect the appropriate relative path root
27
+ # @param [String] path /full/path
28
+ # @param [Array<String>] roots array of full paths
29
+ # @return [String] the closest ancestor root for path, with a trailing {File::SEPARATOR}.
30
+ def path_root(path, roots)
31
+ roots.map do |p|
32
+ File.expand_path(p) + File::SEPARATOR
33
+ end.sort.reverse_each.detect do |root|
34
+ path.start_with?(root)
35
+ end
36
+ end
37
+
38
+ # @param normalized_path [String] path/relative/to/a/root
39
+ # @param calling_method [#call, Symbol, String, false, nil]
40
+ def prefix(normalized_path, calling_method: nil)
41
+ file_key = normalized_path.gsub(%r{(\.[^/]+)*$}, '').tr(File::SEPARATOR, DOT)
42
+ calling_method = calling_method.call if calling_method.respond_to?(:call)
43
+ if calling_method && calling_method.present?
44
+ # Relative keys in mailers have a `_mailer` infix, but relative keys in controllers do not have one:
45
+ "#{file_key.sub(/_controller$/, '')}.#{calling_method}"
46
+ else
47
+ # Remove _ prefix from partials
48
+ file_key.gsub(/\._/, DOT)
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/processes/scanners/results/occurrence'
4
+
5
+ module I18n::Processes::Scanners::Results
6
+ # A scanned key and all its occurrences.
7
+ #
8
+ # @note This is a value type. Equality and hash code are determined from the attributes.
9
+ class KeyOccurrences
10
+ # @return [String] the key.
11
+ attr_reader :key
12
+
13
+ # @return [Array<Occurrence>] the key's occurrences.
14
+ attr_reader :occurrences
15
+
16
+ def initialize(key:, occurrences:)
17
+ @key = key
18
+ @occurrences = occurrences
19
+ end
20
+
21
+ def ==(other)
22
+ other.key == @key && other.occurrences == @occurrences
23
+ end
24
+
25
+ def eql?(other)
26
+ self == other
27
+ end
28
+
29
+ def hash
30
+ [@key, @occurrences].hash
31
+ end
32
+
33
+ def inspect
34
+ "KeyOccurrences(#{key.inspect}, [#{occurrences.map(&:inspect).join(', ')}])"
35
+ end
36
+
37
+ # Merge {KeyOccurrences} in an {Enumerable<KeyOccurrences>} so that in the resulting {Array<KeyOccurrences>}:
38
+ # * Each key occurs only once.
39
+ # * {Occurrence}s from multiple instances of the key are merged.
40
+ # * The order of keys is preserved, occurrences are ordered by {Occurrence#path}.
41
+ # @param keys_occurrences [Enumerable<KeyOccurrences>]
42
+ # @return [Array<KeyOccurrences>] a new array.
43
+ def self.merge_keys(keys_occurrences)
44
+ keys_occurrences.each_with_object({}) do |key_occurrences, results_by_key|
45
+ (results_by_key[key_occurrences.key] ||= []) << key_occurrences.occurrences
46
+ end.map do |key, all_occurrences|
47
+ occurrences = all_occurrences.flatten(1)
48
+ occurrences.sort_by!(&:path)
49
+ occurrences.uniq!
50
+ new(key: key, occurrences: occurrences)
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18n::Processes
4
+ module Scanners
5
+ module Results
6
+ # The occurrence of some key in a file.
7
+ #
8
+ # @note This is a value type. Equality and hash code are determined from the attributes.
9
+ class Occurrence
10
+ # @return [String] source path relative to the current working directory.
11
+ attr_reader :path
12
+
13
+ # @return [Integer] count of characters in the file before the occurrence.
14
+ attr_reader :pos
15
+
16
+ # @return [Integer] line number of the occurrence, counting from 1.
17
+ attr_reader :line_num
18
+
19
+ # @return [Integer] position of the start of the occurrence in the line, counting from 1.
20
+ attr_reader :line_pos
21
+
22
+ # @return [String] the line of the occurrence, excluding the last LF or CRLF.
23
+ attr_reader :line
24
+
25
+ # @return [String, nil] the value of the `default:` argument of the translate call.
26
+ attr_reader :default_arg
27
+
28
+ # @return [String, nil] the raw key (for relative keys and references)
29
+ attr_accessor :raw_key
30
+
31
+ # @param path [String]
32
+ # @param pos [Integer]
33
+ # @param line_num [Integer]
34
+ # @param line_pos [Integer]
35
+ # @param line [String]
36
+ # @param raw_key [String, nil]
37
+ # @param default_arg [String, nil]
38
+ # rubocop:disable Metrics/ParameterLists
39
+ def initialize(path:, pos:, line_num:, line_pos:, line:, raw_key: nil, default_arg: nil)
40
+ @path = path
41
+ @pos = pos
42
+ @line_num = line_num
43
+ @line_pos = line_pos
44
+ @line = line
45
+ @raw_key = raw_key
46
+ @default_arg = default_arg
47
+ end
48
+ # rubocop:enable Metrics/ParameterLists
49
+
50
+ def inspect
51
+ "Occurrence(#{@path}:#{@line_num}:#{@line_pos}:#{@pos}:#{@raw_key}:#{@default_arg})"
52
+ end
53
+
54
+ def ==(other)
55
+ other.path == @path && other.pos == @pos && other.line_num == @line_num && other.line == @line &&
56
+ other.raw_key == @raw_key && other.default_arg == @default_arg
57
+ end
58
+
59
+ def eql?(other)
60
+ self == other
61
+ end
62
+
63
+ def hash
64
+ [@path, @pos, @line_num, @line_pos, @line, @default_arg].hash
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ast'
4
+ require 'set'
5
+ module I18n::Processes::Scanners
6
+ class RubyAstCallFinder
7
+ include AST::Processor::Mixin
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
+ # @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>]
30
+ 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
37
+ end
38
+
39
+ def on_def(node)
40
+ @method_name = node.children[0]
41
+ handler_missing node
42
+ ensure
43
+ @method_name = nil
44
+ end
45
+
46
+ 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 && valid_receivers.any? { |r| r == receiver }
52
+ # always invoke handler_missing to get nested translations in children
53
+ handler_missing send_node
54
+ nil
55
+ end
56
+
57
+ def handler_missing(node)
58
+ node.children.each { |child| process(child) if child.is_a?(::Parser::AST::Node) }
59
+ nil
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/processes/scanners/file_scanner'
4
+ require 'i18n/processes/scanners/relative_keys'
5
+ require 'i18n/processes/scanners/ruby_ast_call_finder'
6
+ require 'parser/current'
7
+
8
+ # rubocop:disable Metrics/AbcSize,Metrics/BlockNesting,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
9
+ # TODO: make this class more readable.
10
+
11
+ module I18n::Processes::Scanners
12
+ # Scan for I18n.translate calls using whitequark/parser
13
+ class RubyAstScanner < FileScanner # rubocop:disable Metrics/ClassLength
14
+ include RelativeKeys
15
+ include AST::Sexp
16
+
17
+ MAGIC_COMMENT_PREFIX = /\A.\s*i18n-processes-use\s+/
18
+ RECEIVER_MESSAGES = [nil, AST::Node.new(:const, [nil, :I18n])].product(%i[t translate])
19
+
20
+ def initialize(**args)
21
+ super(args)
22
+ @parser = ::Parser::CurrentRuby.new
23
+ @magic_comment_parser = ::Parser::CurrentRuby.new
24
+ @call_finder = RubyAstCallFinder.new(
25
+ receiver_messages: config[:receiver_messages] || RECEIVER_MESSAGES
26
+ )
27
+ end
28
+
29
+ protected
30
+
31
+ # Extract all occurrences of translate calls from the file at the given path.
32
+ #
33
+ # @return [Array<[key, Results::KeyOccurrence]>] each occurrence found in the file
34
+ 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
41
+
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)
57
+ rescue Exception => e # rubocop:disable Lint/RescueException
58
+ raise ::I18n::Processes::CommandError.new(e, "Error scanning #{path}: #{e.message}")
59
+ end
60
+
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
88
+ end
89
+
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
101
+ end
102
+
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.
106
+ #
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
140
+ end
141
+ end
142
+
143
+ # Extract an array as a single string.
144
+ #
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}}"
161
+ end
162
+ end
163
+ 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 == ''
168
+ end
169
+ end
170
+ children_strings.join(array_join_with)
171
+ end
172
+
173
+ def keys_relative_to_calling_method?(path)
174
+ /controllers|mailers/.match(path)
175
+ end
176
+
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
192
+
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
202
+ end
203
+ end
204
+ end
205
+ end
206
+ # rubocop:enable Metrics/AbcSize,Metrics/BlockNesting,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module I18n::Processes::Scanners
4
+ module RubyKeyLiterals
5
+ LITERAL_RE = /:?".+?"|:?'.+?'|:\w+/
6
+
7
+ # Match literals:
8
+ # * String: '', "#{}"
9
+ # * Symbol: :sym, :'', :"#{}"
10
+ def literal_re
11
+ LITERAL_RE
12
+ end
13
+
14
+ # remove the leading colon and unwrap quotes from the key match
15
+ # @param literal [String] e.g: "key", 'key', or :key.
16
+ # @return [String] key
17
+ def strip_literal(literal)
18
+ literal = literal[1..-1] if literal[0] == ':'
19
+ literal = literal[1..-2] if literal[0] == "'" || literal[0] == '"'
20
+ literal
21
+ end
22
+
23
+ VALID_KEY_CHARS = /(?:[[:word:]]|[-.?!:;À-ž])/
24
+ VALID_KEY_RE = /^#{VALID_KEY_CHARS}+$/
25
+
26
+ def valid_key?(key)
27
+ key =~ VALID_KEY_RE && !key.end_with?('.')
28
+ end
29
+ end
30
+ end