1
0
mirror of https://github.com/danog/psalm.git synced 2025-01-22 05:41:20 +01:00

Merge pull request #6789 from ohader/issue-6788

Allow plugins to modify Config::$fileExtensions early
This commit is contained in:
orklah 2022-01-30 13:17:12 +01:00 committed by GitHub
commit 64d06c6566
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 309 additions and 197 deletions

View File

@ -198,3 +198,14 @@
- [BC] Method `Psalm\DocComment::parse()` was removed
- [BC] Class `Psalm\Type\Atomic\THtmlEscapedString` has been removed
- [BC] Property `Psalm\Context::$vars_from_global` has been renamed to `$referenced_globals`
- [BC] Self-registration of file type scanners and file type analyzers has been changed
- `Psalm\Plugin\RegistrationInterface::addFileTypeScanner` was removed
- `Psalm\Plugin\RegistrationInterface::addFileTypeAnalyzer` was removed
- :information_source: migration possible using `Psalm\Plugin\FileExtensionsInterface`
- `Psalm\PluginRegistrationSocket::addFileTypeScanner` was removed
- `Psalm\PluginRegistrationSocket::getAdditionalFileTypeScanners` was removed
- `Psalm\PluginRegistrationSocket::addFileTypeAnalyzer` was removed
- `Psalm\PluginRegistrationSocket::getAdditionalFileTypeAnalyzers` was removed
- `Psalm\PluginRegistrationSocket::getAdditionalFileExtensions` was removed
- `Psalm\PluginRegistrationSocket::addFileExtension` was removed
- :information_source: migration possible using `Psalm\PluginFileExtensionsSocket`

View File

@ -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);
}
}
```

View File

@ -39,8 +39,8 @@ To register a stub file manually use `Psalm\Plugin\RegistrationInterface::addStu
In addition to XML configuration node `<fileExtensions>` 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

View File

