diff --git a/docs/running_psalm/checking_non_php_files.md b/docs/running_psalm/checking_non_php_files.md index dcadba5fb85..46a51c809c1 100644 --- a/docs/running_psalm/checking_non_php_files.md +++ b/docs/running_psalm/checking_non_php_files.md @@ -23,14 +23,21 @@ Plugins can register their own custom scanner and analyzer implementations for namespace Psalm\Example; use Psalm\Plugin\PluginEntryPointInterface; +use Psalm\Plugin\PluginFileExtensionsInterface; +use Psalm\Plugin\FileExtensionsInterface; use Psalm\Plugin\RegistrationInterface; -class CustomPlugin implements PluginEntryPointInterface +class CustomPlugin implements PluginEntryPointInterface, PluginFileExtensionsInterface { public function __invoke(RegistrationInterface $registration, ?\SimpleXMLElement $config = null): void { - $registration->addFileTypeScanner('phpt', TemplateScanner::class); - $registration->addFileTypeAnalyzer('phpt', TemplateAnalyzer::class); + // ... regular plugin processes, stub registration, hook registration } + + public function processFileExtensions(FileExtensionsInterface $fileExtensions, ?SimpleXMLElement $config = null): void + { + $fileExtensions->addFileTypeScanner('phpt', TemplateScanner::class); + $fileExtensions->addFileTypeAnalyzer('phpt', TemplateAnalyzer::class); + } } ``` diff --git a/docs/running_psalm/plugins/authoring_plugins.md b/docs/running_psalm/plugins/authoring_plugins.md index 494313970f1..c04f36cb628 100644 --- a/docs/running_psalm/plugins/authoring_plugins.md +++ b/docs/running_psalm/plugins/authoring_plugins.md @@ -39,8 +39,8 @@ To register a stub file manually use `Psalm\Plugin\RegistrationInterface::addStu In addition to XML configuration node `` plugins can register their own custom scanner and analyzer implementations for particular file extensions, e.g. -* `Psalm\Plugin\RegistrationInterface::addFileTypeScanner('html', CustomFileScanner::class)` -* `Psalm\Plugin\RegistrationInterface::addFileTypeAnalyzer('html', CustomFileAnalyzer::class)` +* `Psalm\Plugin\FileExtensionsInterface::addFileTypeScanner('html', CustomFileScanner::class)` +* `Psalm\Plugin\FileExtensionsInterface::addFileTypeAnalyzer('html', CustomFileAnalyzer::class)` ## Publishing your plugin on Packagist diff --git a/src/Psalm/Config.php b/src/Psalm/Config.php index 559b469a2aa..bdb1c780de8 100644 --- a/src/Psalm/Config.php +++ b/src/Psalm/Config.php @@ -27,6 +27,9 @@ use Psalm\Issue\MethodIssue; use Psalm\Issue\PropertyIssue; use Psalm\Issue\VariableIssue; +use Psalm\Plugin\PluginEntryPointInterface; +use Psalm\Plugin\PluginFileExtensionsInterface; +use Psalm\Plugin\PluginInterface; use Psalm\Progress\Progress; use Psalm\Progress\VoidProgress; use SimpleXMLElement; @@ -552,6 +555,11 @@ class Config */ public $trigger_error_exits = 'default'; + /** + * @var array + */ + private $plugins = []; + protected function __construct() { self::$instance = $this; @@ -1236,6 +1244,32 @@ public function getPluginClasses(): array return $this->plugin_classes; } + public function processPluginFileExtensions(ProjectAnalyzer $projectAnalyzer): void + { + $projectAnalyzer->progress->debug('Process plugin adjustments...' . PHP_EOL); + $socket = new PluginFileExtensionsSocket($this); + foreach ($this->plugin_classes as $pluginClassEntry) { + $pluginClassName = $pluginClassEntry['class']; + $pluginConfig = $pluginClassEntry['config']; + $plugin = $this->loadPlugin($projectAnalyzer, $pluginClassName); + if (!$plugin instanceof PluginFileExtensionsInterface) { + continue; + } + $plugin->processFileExtensions($socket, $pluginConfig); + $projectAnalyzer->progress->debug('Initialized plugin ' . $pluginClassName . ' successfully' . PHP_EOL); + } + // populate additional aspects after plugins have been initialized + foreach ($socket->getAdditionalFileExtensions() as $fileExtension) { + $this->file_extensions[] = $fileExtension; + } + foreach ($socket->getAdditionalFileTypeScanners() as $extension => $className) { + $this->filetype_scanners[$extension] = $className; + } + foreach ($socket->getAdditionalFileTypeAnalyzers() as $extension => $className) { + $this->filetype_analyzers[$extension] = $className; + } + } + /** * Initialises all the plugins (done once the config is fully loaded) * @@ -1253,38 +1287,12 @@ public function initializePlugins(ProjectAnalyzer $project_analyzer): void $plugin_class_name = $plugin_class_entry['class']; $plugin_config = $plugin_class_entry['config']; - try { - // Below will attempt to load plugins from the project directory first. - // Failing that, it will use registered autoload chain, which will load - // plugins from Psalm directory or phar file. If that fails as well, it - // will fall back to project autoloader. It may seem that the last step - // will always fail, but it's only true if project uses Composer autoloader - if ($this->composer_class_loader - && ($plugin_class_path = $this->composer_class_loader->findFile($plugin_class_name)) - ) { - $project_analyzer->progress->debug( - 'Loading plugin ' . $plugin_class_name . ' via require' . PHP_EOL - ); - - self::requirePath($plugin_class_path); - } else { - if (!class_exists($plugin_class_name)) { - throw new \UnexpectedValueException($plugin_class_name . ' is not a known class'); - } - } - - /** - * @psalm-suppress InvalidStringClass - * - * @var Plugin\PluginEntryPointInterface - */ - $plugin_object = new $plugin_class_name; - $plugin_object($socket, $plugin_config); - } catch (\Throwable $e) { - throw new ConfigException('Failed to load plugin ' . $plugin_class_name, 0, $e); + $plugin = $this->loadPlugin($project_analyzer, $plugin_class_name); + if (!$plugin instanceof PluginEntryPointInterface) { + continue; } - - $project_analyzer->progress->debug('Loaded plugin ' . $plugin_class_name . ' successfully' . PHP_EOL); + $plugin($socket, $plugin_config); + $project_analyzer->progress->debug('Initialized plugin ' . $plugin_class_name . ' successfully' . PHP_EOL); } foreach ($this->filetype_scanner_paths as $extension => $path) { @@ -1313,28 +1321,53 @@ public function initializePlugins(ProjectAnalyzer $project_analyzer): void foreach ($this->plugin_paths as $path) { try { - $plugin_object = new FileBasedPluginAdapter($path, $this, $codebase); - $plugin_object($socket); + $plugin = new FileBasedPluginAdapter($path, $this, $codebase); + $plugin($socket); } catch (\Throwable $e) { throw new ConfigException('Failed to load plugin ' . $path, 0, $e); } } - // populate additional aspects after plugins have been initialized - foreach ($socket->getAdditionalFileExtensions() as $fileExtension) { - $this->file_extensions[] = $fileExtension; - } - foreach ($socket->getAdditionalFileTypeScanners() as $extension => $className) { - $this->filetype_scanners[$extension] = $className; - } - foreach ($socket->getAdditionalFileTypeAnalyzers() as $extension => $className) { - $this->filetype_analyzers[$extension] = $className; - } new \Psalm\Internal\Provider\AddRemoveTaints\HtmlFunctionTainter(); $socket->registerHooksFromClass(\Psalm\Internal\Provider\AddRemoveTaints\HtmlFunctionTainter::class); } + private function loadPlugin(ProjectAnalyzer $projectAnalyzer, string $pluginClassName): PluginInterface + { + if (isset($this->plugins[$pluginClassName])) { + return $this->plugins[$pluginClassName]; + } + try { + // Below will attempt to load plugins from the project directory first. + // Failing that, it will use registered autoload chain, which will load + // plugins from Psalm directory or phar file. If that fails as well, it + // will fall back to project autoloader. It may seem that the last step + // will always fail, but it's only true if project uses Composer autoloader + if ($this->composer_class_loader + && ($pluginclas_class_path = $this->composer_class_loader->findFile($pluginClassName)) + ) { + $projectAnalyzer->progress->debug( + 'Loading plugin ' . $pluginClassName . ' via require' . PHP_EOL + ); + + self::requirePath($pluginclas_class_path); + } else { + if (!class_exists($pluginClassName)) { + throw new \UnexpectedValueException($pluginClassName . ' is not a known class'); + } + } + if (!is_a($pluginClassName, PluginInterface::class, true)) { + throw new \UnexpectedValueException($pluginClassName . ' is not a PluginInterface implementation'); + } + $this->plugins[$pluginClassName] = new $pluginClassName; + $projectAnalyzer->progress->debug('Loaded plugin ' . $pluginClassName . PHP_EOL); + return $this->plugins[$pluginClassName]; + } catch (\Throwable $e) { + throw new ConfigException('Failed to load plugin ' . $pluginClassName, 0, $e); + } + } + private static function requirePath(string $path): void { /** @psalm-suppress UnresolvableInclude */ diff --git a/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php b/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php index d0d1766750e..39778d978d9 100644 --- a/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/ProjectAnalyzer.php @@ -289,6 +289,7 @@ public function __construct( $this->stdout_report_options = $stdout_report_options; $this->generated_report_options = $generated_report_options; + $this->config->processPluginFileExtensions($this); $file_extensions = $this->config->getFileExtensions(); foreach ($this->config->getProjectDirectories() as $dir_name) { diff --git a/src/Psalm/Plugin/FileExtensionsInterface.php b/src/Psalm/Plugin/FileExtensionsInterface.php new file mode 100644 index 00000000000..2f762cc5a91 --- /dev/null +++ b/src/Psalm/Plugin/FileExtensionsInterface.php @@ -0,0 +1,20 @@ + $className + */ + public function addFileTypeScanner(string $fileExtension, string $className): void; + + /** + * @param string $fileExtension e.g. `'html'` + * @param class-string $className + */ + public function addFileTypeAnalyzer(string $fileExtension, string $className): void; +} diff --git a/src/Psalm/Plugin/PluginEntryPointInterface.php b/src/Psalm/Plugin/PluginEntryPointInterface.php index 64e037be106..cfaffe2ff56 100644 --- a/src/Psalm/Plugin/PluginEntryPointInterface.php +++ b/src/Psalm/Plugin/PluginEntryPointInterface.php @@ -3,7 +3,7 @@ use SimpleXMLElement; -interface PluginEntryPointInterface +interface PluginEntryPointInterface extends PluginInterface { public function __invoke(RegistrationInterface $registration, ?SimpleXMLElement $config = null): void; } diff --git a/src/Psalm/Plugin/PluginFileExtensionsInterface.php b/src/Psalm/Plugin/PluginFileExtensionsInterface.php new file mode 100644 index 00000000000..8f84c6fc65d --- /dev/null +++ b/src/Psalm/Plugin/PluginFileExtensionsInterface.php @@ -0,0 +1,12 @@ + $className - */ - public function addFileTypeScanner(string $fileExtension, string $className): void; - - /** - * @param string $fileExtension e.g. `'html'` - * @param class-string $className - */ - public function addFileTypeAnalyzer(string $fileExtension, string $className): void; } diff --git a/src/Psalm/PluginFileExtensionsSocket.php b/src/Psalm/PluginFileExtensionsSocket.php new file mode 100644 index 00000000000..3d669c2c495 --- /dev/null +++ b/src/Psalm/PluginFileExtensionsSocket.php @@ -0,0 +1,136 @@ +> + */ + private $additionalFileTypeScanners = []; + + /** + * @var array> + */ + private $additionalFileTypeAnalyzers = []; + + /** + * @var list + */ + private $additionalFileExtensions = []; + + /** + * @internal + */ + public function __construct(Config $config) + { + $this->config = $config; + } + + /** + * @param string $fileExtension e.g. `'html'` + * @param class-string $className + */ + public function addFileTypeScanner(string $fileExtension, string $className): void + { + if (!class_exists($className) || !is_a($className, FileScanner::class, true)) { + throw new LogicException( + sprintf( + 'Class %s must be of type %s', + $className, + FileScanner::class + ), + 1622727271 + ); + } + if (!empty($this->config->getFiletypeScanners()[$fileExtension]) + || !empty($this->additionalFileTypeScanners[$fileExtension]) + ) { + throw new LogicException( + sprintf('Cannot redeclare scanner for file-type %s', $fileExtension), + 1622727272 + ); + } + $this->additionalFileTypeScanners[$fileExtension] = $className; + $this->addFileExtension($fileExtension); + } + + /** + * @return array> + */ + public function getAdditionalFileTypeScanners(): array + { + return $this->additionalFileTypeScanners; + } + + /** + * @param string $fileExtension e.g. `'html'` + * @param class-string $className + */ + public function addFileTypeAnalyzer(string $fileExtension, string $className): void + { + if (!class_exists($className) || !is_a($className, FileAnalyzer::class, true)) { + throw new LogicException( + sprintf( + 'Class %s must be of type %s', + $className, + FileAnalyzer::class + ), + 1622727281 + ); + } + if (!empty($this->config->getFiletypeAnalyzers()[$fileExtension]) + || !empty($this->additionalFileTypeAnalyzers[$fileExtension]) + ) { + throw new LogicException( + sprintf('Cannot redeclare analyzer for file-type %s', $fileExtension), + 1622727282 + ); + } + $this->additionalFileTypeAnalyzers[$fileExtension] = $className; + $this->addFileExtension($fileExtension); + } + + /** + * @return array> + */ + public function getAdditionalFileTypeAnalyzers(): array + { + return $this->additionalFileTypeAnalyzers; + } + + /** + * @return list e.g. `['html', 'perl']` + */ + public function getAdditionalFileExtensions(): array + { + return $this->additionalFileExtensions; + } + + /** + * @param string $fileExtension e.g. `'html'` + */ + private function addFileExtension(string $fileExtension): void + { + /** @psalm-suppress RedundantCondition */ + if (!in_array($fileExtension, $this->additionalFileExtensions, true) + && !in_array($fileExtension, $this->config->getFileExtensions(), true) + ) { + $this->additionalFileExtensions[] = $fileExtension; + } + } +} diff --git a/src/Psalm/PluginRegistrationSocket.php b/src/Psalm/PluginRegistrationSocket.php index ea1576ef4e6..c9ecd39fb9e 100644 --- a/src/Psalm/PluginRegistrationSocket.php +++ b/src/Psalm/PluginRegistrationSocket.php @@ -1,8 +1,6 @@ > - */ - private $additionalFileTypeScanners = []; - - /** - * @var array> - */ - private $additionalFileTypeAnalyzers = []; - - /** - * @var list - */ - private $additionalFileExtensions = []; - /** * @internal */ @@ -118,94 +101,4 @@ public function registerHooksFromClass(string $handler): void $this->codebase->functions->return_type_provider->registerClass($handler); } } - - /** - * @param string $fileExtension e.g. `'html'` - * @param class-string $className - */ - public function addFileTypeScanner(string $fileExtension, string $className): void - { - if (!class_exists($className) || !is_a($className, FileScanner::class, true)) { - throw new \LogicException( - sprintf( - 'Class %s must be of type %s', - $className, - FileScanner::class - ), - 1622727271 - ); - } - if (!empty($this->config->getFiletypeScanners()[$fileExtension]) - || !empty($this->additionalFileTypeScanners[$fileExtension]) - ) { - throw new \LogicException( - sprintf('Cannot redeclare scanner for file-type %s', $fileExtension), - 1622727272 - ); - } - $this->additionalFileTypeScanners[$fileExtension] = $className; - $this->addFileExtension($fileExtension); - } - - /** - * @return array> - */ - public function getAdditionalFileTypeScanners(): array - { - return $this->additionalFileTypeScanners; - } - - /** - * @param string $fileExtension e.g. `'html'` - * @param class-string $className - */ - public function addFileTypeAnalyzer(string $fileExtension, string $className): void - { - if (!class_exists($className) || !is_a($className, FileAnalyzer::class, true)) { - throw new \LogicException( - sprintf( - 'Class %s must be of type %s', - $className, - FileAnalyzer::class - ), - 1622727281 - ); - } - if (!empty($this->config->getFiletypeAnalyzers()[$fileExtension]) - || !empty($this->additionalFileTypeAnalyzers[$fileExtension]) - ) { - throw new \LogicException( - sprintf('Cannot redeclare analyzer for file-type %s', $fileExtension), - 1622727282 - ); - } - $this->additionalFileTypeAnalyzers[$fileExtension] = $className; - $this->addFileExtension($fileExtension); - } - - /** - * @return array> - */ - public function getAdditionalFileTypeAnalyzers(): array - { - return $this->additionalFileTypeAnalyzers; - } - - /** - * @return list e.g. `['html', 'perl']` - */ - public function getAdditionalFileExtensions(): array - { - return $this->additionalFileExtensions; - } - - /** - * @param string $fileExtension e.g. `'html'` - */ - private function addFileExtension(string $fileExtension): void - { - if (!in_array($fileExtension, $this->config->getFileExtensions(), true)) { - $this->additionalFileExtensions[] = $fileExtension; - } - } } diff --git a/tests/Config/Plugin/FileTypeSelfRegisteringPlugin.php b/tests/Config/Plugin/FileTypeSelfRegisteringPlugin.php index 1f1dfa9140e..7c3419b89be 100644 --- a/tests/Config/Plugin/FileTypeSelfRegisteringPlugin.php +++ b/tests/Config/Plugin/FileTypeSelfRegisteringPlugin.php @@ -2,10 +2,10 @@ namespace Psalm\Tests\Config\Plugin; use Psalm\Plugin; -use Psalm\Plugin\PluginEntryPointInterface; +use Psalm\Plugin\FileExtensionsInterface; use SimpleXMLElement; -class FileTypeSelfRegisteringPlugin implements PluginEntryPointInterface +class FileTypeSelfRegisteringPlugin implements Plugin\PluginFileExtensionsInterface { public const FLAG_SCANNER_TWICE = 1; public const FLAG_ANALYZER_TWICE = 2; @@ -23,32 +23,32 @@ class FileTypeSelfRegisteringPlugin implements PluginEntryPointInterface */ public static $flags = 0; - public function __invoke(Plugin\RegistrationInterface $registration, ?SimpleXMLElement $config = null): void + public function processFileExtensions(FileExtensionsInterface $fileExtensions, ?SimpleXMLElement $config = null): void { if (self::$flags & self::FLAG_SCANNER_INVALID) { /** @psalm-suppress InvalidArgument */ - $registration->addFileTypeScanner(self::$names['extension'], \stdClass::class); + $fileExtensions->addFileTypeScanner(self::$names['extension'], \stdClass::class); } else { // that's the regular/valid case /** @psalm-suppress ArgumentTypeCoercion */ - $registration->addFileTypeScanner(self::$names['extension'], self::$names['scanner']); + $fileExtensions->addFileTypeScanner(self::$names['extension'], self::$names['scanner']); } if (self::$flags & self::FLAG_ANALYZER_INVALID) { /** @psalm-suppress InvalidArgument */ - $registration->addFileTypeAnalyzer(self::$names['extension'], \stdClass::class); + $fileExtensions->addFileTypeAnalyzer(self::$names['extension'], \stdClass::class); } else { // that's the regular/valid case /** @psalm-suppress ArgumentTypeCoercion */ - $registration->addFileTypeAnalyzer(self::$names['extension'], self::$names['analyzer']); + $fileExtensions->addFileTypeAnalyzer(self::$names['extension'], self::$names['analyzer']); } if (self::$flags & self::FLAG_SCANNER_TWICE) { /** @psalm-suppress ArgumentTypeCoercion */ - $registration->addFileTypeScanner(self::$names['extension'], self::$names['scanner']); + $fileExtensions->addFileTypeScanner(self::$names['extension'], self::$names['scanner']); } if (self::$flags & self::FLAG_ANALYZER_TWICE) { /** @psalm-suppress ArgumentTypeCoercion */ - $registration->addFileTypeAnalyzer(self::$names['extension'], self::$names['analyzer']); + $fileExtensions->addFileTypeAnalyzer(self::$names['extension'], self::$names['analyzer']); } } }