lib/i18n/backend/lazy_loadable.rb



# frozen_string_literal: true

module I18n
  module Backend
    # Backend that lazy loads translations based on the current locale. This
    # implementation avoids loading all translations up front. Instead, it only
    # loads the translations that belong to the current locale. This offers a
    # performance incentive in local development and test environments for
    # applications with many translations for many different locales. It's
    # particularly useful when the application only refers to a single locales'
    # translations at a time (ex. A Rails workload).  The implementation
    # identifies which translation files from the load path belong to the
    # current locale by pattern matching against their path name.
    #
    # Specifically, a translation file is considered to belong to a locale if:
    # a) the filename is in the I18n load path
    # b) the filename ends in a supported extension (ie. .yml, .json, .po, .rb)
    # c) the filename starts with the locale identifier
    # d) the locale identifier and optional proceeding text is separated by an underscore, ie. "_".
    #
    # Examples:
    # Valid files that will be selected by this backend:
    #
    # "files/locales/en_translation.yml" (Selected for locale "en")
    # "files/locales/fr.po"  (Selected for locale "fr")
    #
    # Invalid files that won't be selected by this backend:
    #
    # "files/locales/translation-file"
    # "files/locales/en-translation.unsupported"
    # "files/locales/french/translation.yml"
    # "files/locales/fr/translation.yml"
    #
    # The implementation uses this assumption to defer the loading of
    # translation files until the current locale actually requires them.
    #
    # The backend has two working modes: lazy_load and eager_load.
    #
    # Note: This backend should only be enabled in test environments!
    # When the mode is set to false, the backend behaves exactly like the
    # Simple backend, with an additional check that the paths being loaded
    # abide by the format. If paths can't be matched to the format, an error is raised.
    #
    # You can configure lazy loaded backends through the initializer or backends
    # accessor:
    #
    #   # In test environments
    #
    #   I18n.backend = I18n::Backend::LazyLoadable.new(lazy_load: true)
    #
    #   # In other environments, such as production and CI
    #
    #   I18n.backend = I18n::Backend::LazyLoadable.new(lazy_load: false) # default
    #
    class LocaleExtractor
      class << self
        def locale_from_path(path)
          name = File.basename(path, ".*")
          locale = name.split("_").first
          locale.to_sym unless locale.nil?
        end
      end
    end

    class LazyLoadable < Simple
      def initialize(lazy_load: false)
        @lazy_load = lazy_load
      end

      # Returns whether the current locale is initialized.
      def initialized?
        if lazy_load?
          initialized_locales[I18n.locale]
        else
          super
        end
      end

      # Clean up translations and uninitialize all locales.
      def reload!
        if lazy_load?
          @initialized_locales = nil
          @translations = nil
        else
          super
        end
      end

      # Eager loading is not supported in the lazy context.
      def eager_load!
        if lazy_load?
          raise UnsupportedMethod.new(__method__, self.class, "Cannot eager load translations because backend was configured with lazy_load: true.")
        else
          super
        end
      end

      # Parse the load path and extract all locales.
      def available_locales
        if lazy_load?
          I18n.load_path.map { |path| LocaleExtractor.locale_from_path(path) }.uniq
        else
          super
        end
      end

      def lookup(locale, key, scope = [], options = EMPTY_HASH)
        if lazy_load?
          I18n.with_locale(locale) do
            super
          end
        else
          super
        end
      end

      protected


      # Load translations from files that belong to the current locale.
      def init_translations
        file_errors = if lazy_load?
          initialized_locales[I18n.locale] = true
          load_translations_and_collect_file_errors(filenames_for_current_locale)
        else
          @initialized = true
          load_translations_and_collect_file_errors(I18n.load_path)
        end

        raise InvalidFilenames.new(file_errors) unless file_errors.empty?
      end

      def initialized_locales
        @initialized_locales ||= Hash.new(false)
      end

      private

      def lazy_load?
        @lazy_load
      end

      class FilenameIncorrect < StandardError
        def initialize(file, expected_locale, unexpected_locales)
          super "#{file} can only load translations for \"#{expected_locale}\". Found translations for: #{unexpected_locales}."
        end
      end

      # Loads each file supplied and asserts that the file only loads
      # translations as expected by the name. The method returns a list of
      # errors corresponding to offending files.
      def load_translations_and_collect_file_errors(files)
        errors = []

        load_translations(files) do |file, loaded_translations|
          assert_file_named_correctly!(file, loaded_translations)
        rescue FilenameIncorrect => e
          errors << e
        end

        errors
      end

      # Select all files from I18n load path that belong to current locale.
      # These files must start with the locale identifier (ie. "en", "pt-BR"),
      # followed by an "_" demarcation to separate proceeding text.
      def filenames_for_current_locale
        I18n.load_path.flatten.select do |path|
          LocaleExtractor.locale_from_path(path) == I18n.locale
        end
      end

      # Checks if a filename is named in correspondence to the translations it loaded.
      # The locale extracted from the path must be the single locale loaded in the translations.
      def assert_file_named_correctly!(file, translations)
        loaded_locales = translations.keys.map(&:to_sym)
        expected_locale = LocaleExtractor.locale_from_path(file)
        unexpected_locales = loaded_locales.reject { |locale| locale == expected_locale }

        raise FilenameIncorrect.new(file, expected_locale, unexpected_locales) unless unexpected_locales.empty?
      end
    end
  end
end