diff --git a/src/Renderers/CodeNodeRenderer.php b/src/Renderers/CodeNodeRenderer.php index cfa46a1..fc674bd 100644 --- a/src/Renderers/CodeNodeRenderer.php +++ b/src/Renderers/CodeNodeRenderer.php @@ -59,8 +59,8 @@ public function render(): string } $language = $this->codeNode->getLanguage() ?? 'php'; - $languageMapping = self::LANGUAGES_MAPPING[$language] ?? $language; - $languages = array_unique([$language, $languageMapping]); + $highlightingLanguage = self::LANGUAGES_MAPPING[$language] ?? $language; + $languages = array_unique([$language, $highlightingLanguage]); if ('text' === $language) { // Highlighter escapes correctly the code, we need to manually escape only for "text" code @@ -69,10 +69,11 @@ public function render(): string $this->configureHighlighter(); $highLighter = new Highlighter(); - $highlightedCode = $highLighter->highlight($languageMapping, $code)->value; + $highlightedCode = $highLighter->highlight($highlightingLanguage, $code)->value; + } - // this allows to highlight the $ in PHP variable names - $highlightedCode = str_replace('$', '$', $highlightedCode); + if ('php' === $highlightingLanguage) { + $highlightedCode = $this->processHighlightedPhpCode($highlightedCode); } if ('terminal' === $language) { @@ -139,4 +140,77 @@ private function escapeForbiddenCharactersInsideCodeBlock(string $code): string return strtr($codeEscaped, ['<' => '<', '>' => '>', '"' => '"']); } + + private function processHighlightedPhpCode(string $highlightedCode): string + { + // this allows to highlight the $ in PHP variable names + $highlightedCode = str_replace('$', '$', $highlightedCode); + + // the rest of this method highlights PHP attributes, so if we can't find this pattern, return early + if (!str_contains($highlightedCode, '#[')) { + return $highlightedCode; + } + + // this highlights PHP attributes, which can be defined in many different ways: + // + // #[AttributeName] + // #[AttributeName()] + // #[AttributeName('value')] + // #[AttributeName('value', option: 'value')] + // #[AttributeName(['value' => 'value'])] + // #[AttributeName( + // 'value', + // option: 'value' + // )] + // + // The attribute name is mandatory, but the parentheses and the arguments are optional. + $highlightedCode = preg_replace_callback( + '/#\[\s*(?[a-zA-Z_\\\\][\w\\\\]*)(?\(.*\))?\s*\]/Us', + static function (array $matches) { + $attributeName = $matches['name']; + $attributeArguments = $matches['arguments'] ?? ''; + + if ('' === $attributeArguments) { + return sprintf('#[%s]', $attributeName); + } + + $highlighter = new Highlighter(); + + // this is needed because when using 'class' as the name of an attribute argument, the highlighter + // confuses it for a new class instantiation and highlights it as such (this is later reverted) + $attributeArguments = str_replace('class:', 'klass:', $attributeArguments); + + // this happens in multiline attributes, where the highlighter already highlighted each line of the attribute (except the attribute name) + if (str_contains($attributeArguments, '(.*)\)$/s', '$1', $attributeArguments); + } else { + // the tricky part is to highlight the values and options; so we + // use the highlighter to highlight the whole attribute wrapped with + // some contents to make it valid PHP code: + // Original string to highlight: AttributeName('value', option: 'value') + // String passed to highlighter: $hljsAttribute = new AttributeName('value', option: 'value'); + // After highlighting, we remove the `$hljsAttribute = new ` prefix and the trailing `;` + $highlightedAttribute = $highlighter->highlight('php', sprintf('$hljsAttribute = new %s%s;', $attributeName, $attributeArguments))->value; + $highlightedAttribute = preg_replace('/^\$hljsAttribute<\/span> = new<\/span> (.*);$/', '$1', $highlightedAttribute); + + // fix the transformation of < to &< and > to &> caused by the highlighter + $highlightedAttribute = str_replace('&lt;', '<', $highlightedAttribute); + $highlightedAttribute = str_replace('&gt;', '>', $highlightedAttribute); + + // $highlightedAttribute is like 'Route('/posts/{id}')' + // remove the attribute name and the parenthesis from the highlighted code + $highlightedAttribute = substr($highlightedAttribute, strlen($attributeName) + 1, -1); + } + + // reverse the previous change needed to avoid highlighting 'class' as a new class instantiation + $highlightedAttribute = str_replace('klass:', 'class:', $highlightedAttribute); + + return sprintf('#[%s(%s)]', $attributeName, $highlightedAttribute); + }, + $highlightedCode + ); + + return $highlightedCode; + } } diff --git a/tests/IntegrationTest.php b/tests/IntegrationTest.php index cf34cde..69b3681 100644 --- a/tests/IntegrationTest.php +++ b/tests/IntegrationTest.php @@ -290,6 +290,10 @@ public function parserUnitBlockProvider() 'blockName' => 'code-blocks/php-annotations', ]; + yield 'code-block-php-attributes' => [ + 'blockName' => 'code-blocks/php-attributes', + ]; + yield 'code-block-text' => [ 'blockName' => 'code-blocks/text', ]; diff --git a/tests/fixtures/expected/blocks/code-blocks/php-attributes.html b/tests/fixtures/expected/blocks/code-blocks/php-attributes.html new file mode 100644 index 0000000..5b73756 --- /dev/null +++ b/tests/fixtures/expected/blocks/code-blocks/php-attributes.html @@ -0,0 +1,147 @@ +
+
+
1
+2
+3
+4
+5
+6
+7
+8
+9
+10
+11
+12
+13
+14
+15
+16
+17
+18
+19
+20
+21
+22
+23
+24
+25
+26
+27
+28
+29
+30
+31
+32
+33
+34
+35
+36
+37
+38
+39
+40
+41
+42
+43
+44
+45
+46
+47
+48
+
+            
+                // src/SomePath/SomeClass.php
+namespace App\SomePath;
+                useSymfony\Component\Validator\Constraints as Assert;
+                
+                    class
+                    SomeClass
+                
+                {
+                #[AttributeName]
+                private
+                
+                    $ property1
+                ;
+                #[AttributeName(
+                )]
+                private
+                
+                    $ property2
+                ;
+                #[AttributeName(
+                'value'
+                )]
+                private
+                
+                    $ property3
+                ;
+                #[AttributeName(
+                'value'
+                , option:
+                'value'
+                )]
+                private
+                
+                    $ property4
+                ;
+                #[AttributeName(
+['value' => 'value'])]
+                private
+                
+                    $ property5
+                ;
+                #[AttributeName(
+                'value'
+                , option:
+                'value'
+                )]
+                private
+                
+                    $ property6
+                ;
+                #[Assert\AttributeName(
+                'value'
+                )]
+                private
+                
+                    $ property7
+                ;
+                #[Assert\AttributeName(
+                'value'
+                , option:
+                'value'
+                )]
+                private
+                
+                    $ property8
+                ;
+                #[Route(
+                '/blog/{page<\d+>}'
+                , name:
+                'blog_list'
+                )]
+                private
+                
+                $ property9
+                ;
+                #[Assert\GreaterThanOrEqual(
+                value:
+                18
+                ,
+                )]
+                private
+                
+                $ property10
+                ;
+                #[ORM\CustomIdGenerator(
+                class:
+                'doctrine.uuid_generator'
+                )]
+                private
+                
+                $ property11
+                ;
+}
+
+
diff --git a/tests/fixtures/source/blocks/code-blocks/php-attributes.rst b/tests/fixtures/source/blocks/code-blocks/php-attributes.rst new file mode 100644 index 0000000..d43f8ad --- /dev/null +++ b/tests/fixtures/source/blocks/code-blocks/php-attributes.rst @@ -0,0 +1,51 @@ +.. code-block:: php-attributes + + // src/SomePath/SomeClass.php + namespace App\SomePath; + + use Symfony\Component\Validator\Constraints as Assert; + + class SomeClass + { + #[AttributeName] + private $property1; + + #[AttributeName()] + private $property2; + + #[AttributeName('value')] + private $property3; + + #[AttributeName('value', option: 'value')] + private $property4; + + #[AttributeName(['value' => 'value'])] + private $property5; + + #[AttributeName( + 'value', + option: 'value' + )] + private $property6; + + #[Assert\AttributeName('value')] + private $property7; + + #[Assert\AttributeName( + 'value', + option: 'value' + )] + private $property8; + + #[Route('/blog/{page<\d+>}', name: 'blog_list')] + private $property9; + + #[Assert\GreaterThanOrEqual( + value: 18, + )] + private $property10; + + #[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')] + private $property11; + } +