diff --git a/src/Install/GuidelineAssist.php b/src/Install/GuidelineAssist.php index 8b8cd434..f65e6116 100644 --- a/src/Install/GuidelineAssist.php +++ b/src/Install/GuidelineAssist.php @@ -8,6 +8,7 @@ use Illuminate\Support\Collection; use Illuminate\Support\Str; use Laravel\Boost\Install\Assists\Inertia; +use Laravel\Roster\Enums\Approaches; use Laravel\Roster\Enums\NodePackageManager; use Laravel\Roster\Enums\Packages; use Laravel\Roster\Roster; @@ -68,36 +69,43 @@ public function enums(): array protected function discover(callable $cb): array { $classes = []; - $appPath = app_path(); - - if (! is_dir($appPath)) { - return ['app-path-isnt-a-directory' => $appPath]; + $paths = $this->roster->uses(Approaches::MODULAR) + ? array_filter([ + base_path('modules'), + base_path('Modules'), + base_path('app-modules'), + app_path(), + ], is_dir(...)) + : [app_path()]; + $cacheKey = md5(implode('|', $paths)); + + if ($paths === []) { + return []; } - if (self::$classes === []) { + if (! isset(self::$classes[$cacheKey])) { + self::$classes[$cacheKey] = []; $finder = Finder::create() - ->in($appPath) + ->in($paths) ->files() ->name('/[A-Z].*\.php$/'); foreach ($finder as $file) { - $relativePath = $file->getRelativePathname(); - $namespace = app()->getNamespace(); - $className = $namespace.str_replace( - ['/', '.php'], - ['\\', ''], - $relativePath - ); - try { - $path = $appPath.DIRECTORY_SEPARATOR.$relativePath; + $path = $file->getRealPath(); + + if (! $path) { + continue; + } if (! $this->fileHasClassLike($path)) { continue; } - if (class_exists($className, false)) { - self::$classes[$className] = $path; + $className = $this->classNameFromFile($path); + + if ($className && class_exists($className)) { + self::$classes[$cacheKey][$className] = $path; } } catch (Throwable) { // Ignore exceptions and errors from class loading/reflection @@ -105,7 +113,7 @@ protected function discover(callable $cb): array } } - foreach (self::$classes as $className => $path) { + foreach (self::$classes[$cacheKey] as $className => $path) { if ($cb(new ReflectionClass($className))) { $classes[$className] = $path; } @@ -114,6 +122,55 @@ protected function discover(callable $cb): array return $classes; } + protected function classNameFromFile(string $path): ?string + { + $code = file_get_contents($path); + + if ($code === false) { + return null; + } + + $namespace = null; + $class = null; + + $tokens = token_get_all($code); + $count = count($tokens); + + for ($i = 0; $i < $count; $i++) { + if (is_array($tokens[$i]) && $tokens[$i][0] === T_NAMESPACE) { + $namespace = ''; + + for ($j = $i + 1; $j < $count; $j++) { + if (is_array($tokens[$j]) && in_array($tokens[$j][0], [T_STRING, T_NS_SEPARATOR, T_NAME_QUALIFIED], true)) { + $namespace .= $tokens[$j][1]; + + continue; + } + + if ($tokens[$j] === ';' || $tokens[$j] === '{') { + break; + } + } + } + + if (is_array($tokens[$i]) && in_array($tokens[$i][0], [T_CLASS, T_ENUM], true)) { + for ($j = $i + 1; $j < $count; $j++) { + if (is_array($tokens[$j]) && $tokens[$j][0] === T_STRING) { + $class = $tokens[$j][1]; + + break 2; + } + } + } + } + + if (! $class) { + return null; + } + + return $namespace ? "{$namespace}\\{$class}" : $class; + } + public function fileHasClassLike(string $path): bool { static $cache = []; diff --git a/tests/Feature/Install/GuidelineComposerTest.php b/tests/Feature/Install/GuidelineComposerTest.php index 5dcde82a..fc14111f 100644 --- a/tests/Feature/Install/GuidelineComposerTest.php +++ b/tests/Feature/Install/GuidelineComposerTest.php @@ -27,6 +27,7 @@ $this->herd->shouldReceive('isInstalled')->andReturn(false)->byDefault(); $this->app->instance(Roster::class, $this->roster); + $this->roster->shouldReceive('uses')->andReturn(false)->byDefault(); $this->composer = new GuidelineComposer($this->roster, $this->herd); }); diff --git a/tests/Feature/Install/SkillComposerTest.php b/tests/Feature/Install/SkillComposerTest.php index e09d6308..d60da91a 100644 --- a/tests/Feature/Install/SkillComposerTest.php +++ b/tests/Feature/Install/SkillComposerTest.php @@ -16,6 +16,7 @@ $this->roster = Mockery::mock(Roster::class); $this->roster->shouldReceive('nodePackageManager')->andReturn(NodePackageManager::NPM); $this->roster->shouldReceive('usesVersion')->andReturn(false); + $this->roster->shouldReceive('uses')->andReturn(false); $this->app->instance(Roster::class, $this->roster); }); diff --git a/tests/Feature/Mcp/Prompts/UpgradeInertiav3Test.php b/tests/Feature/Mcp/Prompts/UpgradeInertiav3Test.php index 34d4b7ce..1be2ac12 100644 --- a/tests/Feature/Mcp/Prompts/UpgradeInertiav3Test.php +++ b/tests/Feature/Mcp/Prompts/UpgradeInertiav3Test.php @@ -14,6 +14,7 @@ function mockRosterWithFrameworks(bool $react = false, bool $vue = false, bool $svelte = false): Roster { $roster = Mockery::mock(Roster::class); + $roster->shouldReceive('uses')->withAnyArgs()->andReturn(false)->byDefault(); $roster->shouldReceive('uses')->with(Packages::INERTIA_REACT)->andReturn($react); $roster->shouldReceive('uses')->with(Packages::INERTIA_VUE)->andReturn($vue); $roster->shouldReceive('uses')->with(Packages::INERTIA_SVELTE)->andReturn($svelte); diff --git a/tests/Unit/Install/GuidelineAssistTest.php b/tests/Unit/Install/GuidelineAssistTest.php index 748136d1..92681a49 100644 --- a/tests/Unit/Install/GuidelineAssistTest.php +++ b/tests/Unit/Install/GuidelineAssistTest.php @@ -5,13 +5,14 @@ use Laravel\Boost\Install\GuidelineAssist; use Laravel\Boost\Install\GuidelineConfig; use Laravel\Boost\Install\Sail; +use Laravel\Roster\Enums\Approaches; use Laravel\Roster\Roster; beforeEach(function (): void { $this->roster = Mockery::mock(Roster::class); $this->roster->shouldReceive('nodePackageManager')->andReturn(null); $this->roster->shouldReceive('usesVersion')->andReturn(false); - + $this->roster->shouldReceive('uses')->withAnyArgs()->andReturn(false)->byDefault(); $this->config = new GuidelineConfig; }); @@ -203,3 +204,210 @@ expect($assist->appPath())->toBe('src'); expect($assist->appPath('path'.DIRECTORY_SEPARATOR.'to'.DIRECTORY_SEPARATOR.'file.php'))->toBe('src'.DIRECTORY_SEPARATOR.'path'.DIRECTORY_SEPARATOR.'to'.DIRECTORY_SEPARATOR.'file.php'); })->after(fn () => app()->useAppPath('app')); + +test('discovers models outside the app directory', function (): void { + $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); + $modulesDir = $tempDir.'/modules/Blog/Models'; + mkdir($modulesDir, 0777, true); + + $modelPath = $modulesDir.'/Post.php'; + + file_put_contents($modelPath, <<<'PHP' +app->setBasePath($tempDir); + mkdir($tempDir.'/app', 0777, true); + $this->app->useAppPath($tempDir.'/app'); + + $this->roster + ->shouldReceive('uses') + ->with(Approaches::MODULAR) + ->andReturn(true); + + require_once $modelPath; + + try { + $assist = new GuidelineAssist($this->roster, new GuidelineConfig); + + expect($assist->models())->toHaveKey('Modules\Blog\Models\Post'); + } finally { + unlink($modelPath); + rmdir($modulesDir); + rmdir(dirname($modulesDir)); + rmdir(dirname($modulesDir, 2)); + rmdir($tempDir.'/app'); + rmdir($tempDir); + } +}); + +test('discovers models in Modules directory (capital M)', function (): void { + $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); + $modulesDir = $tempDir.'/Modules/Blog/Models'; + mkdir($modulesDir, 0777, true); + + $modelPath = $modulesDir.'/Article.php'; + + file_put_contents($modelPath, <<<'PHP' +app->setBasePath($tempDir); + mkdir($tempDir.'/app', 0777, true); + $this->app->useAppPath($tempDir.'/app'); + + $this->roster + ->shouldReceive('uses') + ->with(Approaches::MODULAR) + ->andReturn(true); + + require_once $modelPath; + + try { + $assist = new GuidelineAssist($this->roster, new GuidelineConfig); + + expect($assist->models())->toHaveKey('Modules\Blog\Models\Article'); + } finally { + unlink($modelPath); + rmdir($modulesDir); + rmdir(dirname($modulesDir)); + rmdir(dirname($modulesDir, 2)); + rmdir($tempDir.'/app'); + rmdir($tempDir); + } +}); + +test('discovers models in app-modules directory', function (): void { + $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); + $modulesDir = $tempDir.'/app-modules/Blog/Models'; + mkdir($modulesDir, 0777, true); + + $modelPath = $modulesDir.'/Comment.php'; + + file_put_contents($modelPath, <<<'PHP' +app->setBasePath($tempDir); + mkdir($tempDir.'/app', 0777, true); + $this->app->useAppPath($tempDir.'/app'); + + $this->roster + ->shouldReceive('uses') + ->with(Approaches::MODULAR) + ->andReturn(true); + + require_once $modelPath; + + try { + $assist = new GuidelineAssist($this->roster, new GuidelineConfig); + + expect($assist->models())->toHaveKey('AppModules\Blog\Models\Comment'); + } finally { + unlink($modelPath); + rmdir($modulesDir); + rmdir(dirname($modulesDir)); + rmdir(dirname($modulesDir, 2)); + rmdir($tempDir.'/app'); + rmdir($tempDir); + } +}); + +test('discovers models in app directory when not modular', function (): void { + $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); + $appModelsDir = $tempDir.'/app/Models'; + mkdir($appModelsDir, 0777, true); + + $modelPath = $appModelsDir.'/User.php'; + + file_put_contents($modelPath, <<<'PHP' +app->setBasePath($tempDir); + $this->app->useAppPath($tempDir.'/app'); + + $this->roster + ->shouldReceive('uses') + ->with(Approaches::MODULAR) + ->andReturn(false); + + require_once $modelPath; + + try { + $assist = new GuidelineAssist($this->roster, new GuidelineConfig); + + expect($assist->models())->toHaveKey('App\Models\User'); + } finally { + unlink($modelPath); + rmdir($appModelsDir); + rmdir($tempDir.'/app'); + rmdir($tempDir); + } +}); + +test('does not discover models outside app directory when not modular', function (): void { + $tempDir = sys_get_temp_dir().'/boost_test_'.uniqid(); + $modulesDir = $tempDir.'/modules/Blog/Models'; + mkdir($modulesDir, 0777, true); + mkdir($tempDir.'/app', 0777, true); + + $modelPath = $modulesDir.'/Tag.php'; + + file_put_contents($modelPath, <<<'PHP' +app->setBasePath($tempDir); + $this->app->useAppPath($tempDir.'/app'); + + $this->roster + ->shouldReceive('uses') + ->with(Approaches::MODULAR) + ->andReturn(false); + + require_once $modelPath; + + try { + $assist = new GuidelineAssist($this->roster, new GuidelineConfig); + + expect($assist->models())->not->toHaveKey('Modules\Blog\Models\Tag'); + } finally { + unlink($modelPath); + rmdir($modulesDir); + rmdir(dirname($modulesDir)); + rmdir(dirname($modulesDir, 2)); + rmdir($tempDir.'/app'); + rmdir($tempDir); + } +});