i18n-tasks 0.9.37 → 1.0.5

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9cad835aebcfc5621cb07d3a5ef4134a01cfed6286411776a9a5e04cfefe5862
4
- data.tar.gz: 3b9f5a35077463778d1b940bd01738e49225fe7775d02858da5bdd9a9ded8308
3
+ metadata.gz: 4d600c7425116c27f5cadffdc23d6e29de6137f90cea588e1a046ca1ec5970f5
4
+ data.tar.gz: 91ea505ba06486d962e7be61eb52ca6af18bc8d481fdeb6a193f715b3c2f55a2
5
5
  SHA512:
6
- metadata.gz: 8ed7363e9cfd2e8cf179aa0f28c0f00b926b4a53ba95f19ca61d8a08901322c561e84ebab51616f592400dac985f32c82b0a08911ec5f22223fe22e7858dd6af
7
- data.tar.gz: 04b44a9180dc9b1e9d739eb5e5881877f68321bbd7efa1028a2f2d89fd3acf1cf742d587aab566a8cbd43813619693132ec803221d6c7ad31cd703ba516622f3
6
+ metadata.gz: 0e74a51969f4a82f8fa479dc31cb9236cc9de5d6255fe99d6de9eb392fb85e8771f4b21cbaed35f8c114258ada6c88086fb6fae0f1e0d97d8eb1f2d317c4f834
7
+ data.tar.gz: 4a23c041ddef63f1b798f1455af637f77f369dcfb64e93f72c9592caf2105d2e9b15531679b30d7404746674596ee70078bba3525cbc2eacdce00d9d0ebc7054
data/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # i18n-tasks [![Build Status][badge-ci]][ci] [![Coverage Status][badge-coverage]][coverage] [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/glebm/i18n-tasks?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge)
2
2
 
