* Licensed under MIT or GPLv3, see LICENSE */ // // investigate trouble with ^M // fix the alpha value with color when using a percent // class lessc { private $buffer; private $count; private $line; private $expandStack; private $env = array(); private $vars = array(); public $vPrefix = '@'; public $mPrefix = '$'; public $imPrefix = '!'; public $selfSelector = '&'; static private $precedence = array( '+' => 0, '-' => 0, '*' => 1, '/' => 1, '%' => 1, ); static private $operatorString; // regex string to match any of the operators static private $dtypes = array('expression', 'variable', 'function', 'negative'); // types with delayed computation static private $units = array( 'px', '%', 'in', 'cm', 'mm', 'em', 'ex', 'pt', 'pc', 'ms', 's', 'deg'); public $importDisabled = false; public $importDir = ''; // compile chunk off the head of buffer function chunk() { if (empty($this->buffer)) return false; $s = $this->seek(); // a property if ($this->keyword($key) && $this->assign() && $this->propertyValue($value) && $this->end()) { // look for important prefix if ($key{0} == $this->imPrefix && strlen($key) > 1) { $key = substr($key, 1); if ($value[0] == 'list' && $value[1] == ' ') { $value[2][] = array('keyword', '!important'); } else { $value = array('list', ' ', array($value, array('keyword', '!important'))); } } $this->append($key, $value); if (count($this->env) == 1) return $this->compileProperty($key, array($value))."\n"; else return true; } else { $this->seek($s); } // look for special css @ directives if (count($this->env) == 1 && $this->count < strlen($this->buffer) && $this->buffer[$this->count] == '@') { // a font-face block if ($this->literal('@font-face') && $this->literal('{')) { $this->push(); $this->set('__tags', array('@font-face')); $this->set('__dontsave', true); return true; } else { $this->seek($s); } // charset if ($this->literal('@charset') && $this->propertyValue($value) && $this->end()) { return "@charset ".$this->compileValue($value).";\n"; } else { $this->seek($s); } } // opening abstract block if ($this->tag($tag, true) && $this->argumentDef($args) && $this->literal('{')) { $this->push(); // move out of variable scope if ($tag{0} == $this->vPrefix) $tag[0] = $this->mPrefix; $this->set('__tags', array($tag)); if (isset($args)) $this->set('__args', $args); return true; } else { $this->seek($s); } // opening css block if ($this->tags($tags) && $this->literal('{')) { // move @ tags out of variable namespace! foreach($tags as &$tag) { if ($tag{0} == $this->vPrefix) $tag[0] = $this->mPrefix; } $this->push(); $this->set('__tags', $tags); return true; } else { $this->seek($s); } // closing block if ($this->literal('}')) { $tags = $this->multiplyTags(); $env = end($this->env); $ctags = $env['__tags']; unset($env['__tags']); // insert the default arguments if (isset($env['__args'])) { foreach ($env['__args'] as $arg) { if (isset($arg[1])) { $this->prepend($this->vPrefix.$arg[0], $arg[1]); } } } if (!empty($tags)) $out = $this->compileBlock($tags, $env); $this->pop(); // make the block(s) available in the new current scope if (!isset($env['__dontsave'])) { foreach ($ctags as $t) { // if the block already exists then merge if ($this->get($t, array(end($this->env)))) { $this->merge($t, $env); } else { $this->set($t, $env); } } } return isset($out) ? $out : true; } // import statement if ($this->import($url, $media)) { if ($this->importDisabled) return "/* import is disabled */\n"; $full = $this->importDir.$url; if (file_exists($file = $full) || file_exists($file = $full.'.less')) { $loaded = $this->removeComments(ltrim(file_get_contents($file).";")); $this->buffer = substr($this->buffer, 0, $this->count).$loaded.substr($this->buffer, $this->count); return true; } return '@import url("'.$url.'")'.($media ? ' '.$media : '').";\n"; } // setting variable if ($this->variable($name) && $this->assign() && $this->propertyValue($value) && $this->end()) { $this->append($this->vPrefix.$name, $value); $this->vars[$name] = $this->compileValue($value); return true; } else { $this->seek($s); } // mixin/function expand if ($this->tags($tags, true, '>') && ($this->argumentValues($argv) || true) && $this->end()) { $env = $this->getEnv($tags); if ($env == null) return true; // if we have arguments then insert them if (!empty($env['__args'])) { foreach($env['__args'] as $arg) { $vname = $this->vPrefix.$arg[0]; $value = is_array($argv) ? array_shift($argv) : null; // copy default value if there isn't one supplied if ($value == null && isset($arg[1])) $value = $arg[1]; // if ($value == null) continue; // don't define so it can search up // create new entry if var doesn't exist in scope if (isset($env[$vname])) { array_unshift($env[$vname], $value); } else { // new element $env[$vname] = array($value); } } } // set all properties ob_start(); $blocks = array(); foreach ($env as $name => $value) { // skip the metatdata if (preg_match('/^__/', $name)) continue; // if it is a block, remember it to compile after everything // is mixed in if (!isset($value[0])) $blocks[] = array($name, $value); // copy the data // don't overwrite previous value, look in current env for name if ($this->get($name, array(end($this->env)))) { while ($tval = array_shift($value)) $this->append($name, $tval); } else $this->set($name, $value); } // render sub blocks foreach ($blocks as $b) { $rtags = $this->multiplyTags(array($b[0])); echo $this->compileBlock($rtags, $b[1]); } return ob_get_clean(); } else { $this->seek($s); } // spare ; if ($this->literal(';')) return true; return false; // couldn't match anything, throw error } // recursively find the cartesian product of all tags in stack function multiplyTags($tags = array(' '), $d = null) { if ($d === null) $d = count($this->env) - 1; $parents = $d == 0 ? $this->env[$d]['__tags'] : $this->multiplyTags($this->env[$d]['__tags'], $d - 1); $rtags = array(); foreach ($parents as $p) { foreach ($tags as $t) { if ($t{0} == $this->mPrefix) continue; // skip functions $d = ' '; if ($t{0} == ':' || $t{0} == $this->selfSelector) { $t = ltrim($t, $this->selfSelector); $d = ''; } $rtags[] = trim($p.$d.$t); } } return $rtags; } // a list of expressions function expressionList(&$exps) { $values = array(); while ($this->expression($exp)) { $values[] = $exp; } if (count($values) == 0) return false; $exps = $this->compressList($values, ' '); return true; } // a single expression function expression(&$out) { $s = $this->seek(); $needWhite = true; if ($this->literal('(') && $this->expression($exp) && $this->literal(')')) { $lhs = $exp; $needWhite = false; } elseif ($this->seek($s) && $this->value($val)) { $lhs = $val; } else { return false; } $out = $this->expHelper($lhs, 0, $needWhite); return true; } // resursively parse infix equation with $lhs at precedence $minP function expHelper($lhs, $minP, $needWhite = true) { $ss = $this->seek(); // try to find a valid operator while ($this->match(self::$operatorString.($needWhite ? '\s+' : ''), $m) && self::$precedence[$m[1]] >= $minP) { $needWhite = true; // get rhs $s = $this->seek(); if ($this->literal('(') && $this->expression($exp) && $this->literal(')')) { $needWhite = false; $rhs = $exp; } elseif ($this->seek($s) && $this->value($val)) { $rhs = $val; } else break; // peek for next operator to see what to do with rhs if ($this->peek(self::$operatorString, $next) && self::$precedence[$next[1]] > $minP) { $rhs = $this->expHelper($rhs, self::$precedence[$next[1]]); } // don't evaluate yet if it is dynamic if (in_array($rhs[0], self::$dtypes) || in_array($lhs[0], self::$dtypes)) $lhs = array('expression', $m[1], $lhs, $rhs); else $lhs = $this->evaluate($m[1], $lhs, $rhs); $ss = $this->seek(); } $this->seek($ss); return $lhs; } // consume a list of values for a property function propertyValue(&$value) { $values = array(); $s = null; while ($this->expressionList($v)) { $values[] = $v; $s = $this->seek(); if (!$this->literal(',')) break; } if ($s) $this->seek($s); if (count($values) == 0) return false; $value = $this->compressList($values, ', '); return true; } // a single value function value(&$value) { // try a unit if ($this->unit($value)) return true; // see if there is a negation $s = $this->seek(); if ($this->literal('-', false) && $this->variable($vname)) { $value = array('negative', array('variable', $this->vPrefix.$vname)); return true; } else { $this->seek($s); } // accessor // must be done before color // this needs negation too if ($this->accessor($a)) { $tmp = $this->getEnv($a[0]); if ($tmp && isset($tmp[$a[1]])) $value = end($tmp[$a[1]]); return true; } // color if ($this->color($value)) return true; // css function // must be done after color if ($this->func($value)) return true; // string if ($this->string($tmp, $d)) { $value = array('string', $d.$tmp.$d); return true; } // try a keyword if ($this->keyword($word)) { $value = array('keyword', $word); return true; } // try a variable if ($this->variable($vname)) { $value = array('variable', $this->vPrefix.$vname); return true; } return false; } // an import statement function import(&$url, &$media) { $s = $this->seek(); if (!$this->literal('@import')) return false; // @import "something.css" media; // @import url("something.css") media; // @import url(something.css) media; if ($this->literal('url(')) $parens = true; else $parens = false; if (!$this->string($url)) { if ($parens && $this->to(')', $url)) { $parens = false; // got em } else { $this->seek($s); return false; } } if ($parens && !$this->literal(')')) { $this->seek($s); return false; } // now the rest is media return $this->to(';', $media, false, true); } // a scoped value accessor // .hello > @scope1 > @scope2['value']; function accessor(&$var) { $s = $this->seek(); if (!$this->tags($scope, true, '>') || !$this->literal('[')) { $this->seek($s); return false; } // either it is a variable or a property // why is a property wrapped in quotes, who knows! if ($this->variable($name)) { $name = $this->vPrefix.$name; } elseif($this->literal("'") && $this->keyword($name) && $this->literal("'")) { // .. $this->count is messed up if we wanted to test another access type } else { $this->seek($s); return false; } if (!$this->literal(']')) { $this->seek($s); return false; } $var = array($scope, $name); return true; } // a string function string(&$string, &$d = null) { $s = $this->seek(); if ($this->literal('"', false)) { $delim = '"'; } else if($this->literal("'", false)) { $delim = "'"; } else { return false; } if (!$this->to($delim, $string)) { $this->seek($s); return false; } $d = $delim; return true; } // a numerical unit function unit(&$unit, $allowed = null) { $simpleCase = $allowed == null; if (!$allowed) $allowed = self::$units; if ($this->match('(-?[0-9]*(\.)?[0-9]+)('.implode('|', $allowed).')?', $m, !$simpleCase)) { if (!isset($m[3])) $m[3] = 'number'; $unit = array($m[3], $m[1]); // check for size/height font unit.. should this even be here? if ($simpleCase) { $s = $this->seek(); if ($this->literal('/', false) && $this->unit($right, self::$units)) { $unit = array('keyword', $this->compileValue($unit).'/'.$this->compileValue($right)); } else { // get rid of whitespace $this->seek($s); $this->match('', $_); } } return true; } return false; } // a # color function color(&$out) { $color = array('color'); if ($this->match('(#([0-9a-f]{6})|#([0-9a-f]{3}))', $m)) { if (isset($m[3])) { $num = $m[3]; $width = 16; } else { $num = $m[2]; $width = 256; } $num = hexdec($num); foreach(array(3,2,1) as $i) { $t = $num % $width; $num /= $width; $color[$i] = $t * (256/$width) + $t * floor(16/$width); } $out = $color; return true; } return false; } // consume a list of property values delimited by ; and wrapped in () function argumentValues(&$args, $delim = ';') { $s = $this->seek(); if (!$this->literal('(')) return false; $values = array(); while ($this->propertyValue($value)) { $values[] = $value; if (!$this->literal($delim)) break; } if (!$this->literal(')')) { $this->seek($s); return false; } $args = $values; return true; } // consume an argument definition list surrounded by (), each argument is a variable name with optional value function argumentDef(&$args, $delim = ';') { $s = $this->seek(); if (!$this->literal('(')) return false; $values = array(); while ($this->variable($vname)) { $arg = array($vname); if ($this->assign() && $this->propertyValue($value)) { $arg[] = $value; // let the : slide if there is no value } $values[] = $arg; if (!$this->literal($delim)) break; } if (!$this->literal(')')) { $this->seek($s); return false; } $args = $values; return true; } // consume a list of tags // this accepts a hanging delimiter function tags(&$tags, $simple = false, $delim = ',') { $tags = array(); while ($this->tag($tt, $simple)) { $tags[] = $tt; if (!$this->literal($delim)) break; } if (count($tags) == 0) return false; return true; } // a single tag function tag(&$tag, $simple = false) { if ($simple) $chars = '^,:;{}\][>\(\) '; else $chars = '^,;{}['; $tag = ''; while ($this->match('(['.$chars.'0-9]['.$chars.']*)', $m)) { $tag.= $m[1]; if ($simple) break; $s = $this->seek(); if ($this->literal('[') && $this->to(']', $c, true) && $this->literal(']')) { $tag .= '['.$c.'] '; } else { $this->seek($s); break; } } $tag = trim($tag); if ($tag == '') return false; return true; } // a css function function func(&$func) { $s = $this->seek(); if ($this->match('([\w\-_][\w\-_:\.]*)', $m) && $this->literal('(')) { $fname = $m[1]; if ($fname == 'url') { $this->to(')', $content, true); $args = array('string', $content); } else { $args = array(); while (true) { $ss = $this->seek(); if ($this->keyword($name) && $this->literal('=') && $this->expressionList($value)) { $args[] = array('list', '=', array(array('keyword', $name), $value)); } else { $this->seek($ss); if ($this->expressionList($value)) { $args[] = $value; } } if (!$this->literal(',')) break; } $args = array('list', ',', $args); } if ($this->literal(')')) { $func = array('function', $fname, $args); return true; } } $this->seek($s); return false; } // consume a less variable function variable(&$name) { $s = $this->seek(); if ($this->literal($this->vPrefix, false) && $this->keyword($name)) { return true; } return false; } // consume an assignment operator function assign() { return $this->literal(':') || $this->literal('='); } // consume a keyword function keyword(&$word) { if ($this->match('([\w_\-\*!"][\w\-_"]*)', $m)) { $word = $m[1]; return true; } return false; } // consume an end of statement delimiter function end() { if ($this->literal(';')) return true; elseif ($this->count == strlen($this->buffer) || $this->buffer{$this->count} == '}') { // if there is end of file or a closing block next then we don't need a ; return true; } return false; } function compressList($items, $delim) { if (count($items) == 1) return $items[0]; else return array('list', $delim, $items); } function compileBlock($rtags, $env) { // don't render functions // todo: this shouldn't need to happen because multiplyTags prunes them, verify /* foreach ($rtags as $i => $tag) { if (preg_match('/( |^)%/', $tag)) unset($rtags[$i]); } */ if (empty($rtags)) return ''; $props = 0; // print all the visible properties ob_start(); foreach ($env as $name => $value) { // todo: change this, poor hack // make a better name storage system!!! (value types are fine) // but.. don't render special properties (blocks, vars, metadata) if (isset($value[0]) && $name{0} != $this->vPrefix && $name != '__args') { echo $this->compileProperty($name, $value, 1)."\n"; $props++; } } $list = ob_get_clean(); if ($props == 0) return ''; // do some formatting if ($props == 1) $list = ' '.trim($list).' '; return implode(", ", $rtags).' {'.($props > 1 ? "\n" : ''). $list."}\n"; } function compileProperty($name, $value, $level = 0) { // output all repeated properties foreach ($value as $v) $props[] = str_repeat(' ', $level). $name.':'.$this->compileValue($v).';'; return implode("\n", $props); } function compileValue($value) { switch($value[0]) { case 'list': // [1] - delimiter // [2] - array of values return implode($value[1], array_map(array($this, 'compileValue'), $value[2])); case 'keyword': // [1] - the keyword case 'number': // [1] - the number return $value[1]; case 'expression': // [1] - operator // [2] - value of left hand side // [3] - value of right return $this->compileValue($this->evaluate($value[1], $value[2], $value[3])); case 'string': // [1] - contents of string (includes quotes) // search for inline variables to replace $replace = array(); if (preg_match_all('/{(@[\w-_][0-9\w-_]*)}/', $value[1], $m)) { foreach($m[1] as $name) { if (!isset($replace[$name])) $replace[$name] = $this->compileValue(array('variable', $name)); } } foreach ($replace as $var=>$val) { // strip quotes if (preg_match('/^(["\']).*?(\1)$/', $val)) { $val = substr($val, 1, -1); } $value[1] = str_replace('{'.$var.'}', $val, $value[1]); } return $value[1]; case 'color': // [1] - red component (either number for a %) // [2] - green component // [3] - blue component // [4] - optional alpha component if (count($value) == 5) { // rgba return 'rgba('.$value[1].','.$value[2].','.$value[3].','.$value[4].')'; } $out = '#'; foreach (range(1,3) as $i) $out .= ($value[$i] < 16 ? '0' : '').dechex($value[$i]); return $out; case 'variable': // [1] - the name of the variable including @ $tmp = $this->compileValue( $this->getVal($value[1], $this->pushName($value[1])) ); $this->popName(); return $tmp; case 'negative': // [1] - some value that needs to become negative return $this->compileValue($this->reduce($value)); case 'function': // [1] - function name // [2] - some value representing arguments // see if there is a library function for this func $f = array($this, 'lib_'.$value[1]); if (is_callable($f)) { return call_user_func($f, $value[2]); } return $value[1].'('.$this->compileValue($value[2]).')'; default: // assumed to be unit return $value[1].$value[0]; } } function lib_quote($arg) { return '"'.$this->compileValue($arg).'"'; } function lib_unquote($arg) { $out = $this->compileValue($arg); if ($this->quoted($out)) $out = substr($out, 1, -1); return $out; } // is a string surrounded in quotes? returns the quoting char if true function quoted($s) { if (preg_match('/^("|\').*?\1$/', $s, $m)) return $m[1]; else return false; } // convert rgb, rgba into color type suitable for math // todo: add hsl function funcToColor($func) { $fname = $func[1]; if (!preg_match('/^(rgb|rgba)$/', $fname)) return false; if ($func[2][0] != 'list') return false; // need a list of arguments $components = array(); $i = 1; foreach ($func[2][2] as $c) { $c = $this->reduce($c); if ($i < 4) { if ($c[0] == '%') $components[] = 255 * ($c[1] / 100); else $components[] = floatval($c[1]); } elseif ($i == 4) { if ($c[0] == '%') $components[] = 1.0 * ($c[1] / 100); else $components[] = floatval($c[1]); } else break; $i++; } while (count($components) < 3) $components[] = 0; array_unshift($components, 'color'); return $this->fixColor($components); } // reduce a delayed type to its final value // dereference variables and solve equations function reduce($var, $defaultValue = array('number', 0)) { $pushed = 0; // number of variable names pushed while (in_array($var[0], self::$dtypes)) { if ($var[0] == 'expression') { $var = $this->evaluate($var[1], $var[2], $var[3]); } else if ($var[0] == 'variable') { $var = $this->getVal($var[1], $this->pushName($var[1]), $defaultValue); $pushed++; } else if ($var[0] == 'function') { $color = $this->funcToColor($var); if ($color) $var = $color; break; // no where to go after a function } else if ($var[0] == 'negative') { $value = $this->reduce($var[1]); if (is_numeric($value[1])) { $value[1] = -1*$value[1]; } $var = $value; } } while ($pushed != 0) { $this->popName(); $pushed--; } return $var; } // evaluate an expression function evaluate($op, $left, $right) { $left = $this->reduce($left); $right = $this->reduce($right); if ($left[0] == 'color' && $right[0] == 'color') { $out = $this->op_color_color($op, $left, $right); return $out; } if ($left[0] == 'color') { return $this->op_color_number($op, $left, $right); } if ($right[0] == 'color') { return $this->op_number_color($op, $left, $right); } // concatenate strings if ($op == '+' && $left[0] == 'string') { $append = $this->compileValue($right); if ($this->quoted($append)) $append = substr($append, 1, -1); $lhs = $this->compileValue($left); if ($q = $this->quoted($lhs)) $lhs = substr($lhs, 1, -1); if (!$q) $q = ''; return array('string', $q.$lhs.$append.$q); } if ($left[0] == 'keyword' || $right[0] == 'keyword' || $left[0] == 'string' || $right[0] == 'string') { // look for negative op if ($op == '-') $right[1] = '-'.$right[1]; return array('keyword', $this->compileValue($left) .' '. $this->compileValue($right)); } // default to number operation return $this->op_number_number($op, $left, $right); } // make sure a color's components don't go out of bounds function fixColor($c) { foreach (range(1, 3) as $i) { if ($c[$i] < 0) $c[$i] = 0; if ($c[$i] > 255) $c[$i] = 255; $c[$i] = floor($c[$i]); } return $c; } function op_number_color($op, $lft, $rgt) { if ($op == '+' || $op = '*') { return $this->op_color_number($op, $rgt, $lft); } } function op_color_number($op, $lft, $rgt) { if ($rgt[0] == '%') $rgt[1] /= 100; return $this->op_color_color($op, $lft, array_fill(1, count($lft) - 1, $rgt[1])); } function op_color_color($op, $left, $right) { $out = array('color'); $max = count($left) > count($right) ? count($left) : count($right); foreach (range(1, $max - 1) as $i) { $lval = isset($left[$i]) ? $left[$i] : 0; $rval = isset($right[$i]) ? $right[$i] : 0; switch ($op) { case '+': $out[] = $lval + $rval; break; case '-': $out[] = $lval - $rval; break; case '*': $out[] = $lval * $rval; break; case '%': $out[] = $lval % $rval; break; case '/': if ($rval == 0) throw new exception("evaluate error: can't divide by zero"); $out[] = $lval / $rval; break; default: throw new exception('evaluate error: color op number failed on op '.$op); } } return $this->fixColor($out); } // operator on two numbers function op_number_number($op, $left, $right) { if ($right[0] == '%') $right[1] /= 100; // figure out type if ($right[0] == 'number' || $right[0] == '%') $type = $left[0]; else $type = $right[0]; $value = 0; switch($op) { case '+': $value = $left[1] + $right[1]; break; case '*': $value = $left[1] * $right[1]; break; case '-': $value = $left[1] - $right[1]; break; case '%': $value = $left[1] % $right[1]; break; case '/': if ($right[1] == 0) throw new exception('parse error: divide by zero'); $value = $left[1] / $right[1]; break; default: throw new exception('parse error: unknown number operator: '.$op); } return array($type, $value); } /* environment functions */ // push name on expand stack, and return its // count before being pushed function pushName($name) { $count = array_count_values($this->expandStack); $count = isset($count[$name]) ? $count[$name] : 0; $this->expandStack[] = $name; return $count; } // pop name off expand stack and return it function popName() { return array_pop($this->expandStack); } // push a new environment function push() { $this->level++; $this->env[] = array(); } // pop environment off the stack function pop() { if ($this->level == 1) throw new exception('parse error: unexpected end of block'); $this->level--; return array_pop($this->env); } // set something in the current env function set($name, $value) { $this->env[count($this->env) - 1][$name] = $value; } // append to array in the current env function append($name, $value) { $this->env[count($this->env) - 1][$name][] = $value; } // put on the front of the value function prepend($name, $value) { if (isset($this->env[count($this->env) - 1][$name])) array_unshift($this->env[count($this->env) - 1][$name], $value); else $this->append($name, $value); } // get the highest occurrence of value function get($name, $env = null) { if (empty($env)) $env = $this->env; for ($i = count($env) - 1; $i >= 0; $i--) if (isset($env[$i][$name])) return $env[$i][$name]; return null; } // get the most recent value of a variable // return default if it isn't found // $skip is number of vars to skip function getVal($name, $skip = 0, $default = array('keyword', '')) { $val = $this->get($name); if ($val == null) return $default; $tmp = $this->env; while (!isset($tmp[count($tmp) - 1][$name])) array_pop($tmp); while ($skip > 0) { $skip--; if (!empty($val)) { array_pop($val); } if (empty($val)) { array_pop($tmp); $val = $this->get($name, $tmp); } if (empty($val)) return $default; } return end($val); } // get the environment described by path, an array of env names function getEnv($path) { if (!is_array($path)) $path = array($path); // move @ tags out of variable namespace foreach($path as &$tag) if ($tag{0} == $this->vPrefix) $tag[0] = $this->mPrefix; $env = $this->get(array_shift($path)); while ($sub = array_shift($path)) { if (isset($env[$sub])) // todo add a type check for environment $env = $env[$sub]; else { $env = null; break; } } return $env; } // merge a block into the current env function merge($name, $value) { // if the current block isn't there then just set $top =& $this->env[count($this->env) - 1]; if (!isset($top[$name])) return $this->set($name, $value); // copy the block into the old one, including meta data foreach ($value as $k=>$v) { // todo: merge property values instead of replacing // have to check type for this $top[$name][$k] = $v; } } function literal($what, $eatWhitespace = true) { // this is here mainly prevent notice from { } string accessor if ($this->count >= strlen($this->buffer)) return false; // shortcut on single letter if (!$eatWhitespace and strlen($what) == 1) { if ($this->buffer{$this->count} == $what) { $this->count++; return true; } else return false; } return $this->match($this->preg_quote($what), $m, $eatWhitespace); } function preg_quote($what) { return preg_quote($what, '/'); } // advance counter to next occurrence of $what // $until - don't include $what in advance function to($what, &$out, $until = false, $allowNewline = false) { $validChars = $allowNewline ? "[^\n]" : '.'; if (!$this->match('('.$validChars.'*?)'.$this->preg_quote($what), $m, !$until)) return false; if ($until) $this->count -= strlen($what); // give back $what $out = $m[1]; return true; } // try to match something on head of buffer function match($regex, &$out, $eatWhitespace = true) { $r = '/'.$regex.($eatWhitespace ? '\s*' : '').'/Ais'; if (preg_match($r, $this->buffer, $out, null, $this->count)) { $this->count += strlen($out[0]); return true; } return false; } // match something without consuming it function peek($regex, &$out = null) { $r = '/'.$regex.'/Ais'; $result = preg_match($r, $this->buffer, $out, null, $this->count); return $result; } // seek to a spot in the buffer or return where we are on no argument function seek($where = null) { if ($where === null) return $this->count; else $this->count = $where; return true; } // parse and compile buffer function parse($str = null, $type = 'text') { if ($str) $this->buffer = $str; $this->env = array(); $this->expandStack = array(); $this->count = 0; $this->line = 1; $this->buffer = $this->removeComments($this->buffer); $this->push(); // set up global scope $this->set('__tags', array('')); // equivalent to 1 in tag multiplication // trim whitespace on head if (preg_match('/^\s+/', $this->buffer, $m)) { $this->line += substr_count($m[0], "\n"); $this->buffer = ltrim($this->buffer); } $out = ''; while (false !== ($compiled = $this->chunk())) { if (is_string($compiled)) $out .= $compiled; } if ($this->count != strlen($this->buffer)) $this->throwParseError(); if (count($this->env) > 1) throw new exception('parse error: unclosed block'); if ($type == 'variables') { return $this->vars; } // print_r($this->env); return $out; } function throwParseError($msg = 'parse error') { $line = $this->line + substr_count(substr($this->buffer, 0, $this->count), "\n"); if ($this->peek("(.*?)\n", $m)) throw new exception($msg.': failed at `'.$m[1].'` line: '.$line); } function __construct($fname = null) { if (!self::$operatorString) { self::$operatorString = '('.implode('|', array_map(array($this, 'preg_quote'), array_keys(self::$precedence))).')'; } if ($fname) { if (!is_file($fname)) { throw new Exception('load error: failed to find '.$fname); } $pi = pathinfo($fname); $this->fileName = $fname; $this->importDir = $pi['dirname'].'/'; $this->buffer = file_get_contents($fname); } } // remove comments from $text // todo: make it work for all functions, not just url // todo: make it not mess up line counter with block comments function removeComments($text) { $out = ''; while (!empty($text) && preg_match('/^(.*?)("|\'|\/\/|\/\*|url\(|$)/is', $text, $m)) { if (!trim($text)) break; $out .= $m[1]; $text = substr($text, strlen($m[0])); switch ($m[2]) { case 'url(': preg_match('/^(.*?)(\)|$)/is', $text, $inner); $text = substr($text, strlen($inner[0])); $out .= $m[2].$inner[1].$inner[2]; break; case '//': preg_match("/^(.*?)(\n|$)/is", $text, $inner); // give back the newline $text = substr($text, strlen($inner[0]) - 1); break; case '/*'; preg_match("/^(.*?)(\*\/|$)/is", $text, $inner); $text = substr($text, strlen($inner[0])); break; case '"': case "'": preg_match("/^(.*?)(".$m[2]."|$)/is", $text, $inner); $text = substr($text, strlen($inner[0])); $out .= $m[2].$inner[1].$inner[2]; break; } } return $out; } // compile to $in to $out if $in is newer than $out // returns true when it compiles, false otherwise public static function ccompile($in, $out) { if (!is_file($out) || filemtime($in) > filemtime($out)) { $less = new lessc($in); file_put_contents($out, $less->parse()); return true; } return false; } } ?>