Repository: aderyabin/localer Branch: master Commit: 4622d727ab40 Files: 51 Total size: 48.9 KB Directory structure: gitextract_v_eyauum/ ├── .github/ │ └── workflows/ │ ├── bundle-audit.yml │ ├── rubocop.yml │ └── test.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── Appraisals ├── CODE_OF_CONDUCT.md ├── Gemfile ├── LICENSE.txt ├── README.md ├── Rakefile ├── _config.yml ├── bin/ │ └── localer ├── features/ │ ├── global_exclude.feature │ ├── locale_option.feature │ ├── missing_app.feature │ ├── real_locales.feature │ ├── simple.feature │ ├── step_definitions/ │ │ └── additional_cli_steps.rb │ └── support/ │ └── env.rb ├── gemfiles/ │ ├── rails50.gemfile │ ├── rails51.gemfile │ ├── rails52.gemfile │ ├── rails60.gemfile │ └── rails61.gemfile ├── lib/ │ ├── localer/ │ │ ├── config/ │ │ │ └── locale.rb │ │ ├── config.rb │ │ ├── data/ │ │ │ ├── checker.rb │ │ │ ├── missing_translations.rb │ │ │ ├── processor.rb │ │ │ └── service.rb │ │ ├── data.rb │ │ ├── ext/ │ │ │ ├── hash.rb │ │ │ └── string.rb │ │ ├── rails.rb │ │ ├── rake_task.rb │ │ └── version.rb │ └── localer.rb ├── localer.gemspec └── spec/ ├── dummy_app/ │ ├── config/ │ │ ├── application.rb │ │ ├── environment.rb │ │ └── locales/ │ │ ├── en.rails.rb │ │ ├── en.rails.yml │ │ ├── ru.rails.rb │ │ ├── ru.rails.yml │ │ ├── us.rails.rb │ │ └── us.rails.yml │ └── config.ru ├── localer_spec.rb └── spec_helper.rb ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/bundle-audit.yml ================================================ name: Bundle Audit on: push: branches: - master pull_request: schedule: - cron: "0 0 * * *" jobs: rubocop: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: ruby/setup-ruby@v1 with: ruby-version: 2.7 - name: Patch-level verification for Bundler run: | gem install bundle-audit bundle-audit check --update ================================================ FILE: .github/workflows/rubocop.yml ================================================ name: Lint Ruby on: push: branches: - master pull_request: jobs: rubocop: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: ruby/setup-ruby@v1 with: ruby-version: 2.7 - name: Lint Ruby code with RuboCop run: | gem install rubocop rubocop ================================================ FILE: .github/workflows/test.yml ================================================ name: Build on: push: branches: - master pull_request: jobs: test: runs-on: ubuntu-latest env: BUNDLE_JOBS: 4 BUNDLE_RETRY: 3 VERIFY_RESERVED: 1 CI: true CUCUMBER_PUBLISH_QUIET: true strategy: fail-fast: false matrix: ruby: [2.7, 2.6, 2.5, 2.4] gemfile: [ 'gemfiles/rails50.gemfile', 'gemfiles/rails51.gemfile', 'gemfiles/rails52.gemfile', 'gemfiles/rails60.gemfile', 'gemfiles/rails61.gemfile' ] exclude: - ruby: 2.4 gemfile: gemfiles/rails52.gemfile - ruby: 2.4 gemfile: gemfiles/rails60.gemfile - ruby: 2.4 gemfile: gemfiles/rails61.gemfile steps: - uses: actions/checkout@v2 - uses: actions/cache@v1 with: path: /home/runner/bundle key: bundle-${{ matrix.ruby }}-${{ matrix.gemfile }}-${{ hashFiles(matrix.gemfile) }}-${{ hashFiles('**/*.gemspec') }} restore-keys: | bundle-${{ matrix.ruby }}-${{ matrix.gemfile }}- - uses: ruby/setup-ruby@v1 with: ruby-version: ${{ matrix.ruby }} - name: Bundle install run: | bundle config path /home/runner/bundle bundle config --global gemfile ${{ matrix.gemfile }} bundle install bundle update - name: Run tests run: bundle exec rake ================================================ FILE: .gitignore ================================================ /.bundle/ /.yardoc /_yardoc/ /coverage/ /doc/ /pkg/ /spec/reports/ /tmp/ Gemfile.lock erl_crash.dump .ruby-version .vscode spec/dummy_app/log/ vendor/ .devcontainer/ ================================================ FILE: .rspec ================================================ --format documentation --color --require spec_helper ================================================ FILE: .rubocop.yml ================================================ AllCops: # Include gemspec and Rakefile Include: - "lib/**/*.rb" - "lib/**/*.rake" - "spec/**/*.rb" Exclude: - "bin/**/*" - "lib/localer/ext/*.rb" - "Appraisals" - "Gemfile" - "Rakefile" - "*.gemspec" - "spec/dummy_app/" DisplayCopNames: true NewCops: enable StyleGuideCopsOnly: false Naming/AccessorMethodName: Enabled: false Style/PercentLiteralDelimiters: Enabled: false Style/TrivialAccessors: Enabled: false Style/Documentation: Exclude: - "spec/**/*.rb" Style/StringLiterals: Enabled: false Style/BlockDelimiters: Exclude: - "spec/**/*.rb" Style/DoubleNegation: Enabled: false Style/HashEachMethods: Enabled: true Style/HashTransformKeys: Enabled: true Style/HashTransformValues: Enabled: true Layout/SpaceInsideStringInterpolation: EnforcedStyle: no_space Lint/AmbiguousRegexpLiteral: Enabled: false Lint/AmbiguousBlockAssociation: Enabled: false Metrics/MethodLength: Exclude: - "spec/**/*.rb" Layout/LineLength: Max: 120 Exclude: - "spec/**/*.rb" Metrics/BlockLength: Exclude: - "spec/**/*.rb" Security/YAMLLoad: Enabled: false ================================================ FILE: Appraisals ================================================ appraise 'rails50' do gem 'rails', '~> 5.0' end appraise 'rails51' do gem 'rails', '~> 5.1' end appraise 'rails52' do gem 'rails', '~> 5.2' end appraise 'rails60' do gem 'rails', '~> 6.0' end appraise 'rails61' do gem 'rails', '~> 6.1' end ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. ## Our Standards Examples of behavior that contributes to creating a positive environment include: * Using welcoming and inclusive language * Being respectful of differing viewpoints and experiences * Gracefully accepting constructive criticism * Focusing on what is best for the community * Showing empathy towards other community members Examples of unacceptable behavior by participants include: * The use of sexualized language or imagery and unwelcome sexual attention or advances * Trolling, insulting/derogatory comments, and personal or political attacks * Public or private harassment * Publishing others' private information, such as a physical or electronic address, without explicit permission * Other conduct which could reasonably be considered inappropriate in a professional setting ## Our Responsibilities Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. ## Scope This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at deriabin@gmail.com. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] [homepage]: http://contributor-covenant.org [version]: http://contributor-covenant.org/version/1/4/ ================================================ FILE: Gemfile ================================================ source "https://rubygems.org" git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } # Specify your gem's dependencies in localer.gemspec gemspec ================================================ FILE: LICENSE.txt ================================================ The MIT License (MIT) Copyright (c) 2018 Andrey Deryabin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================

