diff --git a/src/Evaluators/ExpressionEvaluator.php b/src/Evaluators/ExpressionEvaluator.php index 5d54bbc..10a0f5a 100644 --- a/src/Evaluators/ExpressionEvaluator.php +++ b/src/Evaluators/ExpressionEvaluator.php @@ -24,6 +24,7 @@ use DartSass\Parsers\Nodes\UnaryNode; use DartSass\Parsers\Nodes\VariableNode; use DartSass\Utils\ArithmeticCalculator; +use DartSass\Utils\SpreadHelper; use DartSass\Utils\StringFormatter; use DartSass\Utils\ValueComparator; use DartSass\Values\SassList; @@ -340,68 +341,14 @@ private function evaluateUrlFunction(array $args): mixed private function evaluateStandardFunction(string $name, array $args): mixed { - $args = $this->evaluateArguments($args); - - if ($this->hasSpreadArguments($args)) { - $args = $this->expandSpreadArguments($args); - } + $args = SpreadHelper::expand( + $this->evaluateArguments($args), + $this->evaluate(...) + ); return $this->context->functionHandler->call($name, $args); } - private function isSpreadArgument(mixed $arg): bool - { - return is_array($arg) && isset($arg['type']) && $arg['type'] === 'spread'; - } - - private function hasSpreadArguments(array $args): bool - { - foreach ($args as $arg) { - if ($this->isSpreadArgument($arg)) { - return true; - } - } - - return false; - } - - private function expandSpreadArguments(array $args): array - { - $processedArgs = []; - - foreach ($args as $arg) { - if ($this->isSpreadArgument($arg)) { - $spreadValue = $this->evaluate($arg['value']); - $processedArgs = $this->appendSpreadValues($processedArgs, $spreadValue); - } else { - $processedArgs[] = $arg; - } - } - - return $processedArgs; - } - - private function appendSpreadValues(array $processedArgs, mixed $spreadValue): array - { - if ($spreadValue instanceof SassList) { - foreach ($spreadValue->value as $item) { - $processedArgs[] = $item; - } - } elseif ($spreadValue instanceof ListNode) { - foreach ($spreadValue->values as $item) { - $processedArgs[] = $this->evaluate($item); - } - } elseif (is_array($spreadValue)) { - foreach ($spreadValue as $item) { - $processedArgs[] = $this->evaluate($item); - } - } else { - $processedArgs[] = $spreadValue; - } - - return $processedArgs; - } - private function hasSlashSeparator(array $args): bool { if (empty($args)) { diff --git a/src/Evaluators/UserFunctionEvaluator.php b/src/Evaluators/UserFunctionEvaluator.php index 7c90c6e..ddf0f0c 100644 --- a/src/Evaluators/UserFunctionEvaluator.php +++ b/src/Evaluators/UserFunctionEvaluator.php @@ -10,9 +10,9 @@ use DartSass\Parsers\Nodes\ListNode; use DartSass\Parsers\Nodes\NodeType; use DartSass\Parsers\Nodes\OperationNode; +use DartSass\Utils\SpreadHelper; use DartSass\Values\SassList; -use function array_slice; use function count; use function is_array; use function is_object; @@ -49,8 +49,9 @@ private function processArguments( $default = $arg['default'] ?? null; if ($arbitrary) { - $remainingArgs = array_slice($callArgs, $argIndex); - $this->environment->getCurrentScope()->setVariable($paramName, new ListNode($remainingArgs)); + $restArgs = SpreadHelper::collect($callArgs, []); + + $this->environment->getCurrentScope()->setVariable($paramName, $restArgs); break; } else { @@ -60,6 +61,7 @@ private function processArguments( } $this->environment->getCurrentScope()->setVariable($paramName, $value); + $argIndex++; } } diff --git a/src/Handlers/MixinHandler.php b/src/Handlers/MixinHandler.php index 94ea15f..8d98a82 100644 --- a/src/Handlers/MixinHandler.php +++ b/src/Handlers/MixinHandler.php @@ -7,22 +7,19 @@ use DartSass\Compilers\CompilerContext; use DartSass\Parsers\Nodes\AstNode; use DartSass\Parsers\Nodes\IdentifierNode; +use DartSass\Utils\SpreadHelper; use DartSass\Values\SassList; -use DartSass\Values\SassMap; use Throwable; use function array_key_exists; use function array_key_first; use function count; -use function in_array; use function is_array; -use function is_int; use function md5; use function preg_replace; use function serialize; use function str_ends_with; use function str_replace; -use function str_starts_with; use function substr; use function trim; @@ -139,29 +136,7 @@ private function bindArguments(array $mixinArgs, array $arguments): void private function bindSpreadArgument(string $argName, array $arguments, array $usedKeys): void { $varName = substr($argName, 0, -3); - - $remainingKeywords = []; - $remainingPositional = []; - - foreach ($arguments as $key => $val) { - if (in_array($key, $usedKeys, true)) { - continue; - } - - if (is_int($key)) { - $remainingPositional[] = $val; - } else { - $keyStr = $key; - - if (str_starts_with($keyStr, '$')) { - $keyStr = substr($keyStr, 1); - } - - $remainingKeywords[$keyStr] = $val; - } - } - - $value = empty($remainingKeywords) ? new SassList($remainingPositional) : new SassMap($remainingKeywords); + $value = SpreadHelper::collectWithKeywords($arguments, $usedKeys); $this->context->variableHandler->define($varName, $value); } diff --git a/src/Utils/SpreadHelper.php b/src/Utils/SpreadHelper.php new file mode 100644 index 0000000..240231c --- /dev/null +++ b/src/Utils/SpreadHelper.php @@ -0,0 +1,101 @@ + $arg) { + if (self::isSpread($arg)) { + $spreadValue = $evaluate($arg['value']); + + $result = self::merge($result, $spreadValue, $evaluate); + } else { + if (is_string($key) && str_starts_with($key, '$')) { + $result[$key] = $arg; + } else { + $result[] = $arg; + } + } + } + + return $result; + } + + public static function collect(array $args, array $usedKeys): SassList + { + [$pos] = self::filter($args, $usedKeys); + + return new SassList($pos); + } + + public static function collectWithKeywords(array $args, array $usedKeys): SassList|SassMap + { + [$pos, $keywords] = self::filter($args, $usedKeys); + + return empty($keywords) ? new SassList($pos) : new SassMap($keywords); + } + + private static function filter(array $args, array $usedKeys): array + { + $pos = $keywords = []; + + foreach ($args as $key => $val) { + if (in_array($key, $usedKeys, true)) { + continue; + } + + if (is_int($key)) { + $pos[] = $val; + } else { + $keyStr = str_starts_with($key, '$') ? substr($key, 1) : $key; + + $keywords[$keyStr] = $val; + } + } + + return [$pos, $keywords]; + } + + private static function merge(array $result, mixed $spreadValue, callable $evaluate): array + { + if ($spreadValue instanceof SassList) { + foreach ($spreadValue->value as $item) { + $result[] = $item; + } + } elseif ($spreadValue instanceof ListNode) { + foreach ($spreadValue->values as $item) { + $result[] = $evaluate($item); + } + } elseif (is_array($spreadValue)) { + foreach ($spreadValue as $item) { + $result[] = $evaluate($item); + } + } else { + $result[] = $spreadValue; + } + + return $result; + } +} diff --git a/tests/Unit/Evaluators/ExpressionEvaluatorTest.php b/tests/Unit/Evaluators/ExpressionEvaluatorTest.php index 0259740..896952f 100644 --- a/tests/Unit/Evaluators/ExpressionEvaluatorTest.php +++ b/tests/Unit/Evaluators/ExpressionEvaluatorTest.php @@ -12,7 +12,6 @@ use DartSass\Parsers\Nodes\AstNode; use DartSass\Parsers\Nodes\FunctionNode; use DartSass\Parsers\Nodes\IdentifierNode; -use DartSass\Parsers\Nodes\ListNode; use DartSass\Parsers\Nodes\NumberNode; use DartSass\Parsers\Nodes\OperationNode; use DartSass\Parsers\Nodes\OperatorNode; @@ -21,6 +20,7 @@ use DartSass\Parsers\Nodes\StringNode; use DartSass\Parsers\Nodes\UnaryNode; use DartSass\Parsers\Nodes\VariableNode; +use DartSass\Utils\SpreadHelper; use DartSass\Values\SassList; use Tests\ReflectionAccessor; @@ -225,7 +225,7 @@ describe('expandSpreadArguments()', function () { it('handles empty array', function () { - $result = $this->accessor->callMethod('expandSpreadArguments', [[]]); + $result = SpreadHelper::expand([], fn($x) => $x); expect($result)->toBe([]); }); @@ -233,7 +233,7 @@ it('handles regular args without spread', function () { $args = ['arg1', 'arg2']; - $result = $this->accessor->callMethod('expandSpreadArguments', [$args]); + $result = SpreadHelper::expand($args, fn($x) => $x); expect($result)->toBe(['arg1', 'arg2']); }); @@ -243,7 +243,7 @@ $args = [['type' => 'spread', 'value' => $spreadValue]]; - $result = $this->accessor->callMethod('expandSpreadArguments', [$args]); + $result = SpreadHelper::expand($args, fn($x) => $x); expect($result)->toBe(['a', 'b', 'c']); }); @@ -257,7 +257,7 @@ ['type' => 'spread', 'value' => $spread2], ]; - $result = $this->accessor->callMethod('expandSpreadArguments', [$args]); + $result = SpreadHelper::expand($args, fn($x) => $x); expect($result)->toBe(['a', 'b', 'c']); }); @@ -267,49 +267,13 @@ $args = ['arg1', ['type' => 'spread', 'value' => $spreadValue], 'arg2']; - $result = $this->accessor->callMethod('expandSpreadArguments', [$args]); + $result = SpreadHelper::expand($args, fn($x) => $x); expect($result)->toBe(['arg1', 'x', 'y', 'arg2']); }); }); - describe('appendSpreadValues()', function () { - it('handles SassList', function () { - $sassList = new SassList(['item1', 'item2'], 'comma'); - $processedArgs = ['existing']; - $result = $this->accessor->callMethod('appendSpreadValues', [$processedArgs, $sassList]); - - expect($result)->toBe(['existing', 'item1', 'item2']); - }); - - it('handles ListNode', function () { - $listNode = new ListNode(['node1', 'node2']); - $processedArgs = ['existing']; - - $result = $this->accessor->callMethod('appendSpreadValues', [$processedArgs, $listNode]); - - expect($result)->toBe(['existing', 'node1', 'node2']); - }); - - it('handles array', function () { - $array = ['elem1', 'elem2']; - $processedArgs = ['existing']; - - $result = $this->accessor->callMethod('appendSpreadValues', [$processedArgs, $array]); - - expect($result)->toBe(['existing', 'elem1', 'elem2']); - }); - - it('handles non-array value', function () { - $value = 'single'; - $processedArgs = ['existing']; - - $result = $this->accessor->callMethod('appendSpreadValues', [$processedArgs, $value]); - - expect($result)->toBe(['existing', 'single']); - }); - }); describe('evaluateVariableString()', function () { it('successfully accesses property with namespace', function () { diff --git a/tests/Unit/Utils/SpreadHelperTest.php b/tests/Unit/Utils/SpreadHelperTest.php new file mode 100644 index 0000000..90f324e --- /dev/null +++ b/tests/Unit/Utils/SpreadHelperTest.php @@ -0,0 +1,193 @@ + 'spread', 'value' => 'test']; + + expect(SpreadHelper::isSpread($arg))->toBeTrue(); + }); + + it('does not identify non-spread arguments', function () { + expect(SpreadHelper::isSpread('test'))->toBeFalse() + ->and(SpreadHelper::isSpread(123))->toBeFalse() + ->and(SpreadHelper::isSpread(true))->toBeFalse() + ->and(SpreadHelper::isSpread(null))->toBeFalse() + ->and(SpreadHelper::isSpread([]))->toBeFalse() + ->and(SpreadHelper::isSpread(['value' => 'test']))->toBeFalse() + ->and(SpreadHelper::isSpread(['type' => 'regular', 'value' => 'test']))->toBeFalse(); + }); + }); + + describe('expand', function () { + it('expands SassList spread arguments', function () { + $sassList = new SassList(['a', 'b', 'c']); + + $args = [['type' => 'spread', 'value' => $sassList]]; + $result = SpreadHelper::expand($args, fn($x) => $x); + + expect($result)->toEqual(['a', 'b', 'c']); + }); + + it('expands ListNode spread arguments', function () { + $listNode = new ListNode(['x', 'y']); + + $args = [['type' => 'spread', 'value' => $listNode]]; + $result = SpreadHelper::expand($args, fn($x) => $x instanceof ListNode ? $x->values : $x); + + expect($result)->toEqual(['x', 'y']); + }); + + it('evaluates each item in ListNode when spreading', function () { + $listNode = new ListNode([10, 20]); + + $args = [['type' => 'spread', 'value' => $listNode]]; + + $result = SpreadHelper::expand($args, function ($x) { + if ($x instanceof ListNode) { + return $x; + } + + return $x * 2; + }); + + expect($result)->toEqual([20, 40]); + }); + + it('preserves regular arguments', function () { + $args = ['arg1', ['type' => 'spread', 'value' => new SassList(['a', 'b'])], 'arg2']; + $result = SpreadHelper::expand($args, fn($x) => $x); + + expect($result)->toEqual(['arg1', 'a', 'b', 'arg2']); + }); + + it('preserves associative array keys', function () { + $args = [ + 'arg1', + ['type' => 'spread', 'value' => new SassList(['a', 'b'])], + '$separator' => 'comma', + '$bracketed' => true, + ]; + + $result = SpreadHelper::expand($args, fn($x) => $x); + expect($result)->toEqual([ + 'arg1', + 'a', + 'b', + '$separator' => 'comma', + '$bracketed' => true, + ]); + }); + + it('expands nested spread arguments', function () { + $nestedList = new SassList(['inner1', 'inner2']); + + $args = [['type' => 'spread', 'value' => new SassList(['outer', $nestedList])]]; + + $result = SpreadHelper::expand($args, function ($x) { + if ($x instanceof SassList) { + return $x->value; + } + + return $x; + }); + + expect($result)->toEqual(['outer', ['inner1', 'inner2']]); + }); + + it('handles non-array spread values', function () { + $args = [['type' => 'spread', 'value' => 'single value']]; + $result = SpreadHelper::expand($args, fn($x) => $x); + + expect($result)->toEqual(['single value']); + }); + + it('returns empty array when input is empty', function () { + $args = []; + $result = SpreadHelper::expand($args, fn($x) => $x); + + expect($result)->toEqual([]); + }); + }); + + describe('collect', function () { + it('collects remaining positional arguments', function () { + $arguments = ['a', 'b', 'c', 'd']; + $usedKeys = [0, 1]; + $result = SpreadHelper::collect($arguments, $usedKeys); + + expect($result->value)->toEqual(['c', 'd']); + }); + + it('collects all arguments when no keys are used', function () { + $arguments = ['a', 'b', 'c']; + $result = SpreadHelper::collect($arguments, []); + + expect($result->value)->toEqual(['a', 'b', 'c']); + }); + + it('returns empty list when all arguments are used', function () { + $arguments = ['a', 'b']; + $result = SpreadHelper::collect($arguments, [0, 1]); + + expect($result->value)->toEqual([]); + }); + }); + + describe('collectWithKeywords', function () { + it('collects remaining positional and keyword arguments', function () { + $arguments = ['a', 'b', '$c' => 3, 'd', '$e' => 5]; + $usedKeys = [0, '$c']; + $result = SpreadHelper::collectWithKeywords($arguments, $usedKeys); + + expect($result)->toBeInstanceOf(SassMap::class) + ->and($result->value)->toEqual(['e' => 5]); + }); + + it('collects only keyword arguments when all positional are used', function () { + $arguments = ['a', 'b', '$c' => 3, '$d' => 4]; + $usedKeys = [0, 1]; + $result = SpreadHelper::collectWithKeywords($arguments, $usedKeys); + + expect($result->value)->toEqual(['c' => 3, 'd' => 4]); + }); + + it('returns empty list when all arguments are used', function () { + $arguments = ['a', '$b' => 2]; + $usedKeys = [0, '$b']; + $result = SpreadHelper::collectWithKeywords($arguments, $usedKeys); + + expect($result->value)->toEqual([]); + }); + }); + + describe('integration', function () { + it('works with actual Sass arguments structure', function () { + $args = [ + new SassList([10, 20]), + new SassList([30, 40]), + '$separator' => 'comma', + '$bracketed' => true, + ['type' => 'spread', 'value' => new SassList(['additional', 'items'])], + ]; + + $result = SpreadHelper::expand($args, fn($x) => $x); + + expect($result[0])->toBeInstanceOf(SassList::class) + ->and($result[0]->value)->toEqual([10, 20]) + ->and($result[1])->toBeInstanceOf(SassList::class) + ->and($result[1]->value)->toEqual([30, 40]) + ->and($result['$separator'])->toBe('comma') + ->and($result['$bracketed'])->toBeTrue() + ->and($result[2])->toBe('additional') + ->and($result[3])->toBe('items'); + }); + }); +});