diff --git a/fractor-xml/src/Contract/XmlFractor.php b/fractor-xml/src/Contract/XmlFractor.php index 70092c27..c6df5453 100644 --- a/fractor-xml/src/Contract/XmlFractor.php +++ b/fractor-xml/src/Contract/XmlFractor.php @@ -2,9 +2,10 @@ namespace a9f\FractorXml\Contract; +use a9f\Fractor\Application\Contract\FractorRule; use a9f\FractorXml\DomDocumentIterator; -interface XmlFractor extends DomNodeVisitor +interface XmlFractor extends DomNodeVisitor, FractorRule { public function canHandle(\DOMNode $node): bool; diff --git a/fractor-xml/src/XmlFileProcessor.php b/fractor-xml/src/XmlFileProcessor.php index be8bdf28..b24e3191 100644 --- a/fractor-xml/src/XmlFileProcessor.php +++ b/fractor-xml/src/XmlFileProcessor.php @@ -6,12 +6,12 @@ use a9f\Fractor\Application\ValueObject\File; use a9f\FractorXml\Contract\XmlFractor; -final class XmlFileProcessor implements FileProcessor +final readonly class XmlFileProcessor implements FileProcessor { /** - * @param list $rules + * @param XmlFractor[] $rules */ - public function __construct(private readonly iterable $rules) + public function __construct(private iterable $rules) { } diff --git a/fractor/config/application.php b/fractor/config/application.php index 5ecd29ae..d92693b4 100644 --- a/fractor/config/application.php +++ b/fractor/config/application.php @@ -17,6 +17,7 @@ return static function (ContainerConfigurator $containerConfigurator, ContainerBuilder $containerBuilder): void { $parameters = $containerConfigurator->parameters(); $parameters->set(Option::PATHS, [__DIR__]); + $parameters->set(Option::SKIP, []); $services = $containerConfigurator->services(); $services->defaults() ->autowire() diff --git a/fractor/src/Application/Contract/FractorRule.php b/fractor/src/Application/Contract/FractorRule.php new file mode 100644 index 00000000..578c6ac6 --- /dev/null +++ b/fractor/src/Application/Contract/FractorRule.php @@ -0,0 +1,9 @@ + $nameCandidates */ - private static function getOptionValue(ArgvInput $input, array $nameCandidates): ?string + private static function getOptionValue(ArgvInput $input): ?string { + $nameCandidates = ['--config', '-c']; foreach ($nameCandidates as $name) { if ($input->hasParameterOption($name, true)) { return $input->getParameterOption($name, null, true); diff --git a/fractor/src/Configuration/ConfigurationFactory.php b/fractor/src/Configuration/ConfigurationFactory.php index 03d67496..231956da 100644 --- a/fractor/src/Configuration/ConfigurationFactory.php +++ b/fractor/src/Configuration/ConfigurationFactory.php @@ -18,6 +18,7 @@ public function create(): Configuration return new Configuration( $this->allowedFileExtensionsResolver->resolve(), $this->parameterBag->get(Option::PATHS), + $this->parameterBag->get(Option::SKIP), ); } } diff --git a/fractor/src/Configuration/Option.php b/fractor/src/Configuration/Option.php index db290ce9..39c8873c 100644 --- a/fractor/src/Configuration/Option.php +++ b/fractor/src/Configuration/Option.php @@ -7,4 +7,5 @@ final class Option { public const PATHS = 'paths'; + public const SKIP = 'skip'; } diff --git a/fractor/src/Configuration/ValueObject/Configuration.php b/fractor/src/Configuration/ValueObject/Configuration.php index 2b40ce9a..42c3467d 100644 --- a/fractor/src/Configuration/ValueObject/Configuration.php +++ b/fractor/src/Configuration/ValueObject/Configuration.php @@ -9,23 +9,32 @@ final readonly class Configuration { /** - * @param list $fileExtensions + * @param string[] $fileExtensions * @param list $paths + * @param string[] $skip */ - public function __construct(private array $fileExtensions, private array $paths) + public function __construct(private array $fileExtensions, private array $paths, private array $skip) { Assert::notEmpty($this->paths, 'No directories given'); Assert::allStringNotEmpty($this->paths, 'No directories given'); } /** - * @return list + * @return string[] */ public function getFileExtensions(): array { return $this->fileExtensions; } + /** + * @return string[] + */ + public function getSkip(): array + { + return $this->skip; + } + /** * @return list */ diff --git a/fractor/src/DependencyInjection/ContainerContainerBuilder.php b/fractor/src/DependencyInjection/ContainerContainerBuilder.php index 39e9d1d7..a27858d4 100644 --- a/fractor/src/DependencyInjection/ContainerContainerBuilder.php +++ b/fractor/src/DependencyInjection/ContainerContainerBuilder.php @@ -3,7 +3,9 @@ namespace a9f\Fractor\DependencyInjection; use a9f\Fractor\DependencyInjection\CompilerPass\CommandsCompilerPass; +use a9f\FractorExtensionInstaller\Generated\InstalledPackages; use Symfony\Component\Config\FileLocator; +use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; @@ -14,13 +16,16 @@ class ContainerContainerBuilder */ public function createDependencyInjectionContainer(array $additionalConfigFiles = []): ContainerInterface { - $containerBuilder = new \Symfony\Component\DependencyInjection\ContainerBuilder(); + $containerBuilder = new ContainerBuilder(); $this->loadFile($containerBuilder, __DIR__ . '/../../config/application.php'); $this->importExtensionConfigurations($containerBuilder); $containerBuilder->addCompilerPass(new CommandsCompilerPass()); foreach ($additionalConfigFiles as $additionalConfigFile) { + if (!file_exists($additionalConfigFile)) { + continue; + } $this->loadFile($containerBuilder, $additionalConfigFile); } @@ -30,19 +35,19 @@ public function createDependencyInjectionContainer(array $additionalConfigFiles } - private function loadFile(\Symfony\Component\DependencyInjection\ContainerBuilder $containerBuilder, string $pathToFile): void + private function loadFile(ContainerBuilder $containerBuilder, string $pathToFile): void { $fileLoader = new PhpFileLoader($containerBuilder, new FileLocator(dirname($pathToFile))); $fileLoader->load($pathToFile); } - private function importExtensionConfigurations(\Symfony\Component\DependencyInjection\ContainerBuilder $containerBuilder): void + private function importExtensionConfigurations(ContainerBuilder $containerBuilder): void { if (!class_exists('a9f\\FractorExtensionInstaller\\Generated\\InstalledPackages')) { return; } - foreach (\a9f\FractorExtensionInstaller\Generated\InstalledPackages::PACKAGES as $package) { + foreach (InstalledPackages::PACKAGES as $package) { $filePath = $package['path'] . '/config/application.php'; if (file_exists($filePath)) { diff --git a/fractor/src/FileSystem/FilesFinder.php b/fractor/src/FileSystem/FilesFinder.php index 63bc9251..8382afec 100644 --- a/fractor/src/FileSystem/FilesFinder.php +++ b/fractor/src/FileSystem/FilesFinder.php @@ -2,11 +2,12 @@ namespace a9f\Fractor\FileSystem; +use a9f\Fractor\Skipper\Skipper\PathSkipper; use Symfony\Component\Finder\Finder; final readonly class FilesFinder { - public function __construct(private FilesystemTweaker $filesystemTweaker, private FileAndDirectoryFilter $fileAndDirectoryFilter) + public function __construct(private FilesystemTweaker $filesystemTweaker, private FileAndDirectoryFilter $fileAndDirectoryFilter, private PathSkipper $pathSkipper) { } @@ -23,7 +24,7 @@ public function findFiles(array $source, array $suffixes, bool $sortByName = tru $filteredFilePaths = array_filter( $files, - fn (string $filePath): bool => true // TODO: Add skipper here + fn (string $filePath): bool => !$this->pathSkipper->shouldSkip($filePath) ); if ($suffixes !== []) { @@ -77,7 +78,9 @@ private function findInDirectories(array $directories, array $suffixes, bool $so continue; } - // TODO: Add skipper here + if ($this->pathSkipper->shouldSkip($path)) { + continue; + } $filePaths[] = $path; } diff --git a/fractor/src/Skipper/FileSystem/FilePathHelper.php b/fractor/src/Skipper/FileSystem/FilePathHelper.php new file mode 100644 index 00000000..58382323 --- /dev/null +++ b/fractor/src/Skipper/FileSystem/FilePathHelper.php @@ -0,0 +1,116 @@ +filesystem = new Filesystem(); + } + + public function relativePath(string $fileRealPath): string + { + if (!$this->filesystem->isAbsolutePath($fileRealPath)) { + return $fileRealPath; + } + + return $this->relativeFilePathFromDirectory($fileRealPath, (string)getcwd()); + } + + /** + * Used from + * https://github.com/phpstan/phpstan-src/blob/02425e61aa48f0668b4efb3e73d52ad544048f65/src/File/FileHelper.php#L40, with custom modifications + */ + public function normalizePathAndSchema(string $originalPath): string + { + $directorySeparator = DIRECTORY_SEPARATOR; + + $matches = Strings::match($originalPath, self::SCHEME_PATH_REGEX); + if ($matches !== null) { + [, $scheme, $path] = $matches; + } else { + $scheme = self::SCHEME_UNDEFINED; + $path = $originalPath; + } + + $normalizedPath = PathNormalizer::normalize((string) $path); + $path = Strings::replace($normalizedPath, self::TWO_AND_MORE_SLASHES_REGEX, '/'); + + $pathRoot = str_starts_with($path, '/') ? $directorySeparator : ''; + $pathParts = explode('/', trim($path, '/')); + + $normalizedPathParts = $this->normalizePathParts($pathParts, $scheme); + + $pathStart = ($scheme !== self::SCHEME_UNDEFINED ? $scheme . '://' : ''); + return PathNormalizer::normalize($pathStart . $pathRoot . implode($directorySeparator, $normalizedPathParts)); + } + + private function relativeFilePathFromDirectory(string $fileRealPath, string $directory): string + { + Assert::directory($directory); + $normalizedFileRealPath = PathNormalizer::normalize($fileRealPath); + + $relativeFilePath = $this->filesystem->makePathRelative($normalizedFileRealPath, $directory); + return rtrim($relativeFilePath, '/'); + } + + /** + * @param string[] $pathParts + * @return string[] + */ + private function normalizePathParts(array $pathParts, string $scheme): array + { + $normalizedPathParts = []; + + foreach ($pathParts as $pathPart) { + if ($pathPart === '.') { + continue; + } + + if ($pathPart !== '..') { + $normalizedPathParts[] = $pathPart; + continue; + } + + /** @var string $removedPart */ + $removedPart = array_pop($normalizedPathParts); + if ($scheme !== 'phar') { + continue; + } + + if (!\str_ends_with($removedPart, '.phar')) { + continue; + } + + $scheme = self::SCHEME_UNDEFINED; + } + + return $normalizedPathParts; + } +} diff --git a/fractor/src/Skipper/FileSystem/FnMatchPathNormalizer.php b/fractor/src/Skipper/FileSystem/FnMatchPathNormalizer.php new file mode 100644 index 00000000..9fa71f33 --- /dev/null +++ b/fractor/src/Skipper/FileSystem/FnMatchPathNormalizer.php @@ -0,0 +1,27 @@ +doesFileMatchPattern($filePath, $filePattern)) { + return true; + } + } + + return false; + } + + /** + * Supports both relative and absolute $file path. They differ for PHP-CS-Fixer and PHP_CodeSniffer. + */ + private function doesFileMatchPattern(string $filePath, string $ignoredPath): bool + { + // in rector.php, the path can be absolute + if ($filePath === $ignoredPath) { + return true; + } + + $ignoredPath = $this->fnMatchPathNormalizer->normalizeForFnmatch($ignoredPath); + if ($ignoredPath === '') { + return false; + } + + if (str_starts_with($filePath, $ignoredPath)) { + return true; + } + + if (str_ends_with($filePath, $ignoredPath)) { + return true; + } + + if ($this->fnmatcher->match($ignoredPath, $filePath)) { + return true; + } + + return $this->realpathMatcher->match($ignoredPath, $filePath); + } +} diff --git a/fractor/src/Skipper/RealpathMatcher.php b/fractor/src/Skipper/RealpathMatcher.php new file mode 100644 index 00000000..9346be19 --- /dev/null +++ b/fractor/src/Skipper/RealpathMatcher.php @@ -0,0 +1,40 @@ +skippedPaths !== null) { + return $this->skippedPaths; + } + + $skip = $this->configuration->getSkip(); + $this->skippedPaths = []; + + foreach ($skip as $key => $value) { + if (!is_int($key)) { + continue; + } + + if (\str_contains($value, '*')) { + $this->skippedPaths[] = $this->filePathHelper->normalizePathAndSchema($value); + continue; + } + + if (file_exists($value)) { + $this->skippedPaths[] = $this->filePathHelper->normalizePathAndSchema($value); + } + } + + return $this->skippedPaths; + } +} diff --git a/fractor/src/Skipper/Skipper/PathSkipper.php b/fractor/src/Skipper/Skipper/PathSkipper.php new file mode 100644 index 00000000..10e0cb2d --- /dev/null +++ b/fractor/src/Skipper/Skipper/PathSkipper.php @@ -0,0 +1,23 @@ +skippedPathsResolver->resolve(); + return $this->fileInfoMatcher->doesFileInfoMatchPatterns($filePath, $skippedPaths); + } +} diff --git a/fractor/tests/Configuration/AllowedFileExtensionsResolver/config/config.php b/fractor/tests/Configuration/AllowedFileExtensionsResolver/config/config.php index e8ad0cea..b18ba37c 100644 --- a/fractor/tests/Configuration/AllowedFileExtensionsResolver/config/config.php +++ b/fractor/tests/Configuration/AllowedFileExtensionsResolver/config/config.php @@ -1,11 +1,10 @@ services(); $services->defaults() ->autowire() diff --git a/fractor/tests/FileSystem/FilesFinder/FilesFinderTest.php b/fractor/tests/FileSystem/FilesFinder/FilesFinderTest.php index 90354f83..574e9c38 100644 --- a/fractor/tests/FileSystem/FilesFinder/FilesFinderTest.php +++ b/fractor/tests/FileSystem/FilesFinder/FilesFinderTest.php @@ -61,4 +61,9 @@ private function getFileBasename(string $foundFile): string { return pathinfo($foundFile, PATHINFO_BASENAME); } + + protected function additionalConfigurationFiles(): array + { + return [__DIR__ . '/config/config.php']; + } } diff --git a/fractor/tests/FileSystem/FilesFinder/Fixtures/SourceWithBrokenSymlinks/folder2/foo.txt b/fractor/tests/FileSystem/FilesFinder/Fixtures/SourceWithBrokenSymlinks/folder2/foo.txt new file mode 100644 index 00000000..56a6051c --- /dev/null +++ b/fractor/tests/FileSystem/FilesFinder/Fixtures/SourceWithBrokenSymlinks/folder2/foo.txt @@ -0,0 +1 @@ +1 \ No newline at end of file diff --git a/fractor/tests/FileSystem/FilesFinder/config/config.php b/fractor/tests/FileSystem/FilesFinder/config/config.php new file mode 100644 index 00000000..18472944 --- /dev/null +++ b/fractor/tests/FileSystem/FilesFinder/config/config.php @@ -0,0 +1,9 @@ +parameters(); + $parameters->set(Option::SKIP, [__DIR__ . '/../Fixtures/SourceWithBrokenSymlinks/folder2']); +}; diff --git a/fractor/tests/Helper/Contract/TextRule.php b/fractor/tests/Helper/Contract/TextRule.php index b3c7d3a4..32c0358f 100644 --- a/fractor/tests/Helper/Contract/TextRule.php +++ b/fractor/tests/Helper/Contract/TextRule.php @@ -4,9 +4,10 @@ namespace a9f\Fractor\Tests\Helper\Contract; +use a9f\Fractor\Application\Contract\FractorRule; use a9f\Fractor\Application\ValueObject\File; -interface TextRule +interface TextRule extends FractorRule { public function apply(File $file): void; } diff --git a/fractor/tests/Helper/FileProcessor/TextFileProcessor.php b/fractor/tests/Helper/FileProcessor/TextFileProcessor.php index f5ccf36f..fa65ddd9 100644 --- a/fractor/tests/Helper/FileProcessor/TextFileProcessor.php +++ b/fractor/tests/Helper/FileProcessor/TextFileProcessor.php @@ -11,7 +11,7 @@ final readonly class TextFileProcessor implements FileProcessor { /** - * @param list $rules + * @param TextRule[] $rules */ public function __construct(private iterable $rules) { diff --git a/fractor/tests/Skipper/FileSystem/FilePathHelper/FilePathHelperTest.php b/fractor/tests/Skipper/FileSystem/FilePathHelper/FilePathHelperTest.php new file mode 100644 index 00000000..ea0fc96e --- /dev/null +++ b/fractor/tests/Skipper/FileSystem/FilePathHelper/FilePathHelperTest.php @@ -0,0 +1,34 @@ +subject = new FilePathHelper(); + } + + #[DataProvider('provideData')] + public function test(string $inputPath, string $expectedNormalizedPath): void + { + $normalizedPath = $this->subject->normalizePathAndSchema($inputPath); + $this->assertSame($expectedNormalizedPath, $normalizedPath); + } + + public static function provideData(): Iterator + { + // based on Linux + yield ['/any/path', '/any/path']; + yield ['\any\path', '/any/path']; + } +} diff --git a/fractor/tests/Skipper/FileSystem/FnMatchPathNormalizer/Fixtures/in/it/KeepThisFile.txt b/fractor/tests/Skipper/FileSystem/FnMatchPathNormalizer/Fixtures/in/it/KeepThisFile.txt new file mode 100644 index 00000000..e69de29b diff --git a/fractor/tests/Skipper/FileSystem/FnMatchPathNormalizer/Fixtures/path/in/it/KeepThisFile.txt b/fractor/tests/Skipper/FileSystem/FnMatchPathNormalizer/Fixtures/path/in/it/KeepThisFile.txt new file mode 100644 index 00000000..e69de29b diff --git a/fractor/tests/Skipper/FileSystem/FnMatchPathNormalizer/Fixtures/path/with/KeepThisFile.txt b/fractor/tests/Skipper/FileSystem/FnMatchPathNormalizer/Fixtures/path/with/KeepThisFile.txt new file mode 100644 index 00000000..e69de29b diff --git a/fractor/tests/Skipper/FileSystem/FnMatchPathNormalizer/FnMatchPathNormalizerTest.php b/fractor/tests/Skipper/FileSystem/FnMatchPathNormalizer/FnMatchPathNormalizerTest.php new file mode 100644 index 00000000..9da505df --- /dev/null +++ b/fractor/tests/Skipper/FileSystem/FnMatchPathNormalizer/FnMatchPathNormalizerTest.php @@ -0,0 +1,39 @@ +subject = $this->getService(FnMatchPathNormalizer::class); + } + + #[DataProvider('providePaths')] + public function testPaths(string $path, string $expectedNormalizedPath): void + { + $normalizedPath = $this->subject->normalizeForFnmatch($path); + self::assertSame($expectedNormalizedPath, $normalizedPath); + } + + public static function providePaths(): Iterator + { + yield ['path/with/no/asterisk', 'path/with/no/asterisk']; + yield ['*path/with/asterisk/begin', '*path/with/asterisk/begin*']; + yield ['path/with/asterisk/end*', '*path/with/asterisk/end*']; + yield ['*path/with/asterisk/begin/and/end*', '*path/with/asterisk/begin/and/end*']; + yield [__DIR__ . '/Fixtures/path/with/../in/it', PathNormalizer::normalize(__DIR__ . '/Fixtures/path/in/it')]; + yield [__DIR__ . '/Fixtures/path/with/../../in/it', PathNormalizer::normalize(__DIR__ . '/Fixtures/in/it')]; + } +} diff --git a/fractor/tests/Skipper/SkipCriteriaResolver/SkippedPathsResolver/Fixtures/existing_paths.txt b/fractor/tests/Skipper/SkipCriteriaResolver/SkippedPathsResolver/Fixtures/existing_paths.txt new file mode 100644 index 00000000..19102815 --- /dev/null +++ b/fractor/tests/Skipper/SkipCriteriaResolver/SkippedPathsResolver/Fixtures/existing_paths.txt @@ -0,0 +1 @@ +foo \ No newline at end of file diff --git a/fractor/tests/Skipper/SkipCriteriaResolver/SkippedPathsResolver/SkippedPathsResolverTest.php b/fractor/tests/Skipper/SkipCriteriaResolver/SkippedPathsResolver/SkippedPathsResolverTest.php new file mode 100644 index 00000000..443c1dc0 --- /dev/null +++ b/fractor/tests/Skipper/SkipCriteriaResolver/SkippedPathsResolver/SkippedPathsResolverTest.php @@ -0,0 +1,35 @@ +subject = $this->getService(SkippedPathsResolver::class); + } + + public function test(): void + { + $skippedPaths = $this->subject->resolve(); + + self::assertCount(2, $skippedPaths); + + self::assertSame(PathNormalizer::normalize(__DIR__ . '/Fixtures'), $skippedPaths[0]); + self::assertSame('*/Mask/*', $skippedPaths[1]); + } + + protected function additionalConfigurationFiles(): array + { + return [__DIR__ . '/config/config.php']; + } +} diff --git a/fractor/tests/Skipper/SkipCriteriaResolver/SkippedPathsResolver/config/config.php b/fractor/tests/Skipper/SkipCriteriaResolver/SkippedPathsResolver/config/config.php new file mode 100644 index 00000000..c6a646f7 --- /dev/null +++ b/fractor/tests/Skipper/SkipCriteriaResolver/SkippedPathsResolver/config/config.php @@ -0,0 +1,14 @@ +parameters(); + $parameters->set(Option::SKIP, [ + // windows slashes + __DIR__ . '\non-existing-path', + __DIR__ . '/../Fixtures', + '*\Mask\*', + ]); +};