From 6e42c83ba1975912ed8f5f008cf3ce8ed5335e42 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Tue, 12 Aug 2025 09:05:17 -0500 Subject: [PATCH 1/4] feat: add support for formatting code from stdin (#390) * feat: add support for formatting code from stdin * Formatting --------- Co-authored-by: Taylor Otwell --- app/Commands/DefaultCommand.php | 52 +++++++++++++++++++++++++ tests/Feature/StdinTest.php | 67 +++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+) create mode 100644 tests/Feature/StdinTest.php diff --git a/app/Commands/DefaultCommand.php b/app/Commands/DefaultCommand.php index c93598da..3999e17d 100644 --- a/app/Commands/DefaultCommand.php +++ b/app/Commands/DefaultCommand.php @@ -2,9 +2,11 @@ namespace App\Commands; +use App\Actions\FixCode; use LaravelZero\Framework\Commands\Command; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; +use Throwable; class DefaultCommand extends Command { @@ -62,8 +64,58 @@ 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. + */ + protected function fixStdinInput(FixCode $fixCode): int + { + $paths = $this->argument('path'); + + $contextPath = ! empty($paths) ? $paths[0] : 'stdin.php'; + $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. + */ + protected function hasStdinInput(): bool + { + if ($this->option('test') || $this->option('bail') || $this->option('repair')) { + return false; + } + + if (! is_resource(STDIN) || stream_isatty(STDIN)) { + return false; + } + + return ! stream_get_meta_data(STDIN)['eof']; + } } diff --git a/tests/Feature/StdinTest.php b/tests/Feature/StdinTest.php new file mode 100644 index 00000000..140baad2 --- /dev/null +++ b/tests/Feature/StdinTest.php @@ -0,0 +1,67 @@ +run('php pint 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, + ], +]); From 02096d4f7bf35f674ccec059c272c94e8ff5ded2 Mon Sep 17 00:00:00 2001 From: Assem Alwaseai <119125167+A909M@users.noreply.github.com> Date: Tue, 30 Sep 2025 16:49:02 +0300 Subject: [PATCH 2/4] feat: require explicit --stdin flag for stdin input --- app/Commands/DefaultCommand.php | 11 ++--------- tests/Feature/StdinTest.php | 2 +- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/app/Commands/DefaultCommand.php b/app/Commands/DefaultCommand.php index 3999e17d..737e51a9 100644 --- a/app/Commands/DefaultCommand.php +++ b/app/Commands/DefaultCommand.php @@ -51,6 +51,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', null, InputOption::VALUE_NONE, 'Read and format code from standard input'), ], ); } @@ -108,14 +109,6 @@ protected function fixStdinInput(FixCode $fixCode): int */ protected function hasStdinInput(): bool { - if ($this->option('test') || $this->option('bail') || $this->option('repair')) { - return false; - } - - if (! is_resource(STDIN) || stream_isatty(STDIN)) { - return false; - } - - return ! stream_get_meta_data(STDIN)['eof']; + return $this->option('stdin'); } } diff --git a/tests/Feature/StdinTest.php b/tests/Feature/StdinTest.php index 140baad2..c09cbbcf 100644 --- a/tests/Feature/StdinTest.php +++ b/tests/Feature/StdinTest.php @@ -3,7 +3,7 @@ use Illuminate\Support\Facades\Process; it('formats code from stdin', function (string $input, ?string $expected) { - $result = Process::input($input)->run('php pint app/Test.php')->throw(); + $result = Process::input($input)->run('php pint app/Test.php --stdin')->throw(); expect($result) ->output()->toBe($expected ?? $input) From e9e318d6dce89f9ee430e6ce7f5cc9c8071d3def Mon Sep 17 00:00:00 2001 From: Assem Alwaseai <119125167+A909M@users.noreply.github.com> Date: Thu, 9 Oct 2025 05:41:41 +0300 Subject: [PATCH 3/4] feat: add stdin support with dash and stdin-filename option - Replace --stdin flag with Unix-standard dash (-) for stdin input - Add --stdin-filename option for editor integration and context - Support both 'pint -' and 'pint --stdin-filename' patterns - Add comprehensive tests for stdin formatting scenarios --- app/Commands/DefaultCommand.php | 20 +++-- pint | 21 +++++ tests/Feature/StdinTest.php | 139 +++++++++++++++++++++++--------- 3 files changed, 139 insertions(+), 41 deletions(-) diff --git a/app/Commands/DefaultCommand.php b/app/Commands/DefaultCommand.php index 737e51a9..a1bf22c5 100644 --- a/app/Commands/DefaultCommand.php +++ b/app/Commands/DefaultCommand.php @@ -51,7 +51,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', null, InputOption::VALUE_NONE, 'Read and format code from standard input'), + new InputOption('stdin-filename', null, InputOption::VALUE_REQUIRED, 'Provide file path context for stdin input'), ], ); } @@ -76,12 +76,13 @@ public function handle($fixCode, $elaborateSummary) /** * Fix the code sent to Pint on stdin and output to stdout. + * + * The stdin-filename option provides file path context for error messages. + * Falls back to 'stdin.php' if not provided. */ protected function fixStdinInput(FixCode $fixCode): int { - $paths = $this->argument('path'); - - $contextPath = ! empty($paths) ? $paths[0] : 'stdin.php'; + $contextPath = $this->option('stdin-filename') ?: 'stdin.php'; $tempFile = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pint_stdin_'.uniqid().'.php'; $this->input->setArgument('path', [$tempFile]); @@ -106,9 +107,18 @@ protected function fixStdinInput(FixCode $fixCode): int /** * Determine if there is input available on stdin. + * + * Stdin mode is triggered by either: + * - Passing '-' as the path argument (Unix convention like Black, cat) + * - Providing the --stdin-filename option (editor-friendly like Prettier) */ protected function hasStdinInput(): bool { - return $this->option('stdin'); + $paths = $this->argument('path'); + + $hasStdinPlaceholder = ! empty($paths) && $paths[0] === '__STDIN_PLACEHOLDER__'; + $hasStdinFilename = ! empty($this->option('stdin-filename')); + + return $hasStdinPlaceholder || $hasStdinFilename; } } 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 index c09cbbcf..b88541e0 100644 --- a/tests/Feature/StdinTest.php +++ b/tests/Feature/StdinTest.php @@ -3,65 +3,132 @@ use Illuminate\Support\Facades\Process; it('formats code from stdin', function (string $input, ?string $expected) { - $result = Process::input($input)->run('php pint app/Test.php --stdin')->throw(); + $result = Process::input($input) + ->run('php pint - --stdin-filename=app/Test.php') + ->throw(); expect($result) - ->output()->toBe($expected ?? $input) - ->errorOutput()->toBe(''); + ->output() + ->toBe($expected ?? $input) + ->errorOutput() + ->toBe(''); })->with([ 'basic array and conditional' => [ <<<'PHP' - [ <<<'PHP' - "value"); - } - } - PHP, + "value"); + } + } + PHP + , <<<'PHP' - 'value']; - } + return ['key' => 'value']; } + } - PHP, + PHP + , ], 'already formatted code' => [ <<<'PHP' - 'value']; - } + return ['key' => 'value']; } + } - PHP, + 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(''); +}); From 7b8461da922379b03afb718ff7d6780bdc8543b2 Mon Sep 17 00:00:00 2001 From: Assem Alwaseai <119125167+A909M@users.noreply.github.com> Date: Fri, 10 Oct 2025 06:12:31 +0300 Subject: [PATCH 4/4] feat(stdin): respect exclusion rules for stdin-filename option - Add ConfigurationFactory::isPathExcluded() to check file exclusion rules - Check stdin-filename against default and pint.json exclusions - Return original code unchanged when path is excluded - Add comprehensive unit and feature tests for exclusion behavior --- app/Commands/DefaultCommand.php | 17 ++++- app/Factories/ConfigurationFactory.php | 58 +++++++++++++++ tests/Feature/StdinTest.php | 44 +++++++++++ .../Factories/ConfigurationFactoryTest.php | 73 +++++++++++++++++++ 4 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 tests/Unit/Factories/ConfigurationFactoryTest.php diff --git a/app/Commands/DefaultCommand.php b/app/Commands/DefaultCommand.php index a1bf22c5..6a8fcfcf 100644 --- a/app/Commands/DefaultCommand.php +++ b/app/Commands/DefaultCommand.php @@ -3,6 +3,7 @@ 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; @@ -77,12 +78,20 @@ public function handle($fixCode, $elaborateSummary) /** * Fix the code sent to Pint on stdin and output to stdout. * - * The stdin-filename option provides file path context for error messages. - * Falls back to 'stdin.php' if not provided. + * 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]); @@ -109,8 +118,8 @@ protected function fixStdinInput(FixCode $fixCode): int * Determine if there is input available on stdin. * * Stdin mode is triggered by either: - * - Passing '-' as the path argument (Unix convention like Black, cat) - * - Providing the --stdin-filename option (editor-friendly like Prettier) + * - Passing '-' as path (transformed to '__STDIN_PLACEHOLDER__' in pint:56-61) + * - Providing the --stdin-filename option */ protected function hasStdinInput(): bool { 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/tests/Feature/StdinTest.php b/tests/Feature/StdinTest.php index b88541e0..79061b88 100644 --- a/tests/Feature/StdinTest.php +++ b/tests/Feature/StdinTest.php @@ -132,3 +132,47 @@ public function method() 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(); +});