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