From cca1d0725895e038d113a1c4b308b955ad8b9142 Mon Sep 17 00:00:00 2001 From: Tomasz Kowalczyk Date: Fri, 23 Jan 2026 21:00:56 +0100 Subject: [PATCH] RegularParser now properly takes into account escaped tokens, updated GitHub actions up to PHP 8.6 --- .github/workflows/test-old.yaml | 2 +- .github/workflows/test.yaml | 7 +++---- Makefile | 2 +- docker-compose.yaml | 5 +++++ docker/php/Dockerfile | 2 +- psalm.xml | 5 +++++ src/Event/FilterShortcodesEvent.php | 1 + src/Event/ReplaceShortcodesEvent.php | 1 + src/Parser/RegexParser.php | 3 +++ src/Parser/RegularParser.php | 17 +++++++++++++++-- src/Parser/WordpressParser.php | 2 ++ src/Serializer/JsonSerializer.php | 2 ++ src/Serializer/YamlSerializer.php | 3 +++ src/ShortcodeFacade.php | 1 + tests/ParserTest.php | 22 ++++++++++++++++++++++ tests/ProcessorTest.php | 3 +++ tests/SerializerTest.php | 4 ++++ tests/ShortcodeTest.php | 2 ++ tests/SyntaxTest.php | 2 ++ 19 files changed, 77 insertions(+), 9 deletions(-) diff --git a/.github/workflows/test-old.yaml b/.github/workflows/test-old.yaml index c860a46..7929dd3 100644 --- a/.github/workflows/test-old.yaml +++ b/.github/workflows/test-old.yaml @@ -20,7 +20,7 @@ jobs: uses: 'shivammathur/setup-php@v2' with: php-version: '${{ matrix.php }}' - tools: 'composer:v1' + tools: 'composer:v2' coverage: 'xdebug' - name: 'PHP' run: 'php -v' diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index cb0fdde..a4fba4c 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -11,12 +11,11 @@ jobs: runs-on: '${{ matrix.os }}' strategy: matrix: - php: ['7.4', '8.0', '8.1', '8.2', '8.3'] + php: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4', '8.5'] os: ['ubuntu-latest'] failure: [false] include: - - { php: '8.4', os: 'ubuntu-latest', failure: true } # Psalm does not support PHP 8.4 yet - - { php: '8.5', os: 'ubuntu-latest', failure: true } # '8.5' means 'nightly' + - { php: '8.6', os: 'ubuntu-latest', failure: true } # '8.6' means 'nightly' steps: - name: 'Checkout' uses: 'actions/checkout@v4' @@ -38,7 +37,7 @@ jobs: - name: 'Psalm' run: | composer remove --dev -W 'phpunit/phpunit' - composer require --dev -W 'vimeo/psalm=^5.0' 'nikic/php-parser=^4.0' + composer require --dev -W 'vimeo/psalm=>=5.0' 'nikic/php-parser=>=4.0' php vendor/bin/psalm --shepherd --php-version=${{ matrix.php }} continue-on-error: '${{ matrix.failure }}' - name: 'Infection' diff --git a/Makefile b/Makefile index 23bdc28..8da5aff 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -PHP_VERSION ?= 8.0 +PHP_VERSION ?= 8.5 PHP := docker-compose run --rm php-${PHP_VERSION} php-version: diff --git a/docker-compose.yaml b/docker-compose.yaml index 5d6d75f..4af5c0a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -18,3 +18,8 @@ services: php-7.3: { <<: *php, build: { context: docker/php, args: { PHP_VERSION: 7.3 } } } php-7.4: { <<: *php, build: { context: docker/php, args: { PHP_VERSION: 7.4 } } } php-8.0: { <<: *php, build: { context: docker/php, args: { PHP_VERSION: 8.0 } } } + php-8.1: { <<: *php, build: { context: docker/php, args: { PHP_VERSION: 8.1 } } } + php-8.2: { <<: *php, build: { context: docker/php, args: { PHP_VERSION: 8.2 } } } + php-8.3: { <<: *php, build: { context: docker/php, args: { PHP_VERSION: 8.3 } } } + php-8.4: { <<: *php, build: { context: docker/php, args: { PHP_VERSION: 8.4 } } } + php-8.5: { <<: *php, build: { context: docker/php, args: { PHP_VERSION: 8.5 } } } diff --git a/docker/php/Dockerfile b/docker/php/Dockerfile index 1cc6625..ced279f 100644 --- a/docker/php/Dockerfile +++ b/docker/php/Dockerfile @@ -2,7 +2,7 @@ ARG PHP_VERSION=8.0 FROM php:$PHP_VERSION RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" \ - && php -r "if (hash_file('sha384', 'composer-setup.php') === '906a84df04cea2aa72f40b5f787e49f22d4c2f19492ac310e8cba5b96ac8b64115ac402c8cd292b8a03482574915d1a8') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" \ + && php -r "if (hash_file('sha384', 'composer-setup.php') === 'c8b085408188070d5f52bcfe4ecfbee5f727afa458b2573b8eaaf77b3419b0bf2768dc67c86944da1544f06fa544fd47') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" \ && php composer-setup.php \ && php -r "unlink('composer-setup.php');" \ && mv composer.phar /usr/local/bin/composer diff --git a/psalm.xml b/psalm.xml index af3be32..5824dd8 100644 --- a/psalm.xml +++ b/psalm.xml @@ -16,4 +16,9 @@ + + + + + diff --git a/src/Event/FilterShortcodesEvent.php b/src/Event/FilterShortcodesEvent.php index 072f257..dd1441e 100644 --- a/src/Event/FilterShortcodesEvent.php +++ b/src/Event/FilterShortcodesEvent.php @@ -10,6 +10,7 @@ * is used directly in processor. * * @author Tomasz Kowalczyk + * @psalm-suppress ClassMustBeFinal */ class FilterShortcodesEvent { diff --git a/src/Event/ReplaceShortcodesEvent.php b/src/Event/ReplaceShortcodesEvent.php index 50218f6..2101756 100644 --- a/src/Event/ReplaceShortcodesEvent.php +++ b/src/Event/ReplaceShortcodesEvent.php @@ -10,6 +10,7 @@ * results in the source text. * * @author Tomasz Kowalczyk + * @psalm-suppress ClassMustBeFinal */ class ReplaceShortcodesEvent { diff --git a/src/Parser/RegexParser.php b/src/Parser/RegexParser.php index c9e2ccb..531961a 100644 --- a/src/Parser/RegexParser.php +++ b/src/Parser/RegexParser.php @@ -46,6 +46,7 @@ public function parse($text) // loop instead of array_map to pass the arguments explicitly $shortcodes = array(); foreach($matches[0] as $match) { + /** @psalm-suppress PossiblyFalseArgument */ $offset = mb_strlen(substr($text, 0, $match[1]), 'utf-8'); $shortcodes[] = $this->parseSingle($match[0], $offset); } @@ -108,11 +109,13 @@ private function parseValue($value) * @param string $value * * @return string + * @psalm-suppress InvalidFalsableReturnType */ private function extractValue($value) { $length = strlen($this->syntax->getParameterValueDelimiter()); + /** @psalm-suppress FalsableReturnStatement */ return $this->isDelimitedValue($value) ? substr($value, $length, -1 * $length) : $value; } diff --git a/src/Parser/RegularParser.php b/src/Parser/RegularParser.php index 023c1c8..d993c7b 100644 --- a/src/Parser/RegularParser.php +++ b/src/Parser/RegularParser.php @@ -9,6 +9,8 @@ /** * @author Tomasz Kowalczyk + * @psalm-suppress PossiblyUndefinedArrayOffset + * @psalm-suppress PossiblyUndefinedVariable */ final class RegularParser implements ParserInterface { @@ -79,6 +81,7 @@ public function parse($text) } } } + /** @psalm-suppress PossiblyFalseArgument */ ini_set('xdebug.max_nesting_level', $nestingLevel); return $shortcodes; @@ -269,7 +272,9 @@ private function getBacktrack() { $position = array_pop($this->backtracks); $backtrack = ''; + /** @psalm-suppress PossiblyNullOperand */ for($i = $position; $i < $this->position; $i++) { + /** @psalm-suppress PossiblyNullArrayOffset */ $backtrack .= $this->tokens[$i][1]; } @@ -285,13 +290,17 @@ private function backtrack($modifyPosition = true) { $position = array_pop($this->backtracks); if($modifyPosition) { + /** @psalm-suppress PossiblyNullPropertyAssignmentValue */ $this->position = $position; } $backtrack = ''; + /** @psalm-suppress PossiblyNullOperand */ for($i = $position; $i < $this->lastBacktrack; $i++) { + /** @psalm-suppress PossiblyNullArrayOffset */ $backtrack .= $this->tokens[$i][1]; } + /** @psalm-suppress PossiblyNullPropertyAssignmentValue */ $this->lastBacktrack = $position; return $backtrack; @@ -339,6 +348,7 @@ private function match($type, $ws) * @param string $text * * @psalm-return list + * @psalm-suppress MixedReturnTypeCoercion */ private function tokenize($text) { @@ -362,9 +372,11 @@ private function tokenize($text) default: { throw new \RuntimeException('Invalid token.'); } } $tokens[] = array($type, $token, $position); + /** @psalm-suppress MixedArgument */ $position += mb_strlen($token, 'utf-8'); } + /** @psalm-suppress MixedReturnTypeCoercion */ return $tokens; } @@ -372,11 +384,11 @@ private function tokenize($text) private function prepareLexer(SyntaxInterface $syntax) { // FIXME: for some reason Psalm does not understand the `@psalm-var callable() $var` annotation - /** @psalm-suppress MissingClosureParamType, MissingClosureReturnType */ + /** @psalm-suppress MissingClosureParamType,MissingClosureReturnType,PossiblyNullOperand */ $group = function($text, $group) { return '(?<'.(string)$group.'>'.preg_replace('/(.)/us', '\\\\$0', (string)$text).')'; }; - /** @psalm-suppress MissingClosureParamType, MissingClosureReturnType */ + /** @psalm-suppress MissingClosureParamType,MissingClosureReturnType */ $quote = function($text) { return preg_replace('/(.)/us', '\\\\$0', (string)$text); }; @@ -388,6 +400,7 @@ private function prepareLexer(SyntaxInterface $syntax) $quote($syntax->getClosingTagMarker()), $quote($syntax->getParameterValueSeparator()), $quote($syntax->getParameterValueDelimiter()), + '\\\\', '\s+', )).').)+)', '(?\s+)', diff --git a/src/Parser/WordpressParser.php b/src/Parser/WordpressParser.php index 07eb872..ea1ef46 100644 --- a/src/Parser/WordpressParser.php +++ b/src/Parser/WordpressParser.php @@ -22,6 +22,8 @@ * @see https://core.trac.wordpress.org/browser/tags/4.3.1/src/wp-includes/shortcodes.php#L239 * @see https://core.trac.wordpress.org/browser/tags/4.3.1/src/wp-includes/shortcodes.php#L448 * @psalm-suppress RiskyTruthyFalsyComparison + * @psalm-suppress PossiblyNullArgument + * @psalm-suppress PossiblyFalseArgument * * @author Tomasz Kowalczyk */ diff --git a/src/Serializer/JsonSerializer.php b/src/Serializer/JsonSerializer.php index 6ee6447..43f5cd8 100644 --- a/src/Serializer/JsonSerializer.php +++ b/src/Serializer/JsonSerializer.php @@ -6,6 +6,8 @@ /** * @author Tomasz Kowalczyk + * @psalm-suppress FalsableReturnStatement + * @psalm-suppress InvalidFalsableReturnType */ final class JsonSerializer implements SerializerInterface { diff --git a/src/Serializer/YamlSerializer.php b/src/Serializer/YamlSerializer.php index b890af1..48a4969 100644 --- a/src/Serializer/YamlSerializer.php +++ b/src/Serializer/YamlSerializer.php @@ -7,6 +7,9 @@ /** * @author Tomasz Kowalczyk + * @psalm-suppress ReservedWord + * @psalm-suppress InvalidReturnStatement + * @psalm-suppress InvalidReturnType */ final class YamlSerializer implements SerializerInterface { diff --git a/src/ShortcodeFacade.php b/src/ShortcodeFacade.php index 32cf1ce..94c17ec 100644 --- a/src/ShortcodeFacade.php +++ b/src/ShortcodeFacade.php @@ -20,6 +20,7 @@ /** * @author Tomasz Kowalczyk + * @psalm-suppress ClassMustBeFinal */ class ShortcodeFacade { diff --git a/tests/ParserTest.php b/tests/ParserTest.php index 63bfcb9..e0bbc57 100644 --- a/tests/ParserTest.php +++ b/tests/ParserTest.php @@ -1,6 +1,7 @@ assertShortcodes($parser->parse($code), $expected); @@ -276,6 +278,26 @@ public function testIssue77() )); } + public function testIssue119() + { + $cases = array( + '[a k="\"y"]inner[/a]' => new ParsedShortcode(new Shortcode('a', array('k' => '\"y'), 'inner', null), '[a k="\"y"]inner[/a]', 0), + '[a k=" \"y"]inner[/a]' => new ParsedShortcode(new Shortcode('a', array('k' => ' \"y'), 'inner', null), '[a k=" \"y"]inner[/a]', 0), + '[a k=" x\"y"]inner[/a]' => new ParsedShortcode(new Shortcode('a', array('k' => ' x\"y'), 'inner', null), '[a k=" x\"y"]inner[/a]', 0), + '[a k="x\"y"]inner[/a]' => new ParsedShortcode(new Shortcode('a', array('k' => 'x\"y'), 'inner', null), '[a k="x\"y"]inner[/a]', 0), + '[mention id=1 name="foo\"ff\""][/mention]' => new ParsedShortcode(new Shortcode('mention', array('id' => '1', 'name' => 'foo\"ff\"'), '', null), '[mention id=1 name="foo\"ff\""][/mention]', 0), + ); + $parser = new RegularParser(); + foreach($cases as $input => $expected) { + $this->assertShortcodes($parser->parse($input), array($expected)); + } + + $this->assertShortcodes($parser->parse('[a k="x\"y"]inner[/a] [mention id=1 name="foo\"ff\""][/mention]'), array( + new ParsedShortcode(new Shortcode('a', array('k' => 'x\"y'), 'inner', null), '[a k="x\"y"]inner[/a]', 0), + new ParsedShortcode(new Shortcode('mention', array('id' => '1', 'name' => 'foo\"ff\"'), '', null), '[mention id=1 name="foo\"ff\""][/mention]', 22), + )); + } + public function testWordPress() { $parser = new WordpressParser(); diff --git a/tests/ProcessorTest.php b/tests/ProcessorTest.php index 2d5b96b..9d8231c 100644 --- a/tests/ProcessorTest.php +++ b/tests/ProcessorTest.php @@ -1,6 +1,7 @@ getHandlers()); @@ -164,6 +166,7 @@ public function testProcessorShortcodePositions() /** * @dataProvider provideBuiltInTests */ + #[DataProvider('provideBuiltInTests')] public function testBuiltInHandlers($text, $result) { $handlers = new HandlerContainer(); diff --git a/tests/SerializerTest.php b/tests/SerializerTest.php index 4165086..317d623 100644 --- a/tests/SerializerTest.php +++ b/tests/SerializerTest.php @@ -1,6 +1,7 @@ serialize($test); @@ -61,6 +63,7 @@ public static function provideShortcodes() /** * @dataProvider provideUnserialized */ + #[DataProvider('provideUnserialized')] public function testUnserialize(SerializerInterface $serializer, ShortcodeInterface $test, $text) { $tested = $serializer->unserialize($text); @@ -89,6 +92,7 @@ public static function provideUnserialized() /** * @dataProvider provideExceptions */ + #[DataProvider('provideExceptions')] public function testSerializerExceptions(SerializerInterface $serializer, $value, $exceptionClass) { $this->willThrowException($exceptionClass); diff --git a/tests/ShortcodeTest.php b/tests/ShortcodeTest.php index b790aa7..e39e788 100644 --- a/tests/ShortcodeTest.php +++ b/tests/ShortcodeTest.php @@ -1,6 +1,7 @@ getOpeningTag());