i18n-tasks 1.0.13 → 1.0.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +125 -38
  3. data/config/locales/en.yml +14 -5
  4. data/config/locales/ru.yml +14 -5
  5. data/i18n-tasks.gemspec +4 -2
  6. data/lib/i18n/tasks/command/commands/data.rb +14 -0
  7. data/lib/i18n/tasks/command/commands/missing.rb +3 -1
  8. data/lib/i18n/tasks/command/option_parsers/enum.rb +4 -3
  9. data/lib/i18n/tasks/command/options/common.rb +1 -1
  10. data/lib/i18n/tasks/command/options/locales.rb +12 -3
  11. data/lib/i18n/tasks/configuration.rb +8 -2
  12. data/lib/i18n/tasks/data/file_system_base.rb +5 -0
  13. data/lib/i18n/tasks/data/router/isolating_router.rb +146 -0
  14. data/lib/i18n/tasks/data/tree/siblings.rb +2 -2
  15. data/lib/i18n/tasks/interpolations.rb +1 -1
  16. data/lib/i18n/tasks/key_pattern_matching.rb +4 -4
  17. data/lib/i18n/tasks/reports/terminal.rb +6 -0
  18. data/lib/i18n/tasks/scanners/ast_matchers/default_i18n_subject_matcher.rb +26 -0
  19. data/lib/i18n/tasks/scanners/ast_matchers/rails_model_matcher.rb +1 -1
  20. data/lib/i18n/tasks/scanners/erb_ast_scanner.rb +55 -25
  21. data/lib/i18n/tasks/scanners/local_ruby_parser.rb +2 -2
  22. data/lib/i18n/tasks/scanners/pattern_scanner.rb +1 -1
  23. data/lib/i18n/tasks/scanners/prism_scanner.rb +83 -0
  24. data/lib/i18n/tasks/scanners/prism_scanners/arguments_visitor.rb +41 -0
  25. data/lib/i18n/tasks/scanners/prism_scanners/nodes.rb +334 -0
  26. data/lib/i18n/tasks/scanners/prism_scanners/visitor.rb +273 -0
  27. data/lib/i18n/tasks/scanners/relative_keys.rb +1 -1
  28. data/lib/i18n/tasks/scanners/ruby_ast_scanner.rb +5 -4
  29. data/lib/i18n/tasks/scanners/ruby_key_literals.rb +1 -1
  30. data/lib/i18n/tasks/scanners/ruby_parser_factory.rb +27 -0
  31. data/lib/i18n/tasks/split_key.rb +30 -47
  32. data/lib/i18n/tasks/translation.rb +4 -1
  33. data/lib/i18n/tasks/translators/base_translator.rb +11 -1
  34. data/lib/i18n/tasks/translators/deepl_translator.rb +32 -1
  35. data/lib/i18n/tasks/translators/google_translator.rb +35 -12
  36. data/lib/i18n/tasks/translators/openai_translator.rb +55 -23
  37. data/lib/i18n/tasks/translators/watsonx_translator.rb +155 -0
  38. data/lib/i18n/tasks/translators/yandex_translator.rb +5 -1
  39. data/lib/i18n/tasks/used_keys.rb +1 -0
  40. data/lib/i18n/tasks/version.rb +1 -1
  41. data/lib/i18n/tasks.rb +1 -0
  42. data/templates/config/i18n-tasks.yml +26 -3
  43. metadata +33 -26
  44. data/lib/i18n/tasks/scanners/erb_ast_processor.rb +0 -74
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/tasks/key_pattern_matching'
4
+ require 'i18n/tasks/data/tree/node'
5
+
6
+ module I18n::Tasks
7
+ module Data::Router
8
+ # Route based on source file path
9
+ class IsolatingRouter
10
+ include ::I18n::Tasks::KeyPatternMatching
11
+
12
+ attr_reader :config_read_patterns, :base_locale
13
+
14
+ def initialize(_adapter, data_config)
15
+ @base_locale = data_config[:base_locale]
16
+ @config_read_patterns = Array.wrap(data_config[:read])
17
+ end
18
+
19
+ # Route keys to destinations
20
+ # @param forest [I18n::Tasks::Data::Tree::Siblings] forest roots are locales.
21
+ # @yieldparam [String] dest_path
22
+ # @yieldparam [I18n::Tasks::Data::Tree::Siblings] tree_slice
23
+ # @return [Hash] mapping of destination => [ [key, value], ... ]
24
+ def route(locale, forest, &block)
25
+ return to_enum(:route, locale, forest) unless block
26
+
27
+ locale = locale.to_s
28
+ out = {}
29
+
30
+ forest.keys do |key_namespaced_with_source_path, _node|
31
+ source_path, key = key_namespaced_with_source_path.match(/\A<([^>]*)>\.(.*)/).captures
32
+ target_path = alternate_path_for(source_path, locale)
33
+ next unless source_path && key && target_path
34
+
35
+ (out[target_path] ||= Set.new) << "#{locale}.#{key}"
36
+ end
37
+
38
+ out.each do |target_path, keys|
39
+ file_namespace_subtree = I18n::Tasks::Data::Tree::Siblings.new(
40
+ nodes: forest.get("#{locale}.<#{alternate_path_for(target_path, base_locale)}>")
41
+ )
42
+ file_namespace_subtree.set_root_key!(locale)
43
+
44
+ block.yield(
45
+ target_path,
46
+ file_namespace_subtree.select_keys { |key, _| keys.include?(key) }
47
+ )
48
+ end
49
+ end
50
+
51
+ def alternate_path_for(source_path, locale)
52
+ source_path = source_path.dup
53
+
54
+ config_read_patterns.each do |pattern|
55
+ regexp = Glob.new(format(pattern, locale: '(*)')).to_regexp
56
+ next unless source_path.match?(regexp)
57
+
58
+ source_path.match(regexp) do |match_data|
59
+ (1..match_data.size - 1).reverse_each do |capture_index|
60
+ capture_begin, capture_end = match_data.offset(capture_index)
61
+ source_path.slice!(Range.new(capture_begin, capture_end, true))
62
+ source_path.insert(capture_begin, locale.to_s)
63
+ end
64
+ end
65
+
66
+ return source_path
67
+ end
68
+
69
+ nil
70
+ end
71
+
72
+ # based on https://github.com/alexch/rerun/blob/36f2d237985b670752abbe4a7f6814893cdde96f/lib/rerun/glob.rb
73
+ class Glob
74
+ NO_LEADING_DOT = '(?=[^\.])'
75
+ START_OF_FILENAME = '(?:\A|\/)'
76
+ END_OF_STRING = '\z'
77
+
78
+ def initialize(pattern)
79
+ @pattern = pattern
80
+ end
81
+
82
+ def to_regexp_string # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
83
+ chars = smoosh(@pattern.chars)
84
+
85
+ curlies = 0
86
+ escaping = false
87
+
88
+ string = chars.map do |char|
89
+ if escaping
90
+ escaping = false
91
+ next char
92
+ end
93
+
94
+ case char
95
+ when '**' then '(?:[^/]+/)*'
96
+ when '*' then '.*'
97
+ when '?' then '.'
98
+ when '.' then '\.'
99
+ when '{'
100
+ curlies += 1
101
+ '('
102
+ when '}'
103
+ if curlies.positive?
104
+ curlies -= 1
105
+ ')'
106
+ else
107
+ char
108
+ end
109
+ when ','
110
+ if curlies.positive?
111
+ '|'
112
+ else
113
+ char
114
+ end
115
+ when '\\'
116
+ escaping = true
117
+ '\\'
118
+ else char
119
+ end
120
+ end.join
121
+
122
+ START_OF_FILENAME + string + END_OF_STRING
123
+ end
124
+
125
+ def to_regexp
126
+ Regexp.new(to_regexp_string)
127
+ end
128
+
129
+ def smoosh(chars)
130
+ out = []
131
+ until chars.empty?
132
+ char = chars.shift
133
+ if char == '*' && chars.first == '*'
134
+ chars.shift
135
+ chars.shift if chars.first == '/'
136
+ out.push('**')
137
+ else
138
+ out.push(char)
139
+ end
140
+ end
141
+ out
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
@@ -36,7 +36,7 @@ module I18n::Tasks::Data::Tree
36
36
  # @param to_pattern [Regexp]