3
+ [![Stand With Ukraine](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner2-direct.svg)](https://stand-with-ukraine.pp.ua/)
4
+
3
5
  i18n-tasks helps you find and manage missing and unused translations.
4
6
 
5
7
  <img width="539" height="331" src="https://i.imgur.com/XZBd8l7.png">
@@ -22,7 +24,7 @@ i18n-tasks can be used with any project using the ruby [i18n gem][i18n-gem] (def
22
24
  Add i18n-tasks to the Gemfile:
23
25
 
24
26
  ```ruby
25
- gem 'i18n-tasks', '~> 0.9.37'
27
+ gem 'i18n-tasks', '~> 1.0.5'
26
28
  ```
27
29
 
28
30
  Copy the default [configuration file](#configuration):
@@ -221,7 +223,7 @@ See the full list of tasks with `i18n-tasks --help`.
221
223
 
222
224
  ### Features and limitations
223
225
 
224
- `i18n-tasks` uses an AST scanner for `.rb` files, and a regexp-based scanner for other files, such as `.haml`.
226
+ `i18n-tasks` uses an AST scanner for `.rb` and `.html.erb` files, and a regexp-based scanner for other files, such as `.haml`.
225
227
 
226
228
  #### Relative keys
227
229
 
data/i18n-tasks.gemspec CHANGED
@@ -35,6 +35,7 @@ Gem::Specification.new do |s| # rubocop:disable Metrics/BlockLength
35
35
 
36
36
  s.add_dependency 'activesupport', '>= 4.0.2'
37
37
  s.add_dependency 'ast', '>= 2.1.0'
38
+ s.add_dependency 'better_html', '~> 1.0'
38
39
  s.add_dependency 'erubi'
39
40
  s.add_dependency 'highline', '>= 2.0.0'
40
41
  s.add_dependency 'i18n'
@@ -16,6 +16,7 @@ module I18n::Tasks
16
16
  end
17
17
 
18
18
  def run(name, opts = {})
19
+ log_stderr "#{Rainbow('#StandWith').bg(:blue)}#{Rainbow('Ukraine').bg(:yellow)}"
19
20
  name = name.to_sym
20
21
  public_name = name.to_s.tr '_', '-'
21
22
  log_verbose "task: #{public_name}(#{opts.map { |k, v| "#{k}: #{v.inspect}" } * ', '})"
@@ -5,11 +5,13 @@ module I18n::Tasks
5
5
  module Data
6
6
  module Adapter
7
7
  module YamlAdapter
8
+ EMOJI_REGEX = /\\u[\da-f]{8}/i
9
+
8
10
  class << self
9
11
  # @return [Hash] locale tree
10
12
  def parse(str, options)
11
13
  if YAML.method(:load).arity.abs == 2
12
- YAML.load(str, **(options || {}))
14
+ YAML.safe_load(str, **(options || {}), permitted_classes: [Symbol], aliases: true)
13
15
  else
14
16
  # older jruby and rbx 2.2.7 do not accept options
15
17
  YAML.load(str)
@@ -18,7 +20,12 @@ module I18n::Tasks
18
20
 
19
21
  # @return [String]
20
22
  def dump(tree, options)
21
- tree.to_yaml(options || {})
23
+ restore_emojis(tree.to_yaml(options || {}))
24
+ end
25
+
26
+ # @return [String]
27
+ def restore_emojis(yaml)
28
+ yaml.gsub(EMOJI_REGEX) { |m| [m[-8..].to_i(16)].pack("U") }
22
29
  end
23
30
  end
24
31
  end
@@ -2,22 +2,26 @@
2
2
 
3
3
  module I18n::Tasks
4
4
  module Interpolations
5
- VARIABLE_REGEX = /%{[^}]+}/.freeze
5
+ class << self
6
+ attr_accessor :variable_regex
7
+ end
8
+ @variable_regex = /%{[^}]+}/.freeze
6
9
 
7
10
  def inconsistent_interpolations(locales: nil, base_locale: nil) # rubocop:disable Metrics/AbcSize
8
11
  locales ||= self.locales
9
12
  base_locale ||= self.base_locale
10
13
  result = empty_forest
14
+ variable_regex = I18n::Tasks::Interpolations.variable_regex
11
15
 
12
16
  data[base_locale].key_values.each do |key, value|
13
17
  next if !value.is_a?(String) || ignore_key?(key, :inconsistent_interpolations)
14
18
 
15
- base_vars = Set.new(value.scan(VARIABLE_REGEX))
19
+ base_vars = Set.new(value.scan(variable_regex))
16
20
  (locales - [base_locale]).each do |current_locale|
17
21
  node = data[current_locale].first.children[key]
18
22
  next unless node&.value.is_a?(String)
19
23
 
20
- if base_vars != Set.new(node.value.scan(VARIABLE_REGEX))
24
+ if base_vars != Set.new(node.value.scan(variable_regex))
21
25
  result.merge!(node.walk_to_root.reduce(nil) { |c, p| [p.derive(children: c)] })
22
26
  end
23
27
  end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'ast'
4
+ require 'set'
5
+ require 'i18n/tasks/scanners/local_ruby_parser'
6
+
7
+ module I18n::Tasks::Scanners
8
+ class ErbAstProcessor
9
+ include AST::Processor::Mixin
10
+ def initialize
11
+ super()
12
+ @ruby_parser = LocalRubyParser.new(ignore_blocks: true)
13
+ @comments = []
14
+ end
15
+
16
+ def process_and_extract_comments(ast)
17
+ result = process(ast)
18
+ [result, @comments]
19
+ end
20
+
21
+ def on_code(node)
22
+ parsed, comments = @ruby_parser.parse(
23
+ node.children[0],
24
+ location: node.location
25
+ )
26
+ @comments.concat(comments)
27
+
28
+ unless parsed.nil?
29
+ parsed = parsed.updated(
30
+ nil,
31
+ parsed.children.map { |child| node?(child) ? process(child) : child }
32
+ )
33
+ node = node.updated(:send, parsed)
34
+ end
35
+ node
36
+ end
37
+
38
+ # @param node [::Parser::AST::Node]
39
+ # @return [::Parser::AST::Node]
40
+ def handler_missing(node)
41
+ node = transform_misparsed_comment(node)
42
+ node.updated(
43
+ nil,
44
+ node.children.map { |child| node?(child) ? process(child) : child }
45
+ )
46
+ end
47
+
48
+ private
49
+
50
+ # Works around incorrect handling of comments of the form:
51
+ # <%# ... #>
52
+ # (no space between % and #)
53
+ #
54
+ # With a space the AST is:
55
+ #
56
+ # s(:erb, nil, nil,
57
+ # s(:code, " # this should not fail: ' "), nil)
58
+ #
59
+ # Without a space the AST is:
60
+ #
61
+ # s(:erb,
62
+ # s(:indicator, "#"), nil,
63
+ # s(:code, " this should not fail: ' "), nil)
64
+ # @param node [::Parser::AST::Node]
65
+ # @return [::Parser::AST::Node]
66
+ def transform_misparsed_comment(node)
67
+ return node unless node.type == :erb && node.children.size == 4 &&
68
+ node.children[0]&.type == :indicator && node.children[0].children[0] == "#" &&
69
+ node.children[1].nil? &&
70
+ node.children[2]&.type == :code &&
71
+ node.children[3].nil?
72
+ code_node = node.children[2]
73
+
74
+ # Prepend # to each line to make it a valid Ruby comment.
75
+ code = code_node.children[0].split("\n").map do |line|
76
+ next line if line =~ /^\s*#/
77
+ "##{line}"
78
+ end.join("\n")
79
+
80
+ node.updated(
81
+ nil,
82
+ [nil, nil, code_node.updated(nil, [code]), nil]
83
+ )
84
+ end
85
+
86
+ def node?(node)
87
+ node.is_a?(::Parser::AST::Node)
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
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'
7
+
8
+ module I18n::Tasks::Scanners
9
+ # Scan for I18n.translate calls in ERB-file better-html and ASTs
10
+ class ErbAstScanner < RubyAstScanner
11
+ def initialize(**args)
12
+ super(**args)
13
+ @erb_ast_processor = ErbAstProcessor.new
14
+ end
15
+
16
+ private
17
+
18
+ # Parse file on path and returns AST and comments.
19
+ #
20
+ # @param path Path to file to parse
21
+ # @return [{Parser::AST::Node}, [Parser::Source::Comment]]
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)
26
+ end
27
+
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
+ )
46
+ end
47
+ end
48
+ end
@@ -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
@@ -48,7 +48,7 @@ module I18n::Tasks
48
48
  # rubocop:enable Metrics/ParameterLists