@ -36,6 +36,8 @@ 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;
@ -593,6 +595,11 @@ class Config
"xdebug" => false,
];
/**
* @var array<class-string, PluginInterface>
*/
private $plugins = [];
protected function __construct()
{
self::$instance = $this;
@ -1372,6 +1379,40 @@ class Config
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;
}
try {
$plugin->processFileExtensions($socket, $pluginConfig);
} catch (Throwable $t) {
throw new ConfigException(
'Failed to process plugin file extensions ' . $pluginClassName,
1_635_800_581,
$t
);
}
$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)
*/
@ -1387,38 +1428,22 @@ class Config
$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 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);
try {
$plugin($socket, $plugin_config);
} catch (Throwable $t) {
throw new ConfigException(
'Failed to invoke plugin ' . $plugin_class_name,
1_635_800_582,
$t
);
}
$project_analyzer->progress->debug('Initialized plugin ' . $plugin_class_name . ' successfully' . PHP_EOL);
}
foreach ($this->filetype_scanner_paths as $extension => $path) {
@ -1447,28 +1472,53 @@ class Config
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 HtmlFunctionTainter();
$socket->registerHooksFromClass(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 */

View File

@ -306,6 +306,7 @@ class ProjectAnalyzer
$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) {

View File

@ -0,0 +1,21 @@
<?php
namespace Psalm\Plugin;
use Psalm\Internal\Analyzer\FileAnalyzer;
use Psalm\Internal\Scanner\FileScanner;
interface FileExtensionsInterface
{
/**
* @param string $fileExtension e.g. `'html'`
* @param class-string<FileScanner> $className
*/
public function addFileTypeScanner(string $fileExtension, string $className): void;
/**
* @param string $fileExtension e.g. `'html'`
* @param class-string<FileAnalyzer> $className
*/
public function addFileTypeAnalyzer(string $fileExtension, string $className): void;
}

View File

@ -4,7 +4,7 @@ namespace Psalm\Plugin;
use SimpleXMLElement;
interface PluginEntryPointInterface
interface PluginEntryPointInterface extends PluginInterface
{
public function __invoke(RegistrationInterface $registration, ?SimpleXMLElement $config = null): void;
}

View File

@ -0,0 +1,13 @@
<?php
namespace Psalm\Plugin;
use SimpleXMLElement;
interface PluginFileExtensionsInterface extends PluginInterface
{
public function processFileExtensions(
FileExtensionsInterface $fileExtensions,
?SimpleXMLElement $config = null
): void;
}

View File

@ -0,0 +1,7 @@
<?php
namespace Psalm\Plugin;
interface PluginInterface
{
}

View File

@ -2,9 +2,6 @@
namespace Psalm\Plugin;
use Psalm\Internal\Analyzer\FileAnalyzer;
use Psalm\Internal\Scanner\FileScanner;
interface RegistrationInterface
{
public function addStubFile(string $file_name): void;
@ -13,18 +10,4 @@ interface RegistrationInterface
* @param class-string $handler
*/
public function registerHooksFromClass(string $handler): void;
/**
* @param string $fileExtension e.g. `'html'`
* @param class-string<FileScanner> $className
* @deprecated will be removed in v5.0, use \Psalm\Plugin\FileExtensionsInterface instead (#6788)
*/
public function addFileTypeScanner(string $fileExtension, string $className): void;
/**
* @param string $fileExtension e.g. `'html'`
* @param class-string<FileAnalyzer> $className
* @deprecated will be removed in v5.0, use \Psalm\Plugin\FileExtensionsInterface instead (#6788)
*/
public function addFileTypeAnalyzer(string $fileExtension, string $className): void;
}

View File

@ -0,0 +1,137 @@
<?php
namespace Psalm;
use LogicException;
use Psalm\Internal\Analyzer\FileAnalyzer;
use Psalm\Internal\Scanner\FileScanner;
use Psalm\Plugin\FileExtensionsInterface;
use function class_exists;
use function in_array;
use function is_a;
use function sprintf;
class PluginFileExtensionsSocket implements FileExtensionsInterface
{
/**
* @var Config
*/
private $config;
/**
* @var array<string, class-string<FileScanner>>
*/
private $additionalFileTypeScanners = [];
/**
* @var array<string, class-string<FileAnalyzer>>
*/
private $additionalFileTypeAnalyzers = [];
/**
* @var list<string>
*/
private $additionalFileExtensions = [];
/**
* @internal
*/
public function __construct(Config $config)
{
$this->config = $config;
}
/**
* @param string $fileExtension e.g. `'html'`
* @param class-string<FileScanner> $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
),
1_622_727_271
);
}
if (!empty($this->config->getFiletypeScanners()[$fileExtension])
|| !empty($this->additionalFileTypeScanners[$fileExtension])
) {
throw new LogicException(
sprintf('Cannot redeclare scanner for file-type %s', $fileExtension),
1_622_727_272
);
}
$this->additionalFileTypeScanners[$fileExtension] = $className;
$this->addFileExtension($fileExtension);
}
/**
* @return array<string, class-string<FileScanner>>
*/
public function getAdditionalFileTypeScanners(): array
{
return $this->additionalFileTypeScanners;
}
/**
* @param string $fileExtension e.g. `'html'`
* @param class-string<FileAnalyzer> $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
),
1_622_727_281
);
}
if (!empty($this->config->getFiletypeAnalyzers()[$fileExtension])
|| !empty($this->additionalFileTypeAnalyzers[$fileExtension])
) {
throw new LogicException(
sprintf('Cannot redeclare analyzer for file-type %s', $fileExtension),
1_622_727_282
);
}
$this->additionalFileTypeAnalyzers[$fileExtension] = $className;
$this->addFileExtension($fileExtension);
}
/**
* @return array<string, class-string<FileAnalyzer>>
*/
public function getAdditionalFileTypeAnalyzers(): array
{
return $this->additionalFileTypeAnalyzers;
}
/**
* @return list<string> 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;
}
}
}

View File

@ -3,9 +3,6 @@
namespace Psalm;
use InvalidArgumentException;
use LogicException;
use Psalm\Internal\Analyzer\FileAnalyzer;
use Psalm\Internal\Scanner\FileScanner;
use Psalm\Plugin\EventHandler\DynamicFunctionStorageProviderInterface;
use Psalm\Plugin\EventHandler\FunctionExistenceProviderInterface;
use Psalm\Plugin\EventHandler\FunctionParamsProviderInterface;
@ -20,10 +17,7 @@ use Psalm\Plugin\EventHandler\PropertyVisibilityProviderInterface;
use Psalm\Plugin\RegistrationInterface;
use function class_exists;
use function in_array;
use function is_a;
use function is_subclass_of;
use function sprintf;
class PluginRegistrationSocket implements RegistrationInterface
{
@ -33,21 +27,6 @@ class PluginRegistrationSocket implements RegistrationInterface
/** @var Codebase */
private $codebase;
/**
* @var array<string, class-string<FileScanner>>
*/
private $additionalFileTypeScanners = [];
/**
* @var array<string, class-string<FileAnalyzer>>
*/
private $additionalFileTypeAnalyzers = [];
/**
* @var list<string>
*/
private $additionalFileExtensions = [];
/**
* @internal
*/
@ -114,100 +93,4 @@ class PluginRegistrationSocket implements RegistrationInterface
$this->codebase->functions->dynamic_storage_provider->registerClass($handler);
}
}
/**
* @param string $fileExtension e.g. `'html'`
* @param class-string<FileScanner> $className
* @deprecated will be removed in v5.0, use \Psalm\Plugin\FileExtensionsInterface instead (#6788)
*/
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
),
1_622_727_271
);
}
if (!empty($this->config->getFiletypeScanners()[$fileExtension])
|| !empty($this->additionalFileTypeScanners[$fileExtension])
) {
throw new LogicException(
sprintf('Cannot redeclare scanner for file-type %s', $fileExtension),
1_622_727_272
);
}
$this->additionalFileTypeScanners[$fileExtension] = $className;
$this->addFileExtension($fileExtension);
}
/**
* @return array<string, class-string<FileScanner>>
* @deprecated will be removed in v5.0, use \Psalm\PluginFileExtensionsSocket instead (#6788)
*/
public function getAdditionalFileTypeScanners(): array
{
return $this->additionalFileTypeScanners;
}
/**
* @param string $fileExtension e.g. `'html'`
* @param class-string<FileAnalyzer> $className
* @deprecated will be removed in v5.0, use \Psalm\PluginFileExtensionsSocket instead (#6788)
*/
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
),
1_622_727_281
);
}
if (!empty($this->config->getFiletypeAnalyzers()[$fileExtension])
|| !empty($this->additionalFileTypeAnalyzers[$fileExtension])
) {
throw new LogicException(
sprintf('Cannot redeclare analyzer for file-type %s', $fileExtension),
1_622_727_282
);
}
$this->additionalFileTypeAnalyzers[$fileExtension] = $className;
$this->addFileExtension($fileExtension);
}
/**
* @return array<string, class-string<FileAnalyzer>>
* @deprecated will be removed in v5.0, use \Psalm\PluginFileExtensionsSocket instead (#6788)
*/
public function getAdditionalFileTypeAnalyzers(): array
{
return $this->additionalFileTypeAnalyzers;
}
/**
* @return list<string> e.g. `['html', 'perl']`
* @deprecated will be removed in v5.0, use \Psalm\PluginFileExtensionsSocket instead (#6788)
*/
public function getAdditionalFileExtensions(): array
{
return $this->additionalFileExtensions;
}
/**
* @param string $fileExtension e.g. `'html'`
* @deprecated will be removed in v5.0, use \Psalm\PluginFileExtensionsSocket instead (#6788)
*/
private function addFileExtension(string $fileExtension): void
{
if (!in_array($fileExtension, $this->config->getFileExtensions(), true)) {
$this->additionalFileExtensions[] = $fileExtension;
}
}
}

