diff --git a/config.xsd b/config.xsd
index b1b159cbe..1ba5c0fb5 100644
--- a/config.xsd
+++ b/config.xsd
@@ -344,6 +344,7 @@
+
diff --git a/docs/running_psalm/issues.md b/docs/running_psalm/issues.md
index e4da143e6..0b56bd474 100644
--- a/docs/running_psalm/issues.md
+++ b/docs/running_psalm/issues.md
@@ -2431,6 +2431,15 @@ $a = new A();
echo $a->getFoo();
```
+### UnusedPsalmSuppress
+
+Emitted when `--find-unused-psalm-suppress` is turned on and Psalm cannot find any uses of a given `@psalm-suppress` annotation
+
+```php
+/** @psalm-suppress InvalidArgument */
+echo strpos("hello", "e");
+```
+
### UnusedVariable
Emitted when `--find-dead-code` is turned on and Psalm cannot find any references to a variable, once instantiated
diff --git a/src/Psalm/CodeLocation/Raw.php b/src/Psalm/CodeLocation/Raw.php
index 6b82d2227..bb69070e8 100644
--- a/src/Psalm/CodeLocation/Raw.php
+++ b/src/Psalm/CodeLocation/Raw.php
@@ -14,6 +14,7 @@ class Raw extends \Psalm\CodeLocation
public function __construct(
string $file_contents,
string $file_path,
+ string $file_name,
int $file_start,
int $file_end
) {
@@ -22,7 +23,7 @@ class Raw extends \Psalm\CodeLocation
$this->raw_file_start = $this->file_start;
$this->raw_file_end = $this->file_end;
$this->file_path = $file_path;
- $this->file_name = $file_path;
+ $this->file_name = $file_name;
$this->single_line = false;
$this->preview_start = $this->file_start;
diff --git a/src/Psalm/Codebase.php b/src/Psalm/Codebase.php
index afc52a2c6..623899f04 100644
--- a/src/Psalm/Codebase.php
+++ b/src/Psalm/Codebase.php
@@ -272,7 +272,10 @@ class Codebase
*/
public $php_minor_version = PHP_MINOR_VERSION;
-
+ /**
+ * @var bool
+ */
+ public $track_unused_suppressions = false;
public function __construct(
Config $config,
@@ -1032,7 +1035,13 @@ class Codebase
$file_contents = $this->getFileContents($file_path);
- return new CodeLocation\Raw($file_contents, $file_path, (int) $symbol_parts[0], (int) $symbol_parts[1]);
+ return new CodeLocation\Raw(
+ $file_contents,
+ $file_path,
+ $this->config->shortenFileName($file_path),
+ (int) $symbol_parts[0],
+ (int) $symbol_parts[1]
+ );
}
try {
diff --git a/src/Psalm/Internal/Analyzer/CommentAnalyzer.php b/src/Psalm/Internal/Analyzer/CommentAnalyzer.php
index 127d9508a..5de7c9497 100644
--- a/src/Psalm/Internal/Analyzer/CommentAnalyzer.php
+++ b/src/Psalm/Internal/Analyzer/CommentAnalyzer.php
@@ -518,7 +518,7 @@ class CommentAnalyzer
if (isset($parsed_docblock['specials']['psalm-suppress'])) {
foreach ($parsed_docblock['specials']['psalm-suppress'] as $offset => $suppress_entry) {
- $info->suppressed_issues[$offset] = preg_split('/[\s]+/', $suppress_entry)[0];
+ $info->suppressed_issues[$offset + $comment->getFilePos()] = preg_split('/[\s]+/', $suppress_entry)[0];
}
}
@@ -866,8 +866,8 @@ class CommentAnalyzer
}
if (isset($parsed_docblock['specials']['psalm-suppress'])) {
- foreach ($parsed_docblock['specials']['psalm-suppress'] as $suppress_entry) {
- $info->suppressed_issues[] = preg_split('/[\s]+/', $suppress_entry)[0];
+ foreach ($parsed_docblock['specials']['psalm-suppress'] as $offset => $suppress_entry) {
+ $info->suppressed_issues[$offset + $comment->getFilePos()] = preg_split('/[\s]+/', $suppress_entry)[0];
}
}
diff --git a/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php b/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php
index f6966be9e..6b67d9fc0 100644
--- a/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php
+++ b/src/Psalm/Internal/Analyzer/FunctionLikeAnalyzer.php
@@ -1016,6 +1016,12 @@ abstract class FunctionLikeAnalyzer extends SourceAnalyzer implements Statements
}
}
+ if ($codebase->track_unused_suppressions) {
+ foreach ($storage->suppressed_issues as $offset => $issue_name) {
+ IssueBuffer::addUnusedSuppression($this->getFilePath(), $offset, $issue_name);
+ }
+ }
+
foreach ($storage->throws as $expected_exception => $_) {
if (isset($storage->throw_locations[$expected_exception])) {
if (ClassLikeAnalyzer::checkFullyQualifiedClassLikeName(
diff --git a/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php b/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php
index 4caf9d239..c1a6da8f4 100644
--- a/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php
+++ b/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php
@@ -558,6 +558,14 @@ class ProjectAnalyzer
$this->codebase->taint = new Taint();
}
+ /**
+ * @return void
+ */
+ public function trackUnusedSuppressions()
+ {
+ $this->codebase->track_unused_suppressions = true;
+ }
+
public function interpretRefactors() : void
{
if (!$this->codebase->alter_code) {
diff --git a/src/Psalm/Internal/Analyzer/StatementsAnalyzer.php b/src/Psalm/Internal/Analyzer/StatementsAnalyzer.php
index 9f87901e5..c62eb7e64 100644
--- a/src/Psalm/Internal/Analyzer/StatementsAnalyzer.php
+++ b/src/Psalm/Internal/Analyzer/StatementsAnalyzer.php
@@ -297,8 +297,13 @@ class StatementsAnalyzer extends SourceAnalyzer implements StatementsSource
);
if ($suppressed) {
- $new_issues = array_diff($suppressed, $this->source->getSuppressedIssues());
- /** @psalm-suppress MixedTypeCoercion */
+ $new_issues = [];
+
+ foreach ($suppressed as $offset => $issue_type) {
+ $new_issues[$offset + $docblock->getFilePos()] = $issue_type;
+ IssueBuffer::addUnusedSuppression($this->getFilePath(), $offset, $issue_type);
+ }
+
$this->addSuppressedIssues($new_issues);
}
}
diff --git a/src/Psalm/Internal/Codebase/Analyzer.php b/src/Psalm/Internal/Codebase/Analyzer.php
index 91e6aa7f9..601f7a2d1 100644
--- a/src/Psalm/Internal/Codebase/Analyzer.php
+++ b/src/Psalm/Internal/Codebase/Analyzer.php
@@ -69,7 +69,8 @@ use function usort;
* class_method_locations: array>,
* class_property_locations: array>,
* possible_method_param_types: array>,
- * taint_data: ?\Psalm\Internal\Codebase\Taint
+ * taint_data: ?\Psalm\Internal\Codebase\Taint,
+ * unused_suppressions: array>
* }
*/
@@ -408,6 +409,7 @@ class Analyzer
'class_property_locations' => $rerun ? [] : $file_reference_provider->getAllClassPropertyLocations(),
'possible_method_param_types' => $rerun ? [] : $analyzer->getPossibleMethodParamTypes(),
'taint_data' => $codebase->taint,
+ 'unused_suppressions' => $codebase->track_unused_suppressions ? IssueBuffer::getUnusedSuppressions() : [],
];
// @codingStandardsIgnoreEnd
},
@@ -427,6 +429,10 @@ class Analyzer
foreach ($forked_pool_data as $pool_data) {
IssueBuffer::addIssues($pool_data['issues']);
+ if ($codebase->track_unused_suppressions) {
+ IssueBuffer::addUnusedSuppressions($pool_data['unused_suppressions']);
+ }
+
if ($codebase->taint && $pool_data['taint_data']) {
$codebase->taint->addThreadData($pool_data['taint_data']);
}
@@ -531,6 +537,10 @@ class Analyzer
$codebase->file_reference_provider->addIssue($issue_data['file_path'], $issue_data);
}
}
+
+ if ($codebase->track_unused_suppressions) {
+ IssueBuffer::processUnusedSuppressions($codebase->file_provider);
+ }
}
/**
diff --git a/src/Psalm/Issue/UnusedPsalmSuppress.php b/src/Psalm/Issue/UnusedPsalmSuppress.php
new file mode 100644
index 000000000..453585450
--- /dev/null
+++ b/src/Psalm/Issue/UnusedPsalmSuppress.php
@@ -0,0 +1,6 @@
+> */
protected static $recorded_issues = [];
+ /**
+ * @var array>
+ */
+ protected static $unused_suppressions = [];
+
/**
* @param CodeIssue $e
* @param string[] $suppressed_issues
@@ -73,6 +79,15 @@ class IssueBuffer
return self::add($e);
}
+ public static function addUnusedSuppression(string $file_path, int $offset, string $issue_type) : void
+ {
+ if (!isset(self::$unused_suppressions[$file_path])) {
+ self::$unused_suppressions[$file_path] = [];
+ }
+
+ self::$unused_suppressions[$file_path][$offset] = $offset + \strlen($issue_type) - 1;
+ }
+
/**
* @param CodeIssue $e
* @param string[] $suppressed_issues
@@ -85,14 +100,17 @@ class IssueBuffer
$fqcn_parts = explode('\\', get_class($e));
$issue_type = array_pop($fqcn_parts);
+ $file_path = $e->getFilePath();
- if (!$config->reportIssueInFile($issue_type, $e->getFilePath())) {
+ if (!$config->reportIssueInFile($issue_type, $file_path)) {
return true;
}
$suppressed_issue_position = array_search($issue_type, $suppressed_issues);
if ($suppressed_issue_position !== false) {
+ /** @psalm-suppress MixedArrayTypeCoercion */
+ unset(self::$unused_suppressions[$file_path][$suppressed_issue_position]);
return true;
}
@@ -102,6 +120,8 @@ class IssueBuffer
$suppressed_issue_position = array_search($parent_issue_type, $suppressed_issues);
if ($suppressed_issue_position !== false) {
+ /** @psalm-suppress MixedArrayTypeCoercion */
+ unset(self::$unused_suppressions[$file_path][$suppressed_issue_position]);
return true;
}
}
@@ -190,6 +210,47 @@ class IssueBuffer
return self::$issues_data;
}
+ /**
+ * @return array>
+ */
+ public static function getUnusedSuppressions() : array
+ {
+ return self::$unused_suppressions;
+ }
+
+ public static function addUnusedSuppressions(array $unused_suppressions) : void
+ {
+ self::$unused_suppressions += $unused_suppressions;
+ }
+
+ public static function processUnusedSuppressions(\Psalm\Internal\Provider\FileProvider $file_provider) : void
+ {
+ $config = Config::getInstance();
+
+ foreach (self::$unused_suppressions as $file_path => $offsets) {
+ if (!$offsets) {
+ continue;
+ }
+
+ $file_contents = $file_provider->getContents($file_path);
+
+ foreach ($offsets as $start => $end) {
+ self::add(
+ new UnusedPsalmSuppress(
+ 'This suppression is never used',
+ new CodeLocation\Raw(
+ $file_contents,
+ $file_path,
+ $config->shortenFileName($file_path),
+ $start,
+ $end
+ )
+ )
+ );
+ }
+ }
+ }
+
/**
* @return int
*/
@@ -492,6 +553,7 @@ class IssueBuffer
self::$recording_level = 0;
self::$recorded_issues = [];
self::$console_issues = [];
+ self::$unused_suppressions = [];
}
/**
diff --git a/src/command_functions.php b/src/command_functions.php
index 19d37b266..b2a26ea06 100644
--- a/src/command_functions.php
+++ b/src/command_functions.php
@@ -321,6 +321,9 @@ Options:
--find-unused-code[=auto]
Look for unused code. Options are 'auto' or 'always'. If no value is specified, default is 'auto'
+ --find-unused-psalm-suppress
+ Finds all @psalm-suppress annotations that aren’t used
+
--find-references-to=[class|method|property]
Searches the codebase for references to the given fully-qualified class or method,
where method is in the format class::methodName
diff --git a/src/psalm.php b/src/psalm.php
index 6d358c1e2..25fdc22ac 100644
--- a/src/psalm.php
+++ b/src/psalm.php
@@ -62,7 +62,8 @@ $valid_long_options = [
'shepherd::',
'no-progress',
'include-php-versions', // used for baseline
- 'track-tainted-input'
+ 'track-tainted-input',
+ 'find-unused-psalm-suppress',
];
gc_collect_cycles();
@@ -511,6 +512,10 @@ if (isset($options['track-tainted-input'])) {
$project_analyzer->trackTaintedInputs();
}
+if (isset($options['find-unused-psalm-suppress'])) {
+ $project_analyzer->trackUnusedSuppressions();
+}
+
/** @var string $plugin_path */
foreach ($plugins as $plugin_path) {
$config->addPluginPath($plugin_path);
diff --git a/tests/DocumentationTest.php b/tests/DocumentationTest.php
index 97ed054bb..21202030d 100644
--- a/tests/DocumentationTest.php
+++ b/tests/DocumentationTest.php
@@ -143,6 +143,7 @@ class DocumentationTest extends TestCase
if ($check_references) {
$this->project_analyzer->getCodebase()->reportUnusedCode();
+ $this->project_analyzer->trackUnusedSuppressions();
}
foreach ($error_levels as $error_level) {
diff --git a/tests/IssueSuppressionTest.php b/tests/IssueSuppressionTest.php
index 3475a100f..6eb170c3d 100644
--- a/tests/IssueSuppressionTest.php
+++ b/tests/IssueSuppressionTest.php
@@ -8,6 +8,55 @@ class IssueSuppressionTest extends TestCase
use Traits\ValidCodeAnalysisTestTrait;
use Traits\InvalidCodeAnalysisTestTrait;
+ /**
+ * @return void
+ */
+ public function testIssueSuppressedOnFunction()
+ {
+ $this->expectException(\Psalm\Exception\CodeException::class);
+ $this->expectExceptionMessage('UnusedPsalmSuppress');
+
+ $this->project_analyzer->trackUnusedSuppressions();
+
+ $this->addFile(
+ 'somefile.php',
+ 'barBar()->bat()->baz()->bam()->bas()->bee()->bet()->bes()->bis();
+ }
+ }'
+ );
+
+ $this->analyzeFile('somefile.php', new \Psalm\Context());
+ }
+
+ /**
+ * @return void
+ */
+ public function testIssueSuppressedOnStatement()
+ {
+ $this->expectException(\Psalm\Exception\CodeException::class);
+ $this->expectExceptionMessage('UnusedPsalmSuppress');
+
+ $this->project_analyzer->trackUnusedSuppressions();
+
+ $this->addFile(
+ 'somefile.php',
+ 'analyzeFile('somefile.php', new \Psalm\Context());
+ }
+
/**
* @return iterable,error_levels?:string[]}>
*/
diff --git a/tests/TestCase.php b/tests/TestCase.php
index 2b330527a..fba1cb020 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -119,6 +119,10 @@ class TestCase extends BaseTestCase
$file_analyzer->analyze($context);
}
}
+
+ if ($codebase->track_unused_suppressions) {
+ \Psalm\IssueBuffer::processUnusedSuppressions($codebase->file_provider);
+ }
}
/**