diff --git a/src/Illuminate/Console/GeneratorCommand.php b/src/Illuminate/Console/GeneratorCommand.php index 5b6af51a576b..d32ce8e3cd6f 100644 --- a/src/Illuminate/Console/GeneratorCommand.php +++ b/src/Illuminate/Console/GeneratorCommand.php @@ -2,14 +2,19 @@ namespace Illuminate\Console; +use Composer\Autoload\ClassLoader; use Illuminate\Console\Concerns\CreatesMatchingTest; use Illuminate\Contracts\Console\PromptsForMissingInput; use Illuminate\Filesystem\Filesystem; use Illuminate\Support\Collection; use Illuminate\Support\Str; +use RuntimeException; use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Finder\Finder; +use function Laravel\Prompts\text; + abstract class GeneratorCommand extends Command implements PromptsForMissingInput { /** @@ -117,6 +122,8 @@ abstract class GeneratorCommand extends Command implements PromptsForMissingInpu '__TRAIT__', ]; + protected string $rootNamespace; + /** * Create a new generator command instance. * @@ -130,6 +137,13 @@ public function __construct(Filesystem $files) $this->addTestOptions(); } + $this->getDefinition()->addOption(new InputOption( + 'in', + null, + InputOption::VALUE_REQUIRED, + "Specify a namespace to generate the {$this->type} class in" + )); + $this->files = $files; } @@ -305,9 +319,70 @@ protected function alreadyExists($rawName) */ protected function getPath($name) { - $name = Str::replaceFirst($this->rootNamespace(), '', $name); + try { + return $this->resolvePathForClass($name); + } catch (RuntimeException) { + return $this->laravel['path.base'].'/'.str_replace('\\', '/', $name).'.php'; + } + } + + /** + * Resolve the expected path for a class based on Composer autoload mappings. + * + * @throws \RuntimeException if multiple base paths match or none can be resolved + */ + protected function resolvePathForClass(string $class): string + { + $namespaceRoots = []; + + // Collect valid PSR-4 and PSR-0 namespace mappings + foreach (ClassLoader::getRegisteredLoaders() as $loader) { + foreach ([$loader->getPrefixesPsr4(), $loader->getPrefixes()] as $prefixes) { + foreach ($prefixes as $namespace => $paths) { + foreach ($paths as $path) { + $real = realpath($path); + if ($real !== false) { + $namespaceRoots[rtrim($namespace, '\\')][] = $real; + } + } + } + } + } + + // Sort by namespace depth (deepest first) + uksort($namespaceRoots, fn ($a, $b) => Str::substrCount($b, '\\') <=> Str::substrCount($a, '\\')); - return $this->laravel['path'].'/'.str_replace('\\', '/', $name).'.php'; + foreach ($namespaceRoots as $prefix => $paths) { + if (! Str::startsWith($class, $prefix)) { + continue; + } + + // Filter duplicates and invalid entries + $paths = array_unique(array_filter($paths)); + + if (count($paths) > 1) { + throw new RuntimeException(sprintf( + 'Multiple base paths found for namespace [%s]: %s', + $prefix, + implode(', ', $paths) + )); + } + + if (empty($paths)) { + continue; + } + + $basePath = reset($paths); + $relative = ltrim(Str::after($class, $prefix), '\\'); + $relativePath = str_replace(['\\', '_'], DIRECTORY_SEPARATOR, $relative).'.php'; + + return rtrim($basePath, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$relativePath; + } + + throw new RuntimeException(sprintf( + 'Unable to resolve a base path for class [%s]', + $class + )); } /** @@ -433,7 +508,20 @@ protected function getNameInput() */ protected function rootNamespace() { - return $this->laravel->getNamespace(); + if (! empty($this->rootNamespace)) { + return $this->rootNamespace; + } + + $in = $this->option('in') ?? $this->laravel->getNamespace(); + if (empty($in)) { + $in = text( + label: 'What namespace would you like to generate the '.$this->type.' in?', + placeholder: 'App', + validate: fn ($value) => empty($value) ? 'The in option is required when the application namespace is empty.' : null, + ); + } + + return $this->rootNamespace = trim($in, '\\').'\\'; } /** diff --git a/tests/Integration/Generators/TestCase.php b/tests/Integration/Generators/TestCase.php index 19bce44b7ddb..b040d3133989 100644 --- a/tests/Integration/Generators/TestCase.php +++ b/tests/Integration/Generators/TestCase.php @@ -2,9 +2,21 @@ namespace Illuminate\Tests\Integration\Generators; +use Composer\Autoload\ClassLoader; use Orchestra\Testbench\Concerns\InteractsWithPublishedFiles; abstract class TestCase extends \Orchestra\Testbench\TestCase { use InteractsWithPublishedFiles; + + protected function setUp(): void + { + parent::setUp(); + + // Register the App namespace with Composer's autoloader for the testbench laravel app + $appPath = $this->app->path(); + foreach (ClassLoader::getRegisteredLoaders() as $loader) { + $loader->addPsr4('App\\', [$appPath]); + } + } }