Skip to content
Draft
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
2 changes: 2 additions & 0 deletions docs/running_psalm/issues/UnusedFunctionCall.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ $a = strlen("hello");
strlen("goodbye"); // unused
echo $a;
```

It is also emitted, regardless of `--find-dead-code`, when the return value of a function annotated with PHP 8.5's `#[\NoDiscard]` attribute is discarded. Casting the call to `(void)` intentionally discards the value without triggering the issue.
2 changes: 2 additions & 0 deletions docs/running_psalm/issues/UnusedMethodCall.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ final class A {
$a = new A("hello");
$a->getFoo();
```

It is also emitted, regardless of `--find-dead-code`, when the return value of a method annotated with PHP 8.5's `#[\NoDiscard]` attribute is discarded. Casting the call to `(void)` intentionally discards the value without triggering the issue.
18 changes: 18 additions & 0 deletions src/Psalm/Internal/Analyzer/AttributesAnalyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use Psalm\Issue\UndefinedClass;
use Psalm\IssueBuffer;
use Psalm\Storage\ClassLikeStorage;
use Psalm\Storage\FunctionLikeStorage;
use Psalm\Storage\HasAttributesInterface;
use Psalm\Type\Atomic\TLiteralString;
use Psalm\Type\Union;
Expand Down Expand Up @@ -119,6 +120,23 @@ public static function analyze(
$suppressed_issues,
);
}

