1
0
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:
Bruce Weirdan 2018-11-11 06:23:36 +02:00 committed by Matthew Brown
parent a3dde47374
commit 052d4f6217
31 changed files with 1877 additions and 44 deletions

View File

@ -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"
}
]
}

View File

@ -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">

View File

@ -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.

View 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
}
}
}
}
}
}
}

View 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);
}
}

View 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\\": ["."]
}
}
}

View File

@ -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
View File

@ -0,0 +1,2 @@
#!/usr/bin/env php
<?php require_once __DIR__ . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'psalm_plugin.php';

View File

@ -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" />

View File

@ -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\*',
]
];

View File

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

View 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;
}
}

View File

@ -0,0 +1,10 @@
<?php
namespace Psalm\PluginApi;
use SimpleXMLElement;
interface PluginEntryPointInterface
{
/** @return void */
public function __invoke(RegistrationInterface $api, SimpleXMLElement $config = null);
}

View 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);
}

View 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');
}
}

View 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');
}
}

View 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');
}
}
}

View 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"]);
}
}

View 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));
}
}

View 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);
}
}

View 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);
}
}

View 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
View 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
View 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
View 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
View 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');
}
}

View File

@ -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
View 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',],
];
}
}

View 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
) {
}
}

View File

@ -0,0 +1,6 @@
<?php
require_once __DIR__ . '/base_plugin.php';
class ExtendingPlugin extends BasePlugin
{
}

View 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);
}
}