lib/dry/schema/message_compiler.rb



# 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

      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 = EMPTY_HASH)
        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_or(node, opts)
        left, right = node.map { |n| visit(n, opts) }

        if [left, right].flatten.map(&:path).uniq.size == 1
          Message::Or.new(left, right, proc { |k| messages.translate(k, default_lookup_options) })
        elsif right.is_a?(Array)
          right
        else
          [left, right].flatten.max
        end
      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] || raise(MissingMessageError, path)

        text = message_text(template, tokens, options)

        message_type(options).new(
          text: text, path: path, predicate: predicate, args: arg_vals, input: input, meta: meta
        )
      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 unless full

        rule = options[:path]
        "#{messages.rule(rule, options) || rule} #{text}"
      end

      # @api private
      def message_tokens(args)
        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
      end
    end
  end
end