├── docs ├── images │ ├── ec-2.0.png │ ├── ec-demo.gif │ └── editorconfig-logo.png ├── CustomFinderInstance.md ├── Contribute.md └── Versions.md ├── src ├── EditorConfig │ ├── Rules │ │ ├── UnfixableException.php │ │ ├── RuleInterface.php │ │ ├── FileUnavailableException.php │ │ ├── RuleError.php │ │ ├── File │ │ │ ├── InsertFinalNewLineRule.php │ │ │ ├── CharsetRule.php │ │ │ ├── TrimTrailingWhitespaceRule.php │ │ │ └── EndOfLineRule.php │ │ ├── Line │ │ │ ├── TrimTrailingWhitespaceRule.php │ │ │ ├── MaxLineLengthRule.php │ │ │ └── IndentionRule.php │ │ ├── Rule.php │ │ ├── FileResult.php │ │ └── Validator.php │ ├── Utility │ │ ├── VersionUtility.php │ │ ├── StringFormatUtility.php │ │ ├── ArrayUtility.php │ │ ├── LineEndingUtility.php │ │ ├── FileEncodingUtility.php │ │ ├── TimeTrackingUtility.php │ │ ├── MimeTypeUtility.php │ │ └── FinderUtility.php │ └── Scanner.php └── Application.php ├── bin ├── ec └── bootstrap.php ├── LICENSE ├── composer.json └── README.md /docs/images/ec-2.0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-r-m-i-n/editorconfig-cli/HEAD/docs/images/ec-2.0.png -------------------------------------------------------------------------------- /docs/images/ec-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-r-m-i-n/editorconfig-cli/HEAD/docs/images/ec-demo.gif -------------------------------------------------------------------------------- /docs/images/editorconfig-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/a-r-m-i-n/editorconfig-cli/HEAD/docs/images/editorconfig-logo.png -------------------------------------------------------------------------------- /src/EditorConfig/Rules/UnfixableException.php: -------------------------------------------------------------------------------- 1 | run(); 18 | -------------------------------------------------------------------------------- /src/EditorConfig/Utility/VersionUtility.php: -------------------------------------------------------------------------------- 1 | unavailableFile; 16 | } 17 | 18 | public function setUnavailableFile(SplFileInfo $unavailableFile): self 19 | { 20 | $this->unavailableFile = $unavailableFile; 21 | 22 | return $this; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/EditorConfig/Rules/RuleError.php: -------------------------------------------------------------------------------- 1 | line ?? 0; 18 | } 19 | 20 | public function getMessage(): string 21 | { 22 | return $this->message; 23 | } 24 | 25 | public function __toString(): string 26 | { 27 | $errorString = ''; 28 | if (!empty($this->getLine())) { 29 | $errorString = 'Line ' . $this->getLine() . ': '; 30 | } 31 | $errorString .= $this->getMessage(); 32 | 33 | return $errorString; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/EditorConfig/Utility/StringFormatUtility.php: -------------------------------------------------------------------------------- 1 | 9 | * Jordi Boggiano 10 | * 11 | * For the full copyright and license information, please view the LICENSE 12 | * file that was distributed with this source code. 13 | */ 14 | function includeIfExists($file) 15 | { 16 | return file_exists($file) ? include $file : false; 17 | } 18 | 19 | if ((!$loader = includeIfExists(__DIR__ . '/../vendor/autoload.php')) && 20 | (!$loader = includeIfExists(__DIR__ . '/../../../autoload.php')) && 21 | (!$loader = includeIfExists(__DIR__ . '/../../../vendor/autoload.php')) 22 | ) { 23 | echo 'You must set up the project dependencies using `composer install`' . PHP_EOL . 24 | 'See https://getcomposer.org/download/ for instructions on installing Composer' . PHP_EOL; 25 | exit(1); 26 | } 27 | return $loader; 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2021-2025 Armin Vieweg 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /src/EditorConfig/Utility/ArrayUtility.php: -------------------------------------------------------------------------------- 1 | |null $arguments Strings in array may contain separated values 14 | * @param non-empty-string $separator 15 | * 16 | * @return string[] 17 | */ 18 | public static function flattenSeparatedValues(?array $arguments, string $separator = ','): array 19 | { 20 | if (!$arguments) { 21 | return []; 22 | } 23 | 24 | $flattenArguments = []; 25 | foreach ($arguments as $argument) { 26 | foreach (explode($separator, $argument ?? '') as $flatArgument) { 27 | $flatArgument = trim($flatArgument); 28 | if (!empty($flatArgument)) { 29 | $flattenArguments[] = $flatArgument; 30 | } 31 | } 32 | } 33 | 34 | return $flattenArguments; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/EditorConfig/Rules/File/InsertFinalNewLineRule.php: -------------------------------------------------------------------------------- 1 | newLineFormat = $newLineFormat ?? "\n"; 21 | parent::__construct($filePath, $fileContent); 22 | } 23 | 24 | protected function validate(string $content): void 25 | { 26 | if ('' === $content) { 27 | return; 28 | } 29 | 30 | $lastChar = substr($content, -1); 31 | if (!in_array($lastChar, ["\r", "\n"], true)) { 32 | $this->addError(null, 'This file has no final new line given'); 33 | } 34 | } 35 | 36 | public function fixContent(string $content): string 37 | { 38 | return rtrim($content) . $this->newLineFormat; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/EditorConfig/Utility/LineEndingUtility.php: -------------------------------------------------------------------------------- 1 | "\r\n", 14 | 'cr' => "\r", 15 | 'lf' => "\n", 16 | ]; 17 | 18 | public static function detectLineEnding(string $content, bool $humanReadable = true): ?string 19 | { 20 | $whitespacesOnly = preg_replace('/[^\r\n]/i', '', $content); 21 | $actualEndOfLine = substr((string)$whitespacesOnly, 0, 2); 22 | if (!empty($actualEndOfLine) && "\r\n" !== $actualEndOfLine) { 23 | $actualEndOfLine = $actualEndOfLine[0]; // first char only 24 | } 25 | 26 | return $humanReadable ? self::convertActualCharToReadable($actualEndOfLine) : $actualEndOfLine; 27 | } 28 | 29 | public static function convertReadableToActualChars(string $lineEnding): ?string 30 | { 31 | return self::$lineEndings[strtolower($lineEnding)] ?? null; 32 | } 33 | 34 | public static function convertActualCharToReadable(string $actualChar): ?string 35 | { 36 | $chars = array_flip(self::$lineEndings); 37 | 38 | return $chars[$actualChar] ? (string)$chars[$actualChar] : null; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/EditorConfig/Utility/FileEncodingUtility.php: -------------------------------------------------------------------------------- 1 | expectedEncoding = strtolower($expectedEncoding); 18 | parent::__construct($filePath, $fileContent); 19 | } 20 | 21 | protected function validate(string $content): void 22 | { 23 | $actualEncoding = FileEncodingUtility::detect($content); 24 | if ($this->expectedEncoding !== $actualEncoding) { 25 | $this->addError( 26 | null, 27 | 'This file has invalid encoding given! Expected: "%s", Given: "%s"', 28 | $this->expectedEncoding, 29 | $actualEncoding 30 | ); 31 | } 32 | } 33 | 34 | /** 35 | * @throws UnfixableException 36 | */ 37 | public function fixContent(string $content): string 38 | { 39 | throw new UnfixableException(sprintf('Automatic fix of wrong charset is not possible for file "%s"', $this->getFilePath()), 1620996364); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/EditorConfig/Rules/Line/TrimTrailingWhitespaceRule.php: -------------------------------------------------------------------------------- 1 | addError($lineCount, 'Trailing whitespaces found'); 28 | } 29 | } 30 | } 31 | 32 | public function fixContent(string $content): string 33 | { 34 | $lineEnding = LineEndingUtility::detectLineEnding($content, false) ?: "\n"; 35 | /** @var array|string[] $lines */ 36 | $lines = explode($lineEnding, $content); 37 | foreach ($lines as $no => $line) { 38 | $updatedLine = rtrim($line); 39 | $lines[$no] = $updatedLine; 40 | } 41 | 42 | return implode($lineEnding, $lines); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/EditorConfig/Rules/File/TrimTrailingWhitespaceRule.php: -------------------------------------------------------------------------------- 1 | insertFinalNewLine) { 27 | $insertFinalNewLineRule = new InsertFinalNewLineRule($this->filePath, $content); 28 | if ($insertFinalNewLineRule->isValid()) { 29 | $trim .= LineEndingUtility::detectLineEnding($content, false) ?: "\n"; 30 | } 31 | } 32 | if ($content !== $trim) { 33 | $this->addError(null, 'This file has trailing whitespaces'); 34 | } 35 | } 36 | 37 | public function fixContent(string $content): string 38 | { 39 | $trim = rtrim($content); 40 | if ($this->insertFinalNewLine) { 41 | $trim .= LineEndingUtility::detectLineEnding($content, false) ?: "\n"; 42 | } 43 | 44 | return $trim; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/EditorConfig/Rules/Line/MaxLineLengthRule.php: -------------------------------------------------------------------------------- 1 | $this->maxLineLength) { 35 | $this->addError($lineCount, 'Max line length (%d chars) exceeded by %d chars', $this->maxLineLength, $lineLength); 36 | } 37 | } 38 | } 39 | 40 | public function fixContent(string $content): string 41 | { 42 | throw new UnfixableException(sprintf('Automatic fix of exceeded max line length is not possible for file "%s"', $this->getFilePath()), 1620998670); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/EditorConfig/Rules/File/EndOfLineRule.php: -------------------------------------------------------------------------------- 1 | endOfLine = strtolower($endOfLine); 18 | $this->expectedEndOfLine = LineEndingUtility::convertReadableToActualChars($this->endOfLine) ?? ''; 19 | 20 | parent::__construct($filePath, $fileContent); 21 | } 22 | 23 | public function getEndOfLine(): string 24 | { 25 | return $this->expectedEndOfLine; 26 | } 27 | 28 | protected function validate(string $content): void 29 | { 30 | $whitespacesOnly = (string)preg_replace('/[^\r\n]/i', '', $content); 31 | 32 | $actualEndOfLine = substr($whitespacesOnly, 0, 2); 33 | if (!empty($actualEndOfLine) && "\r\n" !== $actualEndOfLine) { 34 | $actualEndOfLine = $actualEndOfLine[0]; // first char only 35 | } 36 | $result = $this->expectedEndOfLine === $actualEndOfLine || empty($actualEndOfLine); 37 | if (!$result) { 38 | $this->addError( 39 | null, 40 | 'This file has line ending "%s" given, but "%s" is expected', 41 | LineEndingUtility::convertActualCharToReadable($actualEndOfLine), 42 | $this->endOfLine 43 | ); 44 | } 45 | } 46 | 47 | public function fixContent(string $content): string 48 | { 49 | return str_replace(["\r\n", "\r", "\n"], $this->expectedEndOfLine, $content); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/EditorConfig/Utility/TimeTrackingUtility.php: -------------------------------------------------------------------------------- 1 | self::getMicrotime(), 23 | 'message' => $message, 24 | ]; 25 | } 26 | 27 | public static function getDuration(): float 28 | { 29 | $start = reset(self::$recordedSteps); 30 | $end = end(self::$recordedSteps); 31 | 32 | if (!is_array($start) || !is_array($end)) { 33 | return 0.0; 34 | } 35 | 36 | $start = $start['time']; 37 | $end = $end['time']; 38 | 39 | return round(($end - $start), 3); 40 | } 41 | 42 | /** 43 | * @return array 44 | */ 45 | public static function getRecordedSteps(): array 46 | { 47 | $output = []; 48 | $lastTime = null; 49 | $prevStep = null; 50 | foreach (self::$recordedSteps as $step) { 51 | $time = $step['time'] - ($lastTime ?? $step['time']); 52 | $lastTime = $lastTime ?? $step['time']; 53 | $formattedTime = '' . str_pad((string)round($time, 3), 6, ' ', STR_PAD_LEFT) . 's'; 54 | $formattedTime = str_replace('000000s', '0 ', $formattedTime); 55 | 56 | $formattedDiff = ''; 57 | if ($prevStep && $step['message']) { 58 | $diff = round(($step['time'] - $prevStep['time']) * 1000); 59 | $formattedDiff = ' (' . $diff . 'ms)'; 60 | } 61 | 62 | $output[] = $formattedTime . ' ' . $step['message'] . $formattedDiff; 63 | $prevStep = $step; 64 | } 65 | 66 | return $output; 67 | } 68 | 69 | private static function getMicrotime(): float 70 | { 71 | [$usec, $sec] = explode(' ', microtime()); 72 | 73 | return (float)$usec + (float)$sec; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /docs/CustomFinderInstance.md: -------------------------------------------------------------------------------- 1 | # Custom Finder Instance 2 | 3 | Since version 1.3, the EditorConfigCLI binary allows you to define a specific PHP file, 4 | providing your own Symfony Finder instance, which is used to identify the files to be processed. 5 | 6 | ## CLI Call 7 | 8 | To define the PHP file, you can use the ``--finder-config`` option and pass the relative path to the 9 | config file. You are free to choose the location and filename, here in this example it is located in 10 | the project's root directory and is named **ec-cli-config.php**: 11 | 12 | ``` 13 | $ bin/ec --finder-config ec-cli-config.php 14 | ``` 15 | 16 | **Important:** When this option is set, the following CLI options will not have any impact 17 | anymore: 18 | 19 | - ``-d, --dir[=DIR]`` 20 | - ``-a, --disable-auto-exclude`` 21 | - ``-e, --exclude[=EXCLUDE]`` 22 | - ``-g, --git-only`` 23 | 24 | ## Finder config file 25 | 26 | Within the config file, you need to create and configure a Finder instance. **The following aspects are important:** 27 | 28 | - EditorConfigCLI expects as return value a ``Symfony\Component\Finder\Finder`` instance. 29 | Anything else will cause an exception. 30 | 31 | - The finder instance requires one option to be set: 32 | ``` 33 | $finder->in('/path'); 34 | ``` 35 | - Also, the rules require ``->files()`` to be set, which happens automatically in EditorConfigCLI, after 36 | the custom Finder instance as been successfully imported. 37 | 38 | 39 | 40 | ### Minimum example 41 | 42 | ```php 43 | in($GLOBALS['finderOptions']['path']); 50 | 51 | return $finder; 52 | ``` 53 | 54 | When you provide your own Finder config file, the following CLI arguments and options have no effect anymore, 55 | unless you've implemented them: 56 | 57 | - ``-d, --dir[=DIR]`` (default: current working directory) 58 | - ``-a, --disable-auto-exclude`` 59 | - ``-e, --exclude[=EXCLUDE]`` 60 | - the ``names`` argument (default: ``['*']``) 61 | 62 | **All those values get passed in an array, you can access with:** 63 | 64 | ```php 65 | '/real/path/to/dir', 69 | // 'names' => ['*'], 70 | // 'exclude' => [], 71 | // 'disable-auto-exclude' => false, 72 | // ]; 73 | ``` 74 | -------------------------------------------------------------------------------- /src/EditorConfig/Rules/Rule.php: -------------------------------------------------------------------------------- 1 | validate($fileContent ?? (string)file_get_contents($this->filePath)); 45 | } 46 | 47 | abstract protected function validate(string $content): void; 48 | 49 | public function getFilePath(): string 50 | { 51 | return $this->filePath; 52 | } 53 | 54 | /** 55 | * @return RuleError[] 56 | */ 57 | public function getErrors(): array 58 | { 59 | return $this->errors; 60 | } 61 | 62 | /** 63 | * Only used in UnitTests. 64 | * 65 | * @codeCoverageIgnore 66 | */ 67 | public function getErrorsAsText(): string 68 | { 69 | $errors = []; 70 | foreach ($this->errors as $error) { 71 | $errors[] = (string)$error; 72 | } 73 | 74 | return implode("\n", $errors); 75 | } 76 | 77 | /** 78 | * @param int|string|null ...$arguments 79 | */ 80 | public function addError(?int $line, string $message, ...$arguments): void 81 | { 82 | $this->errors[] = new RuleError(vsprintf($message, $arguments), $line); 83 | } 84 | 85 | public function isValid(): bool 86 | { 87 | return empty($this->errors); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /docs/Contribute.md: -------------------------------------------------------------------------------- 1 | # Contribute 2 | 3 | Any contributions to EditorConfigCLI are very welcome! No matter if you report issues or contribute code. 4 | 5 | If you want to provide some code, here are some hints for you. 6 | 7 | ## Tools 8 | 9 | To ensure proper code styles, etc. you can perform the Composer script **all** before submitting your 10 | merge request. 11 | 12 | ``` 13 | $ ddev composer run all 14 | ``` 15 | 16 | First, it fixes all issues, which can get fixed automatically. Then, it performs an additional check, 17 | which includes phpstan (level 8) and performs unit- and functional tests (without coverage). 18 | 19 | As last step, it will compile the phar binary, locally. 20 | 21 | 22 | ### Code quality tools 23 | 24 | [![Code Checks](https://github.com/a-r-m-i-n/editorconfig-cli/actions/workflows/code-checks.yml/badge.svg)](https://github.com/a-r-m-i-n/editorconfig-cli/actions/workflows/code-checks.yml) 25 | 26 | ``` 27 | $ ddev composer run check 28 | $ ddev composer run fix 29 | $ ddev composer run test 30 | ``` 31 | 32 | ### Testing 33 | 34 | ``` 35 | $ ddev composer run test 36 | $ ddev composer run test-with-coverage 37 | 38 | $ ddev composer run test-php-unit 39 | $ ddev composer run test-php-functional 40 | ``` 41 | Note: Xdebug must be available (``ddev xdebug on``) when testing with code coverage enabled. 42 | 43 | The results will be located here: 44 | 45 | - [Text Report for Unit Tests](../.build/reports/phpunit-unit-results.txt) 46 | - [Text Report for Functional Tests](../.build/reports/phpunit-functional-results.txt) 47 | - [HTML Coverage Report for Unit Tests](../.build/reports/coverage-unit/index.html) 48 | - [HTML Coverage Report for Functional Tests](../.build/reports/coverage-functional/index.html) 49 | 50 | 51 | ### Compiling phar binary 52 | 53 | ``` 54 | $ ddev composer run compile 55 | ``` 56 | 57 | Note: In php.ini the option ``phar.readonly`` must be set to ``0``. 58 | 59 | 60 | ## Automation 61 | 62 | ### Code styles 63 | 64 | When you provide a merge request, Github actions will check your code, using the "check" and "test-with-coverage" 65 | Composer script. 66 | 67 | Also, each build will run on the following combinations of PHP version and Composer dependencies flag: 68 | 69 | - PHP 8.2, Lowest 70 | - PHP 8.2, Highest 71 | - PHP 8.3, Lowest 72 | - PHP 8.3, Highest 73 | - PHP 8.4, Lowest 74 | - PHP 8.4, Highest 75 | 76 | *Note:* "Highest" is the default behaviour of Composer. 77 | "Lowest" is when you run Composer update with ``--prefer-lowest`` 78 | 79 | 80 | **A build may fail when:** 81 | 82 | - EditorConfigCli found issues 83 | - PhpStyleFixer found issues 84 | - PhpStan found issues 85 | - A unit test failed 86 | - A functional test failed 87 | 88 | The Github action will provide artifacts for each build, containing the tests results in various formats. 89 | 90 | 91 | ### Release 92 | 93 | When a new tag is pushed, Github actions will automatically create a release for it. 94 | 95 | This includes compiling the PHAR binary and attaching it, to the release. 96 | Also, the commit messages since last release, will be added. 97 | -------------------------------------------------------------------------------- /src/EditorConfig/Rules/FileResult.php: -------------------------------------------------------------------------------- 1 | rules); 27 | } 28 | 29 | public function isBinary(): bool 30 | { 31 | return $this->isBinary; 32 | } 33 | 34 | public function isValid(): bool 35 | { 36 | foreach ($this->rules as $rule) { 37 | if (!$rule->isValid()) { 38 | return false; 39 | } 40 | } 41 | 42 | return true; 43 | } 44 | 45 | public function getFilePath(): string 46 | { 47 | return $this->filePath; 48 | } 49 | 50 | /** 51 | * @return array|RuleError[] 52 | */ 53 | public function getErrors(): array 54 | { 55 | $errors = []; 56 | /** @var Rule $rule */ 57 | foreach ($this->rules as $rule) { 58 | array_push($errors, ...$rule->getErrors()); 59 | } 60 | 61 | uasort($errors, static fn (RuleError $a, RuleError $b): int => $a->getLine() > $b->getLine() ? 1 : -1); 62 | 63 | return $errors; 64 | } 65 | 66 | public function countErrors(): int 67 | { 68 | return count($this->getErrors()); 69 | } 70 | 71 | public function getErrorsAsString(): string 72 | { 73 | $errors = []; 74 | foreach ($this->getErrors() as $error) { 75 | $errors[] = (string)$error; 76 | } 77 | 78 | return trim(implode("\n", $errors)); 79 | } 80 | 81 | public function applyFixes(): void 82 | { 83 | $content = (string)file_get_contents($this->getFilePath()); 84 | foreach ($this->rules as $rule) { 85 | if (!$rule->isValid()) { 86 | try { 87 | $content = $rule->fixContent($content); 88 | } catch (UnfixableException $e) { 89 | $this->unfixableExceptions[] = $e; 90 | } 91 | } 92 | } 93 | $status = file_put_contents($this->getFilePath(), $content); 94 | if (!$status) { 95 | // @codeCoverageIgnoreStart 96 | throw new \RuntimeException(sprintf('Unable to update file "%s"!', $this->getFilePath())); 97 | // @codeCoverageIgnoreEnd 98 | } 99 | } 100 | 101 | public function hasUnfixableExceptions(): bool 102 | { 103 | return count($this->getUnfixableExceptions()) > 0; 104 | } 105 | 106 | /** 107 | * @return UnfixableException[] 108 | */ 109 | public function getUnfixableExceptions(): array 110 | { 111 | return $this->unfixableExceptions; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "armin/editorconfig-cli", 3 | "description": "EditorConfigCLI is a free CLI tool (written in PHP) to validate and auto-fix text files based on given .editorconfig declarations.", 4 | "type": "library", 5 | "version": "2.2.1", 6 | "license": "MIT", 7 | "authors": [ 8 | { 9 | "name": "Armin Vieweg", 10 | "email": "info@v.ieweg.de", 11 | "homepage": "https://v.ieweg.de" 12 | } 13 | ], 14 | "homepage": "https://github.com/a-r-m-i-n/editorconfig-cli", 15 | "support": { 16 | "issues": "https://github.com/a-r-m-i-n/editorconfig-cli/issues", 17 | "source": "https://github.com/a-r-m-i-n/editorconfig-cli" 18 | }, 19 | "require": { 20 | "php": "^8.2", 21 | "ext-json": "*", 22 | "ext-iconv": "*", 23 | "symfony/console": "^5 || ^6 || ^7 || ^8", 24 | "symfony/finder": "^5 || ^6 || ^7 || ^8", 25 | "symfony/mime": "^5 || ^6 || ^7 || ^8", 26 | "idiosyncratic/editorconfig": "^0.1.1" 27 | }, 28 | "require-dev": { 29 | "seld/phar-utils": "^1.2.1", 30 | "phpstan/phpstan": "^2.1.32", 31 | "jangregor/phpstan-prophecy": "^2.2.0", 32 | "friendsofphp/php-cs-fixer": "^3.90.0", 33 | "phpunit/phpunit": "^10.5.58" 34 | }, 35 | "bin": [ 36 | "bin/ec" 37 | ], 38 | "autoload": { 39 | "psr-4": { 40 | "Armin\\EditorconfigCli\\": "src" 41 | } 42 | }, 43 | "autoload-dev": { 44 | "psr-4": { 45 | "Armin\\EditorconfigCli\\Tests\\": "tests" 46 | } 47 | }, 48 | "scripts": { 49 | "ec": "@editor-config", 50 | "ec-fix": "@editor-config-fix", 51 | "editor-config": "@php bin/ec -n -g", 52 | "editor-config-fix": "@php bin/ec -n -g --fix", 53 | "all": [ 54 | "@fix", 55 | "@check", 56 | "@test", 57 | "@compile" 58 | ], 59 | "check": [ 60 | "@composer dump -o", 61 | "@ec", 62 | "@php-cs", 63 | "@php-stan", 64 | "@composer validate --no-check-version" 65 | ], 66 | "fix": [ 67 | "@composer dump -o", 68 | "@ec-fix", 69 | "@php-fix" 70 | ], 71 | "test": [ 72 | "@composer dump -o", 73 | "@test-php-unit", 74 | "@test-php-functional" 75 | ], 76 | "test-with-coverage": [ 77 | "@composer dump -o", 78 | "@test-php-unit-coverage", 79 | "@test-php-functional-coverage" 80 | ], 81 | "php-stan": "phpstan analyse -c .build/phpstan.neon", 82 | "php-cs": [ 83 | "@putenv PHP_CS_FIXER_IGNORE_ENV=1", 84 | "php-cs-fixer fix --config .build/php-cs-rules.php --ansi --verbose --diff --dry-run" 85 | ], 86 | "php-fix": "php-cs-fixer fix --config .build/php-cs-rules.php --ansi", 87 | "test-php-unit": "phpunit -c .build/phpunit-unit.xml --no-coverage", 88 | "test-php-functional": "phpunit -c .build/phpunit-functional.xml --no-coverage", 89 | "test-php-unit-coverage": "export XDEBUG_MODE=coverage && phpunit -c .build/phpunit-unit.xml --coverage-text", 90 | "test-php-functional-coverage": "export XDEBUG_MODE=coverage && phpunit -c .build/phpunit-functional.xml --coverage-text", 91 | "compile": [ 92 | "@composer dump -o --no-dev", 93 | "Armin\\EditorconfigCli\\Compiler::compile" 94 | ] 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/EditorConfig/Scanner.php: -------------------------------------------------------------------------------- 1 | editorConfig = $editorConfig ?? new EditorConfig(); 41 | $this->validator = $validator ?? new Validator(); 42 | } 43 | 44 | public function setRootPath(?string $rootPath): void 45 | { 46 | if ($rootPath) { 47 | $this->rootPath = realpath($rootPath) ?: null; 48 | } 49 | } 50 | 51 | /** 52 | * @return array|string[] 53 | */ 54 | public function getSkippingRules(): array 55 | { 56 | return $this->skippingRules; 57 | } 58 | 59 | /** 60 | * @param array|string[] $skippingRules 61 | */ 62 | public function setSkippingRules(array $skippingRules): void 63 | { 64 | $this->skippingRules = $skippingRules; 65 | } 66 | 67 | /** 68 | * @return array Key is file path, value is guessed mime-type 69 | */ 70 | public function getSkippedBinaryFiles(): array 71 | { 72 | return $this->skippedBinaryFiles; 73 | } 74 | 75 | /** 76 | * @return array|SplFileInfo[] 77 | */ 78 | public function getUnavailableFiles(): array 79 | { 80 | return $this->unavailableFiles; 81 | } 82 | 83 | /** 84 | * @param bool $strict when true, any difference of indention size is spotted 85 | * 86 | * @return array|FileResult[] 87 | */ 88 | public function scan(Finder $finderInstance, bool $strict = false, ?callable $tickCallback = null, bool $collectBinaryFiles = false): array 89 | { 90 | $results = []; 91 | foreach ($finderInstance as $file) { 92 | $config = $this->editorConfig->getConfigForPath((string)$file->getRealPath()); 93 | 94 | try { 95 | $fileResult = $this->validator->createValidatedFileResult($file, $config, $strict, $this->skippingRules); 96 | } catch (FileUnavailableException $e) { 97 | $this->unavailableFiles[] = $e->getUnavailableFile(); 98 | continue; 99 | } 100 | 101 | $filePath = $fileResult->getFilePath(); 102 | if (!empty($this->rootPath)) { 103 | $filePath = substr($filePath, strlen($this->rootPath)); 104 | } 105 | if (!$fileResult->isBinary()) { 106 | $results[$filePath] = $fileResult; 107 | } elseif ($collectBinaryFiles) { 108 | $this->skippedBinaryFiles[$filePath] = MimeTypeUtility::guessMimeType($fileResult->getFilePath()); 109 | } 110 | if ($tickCallback) { 111 | $tickCallback($fileResult); 112 | } 113 | } 114 | TimeTrackingUtility::addStep('Scan finished'); 115 | 116 | return $results; 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/EditorConfig/Utility/MimeTypeUtility.php: -------------------------------------------------------------------------------- 1 | guessMimeType($filePath); 14 | } 15 | 16 | public static function isCommonTextType(string $filePath): bool 17 | { 18 | $mimeType = self::guessMimeType($filePath); 19 | if (str_starts_with($mimeType, 'text/')) { 20 | return true; 21 | } 22 | 23 | if (str_starts_with($mimeType, 'application/')) { 24 | if (str_ends_with($mimeType, 'script')) { 25 | return true; 26 | } 27 | if (str_ends_with($mimeType, 'json')) { 28 | return true; 29 | } 30 | if (str_ends_with($mimeType, 'yaml')) { 31 | return true; 32 | } 33 | if (str_ends_with($mimeType, 'xml')) { 34 | return true; 35 | } 36 | if (str_ends_with($mimeType, 'sql')) { 37 | return true; 38 | } 39 | } 40 | 41 | return false; 42 | } 43 | 44 | public static function isCommonBinaryType(string $filePath): bool 45 | { 46 | $mimeType = self::guessMimeType($filePath); 47 | if (str_starts_with($mimeType, 'font/')) { 48 | return true; 49 | } 50 | if ('image/svg' !== $mimeType && str_starts_with($mimeType, 'image/')) { 51 | return true; 52 | } 53 | if (str_starts_with($mimeType, 'audio/')) { 54 | return true; 55 | } 56 | if (str_starts_with($mimeType, 'video/')) { 57 | return true; 58 | } 59 | if (str_starts_with($mimeType, 'application/vnd.')) { 60 | return true; 61 | } 62 | if ('application/pdf' === $mimeType) { 63 | return true; 64 | } 65 | if ('application/msword' === $mimeType) { 66 | return true; 67 | } 68 | if ('application/rtf' === $mimeType) { 69 | return true; 70 | } 71 | if ('application/zip' === $mimeType) { 72 | return true; 73 | } 74 | if ('application/tar' === $mimeType) { 75 | return true; 76 | } 77 | if ('application/bzip2' === $mimeType) { 78 | return true; 79 | } 80 | if ('application/octet-stream' === $mimeType) { 81 | return true; 82 | } 83 | if ('application/wasm' === $mimeType) { 84 | return true; 85 | } 86 | if (str_starts_with($mimeType, 'application/')) { 87 | if (str_ends_with($mimeType, '-compressed')) { 88 | return true; 89 | } 90 | if (str_ends_with($mimeType, '-ttf')) { 91 | return true; 92 | } 93 | if (str_ends_with($mimeType, '-archive')) { 94 | return true; 95 | } 96 | } 97 | 98 | return false; 99 | } 100 | 101 | public static function isBinaryFileType(string $filePath, float $threshold = .9): bool 102 | { 103 | $content = file_get_contents($filePath); 104 | if (false === $content) { 105 | throw new \RuntimeException('Unable to check file "' . $filePath . '" for being binary!'); 106 | } 107 | $length = strlen($content); 108 | 109 | if (0 === $length) { 110 | return false; 111 | } 112 | 113 | $printableCount = 0; 114 | 115 | for ($i = 0; $i < $length; ++$i) { 116 | $ord = ord($content[$i]); 117 | 118 | // Printable ASCII chars (32-126), Tabulator (9), CR (10) and LF (13) 119 | if (($ord >= 32 && $ord <= 126) || 9 === $ord || 10 === $ord || 13 === $ord) { 120 | ++$printableCount; 121 | } 122 | } 123 | 124 | return ($printableCount / $length) < $threshold; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/EditorConfig/Rules/Validator.php: -------------------------------------------------------------------------------- 1 | 23 | */ 24 | private array $editorConfig; 25 | 26 | /** 27 | * @var string[] 28 | */ 29 | private array $skippingRules; 30 | 31 | /** 32 | * @param array $editorConfig 33 | * @param string[] $skippingRules 34 | */ 35 | public function createValidatedFileResult(SplFileInfo $file, array $editorConfig, bool $strictMode = false, array $skippingRules = []): FileResult 36 | { 37 | $this->editorConfig = $editorConfig; 38 | $this->skippingRules = $skippingRules; 39 | 40 | $filePath = (string)$file->getRealPath(); 41 | 42 | if (empty($filePath)) { 43 | throw (new FileUnavailableException())->setUnavailableFile($file); 44 | } 45 | 46 | $rules = []; 47 | 48 | if (!MimeTypeUtility::isCommonTextType($filePath) && (MimeTypeUtility::isCommonBinaryType($filePath) || MimeTypeUtility::isBinaryFileType($filePath))) { 49 | return new FileResult($filePath, [], true); // Skip non-ascii files 50 | } 51 | 52 | // Line rules 53 | $style = $size = $width = null; 54 | 55 | if ($this->hasRuleSet(Rule::INDENT_STYLE)) { 56 | $style = $editorConfig[Rule::INDENT_STYLE]->getStringValue(); 57 | } 58 | if ($this->hasRuleSet(Rule::INDENT_SIZE)) { 59 | $size = $editorConfig[Rule::INDENT_SIZE]->getValue(); 60 | } 61 | if ($this->hasRuleSet(Rule::TAB_WIDTH)) { 62 | $width = $editorConfig[Rule::TAB_WIDTH]->getValue(); 63 | } 64 | 65 | if ('tab' === $style) { 66 | $size ??= $width; 67 | } 68 | 69 | if ($style) { 70 | $rules[] = new IndentionRule($filePath, $file->getContents(), $style, $size, $strictMode); 71 | } 72 | 73 | if (isset($editorConfig[Rule::TRIM_TRAILING_WHITESPACE]) && $editorConfig[Rule::TRIM_TRAILING_WHITESPACE] instanceof TrimTrailingWhitespace && $editorConfig[Rule::TRIM_TRAILING_WHITESPACE]->getValue()) { 74 | $rules[] = new Line\TrimTrailingWhitespaceRule($filePath, $file->getContents()); 75 | } 76 | 77 | // File rules 78 | if (isset($editorConfig[Rule::CHARSET]) && $editorConfig[Rule::CHARSET] instanceof Charset) { 79 | $rules[] = new CharsetRule($filePath, $file->getContents(), strtolower($editorConfig[Rule::CHARSET]->getStringValue())); 80 | } 81 | 82 | $eofRule = null; 83 | if ($this->hasRuleSet(Rule::END_OF_LINE)) { 84 | $rules[] = $eofRule = new EndOfLineRule($filePath, $file->getContents(), $editorConfig[Rule::END_OF_LINE]->getStringValue()); 85 | } 86 | 87 | $insertFinalNewLine = null; 88 | if ($this->hasRuleSet(Rule::INSERT_FINAL_NEWLINE) && $insertFinalNewLine = $editorConfig[Rule::INSERT_FINAL_NEWLINE]->getValue()) { 89 | $rules[] = new InsertFinalNewLineRule($filePath, $file->getContents(), $eofRule?->getEndOfLine()); 90 | } 91 | if ($this->hasRuleSet(Rule::TRIM_TRAILING_WHITESPACE)) { 92 | $rules[] = new TrimTrailingWhitespaceRule($filePath, $file->getContents(), $insertFinalNewLine ?? false); 93 | } 94 | 95 | if ($this->hasRuleSet(Rule::MAX_LINE_LENGTH) && 'off' !== $editorConfig[Rule::MAX_LINE_LENGTH]->getValue()) { 96 | $maxLineLength = (int)$editorConfig[Rule::MAX_LINE_LENGTH]->getValue(); 97 | if ($maxLineLength > 0) { 98 | $rules[] = new MaxLineLengthRule($filePath, $file->getContents(), $maxLineLength); 99 | } 100 | } 101 | 102 | return new FileResult($filePath, $rules); 103 | } 104 | 105 | /** 106 | * @param string $ruleName see \Armin\EditorconfigCli\EditorConfig\Rules\Rule class constants 107 | */ 108 | private function hasRuleSet(string $ruleName): bool 109 | { 110 | return !in_array($ruleName, $this->skippingRules, true) 111 | && isset($this->editorConfig[$ruleName]) 112 | && ($this->editorConfig[$ruleName]->getValue() || $this->editorConfig[$ruleName]->getStringValue()); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/EditorConfig/Rules/Line/IndentionRule.php: -------------------------------------------------------------------------------- 1 | style = strtolower($style); 23 | 24 | parent::__construct($filePath, $fileContent); 25 | } 26 | 27 | protected function validate(string $content): void 28 | { 29 | $lineEnding = LineEndingUtility::detectLineEnding($content, false) ?: "\n"; 30 | /** @var array|string[] $lines */ 31 | $lines = explode($lineEnding, $content); 32 | 33 | $lineCount = 0; 34 | foreach ($lines as $line) { 35 | ++$lineCount; 36 | $lineValid = true; 37 | $beginningWhitespaces = preg_replace('/^(\s.*?)\S.*/', '$1', $line); 38 | 39 | if (empty($line) || $beginningWhitespaces === $line || null === $beginningWhitespaces) { 40 | continue; 41 | } 42 | 43 | if ('tab' === $this->style && str_starts_with($beginningWhitespaces, ' ')) { 44 | $this->addError($lineCount, 'Expected indention style "tab" but found "spaces"'); 45 | $lineValid = false; 46 | } 47 | if ('space' === $this->style && str_starts_with($beginningWhitespaces, "\t")) { 48 | $this->addError($lineCount, 'Expected indention style "space" but found "tabs"'); 49 | $lineValid = false; 50 | } 51 | 52 | if (!$lineValid || !$this->strict) { 53 | continue; 54 | } 55 | 56 | // Strict indention checks 57 | if ('space' === $this->style && (int)$this->size > 0) { 58 | $tooMuchSpaces = strlen($beginningWhitespaces) % $this->size; 59 | if ($tooMuchSpaces > 0) { 60 | $expectedMin = (int)floor(strlen($beginningWhitespaces) / $this->size) * $this->size; 61 | $expectedMax = (int)ceil(strlen($beginningWhitespaces) / $this->size) * $this->size; 62 | 63 | $actual = $expectedMin + $tooMuchSpaces; 64 | $this->addError($lineCount, 'Expected %d or %d spaces, found %d', $expectedMin, $expectedMax, $actual); 65 | } 66 | } 67 | } 68 | } 69 | 70 | public function fixContent(string $content): string 71 | { 72 | $lineEnding = LineEndingUtility::detectLineEnding($content, false) ?: "\n"; 73 | /** @var array|string[] $lines */ 74 | $lines = explode($lineEnding, $content); 75 | foreach ($lines as $no => $line) { 76 | $beginningWhitespaces = preg_replace('/^([\t\s].*?)\S.*/', '$1', $line); 77 | 78 | if (empty($line) || $beginningWhitespaces === $line || null === $beginningWhitespaces) { 79 | continue; 80 | } 81 | 82 | $whitespaces = ''; 83 | 84 | // fixed mixed spaces/tabs 85 | if ('tab' === $this->style) { 86 | if (0 === (int)$this->size && str_starts_with($beginningWhitespaces, ' ')) { 87 | throw $this->getUnfixableException(1763644380); 88 | } 89 | $whitespaces = str_replace(str_repeat(' ', (int)$this->size), "\t", $beginningWhitespaces); 90 | 91 | if (str_starts_with($whitespaces, ' ')) { 92 | $whitespaces = preg_replace('/^\s*?(\t.*)/', '$1', $whitespaces) ?: $whitespaces; 93 | } 94 | if (!str_contains($whitespaces, "\t")) { 95 | $whitespaces = str_replace(' ', '', $whitespaces); 96 | } 97 | } 98 | 99 | if ('space' === $this->style) { 100 | if (0 === (int)$this->size && str_starts_with($beginningWhitespaces, "\t")) { 101 | throw $this->getUnfixableException(1763644381); 102 | } 103 | $whitespaces = str_replace("\t", str_repeat(' ', (int)$this->size), $beginningWhitespaces); 104 | } 105 | 106 | if ('space' === $this->style && $this->strict) { 107 | if (0 === (int)$this->size) { 108 | throw $this->getUnfixableException(1763644382); 109 | } 110 | $tooMuchSpaces = strlen($whitespaces) % $this->size; 111 | if ($tooMuchSpaces > 0) { 112 | $expected = strlen($whitespaces) - $tooMuchSpaces; 113 | $whitespaces = str_repeat(' ', $expected); 114 | } 115 | } 116 | // Update line 117 | $lines[$no] = $whitespaces . substr($line, strlen($beginningWhitespaces)); 118 | } 119 | 120 | return implode($lineEnding, $lines); 121 | } 122 | 123 | private function getUnfixableException(int $errorCode): UnfixableException 124 | { 125 | return new UnfixableException(sprintf('Automatic fix of line indention is not possible for file "%s", because indent_size is not defined.', $this->getFilePath()), $errorCode); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/EditorConfig/Utility/FinderUtility.php: -------------------------------------------------------------------------------- 1 | $finderOptions 22 | */ 23 | public static function createByFinderOptions(array $finderOptions, ?string $gitOnly = null): Finder 24 | { 25 | if (!empty($gitOnly)) { 26 | return self::buildGitOnlyFinder($finderOptions['path'], $gitOnly, $finderOptions['names']); 27 | } 28 | 29 | return self::buildFinderByCliArguments($finderOptions); 30 | } 31 | 32 | /** 33 | * Using finderOptions array to build Finder instance. 34 | * 35 | * @param array $finderOptions 36 | */ 37 | protected static function buildFinderByCliArguments(array $finderOptions): Finder 38 | { 39 | self::$currentExcludes = ArrayUtility::flattenSeparatedValues($finderOptions['exclude']); 40 | 41 | $finder = new Finder(); 42 | 43 | return $finder 44 | ->files() 45 | ->ignoreVCS(true) 46 | ->ignoreVCSIgnored(!$finderOptions['disable-auto-exclude'] && is_readable($finderOptions['path'] . '/.gitignore')) 47 | ->name($finderOptions['names']) 48 | ->notPath(self::$currentExcludes) 49 | ->in($finderOptions['path']); 50 | } 51 | 52 | /** 53 | * Calling local git binary to identify files known to Git. 54 | * Used, when --git-only (-g) is set. 55 | * 56 | * @param string[] $names 57 | */ 58 | protected static function buildGitOnlyFinder(string $workingDir, string $gitOnlyCommand, array $names): Finder 59 | { 60 | exec('cd ' . $workingDir . ' && ' . $gitOnlyCommand . ' 2>&1', $result, $returnCode); 61 | if (0 !== $returnCode) { 62 | throw new \RuntimeException('Git binary returned error code ' . $returnCode . ' with the following output:' . PHP_EOL . PHP_EOL . implode(PHP_EOL, $result), $returnCode); 63 | } 64 | 65 | $files = []; 66 | 67 | foreach ($result as $item) { 68 | // Check for quotepath, containing octal values for special chars 69 | if (str_starts_with($item, '"') && str_ends_with($item, '"')) { 70 | $item = substr($item, 1, -1); 71 | // Convert octal values to special chars 72 | $item = preg_replace_callback( 73 | '/\\\\(\d{3})/', 74 | static fn ($matches) => chr((int)octdec((string)$matches[1])), 75 | $item 76 | ); 77 | } 78 | 79 | if (!is_string($item) || empty($item)) { 80 | continue; 81 | } 82 | 83 | $files[] = new FinderSplFileInfo( 84 | $workingDir . '/' . $item, 85 | dirname($item), 86 | $item 87 | ); 88 | } 89 | 90 | $iterator = new \ArrayIterator($files); 91 | 92 | if (!empty($names)) { 93 | $iterator = new FilenameFilterIterator($iterator, $names, []); 94 | } 95 | 96 | $finder = new Finder(); 97 | $finder->files()->ignoreVCS(true); 98 | 99 | $finder->append($iterator); 100 | 101 | return $finder; 102 | } 103 | 104 | /** 105 | * Requires given PHP file (in isolated closure scope) and expect a Symfony Finder instance to be returned. 106 | * Also, the passed $finderOptions will be available in code of required PHP file (scoped and as global variable). 107 | * 108 | * @param array $finderOptions 109 | */ 110 | public static function loadCustomFinderInstance( 111 | string $finderConfigPath, 112 | array $finderOptions 113 | ): Finder { 114 | if (!file_exists($finderConfigPath)) { 115 | throw new \RuntimeException(sprintf('Finder config file "%s" not found!', $finderConfigPath), 1621342890); 116 | } 117 | 118 | $closure = function (string $finderConfigPath) use ($finderOptions): Finder { 119 | // Load custom php in isolated closure scope, providing variable $finderOptions 120 | $GLOBALS['finderOptions'] = $finderOptions; 121 | $finder = require $finderConfigPath; 122 | if (!is_object($finder) || !$finder instanceof Finder) { 123 | if (is_object($finder)) { 124 | $returnType = 'instance of ' . $finder::class; 125 | } else { 126 | $returnType = gettype($finder); 127 | } 128 | throw new \RuntimeException('Custom Symfony Finder configuration (' . $finderConfigPath . ") should return an instance of \Symfony\Component\Finder\Finder. \nInstead it returns: " . $returnType, 1621343069); 129 | } 130 | $finder->files(); 131 | 132 | return $finder; 133 | }; 134 | 135 | return $closure($finderConfigPath); 136 | } 137 | 138 | /** 139 | * @return string[] 140 | */ 141 | public static function getCurrentExcludes(): array 142 | { 143 | return self::$currentExcludes; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /docs/Versions.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | **armin/editorconfig-cli** 4 | 5 | Link to releases: https://github.com/a-r-m-i-n/editorconfig-cli/releases 6 | 7 | 8 | ## Versions 9 | 10 | ### 2.2.1 11 | 12 | - [TASK] Add support for Symfony 8.x 13 | - [TASK] Update dependencies 14 | 15 | ### 2.2.0 16 | 17 | - [DEVOPS] Add PHP 8.5 to Github action "code-checks" 18 | - [DEVOPS] Update minimum required versions of dev-dependencies 19 | - [DOCS] Improve README 20 | - [TASK] Do not throw exception, when passing --skip or --exclude option without values given 21 | - **[FEATURE] Allow argument to filter files, also in --git-only mode** 22 | - [TASK] Refactor tests 23 | - [DEVOPS] Fix deprecated rule in PHPCS fixer config 24 | - [TASK] Update dependencies 25 | - [BUGFIX] Fix detection of wrong indention (**thanks to Mike Street for reporting**) 26 | 27 | ### 2.1.1 28 | 29 | - [BUGFIX] Assure compatibility with Symfony v7.3 (**thanks to Elias Häußler**) 30 | - [BUGFIX] Set minimum required PHP version in "ec" binary 31 | 32 | ### 2.1.0 33 | 34 | - [TASK] Update dependencies 35 | - [BUGFIX] Improve output when indention in strict mode is wrong 36 | - [TASK] Update copyright notice in LICENSE 37 | - [DEVOPS] Remove unnecessary docker-compose.web.yaml 38 | - [DEVOPS] Update GitHub actions (**thanks to Elias Häußler**) 39 | - [DEVOPS] Enable parallel runs in PHP-CS-Fixer (**thanks to Elias Häußler**) 40 | - [DEVOPS] Add PHP 8.4 to test matrix (**thanks to Elias Häußler**) 41 | - [DEVOPS] Update to PHPStan v2 (**thanks to Elias Häußler**) 42 | 43 | ### 2.0.1 44 | 45 | - [TASK] Remove "checkMissingIterableValueType" option and add missing iteration value types 46 | - [TASK] Fix code styles 47 | - [TASK] Update dependencies 48 | - [TASK] Exclude Compiler class from dist archives (**thanks to Elias Häußler**) 49 | - [TASK] Exclude development-only files from dist archives (**thanks to Elias Häußler**) 50 | 51 | ### 2.0.0 52 | 53 | - [DOCS] Update screenshot in README 54 | - [TEST] Improve functional test code coverage 55 | - **[TASK][!!!] Do not support Symfony v4 components (#23)** 56 | - [BUGFIX] Fix verbosity in functional tests 57 | - [TASK] Improve application usages and .editorconfig test exclusions 58 | - [TASK] Apply PHP 8.2 adjustments 59 | - [TASK] Apply phpstan fixes 60 | - [TASK] Apply php-cs-fixer fixes 61 | - **[TASK][!!!] Drop PHP 7.4, 8.0 and 8.1 support, ensure PHP 8.3 support (#17)** 62 | 63 | ### 1.8.1 64 | 65 | - [TASK] Add +x flag to bin/ec binary 66 | - [DOCS] Improve README contents 67 | - [BUGFIX] Return error code 3 when not confirming to continue (#24) 68 | - [TASK] Update dependencies 69 | 70 | ### 1.8.0 71 | 72 | - [TASK] Refactor type hints and method access modifiers 73 | - [TASK] Refactor validate method of rules (#21) 74 | - [BUGFIX] Do not detect "image/svg" as binary file 75 | - **[FEATURE] Detect and output warning when files being staged in Git are physically missing** 76 | - [BUGFIX] Do not throw exception when git repo contains file paths with special chars (#22) 77 | - [TASK] Update dependencies 78 | 79 | ### 1.7.4 80 | 81 | - [BUGFIX] Do not detect trailing whitespaces in empty files 82 | 83 | ### 1.7.3 84 | 85 | - [BUGFIX] Do not apply final new line to empty files 86 | 87 | ### 1.7.2 88 | 89 | - [BUGFIX] Do not throw exception for empty file, when checking if file is binary 90 | 91 | ### 1.7.0 92 | 93 | **Caution!** When updating to this version, for the first time, also files with e.g. "application/" mime-type get checked. 94 | This was a major issue in all previous version and is fixed, now. Before only files with mime-type "text/" has been validated. 95 | 96 | - [BUGFIX] Fix check for binary files and do not exclude JSON or YAML files 97 | 98 | ### 1.6.2 99 | 100 | - [BUGFIX] Set composer.lock file to PHP 7.4 level 101 | 102 | ### 1.6.1 103 | 104 | - [TASK] Drop PHP 7.3 support 105 | 106 | ### 1.6.0 107 | 108 | - [DEVOPS] Various improvements 109 | - [BUGFIX] Add max_line_length to skip-able rules 110 | - [BUGFIX] Fix functional tests, to work on Windows 111 | 112 | ### 1.5.2 113 | 114 | - [BUGFIX] Downgrade to compatible versions (PHP 7.3) 115 | 116 | ### 1.5.1 117 | 118 | - [TASK] Add support for Symfony 6.x 119 | - [DEVOPS] Add PHP 8.1 to GitHub Action matrix Armin Vieweg Today 13:37 120 | 121 | ### 1.5.0 122 | 123 | - [TASK] Update dependencies 124 | - [TASK] Small improvements 125 | - Revert "[BUGFIX] Respect "root=true" flag" 126 | - Revert "[TEMP] Add patched EditorConfig" 127 | - [FEATURE] Measure and show duration of scan/fix 128 | - [DOCS] Improve README 129 | - [DEVOPS] Display code coverage in CLI output 130 | - [BUGFIX] Use current working directory when "--dir" is null 131 | - [FEATURE] Add new option --git-only 132 | - [TEST] Improve functional tests 133 | - [DEVOPS] Add Github action: Upload test reports artifact 134 | - [DEVOPS] Add phpunit code coverage 135 | - [DEVOPS] Update phpunit from ^7.5 to ^9.5 136 | - [TASK] Remove unused code 137 | 138 | 139 | ### 1.4.0 140 | 141 | - [TASK] Improve texts 142 | - [TASK] Streamline wording of error messages 143 | - [TASK] Sort error result by line 144 | - [TEST] Improve functional test 145 | - [FEATURE] Add new option --skip (-s) 146 | - [BUGFIX] Respect "root=true" flag 147 | - [TEMP] Add patched EditorConfig 148 | - [BUGFIX] Respect missing final new line 149 | - [DEVOPS] Add composer script "all" 150 | - [FEATURE] Add new option --uncovered 151 | - [TASK] Add more verbose output (-v) 152 | - [BUGFIX] Do not require "end_of_line", when using "insert_final_newline" 153 | - [FEATURE] Add first functional tests 154 | - [BUGFIX] Do not throw exception, when no root .gitignore file given 155 | - [TASK] Do not output full path in result 156 | - [TASK] Add progress bar and streamline scan result message 157 | - [DEVOPS] Add Github action to automate releases 158 | 159 | 160 | ### 1.3.1 161 | 162 | - [TASK] Update dependencies 163 | - [BUGFIX] Fix minimum required versions 164 | - [DEVOPS] Add Github actions 165 | - [DOCS] Add missing editorconfig logo 166 | 167 | 168 | ### 1.3.0 169 | 170 | - [DOCS] Improve README 171 | - [TASK] Set required ->files() in custom Finder instance 172 | - [BUGFIX] Do not call scan (or fix) when amount of files is zero 173 | - [TASK] Automatic exclusion: Replace hardcoded folders with Finder's ignoreVCSIgnored(true) 174 | - [FEATURE] Configurable custom Symfony Finder instance 175 | 176 | 177 | ### 1.2.2 178 | 179 | - [BUGFIX] Allow uppercase config values 180 | 181 | 182 | ### 1.2.1 183 | 184 | - [TASK] Do not replace "phpunit/php-token-stream" and update dependencies 185 | - [BUGFIX] Make editor-config work in environments using symfony/console in version 4 186 | 187 | 188 | ### 1.2.0 189 | 190 | - [FEATURE] Add MaxLineLengthRule 191 | - [DEVOPS] Update PhpCsFixer config 192 | - [TASK] Allow Rules to only check code (not fixing them) 193 | - [TASK] Remove Composer patch and update dependencies 194 | 195 | 196 | ### 1.1.2 197 | 198 | - [TASK] Update symfony/console 199 | - [BUGFIX] Exclude Compiler.php file correctly from PHAR result 200 | - [BUGFIX] Fix wrong php requirement in "ec" binary 201 | 202 | 203 | ### 1.1.1 204 | 205 | - [BUGFIX] Do not output uncovered file message, when amount is 0 206 | - [BUGFIX] Respect default excludes, in verbose output (-v) 207 | - [BUGFIX] Do not count all files, count only invalid ones 208 | 209 | 210 | ### 1.1.0 211 | 212 | - [FEATURE] Add "vendor" and "node_modules" as default exclude 213 | - [FEATURE] Add new option "no-error-on-exit" 214 | - [FEATURE] In verbose mode (-v) show files not covered by .editorconfig 215 | - [BUGFIX] Do not throw exception, when .editorconfig value is not lowercase 216 | - [FEATURE] PHP 7.3 support 217 | 218 | 219 | ### 1.0.0 220 | 221 | Very first release. 222 | 223 | - [TASK] Add author and support info to composer.json 224 | - [TASK] Add license (MIT) 225 | - [TASK] Implement version 226 | - [TASK] Add more README content 227 | - [INITIAL] Set package name to "armin/editorconfig-cli" 228 | - [TASK] Add Composer Patches 229 | - [FEATURE] Add Unit Tests 230 | - [FEATURE] Add EditorConfigCommand with Rules, Scanner and Validator 231 | - [DEVOPS] Add and apply php-cs-fixer 232 | - [DEVOPS] Add and apply phpstan level 8 233 | - [INITIAL] First commit 234 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Editorconfig Checker for CLI 2 | 3 | [![Code Checks](https://github.com/a-r-m-i-n/editorconfig-cli/actions/workflows/code-checks.yml/badge.svg)](https://github.com/a-r-m-i-n/editorconfig-cli/actions/workflows/code-checks.yml) 4 | 5 | EditorConfigCLI (binary name ``ec``) is a free PHP-based command-line tool that **validates and auto-fixes** text files 6 | according to your project's **.editorconfig** rules. 7 | 8 | This ensures that your **CI pipelines and development workflows** consistently enforce all relevant ``.editorconfig`` 9 | rules automatically. 10 | 11 | **armin/editorconfig-cli** is released under [MIT license](LICENSE). 12 | 13 | Written by **Armin Vieweg** <> 14 | 15 | 16 | ## Requirements 17 | 18 | - PHP 8.2 or higher 19 | - Enabled PHP extensions: iconv, json 20 | 21 | If you require support for older PHP versions, you can check out and use those tags: 22 | 23 | * [PHP 7.4, 8.0, 8.1 or 8.2](https://github.com/a-r-m-i-n/editorconfig-cli/tree/1.x) (Branch 1.x) 24 | 25 | 26 | ## Installation 27 | 28 | You can install EditorConfigCLI by either [downloading the PHAR executable](https://github.com/a-r-m-i-n/editorconfig-cli/releases) 29 | or installing it via Composer: 30 | 31 | ``` 32 | $ composer req --dev armin/editorconfig-cli 33 | ``` 34 | 35 | **Tip:** You can also install the tool globally using ``composer global``. 36 | 37 | To download the PHAR executables, check out the releases section 38 | [here](https://github.com/a-r-m-i-n/editorconfig-cli/releases). 39 | 40 | 41 | ## What is EditorConfig? 42 | 43 | ![EditorConfig logo](docs/images/editorconfig-logo.png) 44 | 45 | > EditorConfig helps maintain consistent coding styles for multiple developers working on the 46 | > same project across various editors and IDEs. 47 | 48 | The coding styles to be enforced are defined in your project’s **.editorconfig** file. 49 | 50 | You'll find more info about syntax and features of EditorConfig on 51 | https://editorconfig.org 52 | 53 | 54 | ## Screenshots 55 | 56 | This screenshot shows the help page you get when calling ``ec --help``: 57 | 58 | ![Screenshot of Help page](docs/images/ec-2.0.png) 59 | 60 | 61 | Here you see two example runs: 62 | 63 | ![Demo run animation](docs/images/ec-demo.gif) 64 | 65 | 66 | ## Features 67 | 68 | - Parsing ``.editorconfig`` file 69 | - Validating files against corresponding ``.editorconfig`` declarations 70 | - Several modes to iterate through your project files 71 | - Automatic fixing of detected issues 72 | - The following EditorConfig declarations (also called "rules") are being processed: 73 | - EndOfLine 74 | - InsertFinalNewLine 75 | - TrimTrailingWhitespace 76 | - Indention 77 | - Style (tab/spaces) 78 | - Size (width) 79 | - Charset (*check only*) 80 | - MaxLineLength (*check only*) 81 | - Optional strict mode (``--strict``) to enforce the configured space indentation size. 82 | *(Note: This may conflict with other linters enforcing more granular indentation rules.)* 83 | - Allow skipping certain rules (e.g. ``--skip charset,eol``) 84 | - List files, currently uncovered by given ``.editorconfig`` declarations (``--uncovered``) 85 | 86 | ## Usage 87 | 88 | Composer style: 89 | ``` 90 | $ vendor/bin/ec [options] [--] [...] 91 | ``` 92 | 93 | PHAR style: 94 | ``` 95 | $ php ec-2.0.0.phar [options] [--] [...] 96 | ``` 97 | 98 | ### Scanning 99 | 100 | If no options are provided, the scan starts automatically when invoking the ``ec`` binary. 101 | 102 | EditorConfigCLI supports **three modes** for discovering files to check: 103 | 104 | 1. **By CLI arguments and options**, using a preconfigured ``symfony/finder`` instance (default mode). 105 | 106 | *Note:* No dotted files and directories are getting scanned (e.g. ``.ddev/`` or ``.htaccess``). 107 | Also, files covered by root ``.gitignore`` file, will be automatically excluded from scan. 108 | 109 | 2. **Git-based mode**, which retrieves all files tracked by Git. 110 | 111 | *Note:* Most CLI args and options are ignored, then. You can still filter files known to Git 112 | using `` argument. (``--git-only``) 113 | 114 | 3. **Using a custom finder instance**, which you can provide via a separate PHP file (``--finder-config``). 115 | 116 | 117 | ### Fixing 118 | 119 | To automatically apply fixes after scanning, append the ``--fix`` (or ``-f``) option. 120 | 121 | Currently, two rules do not support auto-fixing: 122 | 123 | - Charset 124 | - MaxLineLength 125 | 126 | You get a notice for this in result output, when such issues occur. 127 | 128 | If an indentation issue is detected but no `indent_size` is defined, a notice is shown, since fixing indentation 129 | requires a defined size. 130 | 131 | 132 | ## CLI 133 | 134 | ### Argument 135 | 136 | One or more file names or patterns to check. Wildcards allowed. Default: ``['*']`` 137 | 138 | With this you can only scan certain file types, e.g. 139 | 140 | ``` 141 | $ vendor/bin/ec "*.json" "*.yml" "*.yaml" 142 | ``` 143 | 144 | This also works, when `--git-only` mode is used. Then, all files known to Git are filtered. 145 | This is especially useful when applying `--strict` mode only to certain file types such as JSON or YAML. 146 | 147 | #### Wildcard Expansion Notice 148 | 149 | Shells like Bash, Zsh, and PowerShell may expand wildcards (`*`) before the tool receives them, 150 | passing the list of matching files instead of the literal pattern. 151 | 152 | To pass a literal `*`, quote or escape it (e.g. `"*.json"` or `\*.json`). 153 | 154 | Use `-v` (verbose) to see the actual arguments received by the EditorConfigCLI. 155 | 156 | 157 | ### Options 158 | 159 | The ``ec`` binary supports the following options: 160 | 161 | | Option | Shortcut | Description | 162 | |----------------------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 163 | | ``--dir`` | ``-d`` | Define the directory to scan. By default, the current working directory is used. | 164 | | ``--exclude`` | ``-e`` | Directories to exclude from scan. Multiple and comma-separated values are allowed. | 165 | | ``--disable-auto-exclude`` | ``-a`` | Disables automatic exclusion of files listed in the root ``.gitignore`` file (if present). | 166 | | ``--git-only`` | ``-g`` | Ignores all excludes and scans for all files known to Git. Requires git binary to be present. | 167 | | ``--git-only-cmd`` | | Allows you to modify the git command (incl. binary) to get file list. Default: ``git ls-files`` | 168 | | ``--finder-config`` | | Allows to define a PHP file providing a custom Finder instance. [Read more](docs/CustomFinderInstance.md) | 169 | | ``--skip`` | ``-s`` | Disables rules by name. Multiple and comma-separated values are allowed. See [rules list](#rules-list) below. | 170 | | ``--strict`` | | When set, given indention size is forced during scan and fixing. This might conflict with more detailed indention rules, checked by other linters and style-fixers in your project. | 171 | | ``--compact`` | ``-c`` | Shows only the files containing issues, not the issues themselves. | 172 | | ``--uncovered`` | ``-u`` | Lists all files which are not covered by .editorconfig. | 173 | | ``--verbose`` | ``-v`` | Shows additional information, like detailed info about internal time tracking and which binary files have been skipped. | 174 | | ``--no-interaction`` | ``-n`` | Skips the confirmation prompt when more than 500 files are found and proceeds immediately. Always returns error code ``3``, when not confirming. | 175 | | ``--no-error-on-exit`` | | By default ``ec`` returns code 2 when issues or code 1 when warnings occurred. When enabled, the exit code is always ``0``. | 176 | 177 | **Tip:** The "usage" section on ``ec``'s help page shows some examples. 178 | 179 | 180 | ### Rules list 181 | 182 | The following rules are being executed by default and could get disabled using the ``--skip`` (``-s``) option: 183 | 184 | * ``charset`` 185 | * ``end_of_line`` 186 | * ``indent_size`` 187 | * ``indent_style`` 188 | * ``tab_width`` 189 | * ``insert_final_newline`` 190 | * ``max_line_length`` 191 | * ``trim_trailing_whitespace`` 192 | 193 | 194 | ## Support and Contribution 195 | 196 | For questions, issues, or feature requests, please visit the 197 | [issue tracker](https://github.com/a-r-m-i-n/editorconfig-cli/issues) on Github. 198 | 199 | If you like this project, you are welcome to support its development with 200 | [a donation](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=2DCCULSKFRZFU) 201 | to support further development. Thank you! 202 | 203 | In case you want to contribute code, checkout the [Contribution guide](docs/Contribute.md) for developers. 204 | 205 | 206 | ## Changelog 207 | 208 | [See here](docs/Versions.md) 209 | -------------------------------------------------------------------------------- /src/Application.php: -------------------------------------------------------------------------------- 1 | scanner = $scanner ?? new Scanner(); 40 | 41 | $this 42 | ->setName('ec') 43 | ->setVersion($this->version = VersionUtility::getApplicationVersionFromComposerJson()) 44 | ->setDescription("CLI tool to validate and auto-fix text files, based on given .editorconfig declarations.\n Version: " . VersionUtility::getApplicationVersionFromComposerJson() . '' . "\n Written by: Armin Vieweg ") 45 | ->addUsage('') 46 | ->addUsage('--fix') 47 | ->addUsage('"*.js" "*.css"') 48 | ->addUsage('-n --no-progress') 49 | ->addUsage('-e"dist" -e".build"') 50 | ->addUsage('-s charset,eol -s trim') 51 | ->addUsage('--finder-instance finder-config.php') 52 | ->addUsage('-g -u -v -n') 53 | 54 | ->addArgument('names', InputArgument::IS_ARRAY, 'Name(s) of file names to get checked. Wildcards allowed', ['*']) 55 | 56 | ->addOption('skip', 's', InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Disables rules by name. Comma-separation allowed') 57 | ->addOption('strict', null, InputOption::VALUE_NONE, 'When set, any difference of indention size is spotted') 58 | ->addOption('fix', 'f', InputOption::VALUE_NONE, 'Fixes all found issues in files (files get overwritten)') 59 | ->addOption('compact', 'c', InputOption::VALUE_NONE, 'When set, does only list files, no details') 60 | 61 | ->addOption('dir', 'd', InputOption::VALUE_OPTIONAL, 'Working directory to scan', getcwd()) 62 | ->addOption('git-only', 'g', InputOption::VALUE_NONE, 'Only scans files which are currently under control of Git.') 63 | ->addOption('git-only-cmd', null, InputOption::VALUE_OPTIONAL, 'Allows to modify git command executed when --git-only (-g) is given.', 'git ls-files') 64 | ->addOption('finder-config', null, InputOption::VALUE_OPTIONAL, 'Optional path to PHP file (relative from working dir (-d)), returning a pre-configured Symfony Finder instance') 65 | ->addOption('disable-auto-exclude', 'a', InputOption::VALUE_NONE, 'By default all files ignored by existing .gitignore, will be excluded from scanning. This options disables it') 66 | ->addOption('exclude', 'e', InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 'Directories to exclude') 67 | 68 | ->addOption('uncovered', 'u', InputOption::VALUE_NONE, 'When set, all files which are not covered by .editorconfig get listed') 69 | ->addOption('no-progress', '', InputOption::VALUE_NONE, 'When set, no progress indicator is displayed') 70 | ->addOption('no-error-on-exit', '', InputOption::VALUE_NONE, 'When set, the CLI tool will always return code 0, also when issues have been found') 71 | ->setCode($this->executing(...)) 72 | ; 73 | } 74 | 75 | protected function initialize(InputInterface $input, OutputInterface $output): void 76 | { 77 | /** @var string $dir */ 78 | $dir = $input->getOption('dir'); 79 | $this->scanner->setRootPath($dir); 80 | 81 | /** @var string[]|null $skip */ 82 | $skip = $input->getOption('skip'); 83 | $this->scanner->setSkippingRules($this->parseSkippingRules($skip)); 84 | } 85 | 86 | protected function executing(InputInterface $input, OutputInterface $output): int 87 | { 88 | $io = new SymfonyStyle($input, $output); 89 | $io->getFormatter()->setStyle('debug', new OutputFormatterStyle('blue')); 90 | $io->getFormatter()->setStyle('warning', new OutputFormatterStyle('black', 'yellow')); 91 | 92 | $this->isVerbose = $output->isVerbose(); 93 | 94 | /** @var string $workingDirectory */ 95 | $workingDirectory = $input->getOption('dir'); 96 | if (empty($workingDirectory)) { 97 | $workingDirectory = getcwd() ?: '.'; 98 | } 99 | $realPath = realpath($workingDirectory); 100 | if (!$realPath) { 101 | $io->error(sprintf('Invalid working directory "%s" given!', $workingDirectory)); 102 | 103 | return 1; 104 | } 105 | 106 | TimeTrackingUtility::addStep('Command initialized'); 107 | 108 | $io->writeln('EditorConfigCLI v' . $this->version . ' by Armin Vieweg'); 109 | 110 | // Init return value 111 | $returnValue = 0; 112 | // Create (or get) Symfony Finder instance 113 | $finderOptions = [ 114 | 'path' => $realPath, 115 | 'names' => (array)$input->getArgument('names'), 116 | 'exclude' => (array)$input->getOption('exclude'), 117 | 'disable-auto-exclude' => (bool)$input->getOption('disable-auto-exclude'), 118 | ]; 119 | 120 | $finderConfigPath = null; 121 | if (!empty($input->getOption('finder-config'))) { 122 | $finderConfigPath = $input->getOption('finder-config'); 123 | $finderConfigPath = $realPath . '/' . $finderConfigPath; 124 | $finder = FinderUtility::loadCustomFinderInstance($finderConfigPath, $finderOptions); 125 | 126 | $io->writeln( 127 | sprintf('Loading custom Symfony Finder configuration from %s', $finderConfigPath) 128 | ); 129 | } 130 | /** @var bool $gitOnlyEnabled */ 131 | $gitOnlyEnabled = $input->getOption('git-only'); 132 | /** @var string|null $gitOnlyCommand */ 133 | $gitOnlyCommand = $input->getOption('git-only-cmd'); 134 | 135 | $finder ??= FinderUtility::createByFinderOptions($finderOptions, $gitOnlyEnabled ? $gitOnlyCommand : null); 136 | 137 | // Check amount of files to scan and ask for confirmation 138 | if ($finderConfigPath) { 139 | $io->writeln('Searching with custom Finder instance...'); 140 | } else { 141 | $io->writeln(sprintf('Searching in directory %s...', $realPath)); 142 | if ($gitOnlyEnabled && $gitOnlyCommand) { 143 | $io->writeln('Get files from git binary (command: ' . $gitOnlyCommand . '):'); 144 | } 145 | } 146 | if (!$finderConfigPath && $this->isVerbose) { 147 | if ($gitOnlyEnabled && $gitOnlyCommand) { 148 | $io->writeln('(Auto-) excludes disabled, because of set git-only mode.'); 149 | $io->writeln('Names: ' . implode(', ', (array)$input->getArgument('names')) . ''); 150 | } else { 151 | $io->writeln('Names: ' . implode(', ', (array)$input->getArgument('names')) . ''); 152 | $io->writeln('Excluded: ' . (count(FinderUtility::getCurrentExcludes()) > 0 ? implode(', ', FinderUtility::getCurrentExcludes()) : '-') . ''); 153 | $io->writeln('Auto exclude: ' . ($input->getOption('disable-auto-exclude') ? 'disabled' : 'enabled') . ''); 154 | } 155 | } 156 | if ($this->isVerbose) { 157 | $io->writeln('Strict mode: ' . ($input->getOption('strict') ? 'enabled' : 'disabled') . ''); 158 | $io->writeln('Output mode: ' . ($input->getOption('compact') ? 'compact' : 'full') . ''); 159 | } 160 | 161 | $io->writeln(sprintf('Found %d files to scan.', $count = $finder->count())); 162 | TimeTrackingUtility::addStep('Fetched files to scan'); 163 | if (0 === $count) { 164 | $io->writeln('Nothing to do here.'); 165 | 166 | return $returnValue; // Early return 167 | } 168 | 169 | if ($count > 500 && !$input->getOption('no-interaction') && !$io->confirm('Continue?', false)) { 170 | $io->writeln('Canceled.'); 171 | 172 | return 3; // Early return 173 | } 174 | 175 | if (!empty($this->scanner->getSkippingRules())) { 176 | $io->writeln('Skipping rules: ' . implode(', ', $this->scanner->getSkippingRules()) . ''); 177 | } 178 | 179 | // Start scanning or fixing 180 | $returnValue = !$input->getOption('fix') 181 | ? $this->scan($finder, $count, $io, (bool)$input->getOption('strict'), (bool)$input->getOption('no-progress'), (bool)$input->getOption('compact'), (bool)$input->getOption('uncovered')) 182 | : $this->fix($finder, $io, (bool)$input->getOption('strict')); 183 | 184 | if (!empty($this->scanner->getUnavailableFiles())) { 185 | $amountUnavailableFiles = count($this->scanner->getUnavailableFiles()); 186 | $io->warning('Found ' . $amountUnavailableFiles . ' unavailable ' . StringFormatUtility::pluralizeFiles($amountUnavailableFiles) . ' not being scanned!'); 187 | $filePaths = []; 188 | foreach ($this->scanner->getUnavailableFiles() as $unavailableFile) { 189 | $filePaths[] = $unavailableFile->getPathname(); 190 | } 191 | $io->listing($filePaths); 192 | if ($gitOnlyEnabled) { 193 | $io->writeln('The files listed by the "' . $gitOnlyCommand . '" command are not physically present.'); 194 | $io->writeln('This typically occurs when files are deleted without being staged in Git. To verify, check "git status".'); 195 | $io->newLine(); 196 | } 197 | $returnValue = 1; 198 | } 199 | 200 | if ($this->isVerbose) { 201 | if (!empty($this->scanner->getSkippedBinaryFiles())) { 202 | $amountBinaryFiles = count($this->scanner->getSkippedBinaryFiles()); 203 | $io->newLine(); 204 | $io->writeln('' . $amountBinaryFiles . ' binary ' . StringFormatUtility::pluralizeFiles($amountBinaryFiles) . ' skipped:'); 205 | foreach ($this->scanner->getSkippedBinaryFiles() as $binaryFile => $mimeType) { 206 | $io->writeln('' . $binaryFile . ' [' . $mimeType . ']'); 207 | } 208 | } 209 | 210 | $io->newLine(); 211 | $io->writeln('Time tracking'); 212 | $io->writeln('----------------------------------------'); 213 | $io->writeln(TimeTrackingUtility::getRecordedSteps()); 214 | $io->writeln('----------------------------------------'); 215 | $io->writeln('Memory peak: ' . round(memory_get_peak_usage(false) / 1024 / 1024, 2) . 'MB (Real: ' . round(memory_get_peak_usage(true) / 1024 / 1024, 2) . 'MB)'); 216 | } else { 217 | $io->writeln('Duration: ' . TimeTrackingUtility::getDuration() . 's'); 218 | } 219 | 220 | if ($input->getOption('no-error-on-exit')) { 221 | if ($returnValue > 0 && $this->isVerbose) { 222 | $io->writeln(sprintf('Bypassing error code %d', $returnValue)); 223 | } 224 | $returnValue = 0; 225 | } 226 | 227 | return $returnValue; 228 | } 229 | 230 | private function scan(Finder $finder, int $fileCount, SymfonyStyle $io, bool $strict = false, bool $noProgress = false, bool $compact = false, bool $uncovered = false): int 231 | { 232 | $io->writeln('Starting scan...'); 233 | 234 | $callback = null; 235 | $progressBar = null; 236 | if (!$noProgress) { 237 | // Progress bar 238 | $progressBar = $this->createProgressBar($io, $fileCount); 239 | $amountIssues = $amountFilesWithIssues = 0; 240 | $callback = static function (FileResult $fileResult) use ($progressBar, &$amountIssues, &$amountFilesWithIssues) { 241 | $progressBar->advance(); 242 | if (!$fileResult->isValid()) { 243 | ++$amountFilesWithIssues; 244 | $amountIssues += $fileResult->countErrors(); 245 | $progressBar->setMessage( 246 | '' . StringFormatUtility::buildScanResultMessage($amountIssues, $amountFilesWithIssues) . '' 247 | ); 248 | } 249 | }; 250 | } 251 | 252 | // Start the scan 253 | $fileResults = $this->scanner->scan($finder, $strict, $callback, $this->isVerbose); 254 | 255 | if (!$noProgress && $progressBar) { 256 | // Progress bar 257 | $progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%%'); 258 | $progressBar->finish(); 259 | $io->newLine(2); 260 | } 261 | 262 | // Prepare results after scan 263 | $invalidFilesCount = 0; 264 | $errorCountTotal = 0; 265 | $uncoveredFilePaths = []; 266 | foreach ($fileResults as $filePath => $fileResult) { 267 | if (!$fileResult->isValid()) { 268 | $errorCount = $fileResult->countErrors(); 269 | ++$invalidFilesCount; 270 | $errorCountTotal += $errorCount; 271 | $io->writeln('' . $filePath . ' [' . $errorCount . ']'); 272 | if (!$compact) { 273 | $io->listing(explode(PHP_EOL, $fileResult->getErrorsAsString())); 274 | } 275 | } 276 | if ($uncovered && !$fileResult->hasDeclarations()) { 277 | $uncoveredFilePaths[] = $filePath; 278 | } 279 | } 280 | 281 | // Output results 282 | if ($errorCountTotal > 0) { 283 | $io->writeln('' . StringFormatUtility::buildScanResultMessage($errorCountTotal, $invalidFilesCount) . ''); 284 | } else { 285 | $io->writeln('Done. ' . StringFormatUtility::buildScanResultMessage($errorCountTotal, $invalidFilesCount) . ''); 286 | } 287 | 288 | // Uncovered files 289 | if ($uncovered) { 290 | $io->newLine(); 291 | 292 | if (0 === count($uncoveredFilePaths)) { 293 | $io->writeln('No uncovered files found. Good job!'); 294 | } else { 295 | $textFiles = 1 === count($uncoveredFilePaths) ? 'One file is' : count($uncoveredFilePaths) . ' files are'; 296 | 297 | $io->writeln($textFiles . ' not covered by .editorconfig declarations:'); 298 | foreach ($uncoveredFilePaths as $unstagedFilePath) { 299 | $io->writeln('' . $unstagedFilePath . ''); 300 | } 301 | $io->newLine(); 302 | } 303 | } 304 | 305 | return $errorCountTotal > 0 ? 2 : 0; 306 | } 307 | 308 | private function fix(Finder $finder, SymfonyStyle $io, bool $strict = false): int 309 | { 310 | $io->writeln('Starting to fix issues...'); 311 | 312 | $fileResults = $this->scanner->scan($finder, $strict, null, $this->isVerbose); 313 | $invalidFilesCount = 0; 314 | $errorCountTotal = 0; 315 | $hasUnfixableExceptions = false; 316 | foreach ($fileResults as $file => $fileResult) { 317 | if (!$fileResult->isValid()) { 318 | ++$invalidFilesCount; 319 | $errorCountTotal += $fileResult->countErrors(); 320 | $fileResult->applyFixes(); 321 | 322 | if ($fileResult->hasUnfixableExceptions()) { 323 | $errorCountTotal -= $fileResult->countErrors(); 324 | $hasUnfixableExceptions = true; 325 | foreach ($fileResult->getUnfixableExceptions() as $e) { 326 | $io->writeln(' * WARNING ' . $e->getMessage()); 327 | } 328 | } else { 329 | $text = 1 === $fileResult->countErrors() ? 'one issue' : $fileResult->countErrors() . ' issues'; 330 | $io->writeln(' * fixed ' . $text . ' in file ' . $file . ''); 331 | } 332 | } 333 | } 334 | 335 | TimeTrackingUtility::addStep('Fixing finished'); 336 | $io->writeln('Done. ' . StringFormatUtility::buildScanResultMessage($errorCountTotal, $invalidFilesCount, 'Fixed') . ''); 337 | 338 | return false === $hasUnfixableExceptions ? 0 : 1; 339 | } 340 | 341 | private function createProgressBar(SymfonyStyle $io, int $fileCount): ProgressBar 342 | { 343 | $progressBar = $io->createProgressBar($fileCount); 344 | 345 | $progressBar->setProgressCharacter('>'); 346 | $progressBar->setBarCharacter('='); 347 | $progressBar->setBarWidth(50); 348 | $progressBar->setFormat(' %current%/%max% [%bar%] %percent:3s%% %remaining:6s% | %message%'); 349 | 350 | $progressBar->setMessage('No issues found, yet'); 351 | 352 | return $progressBar; 353 | } 354 | 355 | /** 356 | * @param array|null $skippingRules Strings in array may contain comma-separated values 357 | * 358 | * @return array|string[] 359 | */ 360 | private function parseSkippingRules(?array $skippingRules = null): array 361 | { 362 | if (!$skippingRules) { 363 | return []; 364 | } 365 | 366 | $skippingRules = ArrayUtility::flattenSeparatedValues($skippingRules); 367 | 368 | foreach ($skippingRules as $index => $skipRule) { 369 | $replacements = [ 370 | 'char' => 'charset', 371 | 'eol' => 'end_of_line', 372 | 'indent_size' => 'size', 373 | 'indent_style' => 'style', 374 | 'tab' => 'tab_width', 375 | 'newline' => 'insert_final_newline', 376 | 'trim' => 'trim_trailing_whitespace', 377 | ]; 378 | if (array_key_exists($skipRule, $replacements)) { 379 | $skippingRules[$index] = $replacements[$skipRule]; 380 | } 381 | } 382 | 383 | if (!empty($notExistingRules = array_diff($skippingRules, Rule::getDefinitions()))) { 384 | throw new \InvalidArgumentException('You try to skip rules which are not existing (' . implode(', ', $notExistingRules) . ').' . PHP_EOL . 'Available rules are: ' . implode(', ', Rule::getDefinitions()), 1621795334); 385 | } 386 | 387 | return $skippingRules; 388 | } 389 | } 390 | --------------------------------------------------------------------------------