i18n-tasks 1.0.14 → 1.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 +4 -4
- data/README.md +138 -39
- data/Rakefile +4 -4
- data/bin/i18n-tasks +3 -3
- data/config/locales/en.yml +17 -1
- data/config/locales/ru.yml +18 -1
- data/i18n-tasks.gemspec +28 -38
- data/lib/i18n/tasks/base_task.rb +19 -19
- data/lib/i18n/tasks/cli.rb +37 -30
- data/lib/i18n/tasks/command/collection.rb +4 -4
- data/lib/i18n/tasks/command/commander.rb +5 -5
- data/lib/i18n/tasks/command/commands/check_prism.rb +126 -0
- data/lib/i18n/tasks/command/commands/data.rb +33 -33
- data/lib/i18n/tasks/command/commands/eq_base.rb +3 -3
- data/lib/i18n/tasks/command/commands/health.rb +6 -5
- data/lib/i18n/tasks/command/commands/interpolations.rb +14 -3
- data/lib/i18n/tasks/command/commands/meta.rb +6 -6
- data/lib/i18n/tasks/command/commands/missing.rb +28 -26
- data/lib/i18n/tasks/command/commands/tree.rb +33 -33
- data/lib/i18n/tasks/command/commands/usages.rb +24 -24
- data/lib/i18n/tasks/command/dsl.rb +1 -1
- data/lib/i18n/tasks/command/option_parsers/enum.rb +8 -7
- data/lib/i18n/tasks/command/option_parsers/locale.rb +4 -4
- data/lib/i18n/tasks/command/options/common.rb +16 -16
- data/lib/i18n/tasks/command/options/data.rb +18 -18
- data/lib/i18n/tasks/command/options/locales.rb +33 -24
- data/lib/i18n/tasks/commands.rb +14 -12
- data/lib/i18n/tasks/concurrent/cache.rb +1 -1
- data/lib/i18n/tasks/concurrent/cached_value.rb +1 -1
- data/lib/i18n/tasks/configuration.rb +26 -20
- data/lib/i18n/tasks/console_context.rb +11 -11
- data/lib/i18n/tasks/data/adapter/json_adapter.rb +1 -1
- data/lib/i18n/tasks/data/adapter/yaml_adapter.rb +5 -5
- data/lib/i18n/tasks/data/file_formats.rb +3 -3
- data/lib/i18n/tasks/data/file_system.rb +5 -5
- data/lib/i18n/tasks/data/file_system_base.rb +26 -26
- data/lib/i18n/tasks/data/language_names.rb +202 -0
- data/lib/i18n/tasks/data/router/conservative_router.rb +3 -3
- data/lib/i18n/tasks/data/router/isolating_router.rb +19 -19
- data/lib/i18n/tasks/data/router/pattern_router.rb +5 -5
- data/lib/i18n/tasks/data/tree/node.rb +27 -27
- data/lib/i18n/tasks/data/tree/nodes.rb +10 -10
- data/lib/i18n/tasks/data/tree/siblings.rb +20 -20
- data/lib/i18n/tasks/data/tree/traversal.rb +5 -5
- data/lib/i18n/tasks/data.rb +4 -4
- data/lib/i18n/tasks/html_keys.rb +2 -2
- data/lib/i18n/tasks/ignore_keys.rb +9 -9
- data/lib/i18n/tasks/interpolations.rb +21 -1
- data/lib/i18n/tasks/key_pattern_matching.rb +8 -8
- data/lib/i18n/tasks/logging.rb +2 -1
- data/lib/i18n/tasks/missing_keys.rb +24 -8
- data/lib/i18n/tasks/plural_keys.rb +6 -4
- data/lib/i18n/tasks/references.rb +4 -4
- data/lib/i18n/tasks/reports/base.rb +18 -14
- data/lib/i18n/tasks/reports/terminal.rb +64 -47
- data/lib/i18n/tasks/scanners/ast_matchers/base_matcher.rb +3 -3
- data/lib/i18n/tasks/scanners/ast_matchers/default_i18n_subject_matcher.rb +3 -3
- data/lib/i18n/tasks/scanners/ast_matchers/message_receivers_matcher.rb +10 -10
- data/lib/i18n/tasks/scanners/ast_matchers/rails_model_matcher.rb +2 -2
- data/lib/i18n/tasks/scanners/erb_ast_scanner.rb +69 -10
- data/lib/i18n/tasks/scanners/file_scanner.rb +5 -5
- data/lib/i18n/tasks/scanners/files/caching_file_finder.rb +3 -3
- data/lib/i18n/tasks/scanners/files/caching_file_finder_provider.rb +3 -3
- data/lib/i18n/tasks/scanners/files/caching_file_reader.rb +2 -2
- data/lib/i18n/tasks/scanners/files/file_finder.rb +8 -8
- data/lib/i18n/tasks/scanners/files/file_reader.rb +1 -1
- data/lib/i18n/tasks/scanners/local_ruby_parser.rb +9 -9
- data/lib/i18n/tasks/scanners/occurrence_from_position.rb +1 -1
- data/lib/i18n/tasks/scanners/pattern_mapper.rb +7 -7
- data/lib/i18n/tasks/scanners/pattern_scanner.rb +20 -20
- data/lib/i18n/tasks/scanners/pattern_with_scope_scanner.rb +8 -8
- data/lib/i18n/tasks/scanners/prism_scanners/arguments_visitor.rb +48 -0
- data/lib/i18n/tasks/scanners/prism_scanners/nodes.rb +374 -0
- data/lib/i18n/tasks/scanners/prism_scanners/visitor.rb +337 -0
- data/lib/i18n/tasks/scanners/relative_keys.rb +8 -8
- data/lib/i18n/tasks/scanners/results/key_occurrences.rb +3 -3
- data/lib/i18n/tasks/scanners/results/occurrence.rb +14 -10
- data/lib/i18n/tasks/scanners/ruby_ast_call_finder.rb +1 -1
- data/lib/i18n/tasks/scanners/ruby_key_literals.rb +6 -6
- data/lib/i18n/tasks/scanners/ruby_parser_factory.rb +27 -0
- data/lib/i18n/tasks/scanners/ruby_scanner.rb +225 -0
- data/lib/i18n/tasks/scanners/scanner.rb +2 -2
- data/lib/i18n/tasks/scanners/scanner_multiplexer.rb +1 -1
- data/lib/i18n/tasks/split_key.rb +4 -4
- data/lib/i18n/tasks/stats.rb +3 -3
- data/lib/i18n/tasks/translation.rb +8 -5
- data/lib/i18n/tasks/translators/base_translator.rb +43 -13
- data/lib/i18n/tasks/translators/deepl_translator.rb +22 -14
- data/lib/i18n/tasks/translators/google_translator.rb +178 -26
- data/lib/i18n/tasks/translators/openai_translator.rb +56 -31
- data/lib/i18n/tasks/translators/watsonx_translator.rb +155 -0
- data/lib/i18n/tasks/translators/yandex_translator.rb +13 -9
- data/lib/i18n/tasks/unused_keys.rb +1 -1
- data/lib/i18n/tasks/used_keys.rb +32 -32
- data/lib/i18n/tasks/version.rb +1 -1
- data/lib/i18n/tasks.rb +17 -16
- data/templates/config/i18n-tasks.yml +14 -2
- data/templates/minitest/i18n_test.rb +3 -3
- data/templates/rspec/i18n_spec.rb +7 -7
- metadata +38 -172
- data/lib/i18n/tasks/scanners/ruby_ast_scanner.rb +0 -145
|
@@ -10,16 +10,16 @@ module I18n
|
|
|
10
10
|
# @param calling_method [#call, Symbol, String, false, nil]
|
|
11
11
|
# @return [String] absolute version of the key
|
|
12
12
|
def absolute_key(key, path, roots: config[:relative_roots],
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
exclude_method_name_paths: config[:relative_exclude_method_name_paths],
|
|
14
|
+
calling_method: nil)
|
|
15
15
|
return key unless key.start_with?(DOT)
|
|
16
|
-
fail
|
|
16
|
+
fail "roots argument is required" unless roots.present?
|
|
17
17
|
|
|
18
18
|
normalized_path = File.expand_path(path)
|
|
19
19
|
(root = path_root(normalized_path, roots)) ||
|
|
20
20
|
fail(CommandError, "Cannot resolve relative key \"#{key}\".\n" \
|
|
21
21
|
"Set search.relative_roots in config/i18n-tasks.yml (currently #{roots.inspect})")
|
|
22
|
-
normalized_path.sub!(root,
|
|
22
|
+
normalized_path.sub!(root, "")
|
|
23
23
|
|
|
24
24
|
if (exclude_method_name_paths || []).map { |p| expand_path(p) }.include?(root)
|
|
25
25
|
"#{prefix(normalized_path)}#{key}"
|
|
@@ -30,7 +30,7 @@ module I18n
|
|
|
30
30
|
|
|
31
31
|
private
|
|
32
32
|
|
|
33
|
-
DOT =
|
|
33
|
+
DOT = "."
|
|
34
34
|
|
|
35
35
|
# Detect the appropriate relative path root
|
|
36
36
|
# @param [String] path /full/path
|
|
@@ -54,14 +54,14 @@ module I18n
|
|
|
54
54
|
# @param normalized_path [String] path/relative/to/a/root
|
|
55
55
|
# @param calling_method [#call, Symbol, String, false, nil]
|
|
56
56
|
def prefix(normalized_path, calling_method: nil)
|
|
57
|
-
file_key
|
|
57
|
+
file_key = normalized_path.gsub(%r{(\.[^/]+)*$}, "").tr(File::SEPARATOR, DOT)
|
|
58
58
|
calling_method = calling_method.call if calling_method.respond_to?(:call)
|
|
59
59
|
if calling_method&.present?
|
|
60
60
|
# Relative keys in mailers have a `_mailer` infix, but relative keys in controllers do not have one:
|
|
61
|
-
"#{file_key.sub(/_controller$/,
|
|
61
|
+
"#{file_key.sub(/_controller$/, "")}.#{calling_method}"
|
|
62
62
|
else
|
|
63
63
|
# Remove _ prefix from partials
|
|
64
|
-
file_key.gsub(
|
|
64
|
+
file_key.gsub("._", DOT)
|
|
65
65
|
end
|
|
66
66
|
end
|
|
67
67
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require "i18n/tasks/scanners/results/occurrence"
|
|
4
4
|
|
|
5
5
|
module I18n::Tasks::Scanners::Results
|
|
6
6
|
# A scanned key and all its occurrences.
|
|
@@ -14,7 +14,7 @@ module I18n::Tasks::Scanners::Results
|
|
|
14
14
|
attr_reader :occurrences
|
|
15
15
|
|
|
16
16
|
def initialize(key:, occurrences:)
|
|
17
|
-
@key
|
|
17
|
+
@key = key
|
|
18
18
|
@occurrences = occurrences
|
|
19
19
|
end
|
|
20
20
|
|
|
@@ -31,7 +31,7 @@ module I18n::Tasks::Scanners::Results
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def inspect
|
|
34
|
-
"KeyOccurrences(#{key.inspect}, [#{occurrences.map(&:inspect).join(
|
|
34
|
+
"KeyOccurrences(#{key.inspect}, [#{occurrences.map(&:inspect).join(", ")}])"
|
|
35
35
|
end
|
|
36
36
|
|
|
37
37
|
# Merge {KeyOccurrences} in an {Enumerable<KeyOccurrences>} so that in the resulting {Array<KeyOccurrences>}:
|
|
@@ -28,6 +28,9 @@ module I18n::Tasks
|
|
|
28
28
|
# @return [String, nil] the raw key (for relative keys and references)
|
|
29
29
|
attr_accessor :raw_key
|
|
30
30
|
|
|
31
|
+
# @return [Array<String>, nil] candidate keys that may be used at runtime
|
|
32
|
+
attr_reader :candidate_keys
|
|
33
|
+
|
|
31
34
|
# @param path [String]
|
|
32
35
|
# @param pos [Integer]
|
|
33
36
|
# @param line_num [Integer]
|
|
@@ -36,24 +39,25 @@ module I18n::Tasks
|
|
|
36
39
|
# @param raw_key [String, nil]
|
|
37
40
|
# @param default_arg [String, nil]
|
|
38
41
|
# rubocop:disable Metrics/ParameterLists
|
|
39
|
-
def initialize(path:, pos:, line_num:, line_pos:, line:, raw_key: nil, default_arg: nil)
|
|
40
|
-
@path
|
|
41
|
-
@pos
|
|
42
|
-
@line_num
|
|
43
|
-
@line_pos
|
|
44
|
-
@line
|
|
45
|
-
@raw_key
|
|
42
|
+
def initialize(path:, pos:, line_num:, line_pos:, line:, raw_key: nil, default_arg: nil, candidate_keys: nil)
|
|
43
|
+
@path = path
|
|
44
|
+
@pos = pos
|
|
45
|
+
@line_num = line_num
|
|
46
|
+
@line_pos = line_pos
|
|
47
|
+
@line = line
|
|
48
|
+
@raw_key = raw_key
|
|
46
49
|
@default_arg = default_arg
|
|
50
|
+
@candidate_keys = candidate_keys
|
|
47
51
|
end
|
|
48
52
|
# rubocop:enable Metrics/ParameterLists
|
|
49
53
|
|
|
50
54
|
def inspect
|
|
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
|
|
55
|
+
"Occurrence(#{@path}:#{@line_num}, line_pos: #{@line_pos}, pos: #{@pos}, raw_key: #{@raw_key}, candidate_keys: #{@candidate_keys}, default_arg: #{@default_arg}, line: #{@line})" # rubocop:disable Layout/LineLength
|
|
52
56
|
end
|
|
53
57
|
|
|
54
58
|
def ==(other)
|
|
55
59
|
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
|
|
60
|
+
other.raw_key == @raw_key && other.default_arg == @default_arg && other.candidate_keys == @candidate_keys
|
|
57
61
|
end
|
|
58
62
|
|
|
59
63
|
def eql?(other)
|
|
@@ -61,7 +65,7 @@ module I18n::Tasks
|
|
|
61
65
|
end
|
|
62
66
|
|
|
63
67
|
def hash
|
|
64
|
-
[@path, @pos, @line_num, @line_pos, @line, @default_arg].hash
|
|
68
|
+
[@path, @pos, @line_num, @line_pos, @line, @default_arg, @candidate_keys].hash
|
|
65
69
|
end
|
|
66
70
|
|
|
67
71
|
# @param raw_key [String]
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module I18n::Tasks::Scanners
|
|
4
4
|
module RubyKeyLiterals
|
|
5
|
-
LITERAL_RE = /:?".+?"|:?'.+?'|:\w
|
|
5
|
+
LITERAL_RE = /:?".+?"|:?'.+?'|:\w+/
|
|
6
6
|
|
|
7
7
|
# Match literals:
|
|
8
8
|
# * String: '', "#{}"
|
|
@@ -15,16 +15,16 @@ 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..] if literal[0] ==
|
|
19
|
-
literal = literal[1..-2] if
|
|
18
|
+
literal = literal[1..] if literal[0] == ":"
|
|
19
|
+
literal = literal[1..-2] if ["'", '"'].include?(literal[0])
|
|
20
20
|
literal
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
VALID_KEY_CHARS = %r{(?:[[:word:]]|[
|
|
24
|
-
VALID_KEY_RE
|
|
23
|
+
VALID_KEY_CHARS = %r{(?:[[:word:]]|[-.?!:;À-ž\\/]|(?<=[\p{L}\d])\s(?=[\p{L}\d]))}
|
|
24
|
+
VALID_KEY_RE = /^#{VALID_KEY_CHARS}+$/
|
|
25
25
|
|
|
26
26
|
def valid_key?(key)
|
|
27
|
-
key =~ VALID_KEY_RE && !key.end_with?(
|
|
27
|
+
key =~ VALID_KEY_RE && !key.end_with?(".")
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This module provides a factory class for creating a Ruby parser instance.
|
|
4
|
+
# It temporarily disables verbose mode to suppress compatibility warnings
|
|
5
|
+
# when loading the "parser/current" library.
|
|
6
|
+
#
|
|
7
|
+
# Example warning for the release of Ruby 3.4.1:
|
|
8
|
+
# warning: parser/current is loading parser/ruby34, which recognizes
|
|
9
|
+
# 3.4.0-compliant syntax, but you are running 3.4.1.
|
|
10
|
+
# Please see https://github.com/whitequark/parser#compatibility-with-ruby-mri.
|
|
11
|
+
#
|
|
12
|
+
# By disabling verbose mode, these warnings are suppressed to provide a cleaner
|
|
13
|
+
# output and avoid confusion. The verbose mode is restored after the parser
|
|
14
|
+
# instance is created to maintain the original behavior.
|
|
15
|
+
|
|
16
|
+
module I18n::Tasks::Scanners
|
|
17
|
+
class RubyParserFactory
|
|
18
|
+
def self.create_parser
|
|
19
|
+
prev = $VERBOSE
|
|
20
|
+
$VERBOSE = nil
|
|
21
|
+
require "parser/current"
|
|
22
|
+
::Parser::CurrentRuby.new
|
|
23
|
+
ensure
|
|
24
|
+
$VERBOSE = prev
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "i18n/tasks/logging"
|
|
4
|
+
require "i18n/tasks/scanners/file_scanner"
|
|
5
|
+
require "i18n/tasks/scanners/relative_keys"
|
|
6
|
+
require "i18n/tasks/scanners/ruby_ast_call_finder"
|
|
7
|
+
require "i18n/tasks/scanners/ruby_parser_factory"
|
|
8
|
+
require "i18n/tasks/scanners/ast_matchers/default_i18n_subject_matcher"
|
|
9
|
+
require "i18n/tasks/scanners/ast_matchers/message_receivers_matcher"
|
|
10
|
+
require "i18n/tasks/scanners/ast_matchers/rails_model_matcher"
|
|
11
|
+
require "i18n/tasks/scanners/prism_scanners/visitor"
|
|
12
|
+
|
|
13
|
+
module I18n::Tasks::Scanners
|
|
14
|
+
# Scan for I18n.translate calls using whitequark/parser primarily and Prism if configured.
|
|
15
|
+
class RubyScanner < FileScanner
|
|
16
|
+
MAGIC_COMMENT_SKIP_PRISM = "i18n-tasks-skip-prism"
|
|
17
|
+
include RelativeKeys
|
|
18
|
+
include AST::Sexp
|
|
19
|
+
include ::I18n::Tasks::Logging
|
|
20
|
+
|
|
21
|
+
MAGIC_COMMENT_PREFIX = /\A.\s*i18n-tasks-use\s+/
|
|
22
|
+
|
|
23
|
+
protected
|
|
24
|
+
|
|
25
|
+
# Extract all occurrences of translate calls from the file at the given path.
|
|
26
|
+
#
|
|
27
|
+
# @return [Array<[key, Results::KeyOccurrence]>] each occurrence found in the file
|
|
28
|
+
def scan_file(path)
|
|
29
|
+
if config[:prism]
|
|
30
|
+
prism_parse_file(path)
|
|
31
|
+
else
|
|
32
|
+
ast_parser_parse_file(path)
|
|
33
|
+
end
|
|
34
|
+
rescue => e
|
|
35
|
+
raise ::I18n::Tasks::CommandError.new(e, "Error scanning #{path}: #{e.message}")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def ast_parser_parse_file(path)
|
|
39
|
+
setup_ast_parser
|
|
40
|
+
ast, comments = path_to_ast_and_comments(path)
|
|
41
|
+
|
|
42
|
+
ast_to_occurences(ast) + comments_to_occurences(path, ast, comments)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Parse file on path and returns AST and comments.
|
|
46
|
+
#
|
|
47
|
+
# @param path Path to file to parse
|
|
48
|
+
# @return [{Parser::AST::Node}, [Parser::Source::Comment]]
|
|
49
|
+
def path_to_ast_and_comments(path)
|
|
50
|
+
@parser.reset
|
|
51
|
+
@parser.parse_with_comments(make_buffer(path))
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Create an {Parser::Source::Buffer} with the given contents.
|
|
55
|
+
# The contents are assigned a {Parser::Source::Buffer#raw_source}.
|
|
56
|
+
#
|
|
57
|
+
# @param path [String] Path to assign as the buffer name.
|
|
58
|
+
# @param contents [String]
|
|
59
|
+
# @return [Parser::Source::Buffer] file contents
|
|
60
|
+
def make_buffer(path, contents = read_file(path))
|
|
61
|
+
Parser::Source::Buffer.new(path).tap do |buffer|
|
|
62
|
+
buffer.raw_source = contents
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Convert an array of {Parser::Source::Comment} to occurrences.
|
|
67
|
+
#
|
|
68
|
+
# @param path Path to file
|
|
69
|
+
# @param ast Parser::AST::Node
|
|
70
|
+
# @param comments [Parser::Source::Comment]
|
|
71
|
+
# @return [nil, [key, Occurrence]] full absolute key name and the occurrence.
|
|
72
|
+
def comments_to_occurences(path, ast, comments)
|
|
73
|
+
magic_comments = comments.select { |comment| comment.text =~ MAGIC_COMMENT_PREFIX }
|
|
74
|
+
comment_to_node = Parser::Source::Comment.associate_locations(ast, magic_comments).tap do |h|
|
|
75
|
+
h.transform_values!(&:first)
|
|
76
|
+
end.invert
|
|
77
|
+
|
|
78
|
+
magic_comments.flat_map do |comment|
|
|
79
|
+
@parser.reset
|
|
80
|
+
associated_node = comment_to_node[comment]
|
|
81
|
+
ast = @parser.parse(make_buffer(path, comment.text.sub(MAGIC_COMMENT_PREFIX, "").split(/\s+(?=t)/).join("; ")))
|
|
82
|
+
calls = RubyAstCallFinder.new.collect_calls(ast)
|
|
83
|
+
results = []
|
|
84
|
+
|
|
85
|
+
# method_name is not available at this stage
|
|
86
|
+
calls.each do |(send_node, _method_name)|
|
|
87
|
+
@matchers.each do |matcher|
|
|
88
|
+
result = matcher.convert_to_key_occurrences(
|
|
89
|
+
send_node,
|
|
90
|
+
nil,
|
|
91
|
+
location: associated_node || comment.location
|
|
92
|
+
)
|
|
93
|
+
next unless result
|
|
94
|
+
|
|
95
|
+
if result.is_a?(Array) && result.first.is_a?(Array)
|
|
96
|
+
results.concat(result)
|
|
97
|
+
else
|
|
98
|
+
results << result
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
results
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Convert {Parser::AST::Node} to occurrences.
|
|
108
|
+
#
|
|
109
|
+
# @param ast {Parser::Source::Comment}
|
|
110
|
+
# @return [nil, [key, Occurrence]] full absolute key name and the occurrence.
|
|
111
|
+
def ast_to_occurences(ast)
|
|
112
|
+
calls = RubyAstCallFinder.new.collect_calls(ast)
|
|
113
|
+
results = []
|
|
114
|
+
calls.each do |send_node, method_name|
|
|
115
|
+
@matchers.each do |matcher|
|
|
116
|
+
result = matcher.convert_to_key_occurrences(send_node, method_name)
|
|
117
|
+
next unless result
|
|
118
|
+
|
|
119
|
+
if result.is_a?(Array) && result.first.is_a?(Array)
|
|
120
|
+
results.concat(result)
|
|
121
|
+
else
|
|
122
|
+
results << result
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
results
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def setup_ast_parser
|
|
131
|
+
@parser ||= RubyParserFactory.create_parser
|
|
132
|
+
@magic_comment_parser ||= RubyParserFactory.create_parser
|
|
133
|
+
setup_ast_matchers
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def setup_ast_matchers
|
|
137
|
+
return if defined?(@matchers)
|
|
138
|
+
|
|
139
|
+
if config[:receiver_messages]
|
|
140
|
+
@matchers = config[:receiver_messages].map do |receiver, message|
|
|
141
|
+
AstMatchers::MessageReceiversMatcher.new(
|
|
142
|
+
receivers: [receiver],
|
|
143
|
+
message: message,
|
|
144
|
+
scanner: self
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
else
|
|
148
|
+
@matchers = %i[t t! translate translate!].map do |message|
|
|
149
|
+
AstMatchers::MessageReceiversMatcher.new(
|
|
150
|
+
receivers: [
|
|
151
|
+
AST::Node.new(:const, [nil, :I18n]),
|
|
152
|
+
nil
|
|
153
|
+
],
|
|
154
|
+
message: message,
|
|
155
|
+
scanner: self
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
Array(config[:ast_matchers]).each do |class_name|
|
|
160
|
+
@matchers << ActiveSupport::Inflector.constantize(class_name).new(scanner: self)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# ---------- Prism parser below ----------
|
|
166
|
+
|
|
167
|
+
# Extract all occurrences of translate calls from the file at the given path.
|
|
168
|
+
# @return [Array<[key, Results::KeyOccurrence]>] each occurrence found in the file
|
|
169
|
+
def prism_parse_file(path)
|
|
170
|
+
process_prism_results(path, Prism.parse_file(path))
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# This method handles only parsing to be able to test it properly.
|
|
174
|
+
# Therefore it cannot handle the parsing itself.
|
|
175
|
+
def process_prism_results(path, parse_results)
|
|
176
|
+
comments = parse_results.attach_comments!
|
|
177
|
+
parsed = parse_results.value
|
|
178
|
+
|
|
179
|
+
# Check for magic comment to skip prism parsing, fallback to Parser AST
|
|
180
|
+
return ast_parser_parse_file(path) if skip_prism_comment?(comments)
|
|
181
|
+
|
|
182
|
+
visitor = I18n::Tasks::Scanners::PrismScanners::Visitor.new(
|
|
183
|
+
rails: config[:prism] != "ruby",
|
|
184
|
+
file_path: path
|
|
185
|
+
)
|
|
186
|
+
parsed.accept(visitor)
|
|
187
|
+
|
|
188
|
+
occurrences = []
|
|
189
|
+
visitor.process.each do |translation_call|
|
|
190
|
+
result = translation_call.occurrences(path)
|
|
191
|
+
next unless result
|
|
192
|
+
|
|
193
|
+
if result.is_a?(Array) && result.first.is_a?(Array)
|
|
194
|
+
occurrences.concat(result)
|
|
195
|
+
else
|
|
196
|
+
occurrences << result
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
occurrences
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
def skip_prism_comment?(comments)
|
|
204
|
+
comments.any? do |comment|
|
|
205
|
+
content =
|
|
206
|
+
comment.respond_to?(:slice) ? comment.slice : comment.location.slice
|
|
207
|
+
content.include?(MAGIC_COMMENT_SKIP_PRISM)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
class RubyAstScanner < RubyScanner
|
|
213
|
+
def initialize(**args)
|
|
214
|
+
warn_deprecated("RubyAstScanner is deprecated, use RubyScanner instead")
|
|
215
|
+
super
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
class PrismScanner < RubyScanner
|
|
220
|
+
def initialize(**args)
|
|
221
|
+
warn_deprecated('PrismScanner is deprecated, use RubyScanner with prism: "rails" or prism: "ruby" instead')
|
|
222
|
+
super
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
3
|
+
require "i18n/tasks/scanners/results/key_occurrences"
|
|
4
4
|
|
|
5
5
|
module I18n::Tasks::Scanners
|
|
6
6
|
# Describes the API of a scanner.
|
|
@@ -11,7 +11,7 @@ module I18n::Tasks::Scanners
|
|
|
11
11
|
# @abstract
|
|
12
12
|
# @return [Array<Results::KeyOccurrences>] the keys found by this scanner and their occurrences.
|
|
13
13
|
def keys
|
|
14
|
-
fail
|
|
14
|
+
fail "Unimplemented"
|
|
15
15
|
end
|
|
16
16
|
end
|
|
17
17
|
end
|
data/lib/i18n/tasks/split_key.rb
CHANGED
|
@@ -21,7 +21,7 @@ module I18n
|
|
|
21
21
|
|
|
22
22
|
parts = []
|
|
23
23
|
current_parenthesis_end_char = nil
|
|
24
|
-
part =
|
|
24
|
+
part = ""
|
|
25
25
|
key.each_char.with_index do |char, index|
|
|
26
26
|
if current_parenthesis_end_char
|
|
27
27
|
part += char
|
|
@@ -29,14 +29,14 @@ module I18n
|
|
|
29
29
|
elsif START_KEYS.include?(char)
|
|
30
30
|
part += char
|
|
31
31
|
current_parenthesis_end_char = END_KEYS[char]
|
|
32
|
-
elsif char ==
|
|
32
|
+
elsif char == "."
|
|
33
33
|
parts << part
|
|
34
34
|
if parts.size + 1 == max
|
|
35
35
|
remaining = key[(index + 1)..]
|
|
36
36
|
parts << remaining unless remaining.empty?
|
|
37
37
|
return parts
|
|
38
38
|
end
|
|
39
|
-
part =
|
|
39
|
+
part = ""
|
|
40
40
|
else
|
|
41
41
|
part += char
|
|
42
42
|
end
|
|
@@ -44,7 +44,7 @@ module I18n
|
|
|
44
44
|
|
|
45
45
|
return parts if part.empty?
|
|
46
46
|
|
|
47
|
-
current_parenthesis_end_char ? parts.concat(part.split(
|
|
47
|
+
current_parenthesis_end_char ? parts.concat(part.split(".")) : parts << part
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
def last_key_part(key)
|
data/lib/i18n/tasks/stats.rb
CHANGED
|
@@ -6,15 +6,15 @@ module I18n::Tasks
|
|
|
6
6
|
key_count = forest.leaves.count
|
|
7
7
|
locale_count = forest.count
|
|
8
8
|
if key_count.zero?
|
|
9
|
-
{
|
|
9
|
+
{key_count: 0}
|
|
10
10
|
else
|
|
11
11
|
{
|
|
12
|
-
locales: forest.map(&:key).join(
|
|
12
|
+
locales: forest.map(&:key).join(", "),
|
|
13
13
|
key_count: key_count,
|
|
14
14
|
locale_count: locale_count,
|
|
15
15
|
per_locale_avg: forest.inject(0) { |sum, f| sum + f.leaves.count } / locale_count,
|
|
16
16
|
key_segments_avg: format(
|
|
17
|
-
|
|
17
|
+
"%.1f", forest.leaves.inject(0) { |sum, node| sum + node.walk_to_root.count - 1 } / key_count.to_f
|
|
18
18
|
),
|
|
19
19
|
value_chars_avg: forest.leaves.inject(0) { |sum, node| sum + node.value.to_s.length } / key_count
|
|
20
20
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require
|
|
4
|
-
require
|
|
5
|
-
require
|
|
6
|
-
require
|
|
3
|
+
require "i18n/tasks/translators/deepl_translator"
|
|
4
|
+
require "i18n/tasks/translators/google_translator"
|
|
5
|
+
require "i18n/tasks/translators/openai_translator"
|
|
6
|
+
require "i18n/tasks/translators/watsonx_translator"
|
|
7
|
+
require "i18n/tasks/translators/yandex_translator"
|
|
7
8
|
|
|
8
9
|
module I18n::Tasks
|
|
9
10
|
module Translation
|
|
@@ -11,7 +12,7 @@ module I18n::Tasks
|
|
|
11
12
|
# @param [String] from locale
|
|
12
13
|
# @param [:deepl, :openai, :google, :yandex] backend
|
|
13
14
|
# @return [I18n::Tasks::Tree::Siblings] translated forest
|
|
14
|
-
def translate_forest(forest, from:, backend:
|
|
15
|
+
def translate_forest(forest, from:, backend:)
|
|
15
16
|
case backend
|
|
16
17
|
when :deepl
|
|
17
18
|
Translators::DeeplTranslator.new(self).translate_forest(forest, from)
|
|
@@ -19,6 +20,8 @@ module I18n::Tasks
|
|
|
19
20
|
Translators::GoogleTranslator.new(self).translate_forest(forest, from)
|
|
20
21
|
when :openai
|
|
21
22
|
Translators::OpenAiTranslator.new(self).translate_forest(forest, from)
|
|
23
|
+
when :watsonx
|
|
24
|
+
Translators::WatsonxTranslator.new(self).translate_forest(forest, from)
|
|
22
25
|
when :yandex
|
|
23
26
|
Translators::YandexTranslator.new(self).translate_forest(forest, from)
|
|
24
27
|
else
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "i18n/tasks/data/language_names"
|
|
4
|
+
|
|
3
5
|
module I18n::Tasks
|
|
4
6
|
module Translators
|
|
5
7
|
class BaseTranslator
|
|
6
8
|
include ::I18n::Tasks::Logging
|
|
9
|
+
|
|
7
10
|
# @param [I18n::Tasks::BaseTask] i18n_tasks
|
|
8
11
|
def initialize(i18n_tasks)
|
|
9
12
|
@i18n_tasks = i18n_tasks
|
|
@@ -14,7 +17,26 @@ module I18n::Tasks
|
|
|
14
17
|
# @return [I18n::Tasks::Tree::Siblings] translated forest
|
|
15
18
|
def translate_forest(forest, from)
|
|
16
19
|
forest.inject @i18n_tasks.empty_forest do |result, root|
|
|
17
|
-
|
|
20
|
+
pairs = root.key_values(root: true)
|
|
21
|
+
|
|
22
|
+
@progress_bar = ProgressBar.create(total: pairs.flatten.size, format: "%a <%B> %e %c/%C (%p%%)")
|
|
23
|
+
|
|
24
|
+
begin
|
|
25
|
+
translated = translate_pairs(pairs, to: root.key, from: from)
|
|
26
|
+
rescue => e
|
|
27
|
+
warn "Translation for locale #{root.key} failed: #{e.message}"
|
|
28
|
+
# If translate_pairs raised, try to salvage any partial translations
|
|
29
|
+
# by attempting to translate each slice individually and collecting successes.
|
|
30
|
+
translated = []
|
|
31
|
+
pairs.group_by { |k_v| @i18n_tasks.html_key? k_v[0], from }.each do |_is_html, list_slice|
|
|
32
|
+
translated.concat(fetch_translations(list_slice, to: root.key, from: from))
|
|
33
|
+
rescue => e2
|
|
34
|
+
warn "Partial translation failed for locale #{root.key}: #{e2.message} - leaving keys untranslated"
|
|
35
|
+
# leave the original list_slice untranslated
|
|
36
|
+
translated.concat(list_slice)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
18
40
|
result.merge! Data::Tree::Siblings.from_flat_pairs(translated)
|
|
19
41
|
end
|
|
20
42
|
end
|
|
@@ -33,6 +55,10 @@ module I18n::Tasks
|
|
|
33
55
|
list -= reference_key_vals
|
|
34
56
|
result = list.group_by { |k_v| @i18n_tasks.html_key? k_v[0], opts[:from] }.map do |is_html, list_slice|
|
|
35
57
|
fetch_translations(list_slice, opts.merge(is_html ? options_for_html : options_for_plain))
|
|
58
|
+
rescue => e
|
|
59
|
+
warn "Translation slice failed: #{e.message} - leaving slice untranslated"
|
|
60
|
+
# Return the original untranslated slice so already completed translations are preserved
|
|
61
|
+
list_slice
|
|
36
62
|
end.reduce(:+) || []
|
|
37
63
|
result.concat(reference_key_vals)
|
|
38
64
|
result.sort! { |a, b| key_pos[a[0]] <=> key_pos[b[0]] }
|
|
@@ -104,8 +130,7 @@ module I18n::Tasks
|
|
|
104
130
|
end
|
|
105
131
|
end
|
|
106
132
|
|
|
107
|
-
INTERPOLATION_KEY_RE = /%\{[^}]+}
|
|
108
|
-
UNTRANSLATABLE_STRING = 'X__'
|
|
133
|
+
INTERPOLATION_KEY_RE = /%\{[^}]+}/
|
|
109
134
|
|
|
110
135
|
# @param [String] value
|
|
111
136
|
# @return [String] 'hello, %{name}' => 'hello, <round-trippable string>'
|
|
@@ -113,7 +138,7 @@ module I18n::Tasks
|
|
|
113
138
|
i = -1
|
|
114
139
|
value.gsub INTERPOLATION_KEY_RE do
|
|
115
140
|
i += 1
|
|
116
|
-
"#{
|
|
141
|
+
"X__#{i}"
|
|
117
142
|
end
|
|
118
143
|
end
|
|
119
144
|
|
|
@@ -121,13 +146,13 @@ module I18n::Tasks
|
|
|
121
146
|
# @param [String] translated
|
|
122
147
|
# @return [String] 'hello, <round-trippable string>' => 'hello, %{name}'
|
|
123
148
|
def restore_interpolations(untranslated, translated)
|
|
124
|
-
return translated if untranslated
|
|
149
|
+
return translated if !INTERPOLATION_KEY_RE.match?(untranslated)
|
|
125
150
|
|
|
126
151
|
values = untranslated.scan(INTERPOLATION_KEY_RE)
|
|
127
|
-
translated.gsub(
|
|
128
|
-
values[
|
|
152
|
+
translated.gsub(/X__(\d+)/) do |m|
|
|
153
|
+
values[$1.to_i]
|
|
129
154
|
end
|
|
130
|
-
rescue
|
|
155
|
+
rescue => e
|
|
131
156
|
raise_interpolation_error(untranslated, translated, e)
|
|
132
157
|
end
|
|
133
158
|
|
|
@@ -144,24 +169,29 @@ module I18n::Tasks
|
|
|
144
169
|
# @param [Hash] options
|
|
145
170
|
# @return [Array<String>]
|
|
146
171
|
# @abstract
|
|
147
|
-
def translate_values(list, **options)
|
|
172
|
+
def translate_values(list, **options)
|
|
173
|
+
end
|
|
148
174
|
|
|
149
175
|
# @param [Hash] options
|
|
150
176
|
# @return [Hash]
|
|
151
177
|
# @abstract
|
|
152
|
-
def options_for_translate_values(options)
|
|
178
|
+
def options_for_translate_values(options)
|
|
179
|
+
end
|
|
153
180
|
|
|
154
181
|
# @return [Hash]
|
|
155
182
|
# @abstract
|
|
156
|
-
def options_for_html
|
|
183
|
+
def options_for_html
|
|
184
|
+
end
|
|
157
185
|
|
|
158
186
|
# @return [Hash]
|
|
159
187
|
# @abstract
|
|
160
|
-
def options_for_plain
|
|
188
|
+
def options_for_plain
|
|
189
|
+
end
|
|
161
190
|
|
|
162
191
|
# @return [String]
|
|
163
192
|
# @abstract
|
|
164
|
-
def no_results_error_message
|
|
193
|
+
def no_results_error_message
|
|
194
|
+
end
|
|
165
195
|
end
|
|
166
196
|
end
|
|
167
197
|
end
|