From d0b2fe04f1877c56a09ba24d6ace419d4633876b Mon Sep 17 00:00:00 2001 From: Mathieu Rochette Date: Sat, 8 Feb 2025 16:25:46 +0100 Subject: [PATCH] Use Option --- composer.json | 3 +- phpcs.xml.dist | 7 +++ src/Application.php | 11 ++-- src/Iterator/AllExamples.php | 80 ++++++++++++--------------- src/Iterator/Files.php | 12 +++- src/Iterator/FilteredExamples.php | 7 ++- src/Location.php | 60 ++++++++++++-------- src/Subscriber/Summary.php | 2 +- src/Subscriber/TestExecutor.php | 26 +++++---- src/TestSuite.php | 7 ++- tests/Iterator/AllExamplesTest.php | 9 +-- tests/Subscriber/TestExecutorTest.php | 46 ++++++++------- 12 files changed, 153 insertions(+), 117 deletions(-) diff --git a/composer.json b/composer.json index b5fb966..4f23dc4 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,8 @@ "symfony/finder": "^6.1|^7.0", "symfony/console": "^6.1|^7.0", "symfony/event-dispatcher": "^6.1|^7.0", - "webmozart/assert": "^1.11" + "webmozart/assert": "^1.11", + "texthtml/maybe": "^0.6.0" }, "require-dev": { "phpunit/phpunit": "^10.0|^11.0|^12.0", diff --git a/phpcs.xml.dist b/phpcs.xml.dist index d02ef80..c01edb3 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -24,6 +24,7 @@ + @@ -51,6 +52,12 @@ + + + + + + diff --git a/src/Application.php b/src/Application.php index e6b02ce..899a558 100755 --- a/src/Application.php +++ b/src/Application.php @@ -7,6 +7,7 @@ use Symfony\Component\Console\Input; use Symfony\Component\Console\Output; use Symfony\Component\Console\SingleCommandApplication; +use TH\Maybe\Option; #[AsCommand("doctest")] final class Application extends SingleCommandApplication @@ -61,22 +62,20 @@ protected function execute(Input\InputInterface $input, Output\OutputInterface $ } /** - * @return list|null + * @return Option> */ - private function getLanguages(Input\InputInterface $input): ?array + private function getLanguages(Input\InputInterface $input): Option { $languages = []; foreach ($input->getOption("languages") as $lang) { if ($lang === '*') { - $languages = null; - - break; + return Option\none(); } $languages[] = $lang; } - return $languages; + return Option\some($languages); } } diff --git a/src/Iterator/AllExamples.php b/src/Iterator/AllExamples.php index 684f1bf..6c48fd1 100755 --- a/src/Iterator/AllExamples.php +++ b/src/Iterator/AllExamples.php @@ -4,15 +4,16 @@ use TH\DocTest\Example; use TH\DocTest\Location; +use TH\Maybe\Option; final class AllExamples implements Examples { /** - * @param list|null $acceptedLanguages Use empty string for unspecified language, and null for any languages + * @param Option> $languageFilter Use empty string for unspecified language */ public function __construct( private readonly Comments $comments, - private readonly ?array $acceptedLanguages, + private readonly Option $languageFilter, ) {} /** @@ -27,15 +28,15 @@ public function getIterator(): \Traversable /** * @param array $paths paths to files and folder to look for PHP comments code examples in - * @param list|null $acceptedLanguages Use empty string for unspecified language, and null for any languages + * @param Option> $languageFilter Use empty string for unspecified language */ public static function fromPaths( array $paths, - ?array $acceptedLanguages, + Option $languageFilter, ): self { return new self( SourceComments::fromPaths($paths), - $acceptedLanguages, + $languageFilter, ); } @@ -49,38 +50,29 @@ private function iterateComment( $lines = new \ArrayIterator(\explode(PHP_EOL, $comment)); $index = 1; - while ($example = $this->nextExample($lines, $location, $index++)) { - yield $example; + while (($example = $this->nextExample($lines, $location, $index++))->isSome()) { + yield $example->unwrap(); } } /** * @param \ArrayIterator $lines + * @return Option */ - private function nextExample( - \ArrayIterator $lines, - Location $location, - int $index, - ): ?Example { - $codeblockStartedAt = $this->findFencedPHPCodeBlockStart($lines); - - if ($codeblockStartedAt === null) { - return null; - } - - return $this->readExample( - $lines, - $location->startingAt($codeblockStartedAt, $index), + private function nextExample(\ArrayIterator $lines, Location $location, int $index): Option + { + return $this->findFencedCodeBlockStart($lines)->andThen( + fn (int $codeblockStartedAt) + => $this->readExample($lines, $location->startingAt($codeblockStartedAt, $index)), ); } /** * @param \ArrayIterator $lines + * @return Option */ - private function readExample( - \ArrayIterator $lines, - Location $location, - ): ?Example { + private function readExample(\ArrayIterator $lines, Location $location): Option + { $buffer = []; while ($lines->valid()) { @@ -88,23 +80,21 @@ private function readExample( $lines->next(); if ($this->endOfAFencedCodeBlock($line)) { - return new Example( - \implode(PHP_EOL, $buffer), - $location->ofLength($lines->key()), - ); + return Option\some(new Example(\implode(PHP_EOL, $buffer), $location->ofLength($lines->key()))); } $buffer[] = \preg_replace("/^\s*\*( ?)/", "", $line); } - return null; + return Option\none(); } /** * @param \ArrayIterator $lines * phpcs:disable SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh + * @return Option */ - private function findFencedPHPCodeBlockStart(\ArrayIterator $lines): ?int + private function findFencedCodeBlockStart(\ArrayIterator $lines): Option { $insideAFencedCodeBlock = false; @@ -119,28 +109,27 @@ private function findFencedPHPCodeBlockStart(\ArrayIterator $lines): ?int } else { $lang = $this->startOfAFencedCodeBlock($line); - if ($lang === false) { - continue; + if ($lang->mapOr($this->isAcceptedLanguage(...), default: false)) { + return Option\some($lines->key()); } - if ($this->isAcceptedLanguage($lang)) { - return $lines->key(); + if ($lang->isNone()) { + continue; } $insideAFencedCodeBlock = true; } } - return null; + return Option\none(); } private function isAcceptedLanguage(string $lang): bool { - if ($this->acceptedLanguages === null) { - return true; - } - - return \in_array(needle: $lang, haystack: $this->acceptedLanguages, strict: true); + return $this->languageFilter->mapOr( + callback: static fn (array $languages) => \in_array(needle: $lang, haystack: $languages, strict: true), + default: true, + ); } private function endOfAFencedCodeBlock(string $line): bool @@ -148,14 +137,17 @@ private function endOfAFencedCodeBlock(string $line): bool return \ltrim($line) === "* ```"; } - private function startOfAFencedCodeBlock(string $line): false|string + /** + * @return Option + */ + private function startOfAFencedCodeBlock(string $line): Option { $line = \trim($line); if (!\str_starts_with($line, "* ```")) { - return false; + return Option\none(); } - return \substr($line, 5); + return Option\some(\substr($line, 5)); } } diff --git a/src/Iterator/Files.php b/src/Iterator/Files.php index 1a6ea78..1d2b319 100755 --- a/src/Iterator/Files.php +++ b/src/Iterator/Files.php @@ -44,6 +44,16 @@ public function iteratePaths(): \Traversable * @return \Traversable<\SplFileInfo> */ private function iterate(string $pattern): \Traversable + { + foreach (self::glob($pattern) as $path) { + yield from $this->iteratePath($path); + } + } + + /** + * @return \Traversable + */ + private static function glob(string $pattern): \Traversable { $paths = \glob($pattern); @@ -52,7 +62,7 @@ private function iterate(string $pattern): \Traversable } foreach ($paths as $path) { - yield from $this->iteratePath($path); + yield $path; } } diff --git a/src/Iterator/FilteredExamples.php b/src/Iterator/FilteredExamples.php index f759608..2fe6198 100755 --- a/src/Iterator/FilteredExamples.php +++ b/src/Iterator/FilteredExamples.php @@ -3,6 +3,7 @@ namespace TH\DocTest\Iterator; use TH\DocTest\Example; +use TH\Maybe\Option; final class FilteredExamples implements Examples { @@ -30,15 +31,15 @@ public static function filter(Examples $examples, string $filter): self /** * @param array $paths paths to files and folder to look for PHP comments code examples in - * @param list|null $acceptedLanguages Use empty string for unspecified language, and null for any languages + * @param Option> $languageFilter Use empty string for unspecified language */ public static function fromPaths( array $paths, string $filter, - ?array $acceptedLanguages, + Option $languageFilter, ): self { return self::filter( - AllExamples::fromPaths($paths, $acceptedLanguages), + AllExamples::fromPaths($paths, $languageFilter), $filter, ); } diff --git a/src/Location.php b/src/Location.php index f0a169e..5ea956a 100755 --- a/src/Location.php +++ b/src/Location.php @@ -2,17 +2,25 @@ namespace TH\DocTest; +use TH\Maybe\Option; + final class Location implements \Stringable { /** * @param \ReflectionClass<*>|\ReflectionMethod|\ReflectionFunction $source + * @param Option $path, + * @param Option $startLine, + * @param Option $endLine, */ public function __construct( public readonly \ReflectionClass|\ReflectionMethod|\ReflectionFunction $source, public readonly string $name, - public readonly ?string $path, - public readonly ?int $startLine, - public readonly ?int $endLine, + /** @var Option */ + public readonly Option $path, + /** @var Option */ + public readonly Option $startLine, + /** @var Option */ + public readonly Option $endLine, public readonly int $index, ) {} @@ -22,8 +30,8 @@ public function startingAt(int $offset, int $index): Location $this->source, $this->name, $this->path, - $this->startLine !== null ? $this->startLine + $offset : null, - null, + $this->startLine->map(static fn (int $startLine) => $startLine + $offset), + Option\none(), $index, ); } @@ -35,7 +43,7 @@ public function ofLength(int $length): Location $this->name, $this->path, $this->startLine, - $this->startLine !== null ? $this->startLine + $length : null, + $this->startLine->map(static fn (int $startLine) => $startLine + $length), $this->index, ); } @@ -53,33 +61,27 @@ public static function fromReflection( $name = "{$source->getDeclaringClass()->getName()}::$name(…)"; } - $startLine = $source->getStartLine(); - - if ($startLine !== false) { - $endLine = $startLine; - $startLine -= \substr_count($comment, \PHP_EOL); - } else { - $endLine = $startLine = null; - } + $endLine = Option\fromValue($source->getStartLine(), noneValue: false); + $startLine = $endLine->map(static fn (int $endLine) => $endLine - \substr_count($comment, \PHP_EOL)); return new self( $source, $name, - self::makePathRelative($source->getFileName()), + self::makePathRelative(Option\fromValue($source->getFileName(), noneValue: false)), $startLine, $endLine, 1, ); } - private static function makePathRelative(string|false $path): ?string + /** + * @param Option $path + * @return Option + */ + private static function makePathRelative(Option $path): Option { static $stripSrcDirPattern; - if ($path === false) { - return null; - } - $stripSrcDirPattern ??= "/^" . \preg_quote( @@ -90,12 +92,24 @@ private static function makePathRelative(string|false $path): ?string ) . "(\/*)/"; - return \preg_replace($stripSrcDirPattern, "", $path) ?? - throw new \RuntimeException("Making path relative failed for : $path"); + return $path->map( + static fn (string $path) => \preg_replace($stripSrcDirPattern, "", $path) ?? + throw new \RuntimeException("Making path relative failed for : $path"), + ); } public function __toString(): string { - return "{$this->name}#{$this->index} ({$this->path}:{$this->startLine})"; + $suffix = $this->path + ->map( + fn (string $path) => $this->startLine->mapOr( + static fn (int $startLine) => "$path:$startLine", + $path, + ), + ) + ->map(static fn (string $suffix) => " ($suffix)") + ->unwrapOr(""); + + return "{$this->name}#{$this->index}$suffix"; } } diff --git a/src/Subscriber/Summary.php b/src/Subscriber/Summary.php index 257a444..58b904d 100755 --- a/src/Subscriber/Summary.php +++ b/src/Subscriber/Summary.php @@ -53,7 +53,7 @@ public function countFailure(): void public function printSummary(Event\AfterTestSuite $event): void { - if ($event->success) { + if ($event->outcome->isSuccess()) { $this->style->success("All tests succeeded ({$this->numberOfSuccesses})"); return; diff --git a/src/Subscriber/TestExecutor.php b/src/Subscriber/TestExecutor.php index 925b9db..a28308c 100755 --- a/src/Subscriber/TestExecutor.php +++ b/src/Subscriber/TestExecutor.php @@ -5,6 +5,7 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface; use TH\DocTest\Event; use TH\DocTest\Location; +use TH\Maybe\Option; use Webmozart\Assert\Assert; /** @@ -37,9 +38,10 @@ public function execute(Event\ExecuteTest $event): void \ob_start(); try { - $expectedFailure === null - ? $test->eval() - : self::assertThrows($test->eval(...), $expectedFailure); + $expectedFailure->mapOrElse( + static fn (array $expectedFailure) => self::assertThrows($test->eval(...), $expectedFailure), + $test->eval(...), + ); } finally { $output = \ob_get_clean(); \assert(\is_string($output), "example messed up with output buffers"); @@ -80,37 +82,37 @@ private static function assertThrows(callable $callable, array $expectedFailure) } /** - * @return ?Failure + * @return Option */ - private static function expectedFailure(string $code): ?array + private static function expectedFailure(string $code): Option { foreach (\explode(PHP_EOL, $code) as $line) { $expectedFailure = self::expectedFailureFromLine($line); - if ($expectedFailure !== null) { + if ($expectedFailure->isSome()) { return $expectedFailure; } } - return null; + return Option\none(); } /** - * @return ?Failure + * @return Option */ - private static function expectedFailureFromLine(string $line): ?array + private static function expectedFailureFromLine(string $line): Option { \preg_match("/\/\/\s*@throws\s*(?[^ ]+)\s+(?[^\s].*[^\s])\s*/", $line, $matches); if (!\array_key_exists("class", $matches) || !\array_key_exists("message", $matches)) { - return null; + return Option\none(); } if (!\is_a($matches["class"], \Throwable::class, allow_string: true)) { - throw new \RuntimeException("`{$matches['class']}` isn't a `\Throwable`"); + throw new \LogicException("`{$matches['class']}` isn't a `\Throwable`"); } - return ["class" => $matches["class"], "message" => $matches["message"]]; + return Option\some(["class" => $matches["class"], "message" => $matches["message"]]); } private static function expectedOutput(string $code): string diff --git a/src/TestSuite.php b/src/TestSuite.php index 61b628f..e1ba04a 100755 --- a/src/TestSuite.php +++ b/src/TestSuite.php @@ -6,6 +6,7 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface; use TH\DocTest\Iterator\Examples; use TH\DocTest\Iterator\FilteredExamples; +use TH\Maybe\Option; final class TestSuite { @@ -49,11 +50,11 @@ public function addSubscriber(EventSubscriberInterface $eventSubscriber): void /** * @param array $paths paths to files and folder to look for PHP comments code examples in - * @param list|null $acceptedLanguages Use empty string for unspecified language, and null for any languages + * @param Option> $languageFilter Use empty string for unspecified language */ - public static function fromPaths(array $paths, string $filter, ?array $acceptedLanguages): self + public static function fromPaths(array $paths, string $filter, Option $languageFilter): self { - return new self(FilteredExamples::fromPaths($paths, $filter, $acceptedLanguages)); + return new self(FilteredExamples::fromPaths($paths, $filter, $languageFilter)); } private function runExample(Example $example): bool diff --git a/tests/Iterator/AllExamplesTest.php b/tests/Iterator/AllExamplesTest.php index d10afb8..eb78124 100644 --- a/tests/Iterator/AllExamplesTest.php +++ b/tests/Iterator/AllExamplesTest.php @@ -8,6 +8,7 @@ use TH\DocTest\Iterator\AllExamples; use TH\DocTest\Iterator\Comments; use TH\DocTest\Location; +use TH\Maybe\Option; final class AllExamplesTest extends TestCase { @@ -72,7 +73,7 @@ public static function commentsProvider(): \Traversable console.log("Hello World!") JS, ], - "acceptedLanguages" => [""], + "languageFilter" => Option\some([""]), ]; yield "Comment with a non specified code bloc, when not accepted" => [ @@ -160,17 +161,17 @@ public static function commentsProvider(): \Traversable /** * @param list $expectedExamples - * @param list|null $acceptedLanguages + * @param Option> $languageFilter */ #[DataProvider("commentsProvider")] public function testFindingCodeBlocInComments( string $comment, array $expectedExamples, - ?array $acceptedLanguages = ["php"], + ?Option $languageFilter = null, ): void { $examples = new AllExamples( self::comments($comment), - $acceptedLanguages, + $languageFilter ?? Option\some(["php"]), ); $count = 0; diff --git a/tests/Subscriber/TestExecutorTest.php b/tests/Subscriber/TestExecutorTest.php index 45e096e..3c5db7c 100644 --- a/tests/Subscriber/TestExecutorTest.php +++ b/tests/Subscriber/TestExecutorTest.php @@ -10,6 +10,7 @@ use TH\DocTest\Example; use TH\DocTest\Location; use TH\DocTest\Subscriber\TestExecutor; +use TH\Maybe\Option; /** * @phpstan-type Failure array{class:class-string<\Throwable>,message:string} @@ -32,9 +33,9 @@ public static function codeBlocsProvider(): \Traversable $location = new Location( new ReflectionClass(self::class), self::class, - path: $path, - startLine: 1, - endLine: null, + path: Option\some($path), + startLine: Option\some(1), + endLine: Option\none(), index: $index++, ); @@ -51,21 +52,21 @@ public function setUp(): void } /** - * @param Failure|null $failure + * @param Option $failure * @throws \InvalidArgumentException */ #[DataProvider('codeBlocsProvider')] - public function testCodeBlocs(Example $example, ?array $failure): void + public function testCodeBlocs(Example $example, Option $failure): void { - if ($failure !== null) { + $failure->inspect(function (array $failure): void { $this->expectException($failure["class"]); $message = \preg_quote($failure["message"], "/"); $this->expectExceptionMessageMatches("/^$message$/"); - } + }); $this->testExecutor->execute(new ExecuteTest($example)); - self::assertNull($failure); + self::assertTrue($failure->isNone()); } /** @@ -81,7 +82,7 @@ private static function codeBlocs(): \Traversable } /** - * @return array{code:string,failure:?Failure} + * @return array{code:string,failure:Option} */ private static function loadExample(\SplFileInfo $example): array { @@ -90,23 +91,30 @@ private static function loadExample(\SplFileInfo $example): array $code = \preg_replace("/^<\?php/", "", $code); \assert(\is_string($code), "Something wrong happened"); - $failure = null; - - \preg_match("/^( *)\/\/(?.*)/", $code, $matches); - - if ($matches !== []) { - \preg_match("/(?[^ ]+) (?.+)/", $matches["comment"], $matches); - if ($matches !== []) { + $failure = self::pregMatch("/^( *)\/\/(?.*)/", $code) + ->andThen( + static fn (array $matches) => self::pregMatch("/(?[^ ]+) (?.+)/", $matches["comment"]), + ) + ->map(static function (array $matches) { \assert( \is_subclass_of($matches["class"], \Throwable::class), "{$matches["class"]} is not a Throwable", ); - $failure = ["class" => $matches["class"], "message" => $matches["message"]]; - } - } + return ["class" => $matches["class"], "message" => $matches["message"]]; + }); return ["code" => $code, "failure" => $failure]; } + + /** + * @return Option> + */ + private static function pregMatch(string $pattern, string $subject): Option + { + \preg_match($pattern, $subject, $matches); + + return Option\fromValue($matches, []); + } }