i18n-tasks 0.2.22 → 0.3.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGES.md +5 -1
  3. data/Gemfile +0 -1
  4. data/README.md +59 -49
  5. data/bin/i18n-tasks +38 -0
  6. data/i18n-tasks.gemspec +1 -0
  7. data/lib/i18n/tasks/commands.rb +121 -0
  8. data/lib/i18n/tasks/commands_base.rb +54 -0
  9. data/lib/i18n/tasks/configuration.rb +39 -1
  10. data/lib/i18n/tasks/data/storage/file_storage.rb +1 -1
  11. data/lib/i18n/tasks/data_traversal.rb +6 -7
  12. data/lib/i18n/tasks/fill_tasks.rb +20 -48
  13. data/lib/i18n/tasks/google_translation.rb +1 -1
  14. data/lib/i18n/tasks/key.rb +11 -26
  15. data/lib/i18n/tasks/key/key_group.rb +44 -0
  16. data/lib/i18n/tasks/key/match_pattern.rb +23 -0
  17. data/lib/i18n/tasks/key/usages.rb +11 -0
  18. data/lib/i18n/tasks/key_pattern_matching.rb +6 -2
  19. data/lib/i18n/tasks/missing_keys.rb +15 -12
  20. data/lib/i18n/tasks/plural_keys.rb +3 -3
  21. data/lib/i18n/tasks/reports/base.rb +3 -2
  22. data/lib/i18n/tasks/reports/spreadsheet.rb +2 -1
  23. data/lib/i18n/tasks/reports/terminal.rb +6 -6
  24. data/lib/i18n/tasks/scanners/base_scanner.rb +20 -14
  25. data/lib/i18n/tasks/scanners/pattern_scanner.rb +31 -5
  26. data/lib/i18n/tasks/scanners/pattern_with_scope_scanner.rb +75 -0
  27. data/lib/i18n/tasks/translation_data.rb +32 -11
  28. data/lib/i18n/tasks/unused_keys.rb +3 -2
  29. data/lib/i18n/tasks/used_keys.rb +14 -11
  30. data/lib/i18n/tasks/version.rb +1 -1
  31. data/lib/tasks/i18n-tasks.rake +34 -85
  32. data/spec/fixtures/app/controllers/events_controller.rb +23 -3
  33. data/spec/fixtures/app/views/index.html.slim +4 -1
  34. data/spec/fixtures/app/views/usages.html.slim +2 -0
  35. data/spec/fixtures/config/i18n-tasks.yml +1 -1
  36. data/spec/i18n_tasks_spec.rb +66 -38
  37. data/spec/pattern_scanner_spec.rb +1 -1
  38. data/spec/spec_helper.rb +2 -1
  39. data/spec/support/capture_std.rb +17 -0
  40. data/spec/support/fixtures.rb +9 -2
  41. data/spec/support/test_codebase.rb +5 -18
  42. data/spec/support/test_codebase_env.rake +4 -2
  43. data/spec/used_keys_spec.rb +1 -0
  44. metadata +31 -5
@@ -4,26 +4,52 @@ module I18n::Tasks::Scanners
4
4
  # Scans for I18n.t usages
5
5
  #
6
6
  class PatternScanner < BaseScanner
