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