Skip to content

Commit

Permalink
Plugin loading (#855)
Browse files Browse the repository at this point in the history
* add ability to load plugins by class names

- Plugins need to implement `__invoke(PluginFacade $psalm):void` method
- Plugins are enabled by adding `<pluginClass
class="Qualified\Class\Name"/>`
- `PluginFacade` provides a single point of contact with Psalm, so that
plugins cannot become coupled to Psalm internals

* added `psalm-plugin` cli tool to manage plugins

Available commands:
 `psalm-plugin list` - lists available and enabled plugins
 `psalm-plugin enable 'Plugin\Class\Name'` - enables plugin (modifies `psalm.xml`)
 `psalm-plugin disable 'Plugin\Class\Name'` - disables plugin (modifies `psalm.xml`)

Plugin installation:
 `composer install plugin-vendor/plugin-package-name`

Plugin authoring:
 Plugins are identified by package `type` field, which should contain
 `psalm-plugin` string.
 `extra.pluginClass` should refer to the name of the class implementing
 `__invoke(PluginFacade $psalm):void` function

Todo:
 - better config file search
 - better output for `psalm-plugin`
 - better formatting for modified xml file
 - composer skeleton project for plugins
 - ability to refer to plugins by package name (cli only)
 - composer plugin to (optionally) enable plugin upon installation
 - documentation on plugin installation and authoring
 - interfaces for plugin dependencies
 - interface for plugin entry point
 - migration path for legacy plugins

* documented previously undocumented plugin methods

* split legacy plugin registration into a wrapper class

also added `PluginApi` namespace and `RegistrationInterface`

* reuse psalm's config search algorithm

* enable/disable plugins by composer package name

* allow specifying alternative config file name

* whitelist PluginApi namespace

three times, but well, it works now

* interface for plugin entry points

* psalm-plugin as a symfony console app

* fixed errors found by psalm

* suppressed false positive UnusedMethods

* cs fix

* better psalm-plugin output

* don't leave empty `plugins` node to avoid old schema violation

* removed junk file that shouldn't be there

* cs fix

* fixed phpunit failure (constant redefinition)

* work around missing docblock in on symfony console

* php 7.0 compatibility

* allow `pluginClass` child elements as plugin configuration

* decouple console commands from undelying implementation

- introduce PluginListFactory
- add `PluginList::enable(string $class)` and `PluginList::disable(string $class)`

* PluginList tests

* ComposerLock test

* droppped debugging statement

* added part of console command tests

* added tests for EnableCommand

* added DisableCommand tests

* ignore unused args

* ConfigFile test

* disable travis cache in attempt to fix builds

* nah, that didn't work

* update for upstream changes

* rebase fixes

* namespaced `extra` entry for entry point

* s/PluginFacade/PluginRegistrationSocket/g

* Added $config parameter to PluginEntryPointInterface::__invoke()

* cs fixes

* entry point interface php7.0 compatibility

* cleaned up old cruft

- dropped todos I'm not going to pursues
- locked entry point to be a class implementing entry point interface

* fixed legacy plugins docs

* Added RegistrationInterface::registerHooksFromClass()

It mimics the way old plugins were registered in Psalm\Config, so
handler classes extending Psalm\Plugin should be fully compatible with
it.

Since Psalm\Plugin-style plugin registration was moved to
RegistrationSocket, LegacyPlugin now only load file-based plugins, so it
was renamed to FileBasedPluginAdapter.

* Converted EchoChecker plugin to composer-based format

- Its subfolder is registered as a local composer package in the root
composer.json, so it's directly installable with
	```
	composer require psalm/echo-checker-plugin
	```
- Migration is trivial: drop the plugin into a separate folder, then add
simple composer.json and the entry point class.

* Updated docs

* Don't reject hook handlers that inherit handling methods

* strip void return type in stub file
  • Loading branch information
weirdan authored and muglug committed Nov 11, 2018
1 parent a3dde47 commit 052d4f6
Show file tree
Hide file tree
Showing 31 changed files with 1,877 additions and 44 deletions.
12 changes: 10 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
},
Expand Down Expand Up @@ -70,5 +72,11 @@
},
"provide": {
"psalm/psalm": "self.version"
}
},
"repositories": [
{
"type": "path",
"url": "examples/composer-based/echo-checker"
}
]
}
14 changes: 11 additions & 3 deletions config.xsd
Original file line number Diff line number Diff line change
Expand Up @@ -108,13 +108,21 @@
</xs:complexType>

