From b4ea963532f0be8e9a461658fdf44f5d7e011479 Mon Sep 17 00:00:00 2001 From: Damien Dart Date: Fri, 23 Aug 2024 22:24:45 +0100 Subject: [PATCH] Add PHPStan gubbins. - Add PHPStan and "phpstan-strict-rules" gubbins. - Address Level 9 issues raised by PHPStan in the static site generator code base. - "Taskfile.yml": Add static analysis to "ci" task. --- Taskfile.yml | 16 +++ composer.json | 2 + composer.lock | 109 ++++++++++++++++++- phpstan.neon.dist | 10 ++ src/ssg/InputFile.php | 12 +- src/ssg/Pipeline.php | 1 + src/ssg/StaticSiteGenerator.php | 3 + src/ssg/Steps/GenerateSlugsStep.php | 4 +- src/ssg/Steps/MinifyHtmlStep.php | 17 ++- src/ssg/Steps/ProcessFrontMatterStep.php | 10 +- src/ssg/Steps/ProcessMarkdownStep.php | 5 +- src/ssg/Steps/StepInterface.php | 4 +- src/ssg/Support/TwigEnvironmentFactory.php | 5 +- src/ssg/ValueObjects/InputFileCollection.php | 16 ++- src/ssg/ValueObjects/SiteMetadata.php | 5 +- 15 files changed, 192 insertions(+), 27 deletions(-) create mode 100644 phpstan.neon.dist diff --git a/Taskfile.yml b/Taskfile.yml index 9044c70..01dcda2 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -23,6 +23,7 @@ tasks: - task: 'dependencies' - task: 'build' - task: 'lint' + - task: 'static-analysis' desc: 'Run all default CI-related tasks' ci:end-to-end: @@ -190,6 +191,21 @@ tasks: - task: 'ci' desc: 'Run all development environment setup tasks' + static-analysis: + cmds: + - task: 'static-analysis:php' + desc: 'Run all static-analysis-related tasks' + + static-analysis:php: + cmds: + - task: 'static-analysis:php:phpstan' + desc: 'Run all PHP static-analysis-related tasks' + + static-analysis:php:phpstan: + cmds: + - 'protected/vendor/bin/phpstan analyse --no-progress' + desc: 'Analyse PHP files with PHPStan' + test:end-to-end: cmds: - 'protected/vendor/bin/phpunit --testdox' diff --git a/composer.json b/composer.json index f3e8ab4..d64a07c 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,8 @@ "friendsofphp/php-cs-fixer": "^3.59.3", "league/commonmark": "^2.4", "michelf/php-smartypants": "^1.8.1", + "phpstan/phpstan": "^1.11", + "phpstan/phpstan-strict-rules": "^1.6", "phpunit/phpunit": "^11.2.5", "roave/security-advisories": "dev-latest", "symfony/yaml": "^7.1", diff --git a/composer.lock b/composer.lock index dad3c39..6f9e19e 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": "ad2929ccc3b6d08cf5a2364dd9d89126", + "content-hash": "8d233a9f94bdb85af0e8bb35e486aad8", "packages": [ { "name": "clue/stream-filter", @@ -3015,6 +3015,113 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpstan/phpstan", + "version": "1.11.11", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan.git", + "reference": "707c2aed5d8d0075666e673a5e71440c1d01a5a3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/707c2aed5d8d0075666e673a5e71440c1d01a5a3", + "reference": "707c2aed5d8d0075666e673a5e71440c1d01a5a3", + "shasum": "" + }, + "require": { + "php": "^7.2|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2024-08-19T14:37:29+00:00" + }, + { + "name": "phpstan/phpstan-strict-rules", + "version": "1.6.0", + "source": { + "type": "git", + "url": "https://github.com/phpstan/phpstan-strict-rules.git", + "reference": "363f921dd8441777d4fc137deb99beb486c77df1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan-strict-rules/zipball/363f921dd8441777d4fc137deb99beb486c77df1", + "reference": "363f921dd8441777d4fc137deb99beb486c77df1", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "phpstan/phpstan": "^1.11" + }, + "require-dev": { + "nikic/php-parser": "^4.13.0", + "php-parallel-lint/php-parallel-lint": "^1.2", + "phpstan/phpstan-deprecation-rules": "^1.1", + "phpstan/phpstan-phpunit": "^1.0", + "phpunit/phpunit": "^9.5" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "rules.neon" + ] + } + }, + "autoload": { + "psr-4": { + "PHPStan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Extra strict and opinionated rules for PHPStan", + "support": { + "issues": "https://github.com/phpstan/phpstan-strict-rules/issues", + "source": "https://github.com/phpstan/phpstan-strict-rules/tree/1.6.0" + }, + "time": "2024-04-20T06:37:51+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "11.0.3", diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..f922298 --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,10 @@ +# Copyright (C) Damien Dart, . +# This file is distributed under the MIT licence. For more information, +# please refer to the accompanying "LICENCE" file. + +includes: + - 'protected/vendor/phpstan/phpstan-strict-rules/rules.neon' +parameters: + level: 9 + paths: + - 'src/ssg' diff --git a/src/ssg/InputFile.php b/src/ssg/InputFile.php index 32493ff..890c01f 100644 --- a/src/ssg/InputFile.php +++ b/src/ssg/InputFile.php @@ -14,6 +14,7 @@ { private ?string $modifiedContent; + /** @param mixed[] $metadata */ public function __construct( public string $source, public string $outputPath, @@ -35,10 +36,16 @@ public function getContent(): string private function getOriginalContent(): string { - // TODO: Handle false return. - return file_get_contents($this->source); + $content = file_get_contents($this->source); + + // Errors are converted into exceptions by the custom error + // handler, so "file_get_contents" will always return a string. + \assert(\is_string($content)); + + return $content; } + /** @param mixed[] $metadata */ public function withAdditionalMetadata(array $metadata): self { return $this->withMetadata( @@ -66,6 +73,7 @@ public function withOutputPath(string $outputPath): self ); } + /** @param mixed[] $metadata */ public function withMetadata(array $metadata): self { return new self( diff --git a/src/ssg/Pipeline.php b/src/ssg/Pipeline.php index f9329a0..0e69b9c 100644 --- a/src/ssg/Pipeline.php +++ b/src/ssg/Pipeline.php @@ -14,6 +14,7 @@ final readonly class Pipeline implements StepInterface { + /** @var StepInterface[] */ private array $steps; public function __construct(StepInterface ...$steps) diff --git a/src/ssg/StaticSiteGenerator.php b/src/ssg/StaticSiteGenerator.php index e11d5de..25ad79f 100644 --- a/src/ssg/StaticSiteGenerator.php +++ b/src/ssg/StaticSiteGenerator.php @@ -56,6 +56,7 @@ public function run(): int return 0; } + /** @return InputFile[] */ private function getInputFiles(): array { $files = new \RecursiveCallbackFilterIterator( @@ -70,6 +71,8 @@ private function getInputFiles(): array $inputDirectory = realpath($this->inputDirectory); $inputFiles = []; + \assert(\is_string($inputDirectory)); + /** @var \SplFileInfo $file */ foreach (new \RecursiveIteratorIterator($files) as $file) { $inputFiles[] = new InputFile( diff --git a/src/ssg/Steps/GenerateSlugsStep.php b/src/ssg/Steps/GenerateSlugsStep.php index 6f884bc..82dbb4c 100644 --- a/src/ssg/Steps/GenerateSlugsStep.php +++ b/src/ssg/Steps/GenerateSlugsStep.php @@ -10,7 +10,7 @@ namespace StaticSiteGenerator\Steps; -use StaticSiteGenerator\Inputfile; +use StaticSiteGenerator\InputFile; final class GenerateSlugsStep extends AbstractStep { @@ -23,7 +23,7 @@ final class GenerateSlugsStep extends AbstractStep /** @var string[] */ private const TEMPLATE_EXTENSIONS = ['twig']; - protected function process(Inputfile $inputFile): Inputfile + protected function process(InputFile $inputFile): InputFile { return $inputFile->withAdditionalMetadata( ['slug' => $this->slugify($inputFile->outputPath)], diff --git a/src/ssg/Steps/MinifyHtmlStep.php b/src/ssg/Steps/MinifyHtmlStep.php index e5efbcf..3f4a11d 100644 --- a/src/ssg/Steps/MinifyHtmlStep.php +++ b/src/ssg/Steps/MinifyHtmlStep.php @@ -10,7 +10,7 @@ namespace StaticSiteGenerator\Steps; -use StaticSiteGenerator\Inputfile; +use StaticSiteGenerator\InputFile; use voku\helper\HtmlMin; final class MinifyHtmlStep extends AbstractStep @@ -27,7 +27,7 @@ public function __construct() ->doMakeSameDomainsLinksRelative(['www.robotinaponcho.net']); } - protected function process(Inputfile $inputFile): Inputfile + protected function process(InputFile $inputFile): InputFile { if (! str_ends_with($inputFile->outputPath, 'html')) { return $inputFile; @@ -85,12 +85,14 @@ private function processContent(string $content): string $content = $this->minifier->minify($content); // HTMLMin adds superfluous whitespace between block elements. - $content = preg_replace( + // Errors are converted into exceptions by the custom error + // handler, so "preg_replace" will always return a string. + $content = (string) preg_replace( "/({$elementRegexGroup})>\\s<', $content, ); - $content = preg_replace( + $content = (string) preg_replace( "#>\\s<(/?({$elementRegexGroup}))#", '><$1', $content, @@ -100,14 +102,17 @@ private function processContent(string $content): string // Add the scheme and domain name back to canonical link // URLs to prevent any future shenanigans (if the site gets // mirrored, for example). - $content = preg_replace( + $content = (string) preg_replace( '//', '', $content, ); } - return preg_replace_callback( + // Errors are converted into exceptions by the custom error + // handler, so "preg_replace_callback" will always return a + // string in this instance. + return (string) preg_replace_callback( '/(&#\d+;)/', static function ($match): string { return mb_convert_encoding( diff --git a/src/ssg/Steps/ProcessFrontMatterStep.php b/src/ssg/Steps/ProcessFrontMatterStep.php index c721487..cbfc408 100644 --- a/src/ssg/Steps/ProcessFrontMatterStep.php +++ b/src/ssg/Steps/ProcessFrontMatterStep.php @@ -10,7 +10,7 @@ namespace StaticSiteGenerator\Steps; -use StaticSiteGenerator\Inputfile; +use StaticSiteGenerator\InputFile; use StaticSiteGenerator\ValueObjects\GitMetadata; use Symfony\Component\Yaml\Parser; @@ -25,7 +25,7 @@ public function __construct( private readonly Parser $yamlParser, ) {} - protected function process(Inputfile $inputFile): Inputfile + protected function process(InputFile $inputFile): InputFile { foreach (self::FRONT_MATTER_REGEXES as $regex) { if ( @@ -34,7 +34,7 @@ protected function process(Inputfile $inputFile): Inputfile ) { $content = str_replace($matches[0], '', $inputFile->getContent()); - /** @var array{array-key, mixed} $metadata */ + /** @var mixed[] $metadata */ $metadata = $this->yamlParser->parse($matches[1]) ?? []; if (\array_key_exists('git', $metadata)) { @@ -59,8 +59,8 @@ private function parseGitMetadataKeyword(string $keyword): GitMetadata } return new GitMetadata( - \DateTimeImmutable::createFromFormat('U', $parts[2]), - \DateTimeImmutable::createFromFormat('U', $parts[4]), + new \DateTimeImmutable('@' . $parts[2]), + new \DateTimeImmutable('@' . $parts[4]), $parts[1], $parts[3], ); diff --git a/src/ssg/Steps/ProcessMarkdownStep.php b/src/ssg/Steps/ProcessMarkdownStep.php index f353edd..f71ec15 100644 --- a/src/ssg/Steps/ProcessMarkdownStep.php +++ b/src/ssg/Steps/ProcessMarkdownStep.php @@ -44,7 +44,10 @@ private function extractTitle(InputFile $inputFile): InputFile ) { return $inputFile ->withContent( - preg_replace( + // Errors are converted into exceptions by the + // custom error handler, so "preg_replace" will + // always return a string. + (string) preg_replace( "/{$headings[1]}\n=+\n/", '', $content, diff --git a/src/ssg/Steps/StepInterface.php b/src/ssg/Steps/StepInterface.php index 8428569..9b2d8c2 100644 --- a/src/ssg/Steps/StepInterface.php +++ b/src/ssg/Steps/StepInterface.php @@ -10,10 +10,10 @@ namespace StaticSiteGenerator\Steps; -use StaticSiteGenerator\Inputfile; +use StaticSiteGenerator\InputFile; interface StepInterface { /** @return InputFile[] */ - public function run(Inputfile ...$inputFiles): array; + public function run(InputFile ...$inputFiles): array; } diff --git a/src/ssg/Support/TwigEnvironmentFactory.php b/src/ssg/Support/TwigEnvironmentFactory.php index 4fac7b8..f4a3a19 100644 --- a/src/ssg/Support/TwigEnvironmentFactory.php +++ b/src/ssg/Support/TwigEnvironmentFactory.php @@ -59,7 +59,10 @@ static function (?string $string): string { return $string; } - return preg_replace( + // Errors are converted into exceptions by the + // custom error handler, so "preg_replace" will + // always return a string. + return (string) preg_replace( '/\s+(\S+)$/', ' $1', rtrim($string), diff --git a/src/ssg/ValueObjects/InputFileCollection.php b/src/ssg/ValueObjects/InputFileCollection.php index 2576e75..5f87648 100644 --- a/src/ssg/ValueObjects/InputFileCollection.php +++ b/src/ssg/ValueObjects/InputFileCollection.php @@ -15,7 +15,7 @@ /** @implements \IteratorAggregate */ final class InputFileCollection implements \IteratorAggregate { - private array $collections = []; + /** @var InputFile[] */ private array $inputFiles; public function __construct(InputFile ...$inputFiles) @@ -46,6 +46,8 @@ public function getSitemapEntries(): array foreach ($this->inputFiles as $inputFile) { $slug = $inputFile->metadata['slug']; + \assert(\is_string($slug)); + if ( 'robots.txt' === $slug || 'sitemap.xml' === $slug @@ -55,17 +57,19 @@ public function getSitemapEntries(): array continue; } - $entries[] = new SitemapEntry( - $inputFile->metadata['sitemapTitle'] + $title = $inputFile->metadata['sitemapTitle'] ?? $inputFile->metadata['title'] - ?? $slug, - $slug, - ); + ?? $slug; + + \assert(\is_string($title)); + + $entries[] = new SitemapEntry($title, $slug); } return $entries; } + /** @return array */ public function getCollections(): array { static $collections; diff --git a/src/ssg/ValueObjects/SiteMetadata.php b/src/ssg/ValueObjects/SiteMetadata.php index 0f703a3..d0387f7 100644 --- a/src/ssg/ValueObjects/SiteMetadata.php +++ b/src/ssg/ValueObjects/SiteMetadata.php @@ -12,11 +12,14 @@ final class SiteMetadata { + /** @var mixed[] */ public array $metadata; public function __construct() { - $releaseTimestamp = getenv('RELEASE_TIMESTAMP') ?: (new \DateTimeImmutable())->format('YmdHis'); + $releaseTimestamp = false === getenv('RELEASE_TIMESTAMP') + ? (new \DateTimeImmutable())->format('YmdHis') + : getenv('RELEASE_TIMESTAMP'); $this->metadata = [ 'author' => 'Damien Dart',