diff --git a/app/Commands/DefaultCommand.php b/app/Commands/DefaultCommand.php index c93598da..6a8fcfcf 100644 --- a/app/Commands/DefaultCommand.php +++ b/app/Commands/DefaultCommand.php @@ -2,9 +2,12 @@ namespace App\Commands; +use App\Actions\FixCode; +use App\Factories\ConfigurationFactory; use LaravelZero\Framework\Commands\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; +use Throwable; class DefaultCommand extends Command { @@ -49,6 +52,7 @@ protected function configure() new InputOption('cache-file', '', InputArgument::OPTIONAL, 'The path to the cache file'), new InputOption('parallel', 'p', InputOption::VALUE_NONE, 'Runs the linter in parallel (Experimental)'), new InputOption('max-processes', null, InputOption::VALUE_REQUIRED, 'The number of processes to spawn when using parallel execution'), + new InputOption('stdin-filename', null, InputOption::VALUE_REQUIRED, 'Provide file path context for stdin input'), ], ); } @@ -62,8 +66,68 @@ protected function configure() */ public function handle($fixCode, $elaborateSummary) { + if ($this->hasStdinInput()) { + return $this->fixStdinInput($fixCode); + } + [$totalFiles, $changes] = $fixCode->execute(); return $elaborateSummary->execute($totalFiles, $changes); } + + /** + * Fix the code sent to Pint on stdin and output to stdout. + * + * The stdin-filename option provides file path context. If the path matches + * exclusion rules, the original code is returned unchanged. Falls back to + * 'stdin.php' if not provided. + */ + protected function fixStdinInput(FixCode $fixCode): int + { + $contextPath = $this->option('stdin-filename') ?: 'stdin.php'; + + if ($this->option('stdin-filename') && ConfigurationFactory::isPathExcluded($contextPath)) { + fwrite(STDOUT, stream_get_contents(STDIN)); + + return self::SUCCESS; + } + + $tempFile = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pint_stdin_'.uniqid().'.php'; + + $this->input->setArgument('path', [$tempFile]); + $this->input->setOption('format', 'json'); + + try { + file_put_contents($tempFile, stream_get_contents(STDIN)); + $fixCode->execute(); + fwrite(STDOUT, file_get_contents($tempFile)); + + return self::SUCCESS; + } catch (Throwable $e) { + fwrite(STDERR, "pint: error processing {$contextPath}: {$e->getMessage()}\n"); + + return self::FAILURE; + } finally { + if (file_exists($tempFile)) { + @unlink($tempFile); + } + } + } + + /** + * Determine if there is input available on stdin. + * + * Stdin mode is triggered by either: + * - Passing '-' as path (transformed to '__STDIN_PLACEHOLDER__' in pint:56-61) + * - Providing the --stdin-filename option + */ + protected function hasStdinInput(): bool + { + $paths = $this->argument('path'); + + $hasStdinPlaceholder = ! empty($paths) && $paths[0] === '__STDIN_PLACEHOLDER__'; + $hasStdinFilename = ! empty($this->option('stdin-filename')); + + return $hasStdinPlaceholder || $hasStdinFilename; + } } diff --git a/app/Factories/ConfigurationFactory.php b/app/Factories/ConfigurationFactory.php index 4c9b0ec9..eea16dfd 100644 --- a/app/Factories/ConfigurationFactory.php +++ b/app/Factories/ConfigurationFactory.php @@ -76,4 +76,62 @@ public static function finder() return $finder; } + + /** + * Check if a file path should be excluded based on finder rules. + */ + public static function isPathExcluded(string $filePath): bool + { + $localConfiguration = resolve(ConfigurationJsonRepository::class); + $basePath = getcwd(); + + $relativePath = str_starts_with($filePath, $basePath) + ? substr($filePath, strlen($basePath) + 1) + : $filePath; + + $relativePath = str_replace('\\', '/', $relativePath); + $fileName = basename($filePath); + + foreach (static::$notName as $pattern) { + if (fnmatch($pattern, $fileName)) { + return true; + } + } + + foreach (static::$exclude as $excludedFolder) { + $excludedFolder = str_replace('\\', '/', $excludedFolder); + if (str_starts_with($relativePath, $excludedFolder.'/') || $relativePath === $excludedFolder) { + return true; + } + } + + $finderConfig = $localConfiguration->finder(); + + if (isset($finderConfig['notName'])) { + foreach ((array) $finderConfig['notName'] as $pattern) { + if (fnmatch($pattern, $fileName)) { + return true; + } + } + } + + if (isset($finderConfig['exclude'])) { + foreach ((array) $finderConfig['exclude'] as $excludedFolder) { + $excludedFolder = str_replace('\\', '/', $excludedFolder); + if (str_starts_with($relativePath, $excludedFolder.'/') || $relativePath === $excludedFolder) { + return true; + } + } + } + + if (isset($finderConfig['notPath'])) { + foreach ((array) $finderConfig['notPath'] as $pattern) { + if (fnmatch($pattern, $relativePath)) { + return true; + } + } + } + + return false; + } } diff --git a/pint b/pint index 47c00be5..466b6b21 100755 --- a/pint +++ b/pint @@ -40,6 +40,27 @@ $app = require_once __DIR__.'/bootstrap/app.php'; $kernel = $app->make(Illuminate\Contracts\Console\Kernel::class); +/* +|-------------------------------------------------------------------------- +| Handle Stdin Mode +|-------------------------------------------------------------------------- +| +| When using '-' to indicate stdin input (following Unix convention like +| Black, cat, etc.), Symfony Console's ArgvInput parser fails because it +| treats '-' as a malformed option. We work around this by replacing '-' +| with a placeholder before the input is parsed. The DefaultCommand then +| detects this placeholder to enable stdin mode. +| +*/ + +if (isset($_SERVER['argv'])) { + $stdinIndex = array_search('-', $_SERVER['argv'], true); + + if ($stdinIndex !== false) { + $_SERVER['argv'][$stdinIndex] = '__STDIN_PLACEHOLDER__'; + } +} + $status = $kernel->handle( $input = new Symfony\Component\Console\Input\ArgvInput, new Symfony\Component\Console\Output\ConsoleOutput diff --git a/tests/Feature/StdinTest.php b/tests/Feature/StdinTest.php new file mode 100644 index 00000000..79061b88 --- /dev/null +++ b/tests/Feature/StdinTest.php @@ -0,0 +1,178 @@ +run('php pint - --stdin-filename=app/Test.php') + ->throw(); + + expect($result) + ->output() + ->toBe($expected ?? $input) + ->errorOutput() + ->toBe(''); +})->with([ + 'basic array and conditional' => [ + <<<'PHP' + [ + <<<'PHP' + "value"); + } + } + PHP + , + <<<'PHP' + 'value']; + } + } + + PHP + , + ], + 'already formatted code' => [ + <<<'PHP' + 'value']; + } + } + + PHP + , + null, + ], +]); + +it('formats code from stdin without filename', function () { + $input = <<<'PHP' + run('php pint -')->throw(); + + expect($result)->output()->toBe($expected)->errorOutput()->toBe(''); +}); + +it('uses stdin-filename for context', function () { + $input = <<<'PHP' + run('php pint - --stdin-filename=app/Models/User.php') + ->throw(); + + expect($result)->output()->toBe($expected)->errorOutput()->toBe(''); +}); + +it('formats code from stdin using only stdin-filename option', function () { + $input = <<<'PHP' + run('php pint --stdin-filename=app/Models/Example.php') + ->throw(); + + expect($result)->output()->toBe($expected)->errorOutput()->toBe(''); +}); + +it('skips formatting for excluded paths', function (string $filename) { + $input = <<<'PHP' + run("php pint --stdin-filename={$filename}") + ->throw(); + + expect($result)->output()->toBe($input)->errorOutput()->toBe(''); +})->with([ + 'blade files' => ['resources/views/welcome.blade.php'], + 'storage folder' => ['storage/framework/views/compiled.php'], + 'node_modules' => ['node_modules/package/index.php'], +]); + +it('respects pint.json exclusion rules', function (string $filename, bool $shouldFormat) { + $input = <<<'PHP' + path(base_path('tests/Fixtures/finder')) + ->run('php '.base_path('pint')." --stdin-filename={$filename}") + ->throw(); + + expect($result)->output()->toBe($expected)->errorOutput()->toBe(''); +})->with([ + 'excluded folder' => ['my-dir/SomeFile.php', false], + 'excluded notName pattern' => ['src/test-my-file.php', false], + 'excluded notPath pattern' => ['path/to/excluded-file.php', false], + 'not excluded' => ['src/MyClass.php', true], +]); diff --git a/tests/Unit/Factories/ConfigurationFactoryTest.php b/tests/Unit/Factories/ConfigurationFactoryTest.php new file mode 100644 index 00000000..48ee6caa --- /dev/null +++ b/tests/Unit/Factories/ConfigurationFactoryTest.php @@ -0,0 +1,73 @@ +bind(ConfigurationJsonRepository::class, fn () => new ConfigurationJsonRepository(null, null)); + + expect(ConfigurationFactory::isPathExcluded('src/MyClass.php'))->toBeFalse() + ->and(ConfigurationFactory::isPathExcluded('app/Services/UserService.php'))->toBeFalse() + ->and(ConfigurationFactory::isPathExcluded('tests/Unit/MyTest.php'))->toBeFalse(); +}); + +it('excludes files matching default notName patterns', function () { + app()->bind(ConfigurationJsonRepository::class, fn () => new ConfigurationJsonRepository(null, null)); + + expect(ConfigurationFactory::isPathExcluded('resources/views/welcome.blade.php'))->toBeTrue() + ->and(ConfigurationFactory::isPathExcluded('app/User.blade.php'))->toBeTrue() + ->and(ConfigurationFactory::isPathExcluded('_ide_helper.php'))->toBeTrue() + ->and(ConfigurationFactory::isPathExcluded('_ide_helper_models.php'))->toBeTrue() + ->and(ConfigurationFactory::isPathExcluded('.phpstorm.meta.php'))->toBeTrue(); +}); + +it('excludes files in default exclude folders', function () { + app()->bind(ConfigurationJsonRepository::class, fn () => new ConfigurationJsonRepository(null, null)); + + expect(ConfigurationFactory::isPathExcluded('node_modules/package/index.php'))->toBeTrue() + ->and(ConfigurationFactory::isPathExcluded('storage/logs/app.php'))->toBeTrue() + ->and(ConfigurationFactory::isPathExcluded('build/output.php'))->toBeTrue() + ->and(ConfigurationFactory::isPathExcluded('bootstrap/cache/services.php'))->toBeTrue(); +}); + +it('excludes files based on pint.json exclude config', function () { + $configPath = dirname(__DIR__, 2).'/Fixtures/finder/pint.json'; + app()->bind(ConfigurationJsonRepository::class, fn () => new ConfigurationJsonRepository($configPath, null)); + + expect(ConfigurationFactory::isPathExcluded('my-dir/SomeFile.php'))->toBeTrue() + ->and(ConfigurationFactory::isPathExcluded('my-dir/nested/File.php'))->toBeTrue() + ->and(ConfigurationFactory::isPathExcluded('other-dir/File.php'))->toBeFalse(); +}); + +it('excludes files matching pint.json notName patterns', function () { + $configPath = dirname(__DIR__, 2).'/Fixtures/finder/pint.json'; + app()->bind(ConfigurationJsonRepository::class, fn () => new ConfigurationJsonRepository($configPath, null)); + + expect(ConfigurationFactory::isPathExcluded('src/test-my-file.php'))->toBeTrue() + ->and(ConfigurationFactory::isPathExcluded('app/Models/foo-my-file.php'))->toBeTrue() + ->and(ConfigurationFactory::isPathExcluded('test-other-file.php'))->toBeFalse(); +}); + +it('excludes files matching pint.json notPath patterns', function () { + $configPath = dirname(__DIR__, 2).'/Fixtures/finder/pint.json'; + app()->bind(ConfigurationJsonRepository::class, fn () => new ConfigurationJsonRepository($configPath, null)); + + expect(ConfigurationFactory::isPathExcluded('path/to/excluded-file.php'))->toBeTrue() + ->and(ConfigurationFactory::isPathExcluded('path/to/other-file.php'))->toBeFalse(); +}); + +it('handles absolute paths correctly', function () { + app()->bind(ConfigurationJsonRepository::class, fn () => new ConfigurationJsonRepository(null, null)); + + $absolutePath = getcwd().DIRECTORY_SEPARATOR.'storage'.DIRECTORY_SEPARATOR.'app.php'; + + expect(ConfigurationFactory::isPathExcluded($absolutePath))->toBeTrue(); +}); + +it('handles paths with backslashes on Windows', function () { + $configPath = dirname(__DIR__, 2).'/Fixtures/finder/pint.json'; + app()->bind(ConfigurationJsonRepository::class, fn () => new ConfigurationJsonRepository($configPath, null)); + + expect(ConfigurationFactory::isPathExcluded('my-dir\\nested\\File.php'))->toBeTrue() + ->and(ConfigurationFactory::isPathExcluded('path\\to\\excluded-file.php'))->toBeTrue(); +});