Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
```


Expand Down
141 changes: 137 additions & 4 deletions src/ClassMap.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
*/
class ClassMap implements \Countable
{
private const DEFAULT_DUPLICATES_FILTER = '{(?:^|/)(test|fixture|example|stub)s?/}i';

/**
* @var array<class-string, non-empty-string>
*/
Expand Down Expand Up @@ -82,15 +84,13 @@ public function getPsrViolations(): array
*
* @return array<class-string, array<non-empty-string>>
*/
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) {
Expand All @@ -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<non-empty-list<non-empty-string>>
*/
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<string, non-empty-string> $first */
$first = [];
// folded prefix => name of its last segment as written => first path seen with that name
/** @var array<string, array<string, non-empty-string>> $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<string, true>|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
*/
Expand Down
Loading
Loading