* @copyright Copyright (c) 2010 PBM Web Development * @license http://phamlp.googlecode.com/files/license.txt * @package PHamlP * @subpackage Sass.tree */ /** * SassRuleNode class. * Represents a CSS rule. * @package PHamlP * @subpackage Sass.tree */ class SassRuleNode extends SassNode { const MATCH = '/^(.+?)(?:\s*\{)?$/'; const SELECTOR = 1; const CONTINUED = ','; /** * @const string that is replaced with the parent node selector */ const PARENT_REFERENCE = '&'; /** * @var array selector(s) */ private $selectors = array(); /** * @var array parent selectors */ private $parentSelectors = array(); /** * @var array resolved selectors */ private $resolvedSelectors = array(); /** * @var boolean whether the node expects more selectors */ private $isContinued; /** * SassRuleNode constructor. * @param object source token * @param string rule selector * @return SassRuleNode */ public function __construct($token) { parent::__construct($token); preg_match(self::MATCH, $token->source, $matches); $this->addSelectors($matches[SassRuleNode::SELECTOR]); } /** * Adds selector(s) to the rule. * If the selectors are to continue for the rule the selector must end in a comma * @param string selector */ public function addSelectors($selectors, $explode = true) { $this->isContinued = substr($selectors, -1) === self::CONTINUED; $this->selectors = array_merge($this->selectors, $explode ? $this->explode($selectors) : $selectors); } /** * Returns a value indicating if the selectors for this rule are to be continued. * @param boolean true if the selectors for this rule are to be continued, * false if not */ public function getIsContinued() { return $this->isContinued; } /** * Parse this node and its children into static nodes. * @param SassContext the context in which this node is parsed * @return array the parsed node and its children */ public function parse($context) { $node = clone $this; $node->selectors = $this->resolveSelectors($context); $node->children = $this->parseChildren($context); return array($node); } /** * Render this node and its children to CSS. * @return string the rendered node */ public function render() { $this->extend(); $rules = ''; $properties = array(); foreach ($this->children as $child) { $child->parent = $this; if ($child instanceof SassRuleNode) { $rules .= $child->render(); } else { $properties[] = $child->render(); } } return $this->renderer->renderRule($this, $properties, $rules); } /** * Extend this nodes selectors * $extendee is the subject of the @extend directive * $extender is the selector that contains the @extend directive * $selector a selector or selector sequence that is to be extended */ public function extend() { foreach ($this->root->getExtenders() as $extendee => $extenders) { if ($this->isPsuedo($extendee)) { $extendee = explode(':', $extendee); $pattern = preg_quote($extendee[0]).'((\.[-\w]+)*):'.preg_quote($extendee[1]); } else { $pattern = preg_quote($extendee); } foreach (preg_grep('/'.$pattern.'/', $this->selectors) as $selector) { foreach ($extenders as $extender) { # first if establishes that we are using a placeholder and the extendee begins with a tag if ($extendee{0} == '%' && $selector{0} != '%' && preg_match('/(^| )[a-zA-Z][^%]*' . preg_quote($extendee) . '([^a-z0-9_-]|$)/', $selector)) { # the second if establishes that the extender is a tag rather than a class/id $zero = ord(strtolower(substr($extender, 0, 1))); // cheaper than regex if ($zero >= 97 && $zero <= 122) { continue; } } if (is_array($extendee)) { $this->selectors[] = preg_replace('/(.*?)'.$pattern.'([^a-zA-Z0-9_-]|$)/', '$1' . $extender . '$2', $selector); } elseif ($this->isSequence($extender) || $this->isSequence($selector)) { $this->selectors = array_merge($this->selectors, $this->mergeSequence($extender, $extendee, $selector)); } else { $this->selectors[] = str_replace($extendee, $extender, $selector); } } } $this->selectors = array_unique($this->selectors); } } /** * Tests whether the selector is a psuedo selector * @param string selector to test * @return boolean true if the selector is a psuedo selector, false if not */ private function isPsuedo($selector) { return strpos($selector, ':') !== false; } /** * Tests whether the selector is a sequence selector * @param string selector to test * @return boolean true if the selector is a sequence selector, false if not */ private function isSequence($selector) { return strpos($selector, ' ') !== false; } public function isPlaceholder($selector) { return strpos($selector, '%') !== false; } /** * Merges selector sequences * @param string the extender selector * @param string selector to extend * @return array the merged sequences */ private function mergeSequence($extender, $extendee, $selector) { // if it's a placeholder, be lazy. Needs tests. if ($extendee[0] == '%') { // need to stop things like a%foo accepting div { @extend %foo } return array(str_replace($extendee, $extender, $selector)); } $extender = explode(' ', $extender); $end = array_pop($extender); $selector = explode(' ', $selector); array_pop($selector); $common = array(); if (count($extender) && count($selector)) { while (trim($extender[0]) === trim($selector[0])) { $common[] = array_shift($selector); array_shift($extender); if (!count($extender)) { break; } } } $beginning = (!empty($common) ? join(' ', $common) . ' ' : ''); # Richard Lyon - 2011-10-25 - removes duplicates by uniquing and trimming. # regex removes whitespace from start and and end of string as well as removing # whitespace following whitespace. slightly quicker than a trim and simpler replace return array_unique(array( preg_replace('/(^\s+|(\s)\s+|\s+$)/', '$2', $beginning.join(' ', $selector).' '.join(' ', $extender). ' ' . $end), preg_replace('/(^\s+|(\s)\s+|\s+$)/', '$2', $beginning.join(' ', $extender).' '.join(' ', $selector). ' ' . $end) )); } /** * Returns the selectors * @return array selectors */ public function getSelectors() { return $this->selectors; } /** * Resolves selectors. * Interpolates SassScript in selectors and resolves any parent references or * appends the parent selectors. * @param SassContext the context in which this node is parsed * * Change: 7/Dec/11 - change to make selector ordering conform to Ruby compiler. */ public function resolveSelectors($context) { $resolvedSelectors = $normalSelectors = array(); $this->parentSelectors = $this->getParentSelectors($context); foreach ($this->selectors as $key=>$selector) { $selector = $this->interpolate($selector, $context); $selectors = SassList::_build_list($selector); foreach ($selectors as $selector) { $selector = trim($selector, ' \'"'); // strip whitespace and quotes, just-in-case. if ($this->hasParentReference($selector)) { $resolvedSelectors = array_merge($resolvedSelectors, $this->resolveParentReferences($selector, $context)); } else { $normalSelectors[] = $selector; } } } // foreach // merge with parent selectors if ($this->parentSelectors) { $return = array(); foreach ($this->parentSelectors as $parent) { foreach ($normalSelectors as $selector) { $spacer = (substr($selector, 0, 1) == '[') ? '' : ' '; $return[] = $parent . $spacer . $selector; } } $normalSelectors = $return; } return array_merge($normalSelectors, $resolvedSelectors); } /** * Returns the parent selector(s) for this node. * This in an empty array if there is no parent selector. * @return array the parent selector for this node */ protected function getParentSelectors($context) { $ancestor = $this->parent; while (!$ancestor instanceof SassRuleNode && $ancestor->hasParent()) { $ancestor = $ancestor->parent; } if ($ancestor instanceof SassRuleNode) { return $ancestor->resolveSelectors($context); } return array(); } /** * Returns the position of the first parent reference in the selector. * If there is no parent reference in the selector this function returns * boolean FALSE. * Note that the return value may be non-Boolean that evaluates to FALSE, * i.e. 0. The return value should be tested using the === operator. * @param string selector to test * @return mixed integer: position of the the first parent reference, * boolean: false if there is no parent reference. */ private function parentReferencePos($selector) { $inString = ''; for ($i = 0, $l = strlen($selector); $i < $l; $i++) { $c = $selector[$i]; if ($c === self::PARENT_REFERENCE && empty($inString)) { return $i; } elseif (empty($inString) && ($c === '"' || $c === "'")) { $inString = $c; } elseif ($c === $inString) { $inString = ''; } } return false; } /** * Determines if there is a parent reference in the selector * @param string selector * @return boolean true if there is a parent reference in the selector */ private function hasParentReference($selector) { return $this->parentReferencePos($selector) !== false; } /** * Resolves parent references in the selector * @param string selector * @return string selector with parent references resolved */ private function resolveParentReferences($selector, $context) { $resolvedReferences = array(); if (!count($this->parentSelectors)) { throw new SassRuleNodeException('Can not use parent selector (' . self::PARENT_REFERENCE . ') when no parent selectors', $this); } foreach ($this->getParentSelectors($context) as $parentSelector) { $resolvedReferences[] = str_replace(self::PARENT_REFERENCE, $parentSelector, $selector); } return $resolvedReferences; } /** * Explodes a string of selectors into an array. * We can't use PHP::explode as this will potentially explode attribute * matches in the selector, e.g. div[title="some,value"] and interpolations. * @param string selectors * @return array selectors */ private function explode($string) { $selectors = array(); $inString = false; $interpolate = false; $selector = ''; for ($i = 0, $l = strlen($string); $i < $l; $i++) { $c = $string[$i]; if ($c === self::CONTINUED && !$inString && !$interpolate) { $selectors[] = trim($selector); $selector = ''; } else { $selector .= $c; if ($c === '"' || $c === "'") { do { $_c = $string[++$i]; $selector .= $_c; } while ($_c !== $c && isset($string[$i+1])); } elseif ($c === '#' && $string[$i+1] === '{') { do { $c = $string[++$i]; $selector .= $c; } while ($c !== '}'); } } } if (!empty($selector)) { $selectors[] = trim($selector); } return $selectors; } }