From 56b810e91da2889d0fa1b56d74692623909d9ce1 Mon Sep 17 00:00:00 2001 From: Nikita Popov Date: Fri, 28 Apr 2017 17:10:30 +0200 Subject: [PATCH] Add getShortName() API PHP's name resolution rules are f'ing complicated. --- lib/PhpParser/NameContext.php | 154 ++++++++++++++++++--- lib/PhpParser/Node/Name.php | 14 +- lib/PhpParser/Node/Name/FullyQualified.php | 4 + lib/PhpParser/Node/Name/Relative.php | 4 + test/PhpParser/NameContextTest.php | 58 ++++++++ test/PhpParser/Node/NameTest.php | 6 +- 6 files changed, 216 insertions(+), 24 deletions(-) create mode 100644 test/PhpParser/NameContextTest.php diff --git a/lib/PhpParser/NameContext.php b/lib/PhpParser/NameContext.php index 631691a..ccf19ab 100644 --- a/lib/PhpParser/NameContext.php +++ b/lib/PhpParser/NameContext.php @@ -13,6 +13,9 @@ class NameContext { /** @var array Map of format [aliasType => [aliasName => originalName]] */ protected $aliases = []; + /** @var array Same as $aliases but preserving original case */ + protected $origAliases = []; + /** @var ErrorHandler Error handler */ protected $errorHandler; @@ -34,7 +37,7 @@ class NameContext { */ public function startNamespace(Name $namespace = null) { $this->namespace = $namespace; - $this->aliases = [ + $this->origAliases = $this->aliases = [ Stmt\Use_::TYPE_NORMAL => [], Stmt\Use_::TYPE_FUNCTION => [], Stmt\Use_::TYPE_CONSTANT => [], @@ -75,6 +78,7 @@ class NameContext { } $this->aliases[$type][$aliasLookupName] = $name; + $this->origAliases[$type][$aliasName] = $name; } /** @@ -110,11 +114,9 @@ class NameContext { return $name; } - $aliasName = strtolower($name->getFirst()); - if (!$name->isRelative() && isset($this->aliases[Stmt\Use_::TYPE_NORMAL][$aliasName])) { - // resolve aliases (for non-relative names) - $alias = $this->aliases[Stmt\Use_::TYPE_NORMAL][$aliasName]; - return FullyQualified::concat($alias, $name->slice(1), $name->getAttributes()); + // Try to resolve aliases + if (null !== $resolvedName = $this->resolveAlias($name, Stmt\Use_::TYPE_NORMAL)) { + return $resolvedName; } // if no alias exists prepend current namespace @@ -135,24 +137,12 @@ class NameContext { return $name; } - // resolve aliases for qualified names - $aliasName = strtolower($name->getFirst()); - if ($name->isQualified() && isset($this->aliases[Stmt\Use_::TYPE_NORMAL][$aliasName])) { - $alias = $this->aliases[Stmt\Use_::TYPE_NORMAL][$aliasName]; - return FullyQualified::concat($alias, $name->slice(1), $name->getAttributes()); + // Try to resolve aliases + if (null !== $resolvedName = $this->resolveAlias($name, $type)) { + return $resolvedName; } if ($name->isUnqualified()) { - if ($type === Stmt\Use_::TYPE_CONSTANT) { - // constant aliases are case-sensitive, function aliases case-insensitive - $aliasName = $name->getFirst(); - } - - if (isset($this->aliases[$type][$aliasName])) { - // resolve unqualified aliases - return new FullyQualified($this->aliases[$type][$aliasName], $name->getAttributes()); - } - if (null === $this->namespace) { // outside of a namespace unaliased unqualified is same as fully qualified return new FullyQualified($name, $name->getAttributes()); @@ -165,4 +155,126 @@ class NameContext { // if no alias exists prepend current namespace return FullyQualified::concat($this->namespace, $name, $name->getAttributes()); } + + /** + * Get possible ways of writing a fully qualified name (e.g., by making use of aliases) + * + * @param FullyQualified $name Fully-qualified name + * @param int $type One of Stmt\Use_::TYPE_* + * + * @return Name[] Possible representations of the name + */ + public function getPossibleNames(FullyQualified $name, $type) { + $nameStr = (string) $name; + $lcName = strtolower($name); + + // Collect possible ways to write this name, starting with the fully-qualified name + $possibleNames = [$name]; + + if (null !== $nsRelativeName = $this->getNamespaceRelativeName($name, $nameStr, $lcName)) { + // Make sure there is no alias that makes the normally namespace-relative name + // into something else + if (null === $this->resolveAlias($nsRelativeName, $type)) { + $possibleNames[] = $nsRelativeName; + } + } + + // Check for relevant namespace use statements + foreach ($this->origAliases[Stmt\Use_::TYPE_NORMAL] as $alias => $orig) { + $lcOrig = strtolower((string) $orig); + if (0 === strpos($lcName, $lcOrig . '\\')) { + $possibleNames[] = new Name($alias . substr($name, strlen($lcOrig))); + } + } + + // Check for relevant type-specific use statements + foreach ($this->origAliases[$type] as $alias => $orig) { + if ($type === Stmt\Use_::TYPE_CONSTANT) { + // Constants are are complicated-sensitive + if ($this->normalizeConstName($orig) === $this->normalizeConstName($nameStr)) { + $possibleNames[] = new Name($alias); + } + } else { + // Everything else is case-insensitive + if (strtolower((string) $orig) === $lcName) { + $possibleNames[] = new Name($alias); + } + } + } + + return $possibleNames; + } + + /** + * Get shortest representation of this fully-qualified name. + * + * @param FullyQualified $name Fully-qualified name to shorten + * @param int $type One of Stmt\Use_::TYPE_* + * + * @return Name Shortest representation + */ + public function getShortName(Name\FullyQualified $name, $type) { + $possibleNames = $this->getPossibleNames($name, $type); + + // Find shortest name + $shortestName = null; + $shortestLength = INF; + foreach ($possibleNames as $possibleName) { + $length = strlen($possibleName->toCodeString()); + if ($length < $shortestLength) { + $shortestName = $possibleName; + $shortestLength = $length; + } + } + + return $shortestName; + } + + private function resolveAlias(Name $name, $type) { + $firstPart = $name->getFirst(); + + if ($name->isQualified()) { + // resolve aliases for qualified names, always against class alias table + $checkName = strtolower($firstPart); + if (isset($this->aliases[Stmt\Use_::TYPE_NORMAL][$checkName])) { + $alias = $this->aliases[Stmt\Use_::TYPE_NORMAL][$checkName]; + return FullyQualified::concat($alias, $name->slice(1), $name->getAttributes()); + } + } elseif ($name->isUnqualified()) { + // constant aliases are case-sensitive, function aliases case-insensitive + $checkName = $type === Stmt\Use_::TYPE_CONSTANT ? $firstPart : strtolower($firstPart); + if (isset($this->aliases[$type][$checkName])) { + // resolve unqualified aliases + return new FullyQualified($this->aliases[$type][$checkName], $name->getAttributes()); + } + } + + // No applicable aliases + return null; + } + + private function getNamespaceRelativeName(Name\FullyQualified $name, $nameStr, $lcName) { + if (null === $this->namespace) { + return new Name($name); + } + + $namespacePrefix = strtolower($this->namespace . '\\'); + if (0 === strpos($lcName, $namespacePrefix)) { + return new Name(substr($nameStr, strlen($namespacePrefix))); + } + + return null; + } + + private function normalizeConstName($name) { + $nsSep = strrpos($name, '\\'); + if (false === $nsSep) { + return $name; + } + + // Constants have case-insensitive namespace and case-sensitive short-name + $ns = substr($name, 0, $nsSep); + $shortName = substr($name, $nsSep + 1); + return strtolower($ns) . '\\' . $shortName; + } } \ No newline at end of file diff --git a/lib/PhpParser/Node/Name.php b/lib/PhpParser/Node/Name.php index 93ade2f..f8d46ea 100644 --- a/lib/PhpParser/Node/Name.php +++ b/lib/PhpParser/Node/Name.php @@ -82,8 +82,8 @@ class Name extends NodeAbstract } /** - * Returns a string representation of the name by imploding the namespace parts with the - * namespace separator. + * Returns a string representation of the name itself, without taking taking the name type into + * account (e.g., not including a leading backslash for fully qualified names). * * @return string String representation */ @@ -91,6 +91,16 @@ class Name extends NodeAbstract return implode('\\', $this->parts); } + /** + * Returns a string representation of the name as it would occur in code (e.g., including + * leading backslash for fully qualified names. + * + * @return string String representation + */ + public function toCodeString() { + return $this->toString(); + } + /** * Returns a string representation of the name by imploding the namespace parts with the * namespace separator. diff --git a/lib/PhpParser/Node/Name/FullyQualified.php b/lib/PhpParser/Node/Name/FullyQualified.php index 97cc111..b50a035 100644 --- a/lib/PhpParser/Node/Name/FullyQualified.php +++ b/lib/PhpParser/Node/Name/FullyQualified.php @@ -39,4 +39,8 @@ class FullyQualified extends \PhpParser\Node\Name public function isRelative() { return false; } + + public function toCodeString() { + return '\\' . $this->toString(); + } } \ No newline at end of file diff --git a/lib/PhpParser/Node/Name/Relative.php b/lib/PhpParser/Node/Name/Relative.php index 25676ce..3ad3296 100644 --- a/lib/PhpParser/Node/Name/Relative.php +++ b/lib/PhpParser/Node/Name/Relative.php @@ -39,4 +39,8 @@ class Relative extends \PhpParser\Node\Name public function isRelative() { return true; } + + public function toCodeString() { + return 'namespace\\' . $this->toString(); + } } \ No newline at end of file diff --git a/test/PhpParser/NameContextTest.php b/test/PhpParser/NameContextTest.php new file mode 100644 index 0000000..d38e6f3 --- /dev/null +++ b/test/PhpParser/NameContextTest.php @@ -0,0 +1,58 @@ +startNamespace(new Name('NS')); + $nameContext->addAlias(new Name('Foo'), 'Foo', Use_::TYPE_NORMAL); + $nameContext->addAlias(new Name('Foo\Bar'), 'Alias', Use_::TYPE_NORMAL); + $nameContext->addAlias(new Name('Foo\fn'), 'fn', Use_::TYPE_FUNCTION); + $nameContext->addAlias(new Name('Foo\CN'), 'CN', Use_::TYPE_CONSTANT); + + $fqName = new Name\FullyQualified($name); + $possibleNames = $nameContext->getPossibleNames($fqName, $type); + $possibleNames = array_map(function (Name $name) { + return $name->toCodeString(); + }, $possibleNames); + + $this->assertSame($expectedPossibleNames, $possibleNames); + + // Here the last name is always the shortest one + $expectedShortName = $expectedPossibleNames[count($expectedPossibleNames) - 1]; + $this->assertSame( + $expectedShortName, + $nameContext->getShortName($fqName, $type)->toCodeString() + ); + } + + public function provideTestGetPossibleNames() { + return [ + [Use_::TYPE_NORMAL, 'Test', ['\Test']], + [Use_::TYPE_NORMAL, 'Test\Namespaced', ['\Test\Namespaced']], + [Use_::TYPE_NORMAL, 'NS\Test', ['\NS\Test', 'Test']], + [Use_::TYPE_NORMAL, 'ns\Test', ['\ns\Test', 'Test']], + [Use_::TYPE_NORMAL, 'NS\Foo\Bar', ['\NS\Foo\Bar']], + [Use_::TYPE_NORMAL, 'ns\foo\Bar', ['\ns\foo\Bar']], + [Use_::TYPE_NORMAL, 'Foo', ['\Foo', 'Foo']], + [Use_::TYPE_NORMAL, 'Foo\Bar', ['\Foo\Bar', 'Foo\Bar', 'Alias']], + [Use_::TYPE_NORMAL, 'Foo\Bar\Baz', ['\Foo\Bar\Baz', 'Foo\Bar\Baz', 'Alias\Baz']], + [Use_::TYPE_NORMAL, 'Foo\fn\Bar', ['\Foo\fn\Bar', 'Foo\fn\Bar']], + [Use_::TYPE_FUNCTION, 'Foo\fn\bar', ['\Foo\fn\bar', 'Foo\fn\bar']], + [Use_::TYPE_FUNCTION, 'Foo\fn', ['\Foo\fn', 'Foo\fn', 'fn']], + [Use_::TYPE_FUNCTION, 'Foo\FN', ['\Foo\FN', 'Foo\FN', 'fn']], + [Use_::TYPE_CONSTANT, 'Foo\CN\BAR', ['\Foo\CN\BAR', 'Foo\CN\BAR']], + [Use_::TYPE_CONSTANT, 'Foo\CN', ['\Foo\CN', 'Foo\CN', 'CN']], + [Use_::TYPE_CONSTANT, 'foo\CN', ['\foo\CN', 'Foo\CN', 'CN']], + [Use_::TYPE_CONSTANT, 'foo\cn', ['\foo\cn', 'Foo\cn']], + ]; + } +} \ No newline at end of file diff --git a/test/PhpParser/Node/NameTest.php b/test/PhpParser/Node/NameTest.php index bb93da6..7ce11e0 100644 --- a/test/PhpParser/Node/NameTest.php +++ b/test/PhpParser/Node/NameTest.php @@ -100,30 +100,34 @@ class NameTest extends TestCase $this->assertNull(Name::concat(null, null)); } - public function testIs() { + public function testNameTypes() { $name = new Name('foo'); $this->assertTrue ($name->isUnqualified()); $this->assertFalse($name->isQualified()); $this->assertFalse($name->isFullyQualified()); $this->assertFalse($name->isRelative()); + $this->assertSame('foo', $name->toCodeString()); $name = new Name('foo\bar'); $this->assertFalse($name->isUnqualified()); $this->assertTrue ($name->isQualified()); $this->assertFalse($name->isFullyQualified()); $this->assertFalse($name->isRelative()); + $this->assertSame('foo\bar', $name->toCodeString()); $name = new Name\FullyQualified('foo'); $this->assertFalse($name->isUnqualified()); $this->assertFalse($name->isQualified()); $this->assertTrue ($name->isFullyQualified()); $this->assertFalse($name->isRelative()); + $this->assertSame('\foo', $name->toCodeString()); $name = new Name\Relative('foo'); $this->assertFalse($name->isUnqualified()); $this->assertFalse($name->isQualified()); $this->assertFalse($name->isFullyQualified()); $this->assertTrue ($name->isRelative()); + $this->assertSame('namespace\foo', $name->toCodeString()); } /**