<?php /* SVN FILE: $Id: SassParser.php 118 2010-09-21 09:45:11Z chris.l.yates@gmail.com $ */ /**
* SassParser class file. * See the {@link http://sass-lang.com/docs Sass documentation} * for details of Sass. * * Credits: * This is a port of Sass to PHP. All the genius comes from the people that * invented and develop Sass; in particular: * + {@link http://hamptoncatlin.com/ Hampton Catlin}, * + {@link http://nex-3.com/ Nathan Weizenbaum}, * + {@link http://chriseppstein.github.com/ Chris Eppstein} * * The bugs are mine. Please report any found at {@link http://code.google.com/p/phamlp/issues/list} * * @author Chris Yates <chris.l.yates@gmail.com> * @copyright Copyright (c) 2010 PBM Web Development * @license http://phamlp.googlecode.com/files/license.txt * @package PHamlP * @subpackage Sass */
require_once('SassFile.php'); require_once('SassException.php'); require_once('tree/SassNode.php');
/**
* SassParser class. * Parses {@link http://sass-lang.com/ .sass and .sccs} files. * @package PHamlP * @subpackage Sass */
class SassParser {
/**#@+ * Default option values */ const CACHE = true; const CACHE_LOCATION = './sass-cache'; const CSS_LOCATION = './css'; const TEMPLATE_LOCATION = './sass-templates'; const BEGIN_COMMENT = '/'; const BEGIN_CSS_COMMENT = '/*'; const END_CSS_COMMENT = '*/'; const BEGIN_SASS_COMMENT= '//'; const BEGIN_INTERPOLATION = '#'; const BEGIN_INTERPOLATION_BLOCK = '#{'; const BEGIN_BLOCK = '{'; const END_BLOCK = '}'; const END_STATEMENT = ';'; const DOUBLE_QUOTE = '"'; const SINGLE_QUOTE = "'"; /** * @var string the character used for indenting * @see indentChars * @see indentSpaces */ private $indentChar; /** * @var array allowable characters for indenting */ private $indentChars = array(' ', "\t"); /** * @var integer number of spaces for indentation. * Used to calculate {@link Level} if {@link indentChar} is space. */ private $indentSpaces = 2; /** * @var string source */ private $source; /**#@+ * Option */ /** * cache: * @var boolean Whether parsed Sass files should be cached, allowing greater * speed. * * Defaults to true. */ private $cache; /** * cache_location: * @var string The path where the cached sassc files should be written to. * * Defaults to './sass-cache'. */ private $cache_location; /** * css_location: * @var string The path where CSS output should be written to. * * Defaults to './css'. */ private $css_location; /** * debug_info: * @var boolean When true the line number and file where a selector is defined * is emitted into the compiled CSS in a format that can be understood by the * {@link https://addons.mozilla.org/en-US/firefox/addon/103988/ * FireSass Firebug extension}. * Disabled when using the compressed output style. * * Defaults to false. * @see style */ private $debug_info; /** * extensions: * @var array Sass extensions, e.g. Compass. An associative array of the form * $name => $options where $name is the name of the extension and $options * is an array of name=>value options pairs. */ protected $extensions; /** * filename: * @var string The filename of the file being rendered. * This is used solely for reporting errors. */ protected $filename; /** * function_paths: * @var array An array of filesystem paths which should be searched for * SassScript functions. */ private $function_paths; /** * line: * @var integer The number of the first line of the Sass template. Used for * reporting line numbers for errors. This is useful to set if the Sass * template is embedded. * * Defaults to 1. */ private $line; /** * line_numbers: * @var boolean When true the line number and filename where a selector is * defined is emitted into the compiled CSS as a comment. Useful for debugging * especially when using imports and mixins. * Disabled when using the compressed output style or the debug_info option. * * Defaults to false. * @see debug_info * @see style */ private $line_numbers; /** * load_paths: * @var array An array of filesystem paths which should be searched for * Sass templates imported with the @import directive. * * Defaults to './sass-templates'. */ private $load_paths; /** * property_syntax: * @var string Forces the document to use one syntax for * properties. If the correct syntax isn't used, an error is thrown. * Value can be: * + new - forces the use of a colon or equals sign after the property name. * For example color: #0f3 or width: $main_width. * + old - forces the use of a colon before the property name. * For example: :color #0f3 or :width = $main_width. * * By default, either syntax is valid. * * Ignored for SCSS files which alaways use the new style. */ private $property_syntax; /** * quiet: * @var boolean When set to true, causes warnings to be disabled. * Defaults to false. */ private $quiet; /** * style: * @var string the style of the CSS output. * Value can be: * + nested - Nested is the default Sass style, because it reflects the * structure of the document in much the same way Sass does. Each selector * and rule has its own line with indentation is based on how deeply the rule * is nested. Nested style is very useful when looking at large CSS files as * it allows you to very easily grasp the structure of the file without * actually reading anything. * + expanded - Expanded is the typical human-made CSS style, with each selector * and property taking up one line. Selectors are not indented; properties are * indented within the rules. * + compact - Each CSS rule takes up only one line, with every property defined * on that line. Nested rules are placed with each other while groups of rules * are separated by a blank line. * + compressed - Compressed has no whitespace except that necessary to separate * selectors and properties. It's not meant to be human-readable. * * Defaults to 'nested'. */ private $style; /** * syntax: * @var string The syntax of the input file. * 'sass' for the indented syntax and 'scss' for the CSS-extension syntax. * * This is set automatically when parsing a file, else defaults to 'sass'. */ private $syntax; /** * template_location: * @var string Path to the root sass template directory for your * application. */ private $template_location; /** * vendor_properties: * If enabled a property need only be written in the standard form and vendor * specific versions will be added to the style sheet. * @var mixed array: vendor properties, merged with the built-in vendor * properties, to automatically apply. * Boolean true: use built in vendor properties. * * Defaults to vendor_properties disabled. * @see _vendorProperties */ private $vendor_properties = array(); /**#@-*/ /** * Defines the build-in vendor properties * @var array built-in vendor properties * @see vendor_properties */ private $_vendorProperties = array( 'border-radius' => array( '-moz-border-radius', '-webkit-border-radius', '-khtml-border-radius' ), 'border-top-right-radius' => array( '-moz-border-radius-topright', '-webkit-border-top-right-radius', '-khtml-border-top-right-radius' ), 'border-bottom-right-radius' => array( '-moz-border-radius-bottomright', '-webkit-border-bottom-right-radius', '-khtml-border-bottom-right-radius' ), 'border-bottom-left-radius' => array( '-moz-border-radius-bottomleft', '-webkit-border-bottom-left-radius', '-khtml-border-bottom-left-radius' ), 'border-top-left-radius' => array( '-moz-border-radius-topleft', '-webkit-border-top-left-radius', '-khtml-border-top-left-radius' ), 'box-shadow' => array('-moz-box-shadow', '-webkit-box-shadow'), 'box-sizing' => array('-moz-box-sizing', '-webkit-box-sizing'), 'opacity' => array('-moz-opacity', '-webkit-opacity', '-khtml-opacity'), ); /** * Constructor. * Sets parser options * @param array $options * @return SassParser */ public function __construct($options = array()) { if (!is_array($options)) { throw new SassException('{what} must be a {type}', array('{what}'=>'options', '{type}'=>'array')); } if (!empty($options['language'])) { Phamlp::$language = $options['language']; } if (!empty($options['extensions'])) { foreach ($options['extensions'] as $extension=>$extOptions) { include dirname(__FILE__).DIRECTORY_SEPARATOR.'extensions'.DIRECTORY_SEPARATOR.$extension.DIRECTORY_SEPARATOR.'config.php'; $configClass = 'SassExtentions'.$extension.'Config'; $config = new $configClass; $config->config($extOptions); $lp = dirname(__FILE__).DIRECTORY_SEPARATOR.'extensions'.DIRECTORY_SEPARATOR.$extension.DIRECTORY_SEPARATOR.'frameworks'; $fp = dirname(__FILE__).DIRECTORY_SEPARATOR.'extensions'.DIRECTORY_SEPARATOR.$extension.DIRECTORY_SEPARATOR.'functions'; $options['load_paths'] = (empty($options['load_paths']) ? array($lp) : array_merge($options['load_paths'], $lp)); $options['function_paths'] = (empty($options['function_paths']) ? array($fp) : array_merge($options['function_paths'], $fp)); } } if (!empty($options['vendor_properties'])) { if ($options['vendor_properties'] === true) { $this->vendor_properties = $this->_vendorProperties; } elseif (is_array($options['vendor_properties'])) { $this->vendor_properties = array_merge($this->vendor_properties, $this->_vendorProperties); } } unset($options['language'], $options['vendor_properties']); $defaultOptions = array( 'cache' => self::CACHE, 'cache_location' => dirname(__FILE__) . DIRECTORY_SEPARATOR . self::CACHE_LOCATION, 'css_location' => dirname(__FILE__) . DIRECTORY_SEPARATOR . self::CSS_LOCATION, 'debug_info' => false, 'filename' => array('dirname' => '', 'basename' => ''), 'function_paths' => array(), 'load_paths' => array(dirname(__FILE__) . DIRECTORY_SEPARATOR . self::TEMPLATE_LOCATION), 'line' => 1, 'line_numbers' => false, 'style' => SassRenderer::STYLE_NESTED, 'syntax' => SassFile::SASS ); foreach (array_merge($defaultOptions, $options) as $name=>$value) { if (property_exists($this, $name)) { $this->$name = $value; } } } /** * Getter. * @param string name of property to get * @return mixed return value of getter function */ public function __get($name) { $getter = 'get' . ucfirst($name); if (method_exists($this, $getter)) { return $this->$getter(); } throw new SassException('No getter function for {what}', array('{what}'=>$name)); } public function getCache() { return $this->cache; } public function getCache_location() { return $this->cache_location; } public function getCss_location() { return $this->css_location; } public function getDebug_info() { return $this->debug_info; } public function getFilename() { return $this->filename; } public function getLine() { return $this->line; } public function getSource() { return $this->source; } public function getLine_numbers() { return $this->line_numbers; } public function getFunction_paths() { return $this->function_paths; } public function getLoad_paths() { return $this->load_paths; } public function getProperty_syntax() { return $this->property_syntax; } public function getQuiet() { return $this->quiet; } public function getStyle() { return $this->style; } public function getSyntax() { return $this->syntax; } public function getTemplate_location() { return $this->template_location; } public function getVendor_properties() { return $this->vendor_properties; } public function getOptions() { return array( 'cache' => $this->cache, 'cache_location' => $this->cache_location, 'css_location' => $this->css_location, 'filename' => $this->filename, 'function_paths' => $this->function_paths, 'line' => $this->line, 'line_numbers' => $this->line_numbers, 'load_paths' => $this->load_paths, 'property_syntax' => $this->property_syntax, 'quiet' => $this->quiet, 'style' => $this->style, 'syntax' => $this->syntax, 'template_location' => $this->template_location, 'vendor_properties' => $this->vendor_properties ); } /** * Parse a sass file or Sass source code and returns the CSS. * @param string name of source file or Sass source * @return string CSS */ public function toCss($source, $isFile = true) { return $this->parse($source, $isFile)->render(); } /** * Parse a sass file or Sass source code and * returns the document tree that can then be rendered. * The file will be searched for in the directories specified by the * load_paths option. * If caching is enabled a cached version will be used if possible or the * compiled version cached if not. * @param string name of source file or Sass source * @return SassRootNode Root node of document tree */ public function parse($source, $isFile = true) { if ($isFile) { $this->filename = SassFile::getFile($source, $this); if ($isFile) { $this->syntax = substr($this->filename, -4); } elseif ($this->syntax !== SassFile::SASS && $this->syntax !== SassFile::SCSS) { throw new SassException('Invalid {what}', array('{what}'=>'syntax option')); } if ($this->cache) { $cached = SassFile::getCachedFile($this->filename, $this->cache_location); if ($cached !== false) { return $cached; } } $tree = $this->toTree(file_get_contents($this->filename)); if ($this->cache) { SassFile::setCachedFile($tree, $this->filename, $this->cache_location); } return $tree; } else { return $this->toTree($source); } } /** * Parse Sass source into a document tree. * If the tree is already created return that. * @param string Sass source * @return SassRootNode the root of this document tree */ private function toTree($source) { if ($this->syntax === SassFile::SASS) { $this->source = explode("\n", $source); $this->setIndentChar(); } else { $this->source = $source; } unset($source); $root = new SassRootNode($this); $this->buildTree($root); return $root; } /** * Builds a parse tree under the parent node. * Called recursivly until the source is parsed. * @param SassNode the node */ private function buildTree($parent) { $node = $this->getNode($parent); while (is_object($node) && $node->isChildOf($parent)) { $parent->addChild($node); $node = $this->buildTree($node); } return $node; } /** * Creates and returns the next SassNode. * The tpye of SassNode depends on the content of the SassToken. * @return SassNode a SassNode of the appropriate type. Null when no more * source to parse. */ private function getNode($node) { $token = $this->getToken(); if (empty($token)) return null; switch (true) { case SassDirectiveNode::isa($token): return $this->parseDirective($token, $node); break; case SassCommentNode::isa($token): return new SassCommentNode($token); break; case SassVariableNode::isa($token): return new SassVariableNode($token); break; case SassPropertyNode::isa($token, $this->property_syntax): return new SassPropertyNode($token, $this->property_syntax); break; case SassMixinDefinitionNode::isa($token): if ($this->syntax === SassFile::SCSS) { throw new SassException('Mixin {which} shortcut not allowed in SCSS', array('{which}'=>'definition'), $this); } return new SassMixinDefinitionNode($token); break; case SassMixinNode::isa($token): if ($this->syntax === SassFile::SCSS) { throw new SassException('Mixin {which} shortcut not allowed in SCSS', array('{which}'=>'include'), $this); } return new SassMixinNode($token); break; default: return new SassRuleNode($token); break; } // switch } /** * Returns a token object that contains the next source statement and * meta data about it. * @return object */ private function getToken() { return ($this->syntax === SassFile::SASS ? $this->sass2Token() : $this->scss2Token()); } /** * Returns an object that contains the next source statement and meta data * about it from SASS source. * Sass statements are passed over. Statements spanning multiple lines, e.g. * CSS comments and selectors, are assembled into a single statement. * @return object Statement token. Null if end of source. */ private function sass2Token() { $statement = ''; // source line being tokenised $token = null; while (is_null($token) && !empty($this->source)) { while (empty($statement) && !empty($this->source)) { $source = array_shift($this->source); $statement = trim($source); $this->line++; } if (empty($statement)) { break; } $level = $this->getLevel($source); // Comment statements can span multiple lines if ($statement[0] === self::BEGIN_COMMENT) { // Consume Sass comments if (substr($statement, 0, strlen(self::BEGIN_SASS_COMMENT)) === self::BEGIN_SASS_COMMENT) { unset($statement); while($this->getLevel($this->source[0]) > $level) { array_shift($this->source); $this->line++; } continue; } // Build CSS comments elseif (substr($statement, 0, strlen(self::BEGIN_CSS_COMMENT)) === self::BEGIN_CSS_COMMENT) { while($this->getLevel($this->source[0]) > $level) { $statement .= "\n" . ltrim(array_shift($this->source)); $this->line++; } } else { $this->source = $statement; throw new SassException('Illegal comment type', array(), $this); } } // Selector statements can span multiple lines elseif (substr($statement, -1) === SassRuleNode::CONTINUED) { // Build the selector statement while($this->getLevel($this->source[0]) === $level) { $statement .= ltrim(array_shift($this->source)); $this->line++; } } $token = (object) array( 'source' => $statement, 'level' => $level, 'filename' => $this->filename, 'line' => $this->line - 1, ); } return $token; } /** * Returns the level of the line. * Used for .sass source * @param string the source * @return integer the level of the source * @throws Exception if the source indentation is invalid */ private function getLevel($source) { $indent = strlen($source) - strlen(ltrim($source)); $level = $indent/$this->indentSpaces; if (!is_int($level) || preg_match("/[^{$this->indentChar}]/", substr($source, 0, $indent))) { $this->source = $source; throw new SassException('Invalid indentation', array(), $this); } return $level; } /** * Returns an object that contains the next source statement and meta data * about it from SCSS source. * @return object Statement token. Null if end of source. */ private function scss2Token() { static $srcpos = 0; // current position in the source stream static $srclen; // the length of the source stream $statement = ''; $token = null; if (empty($srclen)) { $srclen = strlen($this->source); } while (is_null($token) && $srcpos < $srclen) { $c = $this->source[$srcpos++]; switch ($c) { case self::BEGIN_COMMENT: if (substr($this->source, $srcpos-1, strlen(self::BEGIN_SASS_COMMENT)) === self::BEGIN_SASS_COMMENT) { while ($this->source[$srcpos++] !== "\n"); $statement .= "\n"; } elseif (substr($this->source, $srcpos-1, strlen(self::BEGIN_CSS_COMMENT)) === self::BEGIN_CSS_COMMENT) { if (ltrim($statement)) { throw new SassException('Invalid {what}', array('{what}'=>'comment'), (object) array( 'source' => $statement, 'filename' => $this->filename, 'line' => $this->line, )); } $statement .= $c.$this->source[$srcpos++]; while (substr($this->source, $srcpos, strlen(self::END_CSS_COMMENT)) !== self::END_CSS_COMMENT) { $statement .= $this->source[$srcpos++]; } $srcpos += strlen(self::END_CSS_COMMENT); $token = $this->createToken($statement.self::END_CSS_COMMENT); } else { $statement .= $c; } break; case self::DOUBLE_QUOTE: case self::SINGLE_QUOTE: $statement .= $c; while ($this->source[$srcpos] !== $c) { $statement .= $this->source[$srcpos++]; } $statement .= $this->source[$srcpos++]; break; case self::BEGIN_INTERPOLATION: $statement .= $c; if (substr($this->source, $srcpos-1, strlen(self::BEGIN_INTERPOLATION_BLOCK)) === self::BEGIN_INTERPOLATION_BLOCK) { while ($this->source[$srcpos] !== self::END_BLOCK) { $statement .= $this->source[$srcpos++]; } $statement .= $this->source[$srcpos++]; } break; case self::BEGIN_BLOCK: case self::END_BLOCK: case self::END_STATEMENT: $token = $this->createToken($statement . $c); if (is_null($token)) $statement = ''; break; default: $statement .= $c; break; } } if (is_null($token)) $srclen = $srcpos = 0; return $token; } /** * Returns an object that contains the source statement and meta data about * it. * If the statement is just and end block we update the meta data and return null. * @param string source statement * @return SassToken */ private function createToken($statement) { static $level = 0; $this->line += substr_count($statement, "\n"); $statement = trim($statement); if (substr($statement, 0, strlen(self::BEGIN_CSS_COMMENT)) !== self::BEGIN_CSS_COMMENT) { $statement = str_replace(array("\n","\r"), '', $statement); } $last = substr($statement, -1); // Trim the statement removing whitespace, end statement (;), begin block ({), and (unless the statement ends in an interpolation block) end block (}) $statement = rtrim($statement, ' '.self::BEGIN_BLOCK.self::END_STATEMENT); $statement = (preg_match('/#\{.+?\}$/i', $statement) ? $statement : rtrim($statement, self::END_BLOCK)); $token = ($statement ? (object) array( 'source' => $statement, 'level' => $level, 'filename' => $this->filename, 'line' => $this->line, ) : null); $level += ($last === self::BEGIN_BLOCK ? 1 : ($last === self::END_BLOCK ? -1 : 0)); return $token; } /** * Parses a directive * @param SassToken token to parse * @param SassNode parent node * @return SassNode a Sass directive node */ private function parseDirective($token, $parent) { switch (SassDirectiveNode::extractDirective($token)) { case '@extend': return new SassExtendNode($token); break; case '@mixin': return new SassMixinDefinitionNode($token); break; case '@include': return new SassMixinNode($token); break; case '@import': if ($this->syntax == SassFile::SASS) { $i = 0; $source = ''; while (!empty($this->source) && empty($source)) { $source = $this->source[$i++]; } if (!empty($source) && $this->getLevel($source) > $token->level) { throw new SassException('Nesting not allowed beneath {what}', array('{what}'=>'@import directive'), $token); } } return new SassImportNode($token); break; case '@for': return new SassForNode($token); break; case '@if': return new SassIfNode($token); break; case '@else': // handles else and else if directives return new SassElseNode($token); break; case '@do': case '@while': return new SassWhileNode($token); break; case '@debug': return new SassDebugNode($token); break; case '@warn': return new SassDebugNode($token, true); break; default: return new SassDirectiveNode($token); break; } } /** * Determine the indent character and indent spaces. * The first character of the first indented line determines the character. * If this is a space the number of spaces determines the indentSpaces; this * is always 1 if the indent character is a tab. * Only used for .sass files. * @throws SassException if the indent is mixed or * the indent character can not be determined */ private function setIndentChar() { foreach ($this->source as $l=>$source) { if (!empty($source) && in_array($source[0], $this->indentChars)) { $this->indentChar = $source[0]; for ($i = 0, $len = strlen($source); $i < $len && $source[$i] == $this->indentChar; $i++); if ($i < $len && in_array($source[$i], $this->indentChars)) { $this->line = ++$l; $this->source = $source; throw new SassException('Mixed indentation not allowed', array(), $this); } $this->indentSpaces = ($this->indentChar == ' ' ? $i : 1); return; } } // foreach $this->indentChar = ' '; $this->indentSpaces = 2; }
}