[![Gem Version](https://badge.fury.io/rb/localer.svg)](https://rubygems.org/gems/localer) [![Build Status](https://github.com/aderyabin/localer/workflows/Build/badge.svg)](https://github.com/aderyabin/localer/actions) Localer is a tool that automatically detects missing I18n translations. The goal is to preserve the integrity of translations. Localer parses and merges all application locales’ keys. At the next step, it searches for missing translations among the calculated keys.

## Installation Add this line to your application's Gemfile: ```ruby gem 'localer' ``` And then execute: $ bundle Or install it yourself as: $ gem install localer ## Usage At the root directory of a Rails app, run: $ localer check . or for specific Rails path: $ localer check /path/to/rails/application ## CI integration Localer is easy to integrate into your favorite CI workflow: ```yml # .travis.yml # other configuration options script: - bundle exec bundle-audit - bundle exec rubocop - bundle exec rspec - bundle exec localer ``` or ```ruby # Rakefile # other requirements require 'localer/rake_task' Localer::RakeTask.new() task(:default).clear task default: [:rubocop, :spec, :localer] ``` ## Support Localer supports * Ruby: 2.4, 2.5, 2.6, 2.7 * Rails: 5.0, 5.1, 5.2, 6.0, 6.1 ## Configuration The behavior of Localer can be controlled via the `.localer.yml` configuration file. It makes it possible to disable locales and keys. The file can be placed in your project directory. #### Disable specific locale By default, Localer enables all locales, but you can disable it: ```yml Locale: EN: Enabled: false ``` #### Exclude keys globally By default, Localer enables all keys, but you can disable keys started with specified string or by regex: ```yml Exclude: - /population\z/ - .countries.france ``` #### Exclude keys for specific locale ```yml Locale: EN: Exclude: - /population\z/ - .countries.france ``` ## Using Rake Localer ships with a rake task. To use Localer's rake task you simply need to require the task file and define a task with it. Below is a rake task that will run `localer`: ```ruby require 'rubygems' require 'localer' require 'localer/rake_task' Localer::RakeTask.new(:localer) ``` When you now run: $ rake -T you should see ``` rake localer # Run Localer ``` ## Development After checking out the repo, run `bundle exec appraisal install` to install dependencies for each appraisal. Then, run `bundle exec appraisal rake` to run the tests. ## Built With * [Thor](https://github.com/erikhuda/thor) - Used for building command-line interfaces. * [Appraisal](https://github.com/thoughtbot/appraisal) - Used for testing against different versions of dependencies * [Cucumber](https://github.com/cucumber/cucumber) + [Aruba](https://github.com/cucumber/aruba) - Used for testing command-line commands ## Acknowledge Special thanks to [Roman Shamin](https://www.facebook.com/romanshamin) for the logo. ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/aderyabin/localer. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct. ## License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). ## Code of Conduct Everyone interacting in the Localer project’s codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/aderyabin/localer/blob/master/CODE_OF_CONDUCT.md). ================================================ FILE: Rakefile ================================================ require "bundler/gem_tasks" require "rubocop/rake_task" require 'cucumber/rake/task' RuboCop::RakeTask.new Cucumber::Rake::Task.new(:features) do |t| t.cucumber_opts = "features --format pretty" end task :default do if ENV["RUBOCOP"] Rake::Task["rubocop"].invoke else Rake::Task["features"].invoke end end ================================================ FILE: _config.yml ================================================ theme: jekyll-theme-leap-day ================================================ FILE: bin/localer ================================================ #!/usr/bin/env ruby require "thor" require "irb" require_relative "../lib/localer" module Localer class CLI < Thor desc "version", "Print Localer version" def version say Localer::VERSION end desc "check [/path/to/rails/application]", "Check missing translations" def check(app_path = Localer::Config::APP_PATH) Localer.configure(options.dup.merge(app_path: app_path)) connect_to_rails if Localer.data.complete? say "\xE2\x9C\x94 No missing translations found.", :green else missing_translations = Localer.data.missing_translations say "\xE2\x9C\x96 Missing translations found (#{missing_translations.count}):", :red missing_translations.each do |tr| say "* #{tr}" end exit 1 end end default_task :check private def connect_to_rails return if Localer::Rails.connect! say "No Rails application found" exit 1 end end end Localer::CLI.start(ARGV) ================================================ FILE: features/global_exclude.feature ================================================ Feature: Localer Scenario: Exclude strings Given a real locales Given a config file with: """ Exclude: - .countries.france - .population """ When I run checker Then the checker should pass Scenario: Exclude regexp Given a real locales Given a config file with: """ Exclude: - /population\z/ """ When I run checker Then the checker should pass Scenario: Exclude strict regexp Given a real locales Given a config file with: """ Exclude: - /^.population/ """ When I run checker Then the checker should returns 1 missing translations: | en.countries.france.population | ================================================ FILE: features/locale_option.feature ================================================ Feature: Localer Scenario: Disable en locale Given a real locales Given a config file with: """ Locale: en: Enabled: false """ When I run checker Then the checker should pass Scenario: Disable case-insensitive EN locale Given a real locales Given a config file with: """ Locale: EN: Enabled: false """ When I run checker Then the checker should pass Scenario: Disable en.population Given a real locales Given a config file with: """ Locale: en: Exclude: - .population.italy """ When I run checker Then the checker should returns 2 missing translations: | en.countries.france.population | | en.population.france | Scenario: With empty config file Given a real locales Given a config file with: """ """ When I run checker Then the checker should returns 4 missing translations: | ru.population.italy | | us.population.italy | | en.countries.france.population | | en.population.france | ================================================ FILE: features/missing_app.feature ================================================ Feature: Localer Scenario: No rails application When I run `localer check` Then the checker should not found rails application Scenario: No rails application at existed paths When I run `localer check` Then the checker should not found rails application Scenario: No rails application at not non-existed When I run `localer check non-existed_path` Then the checker should not found rails application ================================================ FILE: features/real_locales.feature ================================================ Feature: Localer Scenario: Real locales does not pass Given a real locales When I run checker Then the checker should returns 4 missing translations: | ru.population.italy | | us.population.italy | | en.population.france | | en.countries.france.population | ================================================ FILE: features/simple.feature ================================================ Feature: Localer Scenario: No locales files When I run checker Then the checker should pass Scenario: Empty en locale Given a "en" locale file with: """ en: """ When I run checker Then the checker should pass Scenario: Empty ru locale Given a "ru" locale file with: """ ru: """ When I run checker Then the checker should pass Scenario: Complete locales Given a "en" locale file with: """ en: one: one """ Given a "ru" locale file with: """ ru: one: один """ Given a "us" locale file with: """ us: one: one """ When I run checker Then the checker should pass Scenario: Empty en locale Given a "ru" locale file with: """ ru: one: один """ When I run checker Then the checker should fail Scenario: Incorrect structure Given a "en" locale file with: """ en: too_long: "Too Long" """ Given a "ru" locale file with: """ ru: too_long: one: слишком большой длины (не может быть больше чем %{count} символ) other: слишком большой длины (не может быть больше чем %{count} символа) """ When I run checker Then the checker should fail ================================================ FILE: features/step_definitions/additional_cli_steps.rb ================================================ # frozen_string_literal: true Given /^a "(.*)" locale file with:$/ do |locale, file_content| write_file("#{LOCALE_DIR}/#{locale}.yml", file_content) end Given /^a config file with:$/ do |file_content| write_file(CONFIG_PATH, file_content) end Given /^a real locales$/ do # rubocop:disable Metrics/BlockLength steps %{ Given a "en" locale file with: """ en: population: italy: 60.6 countries: italy: city: Rome spain: city: Madrid france: city: Paris """ Given a "ru" locale file with: """ ru: population: france: 66.9 countries: italy: city: Рим spain: city: Мадрид france: city: Париж population: 66.9 """ Given a "us" locale file with: """ us: population: france: 66.9 countries: italy: city: Rome spain: city: Madrid france: city: Paris population: 66.9 """ } end Then /^the checker should pass$/ do step 'the output should contain "✔ No missing translations found"' step 'the exit status should be 0' end Then /^the checker should fail$/ do step 'the output should contain "✖ Missing translations found"' step 'the exit status should be 1' end Then /^the checker should returns (.*) missing translations:$/ do |int, translations| step %{the output should contain "✖ Missing translations found (#{int})"} translations.raw.each do |tr| step %{the output should match /^#{Regexp.escape('* ' + tr[0])}$/} end step 'the exit status should be 1' end Then /^the checker should not found rails application$/ do step 'the output should contain "No Rails application found"' step 'the exit status should be 1' end When /^I run checker$/ do if defined?(run) run("localer check ../../spec/dummy_app") else run_command("localer check ../../spec/dummy_app") end end ================================================ FILE: features/support/env.rb ================================================ # frozen_string_literal: true require 'aruba/cucumber' DUMMY_APP_DIR = "../../spec/dummy_app" LOCALE_DIR = "#{DUMMY_APP_DIR}/config/locales" CONFIG_PATH = "#{DUMMY_APP_DIR}/.localer.yml" After do |_| %w[ru en us].each do |locale| path = "#{LOCALE_DIR}/#{locale}.yml" remove(path) if exist?(path) end remove(CONFIG_PATH) if exist?(CONFIG_PATH) end ================================================ FILE: gemfiles/rails50.gemfile ================================================ # This file was generated by Appraisal source "https://rubygems.org" gem "rails", "~> 5.0" gemspec path: "../" ================================================ FILE: gemfiles/rails51.gemfile ================================================ # This file was generated by Appraisal source "https://rubygems.org" gem "rails", "~> 5.1" gemspec path: "../" ================================================ FILE: gemfiles/rails52.gemfile ================================================ # This file was generated by Appraisal source "https://rubygems.org" gem "rails", "~> 5.2" gemspec path: "../" ================================================ FILE: gemfiles/rails60.gemfile ================================================ # This file was generated by Appraisal source "https://rubygems.org" gem "rails", "~> 6.0" gemspec path: "../" ================================================ FILE: gemfiles/rails61.gemfile ================================================ # This file was generated by Appraisal source "https://rubygems.org" gem "rails", "~> 6.1" gemspec path: "../" ================================================ FILE: lib/localer/config/locale.rb ================================================ # frozen_string_literal: true module Localer class Config # Provide config for locale class Locale extend Dry::Initializer option :exclude, default: -> { [] } option :enabled, default: -> { true } end end end ================================================ FILE: lib/localer/config.rb ================================================ # frozen_string_literal: true require 'yaml' require_relative '../localer/ext/hash' require_relative 'config/locale' module Localer # :nodoc: using Localer::Ext::Hash # Loads and parse Localer config file `.localer.yml` class Config extend Dry::Initializer APP_PATH = Dir.pwd CONFIG_FILENAME = ".localer.yml" option :exclude, default: -> { [] } option :locale, proc { |hash| parse_locales(hash) }, default: -> { Hash.new(Locale.new) } option :app_path, default: -> { APP_PATH } class << self def load(options = {}) opts = options.deep_symbolize_keys app_path = opts.fetch(:app_path, APP_PATH) file_options = file_config(CONFIG_FILENAME, app_path) new(file_options.deep_merge(opts).deep_symbolize_keys) end def file_config(filename, path) filename = File.expand_path(filename, path) return {} unless File.exist?(filename) return {} if File.zero?(filename) YAML .load_file(filename) .deep_downcase_keys .deep_symbolize_keys end def parse_locales(hash) hash.each_with_object(Hash.new(Locale.new)) do |(l, v), h| h[l] = Locale.new(v) end end end end end ================================================ FILE: lib/localer/data/checker.rb ================================================ # frozen_string_literal: true module Localer class Data # Check missing translations # Returns true if no missing translations found, otherwise false class Checker < Service param :data def call data.each do |_locale, _key, value| return false if value.nil? end true end end end end ================================================ FILE: lib/localer/data/missing_translations.rb ================================================ # frozen_string_literal: true module Localer class Data # A service that returns array of missing translations class MissingTranslations < Service param :data def call missing = [] data.each do |locale, key, value| missing.push("#{locale}#{key}") if value.nil? end missing end end end end ================================================ FILE: lib/localer/data/processor.rb ================================================ # frozen_string_literal: true require_relative '../ext/string' module Localer # :nodoc: using Localer::Ext::String class Data # Parse translations into hash: # key: translation key # value: hash of locale values class Processor < Service param :translations param :config, default: -> { Localer.config } attr_reader :data, :locales def call @data = Hash.new { |hsh, key| hsh[key] = {} } @locales = [] translations.each do |(locale, translation)| next unless config.locale[locale.downcase].enabled @locales.push locale prepare(locale, translation) end [@locales, @data] end private def prepare(locale, translation, prefix = "") if translation.is_a?(Hash) translation.each do |(key, value)| full_key = prefix + ".#{key}" next if exclude?(full_key, locale) prepare(locale, value, full_key) end else # @data[prefix] ||= {} @data[prefix][locale] = translation end end def exclude?(key, locale) (config.exclude + config.locale[locale.downcase].exclude).any? do |pattern| match?(key, pattern) end end def match?(key, pattern) if (regex = pattern.to_regexp) key =~ regex else key.start_with?(pattern) end end end end end ================================================ FILE: lib/localer/data/service.rb ================================================ # frozen_string_literal: true require 'dry-initializer' module Localer class Data # Core service object class Service extend Dry::Initializer # use `param` and `option` for dependencies class << self # Instantiates and calls the service at once def call(*args, &block) new(*args).call(&block) end end end end end ================================================ FILE: lib/localer/data.rb ================================================ # frozen_string_literal: true require_relative "data/service" require_relative "data/checker" require_relative "data/processor" require_relative "data/missing_translations" module Localer # Stores translations and provides # check methods class Data extend Dry::Initializer param :source, default: -> { {} } param :config, default: -> { Localer.config } attr_reader :translations, :locales def initialize(*args) super @locales, @translations = Processor.call(source, config) end def complete? Checker.call(self) end def missing_translations MissingTranslations.call(self) end def each @translations.each do |key, value| @locales.each do |locale| yield locale, key, value[locale] end end end end end ================================================ FILE: lib/localer/ext/hash.rb ================================================ # frozen_string_literal: true module Localer module Ext # Extend Hash through refinements module Hash refine ::Hash do # From ActiveSupport http://api.rubyonrails.org/classes/Hash.html#metho def deep_merge!(other_hash) other_hash.each_pair do |current_key, other_value| this_value = self[current_key] if this_value.is_a?(::Hash) && other_value.is_a?(::Hash) this_value.deep_merge!(other_value) this_value else self[current_key] = other_value end end self end def deep_merge(other_hash, &block) dup.deep_merge!(other_hash, &block) end def deep_symbolize_keys deep_transform_keys do |key| begin key.to_sym rescue StandardError key end end end def deep_downcase_keys deep_transform_keys do |key| begin key.downcase rescue StandardError key end end end def deep_transform_keys(&block) _deep_transform_keys_in_object(self, &block) end def deep_transform_keys!(&block) _deep_transform_keys_in_object!(self, &block) end private def _deep_transform_keys_in_object!(object, &block) case object when ::Hash object.keys.each do |key| value = object.delete(key) object[yield(key)] = _deep_transform_keys_in_object!(value, &block) end object when Array object.map! { |e| _deep_transform_keys_in_object!(e, &block) } else object end end # support methods for deep transforming nested hashes and arrays def _deep_transform_keys_in_object(object, &block) case object when ::Hash object.each_with_object({}) do |(key, value), result| result[yield(key)] = _deep_transform_keys_in_object(value, &block) end when Array object.map { |e| _deep_transform_keys_in_object(e, &block) } else object end end end end end end ================================================ FILE: lib/localer/ext/string.rb ================================================ # frozen_string_literal: true module Localer module Ext # Extend Hash through refinements # taken from https://github.com/seamusabshere/to_regexp module String INLINE_OPTIONS = /[imxnesu]*/ REGEXP_DELIMITERS = { '%r{' => '}', '/' => '/' }.freeze refine ::String do def literal? REGEXP_DELIMITERS.none? { |s, e| start_with?(s) && self =~ /#{e}#{INLINE_OPTIONS}\z/ } end def to_regexp(options = {}) if args = as_regexp(options) ::Regexp.new(*args) end end def as_regexp(options = {}) raise ::ArgumentError, "[to_regexp] Options must be a Hash" unless options.is_a?(::Hash) str = self return if options[:detect] && (str == '') if options[:literal] || (options[:detect] && str.literal?) content = ::Regexp.escape str elsif delim_set = REGEXP_DELIMITERS.detect { |k, _| str.start_with?(k) } delim_start, delim_end = delim_set /\A#{delim_start}(.*)#{delim_end}(#{INLINE_OPTIONS})\z/u =~ str content = Regexp.last_match(1) inline_options = Regexp.last_match(2) return unless content.is_a?(::String) content.gsub! '\\/', '/' if inline_options options[:ignore_case] = true if inline_options.include?('i') options[:multiline] = true if inline_options.include?('m') options[:extended] = true if inline_options.include?('x') # 'n', 'N' = none, 'e', 'E' = EUC, 's', 'S' = SJIS, 'u', 'U' = UTF-8 options[:lang] = inline_options.scan(/[nesu]/i).join.downcase end else return end ignore_case = options[:ignore_case] ? ::Regexp::IGNORECASE : 0 multiline = options[:multiline] ? ::Regexp::MULTILINE : 0 extended = options[:extended] ? ::Regexp::EXTENDED : 0 lang = options[:lang] || '' lang = lang.delete 'u' if (::RUBY_VERSION > '1.9') && lang.include?('u') if lang.empty? [content, (ignore_case | multiline | extended)] else [content, (ignore_case | multiline | extended), lang] end end end end end end ================================================ FILE: lib/localer/rails.rb ================================================ # frozen_string_literal: true module Localer module Rails # :nodoc: class << self def connect! require File.expand_path("config/environment", Localer.config.app_path) true rescue LoadError false end def translations return {} unless connect! I18n.backend.send(:init_translations) I18n.backend.send(:translations) end end end end ================================================ FILE: lib/localer/rake_task.rb ================================================ # frozen_string_literal: true require 'rake' require 'rake/tasklib' require 'localer' module Localer # Defines a Rake task for running Localer. # The simplest use of it goes something like: # # Localer::Rakeask.new # This will define a task named localer described as 'Run Localer'. class RakeTask < Rake::TaskLib def initialize(name = :localer, *args) # rubocop:disable Lint/MissingSuper @name = name desc 'Run Localer' task(name, *args) do |_, _task_args| sh('localer check') do |ok, res| exit res.exitstatus unless ok end end end end end ================================================ FILE: lib/localer/version.rb ================================================ # frozen_string_literal: true module Localer VERSION = "0.2.0" end ================================================ FILE: lib/localer.rb ================================================ # frozen_string_literal: true require "dry-initializer" require_relative "localer/version" require_relative "localer/rails" require_relative "localer/config" require_relative "localer/data" module Localer # :nodoc: using Localer::Ext::Hash # using Localer::Ext::String class << self def data @data ||= load_data end def config @config ||= configure end def configure(options = {}) @config = Config.load(options) end def load_data(source = Localer::Rails.translations) @data = Data.new(source) end end end ================================================ FILE: localer.gemspec ================================================ lib = File.expand_path("../lib", __FILE__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require "localer/version" Gem::Specification.new do |spec| spec.name = "localer" spec.version = Localer::VERSION spec.authors = ["Andrey Deryabin"] spec.email = ["deriabin@gmail.com"] spec.summary = %q{Automatic detecting missing I18n translations tool.} spec.description = %q{Automatic detecting missing I18n translations tool.} spec.homepage = "https://github.com/aderyabin/localer" spec.license = "MIT" spec.files = `git ls-files -z`.split("\x0").reject do |f| f.match(%r{^(test|spec|features|gemfiles)/}) end spec.bindir = "bin" spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } spec.require_paths = ["lib"] spec.add_development_dependency "appraisal" spec.add_development_dependency "bundler", "~> 1.17" spec.add_development_dependency "rake", ">= 12.3.3" spec.add_development_dependency "rspec", "~> 3.0" spec.add_development_dependency "rubocop", "~> 0.50" spec.add_development_dependency "cucumber" spec.add_development_dependency "aruba" spec.add_dependency "thor", ">= 0.19" spec.add_dependency "dry-initializer", ">= 2.0" end ================================================ FILE: spec/dummy_app/config/application.rb ================================================ # frozen_string_literal: true require "rails" class DummyApp < Rails::Application config.eager_load = false end ================================================ FILE: spec/dummy_app/config/environment.rb ================================================ # frozen_string_literal: true # Load the Rails application. require File.expand_path('application', __dir__) # Initialize the Rails application. Rails.application.initialize! ================================================ FILE: spec/dummy_app/config/locales/en.rails.rb ================================================ # frozen_string_literal: true { en: { number: { nth: { ordinals: lambda do |_key, options| number = options[:number] case number when 1 then "st" when 2 then "nd" when 3 then "rd" when 4, 5, 6, 7, 8, 9, 10, 11, 12, 13 then "th" else num_modulo = number.to_i.abs % 100 num_modulo %= 10 if num_modulo > 13 case num_modulo when 1 then "st" when 2 then "nd" when 3 then "rd" else "th" end end end, ordinalized: lambda do |_key, options| number = options[:number] "#{number}#{ActiveSupport::Inflector.ordinal(number)}" end } } } } ================================================ FILE: spec/dummy_app/config/locales/en.rails.yml ================================================ en: # Used in distance_of_time_in_words(), distance_of_time_in_words_to_now(), time_ago_in_words() datetime: distance_in_words: half_a_minute: "half a minute" less_than_x_seconds: one: "less than 1 second" other: "less than %{count} seconds" x_seconds: one: "1 second" other: "%{count} seconds" less_than_x_minutes: one: "less than a minute" other: "less than %{count} minutes" x_minutes: one: "1 minute" other: "%{count} minutes" about_x_hours: one: "about 1 hour" other: "about %{count} hours" x_days: one: "1 day" other: "%{count} days" about_x_months: one: "about 1 month" other: "about %{count} months" x_months: one: "1 month" other: "%{count} months" about_x_years: one: "about 1 year" other: "about %{count} years" over_x_years: one: "over 1 year" other: "over %{count} years" almost_x_years: one: "almost 1 year" other: "almost %{count} years" prompts: year: "Year" month: "Month" day: "Day" hour: "Hour" minute: "Minute" second: "Seconds" helpers: select: # Default value for :prompt => true in FormOptionsHelper prompt: "Please select" # Default translation keys for submit and button FormHelper submit: create: 'Create %{model}' update: 'Update %{model}' submit: 'Save %{model}' date: formats: # Use the strftime parameters for formats. # When no format has been given, it uses default. # You can provide other formats here if you like! default: "%Y-%m-%d" short: "%b %d" long: "%B %d, %Y" day_names: [Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday] abbr_day_names: [Sun, Mon, Tue, Wed, Thu, Fri, Sat] # Don't forget the nil at the beginning; there's no such thing as a 0th month month_names: [~, January, February, March, April, May, June, July, August, September, October, November, December] abbr_month_names: [~, Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec] # Used in date_select and datetime_select. order: - year - month - day time: formats: default: "%a, %d %b %Y %H:%M:%S %z" short: "%d %b %H:%M" long: "%B %d, %Y %H:%M" am: "am" pm: "pm" # Used in array.to_sentence. support: array: words_connector: ", " two_words_connector: " and " last_word_connector: ", and " number: # Used in NumberHelper.number_to_delimited() # These are also the defaults for 'currency', 'percentage', 'precision', and 'human' format: # Sets the separator between the units, for more precision (e.g. 1.0 / 2.0 == 0.5) separator: "." # Delimits thousands (e.g. 1,000,000 is a million) (always in groups of three) delimiter: "," # Number of decimals, behind the separator (the number 1 with a precision of 2 gives: 1.00) precision: 3 # Determine how rounding is performed (see BigDecimal::mode) round_mode: default # If set to true, precision will mean the number of significant digits instead # of the number of decimal digits (1234 with precision 2 becomes 1200, 1.23543 becomes 1.2) significant: false # If set, the zeros after the decimal separator will always be stripped (eg.: 1.200 will be 1.2) strip_insignificant_zeros: false # Used in NumberHelper.number_to_currency() currency: format: # Where is the currency sign? %u is the currency unit, %n the number (default: $5.00) format: "%u%n" unit: "$" # These five are to override number.format and are optional separator: "." delimiter: "," precision: 2 significant: false strip_insignificant_zeros: false # Used in NumberHelper.number_to_percentage() percentage: format: # These five are to override number.format and are optional # separator: delimiter: "" # precision: # significant: false # strip_insignificant_zeros: false format: "%n%" # Used in NumberHelper.number_to_rounded() precision: format: # These five are to override number.format and are optional # separator: delimiter: "" # precision: # significant: false # strip_insignificant_zeros: false # Used in NumberHelper.number_to_human_size() and NumberHelper.number_to_human() human: format: # These five are to override number.format and are optional # separator: delimiter: "" precision: 3 significant: true strip_insignificant_zeros: true # Used in number_to_human_size() storage_units: # Storage units output formatting. # %u is the storage unit, %n is the number (default: 2 MB) format: "%n %u" units: byte: one: "Byte" other: "Bytes" kb: "KB" mb: "MB" gb: "GB" tb: "TB" pb: "PB" eb: "EB" # Used in NumberHelper.number_to_human() decimal_units: format: "%n %u" # Decimal units output formatting # By default we will only quantify some of the exponents # but the commented ones might be defined or overridden # by the user. units: # femto: Quadrillionth # pico: Trillionth # nano: Billionth # micro: Millionth # mili: Thousandth # centi: Hundredth # deci: Tenth unit: "" # ten: # one: Ten # other: Tens # hundred: Hundred thousand: Thousand million: Million billion: Billion trillion: Trillion quadrillion: Quadrillion ================================================ FILE: spec/dummy_app/config/locales/ru.rails.rb ================================================ # frozen_string_literal: true { ru: { number: { nth: { ordinals: ->(_key, _options) {}, ordinalized: ->(_key, options) {} } } } } ================================================ FILE: spec/dummy_app/config/locales/ru.rails.yml ================================================ ru: date: abbr_day_names: - abbr_month_names: - day_names: - month_names: - order: - formats: default: "%d.%m.%Y" long: "%-d %B %Y" short: "%-d %b" time: am: утра formats: default: "%a, %d %b %Y, %H:%M:%S %z" long: "%d %B %Y, %H:%M" short: "%d %b, %H:%M" pm: вечера datetime: prompts: day: День hour: Часов minute: Минут month: Месяц second: Секунд year: Год distance_in_words: about_x_hours: one: около %{count} часа other: около %{count} часа about_x_months: one: около %{count} месяца other: около %{count} месяца about_x_years: one: около %{count} года other: около %{count} лет almost_x_years: one: почти %{count} год other: почти %{count} лет half_a_minute: меньше минуты less_than_x_minutes: one: меньше %{count} минуты other: меньше %{count} минуты less_than_x_seconds: one: меньше %{count} секунды other: меньше %{count} секунды over_x_years: one: больше %{count} года other: больше %{count} лет x_days: one: "%{count} день" other: "%{count} дня" x_minutes: one: "%{count} минуту" other: "%{count} минуты" x_months: one: "%{count} месяц" other: "%{count} месяца" x_seconds: one: "%{count} секунду" other: "%{count} секунды" number: precision: format: delimiter: '' format: delimiter: " " precision: 3 separator: "," round_mode: default significant: false strip_insignificant_zeros: false percentage: format: delimiter: '' format: "%n%" human: format: delimiter: '' precision: 1 significant: false strip_insignificant_zeros: false decimal_units: format: "%n %u" units: unit: '' billion: миллиард million: миллион quadrillion: квадриллион thousand: тысяча trillion: триллион storage_units: format: "%n %u" units: byte: one: байт other: байта gb: ГБ kb: КБ mb: МБ tb: ТБ pb: "" eb: "" currency: format: delimiter: " " format: "%n %u" precision: 2 separator: "," significant: false strip_insignificant_zeros: false unit: руб. helpers: select: prompt: 'Выберите: ' submit: create: Создать %{model} submit: Сохранить %{model} update: Сохранить %{model} support: array: last_word_connector: " и " two_words_connector: " и " words_connector: ", " ================================================ FILE: spec/dummy_app/config/locales/us.rails.rb ================================================ # frozen_string_literal: true { us: { number: { nth: { ordinals: lambda do |_key, options| number = options[:number] case number when 1 then "st" when 2 then "nd" when 3 then "rd" when 4, 5, 6, 7, 8, 9, 10, 11, 12, 13 then "th" else num_modulo = number.to_i.abs % 100 num_modulo %= 10 if num_modulo > 13 case num_modulo when 1 then "st" when 2 then "nd" when 3 then "rd" else "th" end end end, ordinalized: lambda do |_key, options| number = options[:number] "#{number}#{ActiveSupport::Inflector.ordinal(number)}" end } } } } ================================================ FILE: spec/dummy_app/config/locales/us.rails.yml ================================================ us: # Used in distance_of_time_in_words(), distance_of_time_in_words_to_now(), time_ago_in_words() datetime: distance_in_words: half_a_minute: "half a minute" less_than_x_seconds: one: "less than 1 second" other: "less than %{count} seconds" x_seconds: one: "1 second" other: "%{count} seconds" less_than_x_minutes: one: "less than a minute" other: "less than %{count} minutes" x_minutes: one: "1 minute" other: "%{count} minutes" about_x_hours: one: "about 1 hour" other: "about %{count} hours" x_days: one: "1 day" other: "%{count} days" about_x_months: one: "about 1 month" other: "about %{count} months" x_months: one: "1 month" other: "%{count} months" about_x_years: one: "about 1 year" other: "about %{count} years" over_x_years: one: "over 1 year" other: "over %{count} years" almost_x_years: one: "almost 1 year" other: "almost %{count} years" prompts: year: "Year" month: "Month" day: "Day" hour: "Hour" minute: "Minute" second: "Seconds" helpers: select: # Default value for :prompt => true in FormOptionsHelper prompt: "Please select" # Default translation keys for submit and button FormHelper submit: create: 'Create %{model}' update: 'Update %{model}' submit: 'Save %{model}' date: formats: # Use the strftime parameters for formats. # When no format has been given, it uses default. # You can provide other formats here if you like! default: "%Y-%m-%d" short: "%b %d" long: "%B %d, %Y" day_names: [Sunday, Monday, Tuesday, Wednesday, Thursday, Friday, Saturday] abbr_day_names: [Sun, Mon, Tue, Wed, Thu, Fri, Sat] # Don't forget the nil at the beginning; there's no such thing as a 0th month month_names: [~, January, February, March, April, May, June, July, August, September, October, November, December] abbr_month_names: [~, Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec] # Used in date_select and datetime_select. order: - year - month - day time: formats: default: "%a, %d %b %Y %H:%M:%S %z" short: "%d %b %H:%M" long: "%B %d, %Y %H:%M" am: "am" pm: "pm" # Used in array.to_sentence. support: array: words_connector: ", " two_words_connector: " and " last_word_connector: ", and " number: # Used in NumberHelper.number_to_delimited() # These are also the defaults for 'currency', 'percentage', 'precision', and 'human' format: # Sets the separator between the units, for more precision (e.g. 1.0 / 2.0 == 0.5) separator: "." # Delimits thousands (e.g. 1,000,000 is a million) (always in groups of three) delimiter: "," # Number of decimals, behind the separator (the number 1 with a precision of 2 gives: 1.00) precision: 3 round_mode: default # If set to true, precision will mean the number of significant digits instead # of the number of decimal digits (1234 with precision 2 becomes 1200, 1.23543 becomes 1.2) significant: false # If set, the zeros after the decimal separator will always be stripped (eg.: 1.200 will be 1.2) strip_insignificant_zeros: false # Used in NumberHelper.number_to_currency() currency: format: # Where is the currency sign? %u is the currency unit, %n the number (default: $5.00) format: "%u%n" unit: "$" # These five are to override number.format and are optional separator: "." delimiter: "," precision: 2 significant: false strip_insignificant_zeros: false # Used in NumberHelper.number_to_percentage() percentage: format: # These five are to override number.format and are optional # separator: delimiter: "" # precision: # significant: false # strip_insignificant_zeros: false format: "%n%" # Used in NumberHelper.number_to_rounded() precision: format: # These five are to override number.format and are optional # separator: delimiter: "" # precision: # significant: false # strip_insignificant_zeros: false # Used in NumberHelper.number_to_human_size() and NumberHelper.number_to_human() human: format: # These five are to override number.format and are optional # separator: delimiter: "" precision: 3 significant: true strip_insignificant_zeros: true # Used in number_to_human_size() storage_units: # Storage units output formatting. # %u is the storage unit, %n is the number (default: 2 MB) format: "%n %u" units: byte: one: "Byte" other: "Bytes" kb: "KB" mb: "MB" gb: "GB" tb: "TB" pb: "PB" eb: "EB" # Used in NumberHelper.number_to_human() decimal_units: format: "%n %u" # Decimal units output formatting # By default we will only quantify some of the exponents # but the commented ones might be defined or overridden # by the user. units: # femto: Quadrillionth # pico: Trillionth # nano: Billionth # micro: Millionth # mili: Thousandth # centi: Hundredth # deci: Tenth unit: "" # ten: # one: Ten # other: Tens # hundred: Hundred thousand: Thousand million: Million billion: Billion trillion: Trillion quadrillion: Quadrillion ================================================ FILE: spec/dummy_app/config.ru ================================================ # frozen_string_literal: true require ::File.expand_path("../config/environment", __FILE__) Rails.application.eager_load! run Rails.application ================================================ FILE: spec/localer_spec.rb ================================================ # frozen_string_literal: true RSpec.describe Localer do it "has a version number" do expect(Localer::VERSION).not_to be nil end end ================================================ FILE: spec/spec_helper.rb ================================================ # frozen_string_literal: true require "bundler/setup" require "localer" RSpec.configure do |config| # Disable RSpec exposing methods globally on `Module` and `main` config.disable_monkey_patching! config.expect_with :rspec do |c| c.syntax = :expect end end