<xs:complexType name="PluginsType">
<xs:sequence>
<xs:element name="plugin" maxOccurs="unbounded">
<xs:choice minOccurs="0" maxOccurs="unbounded">
<xs:element name="plugin">
<xs:complexType>
<xs:attribute name="filename" type="xs:string" use="required" />
</xs:complexType>
</xs:element>
</xs:sequence>
<xs:element name="pluginClass">
<xs:complexType>
<xs:sequence>
<xs:any minOccurs="0" maxOccurs="unbounded" processContents="skip"/>
</xs:sequence>
<xs:attribute name="class" type="xs:string" use="required" />
</xs:complexType>
</xs:element>
</xs:choice>
</xs:complexType>

<xs:complexType name="IssueHandlersType">
Expand Down
54 changes: 50 additions & 4 deletions docs/plugins.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
# Plugins
# File-based plugins

Psalm can be extended through plugins to find domain-specific issues.

All plugins must extend `Psalm\Plugin` and return an instance of themselves e.g.
All plugins must extend `Psalm\Plugin`

```php
<?php
class SomePlugin extends \Psalm\Plugin
{
}
return new SomePlugin;
```

`Psalm\Plugin` offers two methods that you can override:
`Psalm\Plugin` offers six methods that you can override:
- `afterStatementsCheck` - called after Psalm evaluates each statement
- `afterExpressionCheck` - called after Psalm evaluates each expression
- `afterVisitClassLike` - called after Psalm crawls the parsed Abstract Syntax Tree for a class-like (class, interface, trait). Due to caching the AST is crawled the first time Psalm sees the file, and is only re-crawled if the file changes, the cache is cleared, or you're disabling cache with `--no-cache`
- `afterClassLikeExistsCheck` - called after Psalm analyzes a reference to a class-like
- `afterMethodCallCheck` - called after Psalm analyzes a method call
- `afterFunctionCallCheck` - called after Psalm analyzes a function call

