Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

❗Allow plugins to modify Config::$fileExtensions early #6789

Merged
merged 2 commits into from Jan 30, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
11 changes: 11 additions & 0 deletions UPGRADING.md
Expand Up @@ -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`
13 changes: 10 additions & 3 deletions docs/running_psalm/checking_non_php_files.md
Expand Up @@ -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);
}
}
```
4 changes: 2 additions & 2 deletions docs/running_psalm/plugins/authoring_plugins.md
Expand Up @@ -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

Expand Down
132 changes: 91 additions & 41 deletions src/Psalm/Config.php
Expand Up @@ -36,6 +36,8 @@
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;
Expand Down Expand Up @@ -593,6 +595,11 @@ class Config
"xdebug" => false,
];

/**
* @var array<class-string, PluginInterface>
*/
private $plugins = [];

protected function __construct()
{
self::$instance = $this;
Expand Down Expand Up @@ -1372,6 +1379,40 @@ 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;
}
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)
*/
Expand All @@ -1387,38 +1428,22 @@ 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');
}
}
$plugin = $this->loadPlugin($project_analyzer, $plugin_class_name);
if (!$plugin instanceof PluginEntryPointInterface) {
continue;
}

/**
* @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);
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('Loaded plugin ' . $plugin_class_name . ' successfully' . PHP_EOL);
$project_analyzer->progress->debug('Initialized plugin ' . $plugin_class_name . ' successfully' . PHP_EOL);
}

foreach ($this->filetype_scanner_paths as $extension => $path) {
Expand Down Expand Up @@ -1447,28 +1472,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 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 */
Expand Down
1 change: 1 addition & 0 deletions src/Psalm/Internal/Analyzer/ProjectAnalyzer.php
Expand Up @@ -306,6 +306,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) {
Expand Down
21 changes: 21 additions & 0 deletions src/Psalm/Plugin/FileExtensionsInterface.php
@@ -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;
}
2 changes: 1 addition & 1 deletion src/Psalm/Plugin/PluginEntryPointInterface.php
Expand Up @@ -4,7 +4,7 @@

use SimpleXMLElement;

interface PluginEntryPointInterface
interface PluginEntryPointInterface extends PluginInterface
{
public function __invoke(RegistrationInterface $registration, ?SimpleXMLElement $config = null): void;
}
13 changes: 13 additions & 0 deletions src/Psalm/Plugin/PluginFileExtensionsInterface.php
@@ -0,0 +1,13 @@
<?php

namespace Psalm\Plugin;

use SimpleXMLElement;

interface PluginFileExtensionsInterface extends PluginInterface
{
public function processFileExtensions(
FileExtensionsInterface $fileExtensions,
?SimpleXMLElement $config = null
): void;
}
7 changes: 7 additions & 0 deletions src/Psalm/Plugin/PluginInterface.php
@@ -0,0 +1,7 @@
<?php

namespace Psalm\Plugin;

interface PluginInterface
{
}
17 changes: 0 additions & 17 deletions src/Psalm/Plugin/RegistrationInterface.php
Expand Up @@ -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;
Expand All @@ -13,18 +10,4 @@ public function addStubFile(string $file_name): void;
* @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;
}