require 'sass/script/lexer' module Sass module Script # The parser for SassScript. # It parses a string of code into a tree of {Script::Tree::Node}s. class Parser # The line number of the parser's current position. # # @return [Integer] def line @lexer.line end # The column number of the parser's current position. # # @return [Integer] def offset @lexer.offset end # @param str [String, StringScanner] The source text to parse # @param line [Integer] The line on which the SassScript appears. # Used for error reporting and sourcemap building # @param offset [Integer] The character (not byte) offset where the script starts in the line. # Used for error reporting and sourcemap building # @param options [{Symbol => Object}] An options hash; see # {file:SASS_REFERENCE.md#Options the Sass options documentation}. # This supports an additional `:allow_extra_text` option that controls # whether the parser throws an error when extra text is encountered # after the parsed construct. def initialize(str, line, offset, options = {}) @options = options @allow_extra_text = options.delete(:allow_extra_text) @lexer = lexer_class.new(str, line, offset, options) @stop_at = nil 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. # # @param warn_for_color [Boolean] Whether raw color values passed to # interoplation should cause a warning. # @return [Script::Tree::Node] The root node of the parse tree # @raise [Sass::SyntaxError] if the expression isn't valid SassScript def parse_interpolated(warn_for_color = false) # Start two characters back to compensate for #{ start_pos = Sass::Source::Position.new(line, offset - 2) expr = assert_expr :expr assert_tok :end_interpolation expr = Sass::Script::Tree::Interpolation.new( nil, expr, nil, false, false, :warn_for_color => warn_for_color) check_for_interpolation expr expr.options = @options node(expr, start_pos) rescue Sass::SyntaxError => e e.modify_backtrace :line => @lexer.line, :filename => @options[:filename] raise e end # Parses a SassScript expression. # # @return [Script::Tree::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 check_for_interpolation expr 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 tokens [#include?(String)] A set of strings that delimit the expression. # @return [Script::Tree::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 check_for_interpolation expr 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::Tree::Node}, # Script::Tree::Node, # Script::Tree::Node)] # The root nodes of the positional arguments, keyword arguments, and # splat argument(s). 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, splat, kwarg_splat = mixin_arglist assert_tok(:rparen) end assert_done args.each do |a| check_for_interpolation a a.options = @options end keywords.each do |_, v| check_for_interpolation v v.options = @options end if splat check_for_interpolation splat splat.options = @options end if kwarg_splat check_for_interpolation kwarg_splat kwarg_splat.options = @options end return args, keywords, splat, kwarg_splat 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::Tree::Node)] # The root nodes of the arguments, and the splat argument. # @raise [Sass::SyntaxError] if the argument list isn't valid SassScript def parse_mixin_definition_arglist args, splat = defn_arglist!(false) assert_done args.each do |k, v| check_for_interpolation k k.options = @options if v check_for_interpolation v v.options = @options end end if splat check_for_interpolation splat splat.options = @options end return args, splat 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::Tree::Node)] # The root nodes of the arguments, and the splat argument. # @raise [Sass::SyntaxError] if the argument list isn't valid SassScript def parse_function_definition_arglist args, splat = defn_arglist!(true) assert_done args.each do |k, v| check_for_interpolation k k.options = @options if v check_for_interpolation v v.options = @options end end if splat check_for_interpolation splat splat.options = @options end return args, splat rescue Sass::SyntaxError => e e.modify_backtrace :line => @lexer.line, :filename => @options[:filename] raise e end # Parse a single string value, possibly containing interpolation. # Doesn't assert that the scanner is finished after parsing. # # @return [Script::Tree::Node] The root node of the parse tree. # @raise [Sass::SyntaxError] if the string isn't valid SassScript def parse_string unless (peek = @lexer.peek) && (peek.type == :string || (peek.type == :funcall && peek.value.downcase == 'url')) lexer.expected!("string") end expr = assert_expr :funcall check_for_interpolation expr expr.options = @options @lexer.unpeek! expr 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::Tree::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.inspect}" 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 < true, :deprecation => deprecation), (prev || str).source_range.start_pos) interpolation(first: interp) end def try_ops_after_interp(ops, name, prev = nil) return unless @lexer.after_interpolation? op = try_toks(*ops) return unless op interp = try_op_before_interp(op, prev, :after_interp) return interp if interp wa = @lexer.whitespace? str = literal_node(Script::Value::String.new(Lexer::OPERATORS_REVERSE[op.type]), op.source_range) str.line = @lexer.line deprecation = case op.type when :comma; :potential when :div, :single_eq; :none when :minus; @lexer.whitespace?(op) ? :immediate : :none else; :immediate end interp = node( Script::Tree::Interpolation.new( prev, str, assert_expr(name), false, wa, :originally_text => true, :deprecation => deprecation), (prev || str).source_range.start_pos) interp end def interpolation(first: nil, inner: :space) e = first || send(inner) while (interp = try_tok(:begin_interpolation)) wb = @lexer.whitespace?(interp) char_before = @lexer.char(interp.pos - 1) mid = assert_expr :expr assert_tok :end_interpolation wa = @lexer.whitespace? char_after = @lexer.char after = send(inner) before_deprecation = e.is_a?(Script::Tree::Interpolation) ? e.deprecation : :none after_deprecation = after.is_a?(Script::Tree::Interpolation) ? after.deprecation : :none deprecation = if before_deprecation == :immediate || after_deprecation == :immediate || # Warn for #{foo}$var and #{foo}(1) but not #{$foo}1. (after && !wa && char_after =~ /[$(]/) || # Warn for $var#{foo} and (a)#{foo} but not a#{foo}. (e && !wb && is_unsafe_before?(e, char_before)) :immediate else :potential end e = node( Script::Tree::Interpolation.new(e, mid, after, wb, wa, :deprecation => deprecation), (e || interp).source_range.start_pos) end e end # Returns whether `expr` is unsafe to include before an interpolation. # # @param expr [Node] The expression to check. # @param char_before [String] The character immediately before the # interpolation being checked (and presumably the last character of # `expr`). # @return [Boolean] def is_unsafe_before?(expr, char_before) return char_before == ')' if is_safe_value?(expr) # Otherwise, it's only safe if it was another interpolation. !expr.is_a?(Script::Tree::Interpolation) end # Returns whether `expr` is safe as the value immediately before an # interpolation. # # It's safe as long as the previous expression is an identifier or number, # or a list whose last element is also safe. def is_safe_value?(expr) return is_safe_value?(expr.elements.last) if expr.is_a?(Script::Tree::ListLiteral) return false unless expr.is_a?(Script::Tree::Literal) expr.value.is_a?(Script::Value::Number) || (expr.value.is_a?(Script::Value::String) && expr.value.type == :identifier) end def space start_pos = source_position e = or_expr return unless e arr = [e] while (e = or_expr) arr << e end if arr.size == 1 arr.first else node(Sass::Script::Tree::ListLiteral.new(arr, separator: :space), start_pos) end 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 = Sass::Script::Value::Color::COLOR_NAMES[name.value.downcase]) literal_node(Sass::Script::Value::Color.new(color, name.value), name.source_range) elsif name.value == "true" literal_node(Sass::Script::Value::Bool.new(true), name.source_range) elsif name.value == "false" literal_node(Sass::Script::Value::Bool.new(false), name.source_range) elsif name.value == "null" literal_node(Sass::Script::Value::Null.new, name.source_range) else literal_node(Sass::Script::Value::String.new(name.value, :identifier), name.source_range) end end def funcall tok = try_tok(:funcall) return raw unless tok args, keywords, splat, kwarg_splat = fn_arglist assert_tok(:rparen) node(Script::Tree::Funcall.new(tok.value, args, keywords, splat, kwarg_splat), tok.source_range.start_pos, source_position) end def defn_arglist!(must_have_parens) if must_have_parens assert_tok(:lparen) else return [], nil unless try_tok(:lparen) end res = [] splat = nil must_have_default = false loop do break if peek_tok(:rparen) c = assert_tok(:const) var = node(Script::Tree::Variable.new(c.value), c.source_range) if try_tok(:colon) val = assert_expr(:space) must_have_default = true elsif try_tok(:splat) splat = var break 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) return res, splat end def fn_arglist arglist(:equals, "function argument") end def mixin_arglist arglist(:interpolation, "mixin argument") end def arglist(subexpr, description) args = [] keywords = Sass::Util::NormalizedMap.new splat = nil while (e = send(subexpr)) if @lexer.peek && @lexer.peek.type == :colon name = e @lexer.expected!("comma") unless name.is_a?(Tree::Variable) assert_tok(:colon) value = assert_expr(subexpr, description) if keywords[name.name] raise SyntaxError.new("Keyword argument \"#{name.to_sass}\" passed more than once") end keywords[name.name] = value else if try_tok(:splat) return args, keywords, splat, e if splat splat, e = e, nil elsif splat raise SyntaxError.new("Only keyword arguments may follow variable arguments (...).") elsif !keywords.empty? raise SyntaxError.new("Positional arguments must come before keyword arguments.") end args << e if e end return args, keywords, splat unless try_tok(:comma) end return args, keywords end def raw tok = try_tok(:raw) return special_fun unless tok literal_node(Script::Value::String.new(tok.value), tok.source_range) end def special_fun first = try_tok(:special_fun) return square_list unless first str = literal_node(first.value, first.source_range) return str unless try_tok(:string_interpolation) mid = assert_expr :expr assert_tok :end_interpolation last = assert_expr(:special_fun) node( Tree::Interpolation.new(str, mid, last, false, false), first.source_range.start_pos) end def square_list start_pos = source_position return paren unless try_tok(:lsquare) space_start_pos = source_position e = interpolation(inner: :or_expr) separator = nil if e elements = [e] while (e = interpolation(inner: :or_expr)) elements << e end # If there's a comma after a space-separated list, it's actually a # space-separated list nested in a comma-separated list. if try_tok(:comma) e = if elements.length == 1 elements.first else node( Sass::Script::Tree::ListLiteral.new(elements, separator: :space), space_start_pos) end elements = [e] while (e = space) elements << e break unless try_tok(:comma) end separator = :comma else separator = :space if elements.length > 1 end else elements = [] end assert_tok(:rsquare) end_pos = source_position node(Sass::Script::Tree::ListLiteral.new(elements, separator: separator, bracketed: true), start_pos, end_pos) end def paren return variable unless try_tok(:lparen) start_pos = source_position e = map e.force_division! if e end_pos = source_position assert_tok(:rparen) e || node(Sass::Script::Tree::ListLiteral.new([]), start_pos, end_pos) end def variable start_pos = source_position c = try_tok(:const) return string unless c node(Tree::Variable.new(*c.value), start_pos) end def string first = try_tok(:string) return number unless first str = literal_node(first.value, first.source_range) return str unless try_tok(:string_interpolation) mid = assert_expr :expr assert_tok :end_interpolation last = assert_expr(:string) node(Tree::StringInterpolation.new(str, mid, last), first.source_range.start_pos) end def number tok = try_tok(:number) return selector unless tok num = tok.value num.options = @options num.original = num.to_s literal_node(num, tok.source_range.start_pos) end def selector tok = try_tok(:selector) return literal unless tok node(tok.value, tok.source_range.start_pos) end def literal t = try_tok(:color) return literal_node(t.value, t.source_range) if t 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", :splat => "...", :special_fun => '")"', } def assert_expr(name, expected = nil) e = send(name) return e if e @lexer.expected!(expected || EXPR_NAMES[name] || EXPR_NAMES[:default]) end def assert_tok(name) # Avoids an array allocation caused by argument globbing in assert_toks. t = try_tok(name) return t if t @lexer.expected!(Lexer::TOKEN_NAMES[name] || name.to_s) end def assert_toks(*names) t = try_toks(*names) return t if t @lexer.expected!(names.map {|tok| Lexer::TOKEN_NAMES[tok] || tok}.join(" or ")) end def peek_tok(name) # Avoids an array allocation caused by argument globbing in the try_toks method. peeked = @lexer.peek peeked && name == peeked.type end def try_tok(name) peek_tok(name) && @lexer.next end def try_toks(*names) peeked = @lexer.peek peeked && names.include?(peeked.type) && @lexer.next end def assert_done if @allow_extra_text # If extra text is allowed, just rewind the lexer so that the # StringScanner is pointing to the end of the parsed text. @lexer.unpeek! else return if @lexer.done? @lexer.expected!(EXPR_NAMES[:default]) end end # @overload node(value, source_range) # @param value [Sass::Script::Value::Base] # @param source_range [Sass::Source::Range] # @overload node(value, start_pos, end_pos = source_position) # @param value [Sass::Script::Value::Base] # @param start_pos [Sass::Source::Position] # @param end_pos [Sass::Source::Position] def literal_node(value, source_range_or_start_pos, end_pos = source_position) node(Sass::Script::Tree::Literal.new(value), source_range_or_start_pos, end_pos) end # @overload node(node, source_range) # @param node [Sass::Script::Tree::Node] # @param source_range [Sass::Source::Range] # @overload node(node, start_pos, end_pos = source_position) # @param node [Sass::Script::Tree::Node] # @param start_pos [Sass::Source::Position] # @param end_pos [Sass::Source::Position] def node(node, source_range_or_start_pos, end_pos = source_position) source_range = if source_range_or_start_pos.is_a?(Sass::Source::Range) source_range_or_start_pos else range(source_range_or_start_pos, end_pos) end node.line = source_range.start_pos.line node.source_range = source_range node.filename = @options[:filename] node end # Checks a script node for any immediately-deprecated interpolations, and # emits warnings for them. # # @param node [Sass::Script::Tree::Node] def check_for_interpolation(node) nodes = [node] until nodes.empty? node = nodes.pop unless node.is_a?(Sass::Script::Tree::Interpolation) && node.deprecation == :immediate nodes.concat node.children next end interpolation_deprecation(node) end end # Emits a deprecation warning for an interpolation node. # # @param node [Sass::Script::Tree::Node] def interpolation_deprecation(interpolation) return if @options[:_convert] location = "on line #{interpolation.line}" location << " of #{interpolation.filename}" if interpolation.filename Sass::Util.sass_warn <