diff --git a/README.md b/README.md
index 6eb5233af..4861e67a3 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,7 @@ To make GrumPHP even more awesome, it will suggest installing some extra package
- codeception/codeception : ~2.1
- sensiolabs/security-checker : ~3.0
- phpmd/phpmd : ~2.4
+- nikic/php-parser: ~2.1
GrumPHP will never push you into using a specific task. You can choose the tasks that fit your needs, and activate or
deactivate any task in no time!
@@ -111,6 +112,7 @@ parameters:
phpcsfixer2: ~
phplint: ~
phpmd: ~
+ phpparser: ~
phpspec: ~
phpunit: ~
robo: ~
diff --git a/composer.json b/composer.json
index 57b34e7fa..d63311ab5 100644
--- a/composer.json
+++ b/composer.json
@@ -11,26 +11,26 @@
"gitonomy/gitlib": "~1.0",
"monolog/monolog": "~1.17",
"seld/jsonlint": "~1.1",
- "symfony/config": "~2.4|~3.0",
- "symfony/console": "~2.6|~3.0",
- "symfony/dependency-injection": "~2.4|~3.0",
- "symfony/event-dispatcher": "~2.5|~3.0",
- "symfony/filesystem": "~2.4|~3.0",
- "symfony/finder": "~2.4|~3.0",
- "symfony/options-resolver": "~2.6|~3.0",
- "symfony/process": "~2.4|~3.0",
- "symfony/proxy-manager-bridge": "~2.6|~3.0",
- "symfony/yaml": "~2.4|~3.0"
+ "symfony/config": "~2.7|~3.0",
+ "symfony/console": "~2.7|~3.0",
+ "symfony/dependency-injection": "~2.7|~3.0",
+ "symfony/event-dispatcher": "~2.7|~3.0",
+ "symfony/filesystem": "~2.7|~3.0",
+ "symfony/finder": "~2.7|~3.0",
+ "symfony/options-resolver": "~2.7|~3.0",
+ "symfony/process": "~2.7|~3.0",
+ "symfony/proxy-manager-bridge": "~2.7|~3.0",
+ "symfony/yaml": "~2.7|~3.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "~1|~2",
"jakub-onderka/php-parallel-lint": "^0.9.2",
+ "nikic/php-parser": "~2.1",
"phpspec/phpspec": "~2.5",
"phpspec/prophecy": "~1.6",
"phpunit/phpunit": "^4.8",
"sensiolabs/security-checker": "^3.0",
- "squizlabs/php_codesniffer": "~2.3",
- "sstalle/php7cc": "^1.1"
+ "squizlabs/php_codesniffer": "~2.3"
},
"suggest": {
"atoum/atoum": "Lets GrumPHP run your unit tests.",
@@ -41,6 +41,7 @@
"friendsofphp/php-cs-fixer": "Lets GrumPHP automatically fix your codestyle.",
"jakub-onderka/php-parallel-lint": "Lets GrumPHP quickly lint your entire code base.",
"malukenho/kawaii-gherkin": "Lets GrumPHP lint your Gherkin files.",
+ "nikic/php-parser": "Lets GrumPHP run static analyses through your PHP files.",
"phing/phing": "Lets GrumPHP run your automated PHP tasks.",
"phpmd/phpmd": "Lets GrumPHP sort out the mess in your code",
"phpspec/phpspec": "Lets GrumPHP spec your code.",
diff --git a/doc/tasks.md b/doc/tasks.md
index 22e3e043c..b0ef2fb55 100644
--- a/doc/tasks.md
+++ b/doc/tasks.md
@@ -30,8 +30,9 @@ parameters:
phpcs: ~
phpcsfixer: ~
phpcsfixer2: ~
- phpmd: ~
phplint: ~
+ phpmd: ~
+ phpparser: ~
phpspec: ~
phpunit: ~
robo: ~
@@ -68,8 +69,9 @@ Every task has it's own default configuration. It is possible to overwrite the p
- [Phpcs](tasks/phpcs.md)
- [PHP-CS-Fixer](tasks/php_cs_fixer.md)
- [PHP-CS-Fixer 2](tasks/php_cs_fixer2.md)
-- [PhpMd](tasks/phpmd.md)
- [PHPLint](tasks/phplint.md)
+- [PhpMd](tasks/phpmd.md)
+- [PhpParser](tasks/phpparser.md)
- [Phpspec](tasks/phpspec.md)
- [Phpunit](tasks/phpunit.md)
- [Robo](tasks/robo.md)
diff --git a/doc/tasks/php7cc.md b/doc/tasks/php7cc.md
index b7c268c97..d6b9b1e2a 100644
--- a/doc/tasks/php7cc.md
+++ b/doc/tasks/php7cc.md
@@ -30,3 +30,9 @@ Minimum issue level. There are 3 issue levels: "info", "warning" and "error". "i
*Default: [php]*
This is a list of extensions to be sniffed.
+
+
+## Known issues
+
+- Since this task is using an old version of phpparser, it currently cannot be used in combination with the `phpparser` task.
+[Click here for more information](https://github.com/sstalle/php7cc/issues/79)
diff --git a/doc/tasks/phpparser.md b/doc/tasks/phpparser.md
new file mode 100644
index 000000000..92f1671e0
--- /dev/null
+++ b/doc/tasks/phpparser.md
@@ -0,0 +1,303 @@
+# Phpparser
+
+The Phpparser task will run static code analyses on your PHP code.
+You can specify which code visitors should run on your code or write your own code visitor.
+It lives under the `php_parser` namespace and has following configurable parameters:
+
+```yaml
+# grumphp.yml
+parameters:
+ tasks:
+ phpparser:
+ ignore_patterns: []
+ kind: php7
+ triggered_by: [php]
+ visitors: {}
+```
+
+**triggered_by**
+
+*Default: [php]*
+
+This option will specify which file extensions will trigger the php blacklist task.
+By default php blacklist will be triggered by altering a php file.
+You can overwrite this option to whatever filetype you want to validate!
+
+**ignore_patterns**
+
+*Default: []*
+
+This is a list of patterns that will be ignored by the PHP Parser.
+With this option you can skip files like tests. Leave this option blank to run analysis for every php file.
+
+**kind**
+
+*Default: php7*
+
+Can be one of: php5, php7.
+This option determines which Lexer the PHP_Parser uses to tokenize the PHP code.
+By default the PREFER_PHP7 is loaded.
+
+**visitors**
+
+*Default: {}*
+
+Use this parameter to specify what code you want to scan. This is made possible by node visitors following PHP_Parser syntax.
+Without any visitors specified, PHP Parser will only check code syntax (similar to php lint).
+In the next chapter, you can find a list of built-in visitors.
+It's also possible to write your own visitor!
+
+
+## Built-in visitors
+
+- [declare_strict_types](#declare_strict_types)
+- [forbidden_class_method_calls](#forbidden_class_method_calls)
+- [forbidden_function_calls](#forbidden_function_calls)
+- [forbidden_static_method_calls](#forbidden_static_method_calls)
+- [nameresolver](#nameresolver)
+- [never_use_else](#never_use_else)
+- [no_exit_statements](#no_exit_statements)
+
+### declare_strict_types
+
+This visitor can be used to enforce `declare(strict_types=1)` in every PHP file.
+
+```yaml
+# grumphp.yml
+parameters:
+ tasks:
+ phpparser:
+ visitors:
+ declare_strict_types: ~
+```
+
+This visitore is not configurable!
+
+
+### forbidden_class_method_calls
+
+This visitor can be used to look for forbidden class method calls.
+
+```yaml
+# grumphp.yml
+parameters:
+ tasks:
+ phpparser:
+ visitors:
+ forbidden_class_method_calls:
+ blacklist:
+ - '$dumper->dump'
+```
+
+**blacklist**
+
+*Default: []*
+
+This is a list of blacklisted class method calls. The syntax is `$variableName->methodName`.
+When one of the functions inside this list is being called by your code,
+the parser will markt this method as an error.
+
+
+### forbidden_function_calls
+
+This visitor can be used to look for forbidden function calls.
+
+```yaml
+# grumphp.yml
+parameters:
+ tasks:
+ phpparser:
+ visitors:
+ forbidden_function_calls:
+ blacklist:
+ - 'var_dump'
+```
+
+**blacklist**
+
+*Default: []*
+
+This is a list of blacklisted function calls.
+When one of the functions inside this list is being called by your code,
+the parser will markt this method as an error.
+
+
+### forbidden_static_method_calls
+
+This visitor can be used to look for forbidden static method calls.
+
+```yaml
+# grumphp.yml
+parameters:
+ tasks:
+ phpparser:
+ visitors:
+ forbidden_static_method_calls:
+ blacklist:
+ - 'Dumper::dump'
+```
+
+**blacklist**
+
+*Default: []*
+
+This is a list of blacklisted static method calls. The syntax is `Fully\Qualified\ClassName::staticMethodName`.
+When one of the functions inside this list is being called by your code,
+the parser will markt this method as an error.
+
+
+### nameresolver
+
+This visitor is an alias for the built-in PhpParser NameResolver visitor.
+It looks for class aliases in your code and adds the alias as an attribute to the class nodes.
+
+*Note:* This visitor is enabled by default since it is used by other visitors.
+You don't have to register it in the task configuration.
+
+This visitor is not configurable!
+
+
+### never_use_else
+
+This visitor will search for the `else` and `elseif` keywords in your code.
+An error will be added if one of those statements is found.
+More information about Object Calisthenics can be found
+[here](http://www.slideshare.net/rdohms/your-code-sucks-lets-fix-it-15471808)
+and
+[here](http://www.slideshare.net/guilhermeblanco/object-calisthenics-applied-to-php).
+
+```yaml
+# grumphp.yml
+parameters:
+ tasks:
+ phpparser:
+ visitors:
+ never_use_else: ~
+```
+
+This visitor is not configurable!
+
+
+### no_exit_statements
+
+This visitor will search for exit statements like `die()` or `exit` in your code.
+An error will be added if an exit statement is found.
+
+```yaml
+# grumphp.yml
+parameters:
+ tasks:
+ phpparser:
+ visitors:
+ no_exit_statements: ~
+```
+
+This visitor is not configurable!
+
+
+## Creating your own visitor
+
+Creating your own visitor is easy!
+Just create a class that implements the `PhpParser\NodeVisitor` interface:
+
+```php
+// PhpParser\NodeVisitor
+interface NodeVisitor
+{
+ public function beforeTraverse(array $nodes);
+ public function enterNode(Node $node);
+ public function leaveNode(Node $node);
+ public function afterTraverse(array $nodes);
+}
+```
+
+Once you've written your visitor, you'll have to register it to the service container:
+
+```yaml
+services:
+ grumphp.parser.php.visitor.your_visitor:
+ class: 'Your\Visitor\Class'
+ arguments: []
+ tags:
+ - {name: 'php_parser.visitor', alias: 'your_visitor'}
+
+```
+
+Since we use the service container, you are able to inject the dependencies you need with the `arguments` attribute.
+The `php_parser.visitor` tag will make your class available in GrumPHP.
+Tha alias `your_visitor` can now be set as a visitor in the phpparser task:
+
+```yml
+# grumphp.yml
+parameters:
+ tasks:
+ phpparser:
+ visitors:
+ your_visitor: ~
+```
+
+### Stateless visitors
+
+An important note on the visitors is that they are completely stateless!
+If you've already written a PhpParser Visitor before, you know that advanced visitors will contain a specific state about the class.
+When scanning the next file, the state needs to be reset.
+
+In our implementation, we've chosen to always create a new visitor for every file.
+This way, you don't have to think about clearing the state of a visitor on each run.
+This will result in easy and understandable visitors!
+
+
+### Optional interfaces and classes
+
+We also added some optional interfaces and to make it easier to interct with the GrumPHP context:
+
+
+**ConfigurableVisitorInterface**
+
+The `ConfigurableVisitorInterface` allows you to make the visitor configurable in the `grumphp.yml` file.
+To make sure the visitor works as you please, It is recommended to use the `OptionsResolver` to validate the configured options.
+
+
+```php
+// GrumPHP\Parser\Php\Visitor\ConfigurableVisitorInterface;
+interface ConfigurableVisitorInterface extends NodeVisitor
+{
+ public function configure(array $options);
+}
+```
+
+
+**ContextAwareVisitorInterface**
+
+The `ContextAwareVisitorInterface` will make your task aware of the context the visitor is running in.
+The `ParserContext` object will have access to the file information and the errors collection.
+This last one can be used to add a new error in your visitor.
+
+```php
+// GrumPHP\Parser\Php\Visitor\ContextAwareVisitorInterface
+interface ContextAwareVisitorInterface extends NodeVisitor
+{
+ public function setContext(ParserContext $context);
+}
+```
+
+**AbstractVisitor**
+
+In the built-in visitors, we use the `AbstractVisitor` that extends the `PhpParser\NodeVisitorAbstract`.
+This means that you only have to implement the methods from the `NodeVisitor` that you need.
+It also implements the `ContextAwareVisitorInterface` and provides an easy method for logging errors in your custom visitor.
+The bleuprint of this abstract visitor looks like this:
+
+
+```php
+// GrumPHP\Parser\Php\Visitor\AbstractVisitor;
+class AbstractVisitor extends NodeVisitorAbstract implements ContextAwareVisitorInterface
+{
+ protected $context;
+
+ public function setContext(ParserContext $context);
+
+ protected function addError($message, $line = -1, $type = ParseError::TYPE_ERROR);
+}
+
+```
diff --git a/grumphp.yml.dist b/grumphp.yml.dist
index 9771cce93..40b2071ec 100644
--- a/grumphp.yml.dist
+++ b/grumphp.yml.dist
@@ -21,3 +21,11 @@ parameters:
ignore_patterns:
- "#test/(.*).yml#"
phplint: ~
+ phpparser:
+ visitors:
+ no_exit_statements: ~
+ never_use_else: ~
+ forbidden_function_calls:
+ blacklist: [var_dump]
+ metadata:
+ priority: 100000
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index f3581db6d..a51662de1 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -3,5 +3,8 @@
./test/src/GrumPHPTest/Linter
+
+ ./test/src/GrumPHPTest/Parser
+
diff --git a/resources/config/parsers.yml b/resources/config/parsers.yml
new file mode 100644
index 000000000..0e6d7e3cc
--- /dev/null
+++ b/resources/config/parsers.yml
@@ -0,0 +1,66 @@
+services:
+ grumphp.parser.php.configurator.traverser:
+ class: 'GrumPHP\Parser\Php\Configurator\TraverserConfigurator'
+ arguments:
+ - '@service_container'
+ calls:
+ - ['registerStandardEnabledVisitor', ['nameresolver', ~]]
+
+ grumphp.parser.php.factory.parser:
+ class: 'GrumPHP\Parser\Php\Factory\ParserFactory'
+ arguments: []
+
+ grumphp.parser.php.factory.traverser:
+ class: 'GrumPHP\Parser\Php\Factory\TraverserFactory'
+ arguments:
+ - '@grumphp.parser.php.configurator.traverser'
+
+ grumphp.parser.php.parser:
+ class: 'GrumPHP\Parser\Php\PhpParser'
+ arguments:
+ - '@grumphp.parser.php.factory.parser'
+ - '@grumphp.parser.php.factory.traverser'
+
+ #
+ # AVAILABLE PHP PARSER VISITORS ...
+ #
+ grumphp.parser.php.visitor.declare_strict_types:
+ class: 'GrumPHP\Parser\Php\Visitor\DeclareStrictTypesVisitor'
+ tags:
+ - {name: 'php_parser.visitor', alias: 'declare_strict_types'}
+
+ grumphp.parser.php.visitor.forbidden_class_method_calls:
+ class: 'GrumPHP\Parser\Php\Visitor\ForbiddenClassMethodCallsVisitor'
+ arguments: []
+ tags:
+ - {name: 'php_parser.visitor', alias: 'forbidden_class_method_calls'}
+
+ grumphp.parser.php.visitor.forbidden_function_calls:
+ class: 'GrumPHP\Parser\Php\Visitor\ForbiddenFunctionCallsVisitor'
+ arguments: []
+ tags:
+ - {name: 'php_parser.visitor', alias: 'forbidden_function_calls'}
+
+ grumphp.parser.php.visitor.forbidden_static_method_calls:
+ class: 'GrumPHP\Parser\Php\Visitor\ForbiddenStaticMethodCallsVisitor'
+ arguments: []
+ tags:
+ - {name: 'php_parser.visitor', alias: 'forbidden_static_method_calls'}
+
+ grumphp.parser.php.visitor.nameresolver:
+ class: 'PhpParser\NodeVisitor\NameResolver'
+ arguments: []
+ tags:
+ - {name: 'php_parser.visitor', alias: 'nameresolver'}
+
+ grumphp.parser.php.visitor.never_use_else:
+ class: 'GrumPHP\Parser\Php\Visitor\NeverUseElseVisitor'
+ arguments: []
+ tags:
+ - {name: 'php_parser.visitor', alias: 'never_use_else'}
+
+ grumphp.parser.php.visitor.no_exit_statements:
+ class: 'GrumPHP\Parser\Php\Visitor\NoExitStatementsVisitor'
+ arguments: []
+ tags:
+ - {name: 'php_parser.visitor', alias: 'no_exit_statements'}
diff --git a/resources/config/tasks.yml b/resources/config/tasks.yml
index c062fee3e..43beeafd7 100644
--- a/resources/config/tasks.yml
+++ b/resources/config/tasks.yml
@@ -213,6 +213,14 @@ services:
tags:
- {name: grumphp.task, config: phpmd}
+ task.phpparser:
+ class: GrumPHP\Task\PhpParser
+ arguments:
+ - '@config'
+ - '@grumphp.parser.php.parser'
+ tags:
+ - {name: grumphp.task, config: phpparser}
+
task.phpspec:
class: GrumPHP\Task\Phpspec
arguments:
diff --git a/spec/GrumPHP/Collection/ParseErrorsCollectionSpec.php b/spec/GrumPHP/Collection/ParseErrorsCollectionSpec.php
new file mode 100644
index 000000000..213d27638
--- /dev/null
+++ b/spec/GrumPHP/Collection/ParseErrorsCollectionSpec.php
@@ -0,0 +1,38 @@
+beConstructedWith([
+ new ParseError(ParseError::TYPE_ERROR, 'Found "count" function call', 'Ant.php', 58),
+ ]);
+ }
+
+ function it_is_initializable()
+ {
+ $this->shouldHaveType('GrumPHP\Collection\ParseErrorsCollection');
+ }
+
+ function it_is_an_array_collection()
+ {
+ $this->shouldHaveType('Doctrine\Common\Collections\ArrayCollection');
+ }
+
+ function it_should_be_parsed_as_string()
+ {
+ $this->__toString()->shouldBe('[ERROR] Ant.php: Found "count" function call on line 58');
+ }
+}
diff --git a/spec/GrumPHP/Parser/ParseErrorSpec.php b/spec/GrumPHP/Parser/ParseErrorSpec.php
new file mode 100644
index 000000000..ec60892ff
--- /dev/null
+++ b/spec/GrumPHP/Parser/ParseErrorSpec.php
@@ -0,0 +1,50 @@
+beConstructedWith(ParseError::TYPE_ERROR, 'Found "count" function call', 'Behat.php', 59);
+ }
+
+ function it_is_initializable()
+ {
+ $this->shouldHaveType('GrumPHP\Parser\ParseError');
+ }
+
+ function it_has_an_error_type()
+ {
+ $this->getType()->shouldBe(ParseError::TYPE_ERROR);
+ }
+
+ function it_has_an_error_message()
+ {
+ $this->getError()->shouldBe('Found "count" function call');
+ }
+
+ function it_has_a_file()
+ {
+ $this->getFile()->shouldBe('Behat.php');
+ }
+
+ function it_has_a_line_number()
+ {
+ $this->getLine()->shouldBe(59);
+ }
+
+ function it_can_be_parsed_as_string()
+ {
+ $this->__toString()->shouldBe('[ERROR] Behat.php: Found "count" function call on line 59');
+ }
+}
diff --git a/spec/GrumPHP/Parser/Php/Configurator/TraverserConfiguratorSpec.php b/spec/GrumPHP/Parser/Php/Configurator/TraverserConfiguratorSpec.php
new file mode 100644
index 000000000..8f48635a6
--- /dev/null
+++ b/spec/GrumPHP/Parser/Php/Configurator/TraverserConfiguratorSpec.php
@@ -0,0 +1,161 @@
+beConstructedWith($container);
+ }
+
+ function it_is_initializable()
+ {
+ $this->shouldHaveType('GrumPHP\Parser\Php\Configurator\TraverserConfigurator');
+ }
+
+
+ function it_throws_an_exception_if_a_context_is_not_set(NodeTraverserInterface $traverser)
+ {
+ $this->registerOptions(['visitors' => []]);
+ $this->shouldThrow('GrumPHP\Exception\RuntimeException')->duringConfigure($traverser);
+ }
+
+ function it_throws_an_exception_if_no_visitors_are_configured(ParserContext $context, NodeTraverserInterface $traverser)
+ {
+ $this->registerContext($context);
+ $this->shouldThrow('GrumPHP\Exception\RuntimeException')->duringConfigure($traverser);
+ }
+
+ function it_loads_standard_enabled_visitors(
+ ContainerInterface $container,
+ ParserContext $context,
+ NodeTraverserInterface $traverser,
+ NodeVisitor $visitor
+ ) {
+ $visitorAlias = 'standard_enabled_visitor';
+ $this->registerVisitorId($visitorAlias, $visitorAlias);
+ $this->registerStandardEnabledVisitor($visitorAlias, null);
+ $this->registerContext($context);
+ $this->registerOptions(['visitors' => []]);
+ $container->get($visitorAlias)->willReturn($visitor);
+
+ $traverser->addVisitor($visitor)->shouldBeCalled();
+
+ $this->configure($traverser);
+ }
+
+ function it_loads_configured_visitors_from_task_configuration(
+ ContainerInterface $container,
+ ParserContext $context,
+ NodeTraverserInterface $traverser,
+ NodeVisitor $visitor
+ ) {
+ $visitorAlias = 'task_visitor';
+ $this->registerVisitorId($visitorAlias, $visitorAlias);
+ $this->registerContext($context);
+ $this->registerOptions([
+ 'visitors' => [
+ $visitorAlias => null
+ ]
+ ]);
+ $container->get($visitorAlias)->willReturn($visitor);
+
+ $traverser->addVisitor($visitor)->shouldBeCalled();
+
+ $this->configure($traverser);
+ }
+
+ function it_throws_an_exception_if_the_configured_visitor_could_not_be_found(
+ ParserContext $context,
+ NodeTraverserInterface $traverser
+ ) {
+ $visitorAlias = 'unknown_visitor';
+ $this->registerContext($context);
+ $this->registerOptions([
+ 'visitors' => [
+ $visitorAlias => null
+ ]
+ ]);
+
+ $this->shouldThrow('GrumPHP\Exception\RuntimeException')->duringConfigure($traverser);
+ }
+
+ function it_does_not_load_unused_visitors(
+ ContainerInterface $container,
+ ParserContext $context,
+ NodeTraverserInterface $traverser,
+ NodeVisitor $visitor
+ ) {
+ $visitorAlias = 'unused_visitor';
+ $this->registerVisitorId($visitorAlias, $visitorAlias);
+ $this->registerContext($context);
+ $this->registerOptions(['visitors' => []]);
+ $container->get($visitorAlias)->willReturn($visitor);
+
+ $traverser->addVisitor($visitor)->shouldNotBeCalled();
+
+ $this->configure($traverser);
+ }
+
+ function it_should_append_the_context_to_a_context_aware_visitor(
+ ContainerInterface $container,
+ ParserContext $context,
+ NodeTraverserInterface $traverser,
+ ContextAwareVisitorInterface $visitor
+ ) {
+ $visitorAlias = 'context_aware_visitor';
+ $this->registerVisitorId($visitorAlias, $visitorAlias);
+ $this->registerContext($context);
+ $this->registerOptions([
+ 'visitors' => [
+ $visitorAlias => null
+ ]
+ ]);
+ $container->get($visitorAlias)->willReturn($visitor);
+
+ $visitor->setContext($context)->shouldBeCalled();
+ $traverser->addVisitor($visitor)->shouldBeCalled();
+
+ $this->configure($traverser);
+ }
+
+ function it_should_pass_visitor_configuration_to_a_configuration_aware_visitor(
+ ContainerInterface $container,
+ ParserContext $context,
+ NodeTraverserInterface $traverser,
+ ConfigurableVisitorInterface $visitor
+ ) {
+ $visitorAlias = 'configurable_visitor';
+ $configuration = ['key' => 'value'];
+ $this->registerVisitorId($visitorAlias, $visitorAlias);
+ $this->registerContext($context);
+ $this->registerOptions([
+ 'visitors' => [
+ $visitorAlias => $configuration
+ ]
+ ]);
+ $container->get($visitorAlias)->willReturn($visitor);
+
+ $visitor->configure($configuration)->shouldBeCalled();
+ $traverser->addVisitor($visitor)->shouldBeCalled();
+
+ $this->configure($traverser);
+ }
+}
diff --git a/spec/GrumPHP/Parser/Php/Context/ParserContextSpec.php b/spec/GrumPHP/Parser/Php/Context/ParserContextSpec.php
new file mode 100644
index 000000000..1aa286acd
--- /dev/null
+++ b/spec/GrumPHP/Parser/Php/Context/ParserContextSpec.php
@@ -0,0 +1,37 @@
+beConstructedWith($file, $errors);
+ }
+
+ function it_is_initializable()
+ {
+ $this->shouldHaveType('GrumPHP\Parser\Php\Context\ParserContext');
+ }
+
+ function it_contains_a_file(\SplFileInfo $file)
+ {
+ $this->getFile()->shouldBe($file);
+ }
+
+ function it_contains_parse_errors(ParseErrorsCollection $errors)
+ {
+ $this->getErrors()->shouldBe($errors);
+ }
+}
diff --git a/spec/GrumPHP/Parser/Php/Factory/ParserFactorySpec.php b/spec/GrumPHP/Parser/Php/Factory/ParserFactorySpec.php
new file mode 100644
index 000000000..8319bd16b
--- /dev/null
+++ b/spec/GrumPHP/Parser/Php/Factory/ParserFactorySpec.php
@@ -0,0 +1,29 @@
+shouldHaveType('GrumPHP\Parser\Php\Factory\ParserFactory');
+ }
+
+ function it_can_create_a_parser_from_task_options()
+ {
+
+ $options = ['kind' => PhpParser::KIND_PHP7];
+ $this->createFromOptions($options)->shouldBeAnInstanceOf('\PhpParser\Parser');
+ }
+}
diff --git a/spec/GrumPHP/Parser/Php/Factory/TraverserFactorySpec.php b/spec/GrumPHP/Parser/Php/Factory/TraverserFactorySpec.php
new file mode 100644
index 000000000..5dac94dc4
--- /dev/null
+++ b/spec/GrumPHP/Parser/Php/Factory/TraverserFactorySpec.php
@@ -0,0 +1,40 @@
+beConstructedWith($configurator);
+ }
+
+ function it_is_initializable()
+ {
+ $this->shouldHaveType('GrumPHP\Parser\Php\Factory\TraverserFactory');
+ }
+
+ function it_can_create_a_task_and_context_specific_traverser(TraverserConfigurator $configurator, ParserContext $context)
+ {
+
+ $taskOptions = ['visitors' => []];
+
+ $configurator->registerOptions($taskOptions)->shouldBeCalled();
+ $configurator->registerContext($context)->shouldBeCalled();
+ $configurator->configure(Argument::type('PhpParser\NodeTraverser'))->shouldBeCalled();
+
+ $this->createForTaskContext($taskOptions, $context)->shouldBeAnInstanceOf('PhpParser\NodeTraverser');
+ }
+}
diff --git a/spec/GrumPHP/Parser/Php/PhpParserErrorSpec.php b/spec/GrumPHP/Parser/Php/PhpParserErrorSpec.php
new file mode 100644
index 000000000..68ef5854d
--- /dev/null
+++ b/spec/GrumPHP/Parser/Php/PhpParserErrorSpec.php
@@ -0,0 +1,53 @@
+ 61]);
+ $this->beConstructedThrough('fromParseException', [$exception, 'JsonLint.php']);
+ }
+
+ function it_is_initializable()
+ {
+ $this->shouldHaveType('GrumPHP\Parser\Php\PhpParserError');
+ }
+
+ function it_has_an_error_type()
+ {
+ $this->getType()->shouldBe(ParseError::TYPE_FATAL);
+ }
+
+ function it_has_an_error_message()
+ {
+ $this->getError()->shouldBe('syntax error');
+ }
+
+ function it_has_a_file()
+ {
+ $this->getFile()->shouldBe('JsonLint.php');
+ }
+
+ function it_has_a_line_number()
+ {
+ $this->getLine()->shouldBe(61);
+ }
+
+ function it_can_be_parsed_as_string()
+ {
+ $this->__toString()->shouldBe('[FATAL] JsonLint.php: syntax error on line 61');
+ }
+}
diff --git a/spec/GrumPHP/Parser/Php/PhpParserSpec.php b/spec/GrumPHP/Parser/Php/PhpParserSpec.php
new file mode 100644
index 000000000..cf645af7a
--- /dev/null
+++ b/spec/GrumPHP/Parser/Php/PhpParserSpec.php
@@ -0,0 +1,89 @@
+beConstructedWith($parserFactory, $traverserFactory);
+ $this->tempFile = tempnam(sys_get_temp_dir(), 'phpparser');
+ $parserFactory->createFromOptions(Argument::any())->willReturn($parser);
+ $traverserFactory->createForTaskContext(Argument::cetera())->willReturn($traverser);
+ $parser->parse(Argument::any())->willReturn([]);
+ }
+
+ function letgo()
+ {
+ unlink($this->tempFile);
+ }
+
+ function it_is_initializable()
+ {
+ $this->shouldHaveType('GrumPHP\Parser\Php\PhpParser');
+ }
+
+ function it_uses_parser_options(
+ ParserFactory $parserFactory,
+ TraverserFactory $traverserFactory,
+ Parser $parser,
+ NodeTraverserInterface $traverser
+ ) {
+ $file = new \SplFileInfo($this->tempFile);
+ $this->setParserOptions($options = ['kind' => 'php7']);
+
+ $parserFactory->createFromOptions($options)->shouldBeCalled()->willReturn($parser);
+ $traverserFactory->createForTaskContext($options, Argument::that(function (ParserContext $context) use ($file) {
+ return $context->getFile() === $file
+ && $context->getErrors() instanceof ParseErrorsCollection;
+ }))->shouldBeCalled()->willReturn($traverser);
+
+ $this->parse($file);
+ }
+
+ function it_parses_a_file(NodeTraverserInterface $traverser)
+ {
+ $file = new \SplFileInfo($this->tempFile);
+ $traverser->traverse([])->shouldBeCalled();
+ $errors = $this->parse($file);
+
+ $errors->shouldBeAnInstanceOf('GrumPHP\Collection\ParseErrorsCollection');
+ $errors->count()->shouldBe(0);
+ }
+
+ function it_catches_parse_exceptions(Parser $parser)
+ {
+ $file = new \SplFileInfo($this->tempFile);
+ $parser->parse(Argument::any())->willThrow(new Error('Error ....'));
+ $errors = $this->parse($file);
+
+ $errors->shouldBeAnInstanceOf('GrumPHP\Collection\ParseErrorsCollection');
+ $errors->count()->shouldBe(1);
+ }
+}
diff --git a/spec/GrumPHP/Runner/TaskRunnerSpec.php b/spec/GrumPHP/Runner/TaskRunnerSpec.php
index 35ae5e0ef..791ef565d 100644
--- a/spec/GrumPHP/Runner/TaskRunnerSpec.php
+++ b/spec/GrumPHP/Runner/TaskRunnerSpec.php
@@ -99,6 +99,15 @@ function it_returns_a_failed_tasks_throws_an_exception(TaskInterface $task1, Tas
$this->run($context)->shouldContainFailedTaskResult();
}
+ function it_returns_non_blocking_faled_when_tasks_throws_a_platform_exception(TaskInterface $task1, TaskInterface $task2, ContextInterface $context)
+ {
+ $task1->run($context)->willThrow('GrumPHP\Exception\PlatformException');
+ $task2->run($context)->shouldBeCalled();
+
+ $this->run($context)->shouldReturnAnInstanceOf('GrumPHP\Collection\TaskResultCollection');
+ $this->run($context)->shouldNotBePassed();
+ $this->run($context)->shouldContainNonBlockingFailedTaskResult();
+ }
function it_returns_a_failed_tasks_result_if_a_non_blocking_task_fails(GrumPHP $grumPHP, TaskInterface $task1, TaskInterface $task2, ContextInterface $context)
{
diff --git a/spec/GrumPHP/Task/AbstractParserTaskSpec.php b/spec/GrumPHP/Task/AbstractParserTaskSpec.php
new file mode 100644
index 000000000..8c9cc01d1
--- /dev/null
+++ b/spec/GrumPHP/Task/AbstractParserTaskSpec.php
@@ -0,0 +1,26 @@
+shouldImplement('GrumPHP\Task\TaskInterface');
+ }
+
+ function it_should_handle_ignore_patterns()
+ {
+ $options = $this->getConfigurableOptions();
+ $options->getDefinedOptions()->shouldContain('ignore_patterns');
+ $options->getDefinedOptions()->shouldContain('triggered_by');
+ }
+}
diff --git a/spec/GrumPHP/Task/PhpParserSpec.php b/spec/GrumPHP/Task/PhpParserSpec.php
new file mode 100644
index 000000000..6eb872606
--- /dev/null
+++ b/spec/GrumPHP/Task/PhpParserSpec.php
@@ -0,0 +1,54 @@
+isInstalled()->willReturn(true);
+ $grumPHP->getTaskConfiguration('phpparser')->willReturn([]);
+ $this->beConstructedWith($grumPHP, $parser);
+ }
+
+ function it_is_initializable()
+ {
+ $this->shouldHaveType('GrumPHP\Task\PhpParser');
+ }
+
+ function it_should_have_a_name()
+ {
+ $this->getName()->shouldBe('phpparser');
+ }
+
+ function it_should_have_configurable_options()
+ {
+ $options = $this->getConfigurableOptions();
+ $options->shouldBeAnInstanceOf('Symfony\Component\OptionsResolver\OptionsResolver');
+ $options->getDefinedOptions()->shouldContain('kind');
+ $options->getDefinedOptions()->shouldContain('visitors');
+ }
+
+ function it_should_run_in_git_pre_commit_context(GitPreCommitContext $context)
+ {
+ $this->canRunInContext($context)->shouldReturn(true);
+ }
+
+ function it_should_run_in_run_context(RunContext $context)
+ {
+ $this->canRunInContext($context)->shouldReturn(true);
+ }
+}
diff --git a/src/GrumPHP/Collection/ParseErrorsCollection.php b/src/GrumPHP/Collection/ParseErrorsCollection.php
new file mode 100644
index 000000000..2a67dfdda
--- /dev/null
+++ b/src/GrumPHP/Collection/ParseErrorsCollection.php
@@ -0,0 +1,26 @@
+getIterator() as $error) {
+ $errors[] = $error->__toString();
+ }
+
+ return implode(PHP_EOL, $errors);
+ }
+}
diff --git a/src/GrumPHP/Configuration/Compiler/PhpParserCompilerPass.php b/src/GrumPHP/Configuration/Compiler/PhpParserCompilerPass.php
new file mode 100644
index 000000000..b6871bc96
--- /dev/null
+++ b/src/GrumPHP/Configuration/Compiler/PhpParserCompilerPass.php
@@ -0,0 +1,69 @@
+findDefinition('grumphp.parser.php.configurator.traverser');
+ foreach ($container->findTaggedServiceIds(self::TAG) as $id => $tags) {
+ $definition = $container->findDefinition($id);
+ $this->markServiceAsPrototype($definition);
+ foreach ($tags as $tag) {
+ $alias = array_key_exists('alias', $tag) ? $tag['alias'] : $id;
+ $traverserConfigurator->addMethodCall('registerVisitorId', [$alias, $id]);
+ }
+ }
+ }
+
+ /**
+ * This method can be used to make the service shared cross-version.
+ * From Symfony 2.8 the setShared method was available.
+ * The 2.7 version is the LTS, so we still need to support it.
+ *
+ * @link http://symfony.com/blog/new-in-symfony-3-1-customizable-yaml-parsing-and-dumping
+ *
+ * @param Definition $definition
+ *
+ * @throws \GrumPHP\Exception\RuntimeException
+ */
+ public function markServiceAsPrototype(Definition $definition)
+ {
+ if (method_exists($definition, 'setShared')) {
+ $definition->setShared(false);
+ return;
+ }
+
+ if (method_exists($definition, 'setScope')) {
+ $definition->setScope('prototype');
+ return;
+ }
+
+ throw new RuntimeException('The visitor could not be marked as unshared');
+ }
+}
diff --git a/src/GrumPHP/Configuration/ContainerFactory.php b/src/GrumPHP/Configuration/ContainerFactory.php
index b1d4fbe1b..fd1f63f34 100644
--- a/src/GrumPHP/Configuration/ContainerFactory.php
+++ b/src/GrumPHP/Configuration/ContainerFactory.php
@@ -29,6 +29,7 @@ public static function buildFromConfiguration($path)
// Add compiler passes:
$container->addCompilerPass(new Compiler\ExtensionCompilerPass());
+ $container->addCompilerPass(new Compiler\PhpParserCompilerPass());
$container->addCompilerPass(new Compiler\TaskCompilerPass());
$container->addCompilerPass(
new RegisterListenersPass('event_dispatcher', 'grumphp.event_listener', 'grumphp.event_subscriber')
@@ -39,6 +40,7 @@ public static function buildFromConfiguration($path)
$loader->load('formatter.yml');
$loader->load('linters.yml');
$loader->load('parameters.yml');
+ $loader->load('parsers.yml');
$loader->load('services.yml');
$loader->load('subscribers.yml');
$loader->load('tasks.yml');
diff --git a/src/GrumPHP/Exception/PlatformException.php b/src/GrumPHP/Exception/PlatformException.php
new file mode 100644
index 000000000..29351bb0e
--- /dev/null
+++ b/src/GrumPHP/Exception/PlatformException.php
@@ -0,0 +1,29 @@
+getCommandLine(), 0, 75)
+ ));
+ }
+}
diff --git a/src/GrumPHP/Formatter/GitBlacklistFormatter.php b/src/GrumPHP/Formatter/GitBlacklistFormatter.php
index 3656df525..8df88db75 100644
--- a/src/GrumPHP/Formatter/GitBlacklistFormatter.php
+++ b/src/GrumPHP/Formatter/GitBlacklistFormatter.php
@@ -57,11 +57,8 @@ private function formatOutput($output)
{
$result = static::RESET_COLOR;
foreach (array_filter(explode("\n", $output)) as $lineNumber => $line) {
- if (preg_match('/^[0-9]+/', $line)) {
- $result .= $this->trimOutputLine($line, (int)$lineNumber) . PHP_EOL;
- } else {
- $result .= $line . PHP_EOL;
- }
+ $result .= preg_match('/^[0-9]+/', $line) ? $this->trimOutputLine($line, (int)$lineNumber) : $line;
+ $result .= PHP_EOL;
}
return trim($result);
}
diff --git a/src/GrumPHP/Parser/ParseError.php b/src/GrumPHP/Parser/ParseError.php
new file mode 100644
index 000000000..a81c19048
--- /dev/null
+++ b/src/GrumPHP/Parser/ParseError.php
@@ -0,0 +1,107 @@
+type = $type;
+ $this->error = $error;
+ $this->file = $file;
+ $this->line = $line;
+ }
+
+ /**
+ * @return string
+ */
+ public function getType()
+ {
+ return $this->type;
+ }
+
+ /**
+ * @return string
+ */
+ public function getError()
+ {
+ return $this->error;
+ }
+
+ /**
+ * @return string
+ */
+ public function getFile()
+ {
+ return $this->file;
+ }
+
+ /**
+ * @return int
+ */
+ public function getLine()
+ {
+ return $this->line;
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ if ($this->getLine() < 0) {
+ return sprintf(
+ '[%s] %s: %s',
+ strtoupper($this->getType()),
+ $this->getFile(),
+ $this->getError()
+ );
+ }
+
+ return sprintf(
+ '[%s] %s: %s on line %d',
+ strtoupper($this->getType()),
+ $this->getFile(),
+ $this->getError(),
+ $this->getLine()
+ );
+ }
+}
diff --git a/src/GrumPHP/Parser/ParserInterface.php b/src/GrumPHP/Parser/ParserInterface.php
new file mode 100644
index 000000000..929f5cb2f
--- /dev/null
+++ b/src/GrumPHP/Parser/ParserInterface.php
@@ -0,0 +1,26 @@
+container = $container;
+ }
+
+ /**
+ * @param string $alias
+ * @param string $visitorId
+ *
+ * @throws \GrumPHP\Exception\RuntimeException
+ */
+ public function registerVisitorId($alias, $visitorId)
+ {
+ if (array_key_exists($alias, $this->registeredVisitorIds)) {
+ $registeredId = $this->registeredVisitorIds[$alias];
+ throw new RuntimeException(
+ sprintf('The visitor alias %s is already registered to visitor with id %s.', $alias, $registeredId)
+ );
+ }
+
+ $this->registeredVisitorIds[$alias] = (string) $visitorId;
+ }
+
+ /**
+ * @param $alias
+ * @param array|null $visitorOptions
+ *
+ * @throws \GrumPHP\Exception\RuntimeException
+ */
+ public function registerStandardEnabledVisitor($alias, array $visitorOptions = null)
+ {
+ if (array_key_exists($alias, $this->standardEnabledVisitors)) {
+ throw new RuntimeException(
+ sprintf('The visitor alias %s is already registered as a default enabled visitor.', $alias)
+ );
+ }
+
+ $this->standardEnabledVisitors[$alias] = $visitorOptions;
+ }
+
+ /**
+ * @param array $options
+ */
+ public function registerOptions(array $options)
+ {
+ $this->options = $options;
+ }
+
+ /**
+ * @param ParserContext $context
+ */
+ public function registerContext(ParserContext $context)
+ {
+ $this->context = $context;
+ }
+
+ /**
+ * @param NodeTraverserInterface $traverser
+ *
+ * @throws \GrumPHP\Exception\RuntimeException
+ */
+ public function configure(NodeTraverserInterface $traverser)
+ {
+ $this->guardTaskHasVisitors();
+ $this->guardContextIsRegistered();
+
+ $configuredVisitors = $this->loadEnabledVisitorsForCurrentOptions();
+ $configuredVisitorIds = array_keys($configuredVisitors);
+ $registeredVisitors = $this->registeredVisitorIds;
+ $registeredVisitorsIds = array_keys($registeredVisitors);
+
+ $visitorIds = array_values(array_intersect($registeredVisitorsIds, $configuredVisitorIds));
+ $unknownConfiguredVisitorIds = array_diff($configuredVisitorIds, $registeredVisitorsIds);
+
+ if (count($unknownConfiguredVisitorIds)) {
+ throw new RuntimeException(
+ sprintf('Found unknown php_parser visitors: %s', implode(',', $unknownConfiguredVisitorIds))
+ );
+ }
+
+ foreach ($visitorIds as $visitorAlias) {
+ $visitorId = $registeredVisitors[$visitorAlias];
+ $visitor = $this->container->get($visitorId);
+
+ if ($visitor instanceof ContextAwareVisitorInterface) {
+ $visitor->setContext($this->context);
+ }
+
+ $options = $configuredVisitors[$visitorAlias];
+ if ($visitor instanceof ConfigurableVisitorInterface && is_array($options)) {
+ $visitor->configure($options);
+ }
+
+ $traverser->addVisitor($visitor);
+ }
+
+ // Reset context to make sure the next configure call will actually run in the correct context:
+ $this->context = null;
+ }
+
+ /**
+ * @return array
+ */
+ private function loadEnabledVisitorsForCurrentOptions()
+ {
+ $visitors = $this->standardEnabledVisitors;
+ foreach ($this->options['visitors'] as $alias => $visitorOptions) {
+ $visitors[$alias] = $visitorOptions;
+ }
+
+ return $visitors;
+ }
+
+ /**
+ *
+ * @throws \GrumPHP\Exception\RuntimeException
+ */
+ private function guardTaskHasVisitors()
+ {
+ if (!isset($this->options['visitors'])) {
+ throw new RuntimeException('The parser context is not set. Please register it to the configurator!');
+ }
+ }
+
+ /**
+ * @throws \GrumPHP\Exception\RuntimeException
+ */
+ private function guardContextIsRegistered()
+ {
+ if (!$this->context instanceof ParserContext) {
+ throw new RuntimeException('The parser context is not set. Please register it to the configurator!');
+ }
+ }
+}
diff --git a/src/GrumPHP/Parser/Php/Context/ParserContext.php b/src/GrumPHP/Parser/Php/Context/ParserContext.php
new file mode 100644
index 000000000..cf097e389
--- /dev/null
+++ b/src/GrumPHP/Parser/Php/Context/ParserContext.php
@@ -0,0 +1,52 @@
+file = $file;
+ $this->errors = $errors;
+ }
+
+ /**
+ * @return SplFileInfo
+ */
+ public function getFile()
+ {
+ return $this->file;
+ }
+
+ /**
+ * @return ParseErrorsCollection
+ */
+ public function getErrors()
+ {
+ return $this->errors;
+ }
+}
diff --git a/src/GrumPHP/Parser/Php/Factory/ParserFactory.php b/src/GrumPHP/Parser/Php/Factory/ParserFactory.php
new file mode 100644
index 000000000..95faceb25
--- /dev/null
+++ b/src/GrumPHP/Parser/Php/Factory/ParserFactory.php
@@ -0,0 +1,27 @@
+create($kind);
+ }
+}
diff --git a/src/GrumPHP/Parser/Php/Factory/TraverserFactory.php b/src/GrumPHP/Parser/Php/Factory/TraverserFactory.php
new file mode 100644
index 000000000..c3ea15c22
--- /dev/null
+++ b/src/GrumPHP/Parser/Php/Factory/TraverserFactory.php
@@ -0,0 +1,48 @@
+configurator = $configurator;
+ }
+
+ /**
+ * @param array $parserOptions
+ * @param ParserContext $context
+ *
+ * @return NodeTraverser
+ * @throws \GrumPHP\Exception\RuntimeException
+ */
+ public function createForTaskContext(array $parserOptions, ParserContext $context)
+ {
+ $this->configurator->registerOptions($parserOptions);
+ $this->configurator->registerContext($context);
+
+ $traverser = new NodeTraverser();
+ $this->configurator->configure($traverser);
+
+ return $traverser;
+ }
+}
diff --git a/src/GrumPHP/Parser/Php/PhpParser.php b/src/GrumPHP/Parser/Php/PhpParser.php
new file mode 100644
index 000000000..fb0782e92
--- /dev/null
+++ b/src/GrumPHP/Parser/Php/PhpParser.php
@@ -0,0 +1,85 @@
+parserFactory = $parserFactory;
+ $this->traverserFactory = $traverserFactory;
+ }
+
+ /**
+ * @param array $options
+ */
+ public function setParserOptions(array $options)
+ {
+ $this->parserOptions = $options;
+ }
+
+ /**
+ * @param SplFileInfo $file
+ *
+ * @return ParseErrorsCollection
+ */
+ public function parse(SplFileInfo $file)
+ {
+ $errors = new ParseErrorsCollection();
+ $context = new ParserContext($file, $errors);
+ $parser = $this->parserFactory->createFromOptions($this->parserOptions);
+ $traverser = $this->traverserFactory->createForTaskContext($this->parserOptions, $context);
+
+ try {
+ $code = file_get_contents($file->getRealPath());
+ $stmts = $parser->parse($code);
+ $traverser->traverse($stmts);
+ } catch (Error $e) {
+ $errors->add(PhpParserError::fromParseException($e, $file->getRealPath()));
+ }
+
+ return $errors;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isInstalled()
+ {
+ return interface_exists('PhpParser\Parser');
+ }
+}
diff --git a/src/GrumPHP/Parser/Php/PhpParserError.php b/src/GrumPHP/Parser/Php/PhpParserError.php
new file mode 100644
index 000000000..b2b6eb3ff
--- /dev/null
+++ b/src/GrumPHP/Parser/Php/PhpParserError.php
@@ -0,0 +1,30 @@
+getRawMessage(),
+ $filename,
+ $exception->getStartLine()
+ );
+ }
+}
diff --git a/src/GrumPHP/Parser/Php/Visitor/AbstractVisitor.php b/src/GrumPHP/Parser/Php/Visitor/AbstractVisitor.php
new file mode 100644
index 000000000..96c94ff27
--- /dev/null
+++ b/src/GrumPHP/Parser/Php/Visitor/AbstractVisitor.php
@@ -0,0 +1,41 @@
+context = $context;
+ }
+
+ /**
+ * @param string $message
+ * @param int $line
+ * @param string $type
+ */
+ protected function addError($message, $line = -1, $type = ParseError::TYPE_ERROR)
+ {
+ $errors = $this->context->getErrors();
+ $fileName = $this->context->getFile()->getRealPath();
+ $errors->add(new PhpParserError($type, $message, $fileName, $line));
+ }
+}
diff --git a/src/GrumPHP/Parser/Php/Visitor/ConfigurableVisitorInterface.php b/src/GrumPHP/Parser/Php/Visitor/ConfigurableVisitorInterface.php
new file mode 100644
index 000000000..bbc70eba4
--- /dev/null
+++ b/src/GrumPHP/Parser/Php/Visitor/ConfigurableVisitorInterface.php
@@ -0,0 +1,18 @@
+declares as $id => $declare) {
+ if ($declare->key !== 'strict_types') {
+ continue;
+ }
+
+ $this->hasStrictType = $declare->value->value === 1;
+ }
+ }
+
+ /**
+ * @param array $nodes
+ *
+ * @return void
+ */
+ public function afterTraverse(array $nodes)
+ {
+ if (!$this->hasStrictType) {
+ $this->addError('No "declare(strict_types = 1)" found in file!');
+ }
+ }
+}
diff --git a/src/GrumPHP/Parser/Php/Visitor/ForbiddenClassMethodCallsVisitor.php b/src/GrumPHP/Parser/Php/Visitor/ForbiddenClassMethodCallsVisitor.php
new file mode 100644
index 000000000..25bf62ebe
--- /dev/null
+++ b/src/GrumPHP/Parser/Php/Visitor/ForbiddenClassMethodCallsVisitor.php
@@ -0,0 +1,62 @@
+setDefaults([
+ 'blacklist' => [],
+ ]);
+
+ $resolver->setAllowedTypes('blacklist', ['array']);
+
+ $config = $resolver->resolve($options);
+ $this->blacklist = $config['blacklist'];
+ }
+
+ /**
+ * @param Node $node
+ *
+ * @return void
+ */
+ public function leaveNode(Node $node)
+ {
+ if (!$node instanceof Node\Expr\MethodCall || !isset($node->var->name)) {
+ return;
+ }
+
+ $variable = $node->var->name;
+ $method = $node->name;
+ $normalized = sprintf('$%s->%s', $variable, $method);
+ if (!in_array($normalized, $this->blacklist)) {
+ return;
+ }
+
+ $this->addError(
+ sprintf('Found blacklisted "%s" method call', $normalized),
+ $node->getline(),
+ ParseError::TYPE_ERROR
+ );
+ }
+}
diff --git a/src/GrumPHP/Parser/Php/Visitor/ForbiddenFunctionCallsVisitor.php b/src/GrumPHP/Parser/Php/Visitor/ForbiddenFunctionCallsVisitor.php
new file mode 100644
index 000000000..e6e1aeb31
--- /dev/null
+++ b/src/GrumPHP/Parser/Php/Visitor/ForbiddenFunctionCallsVisitor.php
@@ -0,0 +1,60 @@
+setDefaults([
+ 'blacklist' => [],
+ ]);
+
+ $resolver->setAllowedTypes('blacklist', ['array']);
+
+ $config = $resolver->resolve($options);
+ $this->blacklist = $config['blacklist'];
+ }
+
+ /**
+ * @param Node $node
+ *
+ * @return void
+ */
+ public function leaveNode(Node $node)
+ {
+ if (!$node instanceof Node\Expr\FuncCall) {
+ return;
+ }
+
+ $function = $node->name;
+ if (!in_array($function, $this->blacklist)) {
+ return;
+ }
+
+ $this->addError(
+ sprintf('Found blacklisted "%s" function call', $function),
+ $node->getLine(),
+ ParseError::TYPE_ERROR
+ );
+ }
+}
diff --git a/src/GrumPHP/Parser/Php/Visitor/ForbiddenStaticMethodCallsVisitor.php b/src/GrumPHP/Parser/Php/Visitor/ForbiddenStaticMethodCallsVisitor.php
new file mode 100644
index 000000000..df64f76b5
--- /dev/null
+++ b/src/GrumPHP/Parser/Php/Visitor/ForbiddenStaticMethodCallsVisitor.php
@@ -0,0 +1,63 @@
+setDefaults([
+ 'blacklist' => [],
+ ]);
+
+ $resolver->setAllowedTypes('blacklist', ['array']);
+
+ $config = $resolver->resolve($options);
+ $this->blacklist = $config['blacklist'];
+ }
+
+ /**
+ * @param Node $node
+ *
+ * @return void
+ */
+ public function leaveNode(Node $node)
+ {
+ if (!$node instanceof Node\Expr\StaticCall) {
+ return;
+ }
+
+ $class = implode('\\', $node->class->parts);
+ $method = $node->name;
+ $normalized = sprintf('%s::%s', $class, $method);
+
+ if (!in_array($normalized, $this->blacklist)) {
+ return;
+ }
+
+ $this->addError(
+ sprintf('Found blacklisted "%s" static method call', $normalized),
+ $node->getline(),
+ ParseError::TYPE_ERROR
+ );
+ }
+}
diff --git a/src/GrumPHP/Parser/Php/Visitor/NeverUseElseVisitor.php b/src/GrumPHP/Parser/Php/Visitor/NeverUseElseVisitor.php
new file mode 100644
index 000000000..f4f6dad55
--- /dev/null
+++ b/src/GrumPHP/Parser/Php/Visitor/NeverUseElseVisitor.php
@@ -0,0 +1,38 @@
+addError(
+ sprintf(
+ 'Object Calisthenics error: Do not use the "%s" keyword!',
+ $node instanceof Node\Stmt\ElseIf_ ? 'elseif' : 'else'
+ ),
+ $node->getLine(),
+ ParseError::TYPE_ERROR
+ );
+ }
+}
diff --git a/src/GrumPHP/Parser/Php/Visitor/NoExitStatementsVisitor.php b/src/GrumPHP/Parser/Php/Visitor/NoExitStatementsVisitor.php
new file mode 100644
index 000000000..9a76815cf
--- /dev/null
+++ b/src/GrumPHP/Parser/Php/Visitor/NoExitStatementsVisitor.php
@@ -0,0 +1,32 @@
+addError(
+ sprintf('Found a forbidden exit statement.'),
+ $node->getLine(),
+ ParseError::TYPE_ERROR
+ );
+ }
+}
diff --git a/src/GrumPHP/Process/ProcessBuilder.php b/src/GrumPHP/Process/ProcessBuilder.php
index 2c961bca7..9c25514ca 100644
--- a/src/GrumPHP/Process/ProcessBuilder.php
+++ b/src/GrumPHP/Process/ProcessBuilder.php
@@ -4,8 +4,10 @@
use GrumPHP\Collection\ProcessArgumentsCollection;
use GrumPHP\Configuration\GrumPHP;
+use GrumPHP\Exception\PlatformException;
use GrumPHP\IO\IOInterface;
use GrumPHP\Locator\ExternalCommand;
+use GrumPHP\Util\Platform;
use Symfony\Component\Process\Process;
use \Symfony\Component\Process\ProcessBuilder as SymfonyProcessBuilder;
@@ -61,16 +63,42 @@ public function createArgumentsForCommand($command)
* @param ProcessArgumentsCollection $arguments
*
* @return Process
+ * @throws \GrumPHP\Exception\PlatformException
*/
public function buildProcess(ProcessArgumentsCollection $arguments)
{
$builder = SymfonyProcessBuilder::create($arguments->getValues());
$builder->setTimeout($this->config->getProcessTimeout());
$process = $builder->getProcess();
+
$this->logProcessInVerboseMode($process);
+ $this->guardWindowsCmdMaxInputStringLimitation($process);
+
return $process;
}
+ /**
+ * @param Process $process
+ *
+ * @throws \GrumPHP\Exception\PlatformException
+ */
+ private function guardWindowsCmdMaxInputStringLimitation(Process $process)
+ {
+ if (!Platform::isWindows()) {
+ return;
+ }
+
+ if (strlen($process->getCommandLine()) <= Platform::WINDOWS_COMMANDLINE_STRING_LIMITATION) {
+ return;
+ }
+
+ $this->io->write('', true);
+ $this->io->write('Oh no, we hit the windows cmd input limit!', true);
+ $this->io->write('Skipping task ...');
+
+ throw PlatformException::commandLineStringLimit($process);
+ }
+
/**
* @param string $command
*
diff --git a/src/GrumPHP/Runner/TaskRunner.php b/src/GrumPHP/Runner/TaskRunner.php
index 740da7508..4f9516172 100644
--- a/src/GrumPHP/Runner/TaskRunner.php
+++ b/src/GrumPHP/Runner/TaskRunner.php
@@ -11,6 +11,7 @@
use GrumPHP\Event\TaskEvent;
use GrumPHP\Event\TaskEvents;
use GrumPHP\Event\TaskFailedEvent;
+use GrumPHP\Exception\PlatformException;
use GrumPHP\Exception\RuntimeException;
use GrumPHP\Task\Context\ContextInterface;
use GrumPHP\Task\TaskInterface;
@@ -124,6 +125,8 @@ private function runTask(TaskInterface $task, ContextInterface $context)
try {
$this->eventDispatcher->dispatch(TaskEvents::TASK_RUN, new TaskEvent($task, $context));
$result = $task->run($context);
+ } catch (PlatformException $e) {
+ $result = TaskResult::createNonBlockingFailed($task, $context, $e->getMessage());
} catch (RuntimeException $e) {
$result = TaskResult::createFailed($task, $context, $e->getMessage());
}
diff --git a/src/GrumPHP/Task/AbstractParserTask.php b/src/GrumPHP/Task/AbstractParserTask.php
new file mode 100644
index 000000000..db4a65461
--- /dev/null
+++ b/src/GrumPHP/Task/AbstractParserTask.php
@@ -0,0 +1,95 @@
+grumPHP = $grumPHP;
+ $this->parser = $parser;
+
+ if (!$parser->isInstalled()) {
+ throw new RuntimeException(
+ sprintf('The %s can\'t run on your system. Please install all dependencies.', $this->getName())
+ );
+ }
+ }
+
+ /**
+ * @return OptionsResolver
+ */
+ public function getConfigurableOptions()
+ {
+ $resolver = new OptionsResolver();
+ $resolver->setDefaults([
+ 'triggered_by' => [],
+ 'ignore_patterns' => [],
+ ]);
+
+ $resolver->addAllowedTypes('triggered_by', ['array']);
+ $resolver->addAllowedTypes('ignore_patterns', ['array']);
+
+ return $resolver;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getConfiguration()
+ {
+ $configured = $this->grumPHP->getTaskConfiguration($this->getName());
+
+ return $this->getConfigurableOptions()->resolve($configured);
+ }
+
+ /**
+ * @param FilesCollection $files
+ *
+ * @return ParseErrorsCollection
+ */
+ protected function parse(FilesCollection $files)
+ {
+ // Skip ignored patterns:
+ $configuration = $this->getConfiguration();
+ foreach ($configuration['ignore_patterns'] as $pattern) {
+ $files = $files->notPath($pattern);
+ }
+
+ // Parse every file:
+ $parseErrors = new ParseErrorsCollection();
+ foreach ($files as $file) {
+ foreach ($this->parser->parse($file) as $error) {
+ $parseErrors->add($error);
+ }
+ }
+
+ return $parseErrors;
+ }
+}
diff --git a/src/GrumPHP/Task/PhpParser.php b/src/GrumPHP/Task/PhpParser.php
new file mode 100644
index 000000000..e3afea5bf
--- /dev/null
+++ b/src/GrumPHP/Task/PhpParser.php
@@ -0,0 +1,83 @@
+setDefaults([
+ 'triggered_by' => ['php'],
+ 'kind' => self::KIND_PHP7,
+ 'visitors' => [],
+ ]);
+
+ return $resolver;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function canRunInContext(ContextInterface $context)
+ {
+ return ($context instanceof GitPreCommitContext || $context instanceof RunContext);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function run(ContextInterface $context)
+ {
+ $config = $this->getConfiguration();
+
+ $files = $context->getFiles(false)->extensions($config['triggered_by']);
+ if (0 === count($files)) {
+ return TaskResult::createSkipped($this, $context);
+ }
+
+ $this->parser->setParserOptions($config);
+
+ $parseErrors = $this->parse($files);
+
+ if ($parseErrors->count()) {
+ return TaskResult::createFailed(
+ $this,
+ $context,
+ sprintf(
+ "Some errors occured while parsing your PHP files:\n%s",
+ $parseErrors->__toString()
+ )
+ );
+ }
+
+ return TaskResult::createPassed($this, $context);
+ }
+}
diff --git a/src/GrumPHP/Util/Platform.php b/src/GrumPHP/Util/Platform.php
new file mode 100644
index 000000000..9867d0d63
--- /dev/null
+++ b/src/GrumPHP/Util/Platform.php
@@ -0,0 +1,29 @@
+getVisitor());
+ }
+
+ /**
+ * @test
+ */
+ function it_is_a_context_aware_visitor()
+ {
+ self::assertInstanceOf('GrumPHP\Parser\Php\Visitor\ContextAwareVisitorInterface', $this->getVisitor());
+ }
+
+ /**
+ * @return ContextAwareVisitorInterface
+ */
+ abstract protected function getVisitor();
+
+ /**
+ * @return ParserContext
+ */
+ protected function createContext()
+ {
+ $file = new \SplFileInfo('code.php');
+ $errors = new ParseErrorsCollection();
+
+ return new ParserContext($file, $errors);
+ }
+
+ /**
+ * @param $code
+ *
+ * @return ParseErrorsCollection
+ */
+ protected function visit($code)
+ {
+ $context = $this->createContext();
+ $visitor = $this->getVisitor();
+ $visitor->setContext($context);
+
+ $parser = (new ParserFactory())->create(ParserFactory::PREFER_PHP7);
+ $traverser = new NodeTraverser();
+ $traverser->addVisitor(new NameResolver());
+ $traverser->addVisitor($visitor);
+
+ $stmts = $parser->parse($code);
+ $traverser->traverse($stmts);
+
+ return $context->getErrors();
+ }
+}
diff --git a/test/src/GrumPHPTest/Parser/Php/Visitor/DeclareStrictTypesVisitorTest.php b/test/src/GrumPHPTest/Parser/Php/Visitor/DeclareStrictTypesVisitorTest.php
new file mode 100644
index 000000000..52cdb64e3
--- /dev/null
+++ b/test/src/GrumPHPTest/Parser/Php/Visitor/DeclareStrictTypesVisitorTest.php
@@ -0,0 +1,77 @@
+visit($code);
+ $this->assertCount(1, $errors);
+ $this->assertEquals(ParseError::TYPE_ERROR, $errors[0]->getType());
+ $this->assertEquals(-1, $errors[0]->getLine());
+ }
+
+ /**
+ * @test
+ */
+ function it_doesnt_allow_strict_types_with_value_0()
+ {
+ $code = <<visit($code);
+ $this->assertCount(1, $errors);
+ }
+
+ /**
+ * @test
+ */
+ function it_allows_code_with_strict_types_set()
+ {
+ $code = <<visit($code);
+ $this->assertCount(0, $errors);
+ }
+}
diff --git a/test/src/GrumPHPTest/Parser/Php/Visitor/ForbiddenClassMethodCallsVisitorTest.php b/test/src/GrumPHPTest/Parser/Php/Visitor/ForbiddenClassMethodCallsVisitorTest.php
new file mode 100644
index 000000000..29af498b3
--- /dev/null
+++ b/test/src/GrumPHPTest/Parser/Php/Visitor/ForbiddenClassMethodCallsVisitorTest.php
@@ -0,0 +1,68 @@
+configure(array(
+ 'blacklist' => array('$dumper->dump'),
+ ));
+
+ return $visitor;
+ }
+
+ /**
+ * @test
+ */
+ function it_is_a_configurable_visitor()
+ {
+ $this->assertInstanceOf('GrumPHP\Parser\Php\Visitor\ConfigurableVisitorInterface', $this->getVisitor());
+ }
+
+ /**
+ * @test
+ */
+ function it_does_not_allow_blacklisted_class_method_calls()
+ {
+ $code = <<dump('something');
+\$this->dumper->dump('something');
+EOC;
+
+ $errors = $this->visit($code);
+ $this->assertCount(2, $errors);
+ $this->assertEquals(ParseError::TYPE_ERROR, $errors[0]->getType());
+ $this->assertEquals(3, $errors[0]->getLine());
+ $this->assertEquals(4, $errors[1]->getLine());
+ }
+
+ /**
+ * @test
+ */
+ function it_allows_code_that_does_not_use_invalid_functions()
+ {
+ $code = <<validMethod();
+EOC;
+
+ $errors = $this->visit($code);
+ $this->assertCount(0, $errors);
+ }
+}
diff --git a/test/src/GrumPHPTest/Parser/Php/Visitor/ForbiddenFunctionCallsVisitorTest.php b/test/src/GrumPHPTest/Parser/Php/Visitor/ForbiddenFunctionCallsVisitorTest.php
new file mode 100644
index 000000000..f25897acb
--- /dev/null
+++ b/test/src/GrumPHPTest/Parser/Php/Visitor/ForbiddenFunctionCallsVisitorTest.php
@@ -0,0 +1,65 @@
+configure(array(
+ 'blacklist' => array('var_dump'),
+ ));
+
+ return $visitor;
+ }
+
+ /**
+ * @test
+ */
+ function it_is_a_configurable_visitor()
+ {
+ $this->assertInstanceOf('GrumPHP\Parser\Php\Visitor\ConfigurableVisitorInterface', $this->getVisitor());
+ }
+
+ /**
+ * @test
+ */
+ function it_does_not_allow_blacklisted_functions()
+ {
+ $code = <<visit($code);
+ $this->assertCount(1, $errors);
+ $this->assertEquals(ParseError::TYPE_ERROR, $errors[0]->getType());
+ $this->assertEquals(2, $errors[0]->getLine());
+ }
+
+ /**
+ * @test
+ */
+ function it_allows_code_that_does_not_use_invalid_functions()
+ {
+ $code = <<visit($code);
+ $this->assertCount(0, $errors);
+ }
+}
diff --git a/test/src/GrumPHPTest/Parser/Php/Visitor/ForbiddenStaticMethodCallsVisitorTest.php b/test/src/GrumPHPTest/Parser/Php/Visitor/ForbiddenStaticMethodCallsVisitorTest.php
new file mode 100644
index 000000000..924c5616c
--- /dev/null
+++ b/test/src/GrumPHPTest/Parser/Php/Visitor/ForbiddenStaticMethodCallsVisitorTest.php
@@ -0,0 +1,74 @@
+configure(array(
+ 'blacklist' => array('Dumper\StaticDumper::dump', 'My\Dumper::dump', 'Dumper\Alias::dump'),
+ ));
+
+ return $visitor;
+ }
+
+ /**
+ * @test
+ */
+ function it_is_a_configurable_visitor()
+ {
+ $this->assertInstanceOf('GrumPHP\Parser\Php\Visitor\ConfigurableVisitorInterface', $this->getVisitor());
+ }
+
+ /**
+ * @test
+ */
+ function it_does_not_allow_blacklisted_static_method_calls()
+ {
+ $code = <<visit($code);
+ $this->assertCount(3, $errors);
+ $this->assertEquals(ParseError::TYPE_ERROR, $errors[0]->getType());
+ $this->assertEquals(5, $errors[0]->getLine());
+ $this->assertEquals(6, $errors[1]->getLine());
+ $this->assertEquals(7, $errors[2]->getLine());
+ }
+
+ /**
+ * @test
+ */
+ function it_allows_code_that_does_not_use_invalid_functions()
+ {
+ $code = <<visit($code);
+ $this->assertCount(0, $errors);
+ }
+}
diff --git a/test/src/GrumPHPTest/Parser/Php/Visitor/NeverUseElseVisitorTest.php b/test/src/GrumPHPTest/Parser/Php/Visitor/NeverUseElseVisitorTest.php
new file mode 100644
index 000000000..696964371
--- /dev/null
+++ b/test/src/GrumPHPTest/Parser/Php/Visitor/NeverUseElseVisitorTest.php
@@ -0,0 +1,64 @@
+visit($code);
+ $this->assertCount(2, $errors);
+ $this->assertEquals(ParseError::TYPE_ERROR, $errors[0]->getType());
+ $this->assertEquals(4, $errors[0]->getLine());
+ $this->assertEquals(6, $errors[1]->getLine());
+ }
+
+ /**
+ * @test
+ */
+ function it_allows_code_with_no_else_statements()
+ {
+ $code = <<visit($code);
+ $this->assertCount(0, $errors);
+ }
+}
diff --git a/test/src/GrumPHPTest/Parser/Php/Visitor/NoExitStatementsVisitorTest.php b/test/src/GrumPHPTest/Parser/Php/Visitor/NoExitStatementsVisitorTest.php
new file mode 100644
index 000000000..50966f332
--- /dev/null
+++ b/test/src/GrumPHPTest/Parser/Php/Visitor/NoExitStatementsVisitorTest.php
@@ -0,0 +1,54 @@
+visit($code);
+ $this->assertCount(2, $errors);
+ $this->assertEquals(ParseError::TYPE_ERROR, $errors[0]->getType());
+ $this->assertEquals(2, $errors[0]->getLine());
+ $this->assertEquals(3, $errors[1]->getLine());
+ }
+
+ /**
+ * @test
+ */
+ function it_allows_code_with_no_exit_statements()
+ {
+ $code = <<visit($code);
+ $this->assertCount(0, $errors);
+ }
+}