mirror of
https://github.com/danog/psalm.git
synced 2024-11-26 12:24:49 +01:00
Plugin loading (#855)
* add ability to load plugins by class names - Plugins need to implement `__invoke(PluginFacade $psalm):void` method - Plugins are enabled by adding `<pluginClass class="Qualified\Class\Name"/>` - `PluginFacade` provides a single point of contact with Psalm, so that plugins cannot become coupled to Psalm internals * added `psalm-plugin` cli tool to manage plugins Available commands: `psalm-plugin list` - lists available and enabled plugins `psalm-plugin enable 'Plugin\Class\Name'` - enables plugin (modifies `psalm.xml`) `psalm-plugin disable 'Plugin\Class\Name'` - disables plugin (modifies `psalm.xml`) Plugin installation: `composer install plugin-vendor/plugin-package-name` Plugin authoring: Plugins are identified by package `type` field, which should contain `psalm-plugin` string. `extra.pluginClass` should refer to the name of the class implementing `__invoke(PluginFacade $psalm):void` function Todo: - better config file search - better output for `psalm-plugin` - better formatting for modified xml file - composer skeleton project for plugins - ability to refer to plugins by package name (cli only) - composer plugin to (optionally) enable plugin upon installation - documentation on plugin installation and authoring - interfaces for plugin dependencies - interface for plugin entry point - migration path for legacy plugins * documented previously undocumented plugin methods * split legacy plugin registration into a wrapper class also added `PluginApi` namespace and `RegistrationInterface` * reuse psalm's config search algorithm * enable/disable plugins by composer package name * allow specifying alternative config file name * whitelist PluginApi namespace three times, but well, it works now * interface for plugin entry points * psalm-plugin as a symfony console app * fixed errors found by psalm * suppressed false positive UnusedMethods * cs fix * better psalm-plugin output * don't leave empty `plugins` node to avoid old schema violation * removed junk file that shouldn't be there * cs fix * fixed phpunit failure (constant redefinition) * work around missing docblock in on symfony console * php 7.0 compatibility * allow `pluginClass` child elements as plugin configuration * decouple console commands from undelying implementation - introduce PluginListFactory - add `PluginList::enable(string $class)` and `PluginList::disable(string $class)` * PluginList tests * ComposerLock test * droppped debugging statement * added part of console command tests * added tests for EnableCommand * added DisableCommand tests * ignore unused args * ConfigFile test * disable travis cache in attempt to fix builds * nah, that didn't work * update for upstream changes * rebase fixes * namespaced `extra` entry for entry point * s/PluginFacade/PluginRegistrationSocket/g * Added $config parameter to PluginEntryPointInterface::__invoke() * cs fixes * entry point interface php7.0 compatibility * cleaned up old cruft - dropped todos I'm not going to pursues - locked entry point to be a class implementing entry point interface * fixed legacy plugins docs * Added RegistrationInterface::registerHooksFromClass() It mimics the way old plugins were registered in Psalm\Config, so handler classes extending Psalm\Plugin should be fully compatible with it. Since Psalm\Plugin-style plugin registration was moved to RegistrationSocket, LegacyPlugin now only load file-based plugins, so it was renamed to FileBasedPluginAdapter. * Converted EchoChecker plugin to composer-based format - Its subfolder is registered as a local composer package in the root composer.json, so it's directly installable with ``` composer require psalm/echo-checker-plugin ``` - Migration is trivial: drop the plugin into a separate folder, then add simple composer.json and the entry point class. * Updated docs * Don't reject hook handlers that inherit handling methods * strip void return type in stub file
This commit is contained in:
parent
a3dde47374
commit
052d4f6217
@ -22,11 +22,13 @@
|
||||
"sabre/event": "^5.0.1",
|
||||
"sabre/uri": "^2.0",
|
||||
"webmozart/glob": "^4.1",
|
||||
"webmozart/path-util": "^2.3"
|
||||
"webmozart/path-util": "^2.3",
|
||||
"symfony/console": "^3.0||^4.0"
|
||||
},
|
||||
"bin": ["psalm", "psalter", "psalm-language-server"],
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Psalm\\PluginApi\\": "src/Psalm/PluginApi",
|
||||
"Psalm\\": "src/Psalm"
|
||||
}
|
||||
},
|
||||
@ -70,5 +72,11 @@
|
||||
},
|
||||
"provide": {
|
||||
"psalm/psalm": "self.version"
|
||||
}
|
||||
},
|
||||
"repositories": [
|
||||
{
|
||||
"type": "path",
|
||||
"url": "examples/composer-based/echo-checker"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
14
config.xsd
14
config.xsd
@ -108,13 +108,21 @@
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="PluginsType">
|
||||
<xs:sequence>
|
||||
<xs:element name="plugin" maxOccurs="unbounded">
|
||||
<xs:choice minOccurs="0" maxOccurs="unbounded">
|
||||
<xs:element name="plugin">
|
||||
<xs:complexType>
|
||||
<xs:attribute name="filename" type="xs:string" use="required" />
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:sequence>
|
||||
<xs:element name="pluginClass">
|
||||
<xs:complexType>
|
||||
<xs:sequence>
|
||||
<xs:any minOccurs="0" maxOccurs="unbounded" processContents="skip"/>
|
||||
</xs:sequence>
|
||||
<xs:attribute name="class" type="xs:string" use="required" />
|
||||
</xs:complexType>
|
||||
</xs:element>
|
||||
</xs:choice>
|
||||
</xs:complexType>
|
||||
|
||||
<xs:complexType name="IssueHandlersType">
|
||||
|
@ -1,20 +1,23 @@
|
||||
# Plugins
|
||||
# File-based plugins
|
||||
|
||||
Psalm can be extended through plugins to find domain-specific issues.
|
||||
|
||||
All plugins must extend `Psalm\Plugin` and return an instance of themselves e.g.
|
||||
All plugins must extend `Psalm\Plugin`
|
||||
|
||||
```php
|
||||
<?php
|
||||
class SomePlugin extends \Psalm\Plugin
|
||||
{
|
||||
}
|
||||
return new SomePlugin;
|
||||
```
|
||||
|
||||
`Psalm\Plugin` offers two methods that you can override:
|
||||
`Psalm\Plugin` offers six methods that you can override:
|
||||
- `afterStatementsCheck` - called after Psalm evaluates each statement
|
||||
- `afterExpressionCheck` - called after Psalm evaluates each expression
|
||||
- `afterVisitClassLike` - called after Psalm crawls the parsed Abstract Syntax Tree for a class-like (class, interface, trait). Due to caching the AST is crawled the first time Psalm sees the file, and is only re-crawled if the file changes, the cache is cleared, or you're disabling cache with `--no-cache`
|
||||
- `afterClassLikeExistsCheck` - called after Psalm analyzes a reference to a class-like
|
||||
- `afterMethodCallCheck` - called after Psalm analyzes a method call
|
||||
- `afterFunctionCallCheck` - called after Psalm analyzes a function call
|
||||
|
||||
An example plugin that checks class references in strings is provided [here](https://github.com/vimeo/psalm/blob/master/examples/StringChecker.php).
|
||||
|
||||
@ -24,3 +27,46 @@ To ensure your plugin runs when Psalm does, add it to your [config](Configuratio
|
||||
<plugin filename="src/plugins/SomePlugin.php" />
|
||||
</plugins>
|
||||
```
|
||||
|
||||
# Composer-based plugins
|
||||
|
||||
Composer-based plugins provide easier way to manage and distribute your plugins.
|
||||
|
||||
## Using composer-based plugins
|
||||
### Discovering plugins
|
||||
|
||||
Plugins can be found on Packagist by `type=psalm-plugin` query: https://packagist.org/packages/list.json?type=psalm-plugin
|
||||
|
||||
### Installing plugins
|
||||
|
||||
`composer require --dev plugin-vendor/plugin-package`
|
||||
|
||||
### Managing known plugins
|
||||
|
||||
Once installed, you can use `psalm-plugin` tool to enable, disable and show available and enabled plugins.
|
||||
|
||||
To enable the plugin, run `psalm-plugin enable plugin-vendor/plugin-package`. To disable it, run `psalm-plugin disable plugin-vendor/plugin-package`. `psalm-plugin show` (as well as bare `psalm-plugin`) will show you the list of enabled plugins, and the list of plugins known to `psalm-plugin` (installed into your `vendor` folder)
|
||||
|
||||
## Authoring composer-based plugins
|
||||
|
||||
### Requirements
|
||||
|
||||
Composer-based plugin is a composer package which conforms to these requirements:
|
||||
|
||||
1. Its `type` field is set to `psalm-plugin`
|
||||
2. It has `extra.psalm.pluginClass` subkey in its `composer.json` that reference an entry-point class that will be invoked to register the plugin into Psalm runtime.
|
||||
3. Entry-point class implements `Psalm\PluginApi\PluginEntryPointInterface`
|
||||
|
||||
### Using skeleton project
|
||||
|
||||
Run `composer create-project weirdan/psalm-plugin-skeleton:dev-master your-plugin-name` to quickly bootstrap a new plugin project in `your-plugin-name` folder. Make sure you adjust namespaces in `composer.json`, `Plugin.php` and `tests` folder.
|
||||
|
||||
### Upgrading file-based plugin to composer-based version
|
||||
|
||||
Create new plugin project using skeleton, then pass the class name of you file-based plugin to `registerHooksFromClass()` method of the `Psalm\PluginApi\RegistrationInterface` instance that was passed into your plugin entry point's `__invoke()` method. See the [conversion example](https://github.com/vimeo/psalm/examples/composer-based/echo-checker/).
|
||||
|
||||
### Registering stub files
|
||||
|
||||
Use `Psalm\PluginApi\RegistrationInterface::addStubFile()`. See the [sample plugin] (https://github.com/weirdan/psalm-doctrine-collections/).
|
||||
|
||||
Stub files provide a way to override third-party type information when you cannot add Psalm's extended docblocks to the upstream source files directly.
|
||||
|
72
examples/composer-based/echo-checker/EchoChecker.php
Normal file
72
examples/composer-based/echo-checker/EchoChecker.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
namespace Vimeo\CodeAnalysis\EchoChecker;
|
||||
|
||||
use PhpParser;
|
||||
use Psalm\Checker;
|
||||
use Psalm\Checker\StatementsChecker;
|
||||
use Psalm\CodeLocation;
|
||||
use Psalm\Context;
|
||||
use Psalm\FileManipulation\FileManipulation;
|
||||
use Psalm\IssueBuffer;
|
||||
use Psalm\Issue\TypeCoercion;
|
||||
|
||||
class EchoChecker extends \Psalm\Plugin
|
||||
{
|
||||
/**
|
||||
* Called after an expression has been checked
|
||||
*
|
||||
* @param StatementsChecker $statements_checker
|
||||
* @param PhpParser\Node $stmt
|
||||
* @param Context $context
|
||||
* @param CodeLocation $code_location
|
||||
* @param string[] $suppressed_issues
|
||||
* @param FileManipulation[] $file_replacements
|
||||
*
|
||||
* @return null|false
|
||||
*/
|
||||
public static function afterStatementCheck(
|
||||
StatementsChecker $statements_checker,
|
||||
PhpParser\Node $stmt,
|
||||
Context $context,
|
||||
CodeLocation $code_location,
|
||||
array $suppressed_issues,
|
||||
array &$file_replacements = []
|
||||
) {
|
||||
if ($stmt instanceof PhpParser\Node\Stmt\Echo_) {
|
||||
foreach ($stmt->exprs as $expr) {
|
||||
if (!isset($expr->inferredType) || $expr->inferredType->isMixed()) {
|
||||
if (IssueBuffer::accepts(
|
||||
new TypeCoercion(
|
||||
'Echo requires an unescaped string, ' . $expr->inferredType . ' provided',
|
||||
new CodeLocation($statements_checker->getSource(), $expr)
|
||||
),
|
||||
$statements_checker->getSuppressedIssues()
|
||||
)) {
|
||||
// keep soldiering on
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$types = $expr->inferredType->getTypes();
|
||||
|
||||
foreach ($types as $type) {
|
||||
if ($type instanceof \Psalm\Type\Atomic\TString
|
||||
&& !$type instanceof \Psalm\Type\Atomic\TLiteralString
|
||||
&& !$type instanceof \Psalm\Type\Atomic\THtmlEscapedString
|
||||
) {
|
||||
if (IssueBuffer::accepts(
|
||||
new TypeCoercion(
|
||||
'Echo requires an unescaped string, ' . $expr->inferredType . ' provided',
|
||||
new CodeLocation($statements_checker->getSource(), $expr)
|
||||
),
|
||||
$statements_checker->getSuppressedIssues()
|
||||
)) {
|
||||
// keep soldiering on
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
15
examples/composer-based/echo-checker/PluginEntryPoint.php
Normal file
15
examples/composer-based/echo-checker/PluginEntryPoint.php
Normal file
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
namespace Vimeo\CodeAnalysis\EchoChecker;
|
||||
|
||||
use Psalm\PluginApi;
|
||||
use SimpleXMLElement;
|
||||
|
||||
class PluginEntryPoint implements PluginApi\PluginEntryPointInterface
|
||||
{
|
||||
/** @return void */
|
||||
public function __invoke(PluginApi\RegistrationInterface $registration, ?SimpleXMLElement $config = null)
|
||||
{
|
||||
require_once __DIR__ . '/EchoChecker.php';
|
||||
$registration->registerHooksFromClass(EchoChecker::class);
|
||||
}
|
||||
}
|
21
examples/composer-based/echo-checker/composer.json
Normal file
21
examples/composer-based/echo-checker/composer.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "psalm/echo-checker-plugin",
|
||||
"description": "Checks echo statements",
|
||||
"type": "psalm-plugin",
|
||||
"license": "MIT",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Matthew Brown"
|
||||
}
|
||||
],
|
||||
"extra": {
|
||||
"psalm": {
|
||||
"pluginClass": "Vimeo\\CodeAnalysis\\EchoChecker\\PluginEntryPoint"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Vimeo\\CodeAnalysis\\EchoChecker\\": ["."]
|
||||
}
|
||||
}
|
||||
}
|
@ -23,6 +23,7 @@
|
||||
<file>src/psalm.php</file>
|
||||
<file>src/psalm-language-server.php</file>
|
||||
<file>src/psalter.php</file>
|
||||
<file>src/psalm_plugin.php</file>
|
||||
<file>src/Psalm/CallMap.php</file>
|
||||
<file>src/Psalm/Fork/Pool.php</file>
|
||||
<file>src/Psalm/PropertyMap.php</file>
|
||||
|
2
psalm-plugin
Executable file
2
psalm-plugin
Executable file
@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env php
|
||||
<?php require_once __DIR__ . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'psalm_plugin.php';
|
@ -95,6 +95,7 @@
|
||||
<PossiblyUnusedMethod>
|
||||
<errorLevel type="suppress">
|
||||
<directory name="tests" />
|
||||
<directory name="src/Psalm/PluginApi" />
|
||||
<file name="src/Psalm/Plugin.php" />
|
||||
<file name="src/Psalm/LanguageServer/Client/TextDocument.php" />
|
||||
<file name="src/Psalm/LanguageServer/Server/TextDocument.php" />
|
||||
|
@ -114,8 +114,17 @@ return [
|
||||
|
||||
return $contents;
|
||||
},
|
||||
function ($filePath, $prefix, $contents) {
|
||||
$ret = str_replace(
|
||||
$prefix . '\Psalm\PluginApi',
|
||||
'Psalm\PluginApi',
|
||||
$contents
|
||||
);
|
||||
return $ret;
|
||||
},
|
||||
],
|
||||
'whitelist' => [
|
||||
\Composer\Autoload\ClassLoader::class,
|
||||
'Psalm\PluginApi\*',
|
||||
]
|
||||
];
|
||||
|
@ -9,6 +9,7 @@ use Psalm\Config\ProjectFileFilter;
|
||||
use Psalm\Exception\ConfigException;
|
||||
use Psalm\Scanner\FileScanner;
|
||||
use SimpleXMLElement;
|
||||
use Psalm\PluginRegistrationSocket;
|
||||
|
||||
class Config
|
||||
{
|
||||
@ -238,6 +239,11 @@ class Config
|
||||
*/
|
||||
public $plugin_paths = [];
|
||||
|
||||
/**
|
||||
* @var array<array{class:string,config:?SimpleXmlElement}>
|
||||
*/
|
||||
private $plugin_classes = [];
|
||||
|
||||
/**
|
||||
* Static methods to be called after method checks have completed
|
||||
*
|
||||
@ -637,19 +643,34 @@ class Config
|
||||
);
|
||||
}
|
||||
|
||||
$config->stub_files[] = $file_path;
|
||||
$config->addStubFile($file_path);
|
||||
}
|
||||
}
|
||||
|
||||
// this plugin loading system borrows heavily from etsy/phan
|
||||
if (isset($config_xml->plugins) && isset($config_xml->plugins->plugin)) {
|
||||
/** @var \SimpleXMLElement $plugin */
|
||||
foreach ($config_xml->plugins->plugin as $plugin) {
|
||||
$plugin_file_name = $plugin['filename'];
|
||||
if (isset($config_xml->plugins)) {
|
||||
if (isset($config_xml->plugins->plugin)) {
|
||||
/** @var \SimpleXMLElement $plugin */
|
||||
foreach ($config_xml->plugins->plugin as $plugin) {
|
||||
$plugin_file_name = $plugin['filename'];
|
||||
|
||||
$path = $config->base_dir . $plugin_file_name;
|
||||
$path = $config->base_dir . $plugin_file_name;
|
||||
|
||||
$config->addPluginPath($path);
|
||||
$config->addPluginPath($path);
|
||||
}
|
||||
}
|
||||
if (isset($config_xml->plugins->pluginClass)) {
|
||||
/** @var \SimpleXMLElement $plugin */
|
||||
foreach ($config_xml->plugins->pluginClass as $plugin) {
|
||||
$plugin_class_name = $plugin['class'];
|
||||
// any child elements are used as plugin configuration
|
||||
$plugin_config = null;
|
||||
if ($plugin->count()) {
|
||||
$plugin_config = $plugin->children();
|
||||
}
|
||||
|
||||
$config->addPluginClass($plugin_class_name, $plugin_config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -757,6 +778,18 @@ class Config
|
||||
$this->plugin_paths[] = $path;
|
||||
}
|
||||
|
||||
/** @return void */
|
||||
public function addPluginClass(string $class_name, SimpleXmlElement $plugin_config = null)
|
||||
{
|
||||
$this->plugin_classes[] = ['class' => $class_name, 'config' => $plugin_config];
|
||||
}
|
||||
|
||||
/** @return array<array{class:string, config:?SimpleXmlElement}> */
|
||||
public function getPluginClasses(): array
|
||||
{
|
||||
return $this->plugin_classes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialises all the plugins (done once the config is fully loaded)
|
||||
*
|
||||
@ -769,7 +802,21 @@ class Config
|
||||
*/
|
||||
public function initializePlugins(ProjectChecker $project_checker)
|
||||
{
|
||||
$codebase = $project_checker->codebase;
|
||||
$codebase = $project_checker->getCodebase();
|
||||
|
||||
$socket = new PluginRegistrationSocket($this);
|
||||
// initialize plugin classes earlier to let them hook into subsequent load process
|
||||
foreach ($this->plugin_classes as $plugin_class_entry) {
|
||||
$plugin_class_name = $plugin_class_entry['class'];
|
||||
$plugin_config = $plugin_class_entry['config'];
|
||||
try {
|
||||
/** @var PluginApi\PluginEntryPointInterface $plugin_object */
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($this->filetype_scanner_paths as $extension => $path) {
|
||||
$fq_class_name = $this->getPluginClassForPath($codebase, $path, 'Psalm\\Scanner\\FileScanner');
|
||||
@ -790,33 +837,11 @@ class Config
|
||||
}
|
||||
|
||||
foreach ($this->plugin_paths as $path) {
|
||||
$fq_class_name = $this->getPluginClassForPath($codebase, $path, 'Psalm\\Plugin');
|
||||
|
||||
/** @psalm-suppress UnresolvableInclude */
|
||||
require_once($path);
|
||||
|
||||
if ($codebase->methods->methodExists($fq_class_name . '::afterMethodCallCheck')) {
|
||||
$this->after_method_checks[$fq_class_name] = $fq_class_name;
|
||||
}
|
||||
|
||||
if ($codebase->methods->methodExists($fq_class_name . '::afterFunctionCallCheck')) {
|
||||
$this->after_function_checks[$fq_class_name] = $fq_class_name;
|
||||
}
|
||||
|
||||
if ($codebase->methods->methodExists($fq_class_name . '::afterExpressionCheck')) {
|
||||
$this->after_expression_checks[$fq_class_name] = $fq_class_name;
|
||||
}
|
||||
|
||||
if ($codebase->methods->methodExists($fq_class_name . '::afterStatementCheck')) {
|
||||
$this->after_statement_checks[$fq_class_name] = $fq_class_name;
|
||||
}
|
||||
|
||||
if ($codebase->methods->methodExists($fq_class_name . '::afterClassLikeExistsCheck')) {
|
||||
$this->after_classlike_exists_checks[$fq_class_name] = $fq_class_name;
|
||||
}
|
||||
|
||||
if ($codebase->methods->methodExists($fq_class_name . '::afterVisitClassLike')) {
|
||||
$this->after_visit_classlikes[$fq_class_name] = $fq_class_name;
|
||||
try {
|
||||
$plugin_object = new FileBasedPluginAdapter($path, $this, $project_checker);
|
||||
$plugin_object($socket);
|
||||
} catch (\Throwable $e) {
|
||||
throw new ConfigException('Failed to load plugin ' . $path, 0, $e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1320,4 +1345,10 @@ class Config
|
||||
{
|
||||
$this->cache_directory .= '-s';
|
||||
}
|
||||
|
||||
/** @return void */
|
||||
public function addStubFile(string $stub_file)
|
||||
{
|
||||
$this->stub_files[] = $stub_file;
|
||||
}
|
||||
}
|
||||
|
73
src/Psalm/FileBasedPluginAdapter.php
Normal file
73
src/Psalm/FileBasedPluginAdapter.php
Normal file
@ -0,0 +1,73 @@
|
||||
<?php
|
||||
namespace Psalm;
|
||||
|
||||
use Psalm\Checker\ClassLikeChecker;
|
||||
use Psalm\Checker\ProjectChecker;
|
||||
use Psalm\Scanner\FileScanner;
|
||||
use Psalm\PluginApi;
|
||||
use SimpleXMLElement;
|
||||
|
||||
class FileBasedPluginAdapter implements PluginApi\PluginEntryPointInterface
|
||||
{
|
||||
/** @var string */
|
||||
private $path;
|
||||
|
||||
/** @var ProjectChecker */
|
||||
private $project_checker;
|
||||
|
||||
/** @var Config */
|
||||
private $config;
|
||||
|
||||
public function __construct(string $path, Config $config, ProjectChecker $project_checker)
|
||||
{
|
||||
$this->path = $path;
|
||||
$this->config = $config;
|
||||
$this->project_checker = $project_checker;
|
||||
}
|
||||
|
||||
/** @return void */
|
||||
public function __invoke(PluginApi\RegistrationInterface $registration, SimpleXMLElement $config = null)
|
||||
{
|
||||
$fq_class_name = $this->getPluginClassForPath($this->path, Plugin::class);
|
||||
|
||||
/** @psalm-suppress UnresolvableInclude */
|
||||
require_once($this->path);
|
||||
|
||||
$registration->registerHooksFromClass($fq_class_name);
|
||||
}
|
||||
|
||||
private function getPluginClassForPath(string $path, string $must_extend): string
|
||||
{
|
||||
$codebase = $this->project_checker->codebase;
|
||||
|
||||
$file_storage = $codebase->createFileStorageForPath($path);
|
||||
$file_to_scan = new FileScanner($path, $this->config->shortenFileName($path), true);
|
||||
$file_to_scan->scan(
|
||||
$codebase,
|
||||
$file_storage
|
||||
);
|
||||
|
||||
$declared_classes = ClassLikeChecker::getClassesForFile($codebase, $path);
|
||||
|
||||
if (count($declared_classes) !== 1) {
|
||||
throw new \InvalidArgumentException(
|
||||
'Plugins must have exactly one class in the file - ' . $path . ' has ' .
|
||||
count($declared_classes)
|
||||
);
|
||||
}
|
||||
|
||||
$fq_class_name = reset($declared_classes);
|
||||
|
||||
if (!$codebase->classExtends(
|
||||
$fq_class_name,
|
||||
$must_extend
|
||||
)
|
||||
) {
|
||||
throw new \InvalidArgumentException(
|
||||
'This plugin must extend ' . $must_extend . ' - ' . $path . ' does not'
|
||||
);
|
||||
}
|
||||
|
||||
return $fq_class_name;
|
||||
}
|
||||
}
|
10
src/Psalm/PluginApi/PluginEntryPointInterface.php
Normal file
10
src/Psalm/PluginApi/PluginEntryPointInterface.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
namespace Psalm\PluginApi;
|
||||
|
||||
use SimpleXMLElement;
|
||||
|
||||
interface PluginEntryPointInterface
|
||||
{
|
||||
/** @return void */
|
||||
public function __invoke(RegistrationInterface $api, SimpleXMLElement $config = null);
|
||||
}
|
13
src/Psalm/PluginApi/RegistrationInterface.php
Normal file
13
src/Psalm/PluginApi/RegistrationInterface.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
namespace Psalm\PluginApi;
|
||||
|
||||
interface RegistrationInterface
|
||||
{
|
||||
/** @return void */
|
||||
public function addStubFile(string $file_name);
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function registerHooksFromClass(string $handler);
|
||||
}
|
77
src/Psalm/PluginManager/Command/DisableCommand.php
Normal file
77
src/Psalm/PluginManager/Command/DisableCommand.php
Normal file
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
namespace Psalm\PluginManager\Command;
|
||||
|
||||
use Psalm\PluginManager\PluginList;
|
||||
use Psalm\PluginManager\PluginListFactory;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class DisableCommand extends Command
|
||||
{
|
||||
/** @var PluginListFactory */
|
||||
private $plugin_list_factory;
|
||||
|
||||
public function __construct(PluginListFactory $plugin_list_factory)
|
||||
{
|
||||
$this->plugin_list_factory = $plugin_list_factory;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-suppress UnusedMethod
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setName('disable')
|
||||
->setDescription('Disables a named plugin')
|
||||
->addArgument(
|
||||
'pluginName',
|
||||
InputArgument::REQUIRED,
|
||||
'Plugin name (fully qualified class name or composer package name)'
|
||||
)
|
||||
->addUsage('vendor/plugin-package-name [-c path/to/psalm.xml]');
|
||||
$this->addUsage('\'Plugin\Class\Name\' [-c path/to/psalm.xml]');
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-suppress UnusedMethod
|
||||
* @return null|int
|
||||
*/
|
||||
protected function execute(InputInterface $i, OutputInterface $o)
|
||||
{
|
||||
$io = new SymfonyStyle($i, $o);
|
||||
|
||||
$current_dir = (string) getcwd() . DIRECTORY_SEPARATOR;
|
||||
|
||||
/** @psalm-suppress MixedAssignment */
|
||||
$config_file_path = $i->getOption('config');
|
||||
assert(null === $config_file_path || is_string($config_file_path));
|
||||
|
||||
$plugin_list = ($this->plugin_list_factory)($current_dir, $config_file_path);
|
||||
|
||||
try {
|
||||
/** @psalm-suppress MixedAssignment */
|
||||
$plugin_name = $i->getArgument('pluginName');
|
||||
assert(is_string($plugin_name));
|
||||
|
||||
$plugin_class = $plugin_list->resolvePluginClass($plugin_name);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$io->error('Unknown plugin class');
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (!$plugin_list->isEnabled($plugin_class)) {
|
||||
$io->note('Plugin already disabled');
|
||||
return 3;
|
||||
}
|
||||
|
||||
$plugin_list->disable($plugin_class);
|
||||
$io->success('Plugin disabled');
|
||||
}
|
||||
}
|
77
src/Psalm/PluginManager/Command/EnableCommand.php
Normal file
77
src/Psalm/PluginManager/Command/EnableCommand.php
Normal file
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
namespace Psalm\PluginManager\Command;
|
||||
|
||||
use Psalm\PluginManager\PluginList;
|
||||
use Psalm\PluginManager\PluginListFactory;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use InvalidArgumentException;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class EnableCommand extends Command
|
||||
{
|
||||
/** @var PluginListFactory */
|
||||
private $plugin_list_factory;
|
||||
|
||||
public function __construct(PluginListFactory $plugin_list_factory)
|
||||
{
|
||||
$this->plugin_list_factory = $plugin_list_factory;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-suppress UnusedMethod
|
||||
* @return void
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setName('enable')
|
||||
->setDescription('Enables a named plugin')
|
||||
->addArgument(
|
||||
'pluginName',
|
||||
InputArgument::REQUIRED,
|
||||
'Plugin name (fully qualified class name or composer package name)'
|
||||
)
|
||||
->addUsage('vendor/plugin-package-name [-c path/to/psalm.xml]');
|
||||
$this->addUsage('\'Plugin\Class\Name\' [-c path/to/psalm.xml]');
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-suppress UnusedMethod
|
||||
* @return null|int
|
||||
*/
|
||||
protected function execute(InputInterface $i, OutputInterface $o)
|
||||
{
|
||||
$io = new SymfonyStyle($i, $o);
|
||||
|
||||
$current_dir = (string) getcwd() . DIRECTORY_SEPARATOR;
|
||||
|
||||
/** @psalm-suppress MixedAssignment */
|
||||
$config_file_path = $i->getOption('config');
|
||||
assert(null === $config_file_path || is_string($config_file_path));
|
||||
|
||||
$plugin_list = ($this->plugin_list_factory)($current_dir, $config_file_path);
|
||||
|
||||
try {
|
||||
/** @psalm-suppress MixedAssignment */
|
||||
$plugin_name = $i->getArgument('pluginName');
|
||||
assert(is_string($plugin_name));
|
||||
|
||||
$plugin_class = $plugin_list->resolvePluginClass($plugin_name);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
$io->error('Unknown plugin class');
|
||||
return 2;
|
||||
}
|
||||
|
||||
if ($plugin_list->isEnabled($plugin_class)) {
|
||||
$io->note('Plugin already enabled');
|
||||
return 3;
|
||||
}
|
||||
|
||||
$plugin_list->enable($plugin_class);
|
||||
$io->success('Plugin enabled');
|
||||
}
|
||||
}
|
87
src/Psalm/PluginManager/Command/ShowCommand.php
Normal file
87
src/Psalm/PluginManager/Command/ShowCommand.php
Normal file
@ -0,0 +1,87 @@
|
||||
<?php
|
||||
namespace Psalm\PluginManager\Command;
|
||||
|
||||
use Psalm\PluginManager\PluginList;
|
||||
use Psalm\PluginManager\PluginListFactory;
|
||||
use Symfony\Component\Console\Command\Command;
|
||||
use Symfony\Component\Console\Input\InputInterface;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Symfony\Component\Console\Style\SymfonyStyle;
|
||||
|
||||
class ShowCommand extends Command
|
||||
{
|
||||
/** @var PluginListFactory */
|
||||
private $plugin_list_factory;
|
||||
|
||||
public function __construct(PluginListFactory $plugin_list_factory)
|
||||
{
|
||||
$this->plugin_list_factory = $plugin_list_factory;
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @psalm-suppress UnusedMethod
|
||||
*/
|
||||
protected function configure()
|
||||
{
|
||||
$this
|
||||
->setName('show')
|
||||
->setDescription('Lists enabled and available plugins')
|
||||
->addUsage('[-c path/to/psalm.xml]');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null|int
|
||||
* @psalm-suppress UnusedMethod
|
||||
*/
|
||||
protected function execute(InputInterface $i, OutputInterface $o)
|
||||
{
|
||||
$io = new SymfonyStyle($i, $o);
|
||||
$current_dir = (string) getcwd() . DIRECTORY_SEPARATOR;
|
||||
|
||||
/** @psalm-suppress MixedAssignment */
|
||||
$config_file_path = $i->getOption('config');
|
||||
assert(null === $config_file_path || is_string($config_file_path));
|
||||
|
||||
$plugin_list = ($this->plugin_list_factory)($current_dir, $config_file_path);
|
||||
|
||||
$enabled = $plugin_list->getEnabled();
|
||||
$available = $plugin_list->getAvailable();
|
||||
|
||||
$formatRow =
|
||||
/** @param null|string $package */
|
||||
function (string $class, $package): array {
|
||||
return [$package, $class];
|
||||
}
|
||||
;
|
||||
|
||||
$io->section('Enabled');
|
||||
if (count($enabled)) {
|
||||
$io->table(
|
||||
['Package', 'Class'],
|
||||
array_map(
|
||||
$formatRow,
|
||||
array_keys($enabled),
|
||||
array_values($enabled)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
$io->note('No plugins enabled');
|
||||
}
|
||||
|
||||
$io->section('Available');
|
||||
if (count($available)) {
|
||||
$io->table(
|
||||
['Package', 'Class'],
|
||||
array_map(
|
||||
$formatRow,
|
||||
array_keys($available),
|
||||
array_values($available)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
$io->note('No plugins available');
|
||||
}
|
||||
}
|
||||
}
|
89
src/Psalm/PluginManager/ComposerLock.php
Normal file
89
src/Psalm/PluginManager/ComposerLock.php
Normal file
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
namespace Psalm\PluginManager;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class ComposerLock
|
||||
{
|
||||
/** @var string */
|
||||
private $file_name;
|
||||
|
||||
public function __construct(string $file_name)
|
||||
{
|
||||
$this->file_name = $file_name;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param mixed $package
|
||||
* @psalm-assert-if-true array{type:'psalm-plugin',name:string,extra:array{psalm:array{pluginClass:string}}}
|
||||
* $package
|
||||
*/
|
||||
public function isPlugin($package): bool
|
||||
{
|
||||
return is_array($package)
|
||||
&& isset($package['name'])
|
||||
&& is_string($package['name'])
|
||||
&& isset($package['type'])
|
||||
&& $package['type'] === 'psalm-plugin'
|
||||
&& isset($package['extra']['psalm']['pluginClass'])
|
||||
&& is_string($package['extra']['psalm']['pluginClass']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string> [packageName => pluginClass, ...]
|
||||
*/
|
||||
public function getPlugins(): array
|
||||
{
|
||||
$pluginPackages = $this->getAllPluginPackages();
|
||||
$ret = [];
|
||||
foreach ($pluginPackages as $package) {
|
||||
$ret[$package['name']] = $package['extra']['psalm']['pluginClass'];
|
||||
}
|
||||
return $ret;
|
||||
}
|
||||
|
||||
private function read(): array
|
||||
{
|
||||
/** @psalm-suppress MixedAssignment */
|
||||
$contents = json_decode(file_get_contents($this->file_name), true);
|
||||
|
||||
if ($error = json_last_error()) {
|
||||
throw new RuntimeException(json_last_error_msg(), $error);
|
||||
}
|
||||
|
||||
if (!is_array($contents)) {
|
||||
throw new RuntimeException('Malformed ' . $this->file_name . ', expecting JSON-encoded object');
|
||||
}
|
||||
|
||||
return $contents;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<mixed,array{name:string,type:string,extra:array{psalm:array{pluginClass:string}}}>
|
||||
*/
|
||||
private function getAllPluginPackages(): array
|
||||
{
|
||||
$packages = $this->getAllPackages();
|
||||
$ret = [];
|
||||
/** @psalm-suppress MixedAssignment */
|
||||
foreach ($packages as $package) {
|
||||
if ($this->isPlugin($package)) {
|
||||
$ret[] = $package;
|
||||
}
|
||||
}
|
||||
return $ret;
|
||||
}
|
||||
|
||||
private function getAllPackages(): array
|
||||
{
|
||||
$composer_lock_contents = $this->read();
|
||||
if (!isset($composer_lock_contents["packages"]) || !is_array($composer_lock_contents["packages"])) {
|
||||
throw new RuntimeException('packages section is missing or not an array');
|
||||
}
|
||||
if (!isset($composer_lock_contents["packages-dev"]) || !is_array($composer_lock_contents["packages-dev"])) {
|
||||
throw new RuntimeException('packages-dev section is missing or not an array');
|
||||
}
|
||||
return array_merge($composer_lock_contents["packages"], $composer_lock_contents["packages-dev"]);
|
||||
}
|
||||
}
|
84
src/Psalm/PluginManager/ConfigFile.php
Normal file
84
src/Psalm/PluginManager/ConfigFile.php
Normal file
@ -0,0 +1,84 @@
|
||||
<?php
|
||||
namespace Psalm\PluginManager;
|
||||
|
||||
use Psalm\Config;
|
||||
use SimpleXmlElement;
|
||||
use RuntimeException;
|
||||
|
||||
class ConfigFile
|
||||
{
|
||||
const NS = 'https://getpsalm.org/schema/config';
|
||||
/** @var string */
|
||||
private $path;
|
||||
|
||||
/** @var string */
|
||||
private $current_dir;
|
||||
|
||||
/** @param null|string $explicit_path */
|
||||
public function __construct(string $current_dir, $explicit_path)
|
||||
{
|
||||
$this->current_dir = $current_dir;
|
||||
|
||||
if ($explicit_path) {
|
||||
$this->path = $explicit_path;
|
||||
} else {
|
||||
$path = Config::locateConfigFile($current_dir);
|
||||
if (!$path) {
|
||||
throw new RuntimeException('Cannot find Psalm config');
|
||||
}
|
||||
$this->path = $path;
|
||||
}
|
||||
}
|
||||
|
||||
public function getConfig(): Config
|
||||
{
|
||||
return Config::loadFromXMLFile($this->path, $this->current_dir);
|
||||
}
|
||||
|
||||
/** @return void */
|
||||
public function removePlugin(string $plugin_class)
|
||||
{
|
||||
$config_xml = $this->readXml();
|
||||
if (!isset($config_xml->plugins)) {
|
||||
// no plugins, nothing to remove
|
||||
return;
|
||||
}
|
||||
assert($config_xml->plugins instanceof SimpleXmlElement);
|
||||
|
||||
if (isset($config_xml->plugins->pluginClass)) {
|
||||
assert($config_xml->plugins->pluginClass instanceof SimpleXmlElement);
|
||||
/** @psalm-suppress MixedAssignment */
|
||||
foreach ($config_xml->plugins->pluginClass as $entry) {
|
||||
assert($entry instanceof SimpleXmlElement);
|
||||
if ((string)$entry['class'] === $plugin_class) {
|
||||
unset($entry[0]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!$config_xml->plugins->children()->count()) {
|
||||
// avoid breaking old psalm binaries, whose schema did not allow empty plugins
|
||||
unset($config_xml->plugins[0]);
|
||||
}
|
||||
|
||||
$config_xml->asXML($this->path);
|
||||
}
|
||||
|
||||
/** @return void */
|
||||
public function addPlugin(string $plugin_class)
|
||||
{
|
||||
$config_xml = $this->readXml();
|
||||
if (!isset($config_xml->plugins)) {
|
||||
$config_xml->addChild('plugins', "\n", self::NS);
|
||||
}
|
||||
assert($config_xml->plugins instanceof SimpleXmlElement);
|
||||
$config_xml->plugins->addChild('pluginClass', '', self::NS)->addAttribute('class', $plugin_class);
|
||||
$config_xml->asXML($this->path);
|
||||
}
|
||||
|
||||
private function readXml(): SimpleXmlElement
|
||||
{
|
||||
return new SimpleXmlElement(file_get_contents($this->path));
|
||||
}
|
||||
}
|
101
src/Psalm/PluginManager/PluginList.php
Normal file
101
src/Psalm/PluginManager/PluginList.php
Normal file
@ -0,0 +1,101 @@
|
||||
<?php
|
||||
namespace Psalm\PluginManager;
|
||||
|
||||
class PluginList
|
||||
{
|
||||
/** @var ConfigFile */
|
||||
private $config_file;
|
||||
|
||||
/** @var ComposerLock */
|
||||
private $composer_lock;
|
||||
|
||||
/** @var ?array<string,string> [pluginClass => packageName]*/
|
||||
private $all_plugins = null;
|
||||
|
||||
/** @var ?array<string,?string> [pluginClass => ?packageName]*/
|
||||
private $enabled_plugins = null;
|
||||
|
||||
public function __construct(ConfigFile $config_file, ComposerLock $composer_lock)
|
||||
{
|
||||
$this->config_file = $config_file;
|
||||
$this->composer_lock = $composer_lock;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return array<string,?string> [pluginClass => ?packageName, ...]
|
||||
*/
|
||||
public function getEnabled(): array
|
||||
{
|
||||
if (!$this->enabled_plugins) {
|
||||
$this->enabled_plugins = [];
|
||||
foreach ($this->config_file->getConfig()->getPluginClasses() as $plugin_entry) {
|
||||
$plugin_class = $plugin_entry['class'];
|
||||
$this->enabled_plugins[$plugin_class] = $this->findPluginPackage($plugin_class);
|
||||
}
|
||||
}
|
||||
return $this->enabled_plugins;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,?string> [pluginCLass => ?packageName]
|
||||
*/
|
||||
public function getAvailable(): array
|
||||
{
|
||||
return array_diff_key($this->getAll(), $this->getEnabled());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string,string> [pluginClass => packageName]
|
||||
*/
|
||||
public function getAll(): array
|
||||
{
|
||||
if (null === $this->all_plugins) {
|
||||
$this->all_plugins = array_flip($this->composer_lock->getPlugins());
|
||||
}
|
||||
return $this->all_plugins;
|
||||
}
|
||||
|
||||
public function resolvePluginClass(string $class_or_package): string
|
||||
{
|
||||
if (false === strpos($class_or_package, '/')) {
|
||||
return $class_or_package; // must be a class then
|
||||
}
|
||||
|
||||
// pluginClass => ?pluginPackage
|
||||
$plugin_classes = $this->getAll();
|
||||
|
||||
$class = array_search($class_or_package, $plugin_classes, true);
|
||||
|
||||
if (false === $class) {
|
||||
throw new \InvalidArgumentException('Unknown plugin: ' . $class_or_package);
|
||||
}
|
||||
|
||||
return $class;
|
||||
}
|
||||
|
||||
/** @return null|string */
|
||||
public function findPluginPackage(string $class)
|
||||
{
|
||||
// pluginClass => ?pluginPackage
|
||||
$plugin_classes = $this->getAll();
|
||||
return $plugin_classes[$class] ?? null;
|
||||
}
|
||||
|
||||
public function isEnabled(string $class): bool
|
||||
{
|
||||
return array_key_exists($class, $this->getEnabled());
|
||||
}
|
||||
|
||||
/** @return void */
|
||||
public function enable(string $class)
|
||||
{
|
||||
$this->config_file->addPlugin($class);
|
||||
}
|
||||
|
||||
/** @return void */
|
||||
public function disable(string $class)
|
||||
{
|
||||
$this->config_file->removePlugin($class);
|
||||
}
|
||||
}
|
12
src/Psalm/PluginManager/PluginListFactory.php
Normal file
12
src/Psalm/PluginManager/PluginListFactory.php
Normal file
@ -0,0 +1,12 @@
|
||||
<?php
|
||||
namespace Psalm\PluginManager;
|
||||
|
||||
class PluginListFactory
|
||||
{
|
||||
public function __invoke(string $current_dir, string $config_file_path = null): PluginList
|
||||
{
|
||||
$config_file = new ConfigFile($current_dir, $config_file_path);
|
||||
$composer_lock = new ComposerLock('composer.lock');
|
||||
return new PluginList($config_file, $composer_lock);
|
||||
}
|
||||
}
|
67
src/Psalm/PluginRegistrationSocket.php
Normal file
67
src/Psalm/PluginRegistrationSocket.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
namespace Psalm;
|
||||
|
||||
use Psalm\PluginApi\RegistrationInterface;
|
||||
|
||||
class PluginRegistrationSocket implements RegistrationInterface
|
||||
{
|
||||
/** @var Config */
|
||||
private $config;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
public function __construct(Config $config)
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
/** @return void */
|
||||
public function addStubFile(string $file_name)
|
||||
{
|
||||
$this->config->addStubFile($file_name);
|
||||
}
|
||||
|
||||
/** @return void */
|
||||
public function registerHooksFromClass(string $handler)
|
||||
{
|
||||
if (!class_exists($handler, false)) {
|
||||
throw new \InvalidArgumentException('Plugins must be loaded before registration');
|
||||
}
|
||||
|
||||
if (!is_subclass_of($handler, Plugin::class)) {
|
||||
throw new \InvalidArgumentException(
|
||||
'This handler must extend ' . Plugin::class . ' - ' . $handler . ' does not'
|
||||
);
|
||||
}
|
||||
|
||||
// check that handler class (or one of its ancestors, but not Plugin) actually redefines specific hooks,
|
||||
// so that we don't register empty handlers provided by Plugin
|
||||
|
||||
$handlerClass = new \ReflectionClass($handler);
|
||||
|
||||
if ($handlerClass->getMethod('afterMethodCallCheck')->getDeclaringClass()->getName() !== Plugin::class) {
|
||||
$this->config->after_method_checks[$handler] = $handler;
|
||||
}
|
||||
|
||||
if ($handlerClass->getMethod('afterFunctionCallCheck')->getDeclaringClass()->getName() !== Plugin::class) {
|
||||
$this->config->after_function_checks[$handler] = $handler;
|
||||
}
|
||||
|
||||
if ($handlerClass->getMethod('afterExpressionCheck')->getDeclaringClass()->getName() !== Plugin::class) {
|
||||
$this->config->after_expression_checks[$handler] = $handler;
|
||||
}
|
||||
|
||||
if ($handlerClass->getMethod('afterStatementCheck')->getDeclaringClass()->getName() !== Plugin::class) {
|
||||
$this->config->after_statement_checks[$handler] = $handler;
|
||||
}
|
||||
|
||||
if ($handlerClass->getMethod('afterClassLikeExistsCheck')->getDeclaringClass()->getName() !== Plugin::class) {
|
||||
$this->config->after_classlike_exists_checks[$handler] = $handler;
|
||||
}
|
||||
|
||||
if ($handlerClass->getMethod('afterVisitClassLike')->getDeclaringClass()->getName() !== Plugin::class) {
|
||||
$this->config->after_visit_classlikes[$handler] = $handler;
|
||||
}
|
||||
}
|
||||
}
|
31
src/psalm_plugin.php
Normal file
31
src/psalm_plugin.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/' . 'command_functions.php';
|
||||
use Psalm\Config;
|
||||
use Psalm\PluginManager\Command\DisableCommand;
|
||||
use Psalm\PluginManager\Command\EnableCommand;
|
||||
use Psalm\PluginManager\Command\ShowCommand;
|
||||
use Psalm\PluginManager\PluginListFactory;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Muglug\PackageVersions\Versions;
|
||||
|
||||
$current_dir = (string)getcwd() . DIRECTORY_SEPARATOR;
|
||||
$vendor_dir = getVendorDir($current_dir);
|
||||
requireAutoloaders($current_dir, false, $vendor_dir);
|
||||
|
||||
|
||||
$app = new Application('psalm-plugin', (string) Versions::getVersion('vimeo/psalm'));
|
||||
|
||||
$plugin_list_factory = new PluginListFactory;
|
||||
$app->addCommands([
|
||||
new ShowCommand($plugin_list_factory),
|
||||
new EnableCommand($plugin_list_factory),
|
||||
new DisableCommand($plugin_list_factory),
|
||||
]);
|
||||
|
||||
$app->getDefinition()->addOption(
|
||||
new InputOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to Psalm config file')
|
||||
);
|
||||
|
||||
$app->setDefaultCommand('show');
|
||||
$app->run();
|
169
tests/ComposerLockTest.php
Normal file
169
tests/ComposerLockTest.php
Normal file
@ -0,0 +1,169 @@
|
||||
<?php
|
||||
namespace Psalm\Tests;
|
||||
|
||||
use Psalm\PluginManager\ComposerLock;
|
||||
|
||||
/** @group PluginManager */
|
||||
class ComposerLockTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function pluginIsPackageOfTypePsalmPlugin()
|
||||
{
|
||||
$lock = new ComposerLock($this->jsonFile((object)[]));
|
||||
$this->assertTrue($lock->isPlugin([
|
||||
'name' => 'vendor/package',
|
||||
'type' => 'psalm-plugin',
|
||||
'extra' => [
|
||||
'psalm' => [
|
||||
'pluginClass' => 'Some\Class',
|
||||
]
|
||||
]
|
||||
]));
|
||||
|
||||
// counterexamples
|
||||
|
||||
$this->assertFalse($lock->isPlugin([]), 'Non-package should not be considered a plugin');
|
||||
|
||||
$this->assertFalse($lock->isPlugin([
|
||||
'name' => 'vendor/package',
|
||||
'type' => 'library',
|
||||
]), 'Non-plugin should not be considered a plugin');
|
||||
|
||||
$this->assertFalse($lock->isPlugin([
|
||||
'name' => 'vendor/package',
|
||||
'type' => 'psalm-plugin',
|
||||
]), 'Invalid plugin should not be considered a plugin');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function seesNonDevPlugins()
|
||||
{
|
||||
$lock = new ComposerLock($this->jsonFile((object)[
|
||||
'packages' => [
|
||||
(object)[
|
||||
'name' => 'vendor/package',
|
||||
'type' => 'psalm-plugin',
|
||||
'extra' => (object)[
|
||||
'psalm' => (object) [
|
||||
'pluginClass' => 'Vendor\Package\PluginClass',
|
||||
]
|
||||
],
|
||||
],
|
||||
],
|
||||
'packages-dev' => [],
|
||||
]));
|
||||
|
||||
$this->assertArraySubset(
|
||||
['vendor/package' => 'Vendor\Package\PluginClass'],
|
||||
$lock->getPlugins()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function seesDevPlugins()
|
||||
{
|
||||
$lock = new ComposerLock($this->jsonFile((object)[
|
||||
'packages' => [],
|
||||
'packages-dev' => [
|
||||
(object)[
|
||||
'name' => 'vendor/package',
|
||||
'type' => 'psalm-plugin',
|
||||
'extra' => (object)[
|
||||
'psalm' => (object)[
|
||||
'pluginClass' => 'Vendor\Package\PluginClass',
|
||||
]
|
||||
],
|
||||
],
|
||||
],
|
||||
]));
|
||||
$this->assertArraySubset(
|
||||
['vendor/package' => 'Vendor\Package\PluginClass'],
|
||||
$lock->getPlugins()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function skipsNonPlugins()
|
||||
{
|
||||
$nonPlugin = (object)[
|
||||
'name' => 'vendor/package',
|
||||
'type' => 'library',
|
||||
];
|
||||
|
||||
$lock = new ComposerLock($this->jsonFile((object)[
|
||||
'packages' => [ $nonPlugin ],
|
||||
'packages-dev' => [ $nonPlugin ],
|
||||
]));
|
||||
$this->assertEmpty($lock->getPlugins());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function failsOnInvalidJson()
|
||||
{
|
||||
$lock = new ComposerLock('data:application/json,[');
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$lock->getPlugins();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function failsOnNonObjectJson()
|
||||
{
|
||||
$lock = new ComposerLock('data:application/json,null');
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$lock->getPlugins();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function failsOnMissingPackagesEntry()
|
||||
{
|
||||
$noPackagesFile = $this->jsonFile((object)[
|
||||
'packages-dev' => [],
|
||||
]);
|
||||
$lock = new ComposerLock($noPackagesFile);
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$lock->getPlugins();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function failsOnMissingPackagesDevEntry()
|
||||
{
|
||||
$noPackagesDevFile = $this->jsonFile((object)[
|
||||
'packages' => [],
|
||||
]);
|
||||
$lock = new ComposerLock($noPackagesDevFile);
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$lock->getPlugins();
|
||||
}
|
||||
|
||||
/** @param mixed $data */
|
||||
private function jsonFile($data): string
|
||||
{
|
||||
return 'data:application/json,' . json_encode($data);
|
||||
}
|
||||
}
|
157
tests/ConfigFileTest.php
Normal file
157
tests/ConfigFileTest.php
Normal file
@ -0,0 +1,157 @@
|
||||
<?php
|
||||
namespace Psalm\Tests;
|
||||
|
||||
use DOMDocument;
|
||||
use Psalm\PluginManager\ConfigFile;
|
||||
use Psalm\Config;
|
||||
|
||||
/** @group PluginManager */
|
||||
class ConfigFileTest extends TestCase
|
||||
{
|
||||
/** @var string */
|
||||
private $file_path;
|
||||
|
||||
/** @return void */
|
||||
public function setUp()
|
||||
{
|
||||
$this->file_path = tempnam(sys_get_temp_dir(), 'psalm-test-config');
|
||||
}
|
||||
|
||||
/** @return void */
|
||||
public function tearDown()
|
||||
{
|
||||
@unlink($this->file_path);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function canCreateConfigObject()
|
||||
{
|
||||
file_put_contents($this->file_path, trim('
|
||||
<?xml version="1.0"?>
|
||||
<psalm></psalm>
|
||||
'));
|
||||
|
||||
$config_file = new ConfigFile((string)getcwd(), $this->file_path);
|
||||
$this->assertInstanceOf(Config::class, $config_file->getConfig());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function addCanAddPluginClassToExistingPluginsNode()
|
||||
{
|
||||
file_put_contents($this->file_path, trim('
|
||||
<?xml version="1.0"?>
|
||||
<psalm>
|
||||
<plugins></plugins>
|
||||
</psalm>
|
||||
'));
|
||||
|
||||
$config_file = new ConfigFile((string)getcwd(), $this->file_path);
|
||||
$config_file->addPlugin('a\b\c');
|
||||
|
||||
$this->assertXmlStringEqualsXmlString(
|
||||
'<?xml version="1.0"?><psalm><plugins><pluginClass xmlns="' . ConfigFile::NS . '" class="a\b\c"/></plugins></psalm>',
|
||||
file_get_contents($this->file_path)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function addCanCreateMissingPluginsNode()
|
||||
{
|
||||
file_put_contents($this->file_path, trim('
|
||||
<?xml version="1.0"?>
|
||||
<psalm></psalm>
|
||||
'));
|
||||
|
||||
$config_file = new ConfigFile((string)getcwd(), $this->file_path);
|
||||
$config_file->addPlugin('a\b\c');
|
||||
|
||||
$this->assertXmlStringEqualsXmlString(
|
||||
'<?xml version="1.0"?><psalm><plugins xmlns="' . ConfigFile::NS . '"><pluginClass class="a\b\c"/></plugins></psalm>',
|
||||
file_get_contents($this->file_path)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function removeDoesNothingWhenThereIsNoPluginsNode()
|
||||
{
|
||||
$noPlugins = trim('
|
||||
<?xml version="1.0"?>
|
||||
<psalm></psalm>
|
||||
');
|
||||
file_put_contents($this->file_path, $noPlugins);
|
||||
|
||||
$config_file = new ConfigFile((string)getcwd(), $this->file_path);
|
||||
$config_file->removePlugin('a\b\c');
|
||||
|
||||
$this->assertXmlStringEqualsXmlString(
|
||||
$noPlugins,
|
||||
file_get_contents($this->file_path)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function removeKillsEmptyPluginsNode()
|
||||
{
|
||||
$noPlugins = trim('
|
||||
<?xml version="1.0"?>
|
||||
<psalm></psalm>
|
||||
');
|
||||
|
||||
$emptyPlugins = trim('
|
||||
<?xml version="1.0"?>
|
||||
<psalm><plugins></plugins></psalm>
|
||||
');
|
||||
|
||||
file_put_contents($this->file_path, $emptyPlugins);
|
||||
|
||||
$config_file = new ConfigFile((string)getcwd(), $this->file_path);
|
||||
$config_file->removePlugin('a\b\c');
|
||||
|
||||
$this->assertXmlStringEqualsXmlString(
|
||||
$noPlugins,
|
||||
file_get_contents($this->file_path)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function removeKillsSpecifiedPlugin()
|
||||
{
|
||||
$noPlugins = trim('
|
||||
<?xml version="1.0"?>
|
||||
<psalm></psalm>
|
||||
');
|
||||
|
||||
$abcEnabled = trim('
|
||||
<?xml version="1.0"?>
|
||||
<psalm><plugins><pluginClass class="a\b\c"/></plugins></psalm>
|
||||
');
|
||||
|
||||
file_put_contents($this->file_path, $abcEnabled);
|
||||
|
||||
$config_file = new ConfigFile((string)getcwd(), $this->file_path);
|
||||
$config_file->removePlugin('a\b\c');
|
||||
|
||||
$this->assertXmlStringEqualsXmlString(
|
||||
$noPlugins,
|
||||
file_get_contents($this->file_path)
|
||||
);
|
||||
}
|
||||
}
|
180
tests/PluginListTest.php
Normal file
180
tests/PluginListTest.php
Normal file
@ -0,0 +1,180 @@
|
||||
<?php
|
||||
namespace Psalm\Tests;
|
||||
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psalm\Config;
|
||||
use Psalm\PluginManager\ComposerLock;
|
||||
use Psalm\PluginManager\ConfigFile;
|
||||
use Psalm\PluginManager\PluginList;
|
||||
|
||||
/** @group PluginManager */
|
||||
class PluginListTest extends TestCase
|
||||
{
|
||||
/** @var ObjectProphecy */
|
||||
private $config_file;
|
||||
/** @var ObjectProphecy */
|
||||
private $config;
|
||||
/** @var ObjectProphecy */
|
||||
private $composer_lock;
|
||||
|
||||
public function setUp()
|
||||
{
|
||||
$this->config = $this->prophesize(Config::class);
|
||||
/** @psalm-suppress TooManyArguments willReturn is old-school variadic, see vimeo/psalm#605 */
|
||||
$this->config->getPluginClasses()->willReturn([]);
|
||||
|
||||
$this->config_file = $this->prophesize(ConfigFile::class);
|
||||
/** @psalm-suppress TooManyArguments willReturn is old-school variadic, see vimeo/psalm#605 */
|
||||
$this->config_file->getConfig()->willReturn($this->config->reveal());
|
||||
|
||||
$this->composer_lock = $this->prophesize(ComposerLock::class);
|
||||
/** @psalm-suppress TooManyArguments willReturn is old-school variadic, see vimeo/psalm#605 */
|
||||
$this->composer_lock->getPlugins()->willReturn([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function pluginsPresentInConfigAreEnabled()
|
||||
{
|
||||
/** @psalm-suppress TooManyArguments willReturn is old-school variadic, see vimeo/psalm#605 */
|
||||
$this->config->getPluginClasses()->willReturn([
|
||||
['class' => 'a\b\c', 'config' => null],
|
||||
['class' => 'c\d\e', 'config' => null],
|
||||
]);
|
||||
|
||||
$plugin_list = new PluginList($this->config_file->reveal(), $this->composer_lock->reveal());
|
||||
|
||||
$this->assertEquals([
|
||||
'a\b\c' => null,
|
||||
'c\d\e' => null,
|
||||
], $plugin_list->getEnabled());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function pluginsPresentInPackageLockOnlyAreAvailable()
|
||||
{
|
||||
/** @psalm-suppress TooManyArguments willReturn is old-school variadic, see vimeo/psalm#605 */
|
||||
$this->config->getPluginClasses()->willReturn([
|
||||
['class' => 'a\b\c', 'config' => null],
|
||||
]);
|
||||
|
||||
/** @psalm-suppress TooManyArguments willReturn is old-school variadic, see vimeo/psalm#605 */
|
||||
$this->composer_lock->getPlugins()->willReturn([
|
||||
'vendor/package' => 'a\b\c',
|
||||
'another-vendor/another-package' => 'c\d\e',
|
||||
]);
|
||||
|
||||
$plugin_list = new PluginList($this->config_file->reveal(), $this->composer_lock->reveal());
|
||||
|
||||
$this->assertEquals([
|
||||
'c\d\e' => 'another-vendor/another-package',
|
||||
], $plugin_list->getAvailable());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function pluginsPresentInPackageLockAndConfigHavePluginPackageName()
|
||||
{
|
||||
/** @psalm-suppress TooManyArguments willReturn is old-school variadic, see vimeo/psalm#605 */
|
||||
$this->config->getPluginClasses()->willReturn([
|
||||
['class' => 'a\b\c', 'config' => null],
|
||||
]);
|
||||
|
||||
/** @psalm-suppress TooManyArguments willReturn is old-school variadic, see vimeo/psalm#605 */
|
||||
$this->composer_lock->getPlugins()->willReturn([
|
||||
'vendor/package' => 'a\b\c',
|
||||
]);
|
||||
|
||||
$plugin_list = new PluginList($this->config_file->reveal(), $this->composer_lock->reveal());
|
||||
|
||||
$this->assertEquals([
|
||||
'a\b\c' => 'vendor/package',
|
||||
], $plugin_list->getEnabled());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function canFindPluginClassByClassName()
|
||||
{
|
||||
$plugin_list = new PluginList($this->config_file->reveal(), $this->composer_lock->reveal());
|
||||
$this->assertEquals('a\b\c', $plugin_list->resolvePluginClass('a\b\c'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function canFindPluginClassByPackageName()
|
||||
{
|
||||
/** @psalm-suppress TooManyArguments willReturn is old-school variadic, see vimeo/psalm#605 */
|
||||
$this->composer_lock->getPlugins()->willReturn([
|
||||
'vendor/package' => 'a\b\c',
|
||||
]);
|
||||
|
||||
$plugin_list = new PluginList($this->config_file->reveal(), $this->composer_lock->reveal());
|
||||
$this->assertEquals('a\b\c', $plugin_list->resolvePluginClass('vendor/package'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function enabledPackageIsEnabled()
|
||||
{
|
||||
/** @psalm-suppress TooManyArguments willReturn is old-school variadic, see vimeo/psalm#605 */
|
||||
$this->config->getPluginClasses()->willReturn([
|
||||
['class' => 'a\b\c', 'config' => null],
|
||||
]);
|
||||
|
||||
$plugin_list = new PluginList($this->config_file->reveal(), $this->composer_lock->reveal());
|
||||
|
||||
$this->assertTrue($plugin_list->isEnabled('a\b\c'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function errorsOutWhenTryingToResolveUnknownPlugin()
|
||||
{
|
||||
$plugin_list = new PluginList($this->config_file->reveal(), $this->composer_lock->reveal());
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessageRegExp('/unknown plugin/i');
|
||||
$plugin_list->resolvePluginClass('vendor/package');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function pluginsAreEnabledInConfigFile()
|
||||
{
|
||||
$plugin_list = new PluginList($this->config_file->reveal(), $this->composer_lock->reveal());
|
||||
|
||||
$this->config_file->addPlugin('a\b\c')->shouldBeCalled();
|
||||
|
||||
$plugin_list->enable('a\b\c');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function pluginsAreDisabledInConfigFile()
|
||||
{
|
||||
$plugin_list = new PluginList($this->config_file->reveal(), $this->composer_lock->reveal());
|
||||
|
||||
$this->config_file->removePlugin('a\b\c')->shouldBeCalled();
|
||||
|
||||
$plugin_list->disable('a\b\c');
|
||||
}
|
||||
}
|
@ -1,9 +1,14 @@
|
||||
<?php
|
||||
namespace Psalm\Tests;
|
||||
|
||||
use Prophecy\Argument;
|
||||
use Psalm\Checker\FileChecker;
|
||||
use Psalm\Config;
|
||||
use Psalm\Context;
|
||||
use Psalm\Plugin;
|
||||
use Psalm\PluginApi\PluginEntryPointInterface;
|
||||
use Psalm\PluginApi\RegistrationInterface;
|
||||
use SimpleXMLElement;
|
||||
|
||||
class PluginTest extends TestCase
|
||||
{
|
||||
@ -330,4 +335,30 @@ class PluginTest extends TestCase
|
||||
|
||||
$this->analyzeFile($file_path, new Context());
|
||||
}
|
||||
/** @return void */
|
||||
public function testInheritedHookHandlersAreCalled()
|
||||
{
|
||||
require_once __DIR__ . '/stubs/extending_plugin_entrypoint.php';
|
||||
|
||||
$this->project_checker = $this->getProjectCheckerWithConfig(
|
||||
TestConfig::loadFromXML(
|
||||
dirname(__DIR__) . DIRECTORY_SEPARATOR,
|
||||
'<?xml version="1.0"?>
|
||||
<psalm>
|
||||
<projectFiles>
|
||||
<directory name="src" />
|
||||
</projectFiles>
|
||||
<plugins>
|
||||
<pluginClass class="ExtendingPluginRegistration" />
|
||||
</plugins>
|
||||
</psalm>'
|
||||
)
|
||||
);
|
||||
|
||||
$this->project_checker->config->initializePlugins($this->project_checker);
|
||||
$this->assertContains(
|
||||
'ExtendingPlugin',
|
||||
$this->project_checker->config->after_function_checks
|
||||
);
|
||||
}
|
||||
}
|
||||
|
325
tests/PsalmPluginTest.php
Normal file
325
tests/PsalmPluginTest.php
Normal file
@ -0,0 +1,325 @@
|
||||
<?php
|
||||
namespace Psalm\Tests;
|
||||
|
||||
use Prophecy\Argument;
|
||||
use Prophecy\Prophecy\ObjectProphecy;
|
||||
use Psalm\PluginManager\Command\DisableCommand;
|
||||
use Psalm\PluginManager\Command\EnableCommand;
|
||||
use Psalm\PluginManager\Command\ShowCommand;
|
||||
use Psalm\PluginManager\PluginList;
|
||||
use Psalm\PluginManager\PluginListFactory;
|
||||
use Symfony\Component\Console\Application;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Tester\CommandTester;
|
||||
|
||||
/** @group PluginManager */
|
||||
class PsalmPluginTest extends TestCase
|
||||
{
|
||||
/** @var ObjectProphecy */
|
||||
private $plugin_list;
|
||||
/** @var ObjectProphecy */
|
||||
private $plugin_list_factory;
|
||||
|
||||
/** @var Application */
|
||||
private $app;
|
||||
|
||||
public function setUp()
|
||||
{
|
||||
$this->plugin_list = $this->prophesize(PluginList::class);
|
||||
$this->plugin_list_factory = $this->prophesize(PluginListFactory::class);
|
||||
/** @psalm-suppress TooManyArguments */
|
||||
$this->plugin_list_factory->__invoke(Argument::any(), Argument::any())->willReturn($this->plugin_list->reveal());
|
||||
|
||||
$this->app = new Application('psalm-plugin', '0.1');
|
||||
$this->app->addCommands([
|
||||
new ShowCommand($this->plugin_list_factory->reveal()),
|
||||
new EnableCommand($this->plugin_list_factory->reveal()),
|
||||
new DisableCommand($this->plugin_list_factory->reveal()),
|
||||
]);
|
||||
|
||||
$this->app->getDefinition()->addOption(
|
||||
new InputOption('config', 'c', InputOption::VALUE_REQUIRED, 'Path to Psalm config file')
|
||||
);
|
||||
|
||||
$this->app->setDefaultCommand('show');
|
||||
|
||||
/** @psalm-suppress TooManyArguments */
|
||||
$this->plugin_list->getEnabled()->willReturn([]);
|
||||
/** @psalm-suppress TooManyArguments */
|
||||
$this->plugin_list->getAvailable()->willReturn([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function showsNoticesWhenTheresNoPlugins()
|
||||
{
|
||||
$show_command = new CommandTester($this->app->find('show'));
|
||||
$show_command->execute([]);
|
||||
|
||||
$output = $show_command->getDisplay();
|
||||
$this->assertContains('No plugins enabled', $output);
|
||||
$this->assertContains('No plugins available', $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function showsEnabledPlugins()
|
||||
{
|
||||
/** @psalm-suppress TooManyArguments */
|
||||
$this->plugin_list->getEnabled()->willReturn(['a\b\c' => 'vendor/package']);
|
||||
|
||||
$show_command = new CommandTester($this->app->find('show'));
|
||||
$show_command->execute([]);
|
||||
|
||||
$output = $show_command->getDisplay();
|
||||
$this->assertContains('vendor/package', $output);
|
||||
$this->assertContains('a\b\c', $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function showsAvailablePlugins()
|
||||
{
|
||||
/** @psalm-suppress TooManyArguments */
|
||||
$this->plugin_list->getAvailable()->willReturn(['a\b\c' => 'vendor/package']);
|
||||
|
||||
$show_command = new CommandTester($this->app->find('show'));
|
||||
$show_command->execute([]);
|
||||
|
||||
$output = $show_command->getDisplay();
|
||||
$this->assertContains('vendor/package', $output);
|
||||
$this->assertContains('a\b\c', $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function passesExplicitConfigToPluginListFactory()
|
||||
{
|
||||
/** @psalm-suppress TooManyArguments */
|
||||
$this->plugin_list_factory->__invoke(Argument::any(), '/a/b/c')->willReturn($this->plugin_list->reveal());
|
||||
|
||||
$show_command = new CommandTester($this->app->find('show'));
|
||||
$show_command->execute([
|
||||
'--config' => '/a/b/c',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function showsColumnHeaders()
|
||||
{
|
||||
/** @psalm-suppress TooManyArguments */
|
||||
$this->plugin_list->getAvailable()->willReturn(['a\b\c' => 'vendor/package']);
|
||||
/** @psalm-suppress TooManyArguments */
|
||||
$this->plugin_list->getAvailable()->willReturn(['c\d\e' => 'another-vendor/package']);
|
||||
|
||||
$show_command = new CommandTester($this->app->find('show'));
|
||||
$show_command->execute([]);
|
||||
|
||||
$output = $show_command->getDisplay();
|
||||
|
||||
$this->assertContains('Package', $output);
|
||||
$this->assertContains('Class', $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @dataProvider commands
|
||||
* @test
|
||||
*/
|
||||
public function listsCommands(string $command)
|
||||
{
|
||||
$list_command = new CommandTester($this->app->find('list'));
|
||||
$list_command->execute([]);
|
||||
$output = $list_command->getDisplay();
|
||||
$this->assertContains($command, $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @dataProvider commands
|
||||
* @test
|
||||
*/
|
||||
public function showsHelpForCommand(string $command)
|
||||
{
|
||||
$help_command = new CommandTester($this->app->find('help'));
|
||||
$help_command->execute(['command_name' => $command]);
|
||||
$output = $help_command->getDisplay();
|
||||
$this->assertRegExp('/Usage:$\s+' . preg_quote($command, '/') . '\b/m', $output);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function requiresPluginNameToEnable()
|
||||
{
|
||||
$enable_command = new CommandTester($this->app->find('enable'));
|
||||
$this->expectExceptionMessage('missing: "pluginName"');
|
||||
$enable_command->execute([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function enableComplainsWhenPassedUnresolvablePlugin()
|
||||
{
|
||||
$this->plugin_list->resolvePluginClass(Argument::any())->willThrow(new \InvalidArgumentException);
|
||||
|
||||
$enable_command = new CommandTester($this->app->find('enable'));
|
||||
$enable_command->execute(['pluginName' => 'vendor/package']);
|
||||
|
||||
$output = $enable_command->getDisplay();
|
||||
|
||||
$this->assertContains('ERROR', $output);
|
||||
$this->assertContains('Unknown plugin', $output);
|
||||
$this->assertNotEquals(0, $enable_command->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function enableComplainsWhenPassedAlreadyEnabledPlugin()
|
||||
{
|
||||
$this->plugin_list->resolvePluginClass('vendor/package')->will(
|
||||
function (array $_args, ObjectProphecy $plugin_list): string {
|
||||
/** @psalm-suppress TooManyArguments */
|
||||
$plugin_list->isEnabled('Vendor\Package\PluginClass')->willReturn(true);
|
||||
return 'Vendor\Package\PluginClass';
|
||||
}
|
||||
);
|
||||
|
||||
$enable_command = new CommandTester($this->app->find('enable'));
|
||||
$enable_command->execute(['pluginName' => 'vendor/package']);
|
||||
|
||||
$output = $enable_command->getDisplay();
|
||||
$this->assertContains('Plugin already enabled', $output);
|
||||
$this->assertNotEquals(0, $enable_command->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function enableReportsSuccessWhenItEnablesPlugin()
|
||||
{
|
||||
$this->plugin_list->resolvePluginClass('vendor/package')->will(
|
||||
function (array $_args, ObjectProphecy $plugin_list): string {
|
||||
$plugin_class = 'Vendor\Package\PluginClass';
|
||||
/** @psalm-suppress TooManyArguments */
|
||||
$plugin_list->isEnabled($plugin_class)->willReturn(false);
|
||||
/** @psalm-suppress TooManyArguments */
|
||||
$plugin_list->enable($plugin_class)->shouldBeCalled();
|
||||
return $plugin_class;
|
||||
}
|
||||
);
|
||||
|
||||
$enable_command = new CommandTester($this->app->find('enable'));
|
||||
$enable_command->execute(['pluginName' => 'vendor/package']);
|
||||
|
||||
$output = $enable_command->getDisplay();
|
||||
$this->assertContains('Plugin enabled', $output);
|
||||
$this->assertEquals(0, $enable_command->getStatusCode());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function requiresPluginNameToDisable()
|
||||
{
|
||||
$disable_command = new CommandTester($this->app->find('disable'));
|
||||
$this->expectExceptionMessage('missing: "pluginName"');
|
||||
$disable_command->execute([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function disableComplainsWhenPassedUnresolvablePlugin()
|
||||
{
|
||||
|
||||
$this->plugin_list->resolvePluginClass(Argument::any())->willThrow(new \InvalidArgumentException);
|
||||
|
||||
$disable_command = new CommandTester($this->app->find('disable'));
|
||||
$disable_command->execute(['pluginName' => 'vendor/package']);
|
||||
|
||||
$output = $disable_command->getDisplay();
|
||||
|
||||
$this->assertContains('ERROR', $output);
|
||||
$this->assertContains('Unknown plugin', $output);
|
||||
$this->assertNotEquals(0, $disable_command->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function disableComplainsWhenPassedNotEnabledPlugin()
|
||||
{
|
||||
$this->plugin_list->resolvePluginClass('vendor/package')->will(
|
||||
function (array $_args, ObjectProphecy $plugin_list): string {
|
||||
/** @psalm-suppress TooManyArguments */
|
||||
$plugin_list->isEnabled('Vendor\Package\PluginClass')->willReturn(false);
|
||||
return 'Vendor\Package\PluginClass';
|
||||
}
|
||||
);
|
||||
|
||||
$disable_command = new CommandTester($this->app->find('disable'));
|
||||
$disable_command->execute(['pluginName' => 'vendor/package']);
|
||||
|
||||
$output = $disable_command->getDisplay();
|
||||
$this->assertContains('Plugin already disabled', $output);
|
||||
$this->assertNotEquals(0, $disable_command->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @test
|
||||
*/
|
||||
public function disableReportsSuccessWhenItDisablesPlugin()
|
||||
{
|
||||
$this->plugin_list->resolvePluginClass('vendor/package')->will(
|
||||
function (array $_args, ObjectProphecy $plugin_list): string {
|
||||
$plugin_class = 'Vendor\Package\PluginClass';
|
||||
/** @psalm-suppress TooManyArguments */
|
||||
$plugin_list->isEnabled($plugin_class)->willReturn(true);
|
||||
/** @psalm-suppress TooManyArguments */
|
||||
$plugin_list->disable($plugin_class)->shouldBeCalled();
|
||||
return $plugin_class;
|
||||
}
|
||||
);
|
||||
|
||||
$disable_command = new CommandTester($this->app->find('disable'));
|
||||
$disable_command->execute(['pluginName' => 'vendor/package']);
|
||||
|
||||
$output = $disable_command->getDisplay();
|
||||
$this->assertContains('Plugin disabled', $output);
|
||||
$this->assertEquals(0, $disable_command->getStatusCode());
|
||||
}
|
||||
|
||||
/** @return string[][] */
|
||||
public function commands(): array
|
||||
{
|
||||
return [
|
||||
['show',],
|
||||
['enable',],
|
||||
['disable',],
|
||||
];
|
||||
}
|
||||
}
|
15
tests/stubs/base_plugin.php
Normal file
15
tests/stubs/base_plugin.php
Normal file
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
class BasePlugin extends \Psalm\Plugin
|
||||
{
|
||||
public static function afterFunctionCallCheck(
|
||||
\Psalm\StatementsSource $statements_source,
|
||||
$function_id,
|
||||
array $args,
|
||||
\Psalm\CodeLocation $code_location,
|
||||
\Psalm\Context $context,
|
||||
array &$file_replacements = [],
|
||||
\Psalm\Type\Union &$return_type_candidate = null
|
||||
) {
|
||||
}
|
||||
}
|
6
tests/stubs/extending_plugin.php
Normal file
6
tests/stubs/extending_plugin.php
Normal file
@ -0,0 +1,6 @@
|
||||
<?php
|
||||
require_once __DIR__ . '/base_plugin.php';
|
||||
|
||||
class ExtendingPlugin extends BasePlugin
|
||||
{
|
||||
}
|
15
tests/stubs/extending_plugin_entrypoint.php
Normal file
15
tests/stubs/extending_plugin_entrypoint.php
Normal file
@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
use Psalm\PluginApi\PluginEntryPointInterface;
|
||||
use Psalm\PluginApi\RegistrationInterface;
|
||||
|
||||
require_once __DIR__ . '/extending_plugin.php';
|
||||
|
||||
class ExtendingPluginRegistration implements PluginEntryPointInterface
|
||||
{
|
||||
/** @return void */
|
||||
public function __invoke(RegistrationInterface $r, SimpleXMLElement $config = null)
|
||||
{
|
||||
$r->registerHooksFromClass(ExtendingPlugin::class);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user