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, {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] 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] 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 < 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 e = assert_expr(:expr) assert_tok(:rparen) return e 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