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.
- checksums.yaml +7 -0
- data/Gemfile.lock +102 -0
- data/LICENSE.txt +21 -0
- data/README.md +46 -0
- data/Rakefile +12 -0
- data/bin/i18n-processes +28 -0
- data/bin/i18n-processes.cmd +2 -0
- data/config/locales/en.yml +2 -0
- data/config/locales/zh-CN.yml +2 -0
- data/i18n-processes.gemspec +64 -0
- data/lib/i18n/processes/base_process.rb +47 -0
- data/lib/i18n/processes/cli.rb +208 -0
- data/lib/i18n/processes/command/collection.rb +21 -0
- data/lib/i18n/processes/command/commander.rb +43 -0
- data/lib/i18n/processes/command/commands/data.rb +107 -0
- data/lib/i18n/processes/command/commands/eq_base.rb +21 -0
- data/lib/i18n/processes/command/commands/health.rb +26 -0
- data/lib/i18n/processes/command/commands/meta.rb +38 -0
- data/lib/i18n/processes/command/commands/missing.rb +86 -0
- data/lib/i18n/processes/command/commands/preprocessing.rb +90 -0
- data/lib/i18n/processes/command/commands/tree.rb +119 -0
- data/lib/i18n/processes/command/commands/usages.rb +69 -0
- data/lib/i18n/processes/command/commands/xlsx.rb +29 -0
- data/lib/i18n/processes/command/dsl.rb +56 -0
- data/lib/i18n/processes/command/option_parsers/enum.rb +55 -0
- data/lib/i18n/processes/command/option_parsers/locale.rb +60 -0
- data/lib/i18n/processes/command/options/common.rb +41 -0
- data/lib/i18n/processes/command/options/data.rb +95 -0
- data/lib/i18n/processes/command/options/locales.rb +36 -0
- data/lib/i18n/processes/command_error.rb +13 -0
- data/lib/i18n/processes/commands.rb +31 -0
- data/lib/i18n/processes/configuration.rb +132 -0
- data/lib/i18n/processes/console_context.rb +76 -0
- data/lib/i18n/processes/data/adapter/json_adapter.rb +29 -0
- data/lib/i18n/processes/data/adapter/yaml_adapter.rb +27 -0
- data/lib/i18n/processes/data/file_formats.rb +111 -0
- data/lib/i18n/processes/data/file_system.rb +14 -0
- data/lib/i18n/processes/data/file_system_base.rb +205 -0
- data/lib/i18n/processes/data/router/conservative_router.rb +66 -0
- data/lib/i18n/processes/data/router/pattern_router.rb +60 -0
- data/lib/i18n/processes/data/tree/node.rb +204 -0
- data/lib/i18n/processes/data/tree/nodes.rb +97 -0
- data/lib/i18n/processes/data/tree/siblings.rb +333 -0
- data/lib/i18n/processes/data/tree/traversal.rb +190 -0
- data/lib/i18n/processes/data.rb +87 -0
- data/lib/i18n/processes/google_translation.rb +125 -0
- data/lib/i18n/processes/html_keys.rb +16 -0
- data/lib/i18n/processes/ignore_keys.rb +30 -0
- data/lib/i18n/processes/key_pattern_matching.rb +37 -0
- data/lib/i18n/processes/locale_list.rb +19 -0
- data/lib/i18n/processes/locale_pathname.rb +17 -0
- data/lib/i18n/processes/logging.rb +37 -0
- data/lib/i18n/processes/missing_keys.rb +122 -0
- data/lib/i18n/processes/path.rb +42 -0
- data/lib/i18n/processes/plural_keys.rb +41 -0
- data/lib/i18n/processes/rainbow_utils.rb +13 -0
- data/lib/i18n/processes/references.rb +101 -0
- data/lib/i18n/processes/reports/base.rb +71 -0
- data/lib/i18n/processes/reports/spreadsheet.rb +72 -0
- data/lib/i18n/processes/reports/terminal.rb +252 -0
- data/lib/i18n/processes/scanners/file_scanner.rb +65 -0
- data/lib/i18n/processes/scanners/files/caching_file_finder.rb +34 -0
- data/lib/i18n/processes/scanners/files/caching_file_finder_provider.rb +33 -0
- data/lib/i18n/processes/scanners/files/caching_file_reader.rb +28 -0
- data/lib/i18n/processes/scanners/files/file_finder.rb +60 -0
- data/lib/i18n/processes/scanners/files/file_reader.rb +19 -0
- data/lib/i18n/processes/scanners/occurrence_from_position.rb +27 -0
- data/lib/i18n/processes/scanners/pattern_mapper.rb +60 -0
- data/lib/i18n/processes/scanners/pattern_scanner.rb +103 -0
- data/lib/i18n/processes/scanners/pattern_with_scope_scanner.rb +98 -0
- data/lib/i18n/processes/scanners/relative_keys.rb +53 -0
- data/lib/i18n/processes/scanners/results/key_occurrences.rb +54 -0
- data/lib/i18n/processes/scanners/results/occurrence.rb +69 -0
- data/lib/i18n/processes/scanners/ruby_ast_call_finder.rb +62 -0
- data/lib/i18n/processes/scanners/ruby_ast_scanner.rb +206 -0
- data/lib/i18n/processes/scanners/ruby_key_literals.rb +30 -0
- data/lib/i18n/processes/scanners/scanner.rb +17 -0
- data/lib/i18n/processes/scanners/scanner_multiplexer.rb +41 -0
- data/lib/i18n/processes/split_key.rb +68 -0
- data/lib/i18n/processes/stats.rb +24 -0
- data/lib/i18n/processes/string_interpolation.rb +16 -0
- data/lib/i18n/processes/unused_keys.rb +23 -0
- data/lib/i18n/processes/used_keys.rb +177 -0
- data/lib/i18n/processes/version.rb +7 -0
- data/lib/i18n/processes.rb +69 -0
- data/source/p1/_messages/zh/article.properties +9 -0
- data/source/p1/_messages/zh/company.properties +62 -0
- data/source/p1/_messages/zh/devices.properties +40 -0
- data/source/p1/_messages/zh/meeting-rooms.properties +99 -0
- data/source/p1/_messages/zh/meetingBooking.properties +18 -0
- data/source/p1/_messages/zh/office-areas.properties +64 -0
- data/source/p1/_messages/zh/orders.properties +25 -0
- data/source/p1/_messages/zh/schedulings.properties +7 -0
- data/source/p1/_messages/zh/tag.properties +2 -0
- data/source/p1/_messages/zh/ticket.properties +9 -0
- data/source/p1/_messages/zh/visitor.properties +5 -0
- data/source/p1/messages +586 -0
- data/source/p2/orders.properties +25 -0
- data/source/p2/schedulings.properties +7 -0
- data/source/p2/tag.properties +2 -0
- data/source/p2/ticket.properties +9 -0
- data/source/p2/visitor.properties +5 -0
- data/source/zh.messages.ts +30 -0
- data/translated/en/p1/_messages/zh/article.properties +9 -0
- data/translated/en/p1/_messages/zh/company.properties +62 -0
- data/translated/en/p1/_messages/zh/devices.properties +40 -0
- data/translated/en/p1/_messages/zh/meeting-rooms.properties +99 -0
- data/translated/en/p1/_messages/zh/meetingBooking.properties +18 -0
- data/translated/en/p1/_messages/zh/office-areas.properties +64 -0
- data/translated/en/p1/_messages/zh/orders.properties +25 -0
- data/translated/en/p1/_messages/zh/schedulings.properties +7 -0
- data/translated/en/p1/_messages/zh/tag.properties +2 -0
- data/translated/en/p1/_messages/zh/ticket.properties +9 -0
- data/translated/en/p1/_messages/zh/visitor.properties +5 -0
- data/translated/en/p1/messages +586 -0
- data/translated/en/p2/orders.properties +25 -0
- data/translated/en/p2/schedulings.properties +7 -0
- data/translated/en/p2/tag.properties +2 -0
- data/translated/en/p2/ticket.properties +9 -0
- data/translated/en/p2/visitor.properties +5 -0
- data/translated/en/zh.messages.ts +30 -0
- data/translation/en/article.properties +9 -0
- data/translation/en/company.properties +56 -0
- data/translation/en/meeting-rooms.properties +87 -0
- data/translation/en/meetingBooking.properties +14 -0
- data/translation/en/messages.en +164 -0
- data/translation/en/office-areas.properties +51 -0
- data/translation/en/orders.properties +26 -0
- data/translation/en/tag.properties +2 -0
- data/translation/en/translated +1263 -0
- data/translation/en/visitor.properties +4 -0
- 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
|