diff --git a/README.md b/README.md index a6b83dc..e4be52e 100644 --- a/README.md +++ b/README.md @@ -191,13 +191,7 @@ This is a list of regex patterns that will filter files to validate. With this o ### PhpMd Extended -Extends the default PhpMd task and splits the files into smaller chunks to prevent the `Argument list too long` error. - -***Composer*** - -``` -composer require --dev phpmd/phpmd -``` +Extends the default [PhpMd task](vendor/phpro/grumphp/doc/tasks/phpmd.md) and splits the files into smaller chunks to prevent the `Argument list too long` error. ***Config*** @@ -216,48 +210,60 @@ grumphp: chunks_size: 1000 ``` -**whitelist_patterns** - -*Default: []* - -This is a list of regex patterns that will filter files to validate. With this option you can skip files like tests. This option is used in relation with the parameter `triggered_by`. -For example: whitelist files in `src/FolderA/` and `src/FolderB/` you can use -```yaml -whitelist_patterns: - - /^src\/FolderA\/(.*)/ - - /^src\/FolderB\/(.*)/ -``` - -**exclude** +**chunk_size** -*Default: []* +*Default: 1000* -This is a list of patterns that will be ignored by phpmd. With this option you can skip directories like tests. Leave this option blank to run phpmd for every php file. +This parameter defines how many files will be checked in one execution of phpmd. This can help with performance on large codebases. -**report_format** +### PHPStan Extended -*Default: text* +Extends the default [PHPStan task](vendor/phpro/grumphp/doc/tasks/phpstan.md) and splits the files into smaller chunks to prevent the `Argument list too long` error. -This sets the output [renderer](https://phpmd.org/documentation/#renderers) of phpmd. -Available formats: ansi, text. +***Config*** -**ruleset** +The task lives under the `phpstan_extended` namespace and has following configurable parameters: -*Default: [cleancode,codesize,naming]* +```yaml +# grumphp.yml +grumphp: + tasks: + phpstan_extended: + autoload_file: ~ + chunk_size: 1000 + configuration: ~ + level: null + force_patterns: [] + ignore_patterns: [] + triggered_by: ['php'] + memory_limit: "-1" + use_grumphp_paths: true +``` -With this parameter you will be able to configure the rule/rulesets you want to use. You can use the standard -sets provided by PhpMd or you can configure your own xml configuration as described in the [PhpMd Documentation](https://phpmd.org/documentation/creating-a-ruleset.html) +**chunk_size** -The full list of rules/rulesets can be found at [PhpMd Rules](https://phpmd.org/rules/index.html) +*Default: 1000* -**triggered_by** +This parameter defines how many files will be checked in one execution of phpstan. This can help with performance on large codebases. -*Default: [php]* +### XmlLint Extended -This is a list of extensions to be sniffed. +Extends the default [XmlLint task](vendor/phpro/grumphp/doc/tasks/xmllint.md) with strict schema validation. +Require `ext-dom` and `ext-libxml` extensions. -**chunk_size** +***Config*** -*Default: 1000* +It lives under the `xmllint_extended` namespace and has following configurable parameters: -This parameter defines how many files will be checked in one execution of phpmd. This can help with performance on large codebases. +```yaml +# grumphp.yml +grumphp: + tasks: + xmllint_extended: + ignore_patterns: [] + load_from_net: false + x_include: false + dtd_validation: false + scheme_validation: false + triggered_by: ['xml'] +``` diff --git a/composer.json b/composer.json index 04c91c3..615fdb6 100644 --- a/composer.json +++ b/composer.json @@ -16,6 +16,8 @@ "squizlabs/php_codesniffer": "^4.0" }, "require-dev": { + "ext-dom": "*", + "ext-libxml": "*", "ergebnis/composer-normalize": "^2.45", "friendsofphp/php-cs-fixer": "^3.89", "nikic/php-parser": "^5.6", diff --git a/config/grumphp.yaml b/config/grumphp.yaml index 6c704ed..48865d6 100644 --- a/config/grumphp.yaml +++ b/config/grumphp.yaml @@ -22,3 +22,21 @@ services: - "@formatter.raw_process" tags: - { name: grumphp.task, task: phpmd_extended } + + PixelFederation\CodingStandards\GrumPHP\Task\PhpStanExtendedTask: + class: PixelFederation\CodingStandards\GrumPHP\Task\PhpStanExtendedTask + arguments: + - "@process_builder" + - "@formatter.raw_process" + tags: + - { name: grumphp.task, task: phpstan_extended } + + PixelFederation\CodingStandards\GrumPHP\Linter\Xml\XmlLinter: + class: PixelFederation\CodingStandards\GrumPHP\Linter\Xml\XmlLinter + + PixelFederation\CodingStandards\GrumPHP\Task\XmlLintExtendedTask: + class: PixelFederation\CodingStandards\GrumPHP\Task\XmlLintExtendedTask + arguments: + - '@PixelFederation\CodingStandards\GrumPHP\Linter\Xml\XmlLinter' + tags: + - {name: grumphp.task, task: xmllint_extended} diff --git a/grumphp.yml b/grumphp.yml index 18bbdc4..34f0054 100644 --- a/grumphp.yml +++ b/grumphp.yml @@ -62,7 +62,8 @@ grumphp: blacklist: - 'Dumper::dump' triggered_by: [ 'php' ] - phpstan: + phpstan_extended: + chunk_size: 500 autoload_file: ~ configuration: 'phpstan.dist.neon' level: max @@ -71,7 +72,7 @@ grumphp: 'tests/', ] triggered_by: [ 'php' ] - xmllint: + xmllint_extended: ignore_patterns: [ ] load_from_net: true x_include: true diff --git a/src/GrumPHP/Linter/Xml/XmlLinter.php b/src/GrumPHP/Linter/Xml/XmlLinter.php new file mode 100644 index 0000000..8020683 --- /dev/null +++ b/src/GrumPHP/Linter/Xml/XmlLinter.php @@ -0,0 +1,369 @@ +useInternalXmlLogging(true); + $this->flushXmlErrors(); + + $document = $this->loadDocument($file); + if (!$document) { + $this->collectXmlErrors($errors, null); + $this->useInternalXmlLogging($useInternalErrors); + + return $errors; + } + + if ($this->xInclude && $document->xinclude() === -1) { + $this->collectXmlErrors($errors, $document); + } + + if ($this->dtdValidation && !$this->validateDTD($document)) { + $this->collectXmlErrors($errors, $document); + } + + $this->checkInternalSchemes($file, $document, $errors); + + $this->useInternalXmlLogging($useInternalErrors); + + return $errors; + } + + #[Override] + public function isInstalled(): bool + { + $extensions = get_loaded_extensions(); + + return in_array('libxml', $extensions, true) && \in_array('dom', $extensions, true); + } + + public function setLoadFromNet(bool $loadFromNet): void + { + $this->loadFromNet = $loadFromNet; + } + + public function setXInclude(bool $xInclude): void + { + $this->xInclude = $xInclude; + } + + public function setDtdValidation(bool $dtdValidation): void + { + $this->dtdValidation = $dtdValidation; + } + + public function setSchemeValidation(bool $schemeValidation): void + { + $this->schemeValidation = $schemeValidation; + } + + private function checkInternalSchemes( + SplFileInfo $file, + DOMDocument $document, + LintErrorsCollection $errors, + ): void { + if (!$this->schemeValidation) { + return; + } + $result = $this->validateInternalSchemes($file, $document, $errors); + if ($result === true) { + return; + } + + $this->collectXmlErrors($errors, $document); + } + + private function useInternalXmlLogging(bool $useInternalErrors): bool + { + return libxml_use_internal_errors($useInternalErrors); + } + + private function loadDocument(SplFileInfo $file): ?DOMDocument + { + $this->registerXmlStreamContext(); + + $document = new DOMDocument(); + $document->resolveExternals = $this->loadFromNet; + $document->preserveWhiteSpace = false; + $document->formatOutput = false; + $loaded = $document->load($file->getPathname()); + + return $loaded ? $document : null; + } + + /** + * This is added to fix a bug with remote DTDs that are blocking automated php request on some domains:. + * + * @see http://stackoverflow.com/questions/4062792/domdocumentvalidate-problem + * @see https://bugs.php.net/bug.php?id=48080 + */ + private function registerXmlStreamContext(): void + { + libxml_set_streams_context(stream_context_create([ + 'http' => [ + 'user_agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:43.0) Gecko/20100101 Firefox/43.0', + ], + ])); + } + + private function collectXmlErrors(LintErrorsCollection $errors, ?DOMDocument $document): void + { + foreach (libxml_get_errors() as $error) { + $this->addError( + $errors, + $this->getErrorType($error), + $error->message, + trim($error->file) !== '' ? $error->file : $document->documentURI ?? 'unknown', + $error->code, + $error->line, + $error->column, + ); + } + $this->flushXmlErrors(); + } + + private function addError( + LintErrorsCollection $errors, + string $type, + string $error, + string $file, + int $code = 0, + int $line = 0, + int $column = 0, + ): void { + $lintError = new XmlLintError( + $type, + $code, + $error, + $file, + $line, + $column, + ); + $errors->add($lintError); + } + + private function getErrorType(LibXMLError $error): string + { + return match ($error->level) { + LIBXML_ERR_WARNING => LintError::TYPE_WARNING, + LIBXML_ERR_FATAL => LintError::TYPE_FATAL, + LIBXML_ERR_ERROR => LintError::TYPE_ERROR, + default => LintError::TYPE_NONE, + }; + } + + /** + * Make sure the libxml errors are flushed and won't be occurring again. + */ + private function flushXmlErrors(): void + { + libxml_clear_errors(); + } + + private function validateDTD(DOMDocument $document): bool + { + /** @psalm-suppress TypeDoesNotContainNull */ + if (null === $document->doctype) { + return true; + } + + // Do not validate external DTDs if the loadFromNet option is disabled: + $systemId = $document->doctype->systemId; + if (!$this->loadFromNet && filter_var($systemId, FILTER_VALIDATE_URL)) { + return true; + } + + return $document->validate(); + } + + private function validateInternalSchemes( + SplFileInfo $file, + DOMDocument $document, + LintErrorsCollection $errors, + ): bool { + $schemas = $this->getSchemas($file, $document, $errors); + if ($schemas === []) { + return true; + } + + $schemas = array_map( + fn (string $scheme): ?string => $this->locateScheme($file, $scheme, $this->loadFromNet), + $schemas, + ); + $schemas = array_filter($schemas); + if ($schemas === []) { + $this->addError( + $errors, + LintError::TYPE_FATAL, + 'missing schemas to validate against', + $file->getPathname(), + ); + + return false; + } + + $isValid = true; + foreach ($schemas as $scheme) { + $isValid = $isValid && $document->schemaValidate($scheme); + } + + return $isValid; + } + + /** + * @return array + */ + private function getSchemas( + SplFileInfo $file, + DOMDocument $document, + LintErrorsCollection $errors, + ): array { + $schemas = []; + $schemas = $this->addSchemasFromSchemaLocation( + $file, + $document, + $errors, + $schemas, + ); + + $schemaLocNoNamespace = $document->documentElement?->attributes->getNamedItemNS( + self::XSI_NAMESPACE, + 'noNamespaceSchemaLocation', + ); + if ($schemaLocNoNamespace !== null) { + /** + * @var array $withoutNamespace + * @phpstan-ignore varTag.nativeType + */ + $withoutNamespace = preg_split('/\s+/', trim($schemaLocNoNamespace->textContent)); + $schemas = array_merge($schemas, $withoutNamespace); + } + + return $schemas; + } + + /** + * @param array $schemas + * @return array + */ + private function addSchemasFromSchemaLocation( + SplFileInfo $file, + DOMDocument $document, + LintErrorsCollection $errors, + array $schemas, + ): array { + $schemaLocation = $document->documentElement?->attributes->getNamedItem('schemaLocation'); + if ($schemaLocation === null) { + return $schemas; + } + + if ($schemaLocation->namespaceURI !== self::XSI_NAMESPACE) { + $this->addError( + $errors, + LintError::TYPE_FATAL, + 'schemaLocation attribute is not in the XML Schema Instance namespace', + $file->getPathname(), + ); + + return $schemas; + } + + /** @var array $parts */ + $parts = preg_split('/\s+/', trim($schemaLocation->textContent)); // @phpstan-ignore varTag.nativeType + if (count($parts) % 2 !== 0) { + $this->addError( + $errors, + LintError::TYPE_FATAL, + 'schemaLocation must contain an even number of URI entries', + $file->getPathname(), + ); + + return $schemas; + } + + foreach ($parts as $key => $value) { + $schemas = $this->addSchemasFromSchemaLocationPart( + $file, + $document, + $errors, + $schemas, + $key, + $value, + ); + } + + return $schemas; + } + + /** + * @param array $schemas + * @return array + */ + private function addSchemasFromSchemaLocationPart( + SplFileInfo $file, + DOMDocument $document, + LintErrorsCollection $errors, + array $schemas, + int $key, + string $value, + ): array { + if ($key & 1) { + $schemas[] = $value; + + return $schemas; + } + + if ($value !== $document->documentElement?->namespaceURI) { + $this->addError( + $errors, + LintError::TYPE_FATAL, + sprintf( + 'Namespace "%s" from schemaLocation is not declared in the document', + $value, + ), + $file->getPathname(), + ); + } + + return $schemas; + } + + private function locateScheme(SplFileInfo $xmlFile, string $scheme, bool $loadFromNet): ?string + { + if (filter_var($scheme, FILTER_VALIDATE_URL)) { + return $loadFromNet ? $scheme : null; + } + + $xmlFilePath = $xmlFile->getPath(); + $schemePath = $xmlFilePath === '' ? $scheme : rtrim($xmlFilePath, '/') . DIRECTORY_SEPARATOR . $scheme; + $schemeFile = new SplFileInfo($schemePath); + + return $schemeFile->isReadable() ? $schemeFile->getPathname() : null; + } +} diff --git a/src/GrumPHP/Task/PhpMdExtendedTask.php b/src/GrumPHP/Task/PhpMdExtendedTask.php index a94e358..5cb2b9e 100644 --- a/src/GrumPHP/Task/PhpMdExtendedTask.php +++ b/src/GrumPHP/Task/PhpMdExtendedTask.php @@ -18,8 +18,9 @@ use Symfony\Component\Process\Process; /** + * @see \GrumPHP\Task\PhpMd * @psalm-type ConfigType = array{ - * chunk_size: int, + * chunk_size: positive-int, * exclude: array, * report_format: string, * ruleset: array, @@ -49,7 +50,12 @@ public static function getConfigurableOptions(): ConfigOptionsResolver $resolver->addAllowedValues('report_format', ['text', 'ansi']); $resolver->addAllowedTypes('ruleset', ['array']); $resolver->addAllowedTypes('triggered_by', ['array']); - $resolver->addAllowedTypes('chunk_size', ['int']); + $resolver->setAllowedValues( + 'chunk_size', + static function (mixed $value): bool { + return false !== filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + }, + ); return ConfigOptionsResolver::fromOptionsResolver($resolver); } @@ -72,14 +78,6 @@ public function run(ContextInterface $context): TaskResultInterface } $chunkSize = $config['chunk_size']; - if ($chunkSize <= 0) { - return TaskResult::createFailed( - $this, - $context, - 'The chunk_size configuration must be a positive integer.', - ); - } - $chunks = array_chunk($files->toArray(), $chunkSize); $totalChunks = count($chunks); foreach ($chunks as $index => $chunk) { diff --git a/src/GrumPHP/Task/PhpStanExtendedTask.php b/src/GrumPHP/Task/PhpStanExtendedTask.php new file mode 100644 index 0000000..05f0817 --- /dev/null +++ b/src/GrumPHP/Task/PhpStanExtendedTask.php @@ -0,0 +1,181 @@ +, + * force_patterns: array, + * triggered_by: array, + * memory_limit: string|null, + * use_grumphp_paths: bool, + * } + * @extends AbstractExternalTask + */ +final class PhpStanExtendedTask extends AbstractExternalTask +{ + #[Override] + public static function getConfigurableOptions(): ConfigOptionsResolver + { + $resolver = new OptionsResolver(); + $resolver->setDefaults([ + 'autoload_file' => null, + 'chunk_size' => 1000, + 'configuration' => null, + 'force_patterns' => [], + 'ignore_patterns' => [], + 'level' => null, + 'memory_limit' => null, + 'triggered_by' => ['php'], + 'use_grumphp_paths' => true, + ]); + + $resolver->addAllowedTypes('autoload_file', ['null', 'string']); + $resolver->setAllowedValues( + 'chunk_size', + static function (mixed $value): bool { + return false !== filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]); + }, + ); + $resolver->addAllowedTypes('configuration', ['null', 'string']); + $resolver->addAllowedTypes('memory_limit', ['null', 'string']); + $resolver->setAllowedValues( + 'level', + static function (mixed $value): bool { + if ($value === null || $value === 'max') { + return true; + } + + return false !== filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]]); + }, + ); + $resolver->addAllowedTypes('ignore_patterns', ['array']); + $resolver->addAllowedTypes('force_patterns', ['array']); + $resolver->addAllowedTypes('triggered_by', ['array']); + $resolver->addAllowedTypes('use_grumphp_paths', ['bool']); + + return ConfigOptionsResolver::fromOptionsResolver($resolver); + } + + #[Override] + public function canRunInContext(ContextInterface $context): bool + { + return $context instanceof GitPreCommitContext || $context instanceof RunContext; + } + + #[Override] + public function run(ContextInterface $context): TaskResultInterface + { + // phpcs:ignore SlevomatCodingStandard.PHP.RequireExplicitAssertion.RequiredExplicitAssertion + /** @var ConfigType $config */ + $config = $this->getConfig()->getOptions(); + $files = $this->getFiles($context, $config); + + if (!$config['use_grumphp_paths']) { + return $this->processWithoutGrumphpPaths($context, $config); + } + + if (count($files) === 0) { + return TaskResult::createSkipped($this, $context); + } + + $chunkSize = $config['chunk_size']; + $chunks = array_chunk($files->toArray(), $chunkSize); + $totalChunks = count($chunks); + foreach ($chunks as $index => $chunk) { + $arguments = $this->createArguments(new FilesCollection($chunk), $config); + $process = $this->processBuilder->buildProcess($arguments); + $process->run(); + + if (!$process->isSuccessful()) { + $message = sprintf( + 'Chunk %d/%d failed:%s%s', + $index + 1, + $totalChunks, + PHP_EOL, + $this->formatter->format($process), + ); + + return TaskResult::createFailed($this, $context, $message); + } + } + + return TaskResult::createPassed($this, $context); + } + + /** + * @param ConfigType $config + */ + private function processWithoutGrumphpPaths(ContextInterface $context, array $config): TaskResultInterface + { + $arguments = $this->createArguments(null, $config); + $process = $this->processBuilder->buildProcess($arguments); + $process->run(); + if (!$process->isSuccessful()) { + return TaskResult::createFailed($this, $context, $this->formatter->format($process)); + } + + return TaskResult::createPassed($this, $context); + } + + /** + * @param ConfigType $config + */ + private function getFiles(ContextInterface $context, array $config): FilesCollection + { + $files = $context + ->getFiles() + ->notPaths($config['ignore_patterns']) + ->extensions($config['triggered_by']); + + if ($config['force_patterns'] !== []) { + $forcedFiles = $context->getFiles()->paths($config['force_patterns']); + $files = $files->ensureFiles($forcedFiles); + } + + return $files; + } + + /** + * @param ConfigType $config + */ + private function createArguments(?FilesCollection $files, array $config): ProcessArgumentsCollection + { + $arguments = $this->processBuilder->createArgumentsForCommand('phpstan'); + + $arguments->add('analyse'); + $arguments->addOptionalArgument('--autoload-file=%s', $config['autoload_file']); + $arguments->addOptionalArgument('--configuration=%s', $config['configuration']); + $arguments->addOptionalArgument('--memory-limit=%s', $config['memory_limit']); + $arguments->addOptionalMixedArgument('--level=%s', $config['level']); + $arguments->add('--no-ansi'); + $arguments->add('--no-interaction'); + $arguments->add('--no-progress'); + + if ($files) { + $arguments->addFiles($files); + } + + return $arguments; + } +} diff --git a/src/GrumPHP/Task/XmlLintExtendedTask.php b/src/GrumPHP/Task/XmlLintExtendedTask.php new file mode 100644 index 0000000..075f6f0 --- /dev/null +++ b/src/GrumPHP/Task/XmlLintExtendedTask.php @@ -0,0 +1,90 @@ + + */ +final class XmlLintExtendedTask extends AbstractLinterTask +{ + #[Override] + public static function getConfigurableOptions(): ConfigOptionsResolver + { + $resolver = self::sharedOptionsResolver(); + $resolver->setDefaults([ + 'dtd_validation' => false, + 'load_from_net' => false, + 'scheme_validation' => false, + 'triggered_by' => ['xml'], + 'x_include' => false, + ]); + + $resolver->addAllowedTypes('load_from_net', ['bool']); + $resolver->addAllowedTypes('x_include', ['bool']); + $resolver->addAllowedTypes('dtd_validation', ['bool']); + $resolver->addAllowedTypes('scheme_validation', ['bool']); + $resolver->addAllowedTypes('triggered_by', ['array']); + + return ConfigOptionsResolver::fromOptionsResolver($resolver); + } + + #[Override] + public function canRunInContext(ContextInterface $context): bool + { + return $context instanceof GitPreCommitContext || $context instanceof RunContext; + } + + #[Override] + public function run(ContextInterface $context): TaskResultInterface + { + /** + * @var array{ + * dtd_validation: bool, + * load_from_net: bool, + * scheme_validation: bool, + * triggered_by: array, + * x_include: bool, + * } $config + */ + $config = $this->getConfig()->getOptions(); + $files = $context->getFiles()->extensions($config['triggered_by']); + if (count($files) === 0) { + return TaskResult::createSkipped($this, $context); + } + + $this->linter->setLoadFromNet($config['load_from_net']); + $this->linter->setXInclude($config['x_include']); + $this->linter->setDtdValidation($config['dtd_validation']); + $this->linter->setSchemeValidation($config['scheme_validation']); + + try { + $lintErrors = $this->lint($files); + } catch (RuntimeException $e) { + return TaskResult::createFailed($this, $context, $e->getMessage()); + } + + if ($lintErrors->count()) { + return TaskResult::createFailed( + $this, + $context, + sprintf("%s\nErrors: %d", $lintErrors, $lintErrors->count()), + ); + } + + return TaskResult::createPassed($this, $context); + } +}