glebm/i18n-tasks

View on GitHub
lib/i18n/tasks/data/file_system_base.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
# frozen_string_literal: true

require 'i18n/tasks/data/tree/node'
require 'i18n/tasks/data/router/pattern_router'
require 'i18n/tasks/data/router/conservative_router'
require 'i18n/tasks/data/router/isolating_router'
require 'i18n/tasks/data/file_formats'
require 'i18n/tasks/key_pattern_matching'

module I18n::Tasks
  module Data
    class FileSystemBase # rubocop:disable Metrics/ClassLength
      include ::I18n::Tasks::Data::FileFormats
      include ::I18n::Tasks::Logging

      attr_accessor :locales
      attr_reader :config, :base_locale
      attr_writer :router

      DEFAULTS = {
        read: ['config/locales/%{locale}.yml'],
        write: ['config/locales/%{locale}.yml']
      }.freeze

      def initialize(config = {})
        self.config = config.except(:base_locale, :locales)
        self.config[:sort] = !config[:keep_order]
        @base_locale = config[:base_locale]
        locales = config[:locales].presence
        @locales = LocaleList.normalize_locale_list(locales || available_locales, base_locale, true)
        if locales.present?
          log_verbose "locales read from config #{@locales * ', '}"
        else
          log_verbose "locales inferred from data: #{@locales * ', '}"
        end
      end

      # @param [String, Symbol] locale
      # @return [::I18n::Tasks::Data::Siblings]
      def get(locale)
        locale = locale.to_s
        @trees ||= {}
        @trees[locale] ||= read_locale(locale)
      end

      alias [] get

      # @param [String, Symbol] locale
      # @return [::I18n::Tasks::Data::Siblings]
      def external(locale)
        locale = locale.to_s
        @external ||= {}
        @external[locale] ||= read_locale(locale, paths: config[:external])
      end

      # set locale tree
      # @param [String, Symbol] locale
      # @param [::I18n::Tasks::Data::Siblings] tree
      def set(locale, tree)
        locale = locale.to_s
        @trees&.delete(locale)
        paths_before = Set.new(get(locale)[locale].leaves.map { |node| node.data[:path] })
        paths_after = Set.new([])
        router.route locale, tree do |path, tree_slice|
          paths_after << path
          write_tree path, tree_slice, config[:sort]
        end
        (paths_before - paths_after).each do |path|
          FileUtils.remove_file(path) if File.exist?(path)
        end
        @trees&.delete(locale)
        @available_locales = nil
      end

      alias []= set

      # @param [String] locale
      # @return [Array<String>] paths to files that are not normalized
      def non_normalized_paths(locale)
        router.route(locale, get(locale))
              .reject { |path, tree_slice| normalized?(path, tree_slice) }
              .map(&:first)
      end

      def write(forest)
        forest.each { |root| set(root.key, root.to_siblings) }
      end

      def merge!(forest)
        forest.inject(Tree::Siblings.new) do |result, root|
          locale = root.key
          merged = get(locale).merge(root)
          set locale, merged
          result.merge! merged
        end
      end

      def remove_by_key!(forest)
        forest.inject(Tree::Siblings.new) do |removed, root|
          locale = root.key
          locale_data = get(locale)
          subtracted = locale_data.subtract_by_key(forest)
          set locale, subtracted
          removed.merge! locale_data.subtract_by_key(subtracted)
        end
      end

      # @return self
      def reload
        @trees             = nil
        @available_locales = nil
        self
      end

      # Get available locales from the list of file names to read
      def available_locales
        @available_locales ||= begin
          locales = Set.new
          Array(config[:read]).map do |pattern|
            [pattern, Dir.glob(format(pattern, locale: '*'))] if pattern.include?('%{locale}')
          end.compact.each do |pattern, paths|
            p  = pattern.gsub('\\', '\\\\').gsub('/', '\/').gsub('.', '\.')
            p  = p.gsub(/(\*+)/) { Regexp.last_match(1) == '**' ? '.*' : '[^/]*?' }.gsub('%{locale}', '([^/.]+)')
            re = /\A#{p}\z/
            paths.each do |path|
              locales << Regexp.last_match(1) if re =~ path
            end
          end
          locales
        end
      end

      def t(key, locale)
        tree = self[locale.to_s]
        return unless tree

        tree[locale][key].try(:value_or_children_hash)
      end

      def config=(config)
        @config = DEFAULTS.deep_merge((config || {}).compact)
        reload
      end

      def with_router(router)
        router_was  = self.router
        self.router = router
        yield
      ensure
        self.router = router_was
      end

      ROUTER_NAME_ALIASES = {
        'conservative_router' => 'I18n::Tasks::Data::Router::ConservativeRouter',
        'isolating_router' => 'I18n::Tasks::Data::Router::IsolatingRouter',
        'pattern_router' => 'I18n::Tasks::Data::Router::PatternRouter'
      }.freeze
      def router
        @router ||= begin
          name = @config[:router].presence || 'conservative_router'
          name = ROUTER_NAME_ALIASES[name] || name
          router_class = ActiveSupport::Inflector.constantize(name)
          router_class.new(self, @config.merge(base_locale: base_locale, locales: locales))
        end
      end

      protected

      def read_locale(locale, paths: config[:read])
        Array(paths).flat_map do |path|
          Dir.glob format(path, locale: locale)
        end.map do |path|
          [path.freeze, load_file(path) || {}]
        end.map do |path, data|
          if router.is_a?(I18n::Tasks::Data::Router::IsolatingRouter)
            data.transform_values! { |tree| { "<#{router.alternate_path_for(path, base_locale)}>" => tree } }
          end
          filter_nil_keys! path, data
          Data::Tree::Siblings.from_nested_hash(data).tap do |s|
            s.leaves { |x| x.data.update(path: path, locale: locale) }
          end
        end.reduce(Tree::Siblings[locale => {}], :merge!)
      end

      def filter_nil_keys!(path, data, suffix = [])
        data.each do |key, value|
          if key.nil?
            data.delete(key)
            log_warn <<~TEXT
              Skipping a nil key found in #{path.inspect}:
                key: #{suffix.join('.')}.`nil`
                value: #{value.inspect}
              Nil keys are not supported by i18n.
              The following unquoted YAML keys result in a nil key:
                #{%w[null Null NULL ~].join(', ')}
              See http://yaml.org/type/null.html
            TEXT
          elsif value.is_a?(Hash)
            filter_nil_keys! path, value, suffix + [key]
          end
        end
      end
    end
  end
end