File "ComposerAutoloadWarmer.php"
Full Path: /var/www/html/back/vendor/psy/psysh/src/TabCompletion/AutoloadWarmer/ComposerAutoloadWarmer.php
File size: 18.77 KB
MIME-type: text/x-php
Charset: utf-8
<?php
/*
* This file is part of Psy Shell.
*
* (c) 2012-2025 Justin Hileman
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Psy\TabCompletion\AutoloadWarmer;
use Psy\Shell;
/**
* Composer autoload warmer.
*
* Loads classes from Composer's autoload configuration. By default, loads
* application classes but excludes vendor packages and test files to balance
* startup time with completion quality.
*
* Example configuration:
*
* // Enable autoload warming with default settings
* $config->setWarmAutoload(true);
*
* // Disable autoload warming (default)
* $config->setWarmAutoload(false);
*
* // Configure ComposerAutoloadWarmer options
* $config->setWarmAutoload([
* 'includeVendor' => true, // Include all vendor packages
* 'includeTests' => false, // Exclude test namespaces
* 'includeNamespaces' => ['App\\', 'Lib\\'], // Only these namespaces
* 'excludeNamespaces' => ['App\\Legacy\\'], // Exclude specific namespaces
* ]);
*
* // Include specific vendor packages only
* $config->setWarmAutoload([
* 'includeVendorNamespaces' => ['Symfony\\', 'Doctrine\\'],
* ]);
*
* // Include all vendor except specific packages
* $config->setWarmAutoload([
* 'includeVendor' => true,
* 'excludeVendorNamespaces' => ['Symfony\\Debug\\'],
* ]);
*
* // Use custom warmers only
* $config->setWarmAutoload([
* 'warmers' => [$myCustomWarmer],
* ]);
*
* // Combine custom warmers with configured ComposerAutoloadWarmer
* $config->setWarmAutoload([
* 'warmers' => [$myCustomWarmer],
* 'includeVendorNamespaces' => ['Symfony\\'],
* ]);
*/
class ComposerAutoloadWarmer implements AutoloadWarmerInterface
{
private bool $includeVendor;
private bool $includeTests;
private array $includeNamespaces;
private array $excludeNamespaces;
private array $includeVendorNamespaces;
private array $excludeVendorNamespaces;
private ?string $vendorDir = null;
private ?string $pharPrefix = null;
private const KNOWN_BAD_NAMESPACES = [
'Psy\\Readline\\Hoa\\',
'Composer\\', // Autoloading Composer classes breaks Composer autoloading :grimacing:
];
/**
* PsySH's php-scoper prefix pattern for PHAR builds.
*
* Vendor dependencies in the PHAR are prefixed with "_Psy<hash>\" to avoid
* conflicts with user dependencies. The hash is randomly generated per build.
*/
private const PHAR_SCOPED_PREFIX_PATTERN = '/^_Psy[a-f0-9]+\\\\/';
/**
* @param array $config Configuration options
* @param string|null $vendorDir Optional vendor directory path (auto-detected if not provided)
*/
public function __construct(array $config = [], ?string $vendorDir = null)
{
$hasVendorFilters = isset($config['includeVendorNamespaces']) || isset($config['excludeVendorNamespaces']);
// Validate conflicting config
if ($hasVendorFilters && isset($config['includeVendor']) && $config['includeVendor'] === false) {
throw new \InvalidArgumentException('Cannot use includeVendorNamespaces or excludeVendorNamespaces when includeVendor is false');
}
// Vendor namespace filters imply includeVendor: true
$this->includeVendor = $config['includeVendor'] ?? $hasVendorFilters;
$this->includeTests = $config['includeTests'] ?? false;
$this->includeNamespaces = $this->normalizeNamespaces($config['includeNamespaces'] ?? []);
$this->excludeNamespaces = $this->normalizeNamespaces($config['excludeNamespaces'] ?? []);
$this->includeVendorNamespaces = $this->normalizeNamespaces($config['includeVendorNamespaces'] ?? []);
$this->excludeVendorNamespaces = $this->normalizeNamespaces($config['excludeVendorNamespaces'] ?? []);
// Cache PHAR prefix to avoid repeated Phar::running() calls
if (Shell::isPhar()) {
$runningPhar = \Phar::running(false);
$this->pharPrefix = 'phar://'.$runningPhar.'/';
}
$vendorDir = $vendorDir ?? $this->findVendorDir();
if ($vendorDir !== null) {
$resolvedVendorDir = \realpath($vendorDir);
if ($resolvedVendorDir !== false) {
$this->vendorDir = \str_replace('\\', '/', $resolvedVendorDir);
}
}
}
/**
* {@inheritdoc}
*/
public function warm(): int
{
// Get count of already-loaded classes before we start
$beforeCount = \count(\get_declared_classes()) +
\count(\get_declared_interfaces()) +
\count(\get_declared_traits());
$classes = $this->getClassNames();
foreach ($classes as $class) {
try {
// Skip if already loaded (check without autoloading first)
if (
\class_exists($class, false) ||
\interface_exists($class, false) ||
\trait_exists($class, false)
) {
continue;
}
// Try to load the class/interface/trait
// The autoload parameter (true) will trigger autoloading
\class_exists($class, true) ||
\interface_exists($class, true) ||
\trait_exists($class, true);
} catch (\Throwable $e) {
// Ignore classes that fail to load
// This is expected for classes with missing dependencies, etc.
}
}
// Return the number of newly loaded classes
$afterCount = \count(\get_declared_classes()) +
\count(\get_declared_interfaces()) +
\count(\get_declared_traits());
return $afterCount - $beforeCount;
}
/**
* Discover classes from available sources.
*
* Uses two complementary strategies:
* 1. ClassLoader's classmap (from optimized autoload or registered classes)
* 2. ClassMapGenerator to scan PSR-4 directories (if available, safe, no side effects)
*
* Both strategies are attempted and results are combined, since ClassLoader
* may have an optimized classmap while ClassMapGenerator can discover new
* classes added during development.
*
* @internal This method is exported for testability and is not part of the public API
*
* @return string[] Fully-qualified class names
*/
public function getClassNames(): array
{
$autoloadMap = $this->getAutoloadClassMap();
$generatedMap = $this->generateClassMap();
return $this->classesFromClassMap(\array_merge($autoloadMap, $generatedMap));
}
/**
* Get class map from the registered Composer ClassLoader, if available.
*
* This map is populated by running `composer dump-autoload`
*
* @return array Map of class name => file path
*/
private function getAutoloadClassMap(): array
{
// If we found a project vendor dir, try to register their autoloader (if it hasn't been already)
// Skip if vendor dir is inside a PHAR (don't re-require the PHAR's autoloader)
if ($this->vendorDir !== null && \substr($this->vendorDir, 0, 7) !== 'phar://') {
$projectAutoload = $this->vendorDir.'/autoload.php';
if (\file_exists($projectAutoload)) {
try {
require_once $projectAutoload;
} catch (\Throwable $e) {
// Ignore autoloader errors
}
}
}
foreach (\spl_autoload_functions() as $autoloader) {
if (!\is_array($autoloader)) {
continue;
}
$loader = $autoloader[0] ?? null;
if (!\is_object($loader) || \get_class($loader) !== 'Composer\Autoload\ClassLoader') {
continue;
}
// Check if this loader contains scoped classes (indicates it's from the PHAR)
if (\method_exists($loader, 'getClassMap')) {
$classMap = $loader->getClassMap();
foreach (\array_keys($classMap) as $class) {
if (\preg_match(self::PHAR_SCOPED_PREFIX_PATTERN, $class)) {
continue 2; // Skip to next autoloader
}
}
}
// If we have an explicit vendor dir, check if this loader serves that directory
if ($this->vendorDir !== null) {
$hasTargetPaths = false;
if (\method_exists($loader, 'getPrefixesPsr4')) {
$prefixes = $loader->getPrefixesPsr4();
foreach ($prefixes as $namespace => $paths) {
foreach ($paths as $path) {
if (\strpos($path, $this->vendorDir.'/') === 0) {
$hasTargetPaths = true;
break 2;
}
}
}
}
if (!$hasTargetPaths) {
continue;
}
}
if (\method_exists($loader, 'getClassMap')) {
return $loader->getClassMap();
}
return [];
}
return [];
}
/**
* Scan autoload directories using a Composer ClassMapGenerator, if available.
*
* @return array Map of class name => file path
*/
private function generateClassMap(): array
{
if ($this->vendorDir === null || !\class_exists('Composer\\ClassMapGenerator\\ClassMapGenerator', true)) {
return [];
}
// Get PSR-4 mappings from Composer
$psr4File = $this->vendorDir.'/composer/autoload_psr4.php';
if (!\file_exists($psr4File)) {
return [];
}
try {
$psr4Map = require $psr4File;
if (!\is_array($psr4Map)) {
return [];
}
$generator = new \Composer\ClassMapGenerator\ClassMapGenerator();
foreach ($psr4Map as $prefix => $paths) {
foreach ($paths as $path) {
if (!\is_dir($path) || $this->shouldSkipPath($path)) {
continue;
}
try {
$generator->scanPaths($path);
} catch (\Throwable $e) {
// Ignore errors (permissions, malformed files, etc.)
}
}
}
return $generator->getClassMap()->getMap();
} catch (\Throwable $e) {
return [];
}
}
/**
* Find the vendor directory by checking registered autoloaders, falling
* back to filesystem search.
*
* @return string|null
*/
private function findVendorDir(): ?string
{
// When running the PsySH PHAR, skip autoloader detection. It will just return the internal
// vendor directory.
if ($this->pharPrefix !== null) {
// Try to find from autoloader
foreach (\spl_autoload_functions() as $autoloader) {
if (!\is_array($autoloader)) {
continue;
}
$loader = $autoloader[0] ?? null;
if (!\is_object($loader)) {
continue;
}
try {
$reflection = new \ReflectionClass($loader);
$loaderPath = $reflection->getFileName();
$normalizedPath = \str_replace('\\', '/', $loaderPath);
// Skip any other PHAR-based autoloaders
if (\strpos($normalizedPath, 'phar://') === 0) {
continue;
}
// ClassLoader is typically at vendor/composer/ClassLoader.php
if (\strpos($normalizedPath, '/vendor/composer/') !== false) {
return \dirname($loaderPath, 2);
}
} catch (\Throwable $e) {
// Ignore and try next autoloader
}
}
}
// Walk up the directory tree from cwd looking for vendor directory
$dir = \getcwd();
$root = \dirname($dir);
while ($dir !== $root) {
$vendorDir = $dir.'/vendor';
if (\is_dir($vendorDir) && \file_exists($vendorDir.'/composer/autoload_psr4.php')) {
return $vendorDir;
}
$root = $dir;
$dir = \dirname($dir);
}
return null;
}
/**
* Normalize namespace prefixes.
*
* Removes leading backslash and ensures trailing backslash.
*
* @param string[] $namespaces
*
* @return string[]
*/
private function normalizeNamespaces(array $namespaces): array
{
return \array_map(function ($namespace) {
return \trim($namespace, '\\').'\\';
}, $namespaces);
}
/**
* Check if a path should be skipped based on configuration.
*
* @param string $path File or directory path
*
* @return bool True if the path should be skipped
*/
private function shouldSkipPath(string $path): bool
{
$normalizedPath = \str_replace('\\', '/', $path);
// Skip paths from PsySH's own PHAR; these are PsySH's bundled dependencies, not user dependencies
if ($this->isPathFromPhar($normalizedPath)) {
return true;
}
// Check if path is under vendor directory
if (!$this->includeVendor && $this->vendorDir !== null) {
// Resolve relative paths like "vendor/composer/../../src/Cache.php" before comparing
$resolvedPath = \realpath($path);
if ($resolvedPath === false) {
// File doesn't exist, permissions, etc. /shrug
return true;
}
$resolvedPath = \str_replace('\\', '/', $resolvedPath);
if (\strpos($resolvedPath, $this->vendorDir.'/') === 0) {
return true;
}
}
// Check test paths
if (!$this->includeTests) {
$patterns = ['/test/', '/tests/', '/spec/', '/specs/'];
foreach ($patterns as $pattern) {
if (\stripos($normalizedPath, $pattern) !== false) {
return true;
}
}
}
return false;
}
/**
* Get classes from class map, filtered based on configured namespace rules and excluded paths.
*
* @param array $classMap Map of class name => file path
*
* @return string[]
*/
private function classesFromClassMap(array $classMap): array
{
// First filter the map by path
$classMap = \array_filter($classMap, function ($path) {
return !$this->shouldSkipPath($path);
});
$classes = \array_keys($classMap);
// Then filter by namespace
return \array_values(
\array_filter($classes, function ($class) use ($classMap) {
// Exclude PsySH's scoped PHAR dependencies (e.g., _Psy3684f4474398\Symfony\...)
if (\preg_match(self::PHAR_SCOPED_PREFIX_PATTERN, $class)) {
return false;
}
// Hardcode excluding known-bad classes
foreach (self::KNOWN_BAD_NAMESPACES as $namespace) {
if (\stripos($class, $namespace) === 0) {
return false;
}
}
$isVendorClass = $this->isVendorClass($class, $classMap);
// Apply vendor-specific exclude filters
if ($isVendorClass && !empty($this->excludeVendorNamespaces)) {
foreach ($this->excludeVendorNamespaces as $namespace) {
if (\stripos($class, $namespace) === 0) {
return false;
}
}
}
// Apply general exclude filters
foreach ($this->excludeNamespaces as $namespace) {
if (\stripos($class, $namespace) === 0) {
return false;
}
}
// Apply vendor-specific include filters
if ($isVendorClass && !empty($this->includeVendorNamespaces)) {
foreach ($this->includeVendorNamespaces as $namespace) {
if (\stripos($class, $namespace) === 0) {
return true;
}
}
return false; // Vendor class doesn't match vendor filters
}
// Apply general include filters
if (!empty($this->includeNamespaces)) {
foreach ($this->includeNamespaces as $namespace) {
if (\stripos($class, $namespace) === 0) {
return true;
}
}
return false;
}
// No include filters provided, and didn't match exclude filters
return true;
}),
);
}
/**
* Check if a class is from the vendor directory.
*
* @param string $class Class name
* @param array $classMap Map of class name => file path
*
* @return bool
*/
private function isVendorClass(string $class, array $classMap): bool
{
if ($this->vendorDir === null || !isset($classMap[$class])) {
return false;
}
$path = $classMap[$class];
// Resolve relative paths like "vendor/composer/../../src/Cache.php" before comparing
// This ensures consistency with shouldSkipPath() logic
$resolvedPath = \realpath($path);
if ($resolvedPath === false) {
// If realpath fails, fall back to raw path comparison
// This matches the behavior in shouldSkipPath()
$normalizedPath = \str_replace('\\', '/', $path);
return \strpos($normalizedPath, $this->vendorDir.'/') === 0;
}
$normalizedPath = \str_replace('\\', '/', $resolvedPath);
return \strpos($normalizedPath, $this->vendorDir.'/') === 0;
}
/**
* Check if a path is from PsySH's own PHAR.
*
* @param string $path File path to check
*
* @return bool True if the path is from PsySH's PHAR
*/
private function isPathFromPhar(string $path): bool
{
if ($this->pharPrefix === null || \strpos($path, 'phar://') !== 0) {
return false;
}
return \strpos($path, $this->pharPrefix) === 0;
}
}