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
46 changes: 46 additions & 0 deletions compiler/build/scoper-namespaces.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php declare(strict_types=1);

return [
/**
* Namespaces php-scoper leaves alone completely - neither declarations nor
* references to them are prefixed.
*/
'excluded' => [
'PHPStan',
// the native turbo extension's classes — must match the loaded
// extension exactly, never prefixed (segment-aware matching means the
// PHPStan entry above does not cover this name)
'PHPStanTurbo',
'PHPUnit',
'PhpParser',
'Hoa',
'Symfony\Polyfill\Php80',
'Symfony\Polyfill\Php81',
'Symfony\Polyfill\Php83',
'Symfony\Polyfill\Php84',
'Symfony\Polyfill\Php85',
'Symfony\Polyfill\Mbstring',
'Symfony\Polyfill\Intl\Normalizer',
'Symfony\Polyfill\Intl\Grapheme',
],

/**
* Namespaces of classes that belong to the analysed code, referenced from
* src/ through string literals like `new ObjectType('BcMath\Number')`.
*
* php-scoper prefixes such literals even when they name internal PHP
* classes, which would turn them into class names that do not exist in the
* analysed code, so a patcher in scoper.inc.php strips the prefix back off.
* ScoperClassNameStringsTest fails when src/ references a namespace that is
* missing here.
*/
'unprefixedClassNameStringsInSrc' => [
'BcMath',
'Dom',
'Ds',
'FFI',
'Filter',
'Foobar',
'PDO',
],
];
45 changes: 10 additions & 35 deletions compiler/build/scoper.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

require_once __DIR__ . '/../vendor/autoload.php';

$namespaces = require __DIR__ . '/scoper-namespaces.php';

$stubs = [
'../../vendor/hoa/consistency/Prelude.php',
'../../vendor/composer/InstalledVersions.php',
Expand Down Expand Up @@ -213,49 +215,22 @@ function (string $filePath, string $prefix, string $content): string {

return str_replace(sprintf('%s\\PropertyHookType', $prefix), 'PropertyHookType', $content);
},
function (string $filePath, string $prefix, string $content): string {
function (string $filePath, string $prefix, string $content) use ($namespaces): string {
if (strpos($filePath, 'src/') !== 0) {
return $content;
}

return str_replace([
sprintf('\'%s\\BcMath\\', $prefix),
sprintf('\'%s\\Dom\\', $prefix),
sprintf('\'%s\\FFI\\', $prefix),
sprintf('\'%s\\Ds\\', $prefix),
], [
'\'BcMath\\',
'\'Dom\\',
'\'FFI\\',
'\'Ds\\',
], $content);
},
function (string $filePath, string $prefix, string $content): string {
if (strpos($filePath, 'src/Testing/ErrorFormatterTestCase.php') !== 0) {
return $content;
$search = [];
$replace = [];
foreach ($namespaces['unprefixedClassNameStringsInSrc'] as $namespace) {
$search[] = sprintf('\'%s\\%s\\', $prefix, $namespace);
$replace[] = sprintf('\'%s\\', $namespace);
}

return str_replace(sprintf('new Error(\'%s\\Foobar\\Buz', $prefix), 'new Error(\'Foobar\\Buz', $content);
return str_replace($search, $replace, $content);
},
],
'exclude-namespaces' => [
'PHPStan',
// the native turbo extension's classes — must match the loaded
// extension exactly, never prefixed (segment-aware matching means the
// PHPStan entry above does not cover this name)
'PHPStanTurbo',
'PHPUnit',
'PhpParser',
'Hoa',
'Symfony\Polyfill\Php80',
'Symfony\Polyfill\Php81',
'Symfony\Polyfill\Php83',
'Symfony\Polyfill\Php84',
'Symfony\Polyfill\Php85',
'Symfony\Polyfill\Mbstring',
'Symfony\Polyfill\Intl\Normalizer',
'Symfony\Polyfill\Intl\Grapheme',
],
'exclude-namespaces' => $namespaces['excluded'],
'expose-global-functions' => false,
'expose-global-classes' => false,
];
108 changes: 108 additions & 0 deletions tests/PHPStan/Build/ScoperClassNameStringsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
<?php declare(strict_types = 1);

namespace PHPStan\Build;

use PHPUnit\Framework\TestCase;
use Symfony\Component\Finder\Finder;
use function file_get_contents;
use function implode;
use function is_array;
use function preg_match;
use function sprintf;
use function str_replace;
use function str_starts_with;
use function strpos;
use function strtolower;
use function substr;
use function token_get_all;
use const T_CONSTANT_ENCAPSED_STRING;

/**
* Class-name string literals in src/ - `new ObjectType('BcMath\Number')` and
* friends - name classes of the analysed code. php-scoper prefixes every string
* that looks like a fully qualified class name, so unless the namespace is
* either excluded from prefixing altogether or listed among the namespaces the
* patcher in compiler/build/scoper.inc.php un-prefixes again, the phar ends up
* reporting class names like `_PHPStan_abcdef\Filter\FilterFailedException`.
*/
final class ScoperClassNameStringsTest extends TestCase
{

public function testClassNameStringsAreNotPrefixedInPhar(): void
{
/** @var array{excluded: list<string>, unprefixedClassNameStringsInSrc: list<string>} $namespaces */
$namespaces = require __DIR__ . '/../../../compiler/build/scoper-namespaces.php';

$finder = new Finder();
$finder->followLinks();
foreach ($finder->files()->name('*.php')->in(__DIR__ . '/../../../src') as $fileInfo) {
$file = $fileInfo->getPathname();
$code = file_get_contents($file);
if ($code === false) {
self::fail(sprintf('Could not read %s', $file));
}

foreach (token_get_all($code) as $token) {
if (!is_array($token) || $token[0] !== T_CONSTANT_ENCAPSED_STRING) {
continue;
}

$className = self::parseStringLiteral($token[1]);
if (preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*(\\\\[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)+$/', $className) !== 1) {
continue;
}

if (
self::belongsToNamespace($className, $namespaces['excluded'])
|| self::belongsToNamespace($className, $namespaces['unprefixedClassNameStringsInSrc'])
) {
continue;
}

self::fail(sprintf(
"%s on line %d contains the class name string '%s'. php-scoper prefixes such strings in the phar, "
. "so its root namespace has to be added to 'unprefixedClassNameStringsInSrc' in compiler/build/scoper-namespaces.php. "
. 'Namespaces currently handled: %s.',
$file,
$token[2],
$className,
implode(', ', $namespaces['unprefixedClassNameStringsInSrc']),
));
}
}

self::expectNotToPerformAssertions();
}

private static function parseStringLiteral(string $token): string
{
$contents = substr($token, 1, -1);
if ($token[0] === '\'') {
return str_replace(['\\\\', '\\\''], ['\\', '\''], $contents);
}

// double-quoted strings with any escape sequence besides \\ cannot be class names
$contents = str_replace('\\\\', "\0", $contents);
if (strpos($contents, '\\') !== false) {
return '';
}

return str_replace("\0", '\\', $contents);
}

/**
* @param list<string> $namespaces
*/
private static function belongsToNamespace(string $className, array $namespaces): bool
{
foreach ($namespaces as $namespace) {
// namespaces are case-insensitive in PHP and so is php-scoper's matching
if (str_starts_with(strtolower($className), strtolower($namespace) . '\\')) {
return true;
}
}

return false;
}

}
Loading