From 3ced7b881adee9d22a0b5881a8a173f4a23dcf2b Mon Sep 17 00:00:00 2001 From: Eric Lippmann Date: Thu, 9 Apr 2026 16:43:54 +0200 Subject: [PATCH] Add Less compiler and pre-eval AST visitors for CSS var support `WikimediaLessCompiler` wraps `Less_Parser` with a clean `compile()` API and normalizes parser exceptions into `RuntimeException`. `PreEvalVisitor` establishes a base for visitors that hook into the pre-`compile()` phase, receiving the raw AST before variables and mixins are resolved. `CssVarVisitor` replaces every Less color variable reference with a `var(--name, fallback)` call, enabling runtime theming through CSS custom properties while preserving Less fallback resolution. `DetachedRulesetCallVisitor` expands a named detached ruleset into a configured Less template, making it possible to wrap styles in constructs like `@media` blocks without modifying the Less source. The `wikimedia/less.php` dependency is bumped from `^3.2.1` to `^5.5` to align with the `isPreEvalVisitor` hook and other APIs these visitors rely on. --- composer.json | 2 +- src/Less/CssVarVisitor.php | 319 ++++++++ src/Less/DetachedRulesetCallVisitor.php | 175 +++++ src/Less/PreEvalVisitor.php | 108 +++ src/Less/WikimediaLessCompiler.php | 47 ++ tests/Less/CssVarVisitorTest.php | 680 ++++++++++++++++++ tests/Less/DetachedRulesetCallVisitorTest.php | 294 ++++++++ tests/Less/LessVisitorTestCase.php | 37 + 8 files changed, 1661 insertions(+), 1 deletion(-) create mode 100644 src/Less/CssVarVisitor.php create mode 100644 src/Less/DetachedRulesetCallVisitor.php create mode 100644 src/Less/PreEvalVisitor.php create mode 100644 src/Less/WikimediaLessCompiler.php create mode 100644 tests/Less/CssVarVisitorTest.php create mode 100644 tests/Less/DetachedRulesetCallVisitorTest.php create mode 100644 tests/Less/LessVisitorTestCase.php diff --git a/composer.json b/composer.json index c18fb15e..1fc301a0 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "ipl/scheduler": ">=0.3.0", "ipl/stdlib": ">=0.15.0", "psr/http-message": "^1.1", - "wikimedia/less.php": "^3.2.1" + "wikimedia/less.php": "^5.5" }, "require-dev": { "icinga/zf1": "dev-main", diff --git a/src/Less/CssVarVisitor.php b/src/Less/CssVarVisitor.php new file mode 100644 index 00000000..6e29e9e1 --- /dev/null +++ b/src/Less/CssVarVisitor.php @@ -0,0 +1,319 @@ + + */ + protected SplDoublyLinkedList $replaceCssVars; + + /** + * Stack of mixin parameter names to exclude from CSS `var()` replacement + * + * Each frame represents one mixin definition currently being visited and contains its parameter names. + * The stack allows nested mixin definitions to restore the previous context correctly. + * + * @var list> + */ + protected array $mixinParams = []; + + /** + * Handle a {@see Less_Tree_Call} node during AST traversal + * + * Call nodes represent both built-in Less functions (e.g., `fade()`) and CSS functions (e.g., `calc()`). + * Less.php calls our {@see visitVariable()} for function call arguments, + * potentially replacing them with CSS `var()` calls. + * That behavior is desired for CSS functions such as `calc()`, but it breaks built-in Less functions + * such as `fade()`, because they require resolved values. To preserve Less semantics, + * this method replaces the call node that compiles twice if applicable: + * + * - First pass: disable CSS `var()` replacement while compiling the call (so built-in Less functions see + * resolved values). + * + * - Second pass: if compilation still returns a call node, treat it as a CSS function and recompile with + * CSS `var()` replacement enabled again. + * + * Given the context `icinga-red: red`, this ensures that: + * + * - `fade(icinga-red)` compiles to `fade(red)`. + * - `calc(icinga-red)` compiles to `calc(var(--icinga-red, red))`. + * + * @param Less_Tree_Call $c The function call node being visited + * + * @return Less_Tree_Call A new call node replacing the original with adjusted `compile()` logic + * + * @see visitVariable() For variable replacement logic + * @see Less_Functions For built-in Less functions + */ + public function visitCall(Less_Tree_Call $c): Less_Tree_Call + { + $call = new class ($c->name, $c->args, $c->index, $c->currentFileInfo) extends Less_Tree_Call { + /** @var SplDoublyLinkedList */ + public SplDoublyLinkedList $replaceCssVars; + + public function compile($env) + { + // Temporarily disable CSS `var()` replacement for current call node arguments. + $this->replaceCssVars->push(false); + $compiled = parent::compile($env); + $this->replaceCssVars->pop(); + + if ($compiled instanceof Less_Tree_Call) { + // Built-in Less functions (e.g. `fade()`) compile to a specific value (e.g. Color), + // whereas CSS functions (e.g. `calc()`) remain a call node after compilation. + // Recompile such call nodes with CSS `var()` replacement enabled for arguments. + // (Note: Replacement might still be disabled due to nested function calls.) + $compiled = parent::compile($env); + } + + return $compiled; + } + }; + $call->replaceCssVars = $this->replaceCssVars; + + return $call; + } + + /** + * Handle a {@see Less_Tree_Variable} node during AST traversal + * + * Less variable nodes represent unresolved references that Less.php resolves during compilation. + * This visitor replaces variable nodes that compile to a CSS `var()` call, unless: + * + * 1. The variable is a mixin parameter name. + * 2. It is an argument to a built-in Less function. + * 3. The resolved value is not a color or not already a CSS `var()` call. + * + * Mixin parameters are special: they behave like variables, but must not be replaced with CSS `var()` calls. + * The parameter stack maintained by {@see visitMixinDefinition()} and {@see visitMixinDefinitionOut()} + * provides context to ignore them. + * + * @param Less_Tree_Variable $v The variable node being visited + * + * @return Less_Tree_Variable The original variable if it is a mixin parameter; otherwise a + * replacement variable node that compiles to a CSS `var()` call if applicable + * + * @see visitCall() For disabling replacement in built-in Less functions + * @see visitMixinDefinition() For mixin parameter stack setup + * @see visitMixinDefinitionOut() For mixin parameter stack teardown + */ + public function visitVariable(Less_Tree_Variable $v): Less_Tree_Variable + { + foreach (array_reverse($this->mixinParams) as $ignoreVars) { + if (in_array($v->name, $ignoreVars, true)) { + return $v; + } + } + + $variable = new class ($v->name, $v->index, $v->currentFileInfo) extends Less_Tree_Variable { + /** @var SplDoublyLinkedList */ + public SplDoublyLinkedList $replaceCssVars; + + public function compile($env) + { + $compiled = parent::compile($env); + + if ($compiled instanceof Less_Tree_Color) { + $compiled->value = null; + } + + // Do not replace variable with CSS `var()` function call if... + if ( + // ... replacing CSS vars is disabled because a function call is compiled, + ! $this->replaceCssVars->top() + // ... or the compiled variable is neither a color nor a CSS `var()` call. + || ( + ! $compiled instanceof Less_Tree_Color + && (! $compiled instanceof Less_Tree_Call || $compiled->name !== 'var') + ) + ) { + return $compiled; + } + + // Remove '@' from name. + $name = substr($this->name, 1); + + if ($name[0] === '@') { + // Evaluate variable variable as in Less_Tree_Variable. + $name = (new Less_Tree_Variable($name, $this->index + 1, $this->currentFileInfo)) + ->compile($env) + ->value; + } + + $args = [ + 'var', + [ + new Less_Tree_Keyword("--{$name}"), + $compiled, + ], + $this->index, + ]; + + // No need to call `compile()` on the new call node (replacing the variable node), + // as it's not a built-in Less function, `Less_Tree_Keyword::compile()` is a no-op, + // and it wraps the already compiled variable node. + return new Less_Tree_Call(...$args); + } + }; + $variable->replaceCssVars = $this->replaceCssVars; + + return $variable; + } + + /** + * Handle a {@see Less_Tree_Mixin_Definition} node during AST traversal + * + * Less.php does not visit default parameter values, which would leave + * `.mixin(@color: @icinga-red)` producing `var(--color, …)` instead of `var(--icinga-red, …)`. + * + * This method works around the traversal gap by pushing the mixin's parameter names onto a stack and + * manually visiting each parameter value. For all frames in the stack, {@see visitVariable()} + * returns parameter names unchanged and only rewrites parameter values. + * + * Simplified AST traversal context: + * ``` + * MixinDefinition(.mixin) ← visitMixinDefinition() + * └─ Parameters + * ├─ Variable (name: color) ← instrument visitVariable() call to ignore this variable + * └─ Variable (value: icinga-red) ← force visitVariable() call + * visitMixinDefinitionOut() revert instrumentation + * ``` + * + * @param Less_Tree_Mixin_Definition $d The mixin definition node being visited + * + * @return Less_Tree_Mixin_Definition The original mixin definition node (modified in-place) + * + * @see visitMixinDefinitionOut() For cleanup after traversal + * @see visitVariable() For replacement logic that checks exclusion stack + */ + public function visitMixinDefinition(Less_Tree_Mixin_Definition $d): Less_Tree_Mixin_Definition + { + // Less_Tree_Mixin_Definition::accept() does not visit parameters, but we have to replace them if necessary. + foreach ($d->params as &$p) { + $p['value'] = $this->visitObj($p['value']); + } + unset($p); + + $this->mixinParams[] = array_column($d->params, 'name'); + + return $d; + } + + /** + * Leave a {@see Less_Tree_Mixin_Definition} node + * + * Pops the current mixin's parameter names from the exclusion stack, + * restoring the state for the parent scope. + * + * @return void + */ + public function visitMixinDefinitionOut(): void + { + array_pop($this->mixinParams); + } + + /** + * Handle a {@see Less_Tree_Mixin_Call} node during AST traversal + * + * Since Less.php does not visit arguments of mixin calls, this visitor forces each argument to be visited + * to trigger our {@see visitVariable()} so that arguments are replaced by CSS `var()` calls if applicable, + * e.g., `.mixin(icinga-red)` → `.mixin(var(--icinga-red, ...))`. + * + * Simplified AST traversal context: + * ``` + * MixinCall (.mixin) ← visitMixinCall() + * └─ Arguments + * └─ Variable (icinga-red) ← force visitVariable() call + * ``` + * + * @param Less_Tree_Mixin_Call $c The mixin call node being visited + * + * @return Less_Tree_Mixin_Call The original mixin call node (modified in-place) + */ + public function visitMixinCall(Less_Tree_Mixin_Call $c): Less_Tree_Mixin_Call + { + // Less_Tree_Mixin_Call::accept() does not visit arguments, but we have to replace them if necessary. + foreach ($c->arguments as &$a) { + $a['value'] = $this->visitObj($a['value']); + } + unset($a); + + return $c; + } + + /** + * Set up the CSS `var()` replacement state stack + * + * Creates {@see $replaceCssVars} with an initial `true` frame so that replacement + * is enabled at the start of each traversal. + * + * @return void + * + * @see visitCall() Which pushes/pops frames to disable replacement inside built-in Less functions + */ + protected function init(): void + { + $this->replaceCssVars = new SplDoublyLinkedList(); + // Enable CSS `var()` replacement. + $this->replaceCssVars->push(true); + } + + /** + * Deep-copy object-type state when this visitor is cloned by {@see PreEvalVisitor::run()} + * + * {@see $replaceCssVars} is a {@see SplDoublyLinkedList} and would otherwise be shared + * between the prototype and the clone. Cloning it ensures each traversal starts with + * its own independent stack. + * + * @return void + */ + public function __clone(): void + { + $this->replaceCssVars = clone $this->replaceCssVars; + } +} diff --git a/src/Less/DetachedRulesetCallVisitor.php b/src/Less/DetachedRulesetCallVisitor.php new file mode 100644 index 00000000..bd87866e --- /dev/null +++ b/src/Less/DetachedRulesetCallVisitor.php @@ -0,0 +1,175 @@ +mixinDef = $this->buildMixinDef($template); + } catch (Less_Exception_Parser $e) { + throw new RuntimeException( + "Failed to parse template for detached ruleset: {$e->getMessage()}", + $e->getCode(), + $e, + ); + } + } + + /** + * Build the mixin definition from the template + * + * Parses the template into a {@see Less_Tree_Mixin_Definition} so that + * {@see visitDeclaration()} can call it wherever the configured variable is + * assigned a detached ruleset. The definition is injected at the root AST by {@see run()}. + * + * @return Less_Tree_Mixin_Definition The mixin definition built from the template + * + * @throws Less_Exception_Parser If the template cannot be parsed + */ + protected function buildMixinDef(string $template): Less_Tree_Mixin_Definition + { + $less = str_replace('{ruleset}', '@rules()', $template); + // Less_Parser::parse() returns $this and does not expose the root ruleset. + // parseFile(returnRoot: true) is the only public API that does, so we write + // the content to a temp file and let the parser read it back. + $t = tmpfile(); + fwrite($t, $less); + try { + $root = (Less_Tree::$parse ?? new Less_Parser()) + ->parseFile(stream_get_meta_data($t)['uri'], returnRoot: true); + } finally { + fclose($t); + } + + return new Less_Tree_Mixin_Definition( + '.' . uniqid($this->variableName), + [['name' => '@rules']], + $root->rules, + null, + ); + } + + /** + * Inject the template mixin definition at the root of the AST before traversal + * + * Overrides {@see PreEvalVisitor::run()} to prepend the mixin definition to the root + * ruleset's rules before the traversal clone starts visiting. This ensures exactly one copy + * of the definition is reachable through `$env->frames` at any nesting depth when the mixin + * calls produced by {@see visitDeclaration()} are evaluated. + * + * @param Less_Tree $tree The root AST node to traverse + * + * @return void + * + * @throws InvalidArgumentException If the root node is not a {@see Less_Tree_Ruleset} + */ + public function run(Less_Tree $tree): void + { + if (! $tree instanceof Less_Tree_Ruleset) { + throw new InvalidArgumentException(sprintf( + '%s can only be run on %s instances', + __METHOD__, + Less_Tree_Ruleset::class, + )); + } + + $clone = clone $this; + array_unshift($tree->rules, $clone->mixinDef); + $clone->visitObj($tree); + } + + /** + * Handle a {@see Less_Tree_Declaration} node during AST traversal + * + * Leaves most declarations unchanged. When it encounters a variable declaration matching + * {@see $variableName} whose value is a detached ruleset, it replaces that declaration with + * a call to the template mixin, passing the detached ruleset directly as the argument. + * The mixin definition itself is already in the root AST, injected by {@see run()}. + * + * @param Less_Tree_Declaration $d The declaration node being visited + * + * @return Less_Tree_Declaration|Less_Tree_Mixin_Call The original node, or the mixin call that replaces it + */ + public function visitDeclaration(Less_Tree_Declaration $d): Less_Tree_Declaration|Less_Tree_Mixin_Call + { + if ( + $d->variable + && $d->name === "@{$this->variableName}" + && $d->value instanceof Less_Tree_DetachedRuleset + ) { + return new Less_Tree_Mixin_Call( + [new Less_Tree_Element(null, $this->mixinDef->name)], + [['name' => null, 'value' => $d->value]], + $d->index, + $d->currentFileInfo, + ); + } + + return $d; + } +} diff --git a/src/Less/PreEvalVisitor.php b/src/Less/PreEvalVisitor.php new file mode 100644 index 00000000..28bce22b --- /dev/null +++ b/src/Less/PreEvalVisitor.php @@ -0,0 +1,108 @@ +isPreEvalVisitor = true; + $this->init(); + } + + /** + * Workaround for a port bug in wikimedia/less.php + * + * {@see Less_Tree_Import::accept()} calls `$visitor->visit($this->root)` instead + * of `$visitor->visitObj($this->root)`. In less.js, the visitor's main + * type-dispatch method is named `visit()`; the PHP port renamed it to `visitObj()` + * but missed this one call site in `Import::accept()`, leaving it referencing a + * method that does not exist on {@see Less_VisitorReplacing}. + * + * Pre-eval visitors are the only ones affected: {@see Less_ImportVisitor} runs + * first and populates `Import::$root` with each imported file's parsed ruleset, so + * the `$root` branch in `accept()` is always taken during pre-eval traversal. + * Post-eval visitors never hit it because `$root->compile()` inlines the import + * contents beforehand, dissolving the `Import` nodes entirely. + * + * Delegates to {@see visitObj()} to preserve normal type dispatch. + * + * @param Less_Tree $node The imported file's root ruleset + * + * @return Less_Tree The visited node, potentially replaced + * + * @link https://github.com/wikimedia/less.php/blob/v5.5.0/lib/Less/Tree/Import.php#L84 + */ + public function visit(Less_Tree $node): Less_Tree + { + return $this->visitObj($node); + } + + + /** + * Visitor entrypoint called by {@see Less_Parser::PreVisitors()} + * + * Clones this instance for a fresh copy of the construction-time state established by + * {@see init()}, starts traversal on the clone, and leaves the original untouched. + * + * @param Less_Tree $tree The root AST node to traverse + * + * @return void + * + * @link https://github.com/wikimedia/less.php/blob/v5.5.0/lib/Less/Parser.php#L406 + */ + public function run(Less_Tree $tree): void + { + $clone = clone $this; + $clone->visitObj($tree); + } + + /** + * Set up visitor state at construction time + * + * Called once from {@see __construct()}; {@see run()} clones the instance before each + * traversal so this state serves as the clean starting point for every run. Subclasses + * assigning object-type properties must implement `__clone()` to deep-copy them. + * + * @return void + */ + protected function init(): void + { + } +} diff --git a/src/Less/WikimediaLessCompiler.php b/src/Less/WikimediaLessCompiler.php new file mode 100644 index 00000000..71040e53 --- /dev/null +++ b/src/Less/WikimediaLessCompiler.php @@ -0,0 +1,47 @@ + $parserOptions Options forwarded verbatim to {@see Less_Parser} + */ + public function __construct(protected array $parserOptions = []) + { + } + + /** + * Compile Less source to CSS + * + * @param string $less Less source code to compile + * @param bool $minify Whether to produce minified CSS; defaults to `false` + * + * @return string The compiled CSS + * + * @throws RuntimeException If Less compilation fails + */ + public function compile(string $less, bool $minify = false): string + { + $parserOptions = [ + 'compress' => $minify, + ]; + + try { + return (new Less_Parser($this->parserOptions + $parserOptions)) + ->parse($less) + ->getCss(); + } catch (Less_Exception_Parser $e) { + throw new RuntimeException('Less compilation failed: ' . $e->getMessage(), $e->getCode(), $e); + } + } +} diff --git a/tests/Less/CssVarVisitorTest.php b/tests/Less/CssVarVisitorTest.php new file mode 100644 index 00000000..d0d51880 --- /dev/null +++ b/tests/Less/CssVarVisitorTest.php @@ -0,0 +1,680 @@ +assertCss($css, $less); + } + + public function testColorsAreReplaced(): void + { + $less = <<<'LESS' +@state-ok: #44bb77; +@state-warning: #ffaa44; +@state-critical: #ff5566; +@state-unknown: #aa44ff; + +.state-ok { + color: @state-ok; +} + +.state-warning { + color: @state-warning; +} + +.state-critical { + color: @state-critical; +} + +.state-unknown { + color: @state-unknown; +} +LESS; + + $css = <<<'CSS' +.state-ok { + color: var(--state-ok, #44bb77); +} +.state-warning { + color: var(--state-warning, #ffaa44); +} +.state-critical { + color: var(--state-critical, #ff5566); +} +.state-unknown { + color: var(--state-unknown, #aa44ff); +} +CSS; + + $this->assertCss($css, $less); + } + + public function testAliasedColorsProduceNestedVarFallbacks(): void + { + $less = <<<'LESS' +@state-ok: #44bb77; +@state-up: @state-ok; +@state-critical: #ff5566; +@state-down: @state-critical; + +.state-up { + background-color: @state-up; +} + +.state-down { + background-color: @state-down; +} +LESS; + + $css = <<<'CSS' +.state-up { + background-color: var(--state-up, var(--state-ok, #44bb77)); +} +.state-down { + background-color: var(--state-down, var(--state-critical, #ff5566)); +} +CSS; + + $this->assertCss($css, $less); + } + + public function testBuiltInFunctionsReceiveResolvedColorValues(): void + { + // Built-in Less functions (fade(), lighten(), darken(), …) require resolved color + // values to compute their result. The visitor disables var() replacement inside + // their arguments so Less can evaluate them directly. + $less = <<<'LESS' +@state-ok: #44bb77; +@badge-color: @state-ok; +@default-text-color: #fff; + +.hint { + color: lighten(@badge-color, 10%); + background-color: darken(@badge-color, 10%); + border-color: fade(@default-text-color, 75%); +} +LESS; + + $css = <<<'CSS' +.hint { + color: #69c992; + background-color: #36965f; + border-color: rgba(255, 255, 255, 0.75); +} +CSS; + + $this->assertCss($css, $less); + } + + public function testVariableDerivedFromBuiltInFunctionIsReplaced(): void + { + // A variable whose value is computed by a built-in Less function resolves to a color, + // so it still gets a var() call with the computed value as the fallback. + $less = <<<'LESS' +@default-text-color: #fff; +@default-text-color-light: fade(@default-text-color, 75%); + +.secondary-text { + color: @default-text-color-light; +} +LESS; + + $css = <<<'CSS' +.secondary-text { + color: var(--default-text-color-light, rgba(255, 255, 255, 0.75)); +} +CSS; + + $this->assertCss($css, $less); + } + + public function testBoxShadowMixinWithColorArgument(): void + { + // The project's .box-shadow mixin uses @arguments to forward all parameters + // including the color. When a color variable is passed as the color argument, + // it is replaced with a var() call inside the forwarded @arguments value. + $less = <<<'LESS' +@state-critical: #ff5566; +@shadow-color: @state-critical; + +.box-shadow(@x: 0.2em; @y: 0.2em; @blur: 0.2em; @spread: 0; @color: rgba(83, 83, 83, 0.25)) { + -webkit-box-shadow: @arguments; + -moz-box-shadow: @arguments; + box-shadow: @arguments; +} + +.dialog-critical { + .box-shadow(@color: @shadow-color); +} +LESS; + + $css = <<<'CSS' +.dialog-critical { + -webkit-box-shadow: 0.2em 0.2em 0.2em 0 var(--shadow-color, var(--state-critical, #ff5566)); + -moz-box-shadow: 0.2em 0.2em 0.2em 0 var(--shadow-color, var(--state-critical, #ff5566)); + box-shadow: 0.2em 0.2em 0.2em 0 var(--shadow-color, var(--state-critical, #ff5566)); +} +CSS; + + $this->assertCss($css, $less); + } + + public function testMixinColorParameterIsNotReplaced(): void + { + // The mixin parameter @color acts as a local placeholder — it must not be + // replaced with var(--color). Only the argument passed at the call site gets replaced. + $less = <<<'LESS' +@state-ok: #44bb77; + +.state-badge(@color) { + background-color: @color; + border: 1px solid @color; +} + +.state-ok { + .state-badge(@state-ok); +} +LESS; + + $css = <<<'CSS' +.state-ok { + background-color: var(--state-ok, #44bb77); + border: 1px solid var(--state-ok, #44bb77); +} +CSS; + + $this->assertCss($css, $less); + } + + public function testMixinDefaultColorParametersAreReplaced(): void + { + // Default parameter values that reference color variables are replaced + // when the mixin is called without explicit arguments. + $less = <<<'LESS' +@base-primary-bg: #00c3ed; +@primary-button-bg: @base-primary-bg; +@default-bg: #282e39; +@default-text-color-inverted: @default-bg; + +.button(@bg: @primary-button-bg; @fg: @default-text-color-inverted) { + background-color: @bg; + color: @fg; +} + +.submit-button { + .button(); +} +LESS; + + $css = <<<'CSS' +.submit-button { + background-color: var(--primary-button-bg, var(--base-primary-bg, #00c3ed)); + color: var(--default-text-color-inverted, var(--default-bg, #282e39)); +} +CSS; + + $this->assertCss($css, $less); + } + + public function testVariablesInsideMediaQueries(): void + { + $less = <<<'LESS' +@base-primary-bg: #00c3ed; +@control-color: @base-primary-bg; + +@media (max-width: 768px) { + .controls { + color: @control-color; + } +} +LESS; + + $css = <<<'CSS' +@media (max-width: 768px) { + .controls { + color: var(--control-color, var(--base-primary-bg, #00c3ed)); + } +} +CSS; + + $this->assertCss($css, $less); + } + + public function testVariablesInsideDetachedRuleset(): void + { + // Color variables used inside a detached ruleset are replaced when the ruleset is called. + $less = <<<'LESS' +@card-border-color: #5c5c5c; + +@card-styles: { + border: 1px solid @card-border-color; +}; + +.card { + @card-styles(); +} +LESS; + + $css = <<<'CSS' +.card { + border: 1px solid var(--card-border-color, #5c5c5c); +} +CSS; + + $this->assertCss($css, $less); + } + + public function testColorVariableInsideCssFunctionIsReplaced(): void + { + // CSS functions (unlike Less built-ins such as fade()) remain call nodes after compilation. + // The visitor re-compiles them with var() replacement enabled. + $less = <<<'LESS' +@state-critical: #ff5566; + +.alert-box { + color: calc(@state-critical); +} +LESS; + + $css = <<<'CSS' +.alert-box { + color: calc(var(--state-critical, #ff5566)); +} +CSS; + + $this->assertCss($css, $less); + } + + public function testVariableVariables(): void + { + $less = <<<'LESS' +@state-ok: #44bb77; +@state-critical: #ff5566; + +.status-panel { + @color-var: state-ok; + color: @@color-var; +} + +.alert-box { + color: @@alert-color-var; +} + +@alert-color-var: state-critical; +LESS; + + $css = <<<'CSS' +.status-panel { + color: var(--state-ok, #44bb77); +} +.alert-box { + color: var(--state-critical, #ff5566); +} +CSS; + + $this->assertCss($css, $less); + } + + public function testPropertyInterpolationWithColorVariable(): void + { + $less = <<<'LESS' +@state-property: color; +@state-ok: #44bb77; + +.status-indicator { + @{state-property}: @state-ok; +} +LESS; + + $css = <<<'CSS' +.status-indicator { + color: var(--state-ok, #44bb77); +} +CSS; + + $this->assertCss($css, $less); + } + + public function testColorVariablesInMixinBodyAndNestedRules(): void + { + $less = <<<'LESS' +@default-text-color: #ecf0f6; + +.page-header-styles() { + color: @default-text-color; + + .badge { + color: @default-text-color !important; + } +} + +.page-header { + .page-header-styles(); +} +LESS; + + $css = <<<'CSS' +.page-header { + color: var(--default-text-color, #ecf0f6); +} +.page-header .badge { + color: var(--default-text-color, #ecf0f6) !important; +} +CSS; + + $this->assertCss($css, $less); + } + + public function testColorVariablesInNamespacedMixin(): void + { + $less = <<<'LESS' +@state-ok: #44bb77; + +#icinga { + .state-color() { + color: @state-ok; + } +} + +.state-ok-badge { + #icinga.state-color(); +} +LESS; + + $css = <<<'CSS' +.state-ok-badge { + color: var(--state-ok, #44bb77); +} +CSS; + + $this->assertCss($css, $less); + } + + public function testColorVariablesInGuardedNamespace(): void + { + $less = <<<'LESS' +@icinga-theme: dark; +@state-ok: #44bb77; + +#theme when (@icinga-theme = dark) { + .state-badge() { + background-color: @state-ok; + } +} + +.status-badge { + #theme.state-badge(); +} +LESS; + + $css = <<<'CSS' +.status-badge { + background-color: var(--state-ok, #44bb77); +} +CSS; + + $this->assertCss($css, $less); + } + + public function testOutOfOrderNamedArgumentsAreReplaced(): void + { + $less = <<<'LESS' +@default-bg: #282e39; +@state-warning: #ffaa44; +@default-text-color: #ecf0f6; + +.status-button(@bg-color: @default-bg, @label-color: @default-text-color) { + background-color: @bg-color; + color: @label-color; +} + +.warning-button { + .status-button(@label-color: @default-text-color, @bg-color: @state-warning); +} +LESS; + + $css = <<<'CSS' +.warning-button { + background-color: var(--state-warning, #ffaa44); + color: var(--default-text-color, #ecf0f6); +} +CSS; + + $this->assertCss($css, $less); + } + + public function testRestParameterWithColorVariable(): void + { + $less = <<<'LESS' +@default-text-color: #ecf0f6; +@state-ok: #44bb77; + +.text-with-shadow(@fg-color, @shadow...) { + color: @fg-color; + text-shadow: @shadow; +} + +.success-hint { + .text-with-shadow(@default-text-color, 0, 0, 3px, @state-ok); +} +LESS; + + $css = <<<'CSS' +.success-hint { + color: var(--default-text-color, #ecf0f6); + text-shadow: 0 0 3px var(--state-ok, #44bb77); +} +CSS; + + $this->assertCss($css, $less); + } + + public function testNestedDetachedRulesetCalls(): void + { + $less = <<<'LESS' +@state-ok: #44bb77; +@state-critical: #ff5566; + +@status-styles: { + color: @state-ok; + @focus-styles(); +}; + +@focus-styles: { + outline-color: @state-critical; +}; + +.status-badge { + @status-styles(); +} +LESS; + + $css = <<<'CSS' +.status-badge { + color: var(--state-ok, #44bb77); + outline-color: var(--state-critical, #ff5566); +} +CSS; + + $this->assertCss($css, $less); + } + + public function testAliasedColorInsideBuiltInFunctionIsResolved(): void + { + // When an aliased color variable is passed to a Less built-in function, + // the function receives the resolved color value — not a var() call. + $less = <<<'LESS' +@state-ok: #44bb77; +@badge-color: @state-ok; + +.hint-label { + color: fade(@badge-color, 60%); +} +LESS; + + $css = <<<'CSS' +.hint-label { + color: rgba(68, 187, 119, 0.6); +} +CSS; + + $this->assertCss($css, $less); + } + + public function testMapLookupIsNotBrokenByVisitor(): void + { + // Less 3.5+ map lookups should pass through unchanged — there is no variable node to replace. + $less = <<<'LESS' +#state-colors() { + ok: #44bb77; + critical: #ff5566; +} + +.state-ok-dot { + color: #state-colors[ok]; +} +LESS; + + $css = <<<'CSS' +.state-ok-dot { + color: #44bb77; +} +CSS; + + $this->assertCss($css, $less); + } + + public function testVisitorInstanceIsReusableAcrossMultipleParsers(): void + { + // run() clones the visitor before each traversal so that $mixinParams and + // $replaceCssVars from one parse do not leak into the next. + // If cloning were removed, @color would remain on the mixin-param exclusion + // stack after the first parse and would be skipped in the second parse, + // causing @state-critical to pass through unreplaced. + $visitor = new CssVarVisitor(); + + $less1 = <<<'LESS' +@state-ok: #44bb77; +.state-label(@color) { color: @color; } +.state-ok { .state-label(@state-ok); } +LESS; + + $less2 = <<<'LESS' +@state-critical: #ff5566; +.state-label(@color) { color: @color; } +.state-critical { .state-label(@state-critical); } +LESS; + + $parser1 = new Less_Parser(['plugins' => [$visitor]]); + $css1 = $parser1->parse($less1)->getCss(); + + $parser2 = new Less_Parser(['plugins' => [$visitor]]); + $css2 = $parser2->parse($less2)->getCss(); + + $this->assertStringContainsString('var(--state-ok, #44bb77)', $css1); + $this->assertStringContainsString('var(--state-critical, #ff5566)', $css2); + } + + public function testColorMathProducesResolvedValue(): void + { + $this->markTestSkipped('Math is not yet supported'); + + // Color arithmetic requires the resolved color value — not a var() call. + $less = <<<'LESS' +@default-bg: #282e39; + +.card-elevated { + border-top-color: @default-bg + #111111; +} +LESS; + + $css = <<<'CSS' +.card-elevated { + border-top-color: #393f4a; +} +CSS; + + $this->assertCss($css, $less); + } + + public function testColorTypeGuardIsNotBrokenByReplacement(): void + { + $this->markTestSkipped('iscolor() guards is not yet supported'); + + // iscolor() guards must still match when the argument is a color variable. + $less = <<<'LESS' +@state-ok: #44bb77; + +.state-badge(@c) when (iscolor(@c)) { + background-color: @c; +} + +.state-badge(@c) when (default()) { + background-color: transparent; +} + +.state-ok-badge { + .state-badge(@state-ok); +} +LESS; + + $css = <<<'CSS' +.state-ok-badge { + background-color: var(--state-ok, #44bb77); +} +CSS; + + $this->assertCss($css, $less); + } + + public function testVariableInterpolationInSelectorIsUnaffected(): void + { + // @{state-name} is a selector interpolation, not a color variable reference. + // The visitor must not interfere with it; the color reference is still replaced. + $less = <<<'LESS' +@state-ok: #44bb77; +@state-name: state-ok; + +.@{state-name} { + color: @state-ok; +} +LESS; + + $css = <<<'CSS' +.state-ok { + color: var(--state-ok, #44bb77); +} +CSS; + + $this->assertCss($css, $less); + } + + protected function assertCss(string $expectedCss, string $actualLess, array $plugins = []): void + { + parent::assertCss($expectedCss, $actualLess, $plugins ?: [new CssVarVisitor()]); + } +} diff --git a/tests/Less/DetachedRulesetCallVisitorTest.php b/tests/Less/DetachedRulesetCallVisitorTest.php new file mode 100644 index 00000000..e4aafa1c --- /dev/null +++ b/tests/Less/DetachedRulesetCallVisitorTest.php @@ -0,0 +1,294 @@ +assertCss($css, $less, [$visitor]); + } + + public function testRulesetsAreWrappedInReducedMotionQuery(): void + { + $visitor = new DetachedRulesetCallVisitor('reduced-motion', <<<'LESS' +@media (prefers-reduced-motion: reduce) { + {ruleset} +} +LESS); + + $less = <<<'LESS' +@reduced-motion: { + .spinner { + animation: none; + } + .page-transition { + transition: none; + } +} +LESS; + + $css = <<<'CSS' +@media (prefers-reduced-motion: reduce) { + .spinner { + animation: none; + } + .page-transition { + transition: none; + } +} +CSS; + + $this->assertCss($css, $less, [$visitor]); + } + + public function testMultipleDeclarationsAreEachExpandedIndependently(): void + { + // Multiple Less files (or scopes) may each contribute their own block. + // Each declaration is expanded into its own wrapped block independently. + $visitor = new DetachedRulesetCallVisitor('print', <<<'LESS' +@media print { + {ruleset} +} +LESS); + + $less = <<<'LESS' +@print: { + .navigation { + display: none; + } +} + +@print: { + .advertisement { + display: none; + } +} +LESS; + + $css = <<<'CSS' +@media print { + .navigation { + display: none; + } +} +@media print { + .advertisement { + display: none; + } +} +CSS; + + $this->assertCss($css, $less, [$visitor]); + } + + public function testSurroundingRulesAreUnaffected(): void + { + $visitor = new DetachedRulesetCallVisitor('print', <<<'LESS' +@media print { + {ruleset} +} +LESS); + + $less = <<<'LESS' +.component { + font-size: 1rem; +} + +@print: { + .component { + font-size: 12pt; + } +} + +.other-component { + color: black; +} +LESS; + + $css = <<<'CSS' +.component { + font-size: 1rem; +} +@media print { + .component { + font-size: 12pt; + } +} +.other-component { + color: black; +} +CSS; + + $this->assertCss($css, $less, [$visitor]); + } + + public function testNonMatchingVariableNamesAreNotExpanded(): void + { + // A visitor configured for 'print' must not expand unrelated detached rulesets. + $visitor = new DetachedRulesetCallVisitor('print', <<<'LESS' +@media print { + {ruleset} +} +LESS); + + $less = <<<'LESS' +@reduced-motion: { + .spinner { + animation: none; + } +} + +.selector { + font-size: 1em; +} +LESS; + + $css = <<<'CSS' +.selector { + font-size: 1em; +} +CSS; + + $this->assertCss($css, $less, [$visitor]); + } + + public function testNonDetachedRulesetValuesAreNotExpanded(): void + { + // A variable with the right name but a scalar value is left unchanged. + $visitor = new DetachedRulesetCallVisitor('brand-color', <<<'LESS' +@media print { + {ruleset} +} +LESS); + + $less = <<<'LESS' +@brand-color: #00c3ed; + +.link { + color: @brand-color; +} +LESS; + + $css = <<<'CSS' +.link { + color: #00c3ed; +} +CSS; + + $this->assertCss($css, $less, [$visitor]); + } + + public function testLightModeBlockIsWrappedInColorSchemeMediaQuery(): void + { + // Dark-mode-first design: var() calls reference custom properties and carry dark + // fallback values. The @light-mode block overrides them for light color schemes. + $visitor = new DetachedRulesetCallVisitor('light-mode', <<<'LESS' +@media (prefers-color-scheme: light) { + {ruleset} +} +LESS); + + $less = <<<'LESS' +.state-ok { + color: var(--color-ok, #44bb77); +} + +.state-critical { + color: var(--color-critical, #ff5566); +} + +.state-unknown { + color: var(--color-unknown, #aa44ff); +} + +.page { + background: var(--color-background, #1c1c1e); + color: var(--color-text, #d9d9d9); +} + +@light-mode: { + :root { + --color-ok: #2ecc71; + --color-critical: #e74c3c; + --color-unknown: #9b59b6; + --color-background: #ffffff; + --color-text: #1c1c1e; + } +} +LESS; + + $css = <<<'CSS' +.state-ok { + color: var(--color-ok, #44bb77); +} +.state-critical { + color: var(--color-critical, #ff5566); +} +.state-unknown { + color: var(--color-unknown, #aa44ff); +} +.page { + background: var(--color-background, #1c1c1e); + color: var(--color-text, #d9d9d9); +} +@media (prefers-color-scheme: light) { + :root { + --color-ok: #2ecc71; + --color-critical: #e74c3c; + --color-unknown: #9b59b6; + --color-background: #ffffff; + --color-text: #1c1c1e; + } +} +CSS; + + $this->assertCss($css, $less, [$visitor]); + } + + public function testRunThrowsOnNonRulesetRoot(): void + { + // run() requires a Less_Tree_Ruleset as its root node. Passing any other + // Less_Tree instance must throw InvalidArgumentException immediately. + $this->expectException(InvalidArgumentException::class); + + $visitor = new DetachedRulesetCallVisitor('print', '@media print { {ruleset} }'); + $visitor->run(new Less_Tree_Declaration('@print', null)); + } +} diff --git a/tests/Less/LessVisitorTestCase.php b/tests/Less/LessVisitorTestCase.php new file mode 100644 index 00000000..2248a9a4 --- /dev/null +++ b/tests/Less/LessVisitorTestCase.php @@ -0,0 +1,37 @@ + $plugins Visitor plugins to register with the parser + * + * @return void + */ + protected function assertCss(string $expectedCss, string $actualLess, array $plugins = []): void + { + try { + (new Less_Parser())->parse($expectedCss); + } catch (Less_Exception_Parser $e) { + $this->fail("Expected CSS is not valid: {$e->getMessage()}"); + } + + try { + $parser = new Less_Parser(['plugins' => $plugins]); + $actualCss = $parser->parse($actualLess)->getCss(); + } catch (Less_Exception_Parser $e) { + $this->fail("Actual Less is not valid: {$e->getMessage()}"); + } + + $this->assertSame(trim($expectedCss), trim($actualCss)); + } +}