i18n-processes 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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