37
37
  # @param root [Boolean]
38
38
  # @return {old key => new key}
39
- def mv_key!(from_pattern, to_pattern, root: false) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
39
+ def mv_key!(from_pattern, to_pattern, root: false, retain: false) # rubocop:disable Metrics/AbcSize,Metrics/MethodLength
40
40
  moved_forest = Siblings.new
41
41
  moved_nodes = []
42
42
  old_key_to_new_key = {}
@@ -69,7 +69,7 @@ module I18n::Tasks::Data::Tree
69
69
  node.value = new_target.to_sym
70
70
  end
71
71
  end
72
- remove_nodes_and_emptied_ancestors! moved_nodes
72
+ remove_nodes_and_emptied_ancestors!(moved_nodes) unless retain
73
73
  merge! moved_forest
74
74
  old_key_to_new_key
75
75
  end
@@ -5,7 +5,7 @@ module I18n::Tasks
5
5
  class << self
6
6
  attr_accessor :variable_regex
7
7
  end
8
- @variable_regex = /%{[^}]+}/.freeze
8
+ @variable_regex = /(?<!%)%{[^}]+}/.freeze
9
9
 
10
10
  def inconsistent_interpolations(locales: nil, base_locale: nil) # rubocop:disable Metrics/AbcSize
11
11
  locales ||= self.locales
