require 'sass/script/lexer'

module Sass
  module Script
    # The parser for SassScript.
    # It parses a string of code into a tree of {Script::Node}s.
    class Parser
      # The line number of the parser's current position.
      #
      # @return [Fixnum]
      def line
        @lexer.line
      end

      # @param str [String, StringScanner] The source text to parse
      # @param line [Fixnum] The line on which the SassScript appears.
      #   Used for error reporting
      # @param offset [Fixnum] The number of characters in on which the SassScript appears.
      #   Used for error reporting
      # @param options [{Symbol => Object}] An options hash;
      #   see {file:SASS_REFERENCE.md#sass_options the Sass options documentation}
      def initialize(str, line, offset, options = {})
        @options = options
        @lexer = lexer_class.new(str, line, offset, options)
      end

      # Parses a SassScript expression within an interpolated segment (`#{}`).
      # This means that it stops when it comes across an unmatched `}`,
      # which signals the end of an interpolated segment,
      # it returns rather than throwing an error.
      #
      # @return [Script::Node] The root node of the parse tree
      # @raise [Sass::SyntaxError] if the expression isn't valid SassScript
      def parse_interpolated
        expr = assert_expr :expr
        assert_tok :end_interpolation
        expr.options = @options
        expr
      rescue Sass::SyntaxError => e
        e.modify_backtrace :line => @lexer.line, :filename => @options[:filename]
        raise e
      end

      # Parses a SassScript expression.
      #
      # @return [Script::Node] The root node of the parse tree
      # @raise [Sass::SyntaxError] if the expression isn't valid SassScript
      def parse
        expr = assert_expr :expr
        assert_done
        expr.options = @options
        expr
      rescue Sass::SyntaxError => e
        e.modify_backtrace :line => @lexer.line, :filename => @options[:filename]
        raise e
      end

      # Parses a SassScript expression,
      # ending it when it encounters one of the given identifier tokens.
      #
      # @param [#include?(String)] A set of strings that delimit the expression.
      # @return [Script::Node] The root node of the parse tree
      # @raise [Sass::SyntaxError] if the expression isn't valid SassScript
      def parse_until(tokens)
        @stop_at = tokens
        expr = assert_expr :expr
        assert_done
        expr.options = @options
        expr
      rescue Sass::SyntaxError => e
        e.modify_backtrace :line => @lexer.line, :filename => @options[:filename]
        raise e
      end

      # Parses the argument list for a mixin include.
      #
      # @return [(Array<Script::Node>, {String => Script::Note})]
      #   The root nodes of the arguments.
      #   Keyword arguments are in a hash from names to values.
      # @raise [Sass::SyntaxError] if the argument list isn't valid SassScript
      def parse_mixin_include_arglist
        args, keywords = [], {}
        if try_tok(:lparen)
          args, keywords = mixin_arglist || [[], {}]
          assert_tok(:rparen)
        end
        assert_done

        args.each {|a| a.options = @options}
        keywords.each {|k, v| v.options = @options}
        return args, keywords
      rescue Sass::SyntaxError => e
        e.modify_backtrace :line => @lexer.line, :filename => @options[:filename]
        raise e
      end

      # Parses the argument list for a mixin definition.
      #
      # @return [Array<Script::Node>] The root nodes of the arguments.
      # @raise [Sass::SyntaxError] if the argument list isn't valid SassScript
      def parse_mixin_definition_arglist
        args = defn_arglist!(false)
        assert_done

        args.each do |k, v|
          k.options = @options
          v.options = @options if v
        end
        args
      rescue Sass::SyntaxError => e
        e.modify_backtrace :line => @lexer.line, :filename => @options[:filename]
        raise e
      end

      # Parses the argument list for a function definition.
      #
      # @return [Array<Script::Node>] The root nodes of the arguments.
      # @raise [Sass::SyntaxError] if the argument list isn't valid SassScript
      def parse_function_definition_arglist
        args = defn_arglist!(true)
        assert_done

        args.each do |k, v|
          k.options = @options
          v.options = @options if v
        end
        args
      rescue Sass::SyntaxError => e
        e.modify_backtrace :line => @lexer.line, :filename => @options[:filename]
        raise e
      end

      # Parses a SassScript expression.
      #
      # @overload parse(str, line, offset, filename = nil)
      # @return [Script::Node] The root node of the parse tree
      # @see Parser#initialize
      # @see Parser#parse
      def self.parse(*args)
        new(*args).parse
      end

      PRECEDENCE = [
        :comma, :single_eq, :space, :or, :and,
        [:eq, :neq],
        [:gt, :gte, :lt, :lte],
        [:plus, :minus],
        [:times, :div, :mod],
      ]

      ASSOCIATIVE = [:plus, :times]

      class << self
        # Returns an integer representing the precedence
        # of the given operator.
        # A lower integer indicates a looser binding.
        #
        # @private
        def precedence_of(op)
          PRECEDENCE.each_with_index do |e, i|
            return i if Array(e).include?(op)
          end
          raise "[BUG] Unknown operator #{op}"
        end

        # Returns whether or not the given operation is associative.
        #
        # @private
        def associative?(op)
          ASSOCIATIVE.include?(op)
        end

        private

        # Defines a simple left-associative production.
        # name is the name of the production,
        # sub is the name of the production beneath it,
        # and ops is a list of operators for this precedence level
        def production(name, sub, *ops)
          class_eval <<RUBY
            def #{name}
              interp = try_ops_after_interp(#{ops.inspect}, #{name.inspect}) and return interp
              return unless e = #{sub}
              while tok = try_tok(#{ops.map {|o| o.inspect}.join(', ')})
                interp = try_op_before_interp(tok, e) and return interp
                line = @lexer.line
                e = Operation.new(e, assert_expr(#{sub.inspect}), tok.type)
                e.line = line
              end
              e
            end
RUBY
        end

        def unary(op, sub)
          class_eval <<RUBY
            def unary_#{op}
              return #{sub} unless tok = try_tok(:#{op})
              interp = try_op_before_interp(tok) and return interp
              line = @lexer.line 
              op = UnaryOperation.new(assert_expr(:unary_#{op}), :#{op})
              op.line = line
              op
            end
RUBY
        end
      end

      private

      # @private
      def lexer_class; Lexer; end

      def expr
        interp = try_ops_after_interp([:comma], :expr) and return interp
        line = @lexer.line
        return unless e = interpolation
        arr = [e]
        while tok = try_tok(:comma)
          interp = try_op_before_interp(tok, e) and return interp
          arr << assert_expr(:interpolation)
        end
        arr.size == 1 ? arr.first : node(List.new(arr, :comma), line)
      end

      production :equals, :interpolation, :single_eq

      def try_op_before_interp(op, prev = nil)
        return unless @lexer.peek && @lexer.peek.type == :begin_interpolation
        wb = @lexer.whitespace?(op)
        str = Script::String.new(Lexer::OPERATORS_REVERSE[op.type])
        str.line = @lexer.line
        interp = Script::Interpolation.new(prev, str, nil, wb, !:wa, :originally_text)
        interp.line = @lexer.line
        interpolation(interp)
      end

      def try_ops_after_interp(ops, name)
        return unless @lexer.after_interpolation?
        return unless op = try_tok(*ops)
        interp = try_op_before_interp(op) and return interp

        wa = @lexer.whitespace?
        str = Script::String.new(Lexer::OPERATORS_REVERSE[op.type])
        str.line = @lexer.line
        interp = Script::Interpolation.new(nil, str, assert_expr(name), !:wb, wa, :originally_text)
        interp.line = @lexer.line
        return interp
      end

      def interpolation(first = space)
        e = first
        while interp = try_tok(:begin_interpolation)
          wb = @lexer.whitespace?(interp)
          line = @lexer.line
          mid = parse_interpolated
          wa = @lexer.whitespace?
          e = Script::Interpolation.new(e, mid, space, wb, wa)
          e.line = line
        end
        e
      end

      def space
        line = @lexer.line
        return unless e = or_expr
        arr = [e]
        while e = or_expr
          arr << e
        end
        arr.size == 1 ? arr.first : node(List.new(arr, :space), line)
      end

      production :or_expr, :and_expr, :or
      production :and_expr, :eq_or_neq, :and
      production :eq_or_neq, :relational, :eq, :neq
      production :relational, :plus_or_minus, :gt, :gte, :lt, :lte
      production :plus_or_minus, :times_div_or_mod, :plus, :minus
      production :times_div_or_mod, :unary_plus, :times, :div, :mod

      unary :plus, :unary_minus
      unary :minus, :unary_div
      unary :div, :unary_not # For strings, so /foo/bar works
      unary :not, :ident

      def ident
        return funcall unless @lexer.peek && @lexer.peek.type == :ident
        return if @stop_at && @stop_at.include?(@lexer.peek.value)

        name = @lexer.next
        if color = Color::HTML4_COLORS[name.value.downcase]
          return node(Color.new(color))
        end
        node(Script::String.new(name.value, :identifier))
      end

      def funcall
        return raw unless tok = try_tok(:funcall)
        args, keywords = fn_arglist || [[], {}]
        assert_tok(:rparen)
        node(Script::Funcall.new(tok.value, args, keywords))
      end

      def defn_arglist!(must_have_parens)
        if must_have_parens
          assert_tok(:lparen)
        else
          return [] unless try_tok(:lparen)
        end
        return [] if try_tok(:rparen)

        res = []
        must_have_default = false
        loop do
          line = @lexer.line
          offset = @lexer.offset + 1
          c = assert_tok(:const)
          var = Script::Variable.new(c.value)
          if tok = try_tok(:colon)
            val = assert_expr(:space)
            must_have_default = true
          elsif must_have_default
            raise SyntaxError.new("Required argument #{var.inspect} must come before any optional arguments.")
          end
          res << [var, val]
          break unless try_tok(:comma)
        end
        assert_tok(:rparen)
        res
      end

      def fn_arglist
        arglist(:fn_arglist, :equals)
      end

      def mixin_arglist
        arglist(:mixin_arglist, :interpolation)
      end

      def arglist(type, subexpr)
        return unless e = send(subexpr)
        if @lexer.peek && @lexer.peek.type == :colon
          name = e
          @lexer.expected!("comma") unless name.is_a?(Variable)
          assert_tok(:colon)
          keywords = {name.underscored_name => assert_expr(subexpr, EXPR_NAMES[type])}
        end

        unless try_tok(:comma)
          return [], keywords if keywords
          return [e], {}
        end

        other_args, other_keywords = assert_expr(type)
        if keywords
          if other_keywords[name.underscored_name]
            raise SyntaxError.new("Keyword argument \"#{name.to_sass}\" passed more than once")
          end
          return other_args, keywords.merge(other_keywords)
        else
          return [e, *other_args], other_keywords
        end
      end

      def keyword_arglist
        return unless var = try_tok(:const)
        unless try_tok(:colon)
          return_tok!
          return
        end
        name = var[1]
        value = interpolation
        return {name => value} unless try_tok(:comma)
        {name => value}.merge(assert_expr(:keyword_arglist))
      end

      def raw
        return special_fun unless tok = try_tok(:raw)
        node(Script::String.new(tok.value))
      end

      def special_fun
        return paren unless tok = try_tok(:special_fun)
        first = node(Script::String.new(tok.value.first))
        Sass::Util.enum_slice(tok.value[1..-1], 2).inject(first) do |l, (i, r)|
          Script::Interpolation.new(
            l, i, r && node(Script::String.new(r)),
            false, false)
        end
      end

      def paren
        return variable unless try_tok(:lparen)
        was_in_parens = @in_parens
        @in_parens = true
        line = @lexer.line
        e = expr
        assert_tok(:rparen)
        return e || node(List.new([], :space), line)
      ensure
        @in_parens = was_in_parens
      end

      def variable
        return string unless c = try_tok(:const)
        node(Variable.new(*c.value))
      end

      def string
        return number unless first = try_tok(:string)
        return first.value unless try_tok(:begin_interpolation)
        line = @lexer.line
        mid = parse_interpolated
        last = assert_expr(:string)
        interp = StringInterpolation.new(first.value, mid, last)
        interp.line = line
        interp
      end

      def number
        return literal unless tok = try_tok(:number)
        num = tok.value
        num.original = num.to_s unless @in_parens
        num
      end

      def literal
        (t = try_tok(:color, :bool)) && (return t.value)
      end

      # It would be possible to have unified #assert and #try methods,
      # but detecting the method/token difference turns out to be quite expensive.

      EXPR_NAMES = {
        :string => "string",
        :default => "expression (e.g. 1px, bold)",
        :mixin_arglist => "mixin argument",
        :fn_arglist => "function argument",
      }

      def assert_expr(name, expected = nil)
        (e = send(name)) && (return e)
        @lexer.expected!(expected || EXPR_NAMES[name] || EXPR_NAMES[:default])
      end

      def assert_tok(*names)
        (t = try_tok(*names)) && (return t)
        @lexer.expected!(names.map {|tok| Lexer::TOKEN_NAMES[tok] || tok}.join(" or "))
      end

      def try_tok(*names)
        peeked =  @lexer.peek
        peeked && names.include?(peeked.type) && @lexer.next
      end

      def assert_done
        return if @lexer.done?
        @lexer.expected!(EXPR_NAMES[:default])
      end

      def node(node, line = @lexer.line)
        node.line = line
        node
      end
    end
  end
end
