require 'sass/tree/node' require 'sass/tree/value_node' require 'sass/tree/rule_node' require 'sass/tree/comment_node' require 'sass/tree/attr_node' require 'sass/tree/directive_node' require 'sass/constant' require 'sass/error' module Sass # This is the class where all the parsing and processing of the Sass # template is done. It can be directly used by the user by creating a # new instance and calling render to render the template. For example: # # template = File.load('stylesheets/sassy.sass') # sass_engine = Sass::Engine.new(template) # output = sass_engine.render # puts output class Engine # The character that begins a CSS attribute. ATTRIBUTE_CHAR = ?: # The character that designates that # an attribute should be assigned to the result of constant arithmetic. SCRIPT_CHAR = ?= # The character that designates the beginning of a comment, # either Sass or CSS. COMMENT_CHAR = ?/ # The character that follows the general COMMENT_CHAR and designates a Sass comment, # which is not output as a CSS comment. SASS_COMMENT_CHAR = ?/ # The character that follows the general COMMENT_CHAR and designates a CSS comment, # which is embedded in the CSS document. CSS_COMMENT_CHAR = ?* # The character used to denote a compiler directive. DIRECTIVE_CHAR = ?@ # Designates a non-parsed rule. ESCAPE_CHAR = ?\\ # Designates block as mixin definition rather than CSS rules to output MIXIN_DEFINITION_CHAR = ?= # Includes named mixin declared using MIXIN_DEFINITION_CHAR MIXIN_INCLUDE_CHAR = ?+ # The regex that matches and extracts data from # attributes of the form :name attr. ATTRIBUTE = /^:([^\s=:]+)\s*(=?)(?:\s+|$)(.*)/ # The regex that matches attributes of the form name: attr. ATTRIBUTE_ALTERNATE_MATCHER = /^[^\s:]+\s*[=:](\s|$)/ # The regex that matches and extracts data from # attributes of the form name: attr. ATTRIBUTE_ALTERNATE = /^([^\s=:]+)(\s*=|:)(?:\s+|$)(.*)/ # Creates a new instace of Sass::Engine that will compile the given # template string when render is called. # See README.rdoc for available options. # #-- # # TODO: Add current options to REFRENCE. Remember :filename! # # When adding options, remember to add information about them # to README.rdoc! #++ # def initialize(template, options={}) @options = { :style => :nested, :load_paths => ['.'] }.merge! options @template = template.split(/\r\n|\r|\n/) @lines = [] @constants = {"important" => "!important"} @mixins = {} end # Processes the template and returns the result as a string. def render begin render_to_tree.to_s rescue SyntaxError => err unless err.sass_filename err.add_backtrace_entry(@options[:filename]) end raise err end end alias_method :to_css, :render protected def constants @constants end def mixins @mixins end def render_to_tree split_lines root = Tree::Node.new(@options[:style]) index = 0 while @lines[index] old_index = index child, index = build_tree(index) if child.is_a? Tree::Node child.line = old_index + 1 root << child elsif child.is_a? Array child.each do |c| root << c end end end @lines.clear root end private # Readies each line in the template for parsing, # and computes the tabulation of the line. def split_lines @line = 0 old_tabs = nil @template.each_with_index do |line, index| @line += 1 tabs = count_tabs(line) if line[0] == COMMENT_CHAR && line[1] == SASS_COMMENT_CHAR && tabs == 0 tabs = old_tabs end if tabs # if line isn't blank raise SyntaxError.new("Indenting at the beginning of the document is illegal.", @line) if old_tabs.nil? && tabs > 0 if old_tabs && tabs - old_tabs > 1 raise SyntaxError.new("#{tabs * 2} spaces were used for indentation. Sass must be indented using two spaces.", @line) end @lines << [line.strip, tabs] old_tabs = tabs else @lines << ['//', old_tabs || 0] end end @line = nil end # Counts the tabulation of a line. def count_tabs(line) return nil if line.strip.empty? return nil unless spaces = line.index(/[^ ]/) if spaces % 2 == 1 raise SyntaxError.new(< tabs end def raw_next_line(index) [@lines[index][0], index + 1] end def parse_line(line) case line[0] when ATTRIBUTE_CHAR parse_attribute(line, ATTRIBUTE) when Constant::CONSTANT_CHAR parse_constant(line) when COMMENT_CHAR parse_comment(line) when DIRECTIVE_CHAR parse_directive(line) when ESCAPE_CHAR Tree::RuleNode.new(line[1..-1], @options[:style]) when MIXIN_DEFINITION_CHAR parse_mixin_definition(line) when MIXIN_INCLUDE_CHAR if line[1].nil? || line[1] == ?\s Tree::RuleNode.new(line, @options[:style]) else parse_mixin_include(line) end else if line =~ ATTRIBUTE_ALTERNATE_MATCHER parse_attribute(line, ATTRIBUTE_ALTERNATE) else Tree::RuleNode.new(line, @options[:style]) end end end def parse_attribute(line, attribute_regx) if @options[:attribute_syntax] == :normal && attribute_regx == ATTRIBUTE_ALTERNATE raise SyntaxError.new("Illegal attribute syntax: can't use alternate syntax when :attribute_syntax => :normal is set.") elsif @options[:attribute_syntax] == :alternate && attribute_regx == ATTRIBUTE raise SyntaxError.new("Illegal attribute syntax: can't use normal syntax when :attribute_syntax => :alternate is set.") end name, eq, value = line.scan(attribute_regx)[0] if name.nil? || value.nil? raise SyntaxError.new("Invalid attribute: \"#{line}\".", @line) end if eq.strip[0] == SCRIPT_CHAR value = Sass::Constant.parse(value, @constants, @line).to_s end Tree::AttrNode.new(name, value, @options[:style]) end def parse_constant(line) name, op, value = line.scan(Sass::Constant::MATCH)[0] unless name && value raise SyntaxError.new("Invalid constant: \"#{line}\".", @line) end constant = Sass::Constant.parse(value, @constants, @line) if op == '||=' @constants[name] ||= constant else @constants[name] = constant end :constant end def parse_comment(line) if line[1] == SASS_COMMENT_CHAR :comment elsif line[1] == CSS_COMMENT_CHAR Tree::CommentNode.new(line, @options[:style]) else Tree::RuleNode.new(line, @options[:style]) end end def parse_directive(line) directive, value = line[1..-1].split(/\s+/, 2) # If value begins with url( or ", # it's a CSS @import rule and we don't want to touch it. if directive == "import" && value !~ /^(url\(|")/ import(value) else Tree::DirectiveNode.new(line, @options[:style]) end end def parse_mixin_definition(line) mixin_name = line[1..-1] @mixins[mixin_name] = [] index = @line line, tabs = @lines[index] while !line.nil? && tabs > 0 child, index = build_tree(index) validate_and_append_child(@mixins[mixin_name], child) line, tabs = @lines[index] end :mixin end def parse_mixin_include(line) mixin_name = line[1..-1] unless @mixins.has_key?(mixin_name) raise SyntaxError.new("Undefined mixin '#{mixin_name}'.", @line) end @mixins[mixin_name] end def import(files) nodes = [] files.split(/,\s*/).each do |filename| engine = nil begin filename = self.class.find_file_to_import(filename, @options[:load_paths]) rescue Exception => e raise SyntaxError.new(e.message, @line) end if filename =~ /\.css$/ nodes << Tree::DirectiveNode.new("@import url(#{filename})", @options[:style]) else File.open(filename) do |file| new_options = @options.dup new_options[:filename] = filename engine = Sass::Engine.new(file.read, @options) end engine.constants.merge! @constants engine.mixins.merge! @mixins begin root = engine.render_to_tree rescue Sass::SyntaxError => err err.add_backtrace_entry(filename) raise err end root.children.each do |child| child.filename = filename nodes << child end @constants = engine.constants @mixins = engine.mixins end end nodes end def self.find_file_to_import(filename, load_paths) was_sass = false original_filename = filename if filename[-5..-1] == ".sass" filename = filename[0...-5] was_sass = true elsif filename[-4..-1] == ".css" return filename end new_filename = find_full_path("#{filename}.sass", load_paths) if new_filename.nil? if was_sass raise Exception.new("File to import not found or unreadable: #{original_filename}.") else return filename + '.css' end else new_filename end end def self.find_full_path(filename, load_paths) load_paths.each do |path| ["_#{filename}", filename].each do |name| full_path = File.join(path, name) if File.readable?(full_path) return full_path end end end nil end end end