@@ -31,10 +31,10 @@ module I18n::Tasks::KeyPatternMatching
31
31
 
32
32
  def key_pattern_re_body(key_pattern)
33
33
  key_pattern
34
- .gsub(/\./, '\.')
35
- .gsub(/\*:/, '[^.]+?')
36
- .gsub(/\*/, '.*')
37
- .gsub(/:/, '(?<=^|\.)[^.]+?(?=\.|$)')
34
+ .gsub('.', '\.')
35
+ .gsub('*:', '[^.]+?')
36
+ .gsub('*', '.*')
37
+ .gsub(':', '(?<=^|\.)[^.]+?(?=\.|$)')
38
38
  .gsub(/\{(.*?)}/) { "(#{Regexp.last_match(1).strip.gsub(/\s*,\s*/, '|')})" }
39
39
  end
40
40
  end
@@ -93,6 +93,12 @@ module I18n
93
93
  end
94
94
  end
95
95
 
96
+ def cp_results(results)
97
+ results.each do |(from, to)|
98
+ print_info "#{Rainbow(from).cyan} #{Rainbow('+').yellow.bright} #{Rainbow(to).green}"
99
+ end
100
+ end
101
+
96
102
  def check_normalized_results(non_normalized)
97
103
  if non_normalized.empty?
98
104
  print_success 'All data is normalized'
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'i18n/tasks/scanners/ast_matchers/base_matcher'
4
+ require 'i18n/tasks/scanners/results/occurrence'
5
+
6
+ module I18n::Tasks::Scanners::AstMatchers
7
+ class DefaultI18nSubjectMatcher < BaseMatcher
8
+ def convert_to_key_occurrences(send_node, method_name, location: send_node.loc)
9
+ children = Array(send_node&.children)
10
+ return unless children[1] == :default_i18n_subject
11
+
12
+ key = @scanner.absolute_key(
13
+ '.subject',
14
+ location.expression.source_buffer.name,
15
+ calling_method: method_name
16
+ )
17
+ [
18
+ key,
19
+ I18n::Tasks::Scanners::Results::Occurrence.from_range(
20
+ raw_key: key,
21
+ range: location.expression
22
+ )
23
+ ]
24
+ end
25
+ end
26
+ end
@@ -16,7 +16,7 @@ module I18n::Tasks::Scanners::AstMatchers
16
16
  receiver = children[0]
17
17
  method_name = children[1]
18
18
 
19
- return unless method_name == :human_attribute_name && receiver.type == :const
19
+ return unless method_name == :human_attribute_name && receiver&.type == :const
20
20
 
21
21
  value = children[2]
22
22
 
@@ -1,16 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'i18n/tasks/scanners/ruby_ast_scanner'
4
- require 'i18n/tasks/scanners/erb_ast_processor'
5
- require 'better_html/errors'
6
- require 'better_html/parser'
4
+ require 'i18n/tasks/scanners/local_ruby_parser'
7
5
 
8
6
  module I18n::Tasks::Scanners
9
7
  # Scan for I18n.translate calls in ERB-file better-html and ASTs
10
8
  class ErbAstScanner < RubyAstScanner