7
- LITERAL_RE = /:?".+?"|:?'.+?'|:\w+/
8
- DEFAULT_PATTERN = /\bt(?:ranslate)?[( ]\s*(#{LITERAL_RE})/
9
-
10
7
  # Extract i18n keys from file based on the pattern which must capture the key literal.
11
8
  # @return [String] keys found in file
12
9
  def scan_file(path, text = read_file(path))
13
10
  keys = []
14
11
  text.scan(pattern) do |match|
15
12
  src_pos = Regexp.last_match.offset(0).first
16
- key = extract_key_from_match(match, path)
13
+ key = match_to_key(match, path)
17
14
  next unless valid_key?(key)
18
15
  keys << ::I18n::Tasks::Key.new(key, usage_context(text, src_pos))
19
16
  end
20
17
  keys
21
18
  end
22
19
 
20
+ def default_pattern
21
+ # capture only the first argument
22
+ /
23
+ #{translate_call_re} [\( ] \s* (?# fn call begin )
24
+ (#{literal_re}) (?# capture the first argument)
25
+ /x
26
+ end
27
+
23
28
  protected
24
29
 
30
+ # Given
31
+ # @param [MatchData] match
32
+ # @param [String] path
33
+ # @return [String] full absolute key name
34
+ def match_to_key(match, path)
35
+ key = strip_literal(match[0])
36
+ key = absolutize_key(key, path) if path && key.start_with?('.')
37
+ key
38
+ end
39
+
25
40
  def pattern
26
- @pattern ||= config[:pattern].present? ? Regexp.new(config[:pattern]) : DEFAULT_PATTERN
41
+ @pattern ||= config[:pattern].present? ? Regexp.new(config[:pattern]) : default_pattern
42
+ end
43
+
44
+ def translate_call_re
45
+ /\bt(?:ranslate)?/
46
+ end
47
+
48
+ # Match literals:
49
+ # * String: '', "#{}"
50
+ # * Symbol: :sym, :'', :"#{}"
51
+ def literal_re
52
+ /:?".+?"|:?'.+?'|:\w+/
27
53
  end
28
54
  end
29
55
  end
@@ -0,0 +1,75 @@
1
+ require 'i18n/tasks/scanners/pattern_scanner'
2
+
3
+ module I18n::Tasks::Scanners
4
+ # Scans for I18n.t(key, scope: ...) usages
5
+ # both scope: "literal", and scope: [:array, :of, 'literals'] forms are supported
6
+ # Caveat: scope is only detected when it is the first argument
7
+ class PatternWithScopeScanner < PatternScanner
8
+
9
+ def default_pattern
10
+ # capture the first argument and scope argument if present
11
+ /#{super}
12
+ (?: \s*,\s* #{scope_arg_re} )? (?# capture scope in second argument )
13
+ /x
14
+ end
15
+
16
+ protected
17
+
18
+ # Given
19
+ # @param [MatchData] match
20
+ # @param [String] path
21
+ # @return [String] full absolute key name with scope resolved if any
22
+ def match_to_key(match, path)
23
+ key = super
24
+ scope = match[1]
25
+ if scope
26
+ scope_ns = scope.gsub(/[\[\]\s]+/, '').split(',').map { |arg| strip_literal(arg) } * '.'
27
+ "#{scope_ns}.#{key}"
28
+ else
29
+ key unless match[0] =~ /\A\w/
30
+ end
31
+ end
32
+
33
+
34
+ # also parse expressions with literals
35
+ def literal_re
36
+ /(?: (?: #{super} ) | #{expr_re} )/x
37
+ end
38
+
39
+ # strip literals, convert expressions to #{interpolations}
40
+ def strip_literal(val)
41
+ if val =~ /\A\w/
42
+ "\#{#{val}}"
43
+ else
44
+ super(val)
45
+ end
46
+ end
47
+
48
+ # Regexps:
49
+
50
+ # scope: literal or code expression or an array of these
51
+ def scope_arg_re
52
+ /(?:
53
+ :#{scope_arg_name}\s*=>\s* | (?# :scope => :home )
54
+ #{scope_arg_name}:\s* (?# scope: :home )
55
+ ) (#{array_or_one_literal_re})/x
56
+ end
57
+
58
+ def scope_arg_name
59
+ 'scope'
60
+ end
61
+
62
+ # match code expression
63
+ # matches characters until , as a heuristic to parse scopes like [:categories, cat.key]
64
+ # can be massively improved by matching parenthesis
65
+ def expr_re
66
+ /[\w():"'.\s]+/x
67
+ end
68
+
69
+ # match +el+ or array of +el+
70
+ def array_or_one_literal_re(el = literal_re)
71
+ /#{el} |
72
+ \[\s* (?:#{el}\s*,\s*)* #{el} \s*\]/x
73
+ end
74
+ end
75
+ end
@@ -13,17 +13,7 @@ module I18n::Tasks::TranslationData
13
13
 
14
14
  # whether the value for key exists in locale (defaults: base_locale)
15
15
  def key_value?(key, locale = base_locale)
16
- t(data[locale], key).present?
17
- end
18
-
19
- # @return [Array<String>] all available locales
20
- def locales
21
- config[:locales] ||= I18n.available_locales.map(&:to_s)
22
- end
23
-
24
- # @return [String] default i18n locale
25
- def base_locale
26
- config[:base_locale] ||= I18n.default_locale.to_s
16
+ t(key, locale).present?
27
17
  end
28
18
 
29
19
  def non_base_locales(from = nil)
@@ -39,4 +29,35 @@ module I18n::Tasks::TranslationData
39
29
  data[target_locale] = data[target_locale]
40
30
  end
41
31
  end
32
+
33
+ # @param locales
34
+ # @param locale
35
+ # @param keys
36
+ # @param value
37
+ # @param values
38
+ def update_data(opts = {})
39
+ if opts.key?(:locales)
40
+ locales = (Array(opts[:locales]).presence || self.locales).map(&:to_s)
41
+ # make sure base_locale always comes first if present
42
+ locales = [base_locale] + (locales - [base_locale]) if locales.include?(base_locale)
43
+ opts = opts.except(:locales)
44
+ locales.each { |locale| update_data(opts.merge(locale: locale)) }
45
+ return
46
+ end
47
+ locale = opts[:locale] || base_locale
48
+ keys = opts[:keys]
49
+ keys = keys.call(locale) if keys.respond_to?(:call)
50
+ return if keys.empty?
51
+ values = opts[:values]
52
+ values = values.call(keys, locale) if values.respond_to?(:call)
53
+ unless values
54
+ value = opts[:value]
55
+ values = if value.respond_to?(:call)
56
+ keys.map { |key| value.call(key, locale) }
57
+ else
58
+ [value] * keys.size
59
+ end
60
+ end
61
+ data[locale] = data[locale].deep_merge(list_to_tree keys.map(&:to_s).zip(values))
62
+ end
42
63
  end
@@ -9,13 +9,14 @@ module I18n
9
9
  @unused_keys ||= {}
10
10
  @unused_keys[locale] ||= ::I18n::Tasks::KeyGroup.new(
11
11
  traverse_map_if(data[locale]) { |key, value|
12
- next if pattern_key?(key) || ignore_key?(key, :unused)
12
+ next if used_in_expr?(key) || ignore_key?(key, :unused)
13
13
  key = depluralize_key(locale, key)
14
14
  [key, value] unless used_key?(key)
15
15
  }.uniq, locale: locale, type: :unused)
16
16
  end
17
17
 
18
- def remove_unused!(locales = self.locales)
18
+ def remove_unused!(locales = nil)
19
+ locales ||= self.locales
19
20
  unused = unused_keys
20
21
  locales.each do |locale|
21
22
  data[locale] = list_to_tree traverse_map_if(data[locale]) { |key, value|
@@ -1,21 +1,25 @@
1
1
  require 'find'
2
- require 'i18n/tasks/scanners/pattern_scanner'
2
+ require 'i18n/tasks/scanners/pattern_with_scope_scanner'
3
3
 
4
4
  module I18n::Tasks::UsedKeys
5
5
  # find all keys in the source (relative keys are absolutized)
6
6
  # @return [Array<String>]
7
7
  def used_keys(with_usages = false)
8
8
  if with_usages
9
- I18n::Tasks::KeyGroup.new(scanner.keys_with_usages, type: :used, key_filter: scanner.key_filter)
9
+ used_keys_group scanner.keys_with_usages
10
10
  else
11
- @used_keys ||= I18n::Tasks::KeyGroup.new(scanner.keys, type: :used, key_filter: scanner.key_filter)
11
+ @used_keys ||= used_keys_group scanner.keys
12
12
  end
13
13
  end
14
14
 
15
+ def used_keys_group(keys, opts = {})
16
+ ::I18n::Tasks::KeyGroup.new(keys, {type: :used, key_filter: scanner.key_filter}.merge(opts))
17
+ end
18
+
15
19
  def scanner
16
20
  @scanner ||= begin
17
21
  search_config = (config[:search] || {}).with_indifferent_access
18
- class_name = search_config[:scanner] || '::I18n::Tasks::Scanners::PatternScanner'
22
+ class_name = search_config[:scanner] || '::I18n::Tasks::Scanners::PatternWithScopeScanner'
19
23
  class_name.constantize.new search_config.merge(relative_roots: relative_roots)
20
24
  end
21
25
  end
@@ -25,15 +29,14 @@ module I18n::Tasks::UsedKeys
25
29
  used_keys.include?(key)
26
30
  end
27
31
 
28
- # dynamically generated keys in the source, e.g t("category.#{category_key}")
29
- def pattern_key?(key)
30
- @pattern_keys_re ||= compile_start_with_re(pattern_key_prefixes)
31
- !!(key =~ @pattern_keys_re)
32
+ # @return whether the key is potentially used in a code expression such as:
33
+ # t("category.#{category_key}")
34
+ def used_in_expr?(key)
35
+ !!(key =~ expr_key_re)
32
36
  end
33
37
 
34
38
  # keys in the source that end with a ., e.g. t("category.#{cat.i18n_key}") or t("category." + category.key)
35
- def pattern_key_prefixes
36
- @pattern_keys_prefixes ||=
37
- used_keys.key_names.select { |k| k =~ /\#{.*?}/ || k.ends_with?('.') }.map { |k| k.split(/\.?#/)[0].presence }.compact
39
+ def expr_key_re
40
+ @expr_key_re ||= compile_key_pattern "{#{used_keys.keys.select(&:expr?).map(&:key_match_pattern) * ','}}"
38
41
  end
39
42
  end
@@ -1,5 +1,5 @@
1
1
  module I18n
2
2
  module Tasks
3
- VERSION = '0.2.22'
3
+ VERSION = '0.3.0.rc1'
4
4
  end
5
5
  end
@@ -1,130 +1,79 @@
1
- require 'set'
2
1
  require 'i18n/tasks'
3
- require 'i18n/tasks/reports/terminal'
4
- require 'active_support/core_ext/module/delegation'
5
- require 'i18n/tasks/reports/spreadsheet'
2
+ require 'i18n/tasks/commands'
6
3
 
7
- namespace :i18n do
8
- require 'highline/import'
4
+ cmd = I18n::Tasks::Commands.new
9
5
 
6
+ namespace :i18n do
10
7
  task :setup do
11
8
  end
12
9
 
13
- desc 'show missing translations'
10
+ desc cmd.desc :missing
14
11
  task :missing, [:locales] => 'i18n:setup' do |t, args|
15
- i18n_report.missing_keys i18n_task.missing_keys(locales: i18n_parse_locales(args[:locales]))
12
+ cmd.missing locales: args[:locales]
16
13
  end
17
14
 
18
15
  namespace :missing do
19
16
  desc 'keys present in code but not existing in base locale data'
20
- task :missing_from_base => 'i18n:setup' do |t, args|
21
- i18n_report.missing_keys i18n_task.keys_missing_from_base
17
+ task :missing_from_base => 'i18n:setup' do
18
+ cmd.missing type: :missing_from_base
22
19
  end
23
20
 
24
21
  desc 'keys present but with value same as in base locale'
25
22
  task :eq_base, [:locales] => 'i18n:setup' do |t, args|
26
- i18n_report.missing_keys i18n_task.missing_keys(type: :eq_base, locales: i18n_parse_locales(args[:locales]))
23
+ cmd.missing type: :eq_base, locales: args[:locales]
27
24
  end
28
25
 
29
26
  desc 'keys that exist in base locale but are blank in passed locales'
30
27
  task :missing_from_locale, [:locales] => 'i18n:setup' do |t, args|
31
- i18n_report.missing_keys i18n_task.missing_keys(type: :missing_from_locale, locales: i18n_parse_locales(args[:locales]))
28
+ cmd.missing type: :missing_from_locale, locales: args[:locales]
32
29
  end
33
30
  end
34
31
 
35
- desc 'show unused translations'
32
+ desc cmd.desc :show_unused
36
33
  task :unused => 'i18n:setup' do
37
- i18n_report.unused_keys
38
- end
39
-
40
- desc 'add placeholder for missing values to the base locale (default: key.humanize)'
41
- task :add_missing, [:placeholder] => 'i18n:setup' do |t, args|
42
- i18n_task.add_missing! base_locale, args[:placeholder]
34
+ cmd.unused
43
35
  end
44
36
 
45
- desc 'remove unused keys'
37
+ desc cmd.desc :remove_unused
46
38
  task :remove_unused, [:locales] => 'i18n:setup' do |t, args|
47
- locales = i18n_parse_locales(args[:locales]) || i18n_task.locales
48
- unused_keys = i18n_task.unused_keys
49
- if unused_keys.present?
50
- i18n_report.unused_keys(unused_keys)
51
- unless ENV['CONFIRM']
52
- exit 1 unless agree(red "All these translations will be removed in #{bold locales * ', '}#{red '.'} " + yellow('Continue? (yes/no)') + ' ')
53
- end
54
- i18n_task.remove_unused!(locales)
55
- else
56
- STDERR.puts bold green 'No unused keys to remove'
57
- end
39
+ cmd.remove_unused
58
40
  end
59
-
60
- desc 'show usages of the keys in the codebase'
41
+
42
+ desc cmd.desc :usages
61
43
  task :usages, [:filter] => 'i18n:setup' do |t, args|
62
- filter = args[:filter] ? args[:filter].tr('+', ',') : nil
63
- i18n_report.used_keys(
64
- i18n_task.scanner.with_key_filter(filter) {
65
- i18n_task.used_keys(true)
66
- }
67
- )
44
+ cmd.find filter: args[:filter]
68
45
  end
69
46
 
70
- desc 'normalize translation data: sort and move to the right files'
47
+ desc cmd.desc :normalize
71
48
  task :normalize, [:locales] => 'i18n:setup' do |t, args|
72
- i18n_task.normalize_store! args[:locales]
49
+ cmd.normalize locales: args[:locales]
73
50
  end
74
51
 
75
- desc 'save missing and unused translations to an Excel file'
76
- task :spreadsheet_report, [:path] => 'i18n:setup' do |t, args|
77
- begin
78
- require 'axlsx'
79
- rescue LoadError
80
- message = %Q(To use i18n:spreadsheet_report please add axlsx gem to Gemfile:\ngem 'axlsx', '~> 2.0')
81
- STDERR.puts Term::ANSIColor.red Term::ANSIColor.bold message
82
- exit 1
83
- end
84
- args.with_defaults path: 'tmp/i18n-report.xlsx'
85
- i18n_spreadsheet_report.save_report(args[:path])
86
- end
87
-
88
- desc 'fill translations with values'
89
- namespace :fill do
90
-
91
- desc 'add "" values for missing and untranslated keys to locales (default: all)'
92
- task :blanks, [:locales] => 'i18n:setup' do |t, args|
93
- i18n_task.fill_with_blanks! i18n_parse_locales args[:locales]
94
- end
95
-
52
+ namespace :add_missing do
96
53
  desc 'add Google Translated values for untranslated keys to locales (default: all non-base)'
97
- task :google_translate, [:locales] => 'i18n:setup' do |t, args|
98
- i18n_task.fill_with_google_translate! i18n_parse_locales args[:locales]
54
+ task :translate, [:locales] => 'i18n:setup' do |t, args|
55
+ cmd.translate locales: args[:locales]
99
56
  end
100
57
 
101
58
  desc 'copy base locale values for all untranslated keys to locales (default: all non-base)'
102
- task :base_value, [:locales] => 'i18n:setup' do |t, args|
103
- i18n_task.fill_with_base_values! i18n_parse_locales args[:locales]
104
- end
105
- end
106
-
107
- module ::I18n::Tasks::RakeHelpers
108
- include Term::ANSIColor
109
-
110
- delegate :base_locale, to: :i18n_task
111
-
112
- def i18n_task
113
- @i18n_task ||= I18n::Tasks::BaseTask.new
59
+ task :placeholder, [:locales, :placeholder] => 'i18n:setup' do |t, args|
60
+ cmd.add_missing locale: args[:locales], placeholder: args[:placeholder]
114
61
  end
115
62
 
116
- def i18n_report
117
- @i18n_report ||= I18n::Tasks::Reports::Terminal.new
63
+ desc 'add values for missing and untranslated keys to locales (default: all)'
64
+ task :empty_string, [:locales] => 'i18n:setup' do |t, args|
65
+ cmd.add_missing locales: args[:locales], placeholder: ''
118
66
  end
67
+ end
119
68
 
120
- def i18n_spreadsheet_report
121
- @i18n_spreadsheet_report ||= I18n::Tasks::Reports::Spreadsheet.new
122
- end
69
+ desc cmd.desc(:config)
70
+ task :tasks_config => 'i18n:setup' do
71
+ cmd.config
72
+ end
123
73
 
124
- def i18n_parse_locales(arg = nil)
125
- arg.try(:strip).try(:split, /\s*\+\s*/).try(:compact).try(:presence)
126
- end
74
+ desc cmd.desc :save_spreadsheet
75
+ task :spreadsheet_report, [:path] => 'i18n:setup' do |t, args|
76
+ cmd.xlsx_report path: args[:path]
127
77
  end
128
- include ::I18n::Tasks::RakeHelpers
129
78
  end
130
79
 
@@ -1,9 +1,29 @@
1
1
  class EventsController < ApplicationController
2
2
  def show
3
3
  redirect_to :edit, notice: I18n.t('cb.a')
4
+
5
+ # args are ignored
4
6
  I18n.t("cb.b", i: "Hello")
5
- I18n.t("hash_pattern.#{some_value}", i: "Hello")
6
- I18n.t("hash_pattern2." + some_value, i: "Hello")
7
- I18n.t "hash_pattern3", scope: "foo.bar"
7
+
8
+ # pattern not reported as unused
9
+ I18n.t("hash.pattern.#{some_value}", i: "Hello")
10
+
11
+ # pattern also not reported as unused
12
+ I18n.t("hash.pattern2." + some_value, i: "Hello")
13
+
14
+ # same as above but with scope argument
15
+ I18n.t(some_value, scope: [:hash, :pattern3])
16
+
17
+ # missing:
18
+ I18n.t 'pattern_missing.a', scope: :hash, other: 1
19
+
20
+ # missing:
21
+ I18n.t :b, scope: [:hash, :pattern_missing], other: 1
22
+
23
+ # missing, but not yet detected as such :(
24
+ I18n.t "#{stuff}.pattern_missing.c"
25
+
26
+ # not missing
27
+ I18n.t "hash.#{stuff}.a"
8
28
  end
9
29
  end