diff --git a/.jrubyrc b/.jrubyrc deleted file mode 100644 index 29cae357..00000000 --- a/.jrubyrc +++ /dev/null @@ -1,2 +0,0 @@ -compat.version=2.0 - diff --git a/.travis.yml b/.travis.yml index 58b8ec9e..ac9ed8fb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,10 +1,13 @@ language: ruby rvm: -- 2.1.3 -- 2.0.0 +- 2.2.1 - 1.9.3 - jruby -- rbx-2.2.10 +- rbx +# travis uses old bundler (https://travis-ci.org/glebm/i18n-tasks/jobs/53485782) +before_install: gem install bundler +cache: bundler +script: bundle exec rspec env: global: - TRAVIS=1 diff --git a/CHANGES.md b/CHANGES.md index 9f20234e..ba7e0978 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,54 @@ +## 0.8.3 + +* Fix regression: ActiveSupport < 4 support [#143](https://github.com/glebm/i18n-tasks/issues/143). + +## 0.8.2 + +* Fix failure on nil values in the data config [#142](https://github.com/glebm/i18n-tasks/issues/142). + +## 0.8.1 + +* The default config file now excludes `app/assets/images` and `app/assets/fonts`. Add `*.otf` to ignored extensions. +* If an error message occurs when scanning, the error message now includes the filename [#141](https://github.com/glebm/i18n-tasks/issues/141). + +## 0.8.0 + +* Parse command line arguments with `optparse`. Remove dependency on Slop. + Simplified commands DSL: options are mostly passed directly to optparse. +* `search.relative_roots` default changed from from `%w(app/views)` to + `%w(app/views app/controllers app/helpers app/presenters)`. +* `add-missing` now adds keys detected in source to all locales (previously just base) [#134](https://github.com/glebm/i18n-tasks/issues/134). +* The default spec template no long requires `spec_helper` by default [Daniel Levenson](https://github.com/dleve123) [#135](https://github.com/glebm/i18n-tasks/pull/135). +* `search.exclude` now appends to and not overrides the default exclude list. More extensions excluded by default: + *.css, *.sass, *.scss, *.less, *.yml, and *.json. [#137](https://github.com/glebm/i18n-tasks/issues/137). + +## 0.7.13 + +* Fix relative keys when controller name consists of more than one word by [Yuji Nakayama](https://github.com/yujinakayama) [#132](https://github.com/glebm/i18n-tasks/pull/132). +* Support keys with UTF8 word characters in the name. [#133](https://github.com/glebm/i18n-tasks/issues/133). +* Change missing report column title from "Details" to "Value in other locales or source", display the locale [#130](https://github.com/glebm/i18n-tasks/issues/130). + +## 0.7.12 + +* Handle relative keys in controllers nested in modules by [Alexander Tipugin](https://github.com/atipugin). [#128](https://github.com/glebm/i18n-tasks/issues/128). +* Only write files that changed [#125](https://github.com/glebm/i18n-tasks/issues/125). +* Allow `[]` in the non-strict scanner pattern [#127](https://github.com/glebm/i18n-tasks/issues/127). + +## 0.7.11 + +* Set slop dependency to 3.5 to ensure Ruby 1.9 compatibility ([#121](https://github.com/glebm/i18n-tasks/pull/121)). + MRI 1.9 EOL is [February 23, 2015](https://www.ruby-lang.org/en/news/2014/01/10/ruby-1-9-3-will-end-on-2015/). + We will support 1.9 until rbx and jruby support 2.0. + +## 0.7.10 + +* Support relative keys in controller action with argument + +## 0.7.9 + +* Support relative keys in Rails controller actions by [Jessie A. Young](https://github.com/jessieay). [#46](https://github.com/glebm/i18n-tasks/issues/46). +* Minor fixes + ## 0.7.8 * Fix Google Translate issues with non-string keys [#100](https://github.com/glebm/i18n-tasks/pull/100) diff --git a/Gemfile b/Gemfile index 36e8c9ef..5f0c4bd7 100644 --- a/Gemfile +++ b/Gemfile @@ -3,8 +3,11 @@ source 'https://rubygems.org' # Specify your gem's dependencies in i18n-tasks.gemspec gemspec -group :development do - gem 'byebug', platform: :mri_21, require: false +unless ENV['TRAVIS'] + group :development do + gem 'byebug', platforms: [:mri_21, :mri_22], require: false + gem 'rubinius-debugger', platform: :rbx, require: false + end end gem 'codeclimate-test-reporter', group: :test, require: nil diff --git a/README.md b/README.md index ea28dc09..64ab93d9 100644 --- a/README.md +++ b/README.md @@ -17,15 +17,15 @@ Thus addressing the two main problems of [i18n gem][i18n-gem] design: ## Installation -i18n-tasks can be used with any project using [i18n][i18n-gem] (default in Rails), or similar, even if it isn't ruby. +i18n-tasks can be used with any project using the ruby [i18n gem][i18n-gem] (default in Rails). -Add it to the Gemfile: +Add i18n-tasks to the Gemfile: ```ruby -gem 'i18n-tasks', '~> 0.7.8' +gem 'i18n-tasks', '~> 0.8.3' ``` -Copy default [configuration file](#configuration) (optional): +Copy the default [configuration file](#configuration): ```console $ cp $(i18n-tasks gem-path)/templates/config/i18n-tasks.yml config/ @@ -146,7 +146,7 @@ See the full list of tasks with `i18n-tasks --help`. ✔ Keys relative to the file path they are used in (see [relative roots configuration](#usage-search)) are supported. -✘ Keys relative to `controller.action_name` in Rails controllers are not supported. +✔ Keys relative to `controller.action_name` in Rails controllers are supported. The closest `def` name is used. #### Plural keys @@ -177,7 +177,7 @@ For now, you can disable dynamic key inference by passing `-s` or `--strict` to Configuration is read from `config/i18n-tasks.yml` or `config/i18n-tasks.yml.erb`. Inspect configuration with `i18n-tasks config`. -Install the default config file with: +Install the [default config file][config] with: ```console $ cp $(i18n-tasks gem-path)/templates/config/i18n-tasks.yml config/ @@ -188,60 +188,16 @@ Settings are compatible with Rails by default. ### Locales By default, `base_locale` is set to `en` and `locales` are inferred from the paths to data files. -You can override these in the config: - -```yaml -# config/i18n-tasks.yml -base_locale: en -locales: [es, fr] # This includes base_locale by default -``` - -`internal_locale` controls the language i18n-tasks reports in. Locales available are `en` and `ru` (pull request to add more!). - -```yaml -internal_locale: en -``` +You can override these in the [config][config]. ### Storage The default data adapter supports YAML and JSON files. -```yaml -# config/i18n-tasks.yml -data: - # configure YAML / JSON serializer options - # passed directly to load / dump / parse / serialize. - yaml: - write: - # do not wrap lines at 80 characters (override default) - line_width: -1 -``` - #### Multiple locale files -Use `data` options to work with locale data spread over multiple files. - -`data.read` accepts a list of file globs to read from per-locale: - -``` -# config/i18n-tasks.yml -data: - read: - # read from namespaced files, e.g. simple_form.en.yml - - 'config/locales/*.%{locale}.yml' - # read from a gem (config is parsed with ERB first, then YAML) - - "<%= %x[bundle show vagrant].chomp %>/templates/locales/%{locale}.yml" - # default - - 'config/locales/%{locale}.yml' -``` - -#### Key pattern syntax - -| syntax | description | -|:------------:|:----------------------------------------------------------| -| `*` | matches everything | -| `:` | matches a single key | -| `{a, b.c}` | match any in set, can use `:` and `*`, match is captured | +i18n-tasks can manage multiple translation files and read translations from other gems. +To find out more the `data` options in the [config][config]. For writing to locale files i18n-tasks provides 2 options. @@ -266,7 +222,7 @@ data: Conservative router keeps the keys where they are found, or infers the path from base locale. If the key is completely new, conservative router will fall back to pattern router behaviour. -Conservative router is the default router. +Conservative router is the **default** router. ``` data: @@ -276,55 +232,26 @@ data: - 'config/locales/%{locale}.yml' ``` -#### Custom adapters - -If you store data somewhere but in the filesystem, e.g. in the database or mongodb, you can implement a custom adapter. -Implement [a handful of methods][adapter-example], then set `data.adapter` to the class name; the rest of the `data` config is passed to the initializer. +##### Key pattern syntax -```yaml -# config/i18n-tasks.yml -data: - # file_system is the default adapter, you can provide a custom class name here: - adapter: file_system -``` +A special syntax similar to file glob patterns is used throughout i18n-tasks to match translation keys: -### Usage search +| syntax | description | +|:------------:|:----------------------------------------------------------| +| `*` | matches everything | +| `:` | matches a single key | +| `{a, b.c}` | match any in set, can use `:` and `*`, match is captured | -Configure usage search in `config/i18n-tasks.yml`: +#### Custom adapters -```yaml -# config/i18n-tasks.yml -# i18n usage search in source -search: - # search these directories (relative to your Rails.root directory, default: 'app/') - paths: - - 'app/' - - 'vendor/' - # paths for relative key resolution: - relative_roots: - # default: - - app/views - # add a custom one: - - app/views-mobile - # include only files matching this glob pattern (default: blank = include all files) - include: - - '*.rb' - - '*.html.*' - - '*.text.*' - # explicitly exclude files (default: exclude common binary files) - exclude: - - '*.js' - # you can override the default key regex pattern: - pattern: "\\bt[( ]\\s*(:?\".+?\"|:?'.+?'|:\\w+)" - # comments are ignored by default - ignore_lines: - - "^\\s*[#/](?!\\si18n-tasks-use)" -``` +If you store data somewhere but in the filesystem, e.g. in the database or mongodb, you can implement a custom adapter. +If you have implemented a custom adapter please share it on [the wiki][wiki]. -It is also possible to use a custom key usage scanner by setting `search.scanner` to a class name. -See this basic [pattern scanner](/lib/i18n/tasks/scanners/pattern_scanner.rb) for reference. +### Usage search +See the `search` section in the [config file][config] for all available configuration options. +An example of a custom scanner can be found here: https://github.com/glebm/i18n-tasks/issues/138#issuecomment-87255708. ### Fine-tuning @@ -335,33 +262,8 @@ Add hints to static analysis with magic comment hints (lines starting with `(#|/ User.model_name.human ``` -You can also explicitly ignore keys appearing in locale files: - -```yaml -# config/i18n-tasks.yml -# do not report these keys as unused -ignore_unused: - - category.*.db_name - -# do not report these keys as missing (both on blank value and no key) -ignore_missing: - - devise.errors.unauthorized # ignore this key - - pagination.views.* # ignore the whole pattern - # E.g to ignore all Rails number / currency keys: - - 'number.{format, percentage.format, precision.format, human.format, currency.format}.{strip_insignificant_zeros,significant,delimiter}' - - 'time.{pm,am}' - -# do not report these keys when they have the same value as the base locale version -ignore_eq_base: - all: - - common.ok - es,fr: - - common.brand - -# do not report these keys ever -ignore: - - kaminari.* -``` +You can also explicitly ignore keys appearing in locale files via `ignore*` settings. +See the [config file][config] to find out more. ### Google Translate @@ -383,7 +285,7 @@ translation: api_key: ``` -## Interactive Console +## Interactive console `i18n-tasks irb` starts an IRB session in i18n-tasks context. Type `guide` for more information. @@ -395,12 +297,7 @@ Export missing and unused data to XLSX: $ i18n-tasks xlsx-report ``` -### HTML - -While i18n-tasks does not provide an HTML version of the report, you can add [one like this](https://gist.github.com/glebm/bdd3ab6d12d915f0c81b). - - -## Add New Tasks +## Add new tasks Tasks that come with the gem are defined in [lib/i18n/tasks/command/commands](lib/i18n/tasks/command/commands). @@ -430,6 +327,8 @@ Run with: $ i18n-tasks my-task ``` +See more examples of custom tasks [on the wiki](https://github.com/glebm/i18n-tasks/wiki#custom-tasks). + [MIT license]: /LICENSE.txt [travis]: https://travis-ci.org/glebm/i18n-tasks [badge-travis]: http://img.shields.io/travis/glebm/i18n-tasks.svg @@ -439,6 +338,8 @@ $ i18n-tasks my-task [badge-gemnasium]: https://gemnasium.com/glebm/i18n-tasks.svg [code-climate]: https://codeclimate.com/github/glebm/i18n-tasks [badge-code-climate]: http://img.shields.io/codeclimate/github/glebm/i18n-tasks.svg +[config]: https://github.com/glebm/i18n-tasks/blob/master/templates/config/i18n-tasks.yml +[wiki]: https://github.com/glebm/i18n-tasks/wiki "i18n-tasks wiki" [i18n-gem]: https://github.com/svenfuchs/i18n "svenfuchs/i18n on Github" [screenshot-find]: https://raw.github.com/glebm/i18n-tasks/master/doc/img/i18n-usages.png "i18n-tasks find output screenshot" [adapter-example]: https://github.com/glebm/i18n-tasks/blob/master/lib/i18n/tasks/data/file_system_base.rb diff --git a/bin/i18n-tasks b/bin/i18n-tasks index 471bb404..d7964860 100755 --- a/bin/i18n-tasks +++ b/bin/i18n-tasks @@ -8,49 +8,6 @@ if i18n_gem_config.respond_to?(:enforce_available_locales=) && i18n_gem_config.e i18n_gem_config.enforce_available_locales = true end -require 'i18n/tasks' -require 'i18n/tasks/commands' -require 'slop' +require 'i18n/tasks/cli' -err = proc { |message, exit_code| - if $stderr.isatty - $stderr.puts Term::ANSIColor.yellow('i18n-tasks: ' + message) - else - $stderr.puts message - end - exit exit_code -} - -begin - ran = false - commander = ::I18n::Tasks::Commands - instance = commander.new - instance.set_internal_locale! - slop_adapter = ::I18n::Tasks::SlopCommand - args = ARGV.dup - args = ['--help'] if args.empty? - Slop.parse(args, help: true) do - on('-v', '--version', 'Print the version') { - puts I18n::Tasks::VERSION - exit - } - commander.cmds.each do |name, attr| - slop_dsl = slop_adapter.slop_command(name, attr) { |_name, opts| - begin - ran = true - instance.safe_run name, opts - rescue Errno::EPIPE - # ignore Errno::EPIPE which is throw when pipe breaks, e.g.: - # i18n-tasks missing | head - exit 1 - end - } - instance_exec(&slop_dsl) - end - end -rescue Slop::Error => e - err.call(e.message, 64) -end - - -err.call("Command unknown: #{args[0]}", 64) if !ran && args[0] +I18n::Tasks::CLI.start(ARGV) diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml index 725418ac..7f0e0b41 100644 --- a/config/i18n-tasks.yml +++ b/config/i18n-tasks.yml @@ -4,86 +4,19 @@ # You can find the default config here: https://github.com/glebm/i18n-tasks/blob/master/templates/config/i18n-tasks.yml base_locale: en -## i18n-tasks detects locales automatically from the existing locale files -## uncomment to set locales explicitly -# locales: [en, es, fr] - -## i18n-tasks report locale, default: en, available: en, ru internal_locale: en -# Read and write locale data data: - ## by default, translation data are read from the file system, or you can provide a custom data adapter - # adapter: I18n::Tasks::Data::FileSystem - - # Locale files to read from read: - config/locales/%{locale}.yml - # - config/locales/*.%{locale}.yml - # - config/locales/**/*.%{locale}.yml - - # key => file routes, matched top to bottom write: - ## E.g., write devise and simple form keys to their respective files - # - ['{devise, simple_form}.*', 'config/locales/\1.%{locale}.yml'] - # Catch-all - config/locales/%{locale}.yml - # `i18n-tasks normalize -p` will force move the keys according to these rules - - # YAML / JSON serializer options, passed to load / dump / parse / serialize yaml: write: ## do not wrap lines at 80 characters (override default) line_width: 96 - json: - write: - # pretty print JSON - indent: ' ' - space: ' ' - object_nl: "\n" - array_nl: "\n" # Find translate calls search: - ## Default scanner finds t() and I18n.t() calls - # scanner: I18n::Tasks::Scanners::PatternWithScopeScanner - - ## Paths to search in, passed to File.find paths: - lib/ - - ## Root for resolving relative keys (default) - # relative_roots: - # - app/views - - ## File.fnmatch patterns to exclude from search (default) - # exclude: ["*.jpg", "*.png", "*.gif", "*.svg", "*.ico", "*.eot", "*.ttf", "*.woff", "*.pdf"] - - ## Or, File.fnmatch patterns to include - # include: ["*.rb", "*.html.slim"] - -## Google Translate -# translation: -# # Get an API key and set billing info at https://code.google.com/apis/console to use Google Translate -# api_key: "AbC-dEf5" - -## Consider these keys not missing -# ignore_missing: -# - pagination.views.* - -## Consider these keys used -# ignore_unused: -# - 'simple_form.{yes,no}' -# - 'simple_form.{placeholders,hints,labels}.*' -# - 'simple_form.{error_notification,required}.:' - -## Exclude these keys from `i18n-tasks eq-base' report -# ignore_eq_base: -# all: -# - common.ok -# fr,es: -# - common.brand - -## Exclude these keys from all of the reports -# ignore: -# - kaminari.* diff --git a/config/locales/en.yml b/config/locales/en.yml index f010fe10..186c7fd4 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -2,23 +2,24 @@ en: i18n_tasks: add_missing: - added: Added %{count} keys + added: + one: Added %{count} key + other: Added %{count} keys cmd: args: - default_all: 'Default: all' default_text: 'Default: %{value}' desc: confirm: Confirm automatically - data_format: 'Data format: %{valid_text}. %{default_text}.' + data_format: 'Data format: %{valid_text}.' key_pattern: Filter by key pattern (e.g. 'common.*') key_pattern_to_rename: Full key (pattern) to rename. Required - locale: 'Locale. Default: base' - locale_to_translate_from: 'Locale to translate from (default: base)' - locales_filter: 'Comma-separated list of locale(s) to process. Default: all. Special: base.' - missing_types: 'Filter by types: %{valid}. Default: all' + locale: Locale + locale_to_translate_from: Locale to translate from + locales_filter: "Locale(s) to process. Special: base" + missing_types: 'Filter by types: %{valid}' new_key_name: New name, interpolates original name as %{key}. Required nostdin: Do not read from stdin - out_format: 'Output format: %{valid_text}. %{default_text}.' + out_format: 'Output format: %{valid_text}' pattern_router: 'Use pattern router: keys moved per config data.write' strict: Do not infer dynamic key usage such as `t("category.\#{category.name}")` value: 'Value. Interpolates: %{value}, %{human_key}, %{value_or_human_key}' @@ -52,22 +53,19 @@ en: - Well done! - Perfect! enum_list_opt: - desc: 'Comma-separated list of: %{valid_text}. %{default_text}' invalid: "%{invalid} is not in: %{valid}." enum_opt: - desc: "%{valid_text}. %{default_text}" invalid: "%{invalid} is not one of: %{valid}." errors: - invalid_format: 'Unknown format %{invalid}. Valid: %{valid}.' - invalid_locale: Invalid locale %{invalid} + invalid_format: 'invalid format: %{invalid}. valid: %{valid}.' + invalid_locale: 'invalid locale: %{invalid}' invalid_missing_type: - one: 'Unknown type %{invalid}. Valid: %{valid}.' - other: 'Unknown types: %{invalid}. Valid: %{valid}.' - pass_forest: Pass locale forest + one: 'invalid type: %{invalid}. valid: %{valid}.' + other: 'unknown types: %{invalid}. valid: %{valid}.' + pass_forest: pass locale forest common: base_value: Base Value continue_q: Continue? - details: Details key: Key locale: Locale n_more: "%{count} more" @@ -88,6 +86,7 @@ en: health: no_keys_detected: No keys detected. Check data.read in config/i18n-tasks.yml. missing: + details_title: Value in other locales or source none: No translations are missing. remove_unused: confirm: diff --git a/config/locales/ru.yml b/config/locales/ru.yml index e103e3bd..f11bc988 100644 --- a/config/locales/ru.yml +++ b/config/locales/ru.yml @@ -5,7 +5,6 @@ ru: added: "Добавлены ключи (%{count})" cmd: args: - default_all: "По умолчанию: все" default_text: "По умолчанию: %{value}" desc: confirm: "Подтвердить автоматом" @@ -53,10 +52,8 @@ ru: - "Отлично!" - "Прекрасно!" enum_list_opt: - desc: "Разделенных запятыми список: %{valid_text}. %{default_text}" invalid: "%{invalid} не в: %{valid}." enum_opt: - desc: "%{valid_text}. %{default_text}" invalid: "%{invalid} не является одним из: %{valid}." errors: invalid_format: "Неизвестный формат %{invalid}. Форматы: %{valid}." @@ -68,7 +65,6 @@ ru: common: base_value: "Исходное значение" continue_q: "Продолжить?" - details: "Детали" key: "Ключ" locale: "Язык" n_more: "ещё %{count}" @@ -82,13 +78,14 @@ ru: title: "Данные (%{locales}):" google_translate: errors: - no_api_key: Задайте ключ API Google через переменную окружения GOOGLE_TRANSLATE_API_KEY - или translation.api_key в config/i18n-tasks.yml. Получите ключ через https://code.google.com/apis/console. + no_api_key: "Задайте ключ API Google через переменную окружения GOOGLE_TRANSLATE_API_KEY или + translation.api_key в config/i18n-tasks.yml. Получите ключ через https://code.google.com/apis/console." no_results: Google Translate не дал результатов. Убедитесь в том, что платежная информация добавлена в https://code.google.com/apis/console. health: no_keys_detected: "Ключи не обнаружены. Проверьте data.read в config/i18n-tasks.yml." missing: + details_title: "На других языках или в коде" none: "Всё переведено." remove_unused: confirm: diff --git a/i18n-tasks.gemspec b/i18n-tasks.gemspec index be0b72f1..c5fbd50d 100644 --- a/i18n-tasks.gemspec +++ b/i18n-tasks.gemspec @@ -39,7 +39,6 @@ TEXT s.add_dependency 'term-ansicolor' s.add_dependency 'terminal-table' s.add_dependency 'highline' - s.add_dependency 'slop', '>= 3.5.0' s.add_dependency 'i18n' s.add_development_dependency 'axlsx', '~> 2.0' s.add_development_dependency 'bundler', '~> 1.3' diff --git a/lib/i18n/tasks.rb b/lib/i18n/tasks.rb index ad473d9d..ed768c85 100644 --- a/lib/i18n/tasks.rb +++ b/lib/i18n/tasks.rb @@ -2,11 +2,22 @@ # define all the modules to be able to use :: module I18n module Tasks + class << self + def gem_path + File.expand_path(File.join(File.dirname(__FILE__), '..', '..')) + end - def self.gem_path - File.expand_path(File.join(File.dirname(__FILE__), '..', '..')) + def verbose? + @verbose + end + + def verbose=(value) + @verbose = value + end end + @verbose = !!ENV['VERBOSE'] + module Data end end @@ -15,6 +26,7 @@ module Data require 'active_support/core_ext/hash' require 'active_support/core_ext/string' +require 'active_support/core_ext/array/access' require 'active_support/core_ext/module/delegation' require 'active_support/core_ext/object/try' require 'active_support/core_ext/object/blank' diff --git a/lib/i18n/tasks/cli.rb b/lib/i18n/tasks/cli.rb new file mode 100644 index 00000000..fdae685c --- /dev/null +++ b/lib/i18n/tasks/cli.rb @@ -0,0 +1,198 @@ +require 'i18n/tasks' +require 'i18n/tasks/commands' +require 'optparse' + +class I18n::Tasks::CLI + include ::I18n::Tasks::Logging + + def self.start(argv) + new.start(argv) + end + + def initialize + end + + def start(argv) + auto_output_coloring do + begin + run(argv) + rescue OptionParser::ParseError => e + error e.message, 64 + rescue I18n::Tasks::CommandError => e + begin + error e.message, 78 + ensure + log_verbose e.backtrace * "\n" + end + rescue Errno::EPIPE + # ignore Errno::EPIPE which is throw when pipe breaks, e.g.: + # i18n-tasks missing | head + exit 1 + end + end + rescue ExecutionError => e + exit e.exit_code + end + + def run(argv) + name, *options = parse!(argv.dup) + context.run(name, *options) + end + + def context + @context ||= ::I18n::Tasks::Commands.new.tap(&:set_internal_locale!) + end + + def commands + @commands ||= ::I18n::Tasks::Commands.cmds.dup.tap do |cmds| + # Hash#transform_keys is only available since activesupport v4.0.0 + cmds.keys.each { |k| cmds[k.to_s.tr('_', '-')] = cmds.delete(k) } + end + end + + private + + def parse!(argv) + command = parse_command! argv + options = optparse! command, argv + parse_options! options, command, argv + [command.tr('-', '_'), options.update(arguments: argv)] + end + + def optparse!(command, argv) + if command + optparse_command!(command, argv) + else + optparse_no_command!(argv) + end + end + + def optparse_command!(command, argv) + cmd_conf = commands[command] + flags = cmd_conf[:args].dup + options = {} + OptionParser.new("Usage: #{program_name} #{command} [options] #{cmd_conf[:pos]}".strip) do |op| + flags.each do |flag| + op.on(*optparse_args(flag)) { |v| options[option_name(flag)] = v } + end + verbose_option op + help_option op + end.parse!(argv) + options + end + + def optparse_no_command!(argv) + argv << '--help' if argv.empty? + OptionParser.new("Usage: #{program_name} [command] [options]") do |op| + op.on('-v', '--version', 'Print the version') do + puts I18n::Tasks::VERSION + exit + end + help_option op + commands_summary op + end.parse!(argv) + end + + def allow_help_arg_first!(argv) + # allow `i18n-tasks --help command` in addition to `i18n-tasks command --help` + argv[0], argv[1] = argv[1], argv[0] if %w(-h --help).include?(argv[0]) && argv[1] && !argv[1].start_with?('-') + end + + def parse_command!(argv) + allow_help_arg_first! argv + if argv[0] && !argv[0].start_with?('-') + if commands.keys.include?(argv[0]) + argv.shift + else + error "unknown command: #{argv[0]}", 64 + end + end + end + + def verbose_option(op) + op.on('--verbose', 'Verbose output') { + ::I18n::Tasks.verbose = true + } + end + + def help_option(op) + op.on('-h', '--help', 'Show this message') do + $stderr.puts op + exit + end + end + + # @param [OptionParser] op + def commands_summary(op) + op.separator '' + op.separator 'Available commands:' + op.separator '' + commands.each do |cmd, cmd_conf| + op.separator " #{cmd.ljust(op.summary_width + 1, ' ')}#{try_call cmd_conf[:desc]}" + end + op.separator '' + op.separator 'See `i18n-tasks --help` for more information on a specific command.' + end + + def optparse_args(flag) + args = flag.dup + args.map! { |v| try_call v } + conf = args.extract_options! + if conf.key?(:default) + args[-1] = "#{args[-1]}. #{I18n.t('i18n_tasks.cmd.args.default_text', value: conf[:default])}" + end + args + end + + def parse_options!(options, command, argv) + commands[command][:args].each do |flag| + name = option_name flag + options[name] = parse_option flag, options[name], argv, self.context + end + end + + def parse_option(flag, val, argv, context) + conf = flag.last.is_a?(Hash) ? flag.last : {} + if conf[:consume_positional] + val = Array(val) + Array(flag.include?(Array) ? argv.flat_map { |x| x.split(',') } : argv) + end + val = conf[:default] if val.nil? && conf.key?(:default) + val = conf[:parser].call(val, context) if conf.key?(:parser) + val + end + + def option_name(flag) + flag.detect { |f| f.start_with?('--') }.sub(/\A--/, '').sub(/[^\-\w].*\z/, '').to_sym + end + + def try_call(v) + if v.respond_to? :call + v.call + else + v + end + end + + def error(message, exit_code) + log_error message + fail ExecutionError.new(message, exit_code) + end + + class ExecutionError < Exception + attr_reader :exit_code + + def initialize(message, exit_code) + super(message) + @exit_code = exit_code + end + end + + def auto_output_coloring(coloring = ENV['I18N_TASKS_COLOR'] || STDOUT.isatty) + coloring_was = Term::ANSIColor.coloring? + Term::ANSIColor.coloring = coloring + yield + ensure + Term::ANSIColor.coloring = coloring_was + end + +end diff --git a/lib/i18n/tasks/command/collection.rb b/lib/i18n/tasks/command/collection.rb index 2e6e3b95..b284cf5a 100644 --- a/lib/i18n/tasks/command/collection.rb +++ b/lib/i18n/tasks/command/collection.rb @@ -1,6 +1,6 @@ require 'i18n/tasks/command/options/common' require 'i18n/tasks/command/options/locales' -require 'i18n/tasks/command/options/trees' +require 'i18n/tasks/command/options/data' module I18n::Tasks module Command @@ -10,7 +10,7 @@ def self.included(base) include Command::DSL include Command::Options::Common include Command::Options::Locales - include Command::Options::Trees + include Command::Options::Data end end end diff --git a/lib/i18n/tasks/command/commander.rb b/lib/i18n/tasks/command/commander.rb index a2b8ad6a..dbba92bf 100644 --- a/lib/i18n/tasks/command/commander.rb +++ b/lib/i18n/tasks/command/commander.rb @@ -1,5 +1,5 @@ # coding: utf-8 -require 'i18n/tasks/slop_command' +require 'i18n/tasks/cli' require 'i18n/tasks/reports/terminal' require 'i18n/tasks/reports/spreadsheet' @@ -12,29 +12,13 @@ def initialize(i18n = nil) @i18n = i18n end - def safe_run(name, opts) - begin - coloring_was = Term::ANSIColor.coloring? - Term::ANSIColor.coloring = ENV['I18N_TASKS_COLOR'] || STDOUT.isatty - run name, opts - rescue CommandError => e - log_error e.message - log_verbose e.backtrace * "\n" - exit 78 - ensure - Term::ANSIColor.coloring = coloring_was - end - end - def run(name, opts = {}) name = name.to_sym public_name = name.to_s.tr '_', '-' - SlopCommand.parse_opts! opts, self.class.cmds[name][:opt], self - if opts.empty? - log_verbose "run #{public_name} without arguments" + log_verbose "task: #{public_name}(#{opts.map { |k, v| "#{k}: #{v.inspect}" } * ', '})" + if opts.empty? || method(name).arity.zero? send name else - log_verbose "run #{public_name} with #{opts.map { |k, v| "#{k}=#{v}" } * ' '}" send name, opts end end @@ -53,15 +37,11 @@ def spreadsheet_report @spreadsheet_report ||= I18n::Tasks::Reports::Spreadsheet.new(i18n) end - def desc(name) - self.class.cmds.try(:[], name).try(:desc) - end - def i18n @i18n ||= I18n::Tasks::BaseTask.new end - delegate :base_locale, :t, to: :i18n + delegate :base_locale, :locales, :t, to: :i18n end end end diff --git a/lib/i18n/tasks/command/commands/data.rb b/lib/i18n/tasks/command/commands/data.rb index c6d45517..18058bac 100644 --- a/lib/i18n/tasks/command/commands/data.rb +++ b/lib/i18n/tasks/command/commands/data.rb @@ -4,60 +4,58 @@ module Commands module Data include Command::Collection - cmd_opt :pattern_router, { - short: :p, - long: :pattern_router, - desc: t('i18n_tasks.cmd.args.desc.pattern_router'), - conf: {argument: false, optional: true} - } + arg :pattern_router, + '-p', + '--pattern_router', + t('i18n_tasks.cmd.args.desc.pattern_router') cmd :normalize, - args: '[locale ...]', + pos: '[locale ...]', desc: t('i18n_tasks.cmd.desc.normalize'), - opt: cmd_opts(:locales, :pattern_router) + args: [:locales, :pattern_router] def normalize(opt = {}) i18n.normalize_store! opt[:locales], opt[:pattern_router] end cmd :data, - args: '[locale ...]', + pos: '[locale ...]', desc: t('i18n_tasks.cmd.desc.data'), - opt: cmd_opts(:locales, :out_format) + args: [:locales, :out_format] def data(opt = {}) print_forest i18n.data_forest(opt[:locales]), opt end cmd :data_merge, - args: '[tree ...]', + pos: '[tree ...]', desc: t('i18n_tasks.cmd.desc.data_merge'), - opt: cmd_opts(:data_format, :nostdin) + args: [:data_format, :nostdin] def data_merge(opt = {}) - forest = opt_forests_merged_stdin_args!(opt) + forest = merge_forests_stdin_and_pos!(opt) merged = i18n.data.merge!(forest) print_forest merged, opt end cmd :data_write, - args: '[tree]', + pos: '[tree]', desc: t('i18n_tasks.cmd.desc.data_write'), - opt: cmd_opts(:data_format, :nostdin) + args: [:data_format, :nostdin] def data_write(opt = {}) - forest = opt_forest_arg_or_stdin!(opt) + forest = forest_pos_or_stdin!(opt) i18n.data.write forest print_forest forest, opt end cmd :data_remove, - args: '[tree]', + pos: '[tree]', desc: t('i18n_tasks.cmd.desc.data_remove'), - opt: cmd_opts(:data_format, :nostdin) + args: [:data_format, :nostdin] def data_remove(opt = {}) - removed = i18n.data.remove_by_key!(opt_forest_arg_or_stdin!(opt)) + removed = i18n.data.remove_by_key!(forest_pos_or_stdin!(opt)) log_stderr 'Removed:' print_forest removed, opt end diff --git a/lib/i18n/tasks/command/commands/eq_base.rb b/lib/i18n/tasks/command/commands/eq_base.rb index 6cd00a40..30a8d494 100644 --- a/lib/i18n/tasks/command/commands/eq_base.rb +++ b/lib/i18n/tasks/command/commands/eq_base.rb @@ -5,9 +5,9 @@ module EqBase include Command::Collection cmd :eq_base, - args: '[locale ...]', + pos: '[locale ...]', desc: t('i18n_tasks.cmd.desc.eq_base'), - opt: cmd_opts(:locales, :out_format) + args: [:locales, :out_format] def eq_base(opt = {}) print_forest i18n.eq_base_keys(opt), opt, :eq_base_keys diff --git a/lib/i18n/tasks/command/commands/health.rb b/lib/i18n/tasks/command/commands/health.rb index 693cfad3..d4cd60ae 100644 --- a/lib/i18n/tasks/command/commands/health.rb +++ b/lib/i18n/tasks/command/commands/health.rb @@ -5,9 +5,9 @@ module Health include Command::Collection cmd :health, - args: '[locale ...]', + pos: '[locale ...]', desc: t('i18n_tasks.cmd.desc.health'), - opt: cmd_opts(:locales, :out_format) + args: [:locales, :out_format] def health(opt = {}) forest = i18n.data_forest(opt[:locales]) diff --git a/lib/i18n/tasks/command/commands/meta.rb b/lib/i18n/tasks/command/commands/meta.rb index a789db2e..bdce5c94 100644 --- a/lib/i18n/tasks/command/commands/meta.rb +++ b/lib/i18n/tasks/command/commands/meta.rb @@ -5,12 +5,12 @@ module Meta include Command::Collection cmd :config, - args: '[section ...]', + pos: '[section ...]', desc: t('i18n_tasks.cmd.desc.config') def config(opts = {}) cfg = i18n.config_for_inspect - cfg = cfg.slice(*opts[:arguments]) if opts[:arguments] + cfg = cfg.slice(*opts[:arguments]) if opts[:arguments].present? cfg = cfg.to_yaml cfg.sub! /\A---\n/, '' cfg.gsub! /^([^\s-].+?:)/, Term::ANSIColor.cyan(Term::ANSIColor.bold('\1')) diff --git a/lib/i18n/tasks/command/commands/missing.rb b/lib/i18n/tasks/command/commands/missing.rb index fbab1ffd..0b0f219d 100644 --- a/lib/i18n/tasks/command/commands/missing.rb +++ b/lib/i18n/tasks/command/commands/missing.rb @@ -4,25 +4,30 @@ module Commands module Missing include Command::Collection - enum_opt :missing_types, I18n::Tasks::MissingKeys.missing_keys_types - cmd_opt :missing_types, enum_list_opt_attr( - :t, :types=, enum_opt(:missing_types), - proc { |valid, default| t('i18n_tasks.cmd.args.desc.missing_types', valid: valid, default: default) }, - proc { |invalid, valid| t('i18n_tasks.cmd.errors.invalid_missing_type', invalid: invalid * ', ', valid: valid * ', ', count: invalid.length) }) + missing_types = I18n::Tasks::MissingKeys.missing_keys_types + arg :missing_types, + '-t', + "--types #{missing_types * ','}", + Array, + t('i18n_tasks.cmd.args.desc.missing_types', valid: missing_types * ', '), + parser: OptionParsers::Enum::ListParser.new( + missing_types, + proc { |invalid, valid| I18n.t('i18n_tasks.cmd.errors.invalid_missing_type', + invalid: invalid * ', ', valid: valid * ', ', count: invalid.length) }) cmd :missing, - args: '[locale ...]', + pos: '[locale ...]', desc: t('i18n_tasks.cmd.desc.missing'), - opt: cmd_opts(:locales, :out_format, :missing_types) + args: [:locales, :out_format, :missing_types] def missing(opt = {}) print_forest i18n.missing_keys(opt), opt, :missing_keys end cmd :translate_missing, - args: '[locale ...]', + pos: '[locale ...]', desc: t('i18n_tasks.cmd.desc.translate_missing'), - opt: cmd_opts(:locales, :locale_to_translate_from) << cmd_opt(:out_format).except(:short) + args: [:locales, :locale_to_translate_from, arg(:out_format).from(1)] def translate_missing(opt = {}) missing = i18n.missing_diff_forest opt[:locales], opt[:from] @@ -32,17 +37,19 @@ def translate_missing(opt = {}) print_forest translated, opt end - DEFAULT_ADD_MISSING_VALUE = '%{value_or_human_key}' - cmd :add_missing, - args: '[locale ...]', + pos: '[locale ...]', desc: t('i18n_tasks.cmd.desc.add_missing'), - opt: cmd_opts(:locales, :out_format) << - cmd_opt(:value).merge(desc: proc { "#{cmd_opt(:value)[:desc].call}. #{t('i18n_tasks.cmd.args.default_text', value: DEFAULT_ADD_MISSING_VALUE)}" }) + args: [:locales, :out_format, arg(:value) + [{default: '%{value_or_human_key}'}]] def add_missing(opt = {}) - forest = i18n.missing_keys(opt).set_each_value!(opt[:value] || DEFAULT_ADD_MISSING_VALUE) + forest = i18n.missing_keys(opt).set_each_value!(opt[:value]) i18n.data.merge! forest + # missing keys detected in the source are only returned in the base locale tree + # merge again in case such keys have been added to add them to other locales + forest_2 = i18n.missing_keys(opt).set_each_value!(opt[:value]) + i18n.data.merge! forest_2 + forest.merge! forest_2 log_stderr t('i18n_tasks.add_missing.added', count: forest.leaves.count) print_forest forest, opt end diff --git a/lib/i18n/tasks/command/commands/tree.rb b/lib/i18n/tasks/command/commands/tree.rb index df8e7490..efa08da9 100644 --- a/lib/i18n/tasks/command/commands/tree.rb +++ b/lib/i18n/tasks/command/commands/tree.rb @@ -5,55 +5,50 @@ module Tree include Command::Collection cmd :tree_translate, - args: '[tree]', + pos: '[tree (or stdin)]', desc: t('i18n_tasks.cmd.desc.tree_translate'), - opt: cmd_opts(:locale_to_translate_from) << cmd_opt(:data_format).except(:short) + args: [:locale_to_translate_from, arg(:data_format).from(1)] def tree_translate(opts = {}) - forest = opt_forest_arg_or_stdin!(opts) - from = opts[:from] - translated = i18n.google_translate_forest(forest, from) - print_forest translated, opts + forest = forest_pos_or_stdin!(opts) + print_forest i18n.google_translate_forest(forest, opts[:from]), opts end cmd :tree_merge, - args: '[tree ...]', + pos: '[[tree] [tree] ... (or stdin)]', desc: t('i18n_tasks.cmd.desc.tree_merge'), - opt: cmd_opts(:data_format, :nostdin) + args: [:data_format, :nostdin] def tree_merge(opts = {}) - print_forest opt_forests_merged_stdin_args!(opts), opts + print_forest merge_forests_stdin_and_pos!(opts), opts end cmd :tree_filter, - args: '[pattern] [tree]', + pos: '[pattern] [tree (or stdin)]', desc: t('i18n_tasks.cmd.desc.tree_filter'), - opt: cmd_opts(:data_format, :pattern) + args: [:data_format, :pattern] - def tree_filter(opt = {}) - pattern = opt_or_arg! :pattern, opt - forest = opt_forest_arg_or_stdin!(opt) + def tree_filter(opts = {}) + pattern = arg_or_pos! :pattern, opts + forest = forest_pos_or_stdin! opts unless pattern.blank? pattern_re = i18n.compile_key_pattern(pattern) forest = forest.select_keys { |full_key, _node| full_key =~ pattern_re } end - print_forest forest, opt + print_forest forest, opts end cmd :tree_rename_key, - args: ' [tree]', + pos: 'KEY_PATTERN NAME [tree (or stdin)]', desc: t('i18n_tasks.cmd.desc.tree_rename_key'), - opt: [ - cmd_opt(:pattern).merge(short: :k, long: :key=, desc: proc { - t('i18n_tasks.cmd.args.desc.key_pattern_to_rename') }), - cmd_opt(:pattern).merge(short: :n, long: :name=, desc: proc { - t('i18n_tasks.cmd.args.desc.new_key_name') }) - ] + cmd_opts(:data_format) + args: [['-k', '--key KEY_PATTERN', t('i18n_tasks.cmd.args.desc.key_pattern_to_rename')], + ['-n', '--name NAME', t('i18n_tasks.cmd.args.desc.new_key_name')], + :data_format] def tree_rename_key(opt = {}) - key = opt_or_arg! :key, opt - name = opt_or_arg! :name, opt - forest = opt_forest_arg_or_stdin! opt + key = arg_or_pos! :key, opt + name = arg_or_pos! :name, opt + forest = forest_pos_or_stdin! opt raise CommandError.new('pass full key to rename (-k, --key)') if key.blank? raise CommandError.new('pass new name (-n, --name)') if name.blank? forest.rename_each_key!(key, name) @@ -61,24 +56,24 @@ def tree_rename_key(opt = {}) end cmd :tree_subtract, - args: '[tree A] [tree B ...]', + pos: '[[tree] [tree] ... (or stdin)]', desc: t('i18n_tasks.cmd.desc.tree_subtract'), - opt: cmd_opts(:data_format, :nostdin) + args: [:data_format, :nostdin] def tree_subtract(opt = {}) - forests = opt_forests_stdin_args! opt, 2 + forests = forests_stdin_and_pos! opt, 2 forest = forests.reduce(:subtract_by_key) || empty_forest print_forest forest, opt end cmd :tree_set_value, - args: '[value] [tree]', + pos: '[VALUE] [tree (or stdin)]', desc: t('i18n_tasks.cmd.desc.tree_set_value'), - opt: cmd_opts(:value, :data_format, :nostdin, :pattern) + args: [:value, :data_format, :nostdin, :pattern] def tree_set_value(opt = {}) - value = opt_or_arg! :value, opt - forest = opt_forest_arg_or_stdin!(opt) + value = arg_or_pos! :value, opt + forest = forest_pos_or_stdin!(opt) key_pattern = opt[:pattern] raise CommandError.new('pass value (-v, --value)') if value.blank? forest.set_each_value!(value, key_pattern) @@ -86,13 +81,13 @@ def tree_set_value(opt = {}) end cmd :tree_convert, - args: '', + pos: '[tree (or stdin)]', desc: t('i18n_tasks.cmd.desc.tree_convert'), - opt: [cmd_opt(:data_format).merge(short: :f, long: :from=), - cmd_opt(:out_format).merge(short: :t, long: :to=)] + args: [arg(:data_format).dup.tap { |a| a[0..1] = ['-f', '--from FORMAT'] }, + arg(:out_format).dup.tap { |a| a[0..1] = ['-t', '--to FORMAT'] }] def tree_convert(opt = {}) - forest = opt_forest_arg_or_stdin! opt.merge(format: opt[:from]) + forest = forest_pos_or_stdin! opt.merge(format: opt[:from]) print_forest forest, opt.merge(format: opt[:to]) end end diff --git a/lib/i18n/tasks/command/commands/usages.rb b/lib/i18n/tasks/command/commands/usages.rb index 3130a13d..dc707b3f 100644 --- a/lib/i18n/tasks/command/commands/usages.rb +++ b/lib/i18n/tasks/command/commands/usages.rb @@ -4,16 +4,15 @@ module Commands module Usages include Command::Collection - cmd_opt :strict, { - short: :s, - long: :strict, - desc: t('i18n_tasks.cmd.args.desc.strict') - } + arg :strict, + '-s', + '--strict', + t('i18n_tasks.cmd.args.desc.strict') cmd :find, - args: '[pattern]', + pos: '[pattern]', desc: t('i18n_tasks.cmd.desc.find'), - opt: cmd_opts(:out_format, :pattern) + args: [:out_format, :pattern] def find(opt = {}) opt[:filter] ||= opt.delete(:pattern) || opt[:arguments].try(:first) @@ -21,18 +20,18 @@ def find(opt = {}) end cmd :unused, - args: '[locale ...]', + pos: '[locale ...]', desc: t('i18n_tasks.cmd.desc.unused'), - opt: cmd_opts(:locales, :out_format, :strict) + args: [:locales, :out_format, :strict] def unused(opt = {}) print_forest i18n.unused_keys(opt), opt, :unused_keys end cmd :remove_unused, - args: '[locale ...]', + pos: '[locale ...]', desc: t('i18n_tasks.cmd.desc.remove_unused'), - opt: cmd_opts(:locales, :out_format, :strict, :confirm) + args: [:locales, :out_format, :strict, :confirm] def remove_unused(opt = {}) unused_keys = i18n.unused_keys(opt) diff --git a/lib/i18n/tasks/command/commands/xlsx.rb b/lib/i18n/tasks/command/commands/xlsx.rb index 5b50b4b3..36601372 100644 --- a/lib/i18n/tasks/command/commands/xlsx.rb +++ b/lib/i18n/tasks/command/commands/xlsx.rb @@ -5,10 +5,10 @@ module XLSX include Command::Collection cmd :xlsx_report, - args: '[locale...]', + pos: '[locale...]', desc: t('i18n_tasks.cmd.desc.xlsx_report'), - opt: [cmd_opt(:locales), - {short: :p, long: :path=, desc: 'Destination path', conf: {default: 'tmp/i18n-report.xlsx'}}] + args: [:locales, + ['-p', '--path PATH', 'Destination path', default: 'tmp/i18n-report.xlsx']] def xlsx_report(opt = {}) begin diff --git a/lib/i18n/tasks/command/dsl.rb b/lib/i18n/tasks/command/dsl.rb index 393f4d7e..05ff267e 100644 --- a/lib/i18n/tasks/command/dsl.rb +++ b/lib/i18n/tasks/command/dsl.rb @@ -1,7 +1,3 @@ -require 'i18n/tasks/command/dsl/cmd' -require 'i18n/tasks/command/dsl/cmd_opt' -require 'i18n/tasks/command/dsl/enum_opt' - module I18n::Tasks module Command module DSL @@ -19,9 +15,28 @@ def t(*args) end module ClassMethods - include DSL::Cmd - include DSL::CmdOpt - include DSL::EnumOpt + def cmd(name, conf = nil) + if conf + conf = conf.dup + conf[:args] = (args = conf[:args]) ? args.map { |arg| Symbol === arg ? arg(arg) : arg } : [] + + dsl(:cmds)[name] = conf + else + dsl(:cmds)[name] + end + end + + def arg(ref, *args) + if args.present? + dsl(:args)[ref] = args + else + dsl(:args)[ref] + end + end + + def cmds + dsl(:cmds) + end def dsl(key) @dsl[key] diff --git a/lib/i18n/tasks/command/dsl/cmd.rb b/lib/i18n/tasks/command/dsl/cmd.rb deleted file mode 100644 index e33ad9c1..00000000 --- a/lib/i18n/tasks/command/dsl/cmd.rb +++ /dev/null @@ -1,19 +0,0 @@ -module I18n::Tasks - module Command - module DSL - module Cmd - def cmd(name, args = nil) - if args - dsl(:cmds)[name] = args - else - dsl(:cmds)[name] - end - end - - def cmds - dsl(:cmds) - end - end - end - end -end diff --git a/lib/i18n/tasks/command/dsl/cmd_opt.rb b/lib/i18n/tasks/command/dsl/cmd_opt.rb deleted file mode 100644 index 45f6a567..00000000 --- a/lib/i18n/tasks/command/dsl/cmd_opt.rb +++ /dev/null @@ -1,19 +0,0 @@ -module I18n::Tasks - module Command - module DSL - module CmdOpt - def cmd_opts(*args) - dsl(:cmd_opts).values_at(*args) - end - - def cmd_opt(arg, opts = nil) - if opts - dsl(:cmd_opts)[arg] = opts - else - dsl(:cmd_opts)[arg] - end - end - end - end - end -end diff --git a/lib/i18n/tasks/command/dsl/enum_opt.rb b/lib/i18n/tasks/command/dsl/enum_opt.rb deleted file mode 100644 index a3e9587e..00000000 --- a/lib/i18n/tasks/command/dsl/enum_opt.rb +++ /dev/null @@ -1,45 +0,0 @@ -module I18n::Tasks - module Command - module DSL - module EnumOpt - def enum_opt(name, list = nil) - if list - dsl(:enum_valid)[name] = list - else - dsl(:enum_valid)[name] - end - end - - DEFAULT_ENUM_OPT_DESC = proc { |valid, default| - I18n.t('i18n_tasks.cmd.enum_opt.desc', valid_text: valid, default_text: default) - } - - def enum_opt_attr(short, long, valid, desc, error_msg) - desc ||= DEFAULT_ENUM_OPT_DESC - desc_proc = proc { desc.call(valid * ', ', I18n.t('i18n_tasks.cmd.args.default_text', value: valid.first)) } - {short: short, long: long, desc: desc_proc, - conf: {default: valid.first, argument: true, optional: false}, - parse: enum_parse_proc(:parse_enum_opt, valid, &error_msg)} - end - - DEFAULT_LIST_OPT_DESC = proc { |valid, default| - I18n.t('i18n_tasks.cmd.enum_list_opt.desc', valid_text: valid, default_text: default) - } - - def enum_list_opt_attr(short, long, valid, desc, error_msg) - desc ||= DEFAULT_LIST_OPT_DESC - desc_proc = proc { desc.call(valid * ', ', I18n.t('i18n_tasks.cmd.args.default_all')) } - {short: short, long: long, desc: desc_proc, - conf: {as: Array, delimiter: /\s*[+:,]\s*/}, - parse: enum_parse_proc(:parse_enum_list_opt, valid, &error_msg)} - end - - def enum_parse_proc(method, valid, &error) - proc { |opt, key| - opt[key] = send(method, opt[key], valid, &error) - } - end - end - end - end -end diff --git a/lib/i18n/tasks/command/option_parsers/enum.rb b/lib/i18n/tasks/command/option_parsers/enum.rb new file mode 100644 index 00000000..b53d86ec --- /dev/null +++ b/lib/i18n/tasks/command/option_parsers/enum.rb @@ -0,0 +1,53 @@ +module I18n::Tasks + module Command + module OptionParsers + module Enum + class Parser + DEFAULT_ERROR = proc { |invalid, valid| + I18n.t('i18n_tasks.cmd.enum_opt.invalid', invalid: invalid, valid: valid * ', ') + } + + def initialize(valid, error_message = DEFAULT_ERROR) + @valid = valid.map(&:to_s) + @error_message = error_message + end + + def call(value, *) + return @valid.first unless value.present? + if @valid.include?(value) + value + else + raise CommandError.new @error_message.call(value, @valid) + end + end + end + + class ListParser + DEFAULT_ERROR = proc { |invalid, valid| + I18n.t('i18n_tasks.cmd.enum_list_opt.invalid', invalid: invalid * ', ', valid: valid * ', ') + } + + def initialize(valid, error_message = DEFAULT_ERROR) + @valid = valid.map(&:to_s) + @error_message = error_message + end + + def call(values, *) + values = Array(values) + return @valid if values == %w(all) + invalid = values - @valid + if invalid.empty? + if values.empty? + @valid + else + values + end + else + raise CommandError.new @error_message.call(invalid, @valid) + end + end + end + end + end + end +end diff --git a/lib/i18n/tasks/command/option_parsers/locale.rb b/lib/i18n/tasks/command/option_parsers/locale.rb new file mode 100644 index 00000000..ad132746 --- /dev/null +++ b/lib/i18n/tasks/command/option_parsers/locale.rb @@ -0,0 +1,48 @@ +module I18n::Tasks + module Command + module OptionParsers + module Locale + module Validator + VALID_LOCALE_RE = /\A\w[\w\-\.]*\z/i + + def validate!(locale) + if VALID_LOCALE_RE !~ locale + raise CommandError.new(I18n.t('i18n_tasks.cmd.errors.invalid_locale', invalid: locale)) + end + locale + end + end + + module Parser + module_function + extend Validator + + # @param [#base_locale, #locales] context + def call(val, context) + if val.blank? || val == 'base' + context.base_locale + else + validate! val + end + end + end + + module ListParser + module_function + extend Validator + + # @param [#base_locale,#locales] context + def call(vals, context) + if vals == %w(all) || vals.blank? + context.locales + else + vals.map { |v| v == 'base' ? context.base_locale : v } + end.tap do |locales| + locales.each { |locale| validate! locale } + end + end + end + end + end + end +end diff --git a/lib/i18n/tasks/command/options/common.rb b/lib/i18n/tasks/command/options/common.rb index 8781e109..57c41c98 100644 --- a/lib/i18n/tasks/command/options/common.rb +++ b/lib/i18n/tasks/command/options/common.rb @@ -1,46 +1,36 @@ -require 'i18n/tasks/command/options/enum_opt' -require 'i18n/tasks/command/options/list_opt' module I18n::Tasks module Command module Options module Common include Command::DSL - include Options::EnumOpt - include Options::ListOpt - VALID_LOCALE_RE = /\A\w[\w\-\.]*\z/i + arg :nostdin, + '-S', + '--nostdin', + t('i18n_tasks.cmd.args.desc.nostdin') - cmd_opt :nostdin, { - short: :S, - long: :nostdin, - desc: t('i18n_tasks.cmd.args.desc.nostdin'), - conf: {default: false} - } + arg :confirm, + '-y', + '--confirm', + desc: t('i18n_tasks.cmd.args.desc.confirm') - cmd_opt :confirm, { - short: :y, - long: :confirm, - desc: t('i18n_tasks.cmd.args.desc.confirm'), - conf: {default: false} - } + arg :pattern, + '-p', + '--pattern PATTERN', + t('i18n_tasks.cmd.args.desc.key_pattern') - cmd_opt :pattern, { - short: :p, - long: :pattern=, - desc: t('i18n_tasks.cmd.args.desc.key_pattern'), - conf: {argument: true, optional: false} - } + arg :value, + '-v', + '--value VALUE', + t('i18n_tasks.cmd.args.desc.value') - cmd_opt :value, { - short: :v, - long: :value=, - desc: t('i18n_tasks.cmd.args.desc.value'), - conf: {argument: true, optional: false} - } + def arg_or_pos!(key, opts) + opts[key] ||= opts[:arguments].try(:shift) + end - def opt_or_arg!(key, opt) - opt[key] ||= opt[:arguments].try(:shift) + def pos_or_stdin!(opts) + opts[:arguments].try(:shift) || $stdin.read end end end diff --git a/lib/i18n/tasks/command/options/data.rb b/lib/i18n/tasks/command/options/data.rb new file mode 100644 index 00000000..2ebc8b99 --- /dev/null +++ b/lib/i18n/tasks/command/options/data.rb @@ -0,0 +1,91 @@ +require 'i18n/tasks/command/option_parsers/enum' + +module I18n::Tasks + module Command + module Options + module Data + include Command::DSL + + DATA_FORMATS = %w(yaml json keys) + OUT_FORMATS = ['terminal-table', *DATA_FORMATS, 'inspect'] + + format_arg = proc { |type, values| + default = values.first + arg type, + '-f', + '--format FORMAT', + values, + {'yml' => 'yaml'}, + t("i18n_tasks.cmd.args.desc.#{type}", valid_text: values * ', ', default_text: default), + parser: OptionParsers::Enum::Parser.new(values, + proc { |value, valid| + I18n.t('i18n_tasks.cmd.errors.invalid_format', + invalid: value, valid: valid * ', ') }) + } + + # i18n-tasks-use t('i18n_tasks.cmd.args.desc.data_format') + format_arg.call(:data_format, DATA_FORMATS) + + # i18n-tasks-use t('i18n_tasks.cmd.args.desc.out_format') + format_arg.call(:out_format, OUT_FORMATS) + + def forest_pos_or_stdin!(opt, format = opt[:format]) + parse_forest(pos_or_stdin!(opt), format) + end + + # @return [Array] trees read from stdin and positional arguments + def forests_stdin_and_pos!(opt, num = false, format = opt[:format]) + args = opt[:arguments] || [] + if opt[:nostdin] + sources = [] + else + sources = [$stdin.read] + num -= 1 if num + end + if num + num.times { sources << args.shift } + else + sources += args + args.clear + end + sources.map { |src| parse_forest(src, format) } + end + + def merge_forests_stdin_and_pos!(opt) + forests_stdin_and_pos!(opt).inject(i18n.empty_forest) { |result, forest| + result.merge! forest + } + end + + def parse_forest(src, format) + if !src + raise CommandError.new(I18n.t('i18n_tasks.cmd.errors.pass_forest')) + end + if format == 'keys' + ::I18n::Tasks::Data::Tree::Siblings.from_key_names parse_keys(src) + else + ::I18n::Tasks::Data::Tree::Siblings.from_nested_hash i18n.data.adapter_parse(src, format) + end + end + + def parse_keys(src) + Array(src).compact.flat_map { |v| v.strip.split(/\s*[,\s\n]\s*/) }.map(&:presence).compact + end + + def print_forest(forest, opts, version = :show_tree) + format = opts[:format].to_s + case format + when 'terminal-table' + terminal_report.send(version, forest) + when 'inspect' + puts forest.inspect + when 'keys' + puts forest.key_names(root: true) + when *DATA_FORMATS + puts i18n.data.adapter_dump forest.to_hash(true), format + end + end + end + end + end +end diff --git a/lib/i18n/tasks/command/options/enum_opt.rb b/lib/i18n/tasks/command/options/enum_opt.rb deleted file mode 100644 index 598e75b2..00000000 --- a/lib/i18n/tasks/command/options/enum_opt.rb +++ /dev/null @@ -1,50 +0,0 @@ -module I18n::Tasks - module Command - module Options - module EnumOpt - DEFAULT_ENUM_OPT_ERROR = proc { |bad, good| - I18n.t('i18n_tasks.cmd.enum_opt.invalid', invalid: bad, valid: good * ', ') - } - - def parse_enum_opt(value, valid, &error_msg) - valid = enum_opt(valid) if Symbol === valid - return valid.first unless value.present? - if enum_opt_valid?(valid, value) - value - else - error_msg ||= DEFAULT_ENUM_OPT_ERROR - raise CommandError.new error_msg.call(value, valid) - end - end - - def enum_opt_valid?(valid, value) - valid = enum_opt(valid) if Symbol === valid - valid.include?(value) - end - - DEFAULT_ENUM_LIST_ERROR = proc { |bad, good| - I18n.t('i18n_tasks.cmd.enum_list_opt.invalid', invalid: bad * ', ', valid: good * ', ') - } - - def parse_enum_list_opt(values, valid, &error_msg) - values = explode_list_opt(values) - invalid = values - valid.map(&:to_s) - if invalid.empty? - if values.empty? - valid - else - values - end - else - error_msg ||= DEFAULT_ENUM_LIST_ERROR - raise CommandError.new error_msg.call(invalid, valid) - end - end - - def enum_opt(*args) - self.class.enum_opt(*args) - end - end - end - end -end diff --git a/lib/i18n/tasks/command/options/list_opt.rb b/lib/i18n/tasks/command/options/list_opt.rb deleted file mode 100644 index 0ffe5950..00000000 --- a/lib/i18n/tasks/command/options/list_opt.rb +++ /dev/null @@ -1,11 +0,0 @@ -module I18n::Tasks - module Command - module Options - module ListOpt - def explode_list_opt(list_opt, delim = /\s*[,]\s*/) - Array(list_opt).compact.map { |v| v.strip.split(delim).compact.presence }.flatten.map(&:presence).compact - end - end - end - end -end diff --git a/lib/i18n/tasks/command/options/locales.rb b/lib/i18n/tasks/command/options/locales.rb index f524a60c..2cba1533 100644 --- a/lib/i18n/tasks/command/options/locales.rb +++ b/lib/i18n/tasks/command/options/locales.rb @@ -1,53 +1,33 @@ +require 'i18n/tasks/command/option_parsers/locale' + module I18n::Tasks module Command module Options module Locales include Command::DSL - cmd_opt :locales, { - short: :l, - long: :locales=, - desc: t('i18n_tasks.cmd.args.desc.locales_filter'), - conf: {as: Array, delimiter: /\s*[+:,]\s*/, default: 'all', argument: true, optional: false}, - parse: :parse_locales - } - - cmd_opt :locale, { - short: :l, - long: :locale=, - desc: t('i18n_tasks.cmd.args.desc.locale'), - conf: {default: 'base', argument: true, optional: false}, - parse: :parse_locale - } - - cmd_opt :locale_to_translate_from, cmd_opt(:locale).merge( - short: :f, - long: :from=, - desc: t('i18n_tasks.cmd.args.desc.locale_to_translate_from')) - - def parse_locales(opt, key = :locales) - argv = Array(opt[:arguments]) + Array(opt[key]) - locales = if argv == ['all'] || argv == 'all' || argv.blank? - i18n.locales - else - explode_list_opt(argv).map { |v| v == 'base' ? base_locale : v } - end - locales.each { |locale| validate_locale!(locale) } - log_verbose "locales for the command are #{locales.inspect}" - opt[key] = locales - end - - def parse_locale(opt, key = :locale) - val = opt[key] - opt[key] = base_locale if val.blank? || val == 'base' - opt[key] - end + arg :locales, + '-l', + '--locales en,es,ru', + Array, + t('i18n_tasks.cmd.args.desc.locales_filter'), + parser: OptionParsers::Locale::ListParser, + default: 'all', + consume_positional: true - VALID_LOCALE_RE = /\A\w[\w\-\.]*\z/i + arg :locale, + '-l', + '--locale en', + t('i18n_tasks.cmd.args.desc.locale'), + parser: OptionParsers::Locale::Parser, + default: 'base' - def validate_locale!(locale) - raise CommandError.new(I18n.t('i18n_tasks.cmd.errors.invalid_locale', invalid: locale)) if VALID_LOCALE_RE !~ locale - end + arg :locale_to_translate_from, + '-f', + '--from en', + t('i18n_tasks.cmd.args.desc.locale_to_translate_from'), + parser: OptionParsers::Locale::Parser, + default: 'base' end end end diff --git a/lib/i18n/tasks/command/options/trees.rb b/lib/i18n/tasks/command/options/trees.rb deleted file mode 100644 index db5ceddf..00000000 --- a/lib/i18n/tasks/command/options/trees.rb +++ /dev/null @@ -1,81 +0,0 @@ -module I18n::Tasks - module Command - module Options - module Trees - include Command::DSL - format_opt = proc { |type| - enum_opt_attr :f, :format=, enum_opt(type), - proc { |valid, default| - I18n.t("i18n_tasks.cmd.args.desc.#{type}", valid_text: valid, default_text: default) }, - proc { |value, valid| - I18n.t('i18n_tasks.cmd.errors.invalid_format', invalid: value, valid: valid * ', ') } - } - - enum_opt :data_format, %w(yaml json keys) - # i18n-tasks-use t('i18n_tasks.cmd.args.desc.data_format') - cmd_opt :data_format, format_opt.call(:data_format) - - enum_opt :out_format, ['terminal-table', *enum_opt(:data_format), 'inspect'] - # i18n-tasks-use t('i18n_tasks.cmd.args.desc.out_format') - cmd_opt :out_format, format_opt.call(:out_format) - - def print_forest(forest, opt, version = :show_tree) - format = opt[:format].to_s - case format - when 'terminal-table' - terminal_report.send(version, forest) - when 'inspect' - puts forest.inspect - when 'keys' - puts forest.key_names(root: true) - when *enum_opt(:data_format) - puts i18n.data.adapter_dump forest.to_hash(true), format - end - end - - def opt_forest_arg_or_stdin!(opt, format = opt[:format]) - src = opt[:arguments].try(:shift) || $stdin.read - parse_forest(src, format) - end - - def opt_forests_stdin_args!(opt, num = false, format = opt[:format]) - args = opt[:arguments] || [] - if opt[:nostdin] - sources = [] - else - sources = [$stdin.read] - num -= 1 if num - end - if num - num.times { sources << args.shift } - else - sources += args - args.clear - end - sources.map { |src| parse_forest(src, format) } - end - - def opt_forests_merged_stdin_args!(opt) - opt_forests_stdin_args!(opt).inject(i18n.empty_forest) { |result, forest| - result.merge! forest - } - end - - def parse_forest(src, format) - if !src - raise CommandError.new(I18n.t('i18n_tasks.cmd.errors.pass_forest')) - end - if format == 'keys' - Data::Tree::Siblings.from_key_names parse_keys(src) - else - Data::Tree::Siblings.from_nested_hash i18n.data.adapter_parse(src, format) - end - end - - def parse_keys(src) - explode_list_opt(src, /\s*[,\s\n]\s*/) - end - end - end - end -end diff --git a/lib/i18n/tasks/configuration.rb b/lib/i18n/tasks/configuration.rb index 0028add1..522e4013 100644 --- a/lib/i18n/tasks/configuration.rb +++ b/lib/i18n/tasks/configuration.rb @@ -13,7 +13,7 @@ def config def file_config file = CONFIG_FILES.detect { |f| File.exist?(f) } - config = file && YAML.load(Erubis::Eruby.new(File.read(file)).result) + config = file && YAML.load(Erubis::Eruby.new(File.read(file, encoding: 'UTF-8')).result) if config.present? config.with_indifferent_access.tap do |c| if c[:relative_roots] diff --git a/lib/i18n/tasks/data/file_formats.rb b/lib/i18n/tasks/data/file_formats.rb index 34c0d536..10109765 100644 --- a/lib/i18n/tasks/data/file_formats.rb +++ b/lib/i18n/tasks/data/file_formats.rb @@ -34,14 +34,20 @@ def read_config(format) end def load_file(path) - adapter_parse ::File.read(path), self.class.adapter_name_for_path(path) + adapter_parse ::File.read(path, encoding: 'UTF-8'), self.class.adapter_name_for_path(path) end def write_tree(path, tree) + hash = tree.to_hash(true) + adapter = self.class.adapter_name_for_path(path) + content = adapter_dump(hash, adapter) + # Ignore unchanged data + return if File.file?(path) && + # Comparing hashes for equality directly would ignore key order. + # Round-trip through the adapter and compare the strings instead: + content == adapter_dump(load_file(path), adapter) ::FileUtils.mkpath(File.dirname path) - ::File.open(path, 'w') { |f| - f.write adapter_dump(tree.to_hash(true), self.class.adapter_name_for_path(path)) - } + ::File.open(path, 'w') { |f| f.write content } end module ClassMethods diff --git a/lib/i18n/tasks/data/file_system_base.rb b/lib/i18n/tasks/data/file_system_base.rb index e324824f..4ac174fb 100644 --- a/lib/i18n/tasks/data/file_system_base.rb +++ b/lib/i18n/tasks/data/file_system_base.rb @@ -25,9 +25,9 @@ def initialize(config = {}) locales = config[:locales].presence @locales = LocaleList.normalize_locale_list(locales || available_locales, base_locale, true) if locales.present? - log_verbose "data locales: #{@locales}" + log_verbose "locales read from config #{@locales * ', '}" else - log_verbose "data locales (inferred): #{@locales}" + log_verbose "locales inferred from data: #{@locales * ', '}" end end @@ -110,7 +110,7 @@ def t(key, locale) end def config=(config) - @config = DEFAULTS.deep_merge((config || {}).with_indifferent_access) + @config = DEFAULTS.deep_merge((config || {}).reject { |k, v| v.nil? }.with_indifferent_access) reload end diff --git a/lib/i18n/tasks/data/tree/node.rb b/lib/i18n/tasks/data/tree/node.rb index be4914dd..5bfeeaee 100644 --- a/lib/i18n/tasks/data/tree/node.rb +++ b/lib/i18n/tasks/data/tree/node.rb @@ -4,6 +4,9 @@ require 'i18n/tasks/data/tree/siblings' module I18n::Tasks::Data::Tree class Node + include Enumerable + include Traversal + attr_accessor :value attr_reader :key, :children, :parent @@ -42,9 +45,6 @@ def each(&block) self end - include Enumerable - include Traversal - def value_or_children_hash leaf? ? value : children.try(:to_hash) end diff --git a/lib/i18n/tasks/data/tree/nodes.rb b/lib/i18n/tasks/data/tree/nodes.rb index be9d2610..2f607263 100644 --- a/lib/i18n/tasks/data/tree/nodes.rb +++ b/lib/i18n/tasks/data/tree/nodes.rb @@ -4,6 +4,9 @@ module I18n::Tasks::Data::Tree # A list of nodes class Nodes + include Enumerable + include Traversal + attr_reader :list def initialize(opts = {}) @@ -11,8 +14,6 @@ def initialize(opts = {}) end delegate :each, :present?, :empty?, :blank?, :size, :to_a, to: :@list - include Enumerable - include Traversal def to_nodes self diff --git a/lib/i18n/tasks/data/tree/siblings.rb b/lib/i18n/tasks/data/tree/siblings.rb index c20ab235..dac57d5d 100644 --- a/lib/i18n/tasks/data/tree/siblings.rb +++ b/lib/i18n/tasks/data/tree/siblings.rb @@ -1,6 +1,5 @@ # coding: utf-8 -require 'i18n/tasks/data/tree/traversal' require 'i18n/tasks/data/tree/nodes' module I18n::Tasks::Data::Tree # Siblings represents a subtree sharing a common parent diff --git a/lib/i18n/tasks/logging.rb b/lib/i18n/tasks/logging.rb index e7ae526b..70df3d08 100644 --- a/lib/i18n/tasks/logging.rb +++ b/lib/i18n/tasks/logging.rb @@ -3,24 +3,28 @@ module I18n::Tasks::Logging extend self def warn_deprecated(message) - log_stderr Term::ANSIColor.yellow Term::ANSIColor.bold "i18n-tasks: [DEPRECATED] #{message}" + log_stderr Term::ANSIColor.yellow Term::ANSIColor.bold "#{program_name}: [DEPRECATED] #{message}" end def log_verbose(message) - if ENV['VERBOSE'] - log_stderr Term::ANSIColor.green "i18n-tasks: #{message}" + if ::I18n::Tasks.verbose? + log_stderr Term::ANSIColor.bright_blue(message) end end def log_warn(message) - log_stderr Term::ANSIColor.yellow "i18n-tasks: [WARN] #{message}" + log_stderr Term::ANSIColor.yellow "#{program_name}: [WARN] #{message}" end def log_error(message) - log_stderr Term::ANSIColor.red Term::ANSIColor.bold "i18n-tasks: #{message}" + log_stderr Term::ANSIColor.red Term::ANSIColor.bold "#{program_name}: #{message}" end def log_stderr(*args) $stderr.puts(*args) end + + def program_name + @program_name ||= File.basename($PROGRAM_NAME) + end end diff --git a/lib/i18n/tasks/missing_keys.rb b/lib/i18n/tasks/missing_keys.rb index 85653929..0312fd40 100644 --- a/lib/i18n/tasks/missing_keys.rb +++ b/lib/i18n/tasks/missing_keys.rb @@ -69,10 +69,12 @@ def missing_diff_tree(locale, compared_to = base_locale) data[compared_to].select_keys { |key, _node| locale_key_missing? locale, depluralize_key(key, compared_to) }.set_root_key!(locale, type: :missing_diff).keys { |_key, node| + # change path and locale to base + data = {locale: locale, missing_diff_locale: node.data[:locale]} if node.data.key?(:path) - # change path and locale to base - node.data.update path: LocalePathname.replace_locale(node.data[:path], node.data[:locale], locale), locale: locale + data[:path] = LocalePathname.replace_locale(node.data[:path], node.data[:locale], locale) end + node.data.update data } end diff --git a/lib/i18n/tasks/reports/spreadsheet.rb b/lib/i18n/tasks/reports/spreadsheet.rb index f8b6a18f..8b7f8d93 100644 --- a/lib/i18n/tasks/reports/spreadsheet.rb +++ b/lib/i18n/tasks/reports/spreadsheet.rb @@ -11,7 +11,6 @@ def save_report(path, opts) add_missing_sheet p.workbook add_unused_sheet p.workbook add_eq_base_sheet p.workbook - p.use_shared_strings = true FileUtils.mkpath(File.dirname(path)) p.serialize(path) $stderr.puts Term::ANSIColor.green "Saved to #{path}" diff --git a/lib/i18n/tasks/reports/terminal.rb b/lib/i18n/tasks/reports/terminal.rb index d899928f..bb288b5c 100644 --- a/lib/i18n/tasks/reports/terminal.rb +++ b/lib/i18n/tasks/reports/terminal.rb @@ -11,9 +11,11 @@ def missing_keys(forest = task.missing_keys) forest = task.collapse_plural_nodes!(forest) if forest.present? print_title missing_title(forest) - print_table headings: [cyan(bold(I18n.t('i18n_tasks.common.locale'))), cyan(bold I18n.t('i18n_tasks.common.key')), I18n.t('i18n_tasks.common.details')] do |t| + print_table headings: [cyan(bold(I18n.t('i18n_tasks.common.locale'))), + cyan(bold I18n.t('i18n_tasks.common.key')), + I18n.t('i18n_tasks.missing.details_title')] do |t| t.rows = sort_by_attr!(forest_to_attr(forest)).map do |a| - [{value: cyan(a[:locale]), alignment: :center}, cyan(a[:key]), wrap_string(key_info(a), 60)] + [{value: cyan(a[:locale]), alignment: :center}, cyan(a[:key]), missing_key_info(a)] end end else @@ -74,6 +76,14 @@ def forest_stats(forest, stats = task.forest_stats(forest)) private + def missing_key_info(leaf) + if leaf[:type] == :missing_used + first_occurrence leaf + else + "#{cyan leaf[:data][:missing_diff_locale]} #{leaf[:value].to_s.strip}" + end + end + def print_occurrences(node, full_key = node.full_key) occurrences = node.data[:source_occurrences] puts "#{bold "#{full_key}"} #{green(occurrences.size.to_s) if occurrences.size > 1}" @@ -84,8 +94,12 @@ def print_occurrences(node, full_key = node.full_key) def print_locale_key_value_table(locale_key_values) if locale_key_values.present? - print_table headings: [bold(cyan(I18n.t('i18n_tasks.common.locale'))), bold(cyan(I18n.t('i18n_tasks.common.key'))), I18n.t('i18n_tasks.common.value')] do |t| - t.rows = locale_key_values.map { |(locale, k, v)| [{value: cyan(locale), alignment: :center}, cyan(k), v.to_s] } + print_table headings: [bold(cyan(I18n.t('i18n_tasks.common.locale'))), + bold(cyan(I18n.t('i18n_tasks.common.key'))), + I18n.t('i18n_tasks.common.value')] do |t| + t.rows = locale_key_values.map { |(locale, k, v)| + [{value: cyan(locale), alignment: :center}, cyan(k), v.to_s] + } end else puts 'ø' @@ -124,17 +138,7 @@ def key_occurrence(full_key, info) end def highlight_key(full_key, line, range = (0..-1)) - result = line.dup - result[range] = result[range].sub(full_key) { |m| underline m } - result - end - - def key_info(leaf) - if leaf[:type] == :missing_used - first_occurrence leaf - else - leaf[:value].to_s.strip - end + line.dup.tap { |s| s[range] = s[range].sub(full_key) { |m| underline m } } end def first_occurrence(leaf) @@ -143,23 +147,6 @@ def first_occurrence(leaf) [green("#{first[:src_path]}:#{first[:line_num]}"), ("(#{I18n.t 'i18n_tasks.common.n_more', count: usages.length - 1})" if usages.length > 1)].compact.join(' ') end - - def wrap_string(s, max) - chars = [] - dist = 0 - s.chars.each do |c| - chars << c - dist += 1 - if c == "\n" - dist = 0 - elsif dist == max - dist = 0 - chars << "\n" - end - end - chars = chars[0..-2] if chars.last == "\n" - chars.join - end end end end diff --git a/lib/i18n/tasks/scanners/base_scanner.rb b/lib/i18n/tasks/scanners/base_scanner.rb index 667b164c..1c2122b3 100644 --- a/lib/i18n/tasks/scanners/base_scanner.rb +++ b/lib/i18n/tasks/scanners/base_scanner.rb @@ -10,27 +10,27 @@ class BaseScanner attr_reader :config, :key_filter, :ignore_lines_res + ALWAYS_EXCLUDE = %w(*.jpg *.png *.gif *.svg *.ico *.eot *.otf *.ttf *.woff *.woff2 *.pdf + *.css *.sass *.scss *.less *.yml *.json) + def initialize(config = {}) @config = config.dup.with_indifferent_access.tap do |conf| - conf[:relative_roots] = %w(app/views) if conf[:relative_roots].blank? + conf[:relative_roots] = %w(app/views app/controllers app/helpers app/presenters) if conf[:relative_roots].blank? conf[:paths] = %w(app/) if conf[:paths].blank? conf[:include] = Array(conf[:include]) if conf[:include].present? - if conf.key?(:exclude) - conf[:exclude] = Array(conf[:exclude]) - else - # exclude common binary extensions by default (images and fonts) - conf[:exclude] = %w(*.jpg *.png *.gif *.svg *.ico *.eot *.ttf *.woff *.pdf) - end + conf[:exclude] = Array(conf[:exclude]) + ALWAYS_EXCLUDE # Regexps for lines to ignore per extension if conf[:ignore_lines] && !conf[:ignore_lines].is_a?(Hash) warn_deprecated "search.ignore_lines must be a Hash, found #{conf[:ignore_lines].class.name}" conf[:ignore_lines] = nil end conf[:ignore_lines] ||= { - 'rb' => %q(^\s*#(?!\si18n-tasks-use)), - 'haml' => %q(^\s*-\s*#(?!\si18n-tasks-use)), - 'slim' => %q(^\s*(?:-#|/)(?!\si18n-tasks-use)), - 'erb' => %q(^\s*<%\s*#(?!\si18n-tasks-use)), + 'rb' => %q(^\s*#(?!\si18n-tasks-use)), + 'opal' => %q(^\s*#(?!\si18n-tasks-use)), + 'haml' => %q(^\s*-\s*#(?!\si18n-tasks-use)), + 'slim' => %q(^\s*(?:-#|/)(?!\si18n-tasks-use)), + 'coffee' => %q(^\s*#(?!\si18n-tasks-use)), + 'erb' => %q(^\s*<%\s*#(?!\si18n-tasks-use)), } @ignore_lines_res = conf[:ignore_lines].inject({}) { |h, (ext, re)| h.update(ext => Regexp.new(re)) } @key_filter = nil @@ -59,7 +59,7 @@ def keys(opts = {}) def read_file(path) result = nil - File.open(path, 'rb') { |f| result = f.read } + File.open(path, 'rb', encoding: 'UTF-8') { |f| result = f.read } result end @@ -126,9 +126,9 @@ def strip_literal(literal) key end - VALID_KEY_CHARS = /[-\w.?!;:]/ + VALID_KEY_CHARS = /(?:[[:word:]]|[-.?!;À-ž])/ VALID_KEY_RE_STRICT = /^#{VALID_KEY_CHARS}+$/ - VALID_KEY_RE = /^(#{VALID_KEY_CHARS}|[\#{@}])+$/ + VALID_KEY_RE = /^(#{VALID_KEY_CHARS}|[:\#{@}\[\]])+$/ def valid_key?(key, strict = false) return false if @key_filter && @key_filter_pattern !~ key diff --git a/lib/i18n/tasks/scanners/pattern_scanner.rb b/lib/i18n/tasks/scanners/pattern_scanner.rb index 1ad7ca91..c32c60f3 100644 --- a/lib/i18n/tasks/scanners/pattern_scanner.rb +++ b/lib/i18n/tasks/scanners/pattern_scanner.rb @@ -13,15 +13,17 @@ def scan_file(path, opts = {}) text = opts[:text] || read_file(path) text.scan(pattern) do |match| src_pos = Regexp.last_match.offset(0).first - key = match_to_key(match, path) - next unless valid_key?(key, strict) - key = key + ':' if key.end_with?('.') location = src_location(path, text, src_pos) - unless exclude_line?(location[:line], path) - keys << [key, data: location] - end + next if exclude_line?(location[:line], path) + key = match_to_key(match, path, location) + next unless key + key = key + ':' if key.end_with?('.') + next unless valid_key?(key, strict) + keys << [key, data: location] end keys + rescue Exception => e + raise ::I18n::Tasks::CommandError.new("Error scanning #{path}: #{e.message}") end def default_pattern @@ -38,10 +40,31 @@ def default_pattern # @param [MatchData] match # @param [String] path # @return [String] full absolute key name - def match_to_key(match, path) + def match_to_key(match, path, location) key = strip_literal(match[0]) - key = absolutize_key(key, path) if path && key.start_with?('.') - key + absolute_key(key, path, location) + end + + def absolute_key(key, path, location) + if key.start_with?('.') + if controller_file?(path) + absolutize_key(key, path, relative_roots, closest_method(location)) + else + absolutize_key(key, path) + end + else + key + end + end + + def controller_file?(path) + /controllers/.match(path) + end + + def closest_method(location) + method = File.readlines(location[:src_path], encoding: 'UTF-8').first(location[:line_num] - 1).reverse_each.find { |x| x=~ /\bdef\b/ } + method &&= method.strip.sub(/^def\s*/, '').sub(/[\(\s;].*$/, '') + method end def pattern diff --git a/lib/i18n/tasks/scanners/pattern_with_scope_scanner.rb b/lib/i18n/tasks/scanners/pattern_with_scope_scanner.rb index e80b2eb6..3d3ee83d 100644 --- a/lib/i18n/tasks/scanners/pattern_with_scope_scanner.rb +++ b/lib/i18n/tasks/scanners/pattern_with_scope_scanner.rb @@ -20,7 +20,7 @@ def default_pattern # @param [MatchData] match # @param [String] path # @return [String] full absolute key name with scope resolved if any - def match_to_key(match, path) + def match_to_key(match, path, location) key = super scope = match[1] if scope diff --git a/lib/i18n/tasks/scanners/relative_keys.rb b/lib/i18n/tasks/scanners/relative_keys.rb index c7e5faee..69a80f82 100644 --- a/lib/i18n/tasks/scanners/relative_keys.rb +++ b/lib/i18n/tasks/scanners/relative_keys.rb @@ -6,14 +6,47 @@ module RelativeKeys # @param key [String] relative i18n key (starts with a .) # @param path [String] path to the file containing the key # @return [String] absolute version of the key - def absolutize_key(key, path, roots = relative_roots) - # normalized path - path = File.expand_path path - (path_root = roots.map { |path| File.expand_path path }.sort.reverse.detect { |root| path.start_with?(root + '/') }) or - raise CommandError.new("Error scanning #{path}: cannot resolve relative key \"#{key}\".\nSet relative_roots in config/i18n-tasks.yml (currently #{relative_roots.inspect})") - # key prefix based on path - prefix = path.gsub(%r(#{path_root}/|(\.[^/]+)*$), '').tr('/', '.').gsub(%r(\._), '.') - "#{prefix}#{key}" + def absolutize_key(key, path, roots = relative_roots, closest_method = "") + normalized_path = File.expand_path(path) + path_root(normalized_path, roots) or + raise CommandError.new( + "Error scanning #{normalized_path}: cannot resolve relative key + \"#{key}\".\nSet search.relative_roots in config/i18n-tasks.yml + (currently #{relative_roots.inspect})" + ) + + prefix_key_based_on_path(key, normalized_path, roots, closest_method: closest_method) + end + + private + + # Detect the appropriate relative path root + # @param [String] path /full/path + # @param [Array] roots array of full paths + # @return [String] the closest ancestor root for path + def path_root(path, roots) + expanded_relative_roots(roots).sort.reverse_each.detect do |root| + path.start_with?(root + '/') + end + end + + def expanded_relative_roots(roots) + roots.map { |path| File.expand_path(path) } + end + + def prefix_key_based_on_path(key, normalized_path, roots, options = {}) + "#{prefix(normalized_path, roots, options)}#{key}" + end + + def prefix(normalized_path, roots, options = {}) + file_name = normalized_path.gsub(%r(#{path_root(normalized_path, roots)}/|(\.[^/]+)*$), '') + + if options[:closest_method].present? + controller_name = file_name.sub(/_controller$/, '') + "#{controller_name}.#{options[:closest_method]}".tr('/', '.') + else + file_name.tr('/', '.').gsub(%r(\._), '.') + end end end end diff --git a/lib/i18n/tasks/slop_command.rb b/lib/i18n/tasks/slop_command.rb deleted file mode 100644 index 0282de1b..00000000 --- a/lib/i18n/tasks/slop_command.rb +++ /dev/null @@ -1,41 +0,0 @@ -module I18n::Tasks::SlopCommand - extend self - - def slop_command(name, attr, &block) - proc { - cmd_name = name.tr('_', '-') - command cmd_name do - args = attr[:args] - banner "Usage: i18n-tasks #{cmd_name} [options] #{args}" if args.present? - desc = attr[:desc] - desc = desc.call if desc.respond_to?(:call) - description desc if desc - attr[:opt].try :each do |opt| - on *opt.values_at(:short, :long, :desc, :conf).compact.map { |v| v.respond_to?(:call) ? v.call : v } - end - run { |slop_opts, slop_args| - slop_opts = slop_opts.to_hash(true).reject { |k, v| v.nil? } - slop_opts.merge!(arguments: slop_args) unless slop_args.empty? - block.call name, slop_opts - } - end - } - end - - def parse_opts!(opts, opts_conf, context) - return if !opts_conf - opts_conf.each do |opt_conf| - parse = opt_conf[:parse] - if parse - key = opt_conf[:long].to_s.sub(/=\z/, '').to_sym - if parse.respond_to?(:call) - context.instance_exec opts, key, &parse - elsif Symbol === parse - context.instance_exec do - send parse, opts, key - end - end - end - end - end -end diff --git a/lib/i18n/tasks/used_keys.rb b/lib/i18n/tasks/used_keys.rb index 7a9e16d7..4a286410 100644 --- a/lib/i18n/tasks/used_keys.rb +++ b/lib/i18n/tasks/used_keys.rb @@ -11,11 +11,10 @@ module UsedKeys # @return [Array] def used_tree(opts = {}) return scanner.with_key_filter(opts[:key_filter]) { used_tree(opts.except(:key_filter)) } if opts[:key_filter] - key_attrs = scanner.keys(opts.slice(:strict)) Data::Tree::Node.new( key: 'used', data: {key_filter: scanner.key_filter}, - children: Data::Tree::Siblings.from_key_attr(key_attrs) + children: Data::Tree::Siblings.from_key_attr(scanner.keys(opts.slice(:strict))) ).to_siblings end diff --git a/lib/i18n/tasks/version.rb b/lib/i18n/tasks/version.rb index a5ca223c..ebedeb8b 100644 --- a/lib/i18n/tasks/version.rb +++ b/lib/i18n/tasks/version.rb @@ -1,6 +1,6 @@ # coding: utf-8 module I18n module Tasks - VERSION = '0.7.8' + VERSION = '0.8.3' end end diff --git a/spec/commands/data_commands_spec.rb b/spec/commands/data_commands_spec.rb index 5d142b71..00fca2c1 100644 --- a/spec/commands/data_commands_spec.rb +++ b/spec/commands/data_commands_spec.rb @@ -20,19 +20,19 @@ def en_data_2 end it '#data' do - expect(JSON.parse(run_cmd :data, format: 'json', locales: 'en')).to eq(en_data) + expect(JSON.parse(run_cmd 'data', '-fjson', '-len')).to eq(en_data) end it '#data-merge' do - expect(JSON.parse(run_cmd :data_merge, format: 'json', arguments: [en_data_2.to_json], nostdin: true)).to eq(en_data.deep_merge en_data_2) + expect(JSON.parse(run_cmd 'data-merge', '-fjson', '-S', en_data_2.to_json)).to eq(en_data.deep_merge en_data_2) end it '#data-write' do - expect(JSON.parse(run_cmd :data_write, format: 'json', arguments: [en_data_2.to_json])).to eq(en_data_2) + expect(JSON.parse(run_cmd 'data-write', '-fjson', '-S', en_data_2.to_json)).to eq(en_data_2) end it '#data-remove' do to_remove = {'en' => {'common' => {'hello' => ''}}} - expect(JSON.parse(run_cmd :data_remove, format: 'json', arguments: [to_remove.to_json])).to eq('en' => {'common' => en_data['en']['common'] }) + expect(JSON.parse(run_cmd 'data-remove', '-fjson', '-S', to_remove.to_json)).to eq('en' => {'common' => en_data['en']['common'] }) end end diff --git a/spec/commands/tree_commands_spec.rb b/spec/commands/tree_commands_spec.rb index 968fc9b8..7c9327a9 100644 --- a/spec/commands/tree_commands_spec.rb +++ b/spec/commands/tree_commands_spec.rb @@ -13,7 +13,7 @@ context 'tree-merge' do trees = [{'a' => '1', 'b' => '2'}, {'a' => '-1', 'c' => '3'}] it trees.map(&:to_json).join(', ') do - merged = JSON.parse run_cmd(:tree_merge, format: 'json', arguments: trees.map(&:to_json), nostdin: true) + merged = JSON.parse run_cmd('tree-merge', '-fjson', '-S', *trees.map(&:to_json)) expect(merged).to eq trees.reduce(:merge) end end @@ -22,7 +22,7 @@ forest = {'a' => '1', 'b' => '2', 'c' => {'a' => '3'}} pattern = '{a,c.*}' it "-p #{pattern.inspect} #{forest.to_json}" do - selected = JSON.parse run_cmd(:tree_filter, format: 'json', pattern: pattern, arguments: [forest.to_json]) + selected = JSON.parse run_cmd('tree-filter', '-fjson', '-p', pattern, forest.to_json) expect(selected).to eq(forest.except('b')) end end @@ -30,7 +30,7 @@ context 'tree-subtract' do trees = [{'a' => '1', 'b' => '2'}, {'a' => '-1', 'c' => '3'}] it trees.map(&:to_json).join(' - ') do - subtracted = JSON.parse run_cmd(:tree_subtract, format: 'json', arguments: trees.map(&:to_json), nostdin: true) + subtracted = JSON.parse run_cmd('tree-subtract', '-fjson', '-S', *trees.map(&:to_json)) expected = {'b' => '2'} expect(subtracted).to eq expected end @@ -41,17 +41,18 @@ def forest {'a' => {'b' => {'a' => '1'}}} end + def rename_key(from, to) + JSON.parse run_cmd('tree-rename-key', '-fjson', '-k', from, '-n', to, forest.to_json) + end + it 'renames root node' do - renamed = JSON.parse run_cmd(:tree_rename_key, key: 'a', name: 'x', format: 'json', arguments: [forest.to_json]) - expect(renamed).to eq(forest.tap { |f| f['x'] = f.delete('a') }) + expect(rename_key('a', 'x')).to eq(forest.tap { |f| f['x'] = f.delete('a') }) end it 'renames node' do - renamed = JSON.parse run_cmd(:tree_rename_key, key: 'a.b', name: 'x', format: 'json', arguments: [forest.to_json]) - expect(renamed).to eq(forest.tap { |f| f['a']['x'] = f['a'].delete('b') }) + expect(rename_key('a.b', 'x')).to eq(forest.tap { |f| f['a']['x'] = f['a'].delete('b') }) end it 'renames leaf' do - renamed = JSON.parse run_cmd(:tree_rename_key, key: 'a.b.a', name: 'x', format: 'json', arguments: [forest.to_json]) - expect(renamed).to eq(forest.tap { |f| f['a']['b']['x'] = f['a']['b'].delete('a') }) + expect(rename_key('a.b.a', 'x')).to eq(forest.tap { |f| f['a']['b']['x'] = f['a']['b'].delete('a') }) end end @@ -61,7 +62,7 @@ def forest end it 'converts to keys' do - keys = run_cmd(:tree_convert, from: 'json', to: 'keys', arguments: [forest.to_json]).split("\n") + keys = run_cmd('tree-convert', '-fjson', '-tkeys', forest.to_json).split("\n") expect(keys.sort).to eq(['a.b.a', 'x'].sort) end end diff --git a/spec/file_system_data_spec.rb b/spec/file_system_data_spec.rb index 66d7be3b..66cd78e1 100644 --- a/spec/file_system_data_spec.rb +++ b/spec/file_system_data_spec.rb @@ -95,7 +95,7 @@ data[:en] = data[:en].merge!('en' => locale_data) files = %w(pizza.en.json sushi.en.json) expect(Dir['*.json'].sort).to eq(files.sort) - files.each { |f| expect(JSON.parse(File.read f)['en']).to eq({File.basename(f, '.en.json') => keys}) } + files.each { |f| expect(JSON.parse(File.read(f, encoding: 'UTF-8'))['en']).to eq({File.basename(f, '.en.json') => keys}) } } end end diff --git a/spec/fixtures/app/controllers/events_controller.rb b/spec/fixtures/app/controllers/events_controller.rb index 8be75c3a..459bc120 100644 --- a/spec/fixtures/app/controllers/events_controller.rb +++ b/spec/fixtures/app/controllers/events_controller.rb @@ -1,6 +1,9 @@ # coding: utf-8 class EventsController < ApplicationController - def show + def create + end + + def show() redirect_to :edit, notice: I18n.t('cb.a') # args are ignored @@ -26,5 +29,11 @@ def show # not missing I18n.t "hash.#{stuff}.a" + + # relative key + I18n.t(".success") + end + + def update end end diff --git a/spec/fixtures/app/views/index.html.slim b/spec/fixtures/app/views/index.html.slim index 88e91ce6..d584c6e8 100644 --- a/spec/fixtures/app/views/index.html.slim +++ b/spec/fixtures/app/views/index.html.slim @@ -3,6 +3,7 @@ / t(:fp_comment) / i18n-tasks-use t(:fn_comment) = t 'only_in_es' +#x = t 'not_a_comment' p #{t('ca.a')} #{t 'ca.b'} #{t "ca.c"} p #{t 'ca.d'} #{t 'ca.f', i: 'world'} #{t 'ca.e', i: 'world'} p #{t 'missing_in_es.a'} #{t 'same_in_es.a'} #{t 'blank_in_es.a'} @@ -25,3 +26,4 @@ p = t 'devise.a' p = t :missing_symbol_key p #{t :"missing_symbol.key_two"} #{t :'missing_symbol.key_three'} = t 'present_in_es_but_not_en.a' += t 'latin_extra.çüéö' \ No newline at end of file diff --git a/spec/fixtures/config/i18n-tasks.yml b/spec/fixtures/config/i18n-tasks.yml index 0fe6ca06..8370869b 100644 --- a/spec/fixtures/config/i18n-tasks.yml +++ b/spec/fixtures/config/i18n-tasks.yml @@ -32,7 +32,11 @@ search: - '*.file' # explicitly exclude files (default: blank = exclude no files) exclude: '*.js' - # search uses grep under the hood + # paths for relative key resolution: + relative_roots: + - app/views + - app/controllers + # do not report these keys ever ignore: diff --git a/spec/google_translate_spec.rb b/spec/google_translate_spec.rb index 1fba2d32..3fec5080 100644 --- a/spec/google_translate_spec.rb +++ b/spec/google_translate_spec.rb @@ -18,7 +18,7 @@ if ENV['GOOGLE_TRANSLATE_API_KEY'] describe 'real world test' do - delegate :i18n_cmd, :i18n_task, :in_test_app_dir, to: :TestCodebase + delegate :i18n_task, :in_test_app_dir, :run_cmd, to: :TestCodebase context '#google_translate_list' do it "works with #{tests.map(&:first)}" do @@ -39,7 +39,6 @@ context 'command' do let(:task) { i18n_task } - let(:cmd) { i18n_cmd(task) } it 'works' do in_test_app_dir do @@ -62,7 +61,7 @@ } }) - cmd.run :translate_missing + run_cmd 'translate-missing' expect(task.t('common.hello', 'es')).to eq(text_test[2]) expect(task.t('common.hello_html', 'es')).to eq(html_test[2]) expect(task.t('common.array_key', 'es')).to eq(array_test[2]) diff --git a/spec/i18n_spec.rb b/spec/i18n_spec.rb index c1b7aeef..71211b8a 100644 --- a/spec/i18n_spec.rb +++ b/spec/i18n_spec.rb @@ -1,4 +1,3 @@ -require 'spec_helper' require 'i18n/tasks' describe 'I18n' do diff --git a/spec/i18n_tasks_spec.rb b/spec/i18n_tasks_spec.rb index b3f7af0e..25e8538e 100644 --- a/spec/i18n_tasks_spec.rb +++ b/spec/i18n_tasks_spec.rb @@ -1,16 +1,38 @@ # coding: utf-8 require 'spec_helper' require 'fileutils' +require 'open3' +# Integration tests describe 'i18n-tasks' do - delegate :run_cmd, :i18n_task, :in_test_app_dir, to: :TestCodebase + delegate :run_cmd, :run_cmd_capture_stderr, :i18n_task, :in_test_app_dir, to: :TestCodebase + + describe 'bin/i18n-tasks' do + it 'shows help when invoked with no arguments, shows version on --version' do + # These bin/i18n-tasks tests are executed in parallel for performance + [ + proc { + out, err, status = Open3.capture3('bin/i18n-tasks') + expect(status).to be_success + expect(out).to be_empty + expect(err).to start_with('Usage: i18n-tasks [command] [options]') + expect(err).to include('Available commands', 'add-missing') + }, + proc { + expect(%x[bin/i18n-tasks --version].chomp).to eq(I18n::Tasks::VERSION) + } + ].map { |test| Thread.start(&test) }.each(&:join) + end + end + + # Tests execute i18n-tasks using I18n::Tasks::CLI directly, via #run_cmd(task, *arguments). + # This avoid launching a process for each command. describe 'health' do it 'outputs stats' do t = i18n_task - stats = in_test_app_dir { t.forest_stats(t.data_forest t.locales) } - out = capture_stderr { run_cmd :health } - stats.values.each do |v| + out = run_cmd_capture_stderr('health') + in_test_app_dir { t.forest_stats(t.data_forest t.locales) }.values.each do |v| expect(out).to include(v.to_s) end end @@ -18,65 +40,72 @@ describe 'missing' do let (:expected_missing_keys) { - %w( en.used_but_missing.key en.relative.index.missing + %w( en.used_but_missing.key + en.relative.index.missing es.missing_in_es.a en.present_in_es_but_not_en.a - en.hash.pattern_missing.a en.hash.pattern_missing.b - en.missing_symbol_key en.missing_symbol.key_two en.missing_symbol.key_three - es.missing_in_es_plural_1.a es.missing_in_es_plural_2.a + en.hash.pattern_missing.a + en.hash.pattern_missing.b + en.missing_symbol_key + en.missing_symbol.key_two + en.missing_symbol.key_three + es.missing_in_es_plural_1.a + es.missing_in_es_plural_2.a en.missing-key-with-a-dash.key en.missing-key-question?.key - en.fn_comment en.only_in_es + en.fn_comment + en.only_in_es + en.events.show.success ) } it 'detects missing' do - capture_stderr do - expect(run_cmd :missing).to be_i18n_keys expected_missing_keys - es_keys = expected_missing_keys.grep(/^es\./) - # locale argument - expect(run_cmd :missing, locales: %w(es)).to be_i18n_keys es_keys - expect(run_cmd :missing, arguments: %w(es)).to be_i18n_keys es_keys - end + es_keys = expected_missing_keys.grep(/^es\./) + expect(run_cmd 'missing').to be_i18n_keys expected_missing_keys + # locale argument + expect(run_cmd 'missing', '-les').to be_i18n_keys es_keys + expect(run_cmd 'missing', 'es').to be_i18n_keys es_keys end end describe 'eq_base' do - it 'detects eq_base' do - capture_stderr do - expect(run_cmd :eq_base).to be_i18n_keys %w(es.same_in_es.a) - end + it 'detects eq-base' do + expect(run_cmd 'eq-base').to be_i18n_keys %w(es.same_in_es.a) end end - let(:expected_unused_keys) { %w(unused.a unused.numeric unused.plural).map { |k| %w(en es).map { |l| "#{l}.#{k}" } }.reduce(:+) } - let(:expected_unused_keys_strict) { expected_unused_keys + %w(hash.pattern.a hash.pattern2.a).map { |k| %w(en es).map { |l| "#{l}.#{k}" } }.reduce(:+) } + let(:expected_unused_keys) do + %w(unused.a unused.numeric unused.plural).map do |k| + %w(en es).map { |l| "#{l}.#{k}" } + end.reduce(:+) + end + + let(:expected_unused_keys_strict) do + expected_unused_keys + %w(hash.pattern.a hash.pattern2.a).map do |k| + %w(en es).map { |l| "#{l}.#{k}" } + end.reduce(:+) + end + describe 'unused' do it 'detects unused' do - capture_stderr do - expect(run_cmd :unused).to be_i18n_keys expected_unused_keys - end + expect(run_cmd 'unused').to be_i18n_keys expected_unused_keys end it 'detects unused (--strict)' do - capture_stderr do - expect(run_cmd :unused, strict: true).to be_i18n_keys expected_unused_keys_strict - end + expect(run_cmd 'unused', '--strict').to be_i18n_keys expected_unused_keys_strict end end describe 'remove_unused' do it 'removes unused' do in_test_app_dir do - t = i18n_task + t = i18n_task unused = expected_unused_keys.map { |k| ::I18n::Tasks::SplitKey.split_key(k, 2)[1] } unused.each do |key| expect(t.key_value?(key, :en)).to be true expect(t.key_value?(key, :es)).to be true end ENV['CONFIRM'] = '1' - capture_stderr { - run_cmd :remove_unused - } + run_cmd 'remove-unused' t.data.reload unused.each do |key| expect(t.key_value?(key, :en)).to be false @@ -89,7 +118,7 @@ describe 'normalize' do it 'sorts the keys' do in_test_app_dir do - run_cmd :normalize + run_cmd 'normalize' en_yml_data = i18n_task.data.reload['en'].select_keys { |_k, node| node.data[:path] == 'config/locales/en.yml' } @@ -105,7 +134,7 @@ it 'moves keys to the corresponding files as per data.write' do in_test_app_dir { expect(File).to_not exist 'config/locales/devise.en.yml' - run_cmd :normalize, pattern_router: true + run_cmd 'normalize', '--pattern_router' expect(YAML.load_file('config/locales/devise.en.yml')['en']['devise']['a']).to eq 'EN_TEXT' } end @@ -114,7 +143,7 @@ describe 'xlsx_report' do it 'saves' do in_test_app_dir { - capture_stderr { run_cmd :xlsx_report } + run_cmd 'xlsx-report' expect(File).to exist 'tmp/i18n-report.xlsx' FileUtils.cp('tmp/i18n-report.xlsx', '..') } @@ -127,7 +156,7 @@ in_test_app_dir { expect(YAML.load_file('config/locales/en.yml')['en']['used_but_missing']).to be_nil } - run_cmd :add_missing, locales: 'base' + run_cmd 'add-missing', 'base' in_test_app_dir { expect(YAML.load_file('config/locales/en.yml')['en']['used_but_missing']['key']).to eq I18n.t('i18n_tasks.common.key') expect(YAML.load_file('config/locales/en.yml')['en']['present_in_es_but_not_en']['a']).to eq 'ES_TEXT' @@ -138,7 +167,7 @@ in_test_app_dir { expect(YAML.load_file('config/locales/es.yml')['es']['missing_in_es']).to be_nil } - run_cmd :add_missing, locales: 'es' + run_cmd 'add-missing', 'es' in_test_app_dir { expect(YAML.load_file('config/locales/es.yml')['es']['missing_in_es']['a']).to eq 'EN_TEXT' expect(YAML.load_file('config/locales/es.yml')['es']['missing_in_es_plural_1']['a']['one']).to eq 'EN_TEXT' @@ -149,8 +178,8 @@ in_test_app_dir { expect(YAML.load_file('config/locales/es.yml')['es']['missing_in_es']).to be_nil } - run_cmd :normalize, pattern_router: true - run_cmd :add_missing, locales: 'all', value: 'TRME' + run_cmd 'normalize', '--pattern_router' + run_cmd 'add-missing', '-v', 'TRME' in_test_app_dir { expect(YAML.load_file('config/locales/es.yml')['es']['missing_in_es']['a']).to eq 'TRME' expect(YAML.load_file('config/locales/devise.es.yml')['es']['devise']['a']).to eq 'ES_TEXT' @@ -162,7 +191,7 @@ in_test_app_dir { expect(YAML.load_file('config/locales/es.yml')['es']['missing_in_es']).to be_nil } - run_cmd :add_missing, locales: 'all', value: 'TRME %{value}' + run_cmd 'add-missing', '-v', 'TRME %{value}' in_test_app_dir { expect(YAML.load_file('config/locales/es.yml')['es']['missing_in_es']['a']).to eq 'TRME EN_TEXT' expect(YAML.load_file('config/locales/en.yml')['en']['present_in_es_but_not_en']['a']).to eq 'TRME ES_TEXT' @@ -172,7 +201,7 @@ describe 'config' do it 'prints config' do - expect(YAML.load(Term::ANSIColor.uncolor(run_cmd :config))).to( + expect(YAML.load(Term::ANSIColor.uncolor(run_cmd 'config'))).to( eq(in_test_app_dir { i18n_task.config_for_inspect }) ) end @@ -180,20 +209,17 @@ describe 'find' do it 'prints usages' do - capture_stderr do - result = Term::ANSIColor.uncolor(run_cmd :find, arguments: ['used.*']) - expect(result).to eq(<<-TXT) + result = Term::ANSIColor.uncolor(run_cmd 'find', 'used.*') + expect(result).to eq(<<-TXT) used.a 2 app/views/usages.html.slim:1 p = t 'used.a' app/views/usages.html.slim:2 b = t 'used.a' - TXT - end + TXT end end - # --- setup --- - BENCH_KEYS = 10 + BENCH_KEYS = ENV['BENCH_KEYS'].to_i before(:each) do gen_data = ->(v) { v_num = v.chars.map(&:ord).join('').to_i @@ -225,21 +251,25 @@ 'devise' => {'a' => v}, 'scoped' => {'x' => v}, 'very' => {'scoped' => {'x' => v}}, - 'used' => {'a' => v} + 'used' => {'a' => v}, + 'latin_extra' => {'çüéö' => v}, + 'not_a_comment' => v }.tap { |r| - gen = r["bench"] = {} - BENCH_KEYS.times { |i| gen["key#{i}"] = v } + if BENCH_KEYS > 0 + gen = r['bench'] = {} + BENCH_KEYS.times { |i| gen["key#{i}"] = v } + end } } - en_data = gen_data.('EN_TEXT') - es_data = gen_data.('ES_TEXT').except( - 'missing_in_es', 'missing_in_es_plural_1', 'missing_in_es_plural_2') - es_data['same_in_es']['a'] = 'EN_TEXT' - es_data['blank_in_es']['a'] = '' - es_data['ignore_eq_base_all']['a'] = 'EN_TEXT' - es_data['ignore_eq_base_es']['a'] = 'EN_TEXT' - es_data['only_in_es'] = 1 + en_data = gen_data.('EN_TEXT') + es_data = gen_data.('ES_TEXT').except('missing_in_es', 'missing_in_es_plural_1', 'missing_in_es_plural_2') + + es_data['same_in_es']['a'] = 'EN_TEXT' + es_data['blank_in_es']['a'] = '' + es_data['ignore_eq_base_all']['a'] = 'EN_TEXT' + es_data['ignore_eq_base_es']['a'] = 'EN_TEXT' + es_data['only_in_es'] = 1 es_data['present_in_es_but_not_en'] = {'a' => 'ES_TEXT'} fs = fixtures_contents.merge( diff --git a/spec/locale_tree/siblings_spec.rb b/spec/locale_tree/siblings_spec.rb index 46492180..684ca1e9 100644 --- a/spec/locale_tree/siblings_spec.rb +++ b/spec/locale_tree/siblings_spec.rb @@ -6,12 +6,32 @@ context 'Node' do it '::new with children' do children = I18n::Tasks::Data::Tree::Siblings.from_key_attr([['a', value: 1]]) - node = I18n::Tasks::Data::Tree::Node.new( + node = new_node( key: 'fr', children: children ) expect(node.to_siblings.first.children.parent.key).to eq 'fr' end + + it '== (false by value)' do + expect(build_node({'a' => {'b' => {'c' => 1}}})).to_not( + eq(build_node({'a' => {'b' => {'c' => 2}}}))) + end + + it '== (false by key)' do + expect(build_node({'a' => {'b' => {'c' => 1}}})).to_not( + eq(build_node({'a' => {'b' => {'d' => 1}}}))) + end + + it '== (false by children)' do + expect(build_node({'a' => {'b' => {'c' => 1}}})).to_not( + eq(build_node({'a' => {'b' => {'c' => 1}, 'x' => 2}}))) + end + + it '== (true)' do + expect(build_node({'a' => {'b' => {'c' => 1}, 'x' => 2}})).to_not( + eq(build_node({'a' => {'b' => {'d' => 1}, 'x' => 2}}))) + end end context 'a tree' do @@ -55,7 +75,7 @@ it '#set conflict value <- scope' do a = build_tree(a: 1) - expect { silence_stderr { a.set('a.b', build_node(key: 'b', value: 1)) } }.to_not raise_error + expect { silence_stderr { a.set('a.b', new_node(key: 'b', value: 1)) } }.to_not raise_error end it '#intersect' do @@ -72,18 +92,18 @@ end it '#append!' do - expect(build_tree({'a' => 1}).append!(build_node(key: 'b', value: 2)).to_hash).to eq('a' => 1, 'b' => 2) + expect(build_tree({'a' => 1}).append!(new_node(key: 'b', value: 2)).to_hash).to eq('a' => 1, 'b' => 2) end it '#set replace value' do - expect(build_tree(a: {b: 1}).tap {|t| t['a.b'] = build_node(key: 'b', value: 2) }.to_hash).to( + expect(build_tree(a: {b: 1}).tap {|t| t['a.b'] = new_node(key: 'b', value: 2) }.to_hash).to( eq('a' => {'b' => 2}) ) end it '#set get' do t = build_tree(a: {x: 1}) - node = build_node(key: 'd', value: 'e') + node = new_node(key: 'd', value: 'e') t['a.b.c.' + node.key] = node expect(t['a.b.c.d'].value).to eq('e') end diff --git a/spec/pattern_scanner_spec.rb b/spec/pattern_scanner_spec.rb index 46d7304e..2a0e954e 100644 --- a/spec/pattern_scanner_spec.rb +++ b/spec/pattern_scanner_spec.rb @@ -2,21 +2,57 @@ require 'spec_helper' describe 'Pattern Scanner' do - describe 'default pattern' do + describe 'scan_file' do + it 'returns absolute keys from controllers' do + file_path = 'spec/fixtures/app/controllers/events_controller.rb' + scanner = I18n::Tasks::Scanners::PatternScanner.new + allow(scanner).to receive(:relative_roots).and_return(['spec/fixtures/app/controllers']) + + keys = scanner.scan_file(file_path) + + expect(keys).to include( + ["events.show.success", + {:data=> + { + :src_path=>"spec/fixtures/app/controllers/events_controller.rb", + :pos=>790, + :line_num=>34, + :line_pos=>10, + :line =>" I18n.t(\".success\")"} + } + ] + ) + end + end + + describe 'default_pattern' do let!(:pattern) { I18n::Tasks::Scanners::PatternScanner.new.default_pattern } - ['t "a.b"', "t 'a.b'", 't("a.b")', "t('a.b')", - "t('a.b', :arg => val)", "t('a.b', arg: val)", - "t :a_b", "t :'a.b'", 't :"a.b"', "t(:ab)", "t(:'a.b')", 't(:"a.b")', - 'I18n.t("a.b")', 'I18n.translate("a.b")'].each do |s| - it "matches #{s}" do - expect(pattern).to match s + [ + 't(".a.b")', + 't "a.b"', + "t 'a.b'", + 't("a.b")', + "t('a.b')", + "t('a.b', :arg => val)", + "t('a.b', arg: val)", + "t :a_b", + "t :'a.b'", + 't :"a.b"', + "t(:ab)", + "t(:'a.b')", + 't(:"a.b")', + 'I18n.t("a.b")', + 'I18n.translate("a.b")' + ].each do |string| + it "matches #{string}" do + expect(pattern).to match string end end - ["t \"a.b'", "t a.b"].each do |s| - it "does not match #{s}" do - expect(pattern).to_not match s + ["t \"a.b'", "t a.b"].each do |string| + it "does not match #{string}" do + expect(pattern).to_not match string end end end diff --git a/spec/readme_spec.rb b/spec/readme_spec.rb index 4ecb3fc6..550bdbb2 100644 --- a/spec/readme_spec.rb +++ b/spec/readme_spec.rb @@ -1,7 +1,7 @@ # coding: utf-8 -require 'spec_helper' + describe 'README.md' do - let(:readme) { File.read('README.md') } + let(:readme) { File.read('README.md', encoding: 'UTF-8') } it 'has valid YAML in ```yaml blocks' do readme.scan /```yaml\n(.*)(?=^)\n```/ do |m| expect { YAML.load(m[0]) }.to_not raise_errors diff --git a/spec/relative_keys_spec.rb b/spec/relative_keys_spec.rb index c6fe5f2d..c4b97af1 100644 --- a/spec/relative_keys_spec.rb +++ b/spec/relative_keys_spec.rb @@ -17,6 +17,43 @@ end end - end + context 'relative key in controller' do + it 'works' do + key = scanner.absolutize_key( + '.success', + 'app/controllers/users_controller.rb', + %w(app/controllers), + 'create' + ) + + expect(key).to eq('users.create.success') + end + + context 'multiple words in controller name' do + it 'works' do + key = scanner.absolutize_key( + '.success', + 'app/controllers/admin_users_controller.rb', + %w(app/controllers), + 'create' + ) + expect(key).to eq('admin_users.create.success') + end + end + + context 'nested in module' do + it 'works' do + key = scanner.absolutize_key( + '.success', + 'app/controllers/nested/users_controller.rb', + %w(app/controllers), + 'create' + ) + + expect(key).to eq('nested.users.create.success') + end + end + end + end end diff --git a/spec/support/capture_std.rb b/spec/support/capture_std.rb index 09502e97..d40193f3 100644 --- a/spec/support/capture_std.rb +++ b/spec/support/capture_std.rb @@ -15,4 +15,8 @@ def capture_stdout ensure $stdout = out end + + def silence_stderr(&block) + silence_stream($stderr, &block) + end end diff --git a/spec/support/i18n_tasks_output_matcher.rb b/spec/support/i18n_tasks_output_matcher.rb index 76940a9b..184df2c3 100644 --- a/spec/support/i18n_tasks_output_matcher.rb +++ b/spec/support/i18n_tasks_output_matcher.rb @@ -15,7 +15,6 @@ def extract_keys(actual) locale_col = 0 key_col = 1 actual.map { |row| - key = key = "#{row[locale_col]}.#{row[key_col]}" key = key[0..-2] if key.end_with?(':') key diff --git a/spec/support/test_codebase.rb b/spec/support/test_codebase.rb index ed9673b3..1fd0a364 100644 --- a/spec/support/test_codebase.rb +++ b/spec/support/test_codebase.rb @@ -3,6 +3,7 @@ require 'yaml' require_relative 'capture_std' require 'i18n/tasks/commands' +require 'i18n/tasks/cli' module TestCodebase include CaptureStd @@ -10,23 +11,29 @@ module TestCodebase AT = 'tmp/test_codebase' def i18n_task(*args) - TestCodebase.in_test_app_dir do + in_test_app_dir do ::I18n::Tasks::BaseTask.new(*args) end end - def i18n_cmd(*args) - TestCodebase.in_test_app_dir do - ::I18n::Tasks::Commands.new(*args) - end + def run_cmd(name, *args) + capture_stdout { capture_stderr { in_test_app_dir { + run_cli(name, *args) + } } } end - def run_cmd(name, *args, &block) - in_test_app_dir do - silence_stderr { - capture_stdout { i18n_cmd.run(name, *args, &block) } - } - end + def run_cmd_capture_stderr(name, *args) + capture_stderr { capture_stdout { in_test_app_dir { + run_cli(name, *args) + } } } + end + + def run_cli(name, *args) + i18n_cli.run([name, *args]) + end + + def i18n_cli + in_test_app_dir { ::I18n::Tasks::CLI.new } end def setup(files = {}) @@ -51,13 +58,13 @@ def rake_result(task, *args) } end - def in_test_app_dir(&block) - return block.call if @in_dir + def in_test_app_dir + return yield if @in_dir begin pwd = Dir.pwd Dir.chdir AT @in_dir = true - block.call + yield ensure Dir.chdir pwd @in_dir = false diff --git a/spec/support/trees.rb b/spec/support/trees.rb index 6e1df886..e5887f3d 100644 --- a/spec/support/trees.rb +++ b/spec/support/trees.rb @@ -10,6 +10,12 @@ def build_tree(hash) end def build_node(attr = {}) + raise 'invalid node (more than 1 root)' if attr.size > 1 + key, value = attr.first + I18n::Tasks::Data::Tree::Node.from_key_value(key, value) + end + + def new_node(attr = {}) I18n::Tasks::Data::Tree::Node.new(attr) end end diff --git a/templates/config/i18n-tasks.yml b/templates/config/i18n-tasks.yml index 8d97b7da..137483b0 100644 --- a/templates/config/i18n-tasks.yml +++ b/templates/config/i18n-tasks.yml @@ -1,75 +1,88 @@ -# i18n-tasks finds and manages missing and unused translations https://github.com/glebm/i18n-tasks +# i18n-tasks finds and manages missing and unused translations: https://github.com/glebm/i18n-tasks +# The "main" locale. base_locale: en -## i18n-tasks detects locales automatically from the existing locale files -## uncomment to set locales explicitly -# locales: [en, es, fr] +## All available locales are inferred from the data by default. Alternatively, specify them explicitly: +# locales: [es, fr] +## Reporting locale, default: en. Available: en, ru. +# internal_locale: en -## i18n-tasks report locale, default: en, available: en, ru -# internal_locale: ru - -# Read and write locale data +# Read and write translations. data: - ## by default, translation data are read from the file system, or you can provide a custom data adapter + ## Translations are read from the file system. Supported format: YAML, JSON. + ## Provide a custom adapter: # adapter: I18n::Tasks::Data::FileSystem - # Locale files to read from + # Locale files or `File.find` patterns where translations are read from: read: - - config/locales/%{locale}.yml - # - config/locales/*.%{locale}.yml + ## Default: + # - config/locales/%{locale}.yml + ## More files: # - config/locales/**/*.%{locale}.yml + ## Another gem: + # - "<%= %x[bundle show vagrant].chomp %>/templates/locales/%{locale}.yml" - # key => file routes, matched top to bottom + # Locale files to write new keys to, based on a list of key pattern => file rules. Matched from top to bottom: + # `i18n-tasks normalize -p` will force move the keys according to these rules write: - ## E.g., write devise and simple form keys to their respective files + ## For example, write devise and simple form keys to their respective files: # - ['{devise, simple_form}.*', 'config/locales/\1.%{locale}.yml'] - # Catch-all - - config/locales/%{locale}.yml - # `i18n-tasks normalize -p` will force move the keys according to these rules + ## Catch-all default: + # - config/locales/%{locale}.yml + + ## Specify the router (see Readme for details). Valid values: conservative_router, pattern_router, or a custom class. + # router: convervative_router - # YAML / JSON serializer options, passed to load / dump / parse / serialize yaml: write: # do not wrap lines at 80 characters line_width: -1 - json: - write: - # pretty print JSON - indent: ' ' - space: ' ' - object_nl: "\n" - array_nl: "\n" + + ## Pretty-print JSON: + # json: + # write: + # indent: ' ' + # space: ' ' + # object_nl: "\n" + # array_nl: "\n" # Find translate calls search: - ## Default scanner finds t() and I18n.t() calls - # scanner: I18n::Tasks::Scanners::PatternWithScopeScanner + ## Paths or `File.find` patterns to search in: + # paths: + # - app/ - ## Paths to search in, passed to File.find - paths: - - app/ - - ## Root for resolving relative keys (default) + ## Root directories for relative keys resolution. # relative_roots: # - app/views - - ## File.fnmatch patterns to exclude from search (default) - # exclude: ["*.jpg", "*.png", "*.gif", "*.svg", "*.ico", "*.eot", "*.ttf", "*.woff", "*.pdf"] - - ## Or, File.fnmatch patterns to include + # - app/controllers + # - app/helpers + # - app/presenters + + ## Files or `File.fnmatch` patterns to exclude from search. Some files are always excluded regardless of this setting: + ## %w(*.jpg *.png *.gif *.svg *.ico *.eot *.otf *.ttf *.woff *.woff2 *.pdf *.css *.sass *.scss *.less *.yml *.json) + exclude: + - app/assets/images + - app/assets/fonts + + ## Alternatively, the only files or `File.fnmatch patterns` to search in `paths`: + ## If specified, this settings takes priority over `exclude`, but `exclude` still applies. # include: ["*.rb", "*.html.slim"] + ## Default scanner finds t() and I18n.t() calls. + # scanner: I18n::Tasks::Scanners::PatternWithScopeScanner + ## Google Translate # translation: # # Get an API key and set billing info at https://code.google.com/apis/console to use Google Translate # api_key: "AbC-dEf5" -## Consider these keys not missing +## Do not consider these keys missing: # ignore_missing: # - 'errors.messages.{accepted,blank,invalid,too_short,too_long}' # - '{devise,simple_form}.*' -## Consider these keys used +## Consider these keys used: # ignore_unused: # - 'activerecord.attributes.*' # - '{devise,kaminari,will_paginate}.*' @@ -77,13 +90,13 @@ search: # - 'simple_form.{placeholders,hints,labels}.*' # - 'simple_form.{error_notification,required}.:' -## Exclude these keys from `i18n-tasks eq-base' report +## Exclude these keys from the `i18n-tasks eq-base' report: # ignore_eq_base: # all: # - common.ok # fr,es: # - common.brand -## Exclude these keys from all of the reports +## Ignore these keys completely: # ignore: # - kaminari.* diff --git a/templates/rspec/i18n_spec.rb b/templates/rspec/i18n_spec.rb index c1b7aeef..71211b8a 100644 --- a/templates/rspec/i18n_spec.rb +++ b/templates/rspec/i18n_spec.rb @@ -1,4 +1,3 @@ -require 'spec_helper' require 'i18n/tasks' describe 'I18n' do