An example plugin that checks class references in strings is provided [here](https://github.com/vimeo/psalm/blob/master/examples/StringChecker.php).

Expand All @@ -24,3 +27,46 @@ To ensure your plugin runs when Psalm does, add it to your [config](Configuratio
<plugin filename="src/plugins/SomePlugin.php" />
</plugins>
```

# Composer-based plugins

Composer-based plugins provide easier way to manage and distribute your plugins.

## Using composer-based plugins
### Discovering plugins

Plugins can be found on Packagist by `type=psalm-plugin` query: https://packagist.org/packages/list.json?type=psalm-plugin

### Installing plugins

`composer require --dev plugin-vendor/plugin-package`

### Managing known plugins

Once installed, you can use `psalm-plugin` tool to enable, disable and show available and enabled plugins.

To enable the plugin, run `psalm-plugin enable plugin-vendor/plugin-package`. To disable it, run `psalm-plugin disable plugin-vendor/plugin-package`. `psalm-plugin show` (as well as bare `psalm-plugin`) will show you the list of enabled plugins, and the list of plugins known to `psalm-plugin` (installed into your `vendor` folder)

## Authoring composer-based plugins

### Requirements

Composer-based plugin is a composer package which conforms to these requirements:

1. Its `type` field is set to `psalm-plugin`
2. It has `extra.psalm.pluginClass` subkey in its `composer.json` that reference an entry-point class that will be invoked to register the plugin into Psalm runtime.
3. Entry-point class implements `Psalm\PluginApi\PluginEntryPointInterface`

### Using skeleton project

Run `composer create-project weirdan/psalm-plugin-skeleton:dev-master your-plugin-name` to quickly bootstrap a new plugin project in `your-plugin-name` folder. Make sure you adjust namespaces in `composer.json`, `Plugin.php` and `tests` folder.

### Upgrading file-based plugin to composer-based version

Create new plugin project using skeleton, then pass the class name of you file-based plugin to `registerHooksFromClass()` method of the `Psalm\PluginApi\RegistrationInterface` instance that was passed into your plugin entry point's `__invoke()` method. See the [conversion example](https://github.com/vimeo/psalm/examples/composer-based/echo-checker/).

### Registering stub files

Use `Psalm\PluginApi\RegistrationInterface::addStubFile()`. See the [sample plugin] (https://github.com/weirdan/psalm-doctrine-collections/).

Stub files provide a way to override third-party type information when you cannot add Psalm's extended docblocks to the upstream source files directly.
72 changes: 72 additions & 0 deletions examples/composer-based/echo-checker/EchoChecker.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php
namespace Vimeo\CodeAnalysis\EchoChecker;

use PhpParser;
use Psalm\Checker;
use Psalm\Checker\StatementsChecker;
use Psalm\CodeLocation;
use Psalm\Context;
use Psalm\FileManipulation\FileManipulation;
use Psalm\IssueBuffer;
use Psalm\Issue\TypeCoercion;

class EchoChecker extends \Psalm\Plugin
{
/**
* Called after an expression has been checked
*
* @param StatementsChecker $statements_checker
* @param PhpParser\Node $stmt
* @param Context $context
* @param CodeLocation $code_location
* @param string[] $suppressed_issues
* @param FileManipulation[] $file_replacements
*
* @return null|false
*/
public static function afterStatementCheck(
StatementsChecker $statements_checker,
PhpParser\Node $stmt,
Context $context,
CodeLocation $code_location,
array $suppressed_issues,
array &$file_replacements = []
) {
if ($stmt instanceof PhpParser\Node\Stmt\Echo_) {
foreach ($stmt->exprs as $expr) {
if (!isset($expr->inferredType) || $expr->inferredType->isMixed()) {
if (IssueBuffer::accepts(
new TypeCoercion(
'Echo requires an unescaped string, ' . $expr->inferredType . ' provided',
new CodeLocation($statements_checker->getSource(), $expr)
),
$statements_checker->getSuppressedIssues()
)) {
// keep soldiering on
}

continue;
}

$types = $expr->inferredType->getTypes();

foreach ($types as $type) {
if ($type instanceof \Psalm\Type\Atomic\TString
&& !$type instanceof \Psalm\Type\Atomic\TLiteralString
&& !$type instanceof \Psalm\Type\Atomic\THtmlEscapedString
) {
if (IssueBuffer::accepts(
new TypeCoercion(
'Echo requires an unescaped string, ' . $expr->inferredType . ' provided',
new CodeLocation($statements_checker->getSource(), $expr)
),
$statements_checker->getSuppressedIssues()
)) {
// keep soldiering on
}
}
}
}
}
}
}
15 changes: 15 additions & 0 deletions examples/composer-based/echo-checker/PluginEntryPoint.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php
namespace Vimeo\CodeAnalysis\EchoChecker;

use Psalm\PluginApi;
use SimpleXMLElement;

class PluginEntryPoint implements PluginApi\PluginEntryPointInterface
{
/** @return void */
public function __invoke(PluginApi\RegistrationInterface $registration, ?SimpleXMLElement $config = null)
{
require_once __DIR__ . '/EchoChecker.php';
$registration->registerHooksFromClass(EchoChecker::class);
}
}
21 changes: 21 additions & 0 deletions examples/composer-based/echo-checker/composer.json
Original file line number Diff line number Diff line change
@@ -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\\": ["."]
}
}
}
1 change: 1 addition & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<file>src/psalm.php</file>
<file>src/psalm-language-server.php</file>
<file>src/psalter.php</file>
<file>src/psalm_plugin.php</file>
<file>src/Psalm/CallMap.php</file>
<file>src/Psalm/Fork/Pool.php</file>
<file>src/Psalm/PropertyMap.php</file>
Expand Down
2 changes: 2 additions & 0 deletions psalm-plugin
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env php
<?php require_once __DIR__ . DIRECTORY_SEPARATOR . 'src' . DIRECTORY_SEPARATOR . 'psalm_plugin.php';
1 change: 1 addition & 0 deletions psalm.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
<PossiblyUnusedMethod>
<errorLevel type="suppress">
<directory name="tests" />
<directory name="src/Psalm/PluginApi" />
<file name="src/Psalm/Plugin.php" />
<file name="src/Psalm/LanguageServer/Client/TextDocument.php" />
<file name="src/Psalm/LanguageServer/Server/TextDocument.php" />
Expand Down
9 changes: 9 additions & 0 deletions scoper.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +114,17 @@ function ($filePath, $prefix, $contents) {

return $contents;
},
function ($filePath, $prefix, $contents) {
$ret = str_replace(
$prefix . '\Psalm\PluginApi',
'Psalm\PluginApi',
$contents
);
return $ret;
},
],
'whitelist' => [
\Composer\Autoload\ClassLoader::class,
'Psalm\PluginApi\*',
]
];
Loading

0 comments on commit 052d4f6

Please sign in to comment.