# frozen_string_literal: true require "dry/initializer" require "dry/schema/constants" require "dry/schema/message" require "dry/schema/message_set" require "dry/schema/message_compiler/visitor_opts" module Dry module Schema # Compiles rule results AST into human-readable format # # @api private class MessageCompiler extend Dry::Initializer resolve_key_predicate = proc { |node, opts| *arg_vals, val = node.map(&:last) [[*opts.path, arg_vals[0]], arg_vals[1..arg_vals.size - 1], val] } resolve_predicate = proc { |node, opts| [Array(opts.path), *node.map(&:last)] } DEFAULT_PREDICATE_RESOLVERS = Hash .new(resolve_predicate).update(key?: resolve_key_predicate).freeze EMPTY_OPTS = VisitorOpts.new EMPTY_MESSAGE_SET = MessageSet.new(EMPTY_ARRAY).freeze FULL_MESSAGE_WHITESPACE = Hash.new(' ').merge( ja: '', zh: '', bn: '', th: '', lo: '', my: '', ) param :messages option :full, default: -> { false } option :locale, default: -> { :en } option :predicate_resolvers, default: -> { DEFAULT_PREDICATE_RESOLVERS } attr_reader :options attr_reader :default_lookup_options # @api private def initialize(messages, **options) super @options = options @default_lookup_options = options[:locale] ? {locale: locale} : EMPTY_HASH end # @api private def with(new_options) return self if new_options.empty? updated_opts = options.merge(new_options) return self if updated_opts.eql?(options) self.class.new(messages, **updated_opts) end # @api private def call(ast) return EMPTY_MESSAGE_SET if ast.empty? current_messages = EMPTY_ARRAY.dup compiled_messages = ast.map { |node| visit(node, EMPTY_OPTS.dup(current_messages)) } MessageSet[compiled_messages, failures: options.fetch(:failures, true)] end # @api private def visit(node, opts = EMPTY_OPTS.dup) __send__(:"visit_#{node[0]}", node[1], opts) end # @api private def visit_failure(node, opts) rule, other = node visit(other, opts.(rule: rule)) end # @api private def visit_hint(*) nil end # @api private def visit_not(node, opts) visit(node, opts.(not: true)) end # @api private def visit_and(node, opts) left, right = node.map { |n| visit(n, opts) } if right [left, right] else left end end # @api private def visit_unexpected_key(node, _opts) path, input = node msg = messages.translate("errors.unexpected_key") Message.new( path: path, meta: msg[:meta] || EMPTY_HASH, text: msg[:text], predicate: nil, input: input ) end # @api private def visit_or(node, opts) left, right = node.map { |n| visit(n, opts) } Message::Or[left, right, or_translator] end # @api private def or_translator @or_translator ||= proc { |k| messages.translate(k, **default_lookup_options) } end # @api private def visit_namespace(node, opts) ns, rest = node self.class.new(messages.namespaced(ns), **options).visit(rest, opts) end # @api private def visit_predicate(node, opts) predicate, args = node tokens = message_tokens(args) path, *arg_vals, input = predicate_resolvers[predicate].(args, opts) options = opts.dup.update( path: path.last, **tokens, **lookup_options(arg_vals: arg_vals, input: input) ).to_h template, meta = messages[predicate, options] unless template raise MissingMessageError.new(path, messages.looked_up_paths(predicate, options)) end text = message_text(template, tokens, options) message_type(options).new( text: text, meta: meta, path: path, predicate: predicate, args: arg_vals, input: input ) end # @api private def message_type(*) Message end # @api private def visit_key(node, opts) name, other = node visit(other, opts.(path: name)) end # @api private def visit_set(node, opts) node.map { |el| visit(el, opts) } end # @api private def visit_implication(node, *args) _, right = node visit(right, *args) end # @api private def visit_xor(node, opts) left, right = node [visit(left, opts), visit(right, opts)].uniq end # @api private def lookup_options(arg_vals:, input:) default_lookup_options.merge( arg_type: arg_vals.size == 1 && arg_vals[0].class, val_type: input.equal?(Undefined) ? NilClass : input.class ) end # @api private def message_text(template, tokens, options) text = template[template.data(tokens)] return text if !text || !full rule = options[:path] [messages.rule(rule, options) || rule, text].join(FULL_MESSAGE_WHITESPACE[template.options[:locale]]) end # @api private def message_tokens(args) tokens = args.each_with_object({}) do |arg, hash| case arg[1] when Array hash[arg[0]] = arg[1].join(LIST_SEPARATOR) when Range hash["#{arg[0]}_left".to_sym] = arg[1].first hash["#{arg[0]}_right".to_sym] = arg[1].last else hash[arg[0]] = arg[1] end end args.any? { |e| e.first == :size } ? append_mapped_size_tokens(tokens) : tokens end # @api private def append_mapped_size_tokens(tokens) # this is a temporary fix for the inconsistency in the "size" errors arguments mapped_hash = tokens.each_with_object({}) { |(k, v), h| h[k.to_s.gsub("size", "num").to_sym] = v } tokens.merge(mapped_hash) end end end end