From 16bbbc2ddfc7b1f97b0aed45354b30cffabc6c88 Mon Sep 17 00:00:00 2001 From: Nyholm Date: Fri, 16 Apr 2021 19:05:13 +0200 Subject: [PATCH 1/5] Validate code blocks --- composer.json | 3 +- composer.lock | 144 ++++++++++++++++++++++- src/Command/BuildDocsCommand.php | 7 +- src/DocsKernel.php | 5 + src/Listener/ValidCodeNodeListener.php | 153 +++++++++++++++++++++++++ 5 files changed, 307 insertions(+), 5 deletions(-) create mode 100644 src/Listener/ValidCodeNodeListener.php diff --git a/composer.json b/composer.json index 98314db..6896603 100644 --- a/composer.json +++ b/composer.json @@ -24,7 +24,8 @@ "symfony/css-selector": "^5.2", "symfony/console": "^5.2", "twig/twig": "^2.14 || ^3.3", - "symfony/http-client": "^5.2" + "symfony/http-client": "^5.2", + "symfony/yaml": "^5.2" }, "require-dev": { "gajus/dindent": "^2.0", diff --git a/composer.lock b/composer.lock index 5e1a162..1a985fc 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2739ab0cffd3ad3caba1283d0b2e09e2", + "content-hash": "c36d501ec8535171300bfa3773f593bd", "packages": [ { "name": "doctrine/event-manager", @@ -506,6 +506,73 @@ ], "time": "2021-01-27T10:01:46+00:00" }, + { + "name": "symfony/deprecation-contracts", + "version": "v2.2.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "5fa56b4074d1ae755beb55617ddafe6f5d78f665" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/5fa56b4074d1ae755beb55617ddafe6f5d78f665", + "reference": "5fa56b4074d1ae755beb55617ddafe6f5d78f665", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.2-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/master" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2020-09-07T11:33:47+00:00" + }, { "name": "symfony/dom-crawler", "version": "v5.2.4", @@ -1516,6 +1583,81 @@ ], "time": "2021-02-16T10:20:28+00:00" }, + { + "name": "symfony/yaml", + "version": "v5.2.5", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "298a08ddda623485208506fcee08817807a251dd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/298a08ddda623485208506fcee08817807a251dd", + "reference": "298a08ddda623485208506fcee08817807a251dd", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1", + "symfony/polyfill-ctype": "~1.8" + }, + "conflict": { + "symfony/console": "<4.4" + }, + "require-dev": { + "symfony/console": "^4.4|^5.0" + }, + "suggest": { + "symfony/console": "For validating YAML files using the lint command" + }, + "bin": [ + "Resources/bin/yaml-lint" + ], + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Yaml\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Loads and dumps YAML files", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/yaml/tree/v5.2.5" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-03-06T07:59:01+00:00" + }, { "name": "twig/twig", "version": "v3.3.0", diff --git a/src/Command/BuildDocsCommand.php b/src/Command/BuildDocsCommand.php index 8b7b7a6..e68ee15 100644 --- a/src/Command/BuildDocsCommand.php +++ b/src/Command/BuildDocsCommand.php @@ -153,8 +153,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->warning($message); } + $errorCount = \count($buildErrors); if ($logPath = $input->getOption('save-errors')) { - if (\count($buildErrors) > 0) { + if ($errorCount > 0) { array_unshift($buildErrors, sprintf('Build errors from "%s"', date('Y-m-d h:i:s'))); } @@ -171,8 +172,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->io->newLine(2); - if (\count($buildErrors) > 0) { - $this->io->success('Build completed with warnings'); + if ($errorCount > 0) { + $this->io->success(sprintf('Build completed with %s errors', $errorCount)); if ($input->getOption('fail-on-errors')) { return 1; diff --git a/src/DocsKernel.php b/src/DocsKernel.php index f98b032..d1baa64 100644 --- a/src/DocsKernel.php +++ b/src/DocsKernel.php @@ -18,6 +18,7 @@ use Doctrine\RST\Kernel; use SymfonyDocsBuilder\Listener\AssetsCopyListener; use SymfonyDocsBuilder\Listener\CopyImagesListener; +use SymfonyDocsBuilder\Listener\ValidCodeNodeListener; class DocsKernel extends Kernel { @@ -46,6 +47,10 @@ private function initializeListeners(EventManager $eventManager, ErrorManager $e PreNodeRenderEvent::PRE_NODE_RENDER, new CopyImagesListener($this->buildConfig, $errorManager) ); + $eventManager->addEventListener( + PreNodeRenderEvent::PRE_NODE_RENDER, + new ValidCodeNodeListener($errorManager) + ); if (!$this->buildConfig->getSubdirectoryToBuild()) { $eventManager->addEventListener( diff --git a/src/Listener/ValidCodeNodeListener.php b/src/Listener/ValidCodeNodeListener.php new file mode 100644 index 0000000..26278f1 --- /dev/null +++ b/src/Listener/ValidCodeNodeListener.php @@ -0,0 +1,153 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace SymfonyDocsBuilder\Listener; + +use Doctrine\RST\ErrorManager; +use Doctrine\RST\Event\PreNodeRenderEvent; +use Doctrine\RST\Nodes\CodeNode; +use Symfony\Component\Process\Process; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Yaml; +use SymfonyDocsBuilder\BuildConfig; +use Twig\Environment; +use Twig\Error\SyntaxError; +use Twig\Loader\ArrayLoader; +use Twig\Source; + +/** + * Verify that all code nodes has the correct syntax. + * + * @author Tobias Nyholm + */ +class ValidCodeNodeListener +{ + + private $errorManager; + private $twig; + + public function __construct(ErrorManager $errorManager) + { + + $this->errorManager = $errorManager; + } + + public function preNodeRender(PreNodeRenderEvent $event) + { + $node = $event->getNode(); + if (!$node instanceof CodeNode) { + return; + } + + $language = $node->getLanguage() ?? 'php'; + if (in_array($language, ['php', 'php-symfony', 'php-standalone', 'php-annotations'])) { + $this->validatePhp($node); + } elseif ('yaml' === $language) { + $this->validateYaml($node); + } elseif ('xml' === $language) { + $this->validateXml($node); + } elseif ('json' === $language) { + $this->validateJson($node); + } elseif (in_array($language, ['twig', 'html+twig'])) { + $this->validateTwig($node); + } + } + + private function validatePhp(CodeNode $node) + { + $file = sys_get_temp_dir().'/'.uniqid('doc_builder', true).'.php'; + $contents = $node->getValue(); + if (!preg_match('#class [a-zA-Z]+#s', $contents) && preg_match('#(public|protected|private) (\$[a-z]+|function)#s', $contents)) { + $contents = 'class Foobar {'.$contents.'}'; + } + + file_put_contents($file, 'run(); + $process->wait(); + if ($process->isSuccessful()) { + return; + } + + $this->errorManager->error(sprintf( + 'Syntax error in PHP example in "%s"', + $node->getEnvironment()->getCurrentFileName() + )); + } + + private function validateXml(CodeNode $node) + { + try { + set_error_handler(static function ($errno, $errstr) { + throw new \RuntimeException($errstr, $errno); + }); + + try { + $xml = new \SimpleXMLElement(str_replace('', '', $node->getValue())); + } finally { + restore_error_handler(); + } + } catch (\Throwable $e) { + $this->errorManager->error(sprintf( + 'Invalid Xml in "%s": "%s"', + $node->getEnvironment()->getCurrentFileName(), + $e->getMessage() + )); + } + } + + private function validateYaml(CodeNode $node) + { + try { + Yaml::parse($node->getValue(), Yaml::PARSE_CUSTOM_TAGS); + } catch (ParseException $e) { + if ('Duplicate key' === substr($e->getMessage(), 0, 13)) { + return; + } + + $this->errorManager->error(sprintf( + 'Invalid Yaml in "%s": "%s"', + $node->getEnvironment()->getCurrentFileName(), + $e->getMessage() + )); + } + } + + private function validateTwig(CodeNode $node) + { + $twig = $this->twig ?? new Environment(new ArrayLoader()); + + if ($node->getLanguage() === 'html+twig') { + $x = 2; + } + + try { + $twig->tokenize(new Source($node->getValue(), $node->getEnvironment()->getCurrentFileName())); + } catch (SyntaxError $e) { + $this->errorManager->error(sprintf( + 'Invalid Twig syntax: "%s"', + $e->getMessage() + )); + } + } + + private function validateJson(CodeNode $node) + { + $data = json_decode($node->getValue(), true); + if (null === $data) { + $this->errorManager->error(sprintf( + 'Invalid Json in "%s"', + $node->getEnvironment()->getCurrentFileName() + )); + } + } +} From 4f73ef352e323d843df3a13f0e2aed8e98e0ff6e Mon Sep 17 00:00:00 2001 From: Nyholm Date: Fri, 16 Apr 2021 19:18:13 +0200 Subject: [PATCH 2/5] CS --- src/Listener/ValidCodeNodeListener.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Listener/ValidCodeNodeListener.php b/src/Listener/ValidCodeNodeListener.php index 26278f1..2431225 100644 --- a/src/Listener/ValidCodeNodeListener.php +++ b/src/Listener/ValidCodeNodeListener.php @@ -126,12 +126,10 @@ private function validateTwig(CodeNode $node) { $twig = $this->twig ?? new Environment(new ArrayLoader()); - if ($node->getLanguage() === 'html+twig') { - $x = 2; - } - try { - $twig->tokenize(new Source($node->getValue(), $node->getEnvironment()->getCurrentFileName())); + $tokens = $twig->tokenize(new Source($node->getValue(), $node->getEnvironment()->getCurrentFileName())); + // We cannot parse the TokenStream because we dont have all extensions loaded. + // $twig->parse($tokens); } catch (SyntaxError $e) { $this->errorManager->error(sprintf( 'Invalid Twig syntax: "%s"', From 42f966b3e5cf64ff498b3ec06b7dc7f3828ab94f Mon Sep 17 00:00:00 2001 From: Nyholm Date: Fri, 16 Apr 2021 19:35:58 +0200 Subject: [PATCH 3/5] Remove first comment --- src/Listener/ValidCodeNodeListener.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Listener/ValidCodeNodeListener.php b/src/Listener/ValidCodeNodeListener.php index 2431225..5f39c75 100644 --- a/src/Listener/ValidCodeNodeListener.php +++ b/src/Listener/ValidCodeNodeListener.php @@ -17,7 +17,6 @@ use Symfony\Component\Process\Process; use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml; -use SymfonyDocsBuilder\BuildConfig; use Twig\Environment; use Twig\Error\SyntaxError; use Twig\Loader\ArrayLoader; @@ -30,7 +29,6 @@ */ class ValidCodeNodeListener { - private $errorManager; private $twig; @@ -92,11 +90,16 @@ private function validateXml(CodeNode $node) }); try { - $xml = new \SimpleXMLElement(str_replace('', '', $node->getValue())); + // Remove first comment only. (No multiline) + $xml = preg_replace('#^\n#', '', $node->getValue()); + $xmlObject = new \SimpleXMLElement($xml); } finally { restore_error_handler(); } } catch (\Throwable $e) { + if ('SimpleXMLElement::__construct(): namespace error : Namespace prefix' === substr($e->getMessage(), 0, 67)) { + return; + } $this->errorManager->error(sprintf( 'Invalid Xml in "%s": "%s"', $node->getEnvironment()->getCurrentFileName(), From dacca0e6b15f24629b6b2037622adae076e81d59 Mon Sep 17 00:00:00 2001 From: Nyholm Date: Fri, 16 Apr 2021 20:25:57 +0200 Subject: [PATCH 4/5] minor fixes --- src/Listener/ValidCodeNodeListener.php | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Listener/ValidCodeNodeListener.php b/src/Listener/ValidCodeNodeListener.php index 5f39c75..fc54e9b 100644 --- a/src/Listener/ValidCodeNodeListener.php +++ b/src/Listener/ValidCodeNodeListener.php @@ -45,7 +45,7 @@ public function preNodeRender(PreNodeRenderEvent $event) return; } - $language = $node->getLanguage() ?? 'php'; + $language = $node->getLanguage() ?? ($node->isRaw() ? null : 'php'); if (in_array($language, ['php', 'php-symfony', 'php-standalone', 'php-annotations'])) { $this->validatePhp($node); } elseif ('yaml' === $language) { @@ -67,6 +67,9 @@ private function validatePhp(CodeNode $node) $contents = 'class Foobar {'.$contents.'}'; } + // Allow us to use "..." as a placeholder + $contents = str_replace('...', 'null', $contents); + file_put_contents($file, '\n#', '', $node->getValue()); - $xmlObject = new \SimpleXMLElement($xml); + if ('' !== $xml) { + $xmlObject = new \SimpleXMLElement($xml); + } } finally { restore_error_handler(); } From 854be0e647335e2ef3ce37f93469ca8510df8700 Mon Sep 17 00:00:00 2001 From: Nyholm Date: Fri, 16 Apr 2021 20:51:28 +0200 Subject: [PATCH 5/5] Print PHP errors --- src/Listener/ValidCodeNodeListener.php | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/Listener/ValidCodeNodeListener.php b/src/Listener/ValidCodeNodeListener.php index fc54e9b..a4157b7 100644 --- a/src/Listener/ValidCodeNodeListener.php +++ b/src/Listener/ValidCodeNodeListener.php @@ -34,7 +34,6 @@ class ValidCodeNodeListener public function __construct(ErrorManager $errorManager) { - $this->errorManager = $errorManager; } @@ -80,8 +79,9 @@ private function validatePhp(CodeNode $node) } $this->errorManager->error(sprintf( - 'Syntax error in PHP example in "%s"', - $node->getEnvironment()->getCurrentFileName() + 'Invalid PHP syntax in "%s": %s', + $node->getEnvironment()->getCurrentFileName(), + str_replace($file, 'example', $process->getErrorOutput()) )); } @@ -106,7 +106,7 @@ private function validateXml(CodeNode $node) return; } $this->errorManager->error(sprintf( - 'Invalid Xml in "%s": "%s"', + 'Invalid Xml in "%s": %s', $node->getEnvironment()->getCurrentFileName(), $e->getMessage() )); @@ -115,15 +115,17 @@ private function validateXml(CodeNode $node) private function validateYaml(CodeNode $node) { + // Allow us to use "..." as a placeholder + $contents = str_replace('...', 'null', $node->getValue()); try { - Yaml::parse($node->getValue(), Yaml::PARSE_CUSTOM_TAGS); + Yaml::parse($contents, Yaml::PARSE_CUSTOM_TAGS); } catch (ParseException $e) { if ('Duplicate key' === substr($e->getMessage(), 0, 13)) { return; } $this->errorManager->error(sprintf( - 'Invalid Yaml in "%s": "%s"', + 'Invalid Yaml in "%s": %s', $node->getEnvironment()->getCurrentFileName(), $e->getMessage() )); @@ -140,7 +142,7 @@ private function validateTwig(CodeNode $node) // $twig->parse($tokens); } catch (SyntaxError $e) { $this->errorManager->error(sprintf( - 'Invalid Twig syntax: "%s"', + 'Invalid Twig syntax: %s', $e->getMessage() )); }