diff --git a/README.md b/README.md index fe5ab82..ae0fa1a 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,11 @@ foreach ($classMap->getMap() as $symbol => $path) { foreach ($classMap->getAmbiguousClasses() as $symbol => $paths) { // warn user about ambiguous class resolution } + +foreach ($classMap->getAmbiguousFolders() as $paths) { + // warn user that these paths only differ in casing, and therefore fold into a + // single file/folder on case insensitive filesystems like those of Windows/macOS +} ``` diff --git a/src/ClassMap.php b/src/ClassMap.php index f4391d2..87ebf71 100644 --- a/src/ClassMap.php +++ b/src/ClassMap.php @@ -19,6 +19,8 @@ */ class ClassMap implements \Countable { + private const DEFAULT_DUPLICATES_FILTER = '{(?:^|/)(test|fixture|example|stub)s?/}i'; + /** * @var array */ @@ -82,15 +84,13 @@ public function getPsrViolations(): array * * @return array> */ - public function getAmbiguousClasses($duplicatesFilter = '{/(test|fixture|example|stub)s?/}i'): array + public function getAmbiguousClasses($duplicatesFilter = self::DEFAULT_DUPLICATES_FILTER): array { if (false === $duplicatesFilter) { return $this->ambiguousClasses; } - if (true === $duplicatesFilter) { - throw new \InvalidArgumentException('$duplicatesFilter should be false or a string with a valid regex, got true.'); - } + self::assertValidDuplicatesFilter($duplicatesFilter); $ambiguousClasses = []; foreach ($this->ambiguousClasses as $class => $paths) { @@ -105,6 +105,139 @@ public function getAmbiguousClasses($duplicatesFilter = '{/(test|fixture|example return $ambiguousClasses; } + /** + * A list of sets of paths which are identical except for their casing + * + * Case-insensitive filesystems (Windows, macOS) fold each such set into a single file or + * directory, so a project which autoloads correctly on a case-sensitive filesystem can behave + * differently there. This typically happens when a folder is renamed to a different casing: + * on a case-sensitive checkout src/Foo and src/foo then co-exist, while elsewhere they merge + * and the classes below them no longer match the casing of the folder they end up in. + * + * Every folder or file whose own name differs in casing is reported, while differences it + * only inherits from a parent folder are reported once at that parent's level. + * + * By default, paths that contain test(s), fixture(s), example(s) or stub(s) are ignored + * as those are typically not problematic when they're dummy classes in the tests folder. + * If you want to get these back as well you can pass false to $duplicatesFilter. Or + * you can pass your own pattern to exclude if you need to change the default. + * + * A set of paths is only left out when every path below it is ignored though, as a test + * folder merging with a production folder is still a problem for the latter. + * + * @param non-empty-string|false $duplicatesFilter + * + * @return list> + */ + public function getAmbiguousFolders($duplicatesFilter = self::DEFAULT_DUPLICATES_FILTER): array + { + self::assertValidDuplicatesFilter($duplicatesFilter); + + $paths = []; + foreach ($this->map as $path) { + $paths[] = strtr($path, '\\', '/'); + } + // a class found in several files only has one of them in the map, but the others + // are just as relevant here, and typically are the renamed folder's stale copies + foreach ($this->ambiguousClasses as $duplicatePaths) { + foreach ($duplicatePaths as $path) { + $paths[] = strtr($path, '\\', '/'); + } + } + // sorted so the result does not depend on scan order, and so that each path can skip + // the folders it shares with the previous one as those were already registered + sort($paths, SORT_STRING); + + // folded prefix => first path seen for it, which has the lowest casing as paths are sorted + /** @var array $first */ + $first = []; + // folded prefix => name of its last segment as written => first path seen with that name + /** @var array> $variants */ + $variants = []; + $previous = ''; + foreach ($paths as $path) { + // length of the common prefix with the previous path, as XOR turns equal bytes into \0 + $common = strspn($path ^ $previous, "\0"); + // cut back to the last / within it, as only whole folders were registered already + $skipUntil = $common > 0 ? (int) strrpos($path, '/', $common - \strlen($path) - 1) : 0; + $previous = $path; + + // walk every prefix of the path, the full path included so that files which only + // differ in casing are caught as well as the folders above them + $foldedPath = self::foldCase($path); + // drive letters and stream wrapper schemes are never case sensitive so they are + // skipped, see also ClassMapGenerator::normalizePath which matches the same prefixes + $rootLength = Preg::isMatchStrictGroups('{^(?:[0-9a-z]{2,}+:(?://(?:[a-z]:)?)?|[a-z]:)}i', $path, $match) ? \strlen($match[0]) : 0; + $offset = 0; + foreach (explode('/', $path) as $name) { + $end = $offset + \strlen($name); + if ('' !== $name && $end > $rootLength && $end > $skipUntil) { + $foldedPrefix = substr($foldedPath, 0, $end); + if (!isset($first[$foldedPrefix])) { + $first[$foldedPrefix] = substr($path, 0, $offset).$name; + } elseif (0 !== substr_compare($first[$foldedPrefix], $name, $offset) && !isset($variants[$foldedPrefix][$name])) { + // a name written the same way as the first one only differs by its + // parents, which get reported at their own level + $variants[$foldedPrefix][$name] = substr($path, 0, $offset).$name; + } + } + $offset = $end + 1; + } + } + + if (\count($variants) === 0) { + return []; + } + + // only look for paths which are not filtered out once something was found + /** @var array|null $relevant */ + $relevant = null; + if (false !== $duplicatesFilter) { + $relevant = []; + foreach ($paths as $path) { + if (Preg::isMatch($duplicatesFilter, $path)) { + continue; + } + $foldedPath = self::foldCase($path); + $offset = 0; + while (false !== ($separator = strpos($foldedPath, '/', $offset))) { + $relevant[substr($foldedPath, 0, $separator)] = true; + $offset = $separator + 1; + } + $relevant[$foldedPath] = true; + } + } + + $ambiguousPaths = []; + foreach ($variants as $foldedPrefix => $names) { + if (null === $relevant || isset($relevant[$foldedPrefix])) { + // already sorted as the paths were walked in order + $ambiguousPaths[$foldedPrefix] = array_merge([$first[$foldedPrefix]], array_values($names)); + } + } + ksort($ambiguousPaths, SORT_STRING); + + return array_values($ambiguousPaths); + } + + /** + * @param non-empty-string|bool $duplicatesFilter + */ + private static function assertValidDuplicatesFilter($duplicatesFilter): void + { + if (true === $duplicatesFilter) { + throw new \InvalidArgumentException('$duplicatesFilter should be false or a string with a valid regex, got true.'); + } + } + + /** + * Lowercases ASCII characters only, as strtolower is locale dependent before PHP 8.2 + */ + private static function foldCase(string $str): string + { + return strtr($str, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'); + } + /** * Sorts the class map alphabetically by class names */ diff --git a/tests/ClassMapTest.php b/tests/ClassMapTest.php new file mode 100644 index 0000000..8ea7efe --- /dev/null +++ b/tests/ClassMapTest.php @@ -0,0 +1,394 @@ + + * Jordi Boggiano + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Composer\ClassMapGenerator; + +use PHPUnit\Framework\TestCase; + +class ClassMapTest extends TestCase +{ + /** + * @dataProvider provideAmbiguousFolders + * + * @param array $classes + * @param list> $expected + */ + public function testGetAmbiguousFolders(array $classes, array $expected): void + { + self::assertSame($expected, self::classMapOf($classes)->getAmbiguousFolders(false)); + } + + /** + * @return array, list>}> + */ + public static function provideAmbiguousFolders(): array + { + return [ + 'empty map' => [ + [], + [], + ], + 'no ambiguity' => [ + [ + 'Foo\Bar' => '/proj/src/Foo/Bar.php', + 'Foo\Baz' => '/proj/src/Foo/Baz.php', + ], + [], + ], + // the case this is all about: a folder renamed to a different casing, which merges + // into a single folder once checked out on a case insensitive filesystem + 'folder differing in casing' => [ + [ + 'foo\Foo' => '/proj/src/foo/Foo.php', + 'Foo\Bar' => '/proj/src/Foo/Bar.php', + ], + [ + ['/proj/src/Foo', '/proj/src/foo'], + ], + ], + 'three variants of the same folder' => [ + [ + 'A\A' => '/proj/src/Casefolding/A.php', + 'B\B' => '/proj/src/casefolding/B.php', + 'C\C' => '/proj/src/CaseFolding/C.php', + ], + [ + ['/proj/src/CaseFolding', '/proj/src/Casefolding', '/proj/src/casefolding'], + ], + ], + // every level whose own name differs in casing is reported, as renaming the parent + // does not make the children match each other + 'nested differences are all reported' => [ + [ + 'A\B' => '/proj/src/Foo/Bar/A.php', + 'A\C' => '/proj/src/foo/bar/C.php', + ], + [ + ['/proj/src/Foo', '/proj/src/foo'], + ['/proj/src/Foo/Bar', '/proj/src/foo/bar'], + ], + ], + // a difference inherited from the parent is not repeated for children matching each other + 'ancestor difference is not repeated for identical children' => [ + [ + 'A\B' => '/proj/src/Foo/Bar/A.php', + 'A\C' => '/proj/src/foo/Bar/B.php', + ], + [ + ['/proj/src/Foo', '/proj/src/foo'], + ], + ], + 'ancestor difference is not repeated for identical files' => [ + [ + 'A\A' => '/proj/src/Foo/A.php', + 'B\A' => '/proj/src/foo/A.php', + ], + [ + ['/proj/src/Foo', '/proj/src/foo'], + ], + ], + 'files differing in casing under folders differing in casing' => [ + [ + 'A\A' => '/proj/src/Foo/A.php', + 'B\B' => '/proj/src/foo/a.php', + ], + [ + ['/proj/src/Foo', '/proj/src/foo'], + ['/proj/src/Foo/A.php', '/proj/src/foo/a.php'], + ], + ], + 'three variants under differently cased parents' => [ + [ + 'A\A' => '/proj/src/Foo/Bar/A.php', + 'B\B' => '/proj/src/foo/bar/B.php', + 'C\C' => '/proj/src/FOO/BAR/C.php', + 'D\D' => '/proj/src/Foo/bar/D.php', + ], + [ + ['/proj/src/FOO', '/proj/src/Foo', '/proj/src/foo'], + ['/proj/src/FOO/BAR', '/proj/src/Foo/Bar', '/proj/src/Foo/bar'], + ], + ], + // Foo\Bar and Foo\bar are the same class as far as PHP is concerned, and the two files + // cannot co-exist on a case insensitive filesystem either + 'files differing in casing' => [ + [ + 'Foo\Bar' => '/proj/src/Bar.php', + 'Foo\bar' => '/proj/src/bar.php', + ], + [ + ['/proj/src/Bar.php', '/proj/src/bar.php'], + ], + ], + 'folder and file with the same name' => [ + [ + 'A\A' => '/proj/src/Foo/A.php', + 'B\B' => '/proj/src/foo.php', + ], + [], + ], + 'unrelated folders in separate trees' => [ + [ + 'A\A' => '/proj/src/Foo/A.php', + 'B\B' => '/proj/lib/foo/B.php', + ], + [], + ], + // a namespace legitimately spread over several folders is not a problem + 'namespace spanning several folders' => [ + [ + 'Foo\A' => '/proj/src/A.php', + 'Foo\B' => '/proj/lib/B.php', + ], + [], + ], + // aws/aws-sdk-php uses the Aws namespace while its aws/aws-crt-php dependency uses AWS, + // but they live in folders which do not fold into each other + 'namespaces differing in casing in separate folders' => [ + [ + 'AWS\CRT\CRT' => '/proj/vendor/aws/aws-crt-php/src/AWS/CRT/CRT.php', + 'Aws\Sdk' => '/proj/vendor/aws/aws-sdk-php/src/Sdk.php', + ], + [], + ], + 'windows directory separators' => [ + [ + 'A\A' => 'C:\\proj\\src\\Foo\\A.php', + 'B\B' => 'C:\\proj\\src\\foo\\B.php', + ], + [ + ['C:/proj/src/Foo', 'C:/proj/src/foo'], + ], + ], + 'mixed directory separators' => [ + [ + 'A\A' => 'C:\\proj\\src\\Foo\\A.php', + 'B\B' => 'C:/proj/src/foo/B.php', + ], + [ + ['C:/proj/src/Foo', 'C:/proj/src/foo'], + ], + ], + // drive letters and stream wrapper schemes are never case sensitive + 'drive letter casing is ignored' => [ + [ + 'A\A' => 'C:\\proj\\src\\A.php', + 'B\B' => 'c:\\proj\\src\\B.php', + ], + [], + ], + 'drive letter casing is kept in reported paths' => [ + [ + 'A\A' => 'C:\\proj\\src\\Foo\\A.php', + 'B\B' => 'c:\\proj\\src\\foo\\B.php', + ], + [ + ['C:/proj/src/Foo', 'c:/proj/src/foo'], + ], + ], + 'stream wrapper scheme casing is ignored' => [ + [ + 'A\A' => 'phar:///proj/lib.phar/src/A.php', + 'B\B' => 'PHAR:///proj/lib.phar/src/B.php', + ], + [], + ], + 'drive letter under a stream wrapper' => [ + [ + 'A\A' => 'phar://C:/proj/lib.phar/src/A.php', + 'B\B' => 'phar://c:/proj/lib.phar/src/B.php', + ], + [], + ], + 'relative paths' => [ + [ + 'A\A' => 'src/Foo/A.php', + 'B\B' => 'src/foo/B.php', + ], + [ + ['src/Foo', 'src/foo'], + ], + ], + 'several ambiguous folders' => [ + [ + 'A\A' => '/proj/src/Zed/A.php', + 'B\B' => '/proj/src/zed/B.php', + 'C\C' => '/proj/src/Abc/C.php', + 'D\D' => '/proj/src/abc/D.php', + ], + [ + ['/proj/src/Abc', '/proj/src/abc'], + ['/proj/src/Zed', '/proj/src/zed'], + ], + ], + ]; + } + + public function testGetAmbiguousFoldersIsNotAffectedByInsertionOrder(): void + { + $classes = [ + 'A\A' => '/proj/src/Foo/Bar/A.php', + 'B\B' => '/proj/src/foo/B.php', + 'C\C' => '/proj/src/Foo/bar/C.php', + 'D\D' => '/proj/src/foo/Bar/D.php', + ]; + // src/foo/Bar only differs from src/Foo/Bar by its parent so it is not listed + $expected = [ + ['/proj/src/Foo', '/proj/src/foo'], + ['/proj/src/Foo/Bar', '/proj/src/Foo/bar'], + ]; + + foreach (self::permutations(array_keys($classes)) as $order) { + $permutation = []; + foreach ($order as $class) { + $permutation[$class] = $classes[$class]; + } + + self::assertSame($expected, self::classMapOf($permutation)->getAmbiguousFolders(false), 'Insertion order: '.implode(', ', $order)); + } + } + + public function testGetAmbiguousFoldersIncludesPathsOfAmbiguousClasses(): void + { + // a folder renamed to a different casing with stale copies left behind: the same classes + // are found in both, so the second copy only ends up in the ambiguous classes + $classMap = self::classMapOf( + [ + 'Foo\A' => '/proj/src/Foo/A.php', + 'Foo\B' => '/proj/src/Foo/B.php', + ], + [ + 'Foo\A' => ['/proj/src/foo/A.php'], + 'Foo\B' => ['/proj/src/foo/B.php'], + ] + ); + + $expected = [['/proj/src/Foo', '/proj/src/foo']]; + self::assertSame($expected, $classMap->getAmbiguousFolders()); + self::assertSame($expected, $classMap->getAmbiguousFolders(false)); + } + + public function testGetAmbiguousFoldersFiltersTestPathsByDefault(): void + { + $classMap = self::classMapOf([ + 'A\A' => '/proj/tests/Foo/A.php', + 'B\B' => '/proj/tests/foo/B.php', + ]); + + self::assertSame([], $classMap->getAmbiguousFolders()); + self::assertSame([['/proj/tests/Foo', '/proj/tests/foo']], $classMap->getAmbiguousFolders(false)); + } + + public function testGetAmbiguousFoldersOnlyFiltersFoldersWithoutUnfilteredPaths(): void + { + // a test folder which merges with a production folder is still a problem for the latter + $classMap = self::classMapOf([ + 'A\A' => '/proj/src/Foo/A.php', + 'B\B' => '/proj/src/foo/Tests/B.php', + 'C\C' => '/proj/src/foo/tests/C.php', + ]); + + self::assertSame([['/proj/src/Foo', '/proj/src/foo']], $classMap->getAmbiguousFolders()); + self::assertSame([ + ['/proj/src/Foo', '/proj/src/foo'], + ['/proj/src/foo/Tests', '/proj/src/foo/tests'], + ], $classMap->getAmbiguousFolders(false)); + + // while folders only containing filtered paths are ignored altogether + $classMap = self::classMapOf([ + 'A\A' => '/proj/tests/Foo/A.php', + 'B\B' => '/proj/Tests/foo/B.php', + ]); + + self::assertSame([], $classMap->getAmbiguousFolders()); + } + + public function testDefaultDuplicatesFilterAppliesToRelativePaths(): void + { + $classMap = self::classMapOf([ + 'A\A' => 'tests/Foo/A.php', + 'B\B' => 'tests/foo/B.php', + ]); + + self::assertSame([], $classMap->getAmbiguousFolders()); + self::assertSame([['tests/Foo', 'tests/foo']], $classMap->getAmbiguousFolders(false)); + + $classMap = self::classMapOf(['A' => 'src/A.php'], ['A' => ['tests/A.php']]); + + self::assertSame([], $classMap->getAmbiguousClasses()); + self::assertSame(['A' => ['tests/A.php']], $classMap->getAmbiguousClasses(false)); + } + + public function testGetAmbiguousFoldersAcceptsACustomFilter(): void + { + $classMap = self::classMapOf([ + 'A\A' => '/proj/generated/Foo/A.php', + 'B\B' => '/proj/generated/foo/B.php', + ]); + + self::assertSame([], $classMap->getAmbiguousFolders('{/generated/}')); + self::assertSame([['/proj/generated/Foo', '/proj/generated/foo']], $classMap->getAmbiguousFolders()); + } + + public function testGetAmbiguousFoldersRejectsTrueAsFilter(): void + { + self::expectException(\InvalidArgumentException::class); + + // @phpstan-ignore argument.type + self::classMapOf([])->getAmbiguousFolders(true); + } + + /** + * @param array $classes + * @param array> $ambiguousClasses + */ + private static function classMapOf(array $classes, array $ambiguousClasses = []): ClassMap + { + $classMap = new ClassMap; + foreach ($classes as $class => $path) { + /** @phpstan-ignore argument.type */ + $classMap->addClass($class, $path); + } + foreach ($ambiguousClasses as $class => $paths) { + foreach ($paths as $path) { + /** @phpstan-ignore argument.type */ + $classMap->addAmbiguousClass($class, $path); + } + } + + return $classMap; + } + + /** + * @template T + * @param list $items + * @return list> + */ + private static function permutations(array $items): array + { + if (\count($items) <= 1) { + return [$items]; + } + + $permutations = []; + foreach ($items as $i => $item) { + $rest = $items; + unset($rest[$i]); + foreach (self::permutations(array_values($rest)) as $permutation) { + array_unshift($permutation, $item); + $permutations[] = $permutation; + } + } + + return $permutations; + } +}