diff --git a/doc/tasks.md b/doc/tasks.md index 1afb7be87..2f3b18ea9 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -20,6 +20,7 @@ grumphp: deptrac: ~ doctrine_orm: ~ ecs: ~ + eslint: ~ file_size: ~ gherkin: ~ git_blacklist: ~ @@ -74,6 +75,7 @@ Every task has it's own default configuration. It is possible to overwrite the p - [Composer Script](tasks/composer_script.md) - [Doctrine ORM](tasks/doctrine_orm.md) - [Ecs EasyCodingStandard](tasks/ecs.md) +- [ESLint](tasks/eslint.md) - [File size](tasks/file_size.md) - [Deptrac](tasks/deptrac.md) - [Gherkin](tasks/gherkin.md) diff --git a/doc/tasks/eslint.md b/doc/tasks/eslint.md new file mode 100644 index 000000000..edcd8e8bd --- /dev/null +++ b/doc/tasks/eslint.md @@ -0,0 +1,107 @@ +# ESLint + +[ESLint](https://eslint.org/) is a static analysis tool for Javascript code. ESLint covers both code quality and coding style issues. + +## NPM +If you'd like to install it globally: +```bash +npm -g eslint +``` + +If you'd like install it as a dev dependency of your project: +```bash +npm install eslint --save-dev +``` + +To generate a .eslintrc.* config file: +``` +npx eslint --init +``` + +Done. See the ESLint [Getting Started](https://eslint.org/docs/user-guide/getting-started) guide for more info. + +## Config +It lives under the `eslint` namespace and has the following configurable parameters: + +```yaml +# grumphp.yml +grumphp: + tasks: + eslint: + bin: node_modules/.bin/eslint + triggered_by: [js, jsx, ts, tsx, vue] + whitelist_patterns: + - /^resources\/js\/(.*)/ + config: .eslintrc.json + debug: false + format: ~ + max_warnings: ~ + no_eslintrc: false + quiet: false +``` + +**bin** + +*Default: null* + +The path to your eslint bin executable. Not necessary if eslint is in your $PATH. Can be used to specify path to project's eslint over globally installed eslint. + + +**triggered_by** + +*Default: [js, jsx, ts, tsx, vue]* + +This is a list of extensions which will trigger the ESLint task. + + +**whitelist_patterns** + +*Default: []* + +This is a list of regex patterns that will filter files to validate. With this option you can specify the folders containing javascript files and thus skip folders like /tests/ or the /vendor/ directory. This option is used in conjunction with the parameter `triggered_by`. +For example: to whitelist files in `resources/js/` (Laravel's JS directory) and `assets/js/` (Symfony's JS directory) you can use: +```yml +whitelist_patterns: + - /^resources\/js\/(.*)/ + - /^assets\/js\/(.*)/ +``` + +**config** + +*Default: null* + +The path to your eslint's configuration file. Not necessary if using a standard eslintrc name, eg. .eslintrc.json, .eslint.js, or .eslint.yml + +**debug** + +*Default: false* + +Turn on debug mode ([eslint.org](https://eslint.org/docs/user-guide/command-line-interface#debug)). + +**format** + +*Default: null* + +Output format, eslint will use `stylish` by default. Other handy ones on cli are `compact`, `codeframe` and `table` (see full list on [eslint.org](https://eslint.org/docs/user-guide/formatters/)). + +**max_warnings** + +*Default: null* + +Number of warnings (not errors) that are allowed before eslint exits with error status ([eslint.org](https://eslint.org/docs/user-guide/command-line-interface#max-warnings)). + +**no_eslintrc** + +*Default: false* + +Set to true to ignore local .eslint config file ([eslint.org](https://eslint.org/docs/user-guide/command-line-interface#max-warnings)). + +**quiet** + +*Default: null* + +Report errors only (no warnings). [eslint.org](https://eslint.org/docs/user-guide/command-line-interface#quiet) + +**other settings** + +Any other eslint settings (such as rules, env, ignore patterns, etc) should be able to be set through an [eslint config file](https://eslint.org/docs/user-guide/configuring) (instructions to generate a config file at top of document). diff --git a/resources/config/formatter.yml b/resources/config/formatter.yml index 8e534b0d9..d416ca0a4 100644 --- a/resources/config/formatter.yml +++ b/resources/config/formatter.yml @@ -9,3 +9,5 @@ services: class: GrumPHP\Formatter\GitBlacklistFormatter arguments: - '@grumphp.io' + formatter.eslint: + class: GrumPHP\Formatter\ESLintFormatter diff --git a/resources/config/tasks.yml b/resources/config/tasks.yml index 5cc2228ed..f2b96c5e2 100644 --- a/resources/config/tasks.yml +++ b/resources/config/tasks.yml @@ -115,6 +115,13 @@ services: tags: - {name: grumphp.task, task: git_branch_name} + GrumPHP\Task\ESLint: + arguments: + - '@process_builder' + - '@formatter.eslint' + tags: + - {name: grumphp.task, task: eslint} + GrumPHP\Task\FileSize: tags: - {name: grumphp.task, task: file_size} diff --git a/spec/Formatter/ESLintFormatterSpec.php b/spec/Formatter/ESLintFormatterSpec.php new file mode 100644 index 000000000..64226b495 --- /dev/null +++ b/spec/Formatter/ESLintFormatterSpec.php @@ -0,0 +1,39 @@ +shouldHaveType(ESLintFormatter::class); + } + + function it_is_a_process_formatter() + { + $this->shouldHaveType(ProcessFormatterInterface::class); + } + + function it_handles_command_exceptions(Process $process) + { + $process->getOutput()->willReturn(''); + $process->getErrorOutput()->willReturn('stderr'); + $this->format($process)->shouldReturn('stderr'); + } + + function it_formats_the_error_message() + { + $this->formatErrorMessage('message1', 'message2') + ->shouldBe(sprintf( + '%sYou can fix all errors by running the following commands:%s', + 'message1' . PHP_EOL . PHP_EOL, + PHP_EOL . 'message2' + )); + } +} diff --git a/src/Formatter/ESLintFormatter.php b/src/Formatter/ESLintFormatter.php new file mode 100644 index 000000000..84037a6f3 --- /dev/null +++ b/src/Formatter/ESLintFormatter.php @@ -0,0 +1,27 @@ +getOutput(); + $stderr = $process->getErrorOutput(); + + return trim($stdout . PHP_EOL . $stderr); + } + + public function formatErrorMessage(string $message, string $suggestion): string + { + return sprintf( + '%sYou can fix all errors by running the following commands:%s', + $message . PHP_EOL . PHP_EOL, + PHP_EOL . $suggestion + ); + } +} diff --git a/src/Task/ESLint.php b/src/Task/ESLint.php new file mode 100644 index 000000000..1ab6491eb --- /dev/null +++ b/src/Task/ESLint.php @@ -0,0 +1,109 @@ +setDefaults([ + // Task config options + 'bin' => null, + 'triggered_by' => ['js', 'jsx', 'ts', 'tsx', 'vue'], + 'whitelist_patterns' => null, + + // ESLint native config options + 'config' => null, + 'debug' => false, + 'format' => null, + 'max_warnings' => null, + 'no_eslintrc' => false, + 'quiet' => false, + ]); + + // Task config options + $resolver->addAllowedTypes('bin', ['null', 'string']); + $resolver->addAllowedTypes('whitelist_patterns', ['null', 'array']); + $resolver->addAllowedTypes('triggered_by', ['array']); + + // ESLint native config options + $resolver->addAllowedTypes('config', ['null', 'string']); + $resolver->addAllowedTypes('debug', ['bool']); + $resolver->addAllowedTypes('format', ['null', 'string']); + $resolver->addAllowedTypes('max_warnings', ['null', 'integer']); + $resolver->addAllowedTypes('no_eslintrc', ['bool']); + $resolver->addAllowedTypes('quiet', ['bool']); + + return $resolver; + } + + public function canRunInContext(ContextInterface $context): bool + { + return ($context instanceof GitPreCommitContext || $context instanceof RunContext); + } + + public function run(ContextInterface $context): TaskResultInterface + { + $config = $this->getConfig()->getOptions(); + + $files = $context + ->getFiles() + ->paths($config['whitelist_patterns'] ?? []) + ->extensions($config['triggered_by']); + + if (0 === \count($files)) { + return TaskResult::createSkipped($this, $context); + } + + $arguments = isset($config['bin']) + ? ProcessArgumentsCollection::forExecutable($config['bin']) + : $this->processBuilder->createArgumentsForCommand('eslint'); + + $arguments->addOptionalArgument('--config=%s', $config['config']); + $arguments->addOptionalArgument('--debug', $config['debug']); + $arguments->addOptionalArgument('--format=%s', $config['format']); + $arguments->addOptionalArgument('--no-eslintrc', $config['no_eslintrc']); + $arguments->addOptionalArgument('--quiet', $config['quiet']); + $arguments->addOptionalIntegerArgument('--max-warnings=%d', $config['max_warnings']); + $arguments->addFiles($files); + + $process = $this->processBuilder->buildProcess($arguments); + $process->run(); + + if (!$process->isSuccessful()) { + $message = $this->formatter->format($process); + + $arguments->add('--fix'); + $process = $this->processBuilder->buildProcess($arguments); + $fixerCommand = $process->getCommandLine(); + + $errorMessage = $this->formatter->formatErrorMessage($message, $fixerCommand); + + return new FixableTaskResult( + TaskResult::createFailed($this, $context, $errorMessage), + FixableProcessProvider::provide($fixerCommand) + ); + } + + return TaskResult::createPassed($this, $context); + } +} diff --git a/test/Unit/Task/ESLintTest.php b/test/Unit/Task/ESLintTest.php new file mode 100644 index 000000000..5344437ac --- /dev/null +++ b/test/Unit/Task/ESLintTest.php @@ -0,0 +1,207 @@ +formatter = $this->prophesize(ESLintFormatter::class); + return new ESLint( + $this->processBuilder->reveal(), + $this->formatter->reveal() + ); + } + + public function provideConfigurableOptions(): iterable + { + yield 'defaults' => [ + [], + [ + // Task config options + 'bin' => null, + 'triggered_by' => ['js', 'jsx', 'ts', 'tsx', 'vue'], + 'whitelist_patterns' => null, + + // ESLint native config options + 'config' => null, + 'debug' => false, + 'format' => null, + 'max_warnings' => null, + 'no_eslintrc' => false, + 'quiet' => false, + ] + ]; + } + + public function provideRunContexts(): iterable + { + yield 'run-context' => [ + true, + $this->mockContext(RunContext::class) + ]; + + yield 'pre-commit-context' => [ + true, + $this->mockContext(GitPreCommitContext::class) + ]; + + yield 'other' => [ + false, + $this->mockContext() + ]; + } + + public function provideFailsOnStuff(): iterable + { + yield 'exitCode1' => [ + [], + $this->mockContext(RunContext::class, ['hello.js']), + function () { + $this->mockProcessBuilder('eslint', $process = $this->mockProcess(1)); + + $this->formatter->format($process)->willReturn($message = 'message'); + $this->formatter->formatErrorMessage($message, Argument::any())->willReturn('nope'); + }, + 'nope', + FixableTaskResult::class + ]; + } + + public function providePassesOnStuff(): iterable + { + yield 'exitCode0' => [ + [], + $this->mockContext(RunContext::class, ['hello.js']), + function () { + $this->mockProcessBuilder('eslint', $this->mockProcess(0)); + } + ]; + } + + public function provideSkipsOnStuff(): iterable + { + yield 'no-files' => [ + [], + $this->mockContext(RunContext::class), + function () {} + ]; + yield 'no-files-after-triggered-by' => [ + [], + $this->mockContext(RunContext::class, ['notajsfile.txt']), + function () {} + ]; + yield 'no-files-after-whitelist-patterns' => [ + [ + 'whitelist_patterns' => ['/^resources\/js\/(.*)/'], + ], + $this->mockContext(RunContext::class, ['resources/dont/find/this/file.js']), + function () {} + ]; + } + + public function provideExternalTaskRuns(): iterable + { + yield 'bin' => [ + [ + 'bin' => 'node_modules/.bin/eslint', + ], + $this->mockContext(RunContext::class, ['hello.js', 'hello2.js']), + 'eslint', + [ + 'node_modules/.bin/eslint', + 'hello.js', + 'hello2.js', + ] + ]; + yield 'config' => [ + [ + 'config' => '.eslintrc.json', + ], + $this->mockContext(RunContext::class, ['hello.js', 'hello2.js']), + 'eslint', + [ + '--config=.eslintrc.json', + 'hello.js', + 'hello2.js', + ] + ]; + yield 'debug' => [ + [ + 'debug' => true, + ], + $this->mockContext(RunContext::class, ['hello.js', 'hello2.js']), + 'eslint', + [ + '--debug', + 'hello.js', + 'hello2.js', + ] + ]; + yield 'format' => [ + [ + 'format' => 'table', + ], + $this->mockContext(RunContext::class, ['hello.js', 'hello2.js']), + 'eslint', + [ + '--format=table', + 'hello.js', + 'hello2.js', + ] + ]; + yield 'no_eslintrc' => [ + [ + 'no_eslintrc' => true, + ], + $this->mockContext(RunContext::class, ['hello.js', 'hello2.js']), + 'eslint', + [ + '--no-eslintrc', + 'hello.js', + 'hello2.js', + ] + ]; + yield 'quiet' => [ + [ + 'quiet' => true, + ], + $this->mockContext(RunContext::class, ['hello.js', 'hello2.js']), + 'eslint', + [ + '--quiet', + 'hello.js', + 'hello2.js', + ] + ]; + yield 'max_warnings' => [ + [ + 'max_warnings' => 10, + ], + $this->mockContext(RunContext::class, ['hello.js', 'hello2.js']), + 'eslint', + [ + '--max-warnings=10', + 'hello.js', + 'hello2.js', + ] + ]; + } +}