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, []);
+ }
}