Skip to content
Open
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
93 changes: 75 additions & 18 deletions src/Install/GuidelineAssist.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -68,44 +69,51 @@ 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
}
}
}

foreach (self::$classes as $className => $path) {
foreach (self::$classes[$cacheKey] as $className => $path) {
if ($cb(new ReflectionClass($className))) {
$classes[$className] = $path;
}
Expand All @@ -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 = [];
Expand Down
1 change: 1 addition & 0 deletions tests/Feature/Install/GuidelineComposerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
1 change: 1 addition & 0 deletions tests/Feature/Install/SkillComposerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
1 change: 1 addition & 0 deletions tests/Feature/Mcp/Prompts/UpgradeInertiav3Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
210 changes: 209 additions & 1 deletion tests/Unit/Install/GuidelineAssistTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});

Expand Down Expand Up @@ -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'
<?php

namespace Modules\Blog\Models;

use Illuminate\Database\Eloquent\Model;

class Post extends Model {}
PHP);

$this->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'
<?php

namespace Modules\Blog\Models;

use Illuminate\Database\Eloquent\Model;

class Article extends Model {}
PHP);

$this->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'
<?php

namespace AppModules\Blog\Models;

use Illuminate\Database\Eloquent\Model;

class Comment extends Model {}
PHP);

$this->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'
<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model {}
PHP);

$this->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'
<?php

namespace Modules\Blog\Models;

use Illuminate\Database\Eloquent\Model;

class Tag extends Model {}
PHP);

$this->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);
}
});
Loading