lib/dry/schema/messages/yaml.rb
# frozen_string_literal: true
require "yaml"
require "pathname"
require "dry/schema/constants"
module Dry
module Schema
module Messages
# Plain YAML message backend
#
# @api public
class YAML < Abstract
LOCALE_TOKEN = "%<locale>s"
TOKEN_REGEXP = /%{(\w*)}/
EMPTY_CONTEXT = Object.new.tap { |ctx|
def ctx.context
binding
end
}.freeze.context
include ::Dry::Equalizer(:data)
# Loaded localized message templates
#
# @return [Hash]
attr_reader :data
# Translation function
#
# @return [Proc]
attr_reader :t
# @api private
def self.build(options = EMPTY_HASH)
super do |config|
config.default_locale = :en unless config.default_locale
config.root = -"%<locale>s.#{config.root}"
config.rule_lookup_paths = config.rule_lookup_paths.map { |path|
-"%<locale>s.#{path}"
}
end
end
# @api private
# rubocop: disable Metrics/PerceivedComplexity
def self.flat_hash(hash, path = EMPTY_ARRAY, keys = {})
hash.each do |key, value|
flat_hash(value, [*path, key], keys) if value.is_a?(Hash)
if value.is_a?(String) && hash["text"] != value
keys[[*path, key].join(DOT)] = {
text: value,
meta: EMPTY_HASH
}
elsif value.is_a?(Hash) && value["text"].is_a?(String)
keys[[*path, key].join(DOT)] = {
text: value["text"],
meta: value.reject { _1.eql?("text") }.transform_keys(&:to_sym)
}
end
end
keys
end
# rubocop: enable Metrics/PerceivedComplexity
# @api private
def self.cache
@cache ||= Concurrent::Map.new do |h, k|
h.compute_if_absent(k) { Concurrent::Map.new }
end
end
# @api private
def self.source_cache
@source_cache ||= Concurrent::Map.new
end
# @api private
def initialize(data: EMPTY_HASH, config: nil)
super()
@data = data
@__config__ = config if config
@t = proc { |key, locale: default_locale| get("%<locale>s.#{key}", locale: locale) }
end
# Get an array of looked up paths
#
# @param [Symbol] predicate
# @param [Hash] options
#
# @return [String]
#
# @api public
def looked_up_paths(predicate, options)
super.map { |path| path % {locale: options[:locale] || default_locale} }
end
# Get a message for the given key and its options
#
# @param [Symbol] key
# @param [Hash] options
#
# @return [String]
#
# @api public
def get(key, options = EMPTY_HASH)
data[evaluated_key(key, options)]
end
# Check if given key is defined
#
# @return [Boolean]
#
# @api public
def key?(key, options = EMPTY_HASH)
data.key?(evaluated_key(key, options))
end
# Merge messages from an additional path
#
# @param [String] overrides
#
# @return [Messages::I18n]
#
# @api public
def merge(overrides)
if overrides.is_a?(Hash)
self.class.new(
data: data.merge(self.class.flat_hash(overrides)),
config: config
)
else
self.class.new(
data: Array(overrides).reduce(data) { |a, e| a.merge(load_translations(e)) },
config: config
)
end
end
# @api private
def prepare
@data = config.load_paths.map { |path| load_translations(path) }.reduce({}, :merge)
self
end
# @api private
def interpolatable_data(key, options, **data)
tokens = evaluation_context(key, options).fetch(:tokens)
data.select { |k,| tokens.include?(k) }
end
# @api private
def interpolate(key, options, **data)
evaluator = evaluation_context(key, options).fetch(:evaluator)
data.empty? ? evaluator.() : evaluator.(**data)
end
private
# @api private
def evaluation_context(key, options)
cache.fetch_or_store(get(key, options).fetch(:text)) do |input|
tokens = input.scan(TOKEN_REGEXP).flatten(1).map(&:to_sym).to_set
text = input.gsub("%", "#")
# rubocop:disable Security/Eval
evaluator = eval(<<~RUBY, EMPTY_CONTEXT, __FILE__, __LINE__ + 1)
-> (#{tokens.map { |token| "#{token}:" }.join(", ")}) { "#{text}" }
RUBY
# rubocop:enable Security/Eval
{
tokens: tokens,
evaluator: evaluator
}
end
end
# @api private
def cache
@cache ||= self.class.cache[self]
end
# @api private
def load_translations(path)
data = self.class.source_cache.fetch_or_store(path) do
self.class.flat_hash(::YAML.load_file(path)).freeze
end
return data unless custom_top_namespace?(path)
data.transform_keys { _1.gsub(DEFAULT_MESSAGES_ROOT, config.top_namespace) }
end
# @api private
def evaluated_key(key, options)
return key unless key.include?(LOCALE_TOKEN)
key % {locale: options[:locale] || default_locale}
end
end
end
end
end