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..a4157b7 --- /dev/null +++ b/src/Listener/ValidCodeNodeListener.php @@ -0,0 +1,161 @@ + + * 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 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() ?? ($node->isRaw() ? null : '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.'}'; + } + + // Allow us to use "..." as a placeholder + $contents = str_replace('...', 'null', $contents); + + file_put_contents($file, 'run(); + $process->wait(); + if ($process->isSuccessful()) { + return; + } + + $this->errorManager->error(sprintf( + 'Invalid PHP syntax in "%s": %s', + $node->getEnvironment()->getCurrentFileName(), + str_replace($file, 'example', $process->getErrorOutput()) + )); + } + + private function validateXml(CodeNode $node) + { + try { + set_error_handler(static function ($errno, $errstr) { + throw new \RuntimeException($errstr, $errno); + }); + + try { + // Remove first comment only. (No multiline) + $xml = preg_replace('#^\n#', '', $node->getValue()); + if ('' !== $xml) { + $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(), + $e->getMessage() + )); + } + } + + private function validateYaml(CodeNode $node) + { + // Allow us to use "..." as a placeholder + $contents = str_replace('...', 'null', $node->getValue()); + try { + 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', + $node->getEnvironment()->getCurrentFileName(), + $e->getMessage() + )); + } + } + + private function validateTwig(CodeNode $node) + { + $twig = $this->twig ?? new Environment(new ArrayLoader()); + + try { + $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', + $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() + )); + } + } +}