diff --git a/contrib/parallel-lint/CHANGELOG.md b/contrib/parallel-lint/CHANGELOG.md new file mode 100644 index 000000000..3df611dcb --- /dev/null +++ b/contrib/parallel-lint/CHANGELOG.md @@ -0,0 +1,111 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). + +## [Unreleased] + +[Unreleased]: https://github.com/php-parallel-lint/PHP-Parallel-Lint/compare/v1.3.0...HEAD + +## [1.3.1] - 2021-08-13 + +### Added + +- Extend by the Code Climate output format [#50] from [@lukas9393]. + +### Fixed + +- PHP 8.1: silence the deprecation notices about missing return types [#64] from [@jrfnl]. + +### Internal + +- Reformat changelog to use reflinks in changelog entries [#58] from [@glensc]. + +[1.3.1]: https://github.com/php-parallel-lint/PHP-Parallel-Lint/compare/v1.3.0...1.3.1 + +[#50]: https://github.com/php-parallel-lint/PHP-Parallel-Lint/pull/50 +[#58]: https://github.com/php-parallel-lint/PHP-Parallel-Lint/pull/58 +[#64]: https://github.com/php-parallel-lint/PHP-Parallel-Lint/pull/64 + +## [1.3.0] - 2021-04-07 + +### Added + +- Allow for multi-part file extensions to be passed using -e (like `-e php,php.dist`) from [@jrfnl]. +- Added syntax error callback [#30] from [@arxeiss]. +- Ignore PHP startup errors [#34] from [@jrfnl]. +- Restore php 5.3 support [#51] from [@glensc]. + +### Fixed + +- Determine skip lint process failure by status code instead of stderr content [#48] from [@jankonas]. + +### Changed + +- Improve wording in the readme [#52] from [@glensc]. + +### Internal + +- Normalized composer.json from [@OndraM]. +- Updated PHPCS dependency from [@jrfnl]. +- Cleaned coding style from [@jrfnl]. +- Provide one true way to run the test suite [#37] from [@mfn]. +- Travis: add build against PHP 8.0 and fix failing test [#41] from [@jrfnl]. +- GitHub Actions for testing, and automatic phar creation [#46] from [@roelofr]. +- Add .github folder to .gitattributes export-ignore [#54] from [@glensc]. +- Suggest to curl composer install via HTTPS [#53] from [@reedy]. +- GH Actions: allow for manually triggering a workflow [#55] from [@jrfnl]. +- GH Actions: fix phar creation [#55] from [@jrfnl]. +- GH Actions: run the tests against all supported PHP versions [#55] from [@jrfnl]. +- GH Actions: report CS violations in the PR [#55] from [@jrfnl]. + +[1.3.0]: https://github.com/php-parallel-lint/PHP-Parallel-Lint/compare/v1.2.0...v1.3.0 +[#30]: https://github.com/php-parallel-lint/PHP-Parallel-Lint/pull/30 +[#34]: https://github.com/php-parallel-lint/PHP-Parallel-Lint/pull/34 +[#37]: https://github.com/php-parallel-lint/PHP-Parallel-Lint/pull/37 +[#41]: https://github.com/php-parallel-lint/PHP-Parallel-Lint/pull/41 +[#46]: https://github.com/php-parallel-lint/PHP-Parallel-Lint/pull/46 +[#48]: https://github.com/php-parallel-lint/PHP-Parallel-Lint/pull/48 +[#51]: https://github.com/php-parallel-lint/PHP-Parallel-Lint/pull/51 +[#52]: https://github.com/php-parallel-lint/PHP-Parallel-Lint/pull/52 +[#53]: https://github.com/php-parallel-lint/PHP-Parallel-Lint/pull/53 +[#54]: https://github.com/php-parallel-lint/PHP-Parallel-Lint/pull/54 +[#55]: https://github.com/php-parallel-lint/PHP-Parallel-Lint/pull/55 + +## [1.2.0] - 2020-04-04 + +### Added + +- Added changelog. + +### Fixed + +- Fixed vendor location for running from other folder from [@Erkens]. + +### Internal + +- Added a .gitattributes file from [@jrfnl], thanks for issue to [@ondrejmirtes]. +- Fixed incorrect unit tests from [@jrfnl]. +- Fixed minor grammatical errors from [@jrfnl]. +- Added Travis: test against nightly (= PHP 8) from [@jrfnl]. +- Travis: removed sudo from [@jrfnl]. +- Added info about installing like not a dependency. +- Cleaned readme - new organization from previous package. +- Added checklist for new version from [@szepeviktor]. + +[1.2.0]: https://github.com/php-parallel-lint/PHP-Parallel-Lint/compare/v1.1.0...v1.2.0 + +[@Erkens]: https://github.com/Erkens +[@OndraM]: https://github.com/OndraM +[@arxeiss]: https://github.com/arxeiss +[@glensc]: https://github.com/glensc +[@jankonas]: https://github.com/jankonas +[@jrfnl]: https://github.com/jrfnl +[@mfn]: https://github.com/mfn +[@ondrejmirtes]: https://github.com/ondrejmirtes +[@reedy]: https://github.com/reedy +[@roelofr]: https://github.com/roelofr +[@szepeviktor]: https://github.com/szepeviktor +[@lukas9393]: https://github.com/lukas9393 + diff --git a/contrib/parallel-lint/LICENSE b/contrib/parallel-lint/LICENSE new file mode 100644 index 000000000..09429bbf0 --- /dev/null +++ b/contrib/parallel-lint/LICENSE @@ -0,0 +1,26 @@ +Copyright (c) 2012, Jakub Onderka +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +The views and conclusions contained in the software and documentation are those +of the authors and should not be interpreted as representing official policies, +either expressed or implied, of the FreeBSD Project. diff --git a/contrib/parallel-lint/README.md b/contrib/parallel-lint/README.md new file mode 100644 index 000000000..eb546e93b --- /dev/null +++ b/contrib/parallel-lint/README.md @@ -0,0 +1,112 @@ +# PHP Parallel Lint + +[![Downloads this Month](https://img.shields.io/packagist/dm/php-parallel-lint/php-parallel-lint.svg)](https://packagist.org/packages/php-parallel-lint/php-parallel-lint) +[![Build Status](https://github.com/php-parallel-lint/PHP-Parallel-Lint/actions/workflows/test.yml/badge.svg)](https://github.com/php-parallel-lint/PHP-Parallel-Lint/actions/workflows/test.yml) +[![License](https://poser.pugx.org/php-parallel-lint/php-parallel-lint/license.svg)](https://packagist.org/packages/php-parallel-lint/php-parallel-lint) + +This application checks syntax of PHP files in parallel. +It can output in plain text, colored text, json and checksyntax formats. +Additionally `blame` can be used to show commits that introduced the breakage. + +Running parallel jobs in PHP is inspired by Nette framework tests. + +The application is officially supported for use with PHP 5.3 to 8.0. + +## Table of contents + +1. [Installation](#installation) +2. [Example output](#example-output) +3. [History](#history) +4. [Command line options](#command-line-options) +5. [Recommended excludes for Symfony framework](#recommended-excludes-for-symfony-framework) +6. [Create Phar package](#create-phar-package) +7. [How to upgrade](#how-to-upgrade) + +## Installation + +Install with `composer` as development dependency: + + composer require --dev php-parallel-lint/php-parallel-lint + +Alternatively you can install as a standalone `composer` project: + + composer create-project php-parallel-lint/php-parallel-lint /path/to/folder/php-parallel-lint + /path/to/folder/php-parallel-lint/parallel-lint # running tool + +For colored output, install the suggested package `php-parallel-lint/php-console-highlighter`: + + composer require --dev php-parallel-lint/php-console-highlighter + +## Example output + +![Example use of tool with error](/tests/examples/example-images/use-error.png?raw=true "Example use of tool with error") + + +## History + +This project was originally created by [@JakubOnderka] and released as +[jakub-onderka/php-parallel-lint]. + +Since then, Jakub has moved on to other interests and as of January 2020, the +second most active maintainer [@grogy] has taken over maintenance of the project +and given the project - and related dependencies - a new home in the PHP +Parallel Lint organisation. + +It is strongly recommended for existing users of the (unmaintained) +[jakub-onderka/php-parallel-lint] package to switch their dependency to +[php-parallel-lint/php-parallel-lint], see [How to upgrade](#how-to-upgrade) below. + +[php-parallel-lint/php-parallel-lint]: https://github.com/php-parallel-lint/PHP-Parallel-Lint +[grogy/php-parallel-lint]: https://github.com/grogy/PHP-Parallel-Lint +[jakub-onderka/php-parallel-lint]: https://github.com/JakubOnderka/PHP-Parallel-Lint +[@JakubOnderka]: https://github.com/JakubOnderka +[@grogy]: https://github.com/grogy + +## Command line options + +- `-p ` Specify PHP-CGI executable to run (default: 'php'). +- `-s, --short` Set short_open_tag to On (default: Off). +- `-a, --asp` Set asp_tags to On (default: Off). +- `-e ` Check only files with selected extensions separated by comma. (default: php,php3,php4,php5,phtml,phpt) +- `--exclude` Exclude a file or directory. If you want exclude multiple items, use multiple exclude parameters. +- `-j ` Run jobs in parallel (default: 10). +- `--colors` Force enable colors in console output. +- `--no-colors` Disable colors in console output. +- `--no-progress` Disable progress in console output. +- `--checkstyle` Output results as Checkstyle XML. +- `--json` Output results as JSON string (requires PHP 5.4). +- `--gitlab` Output results for the GitLab Code Quality widget (requires PHP 5.4), see more in [Code Quality](https://docs.gitlab.com/ee/user/project/merge_requests/code_quality.html) documentation. +- `--blame` Try to show git blame for row with error. +- `--git ` Path to Git executable to show blame message (default: 'git'). +- `--stdin` Load files and folder to test from standard input. +- `--ignore-fails` Ignore failed tests. +- `--syntax-error-callback` File with syntax error callback for ability to modify error, see more in [example](doc/syntax-error-callback.md) +- `-h, --help` Print this help. +- `-V, --version` Display this application version. + + +## Recommended excludes for Symfony framework + +To run from the command line: + + vendor/bin/parallel-lint --exclude app --exclude vendor . + +## Create Phar package + +PHP Parallel Lint supports [Box app](https://box-project.github.io/box2/) for creating Phar package. First, install box app: + + + curl -LSs https://box-project.github.io/box2/installer.php | php + + +then run the build command in parallel lint folder, which creates `parallel-lint.phar` file. + + + box build + +## How to upgrade + +Are you using `jakub-onderka/php-parallel-lint` package? You can switch to `php-parallel-lint/php-parallel-lint` using: + + composer remove --dev jakub-onderka/php-parallel-lint + composer require --dev php-parallel-lint/php-parallel-lint diff --git a/contrib/parallel-lint/bin/skip-linting.php b/contrib/parallel-lint/bin/skip-linting.php new file mode 100644 index 000000000..2c695548d --- /dev/null +++ b/contrib/parallel-lint/bin/skip-linting.php @@ -0,0 +1,19 @@ +run()); diff --git a/contrib/parallel-lint/src/Application.php b/contrib/parallel-lint/src/Application.php new file mode 100644 index 000000000..4b4017e1f --- /dev/null +++ b/contrib/parallel-lint/src/Application.php @@ -0,0 +1,125 @@ +showUsage(); + return self::SUCCESS; + } + + if (in_array('-V', $_SERVER['argv']) || in_array('--version', $_SERVER['argv'])) { + $this->showVersion(); + return self::SUCCESS; + } + + try { + $settings = Settings::parseArguments($_SERVER['argv']); + if ($settings->stdin) { + $settings->addPaths(Settings::getPathsFromStdIn()); + } + if (empty($settings->paths)) { + $this->showUsage(); + return self::FAILED; + } + $manager = new Manager; + $result = $manager->run($settings); + if ($settings->ignoreFails) { + return $result->hasSyntaxError() ? self::WITH_ERRORS : self::SUCCESS; + } else { + return $result->hasError() ? self::WITH_ERRORS : self::SUCCESS; + } + + } catch (InvalidArgumentException $e) { + echo "Invalid option {$e->getArgument()}", PHP_EOL, PHP_EOL; + $this->showOptions(); + return self::FAILED; + + } catch (Exception $e) { + if (isset($settings) && $settings->format === Settings::FORMAT_JSON) { + echo json_encode($e); + } else { + echo $e->getMessage(), PHP_EOL; + } + return self::FAILED; + + } catch (\Exception $e) { + echo $e->getMessage(), PHP_EOL; + return self::FAILED; + } + } + + /** + * Outputs the options + */ + private function showOptions() + { + echo << Specify PHP-CGI executable to run (default: 'php'). + -s, --short Set short_open_tag to On (default: Off). + -a, -asp Set asp_tags to On (default: Off). + -e Check only files with selected extensions separated by comma. + (default: php,php3,php4,php5,phtml,phpt) + --exclude Exclude a file or directory. If you want exclude multiple items, + use multiple exclude parameters. + -j Run jobs in parallel (default: 10). + --colors Enable colors in console output. (disables auto detection of color support) + --no-colors Disable colors in console output. + --no-progress Disable progress in console output. + --json Output results as JSON string. + --gitlab Output results for the GitLab Code Quality Widget. + --checkstyle Output results as Checkstyle XML. + --blame Try to show git blame for row with error. + --git Path to Git executable to show blame message (default: 'git'). + --stdin Load files and folder to test from standard input. + --ignore-fails Ignore failed tests. + --syntax-error-callback File with syntax error callback for ability to modify error + -h, --help Print this help. + -V, --version Display this application version + +HELP; + } + + /** + * Outputs the current version + */ + private function showVersion() + { + echo 'PHP Parallel Lint version ' . self::VERSION.PHP_EOL; + } + + /** + * Shows usage + */ + private function showUsage() + { + $this->showVersion(); + echo <<showOptions(); + } +} diff --git a/contrib/parallel-lint/src/Contracts/SyntaxErrorCallback.php b/contrib/parallel-lint/src/Contracts/SyntaxErrorCallback.php new file mode 100644 index 000000000..0db055faf --- /dev/null +++ b/contrib/parallel-lint/src/Contracts/SyntaxErrorCallback.php @@ -0,0 +1,13 @@ +filePath = $filePath; + $this->message = rtrim($message); + } + + /** + * @return string + */ + public function getMessage() + { + return $this->message; + } + + /** + * @return string + */ + public function getFilePath() + { + return $this->filePath; + } + + /** + * @return string + */ + public function getShortFilePath() + { + $cwd = getcwd(); + + if ($cwd === '/') { + // For root directory in unix, do not modify path + return $this->filePath; + } + + return preg_replace('/' . preg_quote($cwd, '/') . '/', '', $this->filePath, 1); + } + + /** + * (PHP 5 >= 5.4.0)
+ * Specify data which should be serialized to JSON + * @link http://php.net/manual/en/jsonserializable.jsonserialize.php + * @return mixed data which can be serialized by json_encode, + * which is a value of any type other than a resource. + */ + #[ReturnTypeWillChange] + public function jsonSerialize() + { + return array( + 'type' => 'error', + 'file' => $this->getFilePath(), + 'message' => $this->getMessage(), + ); + } +} + +class Blame implements \JsonSerializable +{ + public $name; + + public $email; + + /** @var \DateTime */ + public $datetime; + + public $commitHash; + + public $summary; + + /** + * (PHP 5 >= 5.4.0)
+ * Specify data which should be serialized to JSON + * @link http://php.net/manual/en/jsonserializable.jsonserialize.php + * @return mixed data which can be serialized by json_encode, + * which is a value of any type other than a resource. + */ + #[ReturnTypeWillChange] + function jsonSerialize() + { + return array( + 'name' => $this->name, + 'email' => $this->email, + 'datetime' => $this->datetime, + 'commitHash' => $this->commitHash, + 'summary' => $this->summary, + ); + } + + +} + +class SyntaxError extends Error +{ + /** @var Blame */ + private $blame; + + /** + * @return int|null + */ + public function getLine() + { + preg_match('~on line ([0-9]+)$~', $this->message, $matches); + + if ($matches && isset($matches[1])) { + $onLine = (int) $matches[1]; + return $onLine; + } + + return null; + } + + /** + * @param bool $translateTokens + * @return mixed|string + */ + public function getNormalizedMessage($translateTokens = false) + { + $message = preg_replace('~^(Parse|Fatal) error: (syntax error, )?~', '', $this->message); + $message = preg_replace('~ in ' . preg_quote(basename($this->filePath)) . ' on line [0-9]+$~', '', $message); + $message = ucfirst($message); + + if ($translateTokens) { + $message = $this->translateTokens($message); + } + + return $message; + } + + /** + * @param Blame $blame + */ + public function setBlame(Blame $blame) + { + $this->blame = $blame; + } + + /** + * @return Blame + */ + public function getBlame() + { + return $this->blame; + } + + /** + * @param string $message + * @return string + */ + protected function translateTokens($message) + { + static $translateTokens = array( + 'T_FILE' => '__FILE__', + 'T_FUNC_C' => '__FUNCTION__', + 'T_HALT_COMPILER' => '__halt_compiler()', + 'T_INC' => '++', + 'T_IS_EQUAL' => '==', + 'T_IS_GREATER_OR_EQUAL' => '>=', + 'T_IS_IDENTICAL' => '===', + 'T_IS_NOT_IDENTICAL' => '!==', + 'T_IS_SMALLER_OR_EQUAL' => '<=', + 'T_LINE' => '__LINE__', + 'T_METHOD_C' => '__METHOD__', + 'T_MINUS_EQUAL' => '-=', + 'T_MOD_EQUAL' => '%=', + 'T_MUL_EQUAL' => '*=', + 'T_NS_C' => '__NAMESPACE__', + 'T_NS_SEPARATOR' => '\\', + 'T_OBJECT_OPERATOR' => '->', + 'T_OR_EQUAL' => '|=', + 'T_PAAMAYIM_NEKUDOTAYIM' => '::', + 'T_PLUS_EQUAL' => '+=', + 'T_SL' => '<<', + 'T_SL_EQUAL' => '<<=', + 'T_SR' => '>>', + 'T_SR_EQUAL' => '>>=', + 'T_START_HEREDOC' => '<<<', + 'T_XOR_EQUAL' => '^=', + 'T_ECHO' => 'echo' + ); + + return preg_replace_callback('~T_([A-Z_]*)~', function ($matches) use ($translateTokens) { + list($tokenName) = $matches; + if (isset($translateTokens[$tokenName])) { + $operator = $translateTokens[$tokenName]; + return "$operator ($tokenName)"; + } + + return $tokenName; + }, $message); + } + + /** + * (PHP 5 >= 5.4.0)
+ * Specify data which should be serialized to JSON + * @link http://php.net/manual/en/jsonserializable.jsonserialize.php + * @return mixed data which can be serialized by json_encode, + * which is a value of any type other than a resource. + */ + public function jsonSerialize() + { + return array( + 'type' => 'syntaxError', + 'file' => $this->getFilePath(), + 'line' => $this->getLine(), + 'message' => $this->getMessage(), + 'normalizeMessage' => $this->getNormalizedMessage(), + 'blame' => $this->blame, + ); + } +} \ No newline at end of file diff --git a/contrib/parallel-lint/src/ErrorFormatter.php b/contrib/parallel-lint/src/ErrorFormatter.php new file mode 100644 index 000000000..76b4466cc --- /dev/null +++ b/contrib/parallel-lint/src/ErrorFormatter.php @@ -0,0 +1,127 @@ +useColors = $useColors; + $this->forceColors = $forceColors; + $this->translateTokens = $translateTokens; + } + + /** + * @param Error $error + * @return string + */ + public function format(Error $error) + { + if ($error instanceof SyntaxError) { + return $this->formatSyntaxErrorMessage($error); + } else { + if ($error->getMessage()) { + return $error->getMessage(); + } else { + return "Unknown error for file '{$error->getFilePath()}'."; + } + } + } + + /** + * @param SyntaxError $error + * @param bool $withCodeSnipped + * @return string + */ + public function formatSyntaxErrorMessage(SyntaxError $error, $withCodeSnipped = true) + { + $string = "Parse error: {$error->getShortFilePath()}"; + + if ($error->getLine()) { + $onLine = $error->getLine(); + $string .= ":$onLine" . PHP_EOL; + + if ($withCodeSnipped) { + if ($this->useColors !== Settings::DISABLED) { + $string .= $this->getColoredCodeSnippet($error->getFilePath(), $onLine); + } else { + $string .= $this->getCodeSnippet($error->getFilePath(), $onLine); + } + } + } + + $string .= $error->getNormalizedMessage($this->translateTokens); + + if ($error->getBlame()) { + $blame = $error->getBlame(); + $shortCommitHash = substr($blame->commitHash, 0, 8); + $dateTime = $blame->datetime->format('c'); + $string .= PHP_EOL . "Blame {$blame->name} <{$blame->email}>, commit '$shortCommitHash' from $dateTime"; + } + + return $string; + } + + /** + * @param string $filePath + * @param int $lineNumber + * @param int $linesBefore + * @param int $linesAfter + * @return string + */ + protected function getCodeSnippet($filePath, $lineNumber, $linesBefore = 2, $linesAfter = 2) + { + $lines = file($filePath); + + $offset = $lineNumber - $linesBefore - 1; + $offset = max($offset, 0); + $length = $linesAfter + $linesBefore + 1; + $lines = array_slice($lines, $offset, $length, $preserveKeys = true); + + end($lines); + $lineStrlen = strlen(key($lines) + 1); + + $snippet = ''; + foreach ($lines as $i => $line) { + $snippet .= ($lineNumber === $i + 1 ? ' > ' : ' '); + $snippet .= str_pad($i + 1, $lineStrlen, ' ', STR_PAD_LEFT) . '| ' . rtrim($line) . PHP_EOL; + } + + return $snippet; + } + + /** + * @param string $filePath + * @param int $lineNumber + * @param int $linesBefore + * @param int $linesAfter + * @return string + */ + protected function getColoredCodeSnippet($filePath, $lineNumber, $linesBefore = 2, $linesAfter = 2) + { + if ( + !class_exists('\JakubOnderka\PhpConsoleHighlighter\Highlighter') || + !class_exists('\JakubOnderka\PhpConsoleColor\ConsoleColor') + ) { + return $this->getCodeSnippet($filePath, $lineNumber, $linesBefore, $linesAfter); + } + + $colors = new ConsoleColor(); + $colors->setForceStyle($this->forceColors); + $highlighter = new Highlighter($colors); + + $fileContent = file_get_contents($filePath); + return $highlighter->getCodeSnippet($fileContent, $lineNumber, $linesBefore, $linesAfter); + } +} diff --git a/contrib/parallel-lint/src/Manager.php b/contrib/parallel-lint/src/Manager.php new file mode 100644 index 000000000..84b743406 --- /dev/null +++ b/contrib/parallel-lint/src/Manager.php @@ -0,0 +1,293 @@ +output ?: $this->getDefaultOutput($settings); + + $phpExecutable = PhpExecutable::getPhpExecutable($settings->phpExecutable); + $olderThanPhp54 = $phpExecutable->getVersionId() < 50400; // From PHP version 5.4 are tokens translated by default + $translateTokens = $phpExecutable->isIsHhvmType() || $olderThanPhp54; + + $output->writeHeader($phpExecutable->getVersionId(), $settings->parallelJobs, $phpExecutable->getHhvmVersion()); + + $files = $this->getFilesFromPaths($settings->paths, $settings->extensions, $settings->excluded); + + if (empty($files)) { + throw new Exception('No file found to check.'); + } + + $output->setTotalFileCount(count($files)); + + $parallelLint = new ParallelLint($phpExecutable, $settings->parallelJobs); + $parallelLint->setAspTagsEnabled($settings->aspTags); + $parallelLint->setShortTagEnabled($settings->shortTag); + $parallelLint->setShowDeprecated($settings->showDeprecated); + $parallelLint->setSyntaxErrorCallback($this->createSyntaxErrorCallback($settings)); + + $parallelLint->setProcessCallback(function ($status, $file) use ($output) { + if ($status === ParallelLint::STATUS_OK) { + $output->ok(); + } else if ($status === ParallelLint::STATUS_SKIP) { + $output->skip(); + } else if ($status === ParallelLint::STATUS_ERROR) { + $output->error(); + } else { + $output->fail(); + } + }); + + $result = $parallelLint->lint($files); + + if ($settings->blame) { + $this->gitBlame($result, $settings); + } + + $output->writeResult($result, new ErrorFormatter($settings->colors, $translateTokens), $settings->ignoreFails); + + return $result; + } + + /** + * @param Output $output + */ + public function setOutput(Output $output) + { + $this->output = $output; + } + + /** + * @param Settings $settings + * @return Output + */ + protected function getDefaultOutput(Settings $settings) + { + $writer = new ConsoleWriter; + switch ($settings->format) { + case Settings::FORMAT_JSON: + return new JsonOutput($writer); + case Settings::FORMAT_GITLAB: + return new GitLabOutput($writer); + case Settings::FORMAT_CHECKSTYLE: + return new CheckstyleOutput($writer); + } + + if ($settings->colors === Settings::DISABLED) { + $output = new TextOutput($writer); + } else { + $output = new TextOutputColored($writer, $settings->colors); + } + + $output->showProgress = $settings->showProgress; + + return $output; + } + + /** + * @param Result $result + * @param Settings $settings + * @throws Exception + */ + protected function gitBlame(Result $result, Settings $settings) + { + if (!GitBlameProcess::gitExists($settings->gitExecutable)) { + return; + } + + foreach ($result->getErrors() as $error) { + if ($error instanceof SyntaxError) { + $process = new GitBlameProcess($settings->gitExecutable, $error->getFilePath(), $error->getLine()); + $process->waitForFinish(); + + if ($process->isSuccess()) { + $blame = new Blame; + $blame->name = $process->getAuthor(); + $blame->email = $process->getAuthorEmail(); + $blame->datetime = $process->getAuthorTime(); + $blame->commitHash = $process->getCommitHash(); + $blame->summary = $process->getSummary(); + + $error->setBlame($blame); + } + } + } + } + + /** + * @param array $paths + * @param array $extensions + * @param array $excluded + * @return array + * @throws NotExistsPathException + */ + protected function getFilesFromPaths(array $paths, array $extensions, array $excluded = array()) + { + $extensions = array_map('preg_quote', $extensions, array_fill(0, count($extensions), '`')); + $regex = '`\.(?:' . implode('|', $extensions) . ')$`iD'; + $files = array(); + + foreach ($paths as $path) { + if (is_file($path)) { + $files[] = $path; + } else if (is_dir($path)) { + $iterator = new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS); + if (!empty($excluded)) { + $iterator = new RecursiveDirectoryFilterIterator($iterator, $excluded); + } + $iterator = new \RecursiveIteratorIterator( + $iterator, + \RecursiveIteratorIterator::LEAVES_ONLY, + \RecursiveIteratorIterator::CATCH_GET_CHILD + ); + + $iterator = new \RegexIterator($iterator, $regex); + + /** @var \SplFileInfo[] $iterator */ + foreach ($iterator as $directoryFile) { + $files[] = (string) $directoryFile; + } + } else { + throw new NotExistsPathException($path); + } + } + + $files = array_unique($files); + + return $files; + } + + protected function createSyntaxErrorCallback(Settings $settings) + { + if ($settings->syntaxErrorCallbackFile === null) { + return null; + } + + $fullFilePath = realpath($settings->syntaxErrorCallbackFile); + if ($fullFilePath === false) { + throw new NotExistsPathException($settings->syntaxErrorCallbackFile); + } + + require_once $fullFilePath; + + $expectedClassName = basename($fullFilePath, '.php'); + if (!class_exists($expectedClassName)) { + throw new NotExistsClassException($expectedClassName, $settings->syntaxErrorCallbackFile); + } + + $callbackInstance = new $expectedClassName; + + if (!($callbackInstance instanceof SyntaxErrorCallback)) { + throw new NotImplementCallbackException($expectedClassName); + } + + return $callbackInstance; + } +} + +class RecursiveDirectoryFilterIterator extends \RecursiveFilterIterator +{ + /** @var \RecursiveDirectoryIterator */ + private $iterator; + + /** @var array */ + private $excluded = array(); + + /** + * @param \RecursiveDirectoryIterator $iterator + * @param array $excluded + */ + public function __construct(\RecursiveDirectoryIterator $iterator, array $excluded) + { + parent::__construct($iterator); + $this->iterator = $iterator; + $this->excluded = array_map(array($this, 'getPathname'), $excluded); + } + + /** + * (PHP 5 >= 5.1.0)
+ * Check whether the current element of the iterator is acceptable + * + * @link http://php.net/manual/en/filteriterator.accept.php + * @return bool true if the current element is acceptable, otherwise false. + */ + #[ReturnTypeWillChange] + public function accept() + { + $current = $this->current()->getPathname(); + $current = $this->normalizeDirectorySeparator($current); + + if ('.' . DIRECTORY_SEPARATOR !== $current[0] . $current[1]) { + $current = '.' . DIRECTORY_SEPARATOR . $current; + } + + return !in_array($current, $this->excluded); + } + + /** + * (PHP 5 >= 5.1.0)
+ * Check whether the inner iterator's current element has children + * + * @link http://php.net/manual/en/recursivefilteriterator.haschildren.php + * @return bool true if the inner iterator has children, otherwise false + */ + #[ReturnTypeWillChange] + public function hasChildren() + { + return $this->iterator->hasChildren(); + } + + /** + * (PHP 5 >= 5.1.0)
+ * Return the inner iterator's children contained in a RecursiveFilterIterator + * + * @link http://php.net/manual/en/recursivefilteriterator.getchildren.php + * @return \RecursiveFilterIterator containing the inner iterator's children. + */ + #[ReturnTypeWillChange] + public function getChildren() + { + return new self($this->iterator->getChildren(), $this->excluded); + } + + /** + * @param string $file + * @return string + */ + private function getPathname($file) + { + $file = $this->normalizeDirectorySeparator($file); + + if ('.' . DIRECTORY_SEPARATOR !== $file[0] . $file[1]) { + $file = '.' . DIRECTORY_SEPARATOR . $file; + } + + $directoryFile = new \SplFileInfo($file); + return $directoryFile->getPathname(); + } + + /** + * @param string $file + * @return string + */ + private function normalizeDirectorySeparator($file) + { + return str_replace(array('\\', '/'), DIRECTORY_SEPARATOR, $file); + } +} diff --git a/contrib/parallel-lint/src/Output.php b/contrib/parallel-lint/src/Output.php new file mode 100644 index 000000000..974dde806 --- /dev/null +++ b/contrib/parallel-lint/src/Output.php @@ -0,0 +1,588 @@ +writer = $writer; + } + + public function ok() + { + + } + + public function skip() + { + + } + + public function error() + { + + } + + public function fail() + { + + } + + public function setTotalFileCount($count) + { + + } + + public function writeHeader($phpVersion, $parallelJobs, $hhvmVersion = null) + { + $this->phpVersion = $phpVersion; + $this->parallelJobs = $parallelJobs; + $this->hhvmVersion = $hhvmVersion; + } + + public function writeResult(Result $result, ErrorFormatter $errorFormatter, $ignoreFails) + { + echo json_encode(array( + 'phpVersion' => $this->phpVersion, + 'hhvmVersion' => $this->hhvmVersion, + 'parallelJobs' => $this->parallelJobs, + 'results' => $result, + )); + } +} + +class GitLabOutput implements Output +{ + /** @var IWriter */ + protected $writer; + + /** + * @param IWriter $writer + */ + public function __construct(IWriter $writer) + { + $this->writer = $writer; + } + + public function ok() + { + + } + + public function skip() + { + + } + + public function error() + { + + } + + public function fail() + { + + } + + public function setTotalFileCount($count) + { + + } + + public function writeHeader($phpVersion, $parallelJobs, $hhvmVersion = null) + { + + } + + public function writeResult(Result $result, ErrorFormatter $errorFormatter, $ignoreFails) + { + $errors = array(); + foreach ($result->getErrors() as $error) { + $message = $error->getMessage(); + $line = 1; + if ($error instanceof SyntaxError) { + $line = $error->getLine(); + } + $filePath = $error->getFilePath(); + $result = array( + 'type' => 'issue', + 'check_name' => 'Parse error', + 'description' => $message, + 'categories' => 'Style', + 'fingerprint' => md5($filePath . $message . $line), + 'severity' => 'minor', + 'location' => array( + 'path' => $filePath, + 'lines' => array( + 'begin' => $line, + ), + ), + ); + array_push($errors, $result); + } + + $string = json_encode($errors) . PHP_EOL; + $this->writer->write($string); + } +} + +class TextOutput implements Output +{ + const TYPE_DEFAULT = 'default', + TYPE_SKIP = 'skip', + TYPE_ERROR = 'error', + TYPE_FAIL = 'fail', + TYPE_OK = 'ok'; + + /** @var int */ + public $filesPerLine = 60; + + /** @var bool */ + public $showProgress = true; + + /** @var int */ + protected $checkedFiles; + + /** @var int */ + protected $totalFileCount; + + /** @var IWriter */ + protected $writer; + + /** + * @param IWriter $writer + */ + public function __construct(IWriter $writer) + { + $this->writer = $writer; + } + + public function ok() + { + $this->writeMark(self::TYPE_OK); + } + + public function skip() + { + $this->writeMark(self::TYPE_SKIP); + } + + public function error() + { + $this->writeMark(self::TYPE_ERROR); + } + + public function fail() + { + $this->writeMark(self::TYPE_FAIL); + } + + /** + * @param string $string + * @param string $type + */ + public function write($string, $type = self::TYPE_DEFAULT) + { + $this->writer->write($string); + } + + /** + * @param string|null $line + * @param string $type + */ + public function writeLine($line = null, $type = self::TYPE_DEFAULT) + { + $this->write($line, $type); + $this->writeNewLine(); + } + + /** + * @param int $count + */ + public function writeNewLine($count = 1) + { + $this->write(str_repeat(PHP_EOL, $count)); + } + + /** + * @param int $count + */ + public function setTotalFileCount($count) + { + $this->totalFileCount = $count; + } + + /** + * @param int $phpVersion + * @param int $parallelJobs + * @param string $hhvmVersion + */ + public function writeHeader($phpVersion, $parallelJobs, $hhvmVersion = null) + { + $this->write("PHP {$this->phpVersionIdToString($phpVersion)} | "); + + if ($hhvmVersion) { + $this->write("HHVM $hhvmVersion | "); + } + + if ($parallelJobs === 1) { + $this->writeLine("1 job"); + } else { + $this->writeLine("{$parallelJobs} parallel jobs"); + } + } + + /** + * @param Result $result + * @param ErrorFormatter $errorFormatter + * @param bool $ignoreFails + */ + public function writeResult(Result $result, ErrorFormatter $errorFormatter, $ignoreFails) + { + if ($this->showProgress) { + if ($this->checkedFiles % $this->filesPerLine !== 0) { + $rest = $this->filesPerLine - ($this->checkedFiles % $this->filesPerLine); + $this->write(str_repeat(' ', $rest)); + $this->writePercent(); + } + + $this->writeNewLine(2); + } + + $testTime = round($result->getTestTime(), 1); + $message = "Checked {$result->getCheckedFilesCount()} files in $testTime "; + $message .= $testTime == 1 ? 'second' : 'seconds'; + + if ($result->getSkippedFilesCount() > 0) { + $message .= ", skipped {$result->getSkippedFilesCount()} "; + $message .= ($result->getSkippedFilesCount() === 1 ? 'file' : 'files'); + } + + $this->writeLine($message); + + if (!$result->hasSyntaxError()) { + $message = "No syntax error found"; + } else { + $message = "Syntax error found in {$result->getFilesWithSyntaxErrorCount()} "; + $message .= ($result->getFilesWithSyntaxErrorCount() === 1 ? 'file' : 'files'); + } + + if ($result->hasFilesWithFail()) { + $message .= ", failed to check {$result->getFilesWithFailCount()} "; + $message .= ($result->getFilesWithFailCount() === 1 ? 'file' : 'files'); + + if ($ignoreFails) { + $message .= ' (ignored)'; + } + } + + $hasError = $ignoreFails ? $result->hasSyntaxError() : $result->hasError(); + $this->writeLine($message, $hasError ? self::TYPE_ERROR : self::TYPE_OK); + + if ($result->hasError()) { + $this->writeNewLine(); + foreach ($result->getErrors() as $error) { + $this->writeLine(str_repeat('-', 60)); + $this->writeLine($errorFormatter->format($error)); + } + } + } + + protected function writeMark($type) + { + ++$this->checkedFiles; + + if ($this->showProgress) { + if ($type === self::TYPE_OK) { + $this->writer->write('.'); + + } else if ($type === self::TYPE_SKIP) { + $this->write('S', self::TYPE_SKIP); + + } else if ($type === self::TYPE_ERROR) { + $this->write('X', self::TYPE_ERROR); + + } else if ($type === self::TYPE_FAIL) { + $this->writer->write('-'); + } + + if ($this->checkedFiles % $this->filesPerLine === 0) { + $this->writePercent(); + } + } + } + + protected function writePercent() + { + $percent = floor($this->checkedFiles / $this->totalFileCount * 100); + $current = $this->stringWidth($this->checkedFiles, strlen($this->totalFileCount)); + $this->writeLine(" $current/$this->totalFileCount ($percent %)"); + } + + /** + * @param string $input + * @param int $width + * @return string + */ + protected function stringWidth($input, $width = 3) + { + $multiplier = $width - strlen($input); + return str_repeat(' ', $multiplier > 0 ? $multiplier : 0) . $input; + } + + /** + * @param int $phpVersionId + * @return string + */ + protected function phpVersionIdToString($phpVersionId) + { + $releaseVersion = (int) substr($phpVersionId, -2, 2); + $minorVersion = (int) substr($phpVersionId, -4, 2); + $majorVersion = (int) substr($phpVersionId, 0, strlen($phpVersionId) - 4); + + return "$majorVersion.$minorVersion.$releaseVersion"; + } +} + +class CheckstyleOutput implements Output +{ + private $writer; + + public function __construct(IWriter $writer) + { + $this->writer = $writer; + } + + public function ok() + { + } + + public function skip() + { + } + + public function error() + { + } + + public function fail() + { + } + + public function setTotalFileCount($count) + { + } + + public function writeHeader($phpVersion, $parallelJobs, $hhvmVersion = null) + { + $this->writer->write('' . PHP_EOL); + } + + public function writeResult(Result $result, ErrorFormatter $errorFormatter, $ignoreFails) + { + $this->writer->write('' . PHP_EOL); + $errors = array(); + + foreach ($result->getErrors() as $error) { + $message = $error->getMessage(); + if ($error instanceof SyntaxError) { + $line = $error->getLine(); + $source = "Syntax Error"; + } else { + $line = 1; + $source = "Linter Error"; + } + + $errors[$error->getShortFilePath()][] = array( + 'message' => $message, + 'line' => $line, + 'source' => $source + ); + } + + foreach ($errors as $file => $fileErrors) { + $this->writer->write(sprintf(' ', $file) . PHP_EOL); + foreach ($fileErrors as $fileError) { + $this->writer->write( + sprintf( + ' ', + $fileError['line'], + $fileError['message'], + $fileError['source'] + ) . + PHP_EOL + ); + } + $this->writer->write(' ' . PHP_EOL); + } + + $this->writer->write('' . PHP_EOL); + } +} + +class TextOutputColored extends TextOutput +{ + /** @var \JakubOnderka\PhpConsoleColor\ConsoleColor */ + private $colors; + + public function __construct(IWriter $writer, $colors = Settings::AUTODETECT) + { + parent::__construct($writer); + + if (class_exists('\JakubOnderka\PhpConsoleColor\ConsoleColor')) { + $this->colors = new \JakubOnderka\PhpConsoleColor\ConsoleColor(); + $this->colors->setForceStyle($colors === Settings::FORCED); + } + } + + /** + * @param string $string + * @param string $type + * @throws \JakubOnderka\PhpConsoleColor\InvalidStyleException + */ + public function write($string, $type = self::TYPE_DEFAULT) + { + if (!$this->colors instanceof \JakubOnderka\PhpConsoleColor\ConsoleColor) { + parent::write($string, $type); + } else { + switch ($type) { + case self::TYPE_OK: + parent::write($this->colors->apply('bg_green', $string)); + break; + + case self::TYPE_SKIP: + parent::write($this->colors->apply('bg_yellow', $string)); + break; + + case self::TYPE_ERROR: + parent::write($this->colors->apply('bg_red', $string)); + break; + + default: + parent::write($string); + } + } + } +} + +interface IWriter +{ + /** + * @param string $string + */ + public function write($string); +} + +class NullWriter implements IWriter +{ + /** + * @param string $string + */ + public function write($string) + { + + } +} + +class ConsoleWriter implements IWriter +{ + /** + * @param string $string + */ + public function write($string) + { + echo $string; + } +} + +class FileWriter implements IWriter +{ + /** @var string */ + protected $logFile; + + /** @var string */ + protected $buffer; + + public function __construct($logFile) + { + $this->logFile = $logFile; + } + + public function write($string) + { + $this->buffer .= $string; + } + + public function __destruct() + { + file_put_contents($this->logFile, $this->buffer); + } +} + +class MultipleWriter implements IWriter +{ + /** @var IWriter[] */ + protected $writers; + + /** + * @param IWriter[] $writers + */ + public function __construct(array $writers) + { + foreach ($writers as $writer) { + $this->addWriter($writer); + } + } + + /** + * @param IWriter $writer + */ + public function addWriter(IWriter $writer) + { + $this->writers[] = $writer; + } + + /** + * @param $string + */ + public function write($string) + { + foreach ($this->writers as $writer) { + $writer->write($string); + } + } +} diff --git a/contrib/parallel-lint/src/ParallelLint.php b/contrib/parallel-lint/src/ParallelLint.php new file mode 100644 index 000000000..b1825f317 --- /dev/null +++ b/contrib/parallel-lint/src/ParallelLint.php @@ -0,0 +1,286 @@ +phpExecutable = $phpExecutable; + $this->parallelJobs = $parallelJobs; + } + + /** + * @param array $files + * @return Result + * @throws \Exception + */ + public function lint(array $files) + { + $startTime = microtime(true); + + $skipLintProcess = new SkipLintProcess($this->phpExecutable, $files); + + $processCallback = is_callable($this->processCallback) ? $this->processCallback : function () { + }; + + /** + * @var LintProcess[] $running + * @var LintProcess[] $waiting + */ + $errors = $running = $waiting = array(); + $skippedFiles = $checkedFiles = array(); + + while ($files || $running) { + for ($i = count($running); $files && $i < $this->parallelJobs; $i++) { + $file = array_shift($files); + + if ($skipLintProcess->isSkipped($file) === true) { + $skippedFiles[] = $file; + $processCallback(self::STATUS_SKIP, $file); + } else { + $running[$file] = new LintProcess( + $this->phpExecutable, + $file, + $this->aspTagsEnabled, + $this->shortTagEnabled, + $this->showDeprecated + ); + } + } + + $skipLintProcess->getChunk(); + usleep(100); + + foreach ($running as $file => $process) { + if ($process->isFinished()) { + unset($running[$file]); + + $skipStatus = $skipLintProcess->isSkipped($file); + if ($skipStatus === null) { + $waiting[$file] = $process; + + } else if ($skipStatus === true) { + $skippedFiles[] = $file; + $processCallback(self::STATUS_SKIP, $file); + + } else if ($process->containsError()) { + $checkedFiles[] = $file; + $errors[] = $this->triggerSyntaxErrorCallback(new SyntaxError($file, $process->getSyntaxError())); + $processCallback(self::STATUS_ERROR, $file); + + } else if ($process->isSuccess()) { + $checkedFiles[] = $file; + $processCallback(self::STATUS_OK, $file); + + + } else { + $errors[] = new Error($file, $process->getOutput()); + $processCallback(self::STATUS_FAIL, $file); + } + } + } + } + + if (!empty($waiting)) { + $skipLintProcess->waitForFinish(); + + if ($skipLintProcess->isFail()) { + $message = "Error in skip-linting.php process\nError output: {$skipLintProcess->getErrorOutput()}"; + throw new \Exception($message); + } + + foreach ($waiting as $file => $process) { + $skipStatus = $skipLintProcess->isSkipped($file); + if ($skipStatus === null) { + throw new \Exception("File $file has empty skip status. Please contact the author of PHP Parallel Lint."); + + } else if ($skipStatus === true) { + $skippedFiles[] = $file; + $processCallback(self::STATUS_SKIP, $file); + + } else if ($process->isSuccess()) { + $checkedFiles[] = $file; + $processCallback(self::STATUS_OK, $file); + + } else if ($process->containsError()) { + $checkedFiles[] = $file; + $errors[] = $this->triggerSyntaxErrorCallback(new SyntaxError($file, $process->getSyntaxError())); + $processCallback(self::STATUS_ERROR, $file); + + } else { + $errors[] = new Error($file, $process->getOutput()); + $processCallback(self::STATUS_FAIL, $file); + } + } + } + + $testTime = microtime(true) - $startTime; + + return new Result($errors, $checkedFiles, $skippedFiles, $testTime); + } + + /** + * @return int + */ + public function getParallelJobs() + { + return $this->parallelJobs; + } + + /** + * @param int $parallelJobs + * @return ParallelLint + */ + public function setParallelJobs($parallelJobs) + { + $this->parallelJobs = $parallelJobs; + + return $this; + } + + /** + * @return string + */ + public function getPhpExecutable() + { + return $this->phpExecutable; + } + + /** + * @param string $phpExecutable + * @return ParallelLint + */ + public function setPhpExecutable($phpExecutable) + { + $this->phpExecutable = $phpExecutable; + + return $this; + } + + /** + * @return callable + */ + public function getProcessCallback() + { + return $this->processCallback; + } + + /** + * @param callable $processCallback + * @return ParallelLint + */ + public function setProcessCallback($processCallback) + { + $this->processCallback = $processCallback; + + return $this; + } + + /** + * @return boolean + */ + public function isAspTagsEnabled() + { + return $this->aspTagsEnabled; + } + + /** + * @param boolean $aspTagsEnabled + * @return ParallelLint + */ + public function setAspTagsEnabled($aspTagsEnabled) + { + $this->aspTagsEnabled = $aspTagsEnabled; + + return $this; + } + + /** + * @return boolean + */ + public function isShortTagEnabled() + { + return $this->shortTagEnabled; + } + + /** + * @param boolean $shortTagEnabled + * @return ParallelLint + */ + public function setShortTagEnabled($shortTagEnabled) + { + $this->shortTagEnabled = $shortTagEnabled; + + return $this; + } + + /** + * @return boolean + */ + public function isShowDeprecated() + { + return $this->showDeprecated; + } + + /** + * @param $showDeprecated + * @return ParallelLint + */ + public function setShowDeprecated($showDeprecated) + { + $this->showDeprecated = $showDeprecated; + + return $this; + } + + public function triggerSyntaxErrorCallback($syntaxError) + { + if ($this->syntaxErrorCallback === null) { + return $syntaxError; + } + + return $this->syntaxErrorCallback->errorFound($syntaxError); + } + + /** + * @param SyntaxErrorCallback|null $syntaxErrorCallback + * @return ParallelLint + */ + public function setSyntaxErrorCallback($syntaxErrorCallback) + { + $this->syntaxErrorCallback = $syntaxErrorCallback; + + return $this; + } +} diff --git a/contrib/parallel-lint/src/Process/GitBlameProcess.php b/contrib/parallel-lint/src/Process/GitBlameProcess.php new file mode 100644 index 000000000..2d675b8bf --- /dev/null +++ b/contrib/parallel-lint/src/Process/GitBlameProcess.php @@ -0,0 +1,147 @@ +getStatusCode() === 0; + } + + /** + * @return string + * @throws RunTimeException + */ + public function getAuthor() + { + if (!$this->isSuccess()) { + throw new RunTimeException("Author can only be retrieved for successful process output."); + } + + $output = $this->getOutput(); + preg_match('~^author (.*)~m', $output, $matches); + return $matches[1]; + } + + /** + * @return string + * @throws RunTimeException + */ + public function getAuthorEmail() + { + if (!$this->isSuccess()) { + throw new RunTimeException("Author e-mail can only be retrieved for successful process output."); + } + + $output = $this->getOutput(); + preg_match('~^author-mail <(.*)>~m', $output, $matches); + return $matches[1]; + } + + /** + * @return \DateTime + * @throws RunTimeException + */ + public function getAuthorTime() + { + if (!$this->isSuccess()) { + throw new RunTimeException("Author time can only be retrieved for successful process output."); + } + + $output = $this->getOutput(); + + preg_match('~^author-time (.*)~m', $output, $matches); + $time = $matches[1]; + + preg_match('~^author-tz (.*)~m', $output, $matches); + $zone = $matches[1]; + + return $this->getDateTime($time, $zone); + } + + /** + * @return string + * @throws RunTimeException + */ + public function getCommitHash() + { + if (!$this->isSuccess()) { + throw new RunTimeException("Commit hash can only be retrieved for successful process output."); + } + + return substr($this->getOutput(), 0, strpos($this->getOutput(), ' ')); + } + + /** + * @return string + * @throws RunTimeException + */ + public function getSummary() + { + if (!$this->isSuccess()) { + throw new RunTimeException("Commit summary can only be retrieved for successful process output."); + } + + $output = $this->getOutput(); + preg_match('~^summary (.*)~m', $output, $matches); + return $matches[1]; + } + + /** + * @param string $gitExecutable + * @return bool + * @throws RunTimeException + */ + public static function gitExists($gitExecutable) + { + $process = new Process($gitExecutable, array('--version')); + $process->waitForFinish(); + return $process->getStatusCode() === 0; + } + + /** + * This harakiri method is required to correct support time zone in PHP 5.4 + * + * @param int $time + * @param string $zone + * @return \DateTime + * @throws \Exception + */ + protected function getDateTime($time, $zone) + { + $utcTimeZone = new \DateTimeZone('UTC'); + $datetime = \DateTime::createFromFormat('U', $time, $utcTimeZone); + + $way = substr($zone, 0, 1); + $hours = (int) substr($zone, 1, 2); + $minutes = (int) substr($zone, 3, 2); + + $interval = new \DateInterval("PT{$hours}H{$minutes}M"); + + if ($way === '+') { + $datetime->add($interval); + } else { + $datetime->sub($interval); + } + + return new \DateTime($datetime->format('Y-m-d\TH:i:s') . $zone, $utcTimeZone); + } +} diff --git a/contrib/parallel-lint/src/Process/LintProcess.php b/contrib/parallel-lint/src/Process/LintProcess.php new file mode 100644 index 000000000..e2e6b2de0 --- /dev/null +++ b/contrib/parallel-lint/src/Process/LintProcess.php @@ -0,0 +1,137 @@ +showDeprecatedErrors = $deprecated; + parent::__construct($phpExecutable, $parameters); + } + + /** + * @return bool + * @throws + */ + public function containsError() + { + return $this->containsParserError($this->getOutput()) || + $this->containsFatalError($this->getOutput()) || + $this->containsDeprecatedError($this->getOutput()); + } + + /** + * @return string + * @throws RunTimeException + */ + public function getSyntaxError() + { + if ($this->containsError()) { + // Look for fatal errors first + foreach (explode("\n", $this->getOutput()) as $line) { + if ($this->containsFatalError($line)) { + return $line; + } + } + + // Look for parser errors second + foreach (explode("\n", $this->getOutput()) as $line) { + if ($this->containsParserError($line)) { + return $line; + } + } + + // Look for deprecated errors third + foreach (explode("\n", $this->getOutput()) as $line) { + if ($this->containsDeprecatedError($line)) { + return $line; + } + } + + throw new RunTimeException("The output '{$this->getOutput()}' does not contain Parse or Syntax errors"); + } + + return false; + } + + /** + * @return bool + * @throws RunTimeException + */ + public function isFail() + { + return defined('PHP_WINDOWS_VERSION_MAJOR') ? $this->getStatusCode() === 1 : parent::isFail(); + } + + /** + * @return bool + * @throws RunTimeException + */ + public function isSuccess() + { + return $this->getStatusCode() === 0; + } + + /** + * @param string $string + * @return bool + */ + private function containsParserError($string) + { + return strpos($string, self::PARSE_ERROR) !== false; + } + + /** + * @param string $string + * @return bool + */ + private function containsFatalError($string) + { + return strpos($string, self::FATAL_ERROR) !== false; + } + + /** + * @param string $string + * @return bool + */ + private function containsDeprecatedError($string) + { + if ($this->showDeprecatedErrors === false) { + return false; + } + + return strpos($string, self::DEPRECATED_ERROR) !== false; + } +} diff --git a/contrib/parallel-lint/src/Process/PhpExecutable.php b/contrib/parallel-lint/src/Process/PhpExecutable.php new file mode 100644 index 000000000..001b1e9cc --- /dev/null +++ b/contrib/parallel-lint/src/Process/PhpExecutable.php @@ -0,0 +1,128 @@ +path = $path; + $this->versionId = $versionId; + $this->hhvmVersion = $hhvmVersion; + $this->isHhvmType = $isHhvmType; + } + + /** + * @return string + */ + public function getHhvmVersion() + { + return $this->hhvmVersion; + } + + /** + * @return boolean + */ + public function isIsHhvmType() + { + return $this->isHhvmType; + } + + /** + * @return string + */ + public function getPath() + { + return $this->path; + } + + /** + * @return int + */ + public function getVersionId() + { + return $this->versionId; + } + + /** + * @param string $phpExecutable + * @return PhpExecutable + * @throws \Exception + */ + public static function getPhpExecutable($phpExecutable) + { + $codeToExecute = <<waitForFinish(); + + try { + if ($process->getStatusCode() !== 0 && $process->getStatusCode() !== 255) { + throw new RunTimeException("Unable to execute '{$phpExecutable}'."); + } + + return self::getPhpExecutableFromOutput($phpExecutable, $process->getOutput()); + + } catch (RunTimeException $e) { + // Try HHVM type + $process = new Process($phpExecutable, array('--php', '-r', $codeToExecute)); + $process->waitForFinish(); + + if ($process->getStatusCode() !== 0 && $process->getStatusCode() !== 255) { + throw new RunTimeException("Unable to execute '{$phpExecutable}'."); + } + + return self::getPhpExecutableFromOutput($phpExecutable, $process->getOutput(), $isHhvmType = true); + } + } + + /** + * @param string $phpExecutable + * @param string $output + * @param bool $isHhvmType + * @return PhpExecutable + * @throws RunTimeException + */ + private static function getPhpExecutableFromOutput($phpExecutable, $output, $isHhvmType = false) + { + $parts = explode(';', $output); + + if ($parts[0] !== 'PHP' || !preg_match('~([0-9]+)~', $parts[1], $matches)) { + throw new RunTimeException("'{$phpExecutable}' is not valid PHP binary."); + } + + $hhvmVersion = isset($parts[2]) ? $parts[2] : false; + + return new PhpExecutable( + $phpExecutable, + intval($matches[1]), + $hhvmVersion, + $isHhvmType + ); + } +} diff --git a/contrib/parallel-lint/src/Process/PhpProcess.php b/contrib/parallel-lint/src/Process/PhpProcess.php new file mode 100644 index 000000000..0df293a18 --- /dev/null +++ b/contrib/parallel-lint/src/Process/PhpProcess.php @@ -0,0 +1,35 @@ +constructParameters($parameters, $phpExecutable->isIsHhvmType()); + parent::__construct($phpExecutable->getPath(), $constructedParameters, $stdIn); + } + + /** + * @param array $parameters + * @param bool $isHhvm + * @return array + */ + private function constructParameters(array $parameters, $isHhvm) + { + // Always ignore PHP startup errors ("Unable to load library...") in sub-processes. + array_unshift($parameters, '-d display_startup_errors=0'); + + if ($isHhvm) { + array_unshift($parameters, '-php'); + } + + return $parameters; + } +} diff --git a/contrib/parallel-lint/src/Process/Process.php b/contrib/parallel-lint/src/Process/Process.php new file mode 100644 index 000000000..190511eb0 --- /dev/null +++ b/contrib/parallel-lint/src/Process/Process.php @@ -0,0 +1,153 @@ + array('pipe', self::READ), + self::STDOUT => array('pipe', self::WRITE), + self::STDERR => array('pipe', self::WRITE), + ); + + $cmdLine = $executable . ' ' . implode(' ', array_map('escapeshellarg', $arguments)); + $this->process = proc_open($cmdLine, $descriptors, $pipes, null, null, array('bypass_shell' => true)); + + if ($this->process === false || $this->process === null) { + throw new RunTimeException("Cannot create new process $cmdLine"); + } + + list($stdin, $this->stdout, $this->stderr) = $pipes; + + if ($stdInInput) { + fwrite($stdin, $stdInInput); + } + + fclose($stdin); + } + + /** + * @return bool + */ + public function isFinished() + { + if ($this->statusCode !== null) { + return true; + } + + $status = proc_get_status($this->process); + + if ($status['running']) { + return false; + } else if ($this->statusCode === null) { + $this->statusCode = (int) $status['exitcode']; + } + + // Process outputs + $this->output = stream_get_contents($this->stdout); + fclose($this->stdout); + + $this->errorOutput = stream_get_contents($this->stderr); + fclose($this->stderr); + + $statusCode = proc_close($this->process); + + if ($this->statusCode === null) { + $this->statusCode = $statusCode; + } + + $this->process = null; + + return true; + } + + public function waitForFinish() + { + while (!$this->isFinished()) { + usleep(100); + } + } + + /** + * @return string + * @throws RunTimeException + */ + public function getOutput() + { + if (!$this->isFinished()) { + throw new RunTimeException("Cannot get output for running process"); + } + + return $this->output; + } + + /** + * @return string + * @throws RunTimeException + */ + public function getErrorOutput() + { + if (!$this->isFinished()) { + throw new RunTimeException("Cannot get error output for running process"); + } + + return $this->errorOutput; + } + + /** + * @return int + * @throws RunTimeException + */ + public function getStatusCode() + { + if (!$this->isFinished()) { + throw new RunTimeException("Cannot get status code for running process"); + } + + return $this->statusCode; + } + + /** + * @return bool + * @throws RunTimeException + */ + public function isFail() + { + return $this->getStatusCode() === 1; + } +} diff --git a/contrib/parallel-lint/src/Process/SkipLintProcess.php b/contrib/parallel-lint/src/Process/SkipLintProcess.php new file mode 100644 index 000000000..52f4e7656 --- /dev/null +++ b/contrib/parallel-lint/src/Process/SkipLintProcess.php @@ -0,0 +1,91 @@ +isFinished()) { + $this->processLines(fread($this->stdout, 8192)); + } + } + + /** + * @return bool + * @throws \JakubOnderka\PhpParallelLint\RunTimeException + */ + public function isFinished() + { + $isFinished = parent::isFinished(); + if ($isFinished && !$this->done) { + $this->done = true; + $output = $this->getOutput(); + $this->processLines($output); + } + + return $isFinished; + } + + /** + * @param string $file + * @return bool|null + */ + public function isSkipped($file) + { + if (isset($this->skipped[$file])) { + return $this->skipped[$file]; + } + + return null; + } + + /** + * @param string $content + */ + private function processLines($content) + { + if (!empty($content)) { + $lines = explode(PHP_EOL, $this->endLastChunk . $content); + $this->endLastChunk = array_pop($lines); + foreach ($lines as $line) { + $parts = explode(';', $line); + list($file, $status) = $parts; + $this->skipped[$file] = $status === '1' ? true : false; + } + } + } +} diff --git a/contrib/parallel-lint/src/Result.php b/contrib/parallel-lint/src/Result.php new file mode 100644 index 000000000..9f6a36898 --- /dev/null +++ b/contrib/parallel-lint/src/Result.php @@ -0,0 +1,171 @@ +errors = $errors; + $this->checkedFiles = $checkedFiles; + $this->skippedFiles = $skippedFiles; + $this->testTime = $testTime; + } + + /** + * @return Error[] + */ + public function getErrors() + { + return $this->errors; + } + + /** + * @return bool + */ + public function hasError() + { + return !empty($this->errors); + } + + /** + * @return array + */ + public function getFilesWithFail() + { + $filesWithFail = array(); + foreach ($this->errors as $error) { + if (!$error instanceof SyntaxError) { + $filesWithFail[] = $error->getFilePath(); + } + } + + return $filesWithFail; + } + + /** + * @return int + */ + public function getFilesWithFailCount() + { + return count($this->getFilesWithFail()); + } + + /** + * @return bool + */ + public function hasFilesWithFail() + { + return $this->getFilesWithFailCount() !== 0; + } + + /** + * @return array + */ + public function getCheckedFiles() + { + return $this->checkedFiles; + } + + /** + * @return int + */ + public function getCheckedFilesCount() + { + return count($this->checkedFiles); + } + + /** + * @return array + */ + public function getSkippedFiles() + { + return $this->skippedFiles; + } + + /** + * @return int + */ + public function getSkippedFilesCount() + { + return count($this->skippedFiles); + } + + /** + * @return array + */ + public function getFilesWithSyntaxError() + { + $filesWithSyntaxError = array(); + foreach ($this->errors as $error) { + if ($error instanceof SyntaxError) { + $filesWithSyntaxError[] = $error->getFilePath(); + } + } + + return $filesWithSyntaxError; + } + + /** + * @return int + */ + public function getFilesWithSyntaxErrorCount() + { + return count($this->getFilesWithSyntaxError()); + } + + /** + * @return bool + */ + public function hasSyntaxError() + { + return $this->getFilesWithSyntaxErrorCount() !== 0; + } + + /** + * @return float + */ + public function getTestTime() + { + return $this->testTime; + } + + /** + * (PHP 5 >= 5.4.0)
+ * Specify data which should be serialized to JSON + * @link http://php.net/manual/en/jsonserializable.jsonserialize.php + * @return mixed data which can be serialized by json_encode, + * which is a value of any type other than a resource. + */ + #[ReturnTypeWillChange] + function jsonSerialize() + { + return array( + 'checkedFiles' => $this->getCheckedFiles(), + 'filesWithSyntaxError' => $this->getFilesWithSyntaxError(), + 'skippedFiles' => $this->getSkippedFiles(), + 'errors' => $this->getErrors(), + ); + } + + +} \ No newline at end of file diff --git a/contrib/parallel-lint/src/Settings.php b/contrib/parallel-lint/src/Settings.php new file mode 100644 index 000000000..40cb8b179 --- /dev/null +++ b/contrib/parallel-lint/src/Settings.php @@ -0,0 +1,247 @@ + tags. + * @var bool + */ + public $aspTags = false; + + /** + * Number of jobs running in same time + * @var int + */ + public $parallelJobs = 10; + + /** + * If path contains directory, only file with these extensions are checked + * @var array + */ + public $extensions = array('php', 'phtml', 'php3', 'php4', 'php5', 'phpt'); + + /** + * Array of file or directories to check + * @var array + */ + public $paths = array(); + + /** + * Don't check files or directories + * @var array + */ + public $excluded = array(); + + /** + * Mode for color detection. Possible values: self::FORCED, self::DISABLED and self::AUTODETECT + * @var string + */ + public $colors = self::AUTODETECT; + + /** + * Show progress in text output + * @var bool + */ + public $showProgress = true; + + /** + * Output format (see FORMAT_* constants) + * @var string + */ + public $format = self::FORMAT_TEXT; + + /** + * Read files and folder to tests from standard input (blocking) + * @var bool + */ + public $stdin = false; + + /** + * Try to show git blame for row with error + * @var bool + */ + public $blame = false; + + /** + * Path to git executable for blame + * @var string + */ + public $gitExecutable = 'git'; + + /** + * @var bool + */ + public $ignoreFails = false; + + /** + * @var bool + */ + public $showDeprecated = false; + + /** + * Path to a file with syntax error callback + * @var string|null + */ + public $syntaxErrorCallbackFile = null; + + /** + * @param array $paths + */ + public function addPaths(array $paths) + { + $this->paths = array_merge($this->paths, $paths); + } + + /** + * @param array $arguments + * @return Settings + * @throws InvalidArgumentException + */ + public static function parseArguments(array $arguments) + { + $arguments = new ArrayIterator(array_slice($arguments, 1)); + $settings = new self; + + // Use the currently invoked php as the default if possible + if (defined('PHP_BINARY')) { + $settings->phpExecutable = PHP_BINARY; + } + + foreach ($arguments as $argument) { + if ($argument[0] !== '-') { + $settings->paths[] = $argument; + } else { + switch ($argument) { + case '-p': + $settings->phpExecutable = $arguments->getNext(); + break; + + case '-s': + case '--short': + $settings->shortTag = true; + break; + + case '-a': + case '--asp': + $settings->aspTags = true; + break; + + case '--exclude': + $settings->excluded[] = $arguments->getNext(); + break; + + case '-e': + $settings->extensions = array_map('trim', explode(',', $arguments->getNext())); + break; + + case '-j': + $settings->parallelJobs = max((int) $arguments->getNext(), 1); + break; + + case '--colors': + $settings->colors = self::FORCED; + break; + + case '--no-colors': + $settings->colors = self::DISABLED; + break; + + case '--no-progress': + $settings->showProgress = false; + break; + + case '--json': + $settings->format = self::FORMAT_JSON; + break; + + case '--gitlab': + $settings->format = self::FORMAT_GITLAB; + break; + + case '--checkstyle': + $settings->format = self::FORMAT_CHECKSTYLE; + break; + + case '--git': + $settings->gitExecutable = $arguments->getNext(); + break; + + case '--stdin': + $settings->stdin = true; + break; + + case '--blame': + $settings->blame = true; + break; + + case '--ignore-fails': + $settings->ignoreFails = true; + break; + + case '--show-deprecated': + $settings->showDeprecated = true; + break; + + case '--syntax-error-callback': + $settings->syntaxErrorCallbackFile = $arguments->getNext(); + break; + + default: + throw new InvalidArgumentException($argument); + } + } + } + + return $settings; + } + + /** + * @return array + */ + public static function getPathsFromStdIn() + { + $content = stream_get_contents(STDIN); + + if (empty($content)) { + return array(); + } + + $lines = explode("\n", rtrim($content)); + return array_map('rtrim', $lines); + } +} + +class ArrayIterator extends \ArrayIterator +{ + public function getNext() + { + $this->next(); + return $this->current(); + } +} diff --git a/contrib/parallel-lint/src/exceptions.php b/contrib/parallel-lint/src/exceptions.php new file mode 100644 index 000000000..b96f28af2 --- /dev/null +++ b/contrib/parallel-lint/src/exceptions.php @@ -0,0 +1,93 @@ + get_class($this), + 'message' => $this->getMessage(), + 'code' => $this->getCode(), + ); + } +} + +class RunTimeException extends Exception +{ + +} + +class InvalidArgumentException extends Exception +{ + protected $argument; + + public function __construct($argument) + { + $this->argument = $argument; + $this->message = "Invalid argument $argument"; + } + + public function getArgument() + { + return $this->argument; + } +} + +class NotExistsPathException extends Exception +{ + protected $path; + + public function __construct($path) + { + $this->path = $path; + $this->message = "Path '$path' not found"; + } + + public function getPath() + { + return $this->path; + } +} + +class NotExistsClassException extends Exception +{ + protected $className; + protected $fileName; + + public function __construct($className, $fileName) + { + $this->className = $className; + $this->fileName = $fileName; + $this->message = "Class with name '$className' does not exists in file '$fileName'"; + } + + public function getClassName() + { + return $this->className; + } + + public function getFileName() + { + return $this->fileName; + } +} + +class NotImplementCallbackException extends Exception +{ + protected $className; + + public function __construct($className) + { + $this->className = $className; + $this->message = "Class '$className' does not implement SyntaxErrorCallback interface."; + } + + public function getClassName() + { + return $this->className; + } +} diff --git a/contrib/parallel-lint/src/polyfill.php b/contrib/parallel-lint/src/polyfill.php new file mode 100644 index 000000000..79b3049bc --- /dev/null +++ b/contrib/parallel-lint/src/polyfill.php @@ -0,0 +1,15 @@ +