Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,12 @@
"allow-plugins": {
"pestphp/pest-plugin": true
}
},
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
}
}
}
5 changes: 5 additions & 0 deletions extension.neon
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
services:
-
class: Superscript\Monads\PHPStan\NoPanickingMonadUnwrapExtension
tags:
- phpstan.restrictedMethodUsageExtension
45 changes: 45 additions & 0 deletions src/PHPStan/NoPanickingMonadUnwrapExtension.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace Superscript\Monads\PHPStan;

use PHPStan\Analyser\Scope;
use PHPStan\Reflection\ExtendedMethodReflection;
use PHPStan\Rules\RestrictedUsage\RestrictedMethodUsageExtension;
use PHPStan\Rules\RestrictedUsage\RestrictedUsage;

final class NoPanickingMonadUnwrapExtension implements RestrictedMethodUsageExtension
{
private const BANNED = [
'Superscript\\Monads\\Result\\Result' => [
'unwrap' => "Result::unwrap() throws when the Result is an Err. Handle both cases with match(), document the invariant with expect('why this cannot fail'), or supply a fallback via unwrapOr()/unwrapOrElse().",
'unwrapErr' => "Result::unwrapErr() throws when the Result is an Ok. Handle both cases with match(), or document the invariant with expectErr('why this must be an Err').",
],
'Superscript\\Monads\\Result\\Err' => [
'unwrap' => 'This value is statically known to be an Err, so unwrap() will always throw. Read the error with unwrapErr(), or handle both branches with match().',
],
'Superscript\\Monads\\Result\\Ok' => [
'unwrapErr' => 'This value is statically known to be an Ok, so unwrapErr() will always throw. Read the value with unwrap(), or handle both branches with match().',
],
'Superscript\\Monads\\Option\\Option' => [
'unwrap' => "Option::unwrap() throws when the Option is None. Supply a fallback via unwrapOr()/unwrapOrElse(), collapse both cases with mapOrElse(), or document the invariant with expect('why this cannot be None').",
],
'Superscript\\Monads\\Option\\None' => [
'unwrap' => 'This value is statically known to be None, so unwrap() will always throw. Guard with isSome() first, or supply a fallback via unwrapOr()/unwrapOrElse().',
],
];

public function isRestrictedMethodUsage(ExtendedMethodReflection $methodReflection, Scope $scope): ?RestrictedUsage
{
$declaring = $methodReflection->getDeclaringClass()->getName();
$name = $methodReflection->getName();

$message = self::BANNED[$declaring][$name] ?? null;
if ($message === null) {
return null;
}

return RestrictedUsage::create(errorMessage: $message, identifier: 'monads.panickingUnwrap');
}
}
57 changes: 57 additions & 0 deletions tests/PHPStan/NoPanickingMonadUnwrapExtensionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

declare(strict_types=1);

namespace Superscript\Monads\Tests\PHPStan;

use PHPStan\Rules\RestrictedUsage\RestrictedMethodUsageRule;
use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;

/**
* @extends RuleTestCase<RestrictedMethodUsageRule>
*/
final class NoPanickingMonadUnwrapExtensionTest extends RuleTestCase
{
protected function getRule(): Rule
{
// The rule reads RestrictedMethodUsageExtension services from the container by tag;
// getAdditionalConfigFiles() below loads extension.neon so ours is registered.
return new RestrictedMethodUsageRule(self::getContainer(), self::createReflectionProvider());
}

public static function getAdditionalConfigFiles(): array
{
return [__DIR__ . '/../../extension.neon'];
}

public function testFlagsPanickingUnwraps(): void
{
$this->analyse([__DIR__ . '/data/panicking-unwrap.php'], [
[
"Result::unwrap() throws when the Result is an Err. Handle both cases with match(), document the invariant with expect('why this cannot fail'), or supply a fallback via unwrapOr()/unwrapOrElse().",
15,
],
[
"Result::unwrapErr() throws when the Result is an Ok. Handle both cases with match(), or document the invariant with expectErr('why this must be an Err').",
16,
],
[
'This value is statically known to be an Ok, so unwrapErr() will always throw. Read the value with unwrap(), or handle both branches with match().',
30,
],
[
'This value is statically known to be an Err, so unwrap() will always throw. Read the error with unwrapErr(), or handle both branches with match().',
33,
],
[
"Option::unwrap() throws when the Option is None. Supply a fallback via unwrapOr()/unwrapOrElse(), collapse both cases with mapOrElse(), or document the invariant with expect('why this cannot be None').",
36,
],
[
'This value is statically known to be None, so unwrap() will always throw. Guard with isSome() first, or supply a fallback via unwrapOr()/unwrapOrElse().',
47,
],
]);
}
}
47 changes: 47 additions & 0 deletions tests/PHPStan/data/panicking-unwrap.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace Superscript\Monads\Tests\PHPStan\data;

use Superscript\Monads\Option\None;
use Superscript\Monads\Option\Option;
use Superscript\Monads\Option\Some;
use Superscript\Monads\Result\Err;
use Superscript\Monads\Result\Ok;
use Superscript\Monads\Result\Result;

/** @var Result<int, string> $result */
$result->unwrap(); // flagged: Result::unwrap()
$result->unwrapErr(); // flagged: Result::unwrapErr()
$result->match(fn($e) => $e, fn($v) => $v); // NOT flagged

// After a type guard the value is narrowed to the safe variant, so unwrap is allowed.
if ($result->isOk()) {
$result->unwrap(); // NOT flagged: narrowed to Ok
}

if ($result->isErr()) {
$result->unwrapErr(); // NOT flagged: narrowed to Err
}

/** @var Ok<int> $ok */
$ok->unwrap(); // NOT flagged: Ok::unwrap() is safe
fn() => $ok->unwrapErr(); // flagged: narrowed Ok

/** @var Err<string> $err */
fn() => $err->unwrap(); // flagged: narrowed Err

/** @var Option<int> $option */
$option->unwrap(); // flagged: Option::unwrap()
$option->unwrapOr(0); // NOT flagged

if ($option->isSome()) {
$option->unwrap(); // NOT flagged: narrowed to Some
}

/** @var Some<int> $some */
$some->unwrap(); // NOT flagged: Some::unwrap() is safe

/** @var None $none */
fn() => $none->unwrap(); // flagged: narrowed None
Loading