diff --git a/bin/stubs/gen_preloader.php b/bin/stubs/gen_preloader.php
index 178e5738dad..73691cbb700 100644
--- a/bin/stubs/gen_preloader.php
+++ b/bin/stubs/gen_preloader.php
@@ -55,8 +55,10 @@
RecursiveIteratorIterator::LEAVES_ONLY,
) as $f
) {
- $f = str_replace(['/', '.php', 'src\\'], ['\\', '', ''], $f);
- $classes[$f] = true;
+ if (str_ends_with($f, '.php')) {
+ $f = str_replace(['/', '.php', 'src\\'], ['\\', '', ''], $f);
+ $classes[$f] = true;
+ }
}
foreach ($classes as $class => $_) {
diff --git a/config.xsd b/config.xsd
index 39d6f30945a..53c7c884745 100644
--- a/config.xsd
+++ b/config.xsd
@@ -24,6 +24,8 @@
+
+
@@ -114,6 +116,7 @@
+
@@ -748,6 +751,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -774,4 +789,8 @@
+
+
+
+
diff --git a/docs/running_psalm/configuration.md b/docs/running_psalm/configuration.md
index 72972652353..d0d0027134d 100644
--- a/docs/running_psalm/configuration.md
+++ b/docs/running_psalm/configuration.md
@@ -625,6 +625,31 @@ Optional. A list of extensions to disable. By default, only extensions required
```
+
+#### <ignoreUnusedComposerPackages>
+
+Optional. A list of packages for which [UnusedComposerPackage](issues/UnusedComposerPackage.md) issues should not be emitted, even if they're not used in the current project.
+
+Useful for example for peer dependencies (dependencies which are only used by another dependency, aren't explicitly required by that dependency's composer.json, and aren't used in the current project).
+
+```xml
+
+
+
+```
+
+#### <ignoreUnusedExtensions>
+
+Optional. A list of extensions for which [UnusedExtension](issues/UnusedExtension.md) issues should not be emitted, even if they're not used in the current project.
+
+Useful for example for peer dependencies (extensions which are only used by another dependency, aren't explicitly required by that dependency's composer.json, and aren't used in the current project).
+
+```xml
+
+
+
+```
+
#### <plugins>
Optional. A list of `` entries. See the [Plugins](plugins/using_plugins.md) section for more information.
@@ -645,12 +670,16 @@ functions without the implementations.
You can find a list of stubs for common classes [here](https://github.com/JetBrains/phpstorm-stubs).
List out each file with ``. In case classes to be tested use parent classes
-or interfaces defined in a stub file, this stub should be configured with attribute `preloadClasses="true"`.
+or interfaces defined in a stub file, this stub should be configured with attribute `preloadClasses="true"`.
+
+Extension stubs must also specify the name of the extension (used for dead code detection).
```xml
+
+
```
diff --git a/docs/running_psalm/error_levels.md b/docs/running_psalm/error_levels.md
index 1dd02d7562b..ea0ec498ad1 100644
--- a/docs/running_psalm/error_levels.md
+++ b/docs/running_psalm/error_levels.md
@@ -357,9 +357,12 @@ These issues are treated as errors at level 7 and below.
- [UnnecessaryVarAnnotation](issues/UnnecessaryVarAnnotation.md)
- [UnusedClass](issues/UnusedClass.md)
- [UnusedClosureParam](issues/UnusedClosureParam.md)
+ - [UnusedComposerPackage](issues/UnusedComposerPackage.md)
- [UnusedConstructor](issues/UnusedConstructor.md)
- [UnusedDocblockParam](issues/UnusedDocblockParam.md)
+ - [UnusedExtension](issues/UnusedExtension.md)
- [UnusedForeachValue](issues/UnusedForeachValue.md)
+ - [UnusedFunction](issues/UnusedFunction.md)
- [UnusedMethod](issues/UnusedMethod.md)
- [UnusedParam](issues/UnusedParam.md)
- [UnusedProperty](issues/UnusedProperty.md)
diff --git a/docs/running_psalm/issues.md b/docs/running_psalm/issues.md
index 62614decd43..f8054f5b5d1 100644
--- a/docs/running_psalm/issues.md
+++ b/docs/running_psalm/issues.md
@@ -301,8 +301,10 @@
- [UnusedBaselineEntry](issues/UnusedBaselineEntry.md)
- [UnusedClass](issues/UnusedClass.md)
- [UnusedClosureParam](issues/UnusedClosureParam.md)
+ - [UnusedComposerPackage](issues/UnusedComposerPackage.md)
- [UnusedConstructor](issues/UnusedConstructor.md)
- [UnusedDocblockParam](issues/UnusedDocblockParam.md)
+ - [UnusedExtension](issues/UnusedExtension.md)
- [UnusedForeachValue](issues/UnusedForeachValue.md)
- [UnusedFunctionCall](issues/UnusedFunctionCall.md)
- [UnusedIssueHandlerSuppression](issues/UnusedIssueHandlerSuppression.md)
diff --git a/docs/running_psalm/issues/UnusedComposerPackage.md b/docs/running_psalm/issues/UnusedComposerPackage.md
new file mode 100644
index 00000000000..71f1e71d6c7
--- /dev/null
+++ b/docs/running_psalm/issues/UnusedComposerPackage.md
@@ -0,0 +1,7 @@
+# UnusedComposerPackage
+
+Emitted when `composer.json` contains a package that is not referenced in the analyzed project.
+
+To fix, remove the package from the `require` section of `composer.json`.
+
+Peer dependencies (dependencies which are only used by another dependency, aren't explicitly required by that dependency's composer.json, and aren't used in the current project) may be excluded from unused composer package detection by using the [ignoreUnusedComposerPackages](https://psalm.dev/docs/running_psalm/configuration/#ignoreunusedcomposerpackages) config.
\ No newline at end of file
diff --git a/docs/running_psalm/issues/UnusedExtension.md b/docs/running_psalm/issues/UnusedExtension.md
new file mode 100644
index 00000000000..b470621437f
--- /dev/null
+++ b/docs/running_psalm/issues/UnusedExtension.md
@@ -0,0 +1,7 @@
+# UnusedExtension
+
+Emitted when `composer.json` contains an extension that is not referenced in the analyzed project.
+
+To fix, remove that extension from the `require` section of `composer.json`.
+
+Peer dependencies (extensions which are only used by another dependency, aren't explicitly required by that dependency's composer.json, and aren't used in the current project) may be excluded from unused composer package detection by using the [ignoreUnusedExtensions](https://psalm.dev/docs/running_psalm/configuration/#ignoreunusedextensions) config.
\ No newline at end of file
diff --git a/docs/running_psalm/issues/UnusedFunction.md b/docs/running_psalm/issues/UnusedFunction.md
new file mode 100644
index 00000000000..a950a21465e
--- /dev/null
+++ b/docs/running_psalm/issues/UnusedFunction.md
@@ -0,0 +1,14 @@
+# UnusedFunction
+
+Emitted when `--find-dead-code` is turned on and Psalm cannot find any uses of a
+given function.
+
+If this class is used and part of the public API, annotate it with `@psalm-api`.
+
+```php
+file_start = $file_start;
+ // matches how CodeLocation works
+ $this->file_end = $file_end - 1;
+
+ $this->raw_file_start = $file_start;
+ $this->raw_file_end = $file_end;
+ $this->raw_line_number = $line_number;
+
+ $this->file_path = $file_path;
+ $this->file_name = 'composer.json';
+ $this->single_line = false;
+
+ $this->preview_start = $this->file_start;
+
+ $this->docblock_line_number = $line_number;
+ }
+}
diff --git a/src/Psalm/Config.php b/src/Psalm/Config.php
index 85ade0331ac..f822a3ba655 100644
--- a/src/Psalm/Config.php
+++ b/src/Psalm/Config.php
@@ -14,6 +14,7 @@
use JsonException;
use LogicException;
use OutOfBoundsException;
+use Psalm\CodeLocation\ComposerJsonLocation;
use Psalm\CodeLocation\Raw;
use Psalm\Config\IssueHandler;
use Psalm\Config\ProjectFileFilter;
@@ -83,6 +84,7 @@
use function is_resource;
use function is_string;
use function json_decode;
+use function json_encode;
use function libxml_clear_errors;
use function libxml_get_errors;
use function libxml_use_internal_errors;
@@ -117,6 +119,7 @@
use const DIRECTORY_SEPARATOR;
use const GLOB_NOSORT;
use const JSON_THROW_ON_ERROR;
+use const JSON_UNESCAPED_SLASHES;
use const LIBXML_ERR_ERROR;
use const LIBXML_ERR_FATAL;
use const LIBXML_NONET;
@@ -280,14 +283,21 @@ final class Config
private array $mock_classes = [];
/**
- * @var array
+ * @var array
*/
private array $preloaded_stub_files = [];
/**
- * @var array
+ * @var array
+ * @internal
+ */
+ public array $internal_stubs = [];
+
+ /**
+ * @var array
+ * @internal
*/
- private array $stub_files = [];
+ public array $stub_files = [];
public bool $hide_external_errors = false;
@@ -473,16 +483,22 @@ final class Config
*/
public string $trigger_error_exits = 'default';
- /**
- * @var string[]
- */
- public array $internal_stubs = [];
-
/** @var ?int<1, max> */
public ?int $threads = null;
/** @var ?int<1, max> */
public ?int $scan_threads = null;
+ /**
+ * @internal
+ * @var array
+ */
+ public array $required_packages = [];
+
+ /**
+ * @var array
+ */
+ public array $ignore_unused_packages = [];
+
/**
* A list of php extensions supported by Psalm.
* Where key - extension name (without ext- prefix), value - whether to load extension’s stub.
@@ -993,10 +1009,10 @@ private static function fromXmlAndPaths(
$config->base_dir = $current_dir;
$base_dir = $current_dir;
}
+ $required_extensions = [];
$composer_json_path = Composer::getJsonFilePath($config->base_dir);
- $composer_json = null;
if (file_exists($composer_json_path)) {
$composer_json_contents = file_get_contents($composer_json_path);
assert($composer_json_contents !== false);
@@ -1004,11 +1020,23 @@ private static function fromXmlAndPaths(
if (!is_array($composer_json)) {
throw new UnexpectedValueException('Invalid composer.json at ' . $composer_json_path);
}
- }
- $required_extensions = [];
- foreach (($composer_json["require"] ?? []) as $required => $_) {
- if (str_starts_with((string) $required, "ext-")) {
- $required_extensions[strtolower(substr((string) $required, 4))] = true;
+ foreach (($composer_json["require"] ?? []) as $required => $ver) {
+ assert(is_string($required));
+ if (str_starts_with($required, "ext-")) {
+ $required_extensions[strtolower(substr($required, 4))] = true;
+ } elseif (!str_contains($required, '/')) {
+ continue;
+ }
+ $chunk = (string)json_encode($required, JSON_UNESCAPED_SLASHES)
+ .": ".(string)json_encode($ver, JSON_UNESCAPED_SLASHES);
+ $pos = (int) strpos($composer_json_contents, $chunk);
+ $location = new ComposerJsonLocation(
+ $composer_json_path,
+ $pos,
+ $pos+strlen($chunk),
+ substr_count($composer_json_contents, "\n", 0, $pos),
+ );
+ $config->required_packages[strtolower($required)] = $location;
}
}
foreach ($required_extensions as $required_ext => $_) {
@@ -1037,6 +1065,36 @@ private static function fromXmlAndPaths(
}
}
+ if (isset($config_xml->ignoreUnusedExtensions) && isset($config_xml->ignoreUnusedExtensions->extension)) {
+ foreach ($config_xml->ignoreUnusedExtensions->extension as $extension) {
+ assert(isset($extension["name"]));
+ $extensionName = 'ext-'.strtolower((string) $extension["name"]);
+ if (!isset($config->required_packages[$extensionName])) {
+ $extensionName = substr($extensionName, 4);
+ throw new ConfigException(
+ "The extension $extensionName was ignored in the Psalm configuration file".
+ " (ignoreUnusedExtensions), but it is not required by the composer.json file!",
+ );
+ }
+ $config->ignore_unused_packages[$extensionName] = $config->required_packages[$extensionName];
+ }
+ }
+
+ if (isset($config_xml->ignoreUnusedComposerPackages)
+ && isset($config_xml->ignoreUnusedComposerPackages->package)) {
+ foreach ($config_xml->ignoreUnusedComposerPackages->package as $package) {
+ assert(isset($package["name"]));
+ $packageName = strtolower((string) $package["name"]);
+ if (!isset($config->required_packages[$packageName])) {
+ throw new ConfigException(
+ "The composer package $packageName was ignored in the Psalm configuration file"
+ ." (ignoreUnusedComposerPackages), but it is not required by the composer.json file!",
+ );
+ }
+ $config->ignore_unused_packages[$packageName] = $config->required_packages[$packageName];
+ }
+ }
+
if (isset($config_xml['phpVersion'])) {
$config->configured_php_version = (string) $config_xml['phpVersion'];
}
@@ -1352,16 +1410,20 @@ private static function fromXmlAndPaths(
);
}
+ $preload_classes = false;
if (isset($stub_file['preloadClasses'])) {
$preload_classes = (string)$stub_file['preloadClasses'];
-
- if ($preload_classes === 'true' || $preload_classes === '1') {
- $config->addPreloadedStubFile($file_path);
- } else {
- $config->addStubFile($file_path);
- }
+ $preload_classes = $preload_classes === 'true' || $preload_classes === '1';
+ }
+ $ext = null;
+ if (isset($stub_file['extension'])) {
+ $ext = (string) $stub_file['extension'];
+ $ext = $ext === '' ? null : strtolower($ext);
+ }
+ if ($preload_classes) {
+ $config->addPreloadedStubFile($file_path, $ext);
} else {
- $config->addStubFile($file_path);
+ $config->addStubFile($file_path, $ext);
}
}
}
@@ -2224,7 +2286,7 @@ public function visitPreloadedStubFiles(Codebase $codebase, ?Progress $progress
throw new UnexpectedValueException('Cannot locate PHP 8.0 classes');
}
- $core_generic_files[] = $stringable_path;
+ $core_generic_files[$stringable_path] = null;
}
if (PHP_VERSION_ID < 8_01_00 && $codebase->analysis_php_version_id >= 8_01_00) {
@@ -2234,7 +2296,7 @@ public function visitPreloadedStubFiles(Codebase $codebase, ?Progress $progress
throw new UnexpectedValueException('Cannot locate PHP 8.1 classes');
}
- $core_generic_files[] = $stringable_path;
+ $core_generic_files[$stringable_path] = null;
}
if (PHP_VERSION_ID < 8_02_00 && $codebase->analysis_php_version_id >= 8_02_00) {
@@ -2244,7 +2306,7 @@ public function visitPreloadedStubFiles(Codebase $codebase, ?Progress $progress
throw new UnexpectedValueException('Cannot locate PHP 8.2 classes');
}
- $core_generic_files[] = $stringable_path;
+ $core_generic_files[$stringable_path] = null;
}
if (PHP_VERSION_ID < 8_04_00 && $codebase->analysis_php_version_id >= 8_04_00) {
@@ -2254,7 +2316,7 @@ public function visitPreloadedStubFiles(Codebase $codebase, ?Progress $progress
throw new UnexpectedValueException('Cannot locate PHP 8.4 classes');
}
- $core_generic_files[] = $stringable_path;
+ $core_generic_files[$stringable_path] = null;
}
$stub_files = array_merge($core_generic_files, $this->preloaded_stub_files);
@@ -2263,7 +2325,7 @@ public function visitPreloadedStubFiles(Codebase $codebase, ?Progress $progress
return;
}
- foreach ($stub_files as $file_path) {
+ foreach ($stub_files as $file_path => $_) {
$file_path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $file_path);
// fix mangled phar paths on Windows
if (str_starts_with($file_path, 'phar:\\\\')) {
@@ -2294,46 +2356,40 @@ public function visitStubFiles(Codebase $codebase, ?Progress $progress = null):
$dir_lvl_2 = dirname(__DIR__, 2);
$stubsDir = $dir_lvl_2 . DIRECTORY_SEPARATOR . 'stubs' . DIRECTORY_SEPARATOR;
$this->internal_stubs = [
- $stubsDir . 'CoreGenericFunctions.phpstub',
- $stubsDir . 'CoreGenericClasses.phpstub',
- $stubsDir . 'CoreGenericIterators.phpstub',
- $stubsDir . 'CoreImmutableClasses.phpstub',
- $stubsDir . 'Reflection.phpstub',
- $stubsDir . 'SPL.phpstub',
- $stubsDir . 'CoreGenericAttributes.phpstub',
+ $stubsDir . 'CoreGenericFunctions.phpstub' => null,
+ $stubsDir . 'CoreGenericClasses.phpstub' => null,
+ $stubsDir . 'CoreGenericIterators.phpstub' => null,
+ $stubsDir . 'CoreImmutableClasses.phpstub' => null,
+ $stubsDir . 'Reflection.phpstub' => null,
+ $stubsDir . 'SPL.phpstub' => null,
+ $stubsDir . 'CoreGenericAttributes.phpstub' => null,
];
if ($codebase->analysis_php_version_id >= 7_04_00) {
- $this->internal_stubs[] = $stubsDir . 'Php74.phpstub';
+ $this->internal_stubs[$stubsDir . 'Php74.phpstub'] = null;
}
if ($codebase->analysis_php_version_id >= 8_00_00) {
- $this->internal_stubs[] = $stubsDir . 'Php80.phpstub';
+ $this->internal_stubs[$stubsDir . 'Php80.phpstub'] = null;
}
if ($codebase->analysis_php_version_id >= 8_01_00) {
- $this->internal_stubs[] = $stubsDir . 'Php81.phpstub';
+ $this->internal_stubs[$stubsDir . 'Php81.phpstub'] = null;
}
if ($codebase->analysis_php_version_id >= 8_02_00) {
- $this->internal_stubs[] = $stubsDir . 'Php82.phpstub';
+ $this->internal_stubs[$stubsDir . 'Php82.phpstub'] = null;
$this->php_extensions['random'] = true; // random is a part of the PHP core starting from PHP 8.2
}
if ($codebase->analysis_php_version_id >= 8_04_00) {
- $this->internal_stubs[] = $stubsDir . 'Php84.phpstub';
+ $this->internal_stubs[$stubsDir . 'Php84.phpstub'] = null;
}
- $ext_stubs_dir = $dir_lvl_2 . DIRECTORY_SEPARATOR . "stubs" . DIRECTORY_SEPARATOR . "extensions";
+ $ext_stubs_dir = $stubsDir . "extensions" . DIRECTORY_SEPARATOR;
foreach ($this->php_extensions as $ext => $enabled) {
if ($enabled) {
- $this->internal_stubs[] = $ext_stubs_dir . DIRECTORY_SEPARATOR . "$ext.phpstub";
- }
- }
-
- foreach ($this->internal_stubs as $stub_path) {
- if (!file_exists($stub_path)) {
- throw new UnexpectedValueException('Cannot locate ' . $stub_path);
+ $this->stub_files[$ext_stubs_dir . "$ext.phpstub"] = strtolower($ext);
}
}
@@ -2343,20 +2399,23 @@ public function visitStubFiles(Codebase $codebase, ?Progress $progress = null):
if ($this->use_phpstorm_meta_path) {
if (is_file($phpstorm_meta_path)) {
- $stub_files[] = $phpstorm_meta_path;
+ $stub_files[$phpstorm_meta_path ] = null;
} elseif (is_dir($phpstorm_meta_path)) {
$phpstorm_meta_path = (string) realpath($phpstorm_meta_path);
$phpstorm_meta_files = glob($phpstorm_meta_path . '/*.meta.php', GLOB_NOSORT);
foreach ($phpstorm_meta_files ?: [] as $glob) {
if (is_file($glob) && realpath(dirname($glob)) === $phpstorm_meta_path) {
- $stub_files[] = $glob;
+ $stub_files[$glob] = null;
}
}
}
}
- foreach ($stub_files as $file_path) {
+ foreach ($stub_files as $file_path => $_) {
+ if (!file_exists($file_path)) {
+ throw new UnexpectedValueException('Cannot locate ' . $file_path);
+ }
$file_path = str_replace(['/', '\\'], DIRECTORY_SEPARATOR, $file_path);
// fix mangled phar paths on Windows
if (str_starts_with($file_path, 'phar:\\\\')) {
@@ -2646,27 +2705,30 @@ public function setServerMode(): void
}
}
- public function addStubFile(string $stub_file): void
+ /** @param ?lowercase-string $extension */
+ public function addStubFile(string $stub_file, ?string $extension = null): void
{
- $this->stub_files[$stub_file] = $stub_file;
+ $this->stub_files[$stub_file] = $extension;
}
public function hasStubFile(string $stub_file): bool
{
- return isset($this->stub_files[$stub_file]);
+ return array_key_exists($stub_file, $this->stub_files);
}
/**
- * @return array
+ * @return array
*/
public function getStubFiles(): array
{
return $this->stub_files;
}
- public function addPreloadedStubFile(string $stub_file): void
+ /** @param ?lowercase-string $extension */
+ public function addPreloadedStubFile(string $stub_file, ?string $extension = null): void
{
- $this->preloaded_stub_files[$stub_file] = $stub_file;
+ $this->preloaded_stub_files[$stub_file] = null;
+ $this->stub_files[$stub_file] = $extension;
}
public function getPhpVersion(): ?string
diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallAnalyzer.php
index 250f4178ef6..67afd46fcb8 100644
--- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallAnalyzer.php
+++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallAnalyzer.php
@@ -158,6 +158,12 @@ public static function analyze(
}
}
+ if ($function_call_info->function_storage instanceof FunctionStorage) {
+ $codebase->file_reference_provider->addReferenceToFunction(
+ $function_call_info->function_storage,
+ );
+ }
+
$set_inside_conditional = false;
if ($function_name instanceof PhpParser\Node\Name
diff --git a/src/Psalm/Internal/Codebase/Analyzer.php b/src/Psalm/Internal/Codebase/Analyzer.php
index ee664870864..337f90b9097 100644
--- a/src/Psalm/Internal/Codebase/Analyzer.php
+++ b/src/Psalm/Internal/Codebase/Analyzer.php
@@ -68,6 +68,7 @@
* @psalm-type WorkerData = array{
* issues: array>,
* fixable_issue_counts: array,
+ * references_to_functions: array>,
* nonmethod_references_to_classes: array>,
* method_references_to_classes: array>,
* file_references_to_class_members: array>,
@@ -342,6 +343,8 @@ private function doAnalysis(ProjectAnalyzer $project_analyzer, int $pool_size):
$codebase->taint_flow_graph->addGraph($pool_data['taint_data']);
}
+ $codebase->file_reference_provider->addReferencesToFunctions($pool_data['references_to_functions']);
+
$codebase->file_reference_provider->addNonMethodReferencesToClasses(
$pool_data['nonmethod_references_to_classes'],
);
diff --git a/src/Psalm/Internal/Codebase/ClassLikes.php b/src/Psalm/Internal/Codebase/ClassLikes.php
index 8087baa8de9..7caaf5048ab 100644
--- a/src/Psalm/Internal/Codebase/ClassLikes.php
+++ b/src/Psalm/Internal/Codebase/ClassLikes.php
@@ -30,7 +30,11 @@
use Psalm\Issue\PossiblyUnusedProperty;
use Psalm\Issue\PossiblyUnusedReturnValue;
use Psalm\Issue\UnusedClass;
+use Psalm\Issue\UnusedComposerPackage;
use Psalm\Issue\UnusedConstructor;
+use Psalm\Issue\UnusedExtension;
+use Psalm\Issue\UnusedFunction;
+use Psalm\Issue\UnusedIssueHandlerSuppression;
use Psalm\Issue\UnusedMethod;
use Psalm\Issue\UnusedParam;
use Psalm\Issue\UnusedProperty;
@@ -50,6 +54,7 @@
use UnexpectedValueException;
use function array_filter;
+use function array_key_exists;
use function array_keys;
use function array_merge;
use function array_pop;
@@ -62,6 +67,8 @@
use function preg_match;
use function preg_quote;
use function preg_replace;
+use function str_contains;
+use function str_starts_with;
use function strlen;
use function strrpos;
use function strtolower;
@@ -833,6 +840,33 @@ public function consolidateAnalyzedData(Methods $methods, ?Progress $progress, b
$project_analyzer = ProjectAnalyzer::getInstance();
$codebase = $project_analyzer->getCodebase();
+ $unused_packages = $codebase->config->required_packages;
+
+ $referenced_functions = $this->file_reference_provider->getAllReferencesToFunctions();
+ foreach ($codebase->file_storage_provider->getAll() as $f => $storage) {
+ if (!array_key_exists($f, $referenced_functions)) {
+ continue;
+ }
+ $reffed = $referenced_functions[$f];
+ foreach ($storage->functions as $name => $storage) {
+ if (array_key_exists($name, $reffed)) {
+ $composer = $storage->composer_package;
+ if ($composer !== null) {
+ unset($unused_packages[$composer]);
+ }
+ } elseif ($find_unused_code && $storage->location !== null) {
+ IssueBuffer::maybeAdd(
+ new UnusedFunction(
+ 'Function ' . $storage->cased_name . ' is never used',
+ $storage->location,
+ strtolower($name),
+ ),
+ $storage->suppressed_issues,
+ );
+ }
+ }
+ }
+
foreach ($this->existing_classlikes_lc as $fq_class_name_lc => $_) {
try {
$classlike_storage = $this->classlike_storage_provider->get($fq_class_name_lc);
@@ -840,6 +874,12 @@ public function consolidateAnalyzedData(Methods $methods, ?Progress $progress, b
continue;
}
+ if ($classlike_storage->composer_package !== null
+ && $this->file_reference_provider->isClassReferenced($fq_class_name_lc)
+ ) {
+ unset($unused_packages[$classlike_storage->composer_package]);
+ }
+
if ($classlike_storage->location
&& $this->config->isInProjectDirs($classlike_storage->location->file_path)
&& !$classlike_storage->is_trait
@@ -931,6 +971,42 @@ public function consolidateAnalyzedData(Methods $methods, ?Progress $progress, b
}
}
}
+
+ $ignore = $codebase->config->ignore_unused_packages;
+
+ foreach ($unused_packages as $package => $loc) {
+ if (isset($ignore[$package])) {
+ unset($ignore[$package]);
+ continue;
+ }
+ if (str_starts_with($package, 'ext-') && !str_contains($package, '/')) {
+ $package = substr($package, 4);
+ IssueBuffer::maybeAdd(new UnusedExtension("Extension $package required in composer.json is not used in the project", $loc));
+ continue;
+ }
+ // TODO: check with actual version constraint
+ if (str_starts_with($package, 'symfony/polyfill-php')) {
+ continue;
+ }
+ IssueBuffer::maybeAdd(new UnusedComposerPackage("Package $package required in composer.json is not used in the project", $loc));
+ }
+
+ foreach ($ignore as $package => $loc) {
+ if (str_starts_with($package, 'ext-') && !str_contains($package, '/')) {
+ $package = substr($package, 4);
+ IssueBuffer::maybeAdd(new UnusedIssueHandlerSuppression(
+ "Extension $package ignored in the Psalm configuration file using ignoreUnusedExtensions"
+ ." and required in composer.json is actually used in the project",
+ $loc,
+ ));
+ continue;
+ }
+ IssueBuffer::maybeAdd(new UnusedIssueHandlerSuppression(
+ "Package $package ignored in the Psalm configuration file using ignoreUnusedComposerPackages"
+ ." and required in composer.json is actually used in the project",
+ $loc,
+ ));
+ }
}
public static function makeImmutable(
diff --git a/src/Psalm/Internal/Codebase/Reflection.php b/src/Psalm/Internal/Codebase/Reflection.php
index 2e587187463..a75fcdba3e3 100644
--- a/src/Psalm/Internal/Codebase/Reflection.php
+++ b/src/Psalm/Internal/Codebase/Reflection.php
@@ -80,6 +80,11 @@ public function registerClass(ReflectionClass $reflected_class): void
$storage->potential_declaring_method_ids['__construct'][$class_name_lower . '::__construct'] = true;
+ $ext = $reflected_class->getExtensionName();
+ if ($ext !== false) {
+ $storage->composer_package = strtolower("ext-$ext");
+ }
+
if ($reflected_parent_class) {
$parent_class_name = $reflected_parent_class->getName();
$this->registerClass($reflected_parent_class);
@@ -251,6 +256,10 @@ public function extractReflectionMethodInfo(ReflectionMethod $method): void
$storage = $class_storage->methods[$method_name_lc] = new MethodStorage();
+ $ext = $method->getExtensionName();
+ if ($ext !== false) {
+ $storage->composer_package = strtolower("ext-$ext");
+ }
$storage->cased_name = $method->name;
$storage->defining_fqcln = $method->class;
@@ -369,6 +378,11 @@ public function registerFunction(string $function_id): ?bool
$storage = self::$builtin_functions[$function_id] = new FunctionStorage();
+ $ext = $reflection_function->getExtensionName();
+ if ($ext !== false) {
+ $storage->composer_package = strtolower("ext-$ext");
+ }
+
if (InternalCallMapHandler::inCallMap($function_id)) {
$callmap_callable = InternalCallMapHandler::getCallableFromCallMapById(
$this->codebase,
diff --git a/src/Psalm/Internal/Codebase/Scanner.php b/src/Psalm/Internal/Codebase/Scanner.php
index fc8f73394b4..c615e8914cb 100644
--- a/src/Psalm/Internal/Codebase/Scanner.php
+++ b/src/Psalm/Internal/Codebase/Scanner.php
@@ -4,6 +4,7 @@
namespace Psalm\Internal\Codebase;
+use Psalm\CodeLocation;
use Psalm\Codebase;
use Psalm\Config;
use Psalm\Internal\Analyzer\IssueData;
@@ -31,6 +32,7 @@
use function array_filter;
use function array_merge;
use function array_pop;
+use function assert;
use function ceil;
use function count;
use function error_reporting;
@@ -39,6 +41,10 @@
use function min;
use function realpath;
use function str_ends_with;
+use function str_replace;
+use function str_starts_with;
+use function strlen;
+use function strpos;
use function strtolower;
use function substr;
@@ -84,9 +90,6 @@
* global_constants: array,
* global_functions: array
* }
- */
-
-/**
* @internal
*
* Contains methods that aid in the scanning of Psalm's codebase
@@ -138,6 +141,8 @@ final class Scanner
*/
private array $reflected_classlikes_lc = [];
+ private readonly string $vendor_prefix;
+
private bool $is_forked = false;
public function __construct(
@@ -149,6 +154,41 @@ public function __construct(
private readonly FileReferenceProvider $file_reference_provider,
private readonly Progress $progress,
) {
+ $this->vendor_prefix = $config->base_dir.DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR;
+ }
+
+ /**
+ * @return ?lowercase-string
+ */
+ public function getComposerPackage(CodeLocation $path): ?string
+ {
+ $path = $path->file_path;
+ $ext = $this->config->stub_files[$path] ?? null;
+ if ($ext !== null) {
+ return "ext-$ext";
+ }
+ if (str_starts_with($path, $this->vendor_prefix)) {
+ $l = strlen($this->vendor_prefix);
+ $pos = strpos($path, DIRECTORY_SEPARATOR, $l);
+ assert($pos !== false);
+ $pos = strpos(
+ $path,
+ DIRECTORY_SEPARATOR,
+ $pos+1,
+ );
+ if ($pos !== false) {
+ $res = substr(
+ $path,
+ $l,
+ $pos-$l,
+ );
+ if (DIRECTORY_SEPARATOR === '\\') {
+ $res = str_replace('\\', '/', $res);
+ }
+ return strtolower($res);
+ }
+ }
+ return null;
}
/**
diff --git a/src/Psalm/Internal/Fork/InitAnalyzerTask.php b/src/Psalm/Internal/Fork/InitAnalyzerTask.php
index 9772857c181..1fd0674a19a 100644
--- a/src/Psalm/Internal/Fork/InitAnalyzerTask.php
+++ b/src/Psalm/Internal/Fork/InitAnalyzerTask.php
@@ -34,6 +34,7 @@ public function run(Channel $channel, Cancellation $cancellation): mixed
$file_reference_provider->setCallingMethodReferencesToClassProperties([]);
$file_reference_provider->setFileReferencesToClassMembers([]);
$file_reference_provider->setFileReferencesToClassProperties([]);
+ $file_reference_provider->setReferencesToFunctions([]);
$file_reference_provider->setCallingMethodReferencesToMissingClassMembers([]);
$file_reference_provider->setFileReferencesToMissingClassMembers([]);
$file_reference_provider->setReferencesToMixedMemberNames([]);
diff --git a/src/Psalm/Internal/Fork/ShutdownAnalyzerTask.php b/src/Psalm/Internal/Fork/ShutdownAnalyzerTask.php
index 5b92e077070..0c3f26f5673 100644
--- a/src/Psalm/Internal/Fork/ShutdownAnalyzerTask.php
+++ b/src/Psalm/Internal/Fork/ShutdownAnalyzerTask.php
@@ -38,6 +38,7 @@ public function run(Channel $channel, Cancellation $cancellation): mixed
return [
'issues' => IssueBuffer::getIssuesData(),
'fixable_issue_counts' => IssueBuffer::getFixableIssues(),
+ 'references_to_functions' => $file_reference_provider->getAllReferencesToFunctions(),
'nonmethod_references_to_classes' => $file_reference_provider->getAllNonMethodReferencesToClasses(),
'method_references_to_classes' => $file_reference_provider->getAllMethodReferencesToClasses(),
'file_references_to_class_members' => $file_reference_provider->getAllFileReferencesToClassMembers(),
diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php
index 8ccf525ba09..7c5f19a8d7e 100644
--- a/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php
+++ b/src/Psalm/Internal/PhpVisitor/Reflector/ClassLikeNodeScanner.php
@@ -251,6 +251,8 @@ public function start(PhpParser\Node\Stmt\ClassLike $node): ?bool
$storage->stubbed = $this->codebase->register_stub_files;
$storage->aliases = $this->aliases;
+ $storage->composer_package ??= $this->codebase->scanner->getComposerPackage($class_location);
+
if ($node instanceof PhpParser\Node\Stmt\Class_) {
$storage->abstract = $node->isAbstract();
$storage->final = $node->isFinal();
diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockScanner.php b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockScanner.php
index a7d76d50257..c64e03e3c5d 100644
--- a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockScanner.php
+++ b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeDocblockScanner.php
@@ -51,11 +51,11 @@
use function array_any;
use function array_filter;
+use function array_key_exists;
use function array_merge;
use function array_values;
use function count;
use function explode;
-use function in_array;
use function preg_last_error_msg;
use function preg_match;
use function preg_replace;
@@ -150,7 +150,7 @@ public static function addDocblockInfo(
if ($docblock_info->ignore_nullable_return
&& $storage->return_type
&& ($codebase->config->ignore_internal_nullable_issues
- || !in_array($file_storage->file_path, $codebase->config->internal_stubs)
+ || !array_key_exists($file_storage->file_path, $codebase->config->internal_stubs)
)
) {
/** @psalm-suppress InaccessibleProperty We just created this type */
@@ -161,7 +161,7 @@ public static function addDocblockInfo(
if ($docblock_info->ignore_falsable_return
&& $storage->return_type
&& ($codebase->config->ignore_internal_falsable_issues
- || !in_array($file_storage->file_path, $codebase->config->internal_stubs)
+ || !array_key_exists($file_storage->file_path, $codebase->config->internal_stubs)
)
) {
/** @psalm-suppress InaccessibleProperty We just created this type */
@@ -1046,7 +1046,7 @@ private static function handleReturn(
if ($docblock_info->ignore_nullable_return
&& $storage->return_type
&& ($codebase->config->ignore_internal_nullable_issues
- || !in_array($file_storage->file_path, $codebase->config->internal_stubs)
+ || !array_key_exists($file_storage->file_path, $codebase->config->internal_stubs)
)
) {
/** @psalm-suppress InaccessibleProperty We just created this type */
@@ -1057,7 +1057,7 @@ private static function handleReturn(
if ($docblock_info->ignore_falsable_return
&& $storage->return_type
&& ($codebase->config->ignore_internal_falsable_issues
- || !in_array($file_storage->file_path, $codebase->config->internal_stubs)
+ || !array_key_exists($file_storage->file_path, $codebase->config->internal_stubs)
)
) {
/** @psalm-suppress InaccessibleProperty We just created this type */
diff --git a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeNodeScanner.php b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeNodeScanner.php
index 8829d04dfc7..8713a3e60d4 100644
--- a/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeNodeScanner.php
+++ b/src/Psalm/Internal/PhpVisitor/Reflector/FunctionLikeNodeScanner.php
@@ -53,13 +53,13 @@
use ReflectionFunction;
use UnexpectedValueException;
+use function array_key_exists;
use function array_keys;
use function array_pop;
use function array_search;
use function count;
use function end;
use function explode;
-use function in_array;
use function is_string;
use function spl_object_id;
use function str_contains;
@@ -142,6 +142,7 @@ public function start(
} else {
$storage->location = new CodeLocation($this->file_scanner, $stmt, null, true);
}
+ $storage->composer_package ??= $this->codebase->scanner->getComposerPackage($storage->location);
$storage->stmt_location = new CodeLocation($this->file_scanner, $stmt);
@@ -180,7 +181,7 @@ public function start(
}
if ($param_storage->name === 'haystack'
- && in_array($this->file_path, $this->codebase->config->internal_stubs)
+ && array_key_exists($this->file_path, $this->codebase->config->internal_stubs)
) {
$param_storage->expect_variable = true;
}
diff --git a/src/Psalm/Internal/PreloaderList.php b/src/Psalm/Internal/PreloaderList.php
index 71f378d171d..c0a93eb0f67 100644
--- a/src/Psalm/Internal/PreloaderList.php
+++ b/src/Psalm/Internal/PreloaderList.php
@@ -573,6 +573,7 @@ final class PreloaderList {
\PhpParser\Token::class,
\Psalm\Aliases::class,
\Psalm\CodeLocation::class,
+ \Psalm\CodeLocation\ComposerJsonLocation::class,
\Psalm\CodeLocation\DocblockTypeLocation::class,
\Psalm\CodeLocation\ParseErrorLocation::class,
\Psalm\CodeLocation\Raw::class,
@@ -1340,9 +1341,12 @@ final class PreloaderList {
\Psalm\Issue\UnusedBaselineEntry::class,
\Psalm\Issue\UnusedClass::class,
\Psalm\Issue\UnusedClosureParam::class,
+ \Psalm\Issue\UnusedComposerPackage::class,
\Psalm\Issue\UnusedConstructor::class,
\Psalm\Issue\UnusedDocblockParam::class,
+ \Psalm\Issue\UnusedExtension::class,
\Psalm\Issue\UnusedForeachValue::class,
+ \Psalm\Issue\UnusedFunction::class,
\Psalm\Issue\UnusedFunctionCall::class,
\Psalm\Issue\UnusedIssueHandlerSuppression::class,
\Psalm\Issue\UnusedMethod::class,
diff --git a/src/Psalm/Internal/Provider/FileReferenceProvider.php b/src/Psalm/Internal/Provider/FileReferenceProvider.php
index 22d6a3fe48b..8f644cb2340 100644
--- a/src/Psalm/Internal/Provider/FileReferenceProvider.php
+++ b/src/Psalm/Internal/Provider/FileReferenceProvider.php
@@ -9,13 +9,16 @@
use Psalm\Internal\Analyzer\ClassLikeAnalyzer;
use Psalm\Internal\Analyzer\IssueData;
use Psalm\Internal\Codebase\Analyzer;
+use Psalm\Storage\FunctionStorage;
use UnexpectedValueException;
use function array_filter;
use function array_keys;
use function array_merge;
use function array_unique;
+use function array_values;
use function explode;
+use function strtolower;
/**
* Used to determine which files reference other files, necessary for using the --diff
@@ -28,6 +31,13 @@ final class FileReferenceProvider
{
private bool $loaded_from_cache = false;
+ /**
+ * Filepath => (function_id => true)
+ *
+ * @var array>
+ */
+ private static array $references_to_functions = [];
+
/**
* A lookup table used for getting all the references to a class not inside a method
* indexed by file
@@ -186,6 +196,32 @@ public function getDeletedReferencedFiles(): array
return self::$deleted_files;
}
+ public function addReferenceToFunction(FunctionStorage $storage): void
+ {
+ $f = $storage->location?->file_name;
+ if ($f !== null && $storage->cased_name !== null) {
+ self::$references_to_functions[strtolower($f)][$storage->cased_name] = true;
+ }
+ }
+ /**
+ * @return array>
+ */
+ public function getAllReferencesToFunctions(): array
+ {
+ return self::$references_to_functions;
+ }
+
+ /**
+ * @param array> $references
+ */
+ public function addReferencesToFunctions(array $references): void
+ {
+ foreach ($references as $f => $refs) {
+ self::$references_to_functions[$f] ??= [];
+ self::$references_to_functions[$f] += $refs;
+ }
+ }
+
/**
* @param lowercase-string $fq_class_name_lc
*/
@@ -360,7 +396,7 @@ public function addMethodParamUse(string $method_id, int $offset, string $refere
}
/**
- * @return array
+ * @return list
*/
private function calculateFilesReferencingFile(Codebase $codebase, string $file): array
{
@@ -392,7 +428,7 @@ private function calculateFilesReferencingFile(Codebase $codebase, string $file)
}
}
- return array_unique($referenced_files);
+ return array_values(array_unique($referenced_files));
}
/**
@@ -1022,6 +1058,14 @@ public function addMethodParamUses(array $references): void
}
}
+ /**
+ * @param array> $references
+ */
+ public function setReferencesToFunctions(array $references): void
+ {
+ self::$references_to_functions = $references;
+ }
+
/**
* @param array> $references
*/
@@ -1275,5 +1319,6 @@ public static function clearCache(): void
self::$method_param_uses = [];
self::$classlike_files = [];
self::$mixed_counts = [];
+ self::$references_to_functions = [];
}
}
diff --git a/src/Psalm/Issue/FunctionIssue.php b/src/Psalm/Issue/FunctionIssue.php
index 9d3b5ee05d8..4f5f18d7fb4 100644
--- a/src/Psalm/Issue/FunctionIssue.php
+++ b/src/Psalm/Issue/FunctionIssue.php
@@ -6,18 +6,14 @@
use Psalm\CodeLocation;
-use function strtolower;
-
abstract class FunctionIssue extends CodeIssue
{
- public string $function_id;
-
public function __construct(
string $message,
CodeLocation $code_location,
- string $function_id,
+ /** @var lowercase-string */
+ public readonly string $function_id,
) {
parent::__construct($message, $code_location);
- $this->function_id = strtolower($function_id);
}
}
diff --git a/src/Psalm/Issue/UnusedComposerPackage.php b/src/Psalm/Issue/UnusedComposerPackage.php
new file mode 100644
index 00000000000..2a35ae6b2c0
--- /dev/null
+++ b/src/Psalm/Issue/UnusedComposerPackage.php
@@ -0,0 +1,11 @@
+getFilePath();
- if (!$e instanceof ConfigIssue && !$config->reportIssueInFile($issue_type, $file_path)) {
+ if (!$e instanceof ConfigIssue
+ && !$e instanceof UnusedExtension
+ && !$e instanceof UnusedComposerPackage
+ && !$e instanceof UnusedIssueHandlerSuppression
+ && !$config->reportIssueInFile($issue_type, $file_path)
+ ) {
return true;
}
diff --git a/src/Psalm/Plugin/RegistrationInterface.php b/src/Psalm/Plugin/RegistrationInterface.php
index 47478777b91..c185c96b2b1 100644
--- a/src/Psalm/Plugin/RegistrationInterface.php
+++ b/src/Psalm/Plugin/RegistrationInterface.php
@@ -6,7 +6,7 @@
interface RegistrationInterface
{
- public function addStubFile(string $file_name): void;
+ public function addStubFile(string $file_name, ?string $extension = null): void;
/**
* @param class-string $handler
diff --git a/src/Psalm/PluginRegistrationSocket.php b/src/Psalm/PluginRegistrationSocket.php
index a6f7789ca41..f0175b31d1d 100644
--- a/src/Psalm/PluginRegistrationSocket.php
+++ b/src/Psalm/PluginRegistrationSocket.php
@@ -34,9 +34,9 @@ public function __construct(
}
#[Override]
- public function addStubFile(string $file_name): void
+ public function addStubFile(string $file_name, ?string $extension = null): void
{
- $this->config->addStubFile($file_name);
+ $this->config->addStubFile($file_name, $extension);
}
#[Override]
diff --git a/src/Psalm/Storage/ClassLikeStorage.php b/src/Psalm/Storage/ClassLikeStorage.php
index 9e1b92723d3..c1d75b57d72 100644
--- a/src/Psalm/Storage/ClassLikeStorage.php
+++ b/src/Psalm/Storage/ClassLikeStorage.php
@@ -26,6 +26,9 @@ final class ClassLikeStorage implements HasAttributesInterface
use CustomMetadataTrait;
use UnserializeMemoryUsageSuppressionTrait;
+ /** @var ?lowercase-string */
+ public ?string $composer_package = null;
+
/**
* @var array
*/
diff --git a/src/Psalm/Storage/FunctionLikeStorage.php b/src/Psalm/Storage/FunctionLikeStorage.php
index 15dd876c10a..4fd31599ddc 100644
--- a/src/Psalm/Storage/FunctionLikeStorage.php
+++ b/src/Psalm/Storage/FunctionLikeStorage.php
@@ -22,6 +22,9 @@ abstract class FunctionLikeStorage implements HasAttributesInterface, Stringable
use CustomMetadataTrait;
use UnserializeMemoryUsageSuppressionTrait;
+ /** @var ?lowercase-string */
+ public ?string $composer_package = null;
+
public ?CodeLocation $location = null;
public ?CodeLocation $stmt_location = null;
diff --git a/tests/Config/ConfigTest.php b/tests/Config/ConfigTest.php
index 49998b46a63..eb855061844 100644
--- a/tests/Config/ConfigTest.php
+++ b/tests/Config/ConfigTest.php
@@ -27,6 +27,7 @@
use Psalm\Tests\TestCase;
use Psalm\Tests\TestConfig;
+use function array_diff;
use function array_map;
use function dirname;
use function error_get_last;
@@ -1111,7 +1112,10 @@ public function testAllPossibleIssues(): void
* @return string
*/
static fn($issue_name): string => '<' . $issue_name . ' errorLevel="suppress" />' . "\n",
- IssueHandler::getAllIssueTypes(),
+ array_diff(
+ IssueHandler::getAllIssueTypes(),
+ ['UnusedComposerPackage', 'UnusedExtension'],
+ ),
),
);