Skip to content

Add Less compiler and pre-eval AST visitors for CSS var support#365

Open
lippserd wants to merge 1 commit intomainfrom
less
Open

Add Less compiler and pre-eval AST visitors for CSS var support#365
lippserd wants to merge 1 commit intomainfrom
less

Conversation

@lippserd
Copy link
Copy Markdown
Member

@lippserd lippserd commented Apr 9, 2026

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.

refs Icinga/icingaweb2#5492
refs Icinga/icinga-php-thirdparty#115

`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.
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Introduces a Less compilation wrapper and pre-eval AST visitors to enable CSS custom-property theming and detached-ruleset templating, alongside a dependency bump to a newer wikimedia/less.php version that supports the required hooks.

Changes:

  • Add WikimediaLessCompiler with a compile() API that normalizes parser exceptions.
  • Add PreEvalVisitor plus CssVarVisitor and DetachedRulesetCallVisitor for pre-compile AST transformations.
  • Add comprehensive PHPUnit coverage for the visitors and bump wikimedia/less.php to ^5.5.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
composer.json Bumps wikimedia/less.php to a newer major version required by the pre-eval visitor hook.
src/Less/WikimediaLessCompiler.php Adds a small compiler wrapper around Less_Parser with exception normalization.
src/Less/PreEvalVisitor.php Adds a base visitor for pre-eval traversal (incl. workaround for import accept/visit dispatch).
src/Less/CssVarVisitor.php Adds pre-eval rewrites from Less color variables to CSS var(--x, fallback) calls.
src/Less/DetachedRulesetCallVisitor.php Adds pre-eval expansion of a named detached ruleset into a configured Less template.
tests/Less/LessVisitorTestCase.php Adds a reusable test helper for compiling Less and asserting CSS output.
tests/Less/CssVarVisitorTest.php Adds extensive test coverage for CssVarVisitor behaviors and edge cases.
tests/Less/DetachedRulesetCallVisitorTest.php Adds test coverage for template wrapping and replacement behavior.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +35 to +40
$parserOptions = [
'compress' => $minify,
];

try {
return (new Less_Parser($this->parserOptions + $parserOptions))
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

compile() merges options using the array union operator ($this->parserOptions + $parserOptions), so any compress value provided in $parserOptions passed to the constructor will win over the $minify argument. If callers expect compile(..., $minify) to control minification, switch to an override merge order (e.g., merge then set 'compress' => $minify last) so $minify reliably takes precedence.

Suggested change
$parserOptions = [
'compress' => $minify,
];
try {
return (new Less_Parser($this->parserOptions + $parserOptions))
$parserOptions = $this->parserOptions;
$parserOptions['compress'] = $minify;
try {
return (new Less_Parser($parserOptions))

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude and I agree.

Comment on lines +100 to +107
$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);
}
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tmpfile() can return false on failure. The current code passes the result straight to fwrite()/stream_get_meta_data()/fclose(), which will raise warnings or errors if a temp file cannot be created. Handle the failure case explicitly and throw a RuntimeException with a clear message when tmpfile() fails.

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +56
/**
* Parsed template mixin definition, injected at root level once per run by {@see run()}
*
* @var Less_Tree_Mixin_Definition
*/
protected readonly Less_Tree_Mixin_Definition $mixinDef;

Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This visitor stores a Less_Tree_Mixin_Definition object in $mixinDef, but the visitor is cloned on each run(). Because PHP cloning is shallow, the clone and prototype will share the same $mixinDef instance unless you deep-copy it. Either implement __clone() to clone/rebuild $mixinDef, or document/ensure that Less.php will never mutate the mixin definition object during compilation (to avoid cross-run state leakage).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oof, not sure what the probablity of $mixinDef being mutated is. I'd leave this up to @lippserd to decide.

Comment on lines +147 to +151
foreach (array_reverse($this->mixinParams) as $ignoreVars) {
if (in_array($v->name, $ignoreVars, true)) {
return $v;
}
}
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

visitVariable() calls array_reverse($this->mixinParams) on every variable node visit, which allocates a reversed copy each time. Since this runs during AST traversal, it can become a hot path on large stylesheets. Iterate the stack backwards by index (or maintain it in the needed order) to avoid repeated array copies.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it even necessary to reverse the stack as seemingly the order isn't relevant for the loop?

Comment on lines +5 to +7
use Less_Exception_Parser;
use Less_Parser;
use PHPUnit\Framework\TestCase;
Copy link

Copilot AI Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Project tests consistently extend ipl\Tests\Web\TestCase (see tests/TestCase.php and e.g. tests/FilterTest.php). This new base test case extends PHPUnit\Framework\TestCase directly, which diverges from that convention. Consider extending the project TestCase instead to keep a single common base and allow future shared setup/teardown.

Suggested change
use Less_Exception_Parser;
use Less_Parser;
use PHPUnit\Framework\TestCase;
use ipl\Tests\Web\TestCase;
use Less_Exception_Parser;
use Less_Parser;

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

@nilmerg nilmerg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the bump to wikimedia/less.php v5, it complains about empty variable values as noted by @sukhwinder33445 in Icinga/icingaweb2#5492.

Icinga Notifications Web might be responsible but either way, I think \ipl\Web\LessRuleset should ignore empty properties since its interface isn't strict enough when handling arrays.

$t = tmpfile();
fwrite($t, $less);
try {
$root = (Less_Tree::$parse ?? new Less_Parser())
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might warrant a comment: Falling back to a new parser instance causes a template being parsed with default configuration options.

Comment on lines +50 to +56
/**
* Parsed template mixin definition, injected at root level once per run by {@see run()}
*
* @var Less_Tree_Mixin_Definition
*/
protected readonly Less_Tree_Mixin_Definition $mixinDef;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oof, not sure what the probablity of $mixinDef being mutated is. I'd leave this up to @lippserd to decide.

Comment on lines +147 to +151
foreach (array_reverse($this->mixinParams) as $ignoreVars) {
if (in_array($v->name, $ignoreVars, true)) {
return $v;
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it even necessary to reverse the stack as seemingly the order isn't relevant for the loop?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants