diff --git a/build/phpstan.neon b/build/phpstan.neon index 63e9a82ace..61f9c9290c 100644 --- a/build/phpstan.neon +++ b/build/phpstan.neon @@ -22,17 +22,13 @@ parameters: checkUninitializedProperties: true checkMissingCallableSignature: true excludePaths: - - ../src/Reflection/SignatureMap/functionMap.php - - ../src/Reflection/SignatureMap/functionMetadata.php - ../tests/*/data/* - ../tests/tmp/* - ../tests/PHPStan/Analyser/nsrt/* - ../tests/PHPStan/Analyser/traits/* - ../tests/notAutoloaded/* - - ../tests/PHPStan/Generics/functions.php - ../tests/PHPStan/Reflection/UnionTypesTest.php - ../tests/PHPStan/Reflection/MixedTypeTest.php - - ../tests/PHPStan/Reflection/StaticTypeTest.php - ../tests/e2e/magic-setter/* - ../tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php - ../tests/PHPStan/Command/IgnoredRegexValidatorTest.php diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index 61482ac786..a924d8222a 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -59,5 +59,6 @@ parameters: printfArrayParameters: true preciseMissingReturn: true validatePregQuote: true + noImplicitWildcard: true stubFiles: - ../stubs/bleedingEdge/Rule.stub diff --git a/conf/config.neon b/conf/config.neon index 8adebd5693..a6a75e7b59 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -94,6 +94,7 @@ parameters: printfArrayParameters: false preciseMissingReturn: false validatePregQuote: false + noImplicitWildcard: false fileExtensions: - php checkAdvancedIsset: false @@ -269,6 +270,7 @@ extensions: conditionalTags: PHPStan\DependencyInjection\ConditionalTagsExtension parametersSchema: PHPStan\DependencyInjection\ParametersSchemaExtension validateIgnoredErrors: PHPStan\DependencyInjection\ValidateIgnoredErrorsExtension + validateExcludePaths: PHPStan\DependencyInjection\ValidateExcludePathsExtension rules: - PHPStan\Rules\Debug\DumpTypeRule @@ -676,6 +678,8 @@ services: - implement: PHPStan\File\FileExcluderRawFactory + arguments: + noImplicitWildcard: %featureToggles.noImplicitWildcard% fileExcluderAnalyse: class: PHPStan\File\FileExcluder diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 29efd1a8b3..a217bfc4e0 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -89,6 +89,7 @@ parametersSchema: printfArrayParameters: bool() preciseMissingReturn: bool() validatePregQuote: bool() + noImplicitWildcard: bool() ]) fileExtensions: listOf(string()) checkAdvancedIsset: bool() diff --git a/src/Analyser/Ignore/IgnoredError.php b/src/Analyser/Ignore/IgnoredError.php index 00cc1e7df8..ecbe6e1059 100644 --- a/src/Analyser/Ignore/IgnoredError.php +++ b/src/Analyser/Ignore/IgnoredError.php @@ -4,6 +4,7 @@ use Nette\Utils\Strings; use PHPStan\Analyser\Error; +use PHPStan\DependencyInjection\BleedingEdgeToggle; use PHPStan\File\FileExcluder; use PHPStan\File\FileHelper; use PHPStan\ShouldNotHappenException; @@ -85,7 +86,7 @@ public static function shouldIgnore( } if ($path !== null) { - $fileExcluder = new FileExcluder($fileHelper, [$path]); + $fileExcluder = new FileExcluder($fileHelper, [$path], BleedingEdgeToggle::isBleedingEdge()); $isExcluded = $fileExcluder->isExcludedFromAnalysing($error->getFilePath()); if (!$isExcluded && $error->getTraitFilePath() !== null) { return $fileExcluder->isExcludedFromAnalysing($error->getTraitFilePath()); diff --git a/src/Command/CommandHelper.php b/src/Command/CommandHelper.php index d38aee7f23..ff3bf242bd 100644 --- a/src/Command/CommandHelper.php +++ b/src/Command/CommandHelper.php @@ -18,6 +18,7 @@ use PHPStan\DependencyInjection\Container; use PHPStan\DependencyInjection\ContainerFactory; use PHPStan\DependencyInjection\DuplicateIncludedFilesException; +use PHPStan\DependencyInjection\InvalidExcludePathsException; use PHPStan\DependencyInjection\InvalidIgnoredErrorPatternsException; use PHPStan\DependencyInjection\LoaderFactory; use PHPStan\ExtensionInstaller\GeneratedConfig; @@ -355,6 +356,13 @@ public static function begin( $errorOutput->writeLineFormatted(''); } throw new InceptionNotSuccessfulException(); + } catch (InvalidExcludePathsException $e) { + $errorOutput->writeLineFormatted(sprintf('Invalid %s in excludePaths:', count($e->getErrors()) === 1 ? 'entry' : 'entries')); + foreach ($e->getErrors() as $error) { + $errorOutput->writeLineFormatted($error); + $errorOutput->writeLineFormatted(''); + } + throw new InceptionNotSuccessfulException(); } catch (ValidationException $e) { foreach ($e->getMessages() as $message) { $errorOutput->writeLineFormatted('Invalid configuration:'); @@ -583,7 +591,7 @@ public static function begin( $pathRoutingParser->setAnalysedFiles($files); - $stubFilesExcluder = new FileExcluder($currentWorkingDirectoryFileHelper, $stubFilesProvider->getProjectStubFiles()); + $stubFilesExcluder = new FileExcluder($currentWorkingDirectoryFileHelper, $stubFilesProvider->getProjectStubFiles(), true); $files = array_values(array_filter($files, static fn (string $file) => !$stubFilesExcluder->isExcludedFromAnalysing($file))); diff --git a/src/DependencyInjection/InvalidExcludePathsException.php b/src/DependencyInjection/InvalidExcludePathsException.php new file mode 100644 index 0000000000..d230b21108 --- /dev/null +++ b/src/DependencyInjection/InvalidExcludePathsException.php @@ -0,0 +1,27 @@ +errors)); + } + + /** + * @return string[] + */ + public function getErrors(): array + { + return $this->errors; + } + +} diff --git a/src/DependencyInjection/NeonAdapter.php b/src/DependencyInjection/NeonAdapter.php index 15fb210b33..299bd18b3e 100644 --- a/src/DependencyInjection/NeonAdapter.php +++ b/src/DependencyInjection/NeonAdapter.php @@ -29,7 +29,7 @@ class NeonAdapter implements Adapter { - public const CACHE_KEY = 'v25-nette-di-again'; + public const CACHE_KEY = 'v26-no-implicit-wildcard'; private const PREVENT_MERGING_SUFFIX = '!'; diff --git a/src/DependencyInjection/ValidateExcludePathsExtension.php b/src/DependencyInjection/ValidateExcludePathsExtension.php new file mode 100644 index 0000000000..e98e512b31 --- /dev/null +++ b/src/DependencyInjection/ValidateExcludePathsExtension.php @@ -0,0 +1,68 @@ +getContainerBuilder(); + if (!$builder->parameters['__validate']) { + return; + } + + $excludePaths = $builder->parameters['excludePaths']; + if ($excludePaths === null) { + return; + } + + $noImplicitWildcard = $builder->parameters['featureToggles']['noImplicitWildcard']; + if (!$noImplicitWildcard) { + return; + } + + $paths = []; + if (array_key_exists('analyse', $excludePaths)) { + $paths = $excludePaths['analyse']; + } + if (array_key_exists('analyseAndScan', $excludePaths)) { + $paths = array_merge($paths, $excludePaths['analyseAndScan']); + } + + $errors = []; + foreach (array_unique($paths) as $path) { + if (is_dir($path)) { + continue; + } + if (is_file($path)) { + continue; + } + if (FileExcluder::isFnmatchPattern($path)) { + continue; + } + + $errors[] = sprintf('Path %s is neither a directory, nor a file path, nor a fnmatch pattern.', $path); + } + + if (count($errors) === 0) { + return; + } + + throw new InvalidExcludePathsException($errors); + } + +} diff --git a/src/DependencyInjection/ValidateIgnoredErrorsExtension.php b/src/DependencyInjection/ValidateIgnoredErrorsExtension.php index cd93ab2247..572e7c6dcd 100644 --- a/src/DependencyInjection/ValidateIgnoredErrorsExtension.php +++ b/src/DependencyInjection/ValidateIgnoredErrorsExtension.php @@ -11,6 +11,7 @@ use PHPStan\Analyser\NameScope; use PHPStan\Command\IgnoredRegexValidator; use PHPStan\DependencyInjection\Type\OperatorTypeSpecifyingExtensionRegistryProvider; +use PHPStan\File\FileExcluder; use PHPStan\Php\PhpVersion; use PHPStan\PhpDoc\DirectTypeNodeResolverExtensionRegistryProvider; use PHPStan\PhpDoc\TypeNodeResolver; @@ -34,6 +35,8 @@ use function count; use function implode; use function is_array; +use function is_dir; +use function is_file; use function sprintf; use const PHP_VERSION_ID; @@ -55,6 +58,8 @@ public function loadConfiguration(): void return; } + $noImplicitWildcard = $builder->parameters['featureToggles']['noImplicitWildcard']; + /** @throws void */ $parser = Llk::load(new Read(__DIR__ . '/../../resources/RegexGrammar.pp')); $reflectionProvider = new DummyReflectionProvider(); @@ -131,6 +136,36 @@ public function getRegistry(): OperatorTypeSpecifyingExtensionRegistry } } + if ($noImplicitWildcard) { + foreach ($ignoreErrors as $ignoreError) { + if (!is_array($ignoreError)) { + continue; + } + + if (isset($ignoreError['path'])) { + $ignorePaths = [$ignoreError['path']]; + } elseif (isset($ignoreError['paths'])) { + $ignorePaths = $ignoreError['paths']; + } else { + continue; + } + + foreach ($ignorePaths as $ignorePath) { + if (is_dir($ignorePath)) { + continue; + } + if (is_file($ignorePath)) { + continue; + } + if (FileExcluder::isFnmatchPattern($ignorePath)) { + continue; + } + + $errors[] = sprintf('Path %s is neither a directory, nor a file path, nor a fnmatch pattern.', $ignorePath); + } + } + } + if (count($errors) === 0) { return; } diff --git a/src/File/FileExcluder.php b/src/File/FileExcluder.php index 2ea5271d5d..7ebf1aa20a 100644 --- a/src/File/FileExcluder.php +++ b/src/File/FileExcluder.php @@ -4,6 +4,8 @@ use function fnmatch; use function in_array; +use function is_dir; +use function is_file; use function preg_match; use function str_starts_with; use function strlen; @@ -15,12 +17,26 @@ class FileExcluder { /** - * Directories to exclude from analysing + * Paths to exclude from analysing * * @var string[] */ private array $literalAnalyseExcludes = []; + /** + * Directories to exclude from analysing + * + * @var string[] + */ + private array $literalAnalyseDirectoryExcludes = []; + + /** + * Files to exclude from analysing + * + * @var string[] + */ + private array $literalAnalyseFilesExcludes = []; + /** * fnmatch() patterns to use for excluding files and directories from analysing * @var string[] @@ -35,6 +51,7 @@ class FileExcluder public function __construct( FileHelper $fileHelper, array $analyseExcludes, + private bool $noImplicitWildcard, ) { foreach ($analyseExcludes as $exclude) { @@ -47,10 +64,22 @@ public function __construct( $normalized .= DIRECTORY_SEPARATOR; } - if ($this->isFnmatchPattern($normalized)) { + if (self::isFnmatchPattern($normalized)) { $this->fnmatchAnalyseExcludes[] = $normalized; } else { - $this->literalAnalyseExcludes[] = $fileHelper->absolutizePath($normalized); + if ($this->noImplicitWildcard) { + if (is_file($normalized)) { + $this->literalAnalyseFilesExcludes[] = $normalized; + } elseif (is_dir($normalized)) { + if (!$trailingDirSeparator) { + $normalized .= DIRECTORY_SEPARATOR; + } + + $this->literalAnalyseDirectoryExcludes[] = $normalized; + } + } else { + $this->literalAnalyseExcludes[] = $fileHelper->absolutizePath($normalized); + } } } @@ -69,6 +98,18 @@ public function isExcludedFromAnalysing(string $file): bool return true; } } + if ($this->noImplicitWildcard) { + foreach ($this->literalAnalyseDirectoryExcludes as $exclude) { + if (str_starts_with($file, $exclude)) { + return true; + } + } + foreach ($this->literalAnalyseFilesExcludes as $exclude) { + if ($file === $exclude) { + return true; + } + } + } foreach ($this->fnmatchAnalyseExcludes as $exclude) { if (fnmatch($exclude, $file, $this->fnmatchFlags)) { return true; @@ -78,7 +119,7 @@ public function isExcludedFromAnalysing(string $file): bool return false; } - private function isFnmatchPattern(string $path): bool + public static function isFnmatchPattern(string $path): bool { return preg_match('~[*?[\]]~', $path) > 0; } diff --git a/tests/PHPStan/File/FileExcluderTest.php b/tests/PHPStan/File/FileExcluderTest.php index eebe8236e6..7477416b6c 100644 --- a/tests/PHPStan/File/FileExcluderTest.php +++ b/tests/PHPStan/File/FileExcluderTest.php @@ -19,7 +19,7 @@ public function testFilesAreExcludedFromAnalysingOnWindows( { $this->skipIfNotOnWindows(); - $fileExcluder = new FileExcluder($this->getFileHelper(), $analyseExcludes); + $fileExcluder = new FileExcluder($this->getFileHelper(), $analyseExcludes, false); $this->assertSame($isExcluded, $fileExcluder->isExcludedFromAnalysing($filePath)); } @@ -127,7 +127,7 @@ public function testFilesAreExcludedFromAnalysingOnUnix( { $this->skipIfNotOnUnix(); - $fileExcluder = new FileExcluder($this->getFileHelper(), $analyseExcludes); + $fileExcluder = new FileExcluder($this->getFileHelper(), $analyseExcludes, false); $this->assertSame($isExcluded, $fileExcluder->isExcludedFromAnalysing($filePath)); } @@ -208,4 +208,70 @@ public function dataExcludeOnUnix(): array ]; } + public function dataNoImplicitWildcard(): iterable + { + yield [ + __DIR__ . '/tests/foo.php', + [ + __DIR__ . '/test', + ], + false, + true, + ]; + + yield [ + __DIR__ . '/tests/foo.php', + [ + __DIR__ . '/test', + ], + true, + false, + ]; + + yield [ + __DIR__ . '/test/foo.php', + [ + __DIR__ . '/test', + ], + true, + true, + ]; + + yield [ + __DIR__ . '/FileExcluderTest.php', + [ + __DIR__ . '/FileExcluderTest.php', + ], + true, + true, + ]; + + yield [ + __DIR__ . '/tests/foo.php', + [ + __DIR__ . '/test*', + ], + true, + true, + ]; + } + + /** + * @dataProvider dataNoImplicitWildcard + * @param string[] $analyseExcludes + */ + public function testNoImplicitWildcard( + string $filePath, + array $analyseExcludes, + bool $noImplicitWildcard, + bool $isExcluded, + ): void + { + $this->skipIfNotOnUnix(); + + $fileExcluder = new FileExcluder($this->getFileHelper(), $analyseExcludes, $noImplicitWildcard); + + $this->assertSame($isExcluded, $fileExcluder->isExcludedFromAnalysing($filePath)); + } + } diff --git a/tests/PHPStan/File/test/.gitkeep b/tests/PHPStan/File/test/.gitkeep new file mode 100644 index 0000000000..e69de29bb2