9
+ DEFAULT_REGEXP = /<%(={1,2}|-|\#|%)?(.*?)([-=])?%>/m.freeze
10
+
11
11
  def initialize(**args)
12
12
  super(**args)
13
- @erb_ast_processor = ErbAstProcessor.new
13
+ @ruby_parser = LocalRubyParser.new(ignore_blocks: true)
14
14
  end
15
15
 
16
16
  private
@@ -20,29 +20,59 @@ module I18n::Tasks::Scanners
20
20
  # @param path Path to file to parse
21
21
  # @return [{Parser::AST::Node}, [Parser::Source::Comment]]
22
22
  def path_to_ast_and_comments(path)
23
- parser = BetterHtml::Parser.new(make_buffer(path))
24
- ast = convert_better_html(parser.ast)
25
- @erb_ast_processor.process_and_extract_comments(ast)
23
+ comments = []
24
+ buffer = make_buffer(path)
25
+
26
+ children = []
27
+ buffer
28
+ .source
29
+ .scan(DEFAULT_REGEXP) do |indicator, code, tailch, _rspace|
30
+ match = Regexp.last_match
31
+ character = indicator ? indicator[0] : nil
32
+
33
+ start = match.begin(0) + 2 + (character&.size || 0)
34
+ stop = match.end(0) - 2 - (tailch&.size || 0)
35
+
36
+ case character
37
+ when '=', nil, '-'
38
+ parsed, parsed_comments = handle_code(buffer, code, start, stop)
39
+ comments.concat(parsed_comments)
40
+ children << parsed unless parsed.nil?
41
+ when '#', '#-'
42
+ comments << handle_comment(buffer, start, stop)
43
+ end
44
+ end
45
+
46
+ [root_node(children, buffer), comments]
26
47
  end
27
48
 
28
- # Convert BetterHtml nodes to Parser::AST::Node
29
- #
30
- # @param node BetterHtml::Parser::AST::Node
31
- # @return Parser::AST::Node
32
- def convert_better_html(node)
33
- definition = Parser::Source::Map::Definition.new(
34
- node.location.begin,
35
- node.location.begin,
36
- node.location.begin,
37
- node.location.end
38
- )
39
- Parser::AST::Node.new(
40
- node.type,
41
- node.children.map { |child| child.is_a?(BetterHtml::AST::Node) ? convert_better_html(child) : child },
42
- {
43
- location: definition
44
- }
45
- )
49
+ def handle_code(buffer, code, start, stop)
50
+ range = ::Parser::Source::Range.new(buffer, start, stop)
51
+ location =
52
+ Parser::Source::Map::Definition.new(
53
+ range.begin,
54
+ range.begin,
55
+ range.begin,
56
+ range.end
57
+ )
58
+ @ruby_parser.parse(code, location: location)
59
+ end
60
+
61
+ def handle_comment(buffer, start, stop)
62
+ range = ::Parser::Source::Range.new(buffer, start, stop)
63
+ ::Parser::Source::Comment.new(range)
64
+ end
65
+
66
+ def root_node(children, buffer)
67
+ range = ::Parser::Source::Range.new(buffer, 0, buffer.source.size)
68
+ location =
69
+ Parser::Source::Map::Definition.new(
70
+ range.begin,
71
+ range.begin,
72
+ range.begin,
73
+ range.end
74
+ )
75
+ ::Parser::AST::Node.new(:erb, children, location: location)
46
76
  end
47
77
  end
48
78
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'parser/current'
3
+ require 'i18n/tasks/scanners/ruby_parser_factory'
4
4
 
5
5
  module I18n::Tasks::Scanners
6
6
  class LocalRubyParser
@@ -9,7 +9,7 @@ module I18n::Tasks::Scanners
9
9
  BLOCK_EXPR = /\s*((\s+|\))do|\{)(\s*\|[^|]*\|)?\s*\Z/.freeze
10
10
 
11
11
  def initialize(ignore_blocks: false)
12
- @parser = ::Parser::CurrentRuby.new
12
+ @parser = RubyParserFactory.create_parser
13
13
  @ignore_blocks = ignore_blocks
14
14
  end
15
15
 
@@ -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!?)?/.freeze
15
+ TRANSLATE_CALL_RE = /(?<=^|[^\p{L}_'\-.]|[^\p{L}'-]I18n\.|I18n\.)t(?:!|ranslate!?)?/.freeze
16
16
  IGNORE_LINES = {
17
17
  'coffee' => /^\s*#(?!\si18n-tasks-use)/,
18
18
  'erb' => /^\s*<%\s*#(?!\si18n-tasks-use)/,
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'file_scanner'
4
+ require_relative 'ruby_ast_scanner'
5
+
6
+ module I18n::Tasks::Scanners
7
+ class PrismScanner < FileScanner
8
+ MAGIC_COMMENT_SKIP_PRISM = 'i18n-tasks-skip-prism'
9
+
10
+ def initialize(**args)
11
+ unless VISITOR
12
+ warn(
13
+ 'Please make sure `prism` is available to use this feature. Fallback to Ruby AST Scanner.'
14
+ )
15
+ end
16
+ super
17
+
18
+ @fallback = RubyAstScanner.new(**args)
19
+ end
20
+
21
+ protected
22
+
23
+ # Extract all occurrences of translate calls from the file at the given path.
24
+ #
25
+ # @return [Array<[key, Results::KeyOccurrence]>] each occurrence found in the file
26
+ def scan_file(path)
27
+ return @fallback.send(:scan_file, path) if VISITOR.nil?
28
+
29
+ process_results(path, PARSER.parse_file(path))
30
+ rescue Exception => e # rubocop:disable Lint/RescueException
31
+ raise(
32
+ ::I18n::Tasks::CommandError.new(
33
+ e,
34
+ "Error scanning #{path}: #{e.message}"
35
+ )
36
+ )
37
+ end
38
+
39
+ # Need to have method that can be overridden to be able to test it
40
+ def process_results(path, parse_results)
41
+ parsed = parse_results.value
42
+ comments = parse_results.comments
43
+
44
+ return @fallback.send(:scan_file, path) if skip_prism_comment?(comments)
45
+
46
+ rails = if config[:prism_visitor].blank?
47
+ true
48
+ else
49
+ config[:prism_visitor] != 'ruby'
50
+ end
51
+
52
+ visitor = VISITOR.new(comments: comments, rails: rails)
53
+ parsed.accept(visitor)
54
+
55
+ occurrences = []
56
+ visitor.process.each do |translation_call|
57
+ result = translation_call.occurrences(path)
58
+ occurrences << result if result
59
+ end
60
+
61
+ occurrences
62
+ end
63
+
64
+ def skip_prism_comment?(comments)
65
+ comments.any? do |comment|
66
+ content =
67
+ comment.respond_to?(:slice) ? comment.slice : comment.location.slice
68
+ content.include?(MAGIC_COMMENT_SKIP_PRISM)
69
+ end
70
+ end
71
+
72
+ # This block handles adding a fallback if the `prism` gem is not available.
73
+ begin
74
+ require 'prism'
75
+ require_relative 'prism_scanners/visitor'
76
+ PARSER = Prism
77
+ VISITOR = I18n::Tasks::Scanners::PrismScanners::Visitor
78
+ rescue LoadError
79
+ PARSER = nil
80
+ VISITOR = nil
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prism/visitor'
4
+
5
+ # This class is used to parse the arguments to e.g. a Prism::CallNode and return the values we need
6
+ # for turning them into translations and occurrences.
7
+ # Used in the PrismScanners::Visitor class.
8
+ module I18n::Tasks::Scanners::PrismScanners
9
+ class ArgumentsVisitor < Prism::Visitor
10
+ def visit_keyword_hash_node(node)
11
+ node.child_nodes.each_with_object({}) do |child, hash|
12
+ hash[visit(child.key)] = visit(child.value)
13
+ hash
14
+ end
15
+ end
16
+
17
+ def visit_symbol_node(node)
18
+ node.value
19
+ end
20
+
21
+ def visit_string_node(node)
22
+ node.content
23
+ end
24
+
25
+ def visit_array_node(node)
26
+ node.child_nodes.map { |child| visit(child) }
27
+ end
28
+
29
+ def visit_arguments_node(node)
30
+ node.child_nodes.map { |child| visit(child) }
31
+ end
32
+
33
+ def visit_integer_node(node)
34
+ node.value
35
+ end
36
+
37
+ def visit_lambda_node(node)
38
+ node
39
+ end
40
+ end
41
+ end