// PHP 8.5 rejects #[\NoDiscard] at declaration time when there is no return value
// to discard (a fatal error for void/never native return types).
if ($fq_attribute_name === 'NoDiscard'
&& $storage instanceof FunctionLikeStorage
&& $storage->signature_return_type !== null
&& ($storage->signature_return_type->isVoid() || $storage->signature_return_type->isNever())
) {
IssueBuffer::maybeAdd(
new InvalidAttribute(
"Attribute {$attribute_name} cannot be used on a function or method with a "
. 'void or never return type',
$attribute_name_location,
),
$suppressed_issues,
);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,14 @@ public static function analyze(
$context,
);

self::checkFunctionNoDiscard(
$statements_analyzer,
$stmt,
$function_name,
$function_call_info,
$context,
);

if ($function_call_info->function_storage) {
if ($function_call_info->function_storage->assertions && $function_name instanceof PhpParser\Node\Name) {
self::applyAssertionsToContext(
Expand Down Expand Up @@ -1121,6 +1129,58 @@ private static function checkFunctionCallPurity(
}
}

/**
* Reports the return value of a `#[\NoDiscard]` function being discarded at a call site.
*
* Unlike the pure-call check in {@see self::checkFunctionCallPurity()}, this runs
* regardless of purity and the find-unused-variables setting, matching PHP 8.5's
* runtime behaviour. A `(void)` cast is the documented escape hatch: it analyses its
* operand with `inside_general_use`, so `$context->insideUse()` is already true there.
*
* `#[\NoDiscard]` is only enforced by PHP from 8.5, where the `(void)` escape hatch also
* becomes available, so the report is gated on the analysis PHP version. Below 8.5 the
* attribute is inert at runtime and `@psalm-suppress` remains the only way to silence it.
*/
private static function checkFunctionNoDiscard(
StatementsAnalyzer $statements_analyzer,
PhpParser\Node\Expr\FuncCall $stmt,
PhpParser\Node $function_name,
FunctionCallInfo $function_call_info,
Context $context,
): void {
if ($context->collect_initializations
|| $context->collect_mutations
|| $stmt->isFirstClassCallable()
|| $statements_analyzer->getCodebase()->analysis_php_version_id < 8_05_00
|| $function_call_info->function_id === null
|| $function_call_info->function_storage === null
|| !$function_call_info->function_storage->no_discard
) {
return;
}

if ($context->inside_unset || $context->insideUse()) {
return;
}

// A void/never native return has nothing to discard. PHP rejects such a declaration
// outright (reported as InvalidAttribute), so this is just a defensive call-site guard.
$return_type = $function_call_info->function_storage->signature_return_type;

if ($return_type !== null && ($return_type->isVoid() || $return_type->isNever())) {
return;
}

IssueBuffer::maybeAdd(
new UnusedFunctionCall(
'The call to ' . $function_call_info->function_id . ' is not used',
new CodeLocation($statements_analyzer, $function_name),
$function_call_info->function_id,
),
$statements_analyzer->getSuppressedIssues(),
);
}

private static function callUsesByReferenceArguments(
FunctionCallInfo $function_call_info,
PhpParser\Node\Expr\FuncCall $stmt,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,37 @@ public static function analyze(
}
}

// PHP 8.5's #[\NoDiscard] requires the return value to be used. Unlike the
// pure/mutation-free check above, this runs regardless of purity and the
// find-unused-variables setting. It is gated on PHP 8.5 (where the attribute is
// enforced and the (void) escape hatch exists). PHP does not inherit the attribute
// onto an implementation, so an abstract/interface declaration alone never fires;
// a void/never native return has nothing to discard.
if ($method_storage->no_discard
&& !$method_storage->abstract
&& !$class_storage->is_interface
&& $codebase->analysis_php_version_id >= 8_05_00
&& !$context->collect_initializations
&& !$context->collect_mutations
&& !$stmt->isFirstClassCallable()
&& !$context->inside_unset
&& !$context->insideUse()
&& !(
$method_storage->signature_return_type
&& ($method_storage->signature_return_type->isVoid()
|| $method_storage->signature_return_type->isNever())
)
) {
IssueBuffer::maybeAdd(
new UnusedMethodCall(
'The call to ' . $cased_method_id . ' is not used',
new CodeLocation($statements_analyzer, $stmt->name),
(string) $method_id,
),
$statements_analyzer->getSuppressedIssues(),
);
}

if ($statements_analyzer->getSource() instanceof FunctionLikeAnalyzer
&& $statements_analyzer->getSource()->track_mutations
&& !$method_storage->mutation_free
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use Psalm\Internal\TypeVisitor\ContainsStaticVisitor;
use Psalm\Issue\AbstractMethodCall;
use Psalm\Issue\ImpureMethodCall;
use Psalm\Issue\UnusedMethodCall;
use Psalm\IssueBuffer;
use Psalm\Plugin\EventHandler\Event\AfterMethodCallAnalysisEvent;
use Psalm\Storage\ClassLikeStorage;
Expand Down Expand Up @@ -317,6 +318,37 @@ public static function analyze(
}
}

// PHP 8.5's #[\NoDiscard] requires the return value to be used, including for
// static calls. Runs regardless of purity and the find-unused-variables setting,
// but is gated on PHP 8.5 (where the attribute is enforced and the (void) escape
// hatch exists). PHP does not inherit the attribute onto an implementation, so an
// abstract declaration alone never fires; a void/never native return has nothing
// to discard.
if ($method_storage->no_discard
&& !$method_storage->abstract
&& !$class_storage->is_interface
&& $codebase->analysis_php_version_id >= 8_05_00
&& !$context->collect_initializations
&& !$context->collect_mutations
&& !$stmt->isFirstClassCallable()
&& !$context->inside_unset
&& !$context->insideUse()
&& !(
$method_storage->signature_return_type
&& ($method_storage->signature_return_type->isVoid()
|| $method_storage->signature_return_type->isNever())
)
) {
IssueBuffer::maybeAdd(
new UnusedMethodCall(
'The call to ' . $cased_method_id . ' is not used',
new CodeLocation($statements_analyzer, $stmt_name),
(string) $method_id,
),
$statements_analyzer->getSuppressedIssues(),
);
}

$assertionsResolver = new AssertionsFromInheritanceResolver($codebase);
$assertions = $assertionsResolver->resolve(
$method_storage,
Expand Down
25 changes: 25 additions & 0 deletions src/Psalm/Internal/Analyzer/Statements/Expression/CastAnalyzer.php
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,31 @@ public static function analyze(
return true;
}

if ($stmt instanceof PhpParser\Node\Expr\Cast\Void_) {
// PHP 8.5's (void) cast evaluates its operand and explicitly discards the result.
// It is only valid as a statement; consuming its result is a compile error in PHP
// (the parser nikic/php-parser accepts more leniently), so reject it in any
// value-consuming position. Otherwise it is the documented escape hatch for
// #[\NoDiscard]: analysing the operand under inside_general_use marks it as used, so
// no discarded-return-value issue is raised. The parser only produces this node when
// targeting PHP 8.5+, so no version guard is needed here.
if ($context->insideUse()) {
IssueBuffer::maybeAdd(
new InvalidCast(
'The (void) cast can only be used as a statement, not as an expression',
new CodeLocation($statements_analyzer->getSource(), $stmt),
),
$statements_analyzer->getSuppressedIssues(),
);
}

$expression_result = self::checkExprGeneralUse($statements_analyzer, $stmt, $context);

$statements_analyzer->node_data->setType($stmt, Type::getVoid());

return $expression_result;
}

IssueBuffer::maybeAdd(
new UnrecognizedExpression(
'Psalm does not understand the cast ' . $stmt::class,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -683,6 +683,10 @@ public function start(
}
}

if ($attribute->fq_class_name === 'NoDiscard') {
$storage->no_discard = true;
}

if ($attribute->fq_class_name === 'Psalm\\Deprecated'
|| $attribute->fq_class_name === 'Deprecated'
|| $attribute->fq_class_name === 'JetBrains\\PhpStorm\\Deprecated'
Expand Down
9 changes: 9 additions & 0 deletions src/Psalm/Storage/FunctionLikeStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,15 @@ abstract class FunctionLikeStorage implements HasAttributesInterface, Stringable

public bool $pure = false;

/**
* Whether the return value of this function/method must be used by callers.
*
* Set when the function-like is annotated with PHP 8.5's `#[\NoDiscard]` attribute.
* When true, Psalm reports the return value being discarded at a call site
* independently of purity and the find-unused-variables setting.
*/
public bool $no_discard = false;

/**
* Whether or not the function output is dependent solely on input - a function can be
* impure but still have this property (e.g. var_export). Useful for taint analysis.
Expand Down
11 changes: 11 additions & 0 deletions tests/CastTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,17 @@ final class CastTest extends TestCase
#[Override]
public function providerValidCodeParse(): iterable
{
yield 'voidCastDiscardsValueWithoutError' => [
'code' => '<?php
$x = strlen("hello");
(void) $x;
(void) strlen("world");
echo "reachable";
',
'assertions' => [],
'ignored_issues' => [],
'php_version' => '8.5',
];
yield 'SKIPPED-castFalseOrIntToInt' => [
'code' => '<?php
/** @var false|int<10, 20> */
Expand Down
Loading
Loading