From 3de58cd673027511f9a782514e5395d52aa50ed8 Mon Sep 17 00:00:00 2001 From: Simon Chester Date: Mon, 1 Dec 2025 16:29:03 +1300 Subject: [PATCH] [BUGFIX] Parse calc split over multiple lines --- CHANGELOG.md | 2 + src/Value/CalcFunction.php | 7 +- tests/Unit/Value/CalcFunctionTest.php | 206 ++++++++++++++++++++++++++ 3 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 tests/Unit/Value/CalcFunctionTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8978fbb7..ded270b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ Please also have a look at our ### Fixed - Use typesafe versions of PHP functions (#1379, #1380, #1382, #1383, #1384) +- Fix parsing of `calc` expressions when a newline immediately precedes or + follows a + or - operator (#1399) ### Documentation diff --git a/src/Value/CalcFunction.php b/src/Value/CalcFunction.php index cd164887..f674ed15 100644 --- a/src/Value/CalcFunction.php +++ b/src/Value/CalcFunction.php @@ -8,6 +8,8 @@ use Sabberworm\CSS\Parsing\UnexpectedEOFException; use Sabberworm\CSS\Parsing\UnexpectedTokenException; +use function Safe\preg_match; + class CalcFunction extends CSSFunction { private const T_OPERAND = 1; @@ -60,9 +62,8 @@ public static function parse(ParserState $parserState, bool $ignoreCase = false) if (\in_array($parserState->peek(), $operators, true)) { if (($parserState->comes('-') || $parserState->comes('+'))) { if ( - $parserState->peek(1, -1) !== ' ' - || !($parserState->comes('- ') - || $parserState->comes('+ ')) + preg_match('/\\s/', $parserState->peek(1, -1)) !== 1 + || preg_match('/\\s/', $parserState->peek(1, 1)) !== 1 ) { throw new UnexpectedTokenException( " {$parserState->peek()} ", diff --git a/tests/Unit/Value/CalcFunctionTest.php b/tests/Unit/Value/CalcFunctionTest.php new file mode 100644 index 00000000..b87f742b --- /dev/null +++ b/tests/Unit/Value/CalcFunctionTest.php @@ -0,0 +1,206 @@ +parse($css); + + self::assertInstanceOf(CalcFunction::class, $calcFunction); + self::assertSame('calc', $calcFunction->getName()); + + $args = $calcFunction->getArguments(); + self::assertCount(1, $args); + self::assertInstanceOf(CalcRuleValueList::class, $args[0]); + + /** @var CalcRuleValueList $value */ + $value = $args[0]; + $components = $value->getListComponents(); + self::assertCount(3, $components); // 100%, -, 20px + + self::assertInstanceOf(Size::class, $components[0]); + self::assertSame(100.0, $components[0]->getSize()); + self::assertSame('%', $components[0]->getUnit()); + + self::assertSame('-', $components[1]); + + self::assertInstanceOf(Size::class, $components[2]); + self::assertSame(20.0, $components[2]->getSize()); + self::assertSame('px', $components[2]->getUnit()); + } + /** + * @test + */ + public function parseNestedCalc(): void + { + $css = 'calc(100% - calc(20px + 1em))'; + $calcFunction = $this->parse($css); + + /** @var CalcRuleValueList $value */ + $value = $calcFunction->getArguments()[0]; + $components = $value->getListComponents(); + + self::assertCount(3, $components); + self::assertSame('-', $components[1]); + + /** @var CalcFunction */ + $nestedCalc = $components[2]; + self::assertInstanceOf(CalcFunction::class, $nestedCalc); + + /** @var CalcRuleValueList $nestedValue */ + $nestedValue = $nestedCalc->getArguments()[0]; + self::assertInstanceOf(CalcRuleValueList::class, $nestedValue); + $nestedComponents = $nestedValue->getListComponents(); + + self::assertCount(3, $nestedComponents); + self::assertSame('+', $nestedComponents[1]); + } + + /** + * @test + */ + public function parseWithParentheses(): void + { + $css = 'calc((100% - 20px) * 2)'; + $calcFunction = $this->parse($css); + + /** @var CalcRuleValueList $value */ + $value = $calcFunction->getArguments()[0]; + $components = $value->getListComponents(); + + self::assertCount(7, $components); + self::assertSame('(', $components[0]); + self::assertInstanceOf(Size::class, $components[1]); // 100% + self::assertSame('-', $components[2]); + self::assertInstanceOf(Size::class, $components[3]); // 20px + self::assertSame(')', $components[4]); + self::assertSame('*', $components[5]); + self::assertInstanceOf(Size::class, $components[6]); // 2 + } + + /** + * @return array + */ + public function provideValidOperatorSyntax(): array + { + return [ + '+ op' => ['calc(100% + 20px)', 'calc(100% + 20px)'], + '- op' => ['calc(100% - 20px)', 'calc(100% - 20px)'], + '* op' => ['calc(100% * 20)', 'calc(100% * 20)'], + '* op no space' => ['calc(100%*20)', 'calc(100% * 20)'], + '/ op' => ['calc(100% / 20)', 'calc(100% / 20)'], + '/ op no space' => ['calc(100%/20)', 'calc(100% / 20)'], + ]; + } + + /** + * @test + * + * @dataProvider provideValidOperatorSyntax + */ + public function parseValidOperators(string $css, string $rendered): void + { + $calcFunction = $this->parse($css); + $output = $calcFunction->render(OutputFormat::create()); + self::assertSame($rendered, $output); + } + + /** + * @return array + */ + public function provideMultiline(): array + { + return [ + 'right newline' => ["calc(100% +\n20px)", 'calc(100% + 20px)'], + 'right and outer newline' => ["calc(\n100% +\n20px\n)", 'calc(100% + 20px)'], + 'left newline' => ["calc(100%\n+ 20px)", 'calc(100% + 20px)'], + 'both newline' => ["calc(100%\n+\n20px)", 'calc(100% + 20px)'], + 'tab whitespace' => ["calc(100%\t+\t20px)", 'calc(100% + 20px)'], + '- op' => ["calc(100%\n-\n20px)", 'calc(100% - 20px)'], + '/ op' => ["calc(100% /\n20)", 'calc(100% / 20)'], + ]; + } + + /** + * @test + * + * @dataProvider provideMultiline + */ + public function parseMultiline(string $css, string $rendered): void + { + $calcFunction = $this->parse($css); + $output = $calcFunction->render(OutputFormat::create()); + self::assertSame($rendered, $output); + } + + /** + * @return array + */ + public function provideInvalidSyntax(): array + { + return [ + 'missing space around -' => ['calc(100%-20px)'], + 'missing space around +' => ['calc(100%+20px)'], + 'invalid operator' => ['calc(100% ^ 20px)'], + ]; + } + + /** + * @test + * + * @dataProvider provideInvalidSyntax + */ + public function parseThrowsExceptionForInvalidSyntax(string $css): void + { + $this->expectException(UnexpectedTokenException::class); + $this->parse($css); + } + + /** + * @test + */ + public function parseThrowsExceptionIfCalledWithWrongFunctionName(): void + { + $css = 'wrong(100% - 20px)'; + $parserState = new ParserState($css, Settings::create()); + + $this->expectException(UnexpectedTokenException::class); + $this->expectExceptionMessage('calc'); + CalcFunction::parse($parserState); + } + + /** + * Parse provided CSS as a CalcFunction + * + * @param string $css + * @return CalcFunction + */ + private function parse(string $css): CalcFunction + { + $parserState = new ParserState($css, Settings::create()); + + $function = CalcFunction::parse($parserState); + self::assertInstanceOf(CalcFunction::class, $function); + return $function; + } +}