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