49
49
 
50
50
  def inspect
51
- "Occurrence(#{@path}:#{@line_num}:#{@line_pos}:#{@pos}:#{@raw_key}:#{@default_arg})"
51
+ "Occurrence(#{@path}:#{@line_num}, line_pos: #{@line_pos}, pos: #{@pos}, raw_key: #{@raw_key}, default_arg: #{@default_arg}, line: #{@line})"
52
52
  end
53
53
 
54
54
  def ==(other)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'ast'
4
4
  require 'set'
5
+
5
6
  module I18n::Tasks::Scanners
6
7
  class RubyAstCallFinder
7
8
  include AST::Processor::Mixin
@@ -32,32 +32,22 @@ module I18n::Tasks::Scanners
32
32
  #
33
33
  # @return [Array<[key, Results::KeyOccurrence]>] each occurrence found in the file
34
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
35
+ ast, comments = path_to_ast_and_comments(path)
41
36
 
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)
37
+ ast_to_occurences(ast) + comments_to_occurences(path, ast, comments)
57
38
  rescue Exception => e # rubocop:disable Lint/RescueException
58
39
  raise ::I18n::Tasks::CommandError.new(e, "Error scanning #{path}: #{e.message}")
59
40
  end
60
41
 
42
+ # Parse file on path and returns AST and comments.
43
+ #
44
+ # @param path Path to file to parse
45
+ # @return [{Parser::AST::Node}, [Parser::Source::Comment]]
46
+ def path_to_ast_and_comments(path)
47
+ @parser.reset
48
+ @parser.parse_with_comments(make_buffer(path))
49
+ end
50
+
61
51
  # @param send_node [Parser::AST::Node]
62
52
  # @param method_name [Symbol, nil]
63
53
  # @param location [Parser::Source::Map]
@@ -204,6 +194,41 @@ module I18n::Tasks::Scanners
204
194
  buffer.raw_source = contents
205
195
  end
206
196
  end