View File

@ -1416,7 +1416,7 @@ class ConfigTest extends TestCase
$extension = uniqid('test');
$names = [
'scanner' => uniqid('PsalmTestFileTypeScanner'),
'analyzer' => uniqid('PsalmTestFileTypeAnaylzer'),
'analyzer' => uniqid('PsalmTestFileTypeAnalyzer'),
'extension' => $extension,
];
$scannerMock = $this->getMockBuilder(FileScanner::class)
@ -1437,12 +1437,11 @@ class ConfigTest extends TestCase
<psalm><plugins><pluginClass class="%s"/></plugins></psalm>',
FileTypeSelfRegisteringPlugin::class
);
$projectAnalyzer = $this->getProjectAnalyzerWithConfig(
TestConfig::loadFromXML(dirname(__DIR__, 2), $xml)
);
try {
$projectAnalyzer = $this->getProjectAnalyzerWithConfig(
TestConfig::loadFromXML(dirname(__DIR__, 2), $xml)
);
$config = $projectAnalyzer->getConfig();
$config->initializePlugins($projectAnalyzer);
} catch (ConfigException $exception) {

View File

@ -2,12 +2,12 @@
namespace Psalm\Tests\Config\Plugin;
use Psalm\Plugin\PluginEntryPointInterface;
use Psalm\Plugin\RegistrationInterface;
use Psalm\Plugin\FileExtensionsInterface;
use Psalm\Plugin\PluginFileExtensionsInterface;
use SimpleXMLElement;
use stdClass;
class FileTypeSelfRegisteringPlugin implements PluginEntryPointInterface
class FileTypeSelfRegisteringPlugin implements PluginFileExtensionsInterface
{
public const FLAG_SCANNER_TWICE = 1;
public const FLAG_ANALYZER_TWICE = 2;
@ -25,32 +25,32 @@ class FileTypeSelfRegisteringPlugin implements PluginEntryPointInterface
*/
public static $flags = 0;
public function __invoke(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']);
}
}
}