src/derails/action_dispatch/journey/path/pattern.rb
# frozen_string_literal: true

# :markup: markdown

module ActionDispatch
  module Journey # :nodoc:
    module Path # :nodoc:
      class Pattern # :nodoc:
        attr_reader :ast, :names, :requirements, :anchored, :spec

        def initialize(ast, requirements, separators, anchored)
          @ast          = ast
          @spec         = ast.root
          @requirements = requirements
          @separators   = separators
          @anchored     = anchored

          @names          = ast.names
          @optional_names = nil
          @required_names = nil
          @re             = nil
          @offsets        = nil
        end

        def build_formatter
          Visitors::FormatBuilder.new.accept(spec)
        end

        def eager_load!
          required_names
          offsets
          to_regexp
          @ast = nil
        end

        def requirements_anchored?
          # each required param must not be surrounded by a literal, otherwise it isn't
          # simple to chunk-match the url piecemeal
          terminals = ast.terminals

          terminals.each_with_index { |s, index|
            next if index < 1
            next if s.type == :DOT || s.type == :SLASH

            back = terminals[index - 1]
            fwd = terminals[index + 1]

            # we also don't support this yet, constraints must be regexps
            return false if s.symbol? && s.regexp.is_a?(Array)

            return false if back.literal?
            return false if !fwd.nil? && fwd.literal?
          }

          true
        end

        def required_names
          @required_names ||= names - optional_names
        end

        def optional_names
          @optional_names ||= spec.find_all(&:group?).flat_map { |group|
            group.find_all(&:symbol?)
          }.map(&:name).uniq
        end

        class AnchoredRegexp < Journey::Visitors::Visitor # :nodoc:
          def initialize(separator, matchers)
            @separator = separator
            @matchers  = matchers
            @separator_re = "([^#{separator}]+)"
            super()
          end

          def accept(node)
            %r{\A#{visit node}\Z}
          end

          def visit_CAT(node)
            "#{visit(node.left)}#{visit(node.right)}"
          end

          def visit_SYMBOL(node)
            node = node.to_sym

            return @separator_re unless @matchers.key?(node)

            re = @matchers[node]
            "(#{Regexp.union(re)})"
          end

          def visit_GROUP(node)
            "(?:#{visit node.left})?"
          end

          def visit_LITERAL(node)
            Regexp.escape(node.left)
          end
          alias :visit_DOT :visit_LITERAL

          def visit_SLASH(node)
            node.left
          end

          def visit_STAR(node)
            re = @matchers[node.left.to_sym]
            re ? "(#{re})" : "(.+)"
          end

          def visit_OR(node)
            children = node.children.map { |n| visit n }
            "(?:#{children.join(?|)})"
          end
        end

        class UnanchoredRegexp < AnchoredRegexp # :nodoc:
          def accept(node)
            path = visit node
            path == "/" ? %r{\A/} : %r{\A#{path}(?:\b|\Z|/)}
          end
        end

        class MatchData # :nodoc:
          attr_reader :names

          def initialize(names, offsets, match)
            @names   = names
            @offsets = offsets
            @match   = match
          end

          def captures
            Array.new(length - 1) { |i| self[i + 1] }
          end

          def named_captures
            @names.zip(captures).to_h
          end

          def [](x)
            idx = @offsets[x - 1] + x
            @match[idx]
          end

          def length
            @offsets.length
          end

          def post_match
            @match.post_match
          end

          def to_s
            @match.to_s
          end
        end

        def match(other)
          return unless match = to_regexp.match(other)
          MatchData.new(names, offsets, match)
        end
        alias :=~ :match

        def match?(other)
          to_regexp.match?(other)
        end

        def source
          to_regexp.source
        end

        def to_regexp
          @re ||= regexp_visitor.new(@separators, @requirements).accept spec
        end

        def requirements_for_missing_keys_check
          @requirements_for_missing_keys_check ||= requirements.transform_values do |regex|
            /\A#{regex}\Z/
          end
        end

        private
          def regexp_visitor
            @anchored ? AnchoredRegexp : UnanchoredRegexp
          end

          def offsets
            @offsets ||= begin
              offsets = [0]

              spec.find_all(&:symbol?).each do |node|
                node = node.to_sym

                if @requirements.key?(node)
                  re = /#{Regexp.union(@requirements[node])}|/
                  offsets.push((re.match("").length - 1) + offsets.last)
                else
                  offsets << offsets.last
                end
              end

              offsets
            end
          end
      end
    end
  end
end