197
+
198
+ # Convert an array of {Parser::Source::Comment} to occurrences.
199
+ #
200
+ # @param path Path to file
201
+ # @param ast Parser::AST::Node
202
+ # @param comments [Parser::Source::Comment]
203
+ # @return [nil, [key, Occurrence]] full absolute key name and the occurrence.
204
+ def comments_to_occurences(path, ast, comments)
205
+ magic_comments = comments.select { |comment| comment.text =~ MAGIC_COMMENT_PREFIX }
206
+ comment_to_node = Parser::Source::Comment.associate_locations(ast, magic_comments).tap do |h|
207
+ # transform_values is only available in ActiveSupport 4.2+
208
+ h.each { |k, v| h[k] = v.first }
209
+ end.invert
210
+
211
+ magic_comments.flat_map do |comment|
212
+ @parser.reset
213
+ associated_node = comment_to_node[comment]
214
+ @call_finder.collect_calls(
215
+ @parser.parse(make_buffer(path, comment.text.sub(MAGIC_COMMENT_PREFIX, '').split(/\s+(?=t)/).join('; ')))
216
+ ) do |send_node, _method_name|
217
+ # method_name is not available at this stage
218
+ send_node_to_key_occurrence(send_node, nil, location: associated_node || comment.location)
219
+ end
220
+ end
221
+ end
222
+
223
+ # Convert {Parser::AST::Node} to occurrences.
224
+ #
225
+ # @param ast {Parser::Source::Comment}
226
+ # @return [nil, [key, Occurrence]] full absolute key name and the occurrence.
227
+ def ast_to_occurences(ast)
228
+ @call_finder.collect_calls(ast) do |send_node, method_name|
229
+ send_node_to_key_occurrence(send_node, method_name)
230
+ end
231
+ end
207
232
  end
208
233
  end
209
234
  # rubocop:enable Metrics/AbcSize,Metrics/BlockNesting,Metrics/PerceivedComplexity
@@ -3,6 +3,7 @@
3
3
  require 'find'
4
4
  require 'i18n/tasks/scanners/pattern_with_scope_scanner'
5
5
  require 'i18n/tasks/scanners/ruby_ast_scanner'
6
+ require 'i18n/tasks/scanners/erb_ast_scanner'
6
7
  require 'i18n/tasks/scanners/scanner_multiplexer'
7
8
  require 'i18n/tasks/scanners/files/caching_file_finder_provider'
8
9
  require 'i18n/tasks/scanners/files/caching_file_reader'
@@ -20,7 +21,8 @@ module I18n::Tasks
20
21
  relative_roots: %w[app/controllers app/helpers app/mailers app/presenters app/views].freeze,
21
22
  scanners: [
22
23
  ['::I18n::Tasks::Scanners::RubyAstScanner', { only: %w[*.rb] }],
23
- ['::I18n::Tasks::Scanners::PatternWithScopeScanner', { exclude: %w[*.rb] }]
24
+ ['::I18n::Tasks::Scanners::ErbAstScanner', { only: %w[*.erb] }],
25
+ ['::I18n::Tasks::Scanners::PatternWithScopeScanner', { exclude: %w[*.erb *.rb] }]
24
26
  ],
25
27
  strict: true
26
28
  }.freeze
@@ -2,6 +2,6 @@
2
2
 
3
3
  module I18n
4
4
  module Tasks
5
- VERSION = '0.9.37'
5
+ VERSION = '1.0.5'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: i18n-tasks
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.37
4
+ version: 1.0.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - glebm
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-12-26 00:00:00.000000000 Z
11
+ date: 2022-03-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: 2.1.0
41
+ - !ruby/object:Gem::Dependency
42
+ name: better_html
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.0'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: erubi
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -357,12 +371,15 @@ files:
357
371
  - lib/i18n/tasks/references.rb
358
372
  - lib/i18n/tasks/reports/base.rb
359
373
  - lib/i18n/tasks/reports/terminal.rb
374
+ - lib/i18n/tasks/scanners/erb_ast_processor.rb
375
+ - lib/i18n/tasks/scanners/erb_ast_scanner.rb
360
376
  - lib/i18n/tasks/scanners/file_scanner.rb
361
377
  - lib/i18n/tasks/scanners/files/caching_file_finder.rb
362
378
  - lib/i18n/tasks/scanners/files/caching_file_finder_provider.rb
363
379
  - lib/i18n/tasks/scanners/files/caching_file_reader.rb
364
380
  - lib/i18n/tasks/scanners/files/file_finder.rb
365
381
  - lib/i18n/tasks/scanners/files/file_reader.rb
382
+ - lib/i18n/tasks/scanners/local_ruby_parser.rb
366
383
  - lib/i18n/tasks/scanners/occurrence_from_position.rb
367
384
  - lib/i18n/tasks/scanners/pattern_mapper.rb
368
385
  - lib/i18n/tasks/scanners/pattern_scanner.rb