diff --git a/lib/i18n/backend/lazy_loadable.rb b/lib/i18n/backend/lazy_loadable.rb index f519960f..92ee8415 100644 --- a/lib/i18n/backend/lazy_loadable.rb +++ b/lib/i18n/backend/lazy_loadable.rb @@ -36,7 +36,7 @@ module Backend # # The backend has two working modes: lazy_load and eager_load. # - # We recommend enabling this to true in test environments only. + # Note: This backend should only be enabled in 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. @@ -48,7 +48,7 @@ module Backend # # I18n.backend = I18n::Backend::LazyLoadable.new(lazy_load: true) # - # # In other environments, such as Prod and CI + # # In other environments, such as production and CI # # I18n.backend = I18n::Backend::LazyLoadable.new(lazy_load: false) # default # @@ -95,16 +95,6 @@ def eager_load! end 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 && - supported_extension?(path) - end - end - # Parse the load path and extract all locales. def available_locales if lazy_load? @@ -116,16 +106,18 @@ def available_locales protected + # Load translations from files that belong to the current locale. def init_translations - if lazy_load? - load_translations(filenames_for_current_locale) + file_errors = if lazy_load? initialized_locales[I18n.locale] = true + load_translations_and_collect_file_errors(filenames_for_current_locale) else - super - filenames_named_incorrectly = I18n.load_path.reject { |path| file_named_correctly?(path) } - raise InvalidFilenames.new(filenames_named_incorrectly) unless filenames_named_incorrectly.empty? + @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 @@ -138,15 +130,44 @@ def lazy_load? @lazy_load end - SUPPORTED_EXTENSIONS = [".yml", ".yaml", ".po", ".json", ".rb"].freeze + 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 - def supported_extension?(path) - path.end_with?(*SUPPORTED_EXTENSIONS) + errors end - def file_named_correctly?(path) - extracted_locale = LocaleExtractor.locale_from_path(path) - available_locales.include?(extracted_locale) + # 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 diff --git a/lib/i18n/exceptions.rb b/lib/i18n/exceptions.rb index c4c69093..05741633 100644 --- a/lib/i18n/exceptions.rb +++ b/lib/i18n/exceptions.rb @@ -121,10 +121,26 @@ def initialize(method, backend_klass) end class InvalidFilenames < ArgumentError - attr_reader :invalid_filenames - - def initialize(invalid_filenames) - super "Locales cannot be extracted from the following paths: #{invalid_filenames}" + NUMBER_OF_ERRORS_SHOWN = 20 + def initialize(file_errors) + super <<~MSG + Found #{file_errors.count} error(s). + The first #{[file_errors.count, NUMBER_OF_ERRORS_SHOWN].min} error(s): + #{file_errors.map(&:message).first(NUMBER_OF_ERRORS_SHOWN).join("\n")} + + To use the LazyLoadable backend: + 1. Filenames must start with the locale. + 2. An underscore must separate the locale with any optional text that follows. + 3. The file must only contain translation data for the single locale. + + Example: + "/config/locales/fr.yml" which contains: + ```yml + fr: + dog: + chien + ``` + MSG end end end diff --git a/lib/i18n/tests/basics.rb b/lib/i18n/tests/basics.rb index cc9d162b..be824302 100644 --- a/lib/i18n/tests/basics.rb +++ b/lib/i18n/tests/basics.rb @@ -9,7 +9,7 @@ def teardown I18n.backend.store_translations('de', :foo => 'bar') I18n.backend.store_translations('en', :foo => 'foo') - assert_equal I18n.backend.available_locales, I18n.available_locales + assert_equal I18n.available_locales, I18n.backend.available_locales end test "available_locales can be set to something else independently from the actual locale data" do @@ -23,7 +23,7 @@ def teardown assert_equal [:foo, :bar], I18n.available_locales I18n.available_locales = nil - assert_equal I18n.backend.available_locales, I18n.available_locales + assert_equal I18n.available_locales, I18n.backend.available_locales end test "available_locales memoizes when set explicitely" do diff --git a/test/backend/lazy_loadable_test.rb b/test/backend/lazy_loadable_test.rb index 50076c66..968efe41 100644 --- a/test/backend/lazy_loadable_test.rb +++ b/test/backend/lazy_loadable_test.rb @@ -73,13 +73,12 @@ def setup end end - test "lazy mode: loads translations from files that start with current locale identifier or contain identifier in path components, and end with a supported extension" do + test "lazy mode: loads translations from files that start with current locale identifier" do with_lazy_mode do file_contents = { en: { alice: "bob" } }.to_yaml invalid_files = [ { filename: ['translation', '.yml'] }, # No locale identifier - { filename: ['en_translation', '.unsupported'] }, # Unsupported extension { filename: ['translation', '.unsupported'] }, # No locale identifier and unsupported extension ] @@ -104,6 +103,17 @@ def setup end end + test "lazy mode: files with unsupported extensions raise UnknownFileType error" do + with_lazy_mode do + file_contents = { en: { alice: "bob" } }.to_yaml + filename = ['en_translation', '.unsupported'] # Correct locale identifier, but unsupported extension + + with_translation_file_in_load_path(filename, nil, file_contents) do + assert_raises(I18n::UnknownFileType) { I18n.t("foo.bar") } + end + end + end + test "lazy mode: #available_locales returns all locales available from load path irrespective of current locale" do with_lazy_mode do I18n.with_locale(:en) { assert_equal [:en, :fr], @backend.available_locales } @@ -111,6 +121,34 @@ def setup end end + test "lazy mode: raises error if translations loaded don't correspond to locale extracted from filename" do + filename = ["en_", ".yml"] + file_contents = { fr: { dog: "chien" } }.to_yaml + + with_lazy_mode do + with_translation_file_in_load_path(filename, nil, file_contents) do |file_path| + exception = assert_raises(I18n::InvalidFilenames) { I18n.t("foo.bar") } + + expected_message = /#{Regexp.escape(file_path)} can only load translations for "en"\. Found translations for: \[\:fr\]/ + assert_match expected_message, exception.message + end + end + end + + test "lazy mode: raises error if translations for more than one locale are loaded from a single file" do + filename = ["en_", ".yml"] + file_contents = { en: { alice: "bob" }, fr: { dog: "chien" }, de: { cat: 'katze' } }.to_yaml + + with_lazy_mode do + with_translation_file_in_load_path(filename, nil, file_contents) do |file_path| + exception = assert_raises(I18n::InvalidFilenames) { I18n.t("foo.bar") } + + expected_message = /#{Regexp.escape(file_path)} can only load translations for "en"\. Found translations for: \[\:fr\, \:de\]/ + assert_match expected_message, exception.message + end + end + end + test "eager mode: load all translations, irrespective of locale" do with_eager_mode do @backend.reload! @@ -128,9 +166,11 @@ def setup contents = { de: { cat: 'katze' } }.to_yaml - with_translation_file_in_load_path(['translation', '.yml'], nil, contents) do |file_path| + with_translation_file_in_load_path(['fr_translation', '.yml'], nil, contents) do |file_path| exception = assert_raises(I18n::InvalidFilenames) { I18n.t("foo.bar") } - assert_equal "Locales cannot be extracted from the following paths: #{[file_path]}", exception.message + + expected_message = /#{Regexp.escape(file_path)} can only load translations for "fr"\. Found translations for: \[\:de\]/ + assert_match expected_message, exception.message end end end