diff --git a/composer.json b/composer.json
index c7a49a714..4e3fe5654 100644
--- a/composer.json
+++ b/composer.json
@@ -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"
+ }
+ ]
}
diff --git a/config.xsd b/config.xsd
index 935fe464d..2015fafab 100644
--- a/config.xsd
+++ b/config.xsd
@@ -108,13 +108,21 @@
-
-
+
+
-
+
+
+
+
+
+
+
+
+
diff --git a/docs/plugins.md b/docs/plugins.md
index 50e078b82..8e710724e 100644
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -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
```
+
+# 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.
diff --git a/examples/composer-based/echo-checker/EchoChecker.php b/examples/composer-based/echo-checker/EchoChecker.php
new file mode 100644
index 000000000..78db9bdc9
--- /dev/null
+++ b/examples/composer-based/echo-checker/EchoChecker.php
@@ -0,0 +1,72 @@
+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
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/examples/composer-based/echo-checker/PluginEntryPoint.php b/examples/composer-based/echo-checker/PluginEntryPoint.php
new file mode 100644
index 000000000..e98d8662b
--- /dev/null
+++ b/examples/composer-based/echo-checker/PluginEntryPoint.php
@@ -0,0 +1,15 @@
+registerHooksFromClass(EchoChecker::class);
+ }
+}
diff --git a/examples/composer-based/echo-checker/composer.json b/examples/composer-based/echo-checker/composer.json
new file mode 100644
index 000000000..d8b4858c1
--- /dev/null
+++ b/examples/composer-based/echo-checker/composer.json
@@ -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\\": ["."]
+ }
+ }
+}
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index f1d189bcd..c68316e85 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -23,6 +23,7 @@
src/psalm.php
src/psalm-language-server.php
src/psalter.php
+ src/psalm_plugin.php
src/Psalm/CallMap.php
src/Psalm/Fork/Pool.php
src/Psalm/PropertyMap.php
diff --git a/psalm-plugin b/psalm-plugin
new file mode 100755
index 000000000..c98213d44
--- /dev/null
+++ b/psalm-plugin
@@ -0,0 +1,2 @@
+#!/usr/bin/env php
+
+
diff --git a/scoper.inc.php b/scoper.inc.php
index 2fbf60f74..51b464f46 100644
--- a/scoper.inc.php
+++ b/scoper.inc.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\*',
]
];
diff --git a/src/Psalm/Config.php b/src/Psalm/Config.php
index f2ec6d94a..c8cfd8dbb 100644
--- a/src/Psalm/Config.php
+++ b/src/Psalm/Config.php
@@ -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
+ */
+ 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 */
+ 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;
+ }
}
diff --git a/src/Psalm/FileBasedPluginAdapter.php b/src/Psalm/FileBasedPluginAdapter.php
new file mode 100644
index 000000000..997336ee2
--- /dev/null
+++ b/src/Psalm/FileBasedPluginAdapter.php
@@ -0,0 +1,73 @@
+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;
+ }
+}
diff --git a/src/Psalm/PluginApi/PluginEntryPointInterface.php b/src/Psalm/PluginApi/PluginEntryPointInterface.php
new file mode 100644
index 000000000..da77b5099
--- /dev/null
+++ b/src/Psalm/PluginApi/PluginEntryPointInterface.php
@@ -0,0 +1,10 @@
+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');
+ }
+}
diff --git a/src/Psalm/PluginManager/Command/EnableCommand.php b/src/Psalm/PluginManager/Command/EnableCommand.php
new file mode 100644
index 000000000..df880e741
--- /dev/null
+++ b/src/Psalm/PluginManager/Command/EnableCommand.php
@@ -0,0 +1,77 @@
+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');
+ }
+}
diff --git a/src/Psalm/PluginManager/Command/ShowCommand.php b/src/Psalm/PluginManager/Command/ShowCommand.php
new file mode 100644
index 000000000..4db5a493f
--- /dev/null
+++ b/src/Psalm/PluginManager/Command/ShowCommand.php
@@ -0,0 +1,87 @@
+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');
+ }
+ }
+}
diff --git a/src/Psalm/PluginManager/ComposerLock.php b/src/Psalm/PluginManager/ComposerLock.php
new file mode 100644
index 000000000..ef786d4f6
--- /dev/null
+++ b/src/Psalm/PluginManager/ComposerLock.php
@@ -0,0 +1,89 @@
+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 [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
+ */
+ 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"]);
+ }
+}
diff --git a/src/Psalm/PluginManager/ConfigFile.php b/src/Psalm/PluginManager/ConfigFile.php
new file mode 100644
index 000000000..2916da512
--- /dev/null
+++ b/src/Psalm/PluginManager/ConfigFile.php
@@ -0,0 +1,84 @@
+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));
+ }
+}
diff --git a/src/Psalm/PluginManager/PluginList.php b/src/Psalm/PluginManager/PluginList.php
new file mode 100644
index 000000000..c5bffd140
--- /dev/null
+++ b/src/Psalm/PluginManager/PluginList.php
@@ -0,0 +1,101 @@
+ [pluginClass => packageName]*/
+ private $all_plugins = null;
+
+ /** @var ?array [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 [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 [pluginCLass => ?packageName]
+ */
+ public function getAvailable(): array
+ {
+ return array_diff_key($this->getAll(), $this->getEnabled());
+ }
+
+ /**
+ * @return array [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);
+ }
+}
diff --git a/src/Psalm/PluginManager/PluginListFactory.php b/src/Psalm/PluginManager/PluginListFactory.php
new file mode 100644
index 000000000..6d89fb802
--- /dev/null
+++ b/src/Psalm/PluginManager/PluginListFactory.php
@@ -0,0 +1,12 @@
+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;
+ }
+ }
+}
diff --git a/src/psalm_plugin.php b/src/psalm_plugin.php
new file mode 100644
index 000000000..ed1e2f9a0
--- /dev/null
+++ b/src/psalm_plugin.php
@@ -0,0 +1,31 @@
+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();
diff --git a/tests/ComposerLockTest.php b/tests/ComposerLockTest.php
new file mode 100644
index 000000000..67ad51a04
--- /dev/null
+++ b/tests/ComposerLockTest.php
@@ -0,0 +1,169 @@
+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);
+ }
+}
diff --git a/tests/ConfigFileTest.php b/tests/ConfigFileTest.php
new file mode 100644
index 000000000..42b174193
--- /dev/null
+++ b/tests/ConfigFileTest.php
@@ -0,0 +1,157 @@
+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('
+
+
+ '));
+
+ $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('
+
+
+
+
+ '));
+
+ $config_file = new ConfigFile((string)getcwd(), $this->file_path);
+ $config_file->addPlugin('a\b\c');
+
+ $this->assertXmlStringEqualsXmlString(
+ '',
+ file_get_contents($this->file_path)
+ );
+ }
+
+ /**
+ * @return void
+ * @test
+ */
+ public function addCanCreateMissingPluginsNode()
+ {
+ file_put_contents($this->file_path, trim('
+
+
+ '));
+
+ $config_file = new ConfigFile((string)getcwd(), $this->file_path);
+ $config_file->addPlugin('a\b\c');
+
+ $this->assertXmlStringEqualsXmlString(
+ '',
+ file_get_contents($this->file_path)
+ );
+ }
+
+ /**
+ * @return void
+ * @test
+ */
+ public function removeDoesNothingWhenThereIsNoPluginsNode()
+ {
+ $noPlugins = trim('
+
+
+ ');
+ 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('
+
+
+ ');
+
+ $emptyPlugins = trim('
+
+
+ ');
+
+ 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('
+
+
+ ');
+
+ $abcEnabled = trim('
+
+
+ ');
+
+ 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)
+ );
+ }
+}
diff --git a/tests/PluginListTest.php b/tests/PluginListTest.php
new file mode 100644
index 000000000..2ef0c765b
--- /dev/null
+++ b/tests/PluginListTest.php
@@ -0,0 +1,180 @@
+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');
+ }
+}
diff --git a/tests/PluginTest.php b/tests/PluginTest.php
index fe9c8fd3b..00d2447d0 100644
--- a/tests/PluginTest.php
+++ b/tests/PluginTest.php
@@ -1,9 +1,14 @@
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,
+ '
+
+
+
+
+
+
+
+ '
+ )
+ );
+
+ $this->project_checker->config->initializePlugins($this->project_checker);
+ $this->assertContains(
+ 'ExtendingPlugin',
+ $this->project_checker->config->after_function_checks
+ );
+ }
}
diff --git a/tests/PsalmPluginTest.php b/tests/PsalmPluginTest.php
new file mode 100644
index 000000000..64715da75
--- /dev/null
+++ b/tests/PsalmPluginTest.php
@@ -0,0 +1,325 @@
+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',],
+ ];
+ }
+}
diff --git a/tests/stubs/base_plugin.php b/tests/stubs/base_plugin.php
new file mode 100644
index 000000000..faa61c0f3
--- /dev/null
+++ b/tests/stubs/base_plugin.php
@@ -0,0 +1,15 @@
+registerHooksFromClass(ExtendingPlugin::class);
+ }
+}