diff --git a/.github/workflows/test-laravel.yml b/.github/workflows/test-laravel.yml index 50851e44..967e3b88 100644 --- a/.github/workflows/test-laravel.yml +++ b/.github/workflows/test-laravel.yml @@ -1,4 +1,4 @@ -name: Test laravel projects +name: Test Laravel app on: push: @@ -19,13 +19,22 @@ on: jobs: build: runs-on: ubuntu-latest - - name: Test Laravel + name: Test Laravel app steps: - name: Checkout code uses: actions/checkout@v4 - - name: Test Laravel + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + coverage: none + + - name: Build a Laravel project and run Psalm on it run: | ./tests/Application/laravel-test.sh + + - run: | + echo "Psalm analysis failed on a fresh Laravel project. Please consider updating baseline: tests/Application/laravel-test-baseline.xml" + if: ${{ failure() }} \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b84c7bcd..ee2b1047 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,7 @@ on: paths: - '**.php' - '**.stubphp' - - '**.feature' + - '**.phpt' - '**.yml' - 'composer.json' - '.github/workflows/test.yml' @@ -14,7 +14,7 @@ on: paths: - '**.php' - '**.stubphp' - - '**.feature' + - '**.phpt' - 'composer.json' - '.github/workflows/test.yml' schedule: @@ -51,13 +51,10 @@ jobs: strategy: fail-fast: true matrix: - php: [8.4, 8.3, 8.2, 8.1] - laravel: [^11.0, ^10.0] - dependencies: [lowest, highest] - exclude: - - php: 8.1 - laravel: ^11.0 - name: Type P${{ matrix.php }} | L${{ matrix.laravel }} | ${{ matrix.dependencies == 'highest' && '↑' || '↓' }} + php: [8.4, 8.3, 8.2] + laravel: [^11.35] + dependencies: [highest] + name: Type test P${{ matrix.php }} | L${{ matrix.laravel }} | ${{ matrix.dependencies == 'highest' && '↑' || '↓' }} steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/composer.json b/composer.json index 9d685582..eb85f45c 100644 --- a/composer.json +++ b/composer.json @@ -11,33 +11,33 @@ ], "homepage": "https://github.com/psalm/psalm-plugin-laravel", "require": { - "php": "^8.1", + "php": "^8.2", "ext-simplexml": "*", - "barryvdh/laravel-ide-helper": "^2.13 || ^3.0", - "illuminate/config": "^10.48 || ^11.0", - "illuminate/container": "^10.48 || ^11.0", - "illuminate/contracts": "^10.48 || ^11.0", - "illuminate/database": "^10.48 || ^11.0", - "illuminate/events": "^10.48 || ^11.0", - "illuminate/http": "^10.48 || ^11.0", - "illuminate/routing": "^10.48 || ^11.0", - "illuminate/support": "^10.48 || ^11.0", - "illuminate/view": "^10.48 || ^11.0", - "nikic/php-parser": "^4.18 || ^5.0", - "orchestra/testbench-core": "^8.22 || ^9.0", - "symfony/console": "^6.0 || ^7.0", - "symfony/finder": "^6.0 || ^7.0", - "vimeo/psalm": "^5.20|^6" + "barryvdh/laravel-ide-helper": "~3.5.4", + "illuminate/config": "^11.35", + "illuminate/container": "^11.35", + "illuminate/contracts": "^11.35", + "illuminate/database": "^11.35", + "illuminate/events": "^11.35", + "illuminate/http": "^11.35", + "illuminate/routing": "^11.35", + "illuminate/support": "^11.35", + "illuminate/view": "^11.35", + "nikic/php-parser": "^5.0", + "orchestra/testbench-core": "^9.9", + "symfony/console": "^7.1", + "symfony/finder": "^7.1", + "vimeo/psalm": "^5.26 || ^6.0" }, "require-dev": { - "laravel/framework": "^10.48 || ^11.0", - "phpunit/phpunit": "^10.5 || ^11.0", + "laravel/framework": "^11.35", + "phpunit/phpunit": "^10.5 || ^11.5", "phpyh/psalm-tester": "^0.1.0", "ramsey/collection": "^1.3", - "rector/rector": "^1.0", - "slevomat/coding-standard": "^8.8", - "squizlabs/php_codesniffer": "*", - "symfony/http-foundation": "^6.0 || ^7.0" + "rector/rector": "^2.0", + "slevomat/coding-standard": "^8.15", + "squizlabs/php_codesniffer": "^3.11", + "symfony/http-foundation": "^7.1" }, "minimum-stability": "dev", "prefer-stable": true, @@ -68,14 +68,16 @@ "lint-fix": "phpcbf -n", "psalm": "psalm --find-dead-code --find-unused-psalm-suppress --long-progress", "psalm-set-baseline": "psalm --set-baseline=psalm-baseline.xml", + "rector": "./vendor/bin/rector", + "rector:dry": "./vendor/bin/rector --dry-run", "test": [ "@lint", "@psalm", "@test:unit", "@test:type" ], + "test:app": "./tests/Application/laravel-test.sh", "test:type": "phpunit --testsuite=type", - "test:unit": "phpunit --testsuite=unit", - "rector": "./vendor/bin/rector --dry-run" + "test:unit": "phpunit --testsuite=unit" } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 16945baa..6bb5e14e 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,25 +1,25 @@ - - - tests/Unit - - - tests/Type - - + xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.5/phpunit.xsd" + bootstrap="vendor/autoload.php" + cacheDirectory=".phpunit.cache" + executionOrder="depends,defects" + beStrictAboutCoverageMetadata="true" + beStrictAboutOutputDuringTests="true" + failOnRisky="true" + failOnWarning="true"> + + + tests/Unit + + + tests/Type + + - - src - - + + src + + diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 6a3abd58..650e3a45 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,18 +1,9 @@ - - - - $abstract - - + config->get('auth.guards')]]> - - ?string - |null]]> - config->get("auth.providers.{$provider}.model", null)]]> config->get('auth.defaults.guard')]]> @@ -20,14 +11,9 @@ - null + - - - name->parts]]> - - getFqClasslikeName()]]> @@ -35,29 +21,39 @@ getFqClasslikeName()]]> - array - array - array + + + - - - value->name->parts]]> - - - new TLiteralString($result) + + + + + + + + - - - + + + + + + + + + + + - new TLiteralString($concrete) + diff --git a/psalm.xml b/psalm.xml index 45b1c6ed..6f4b2b37 100644 --- a/psalm.xml +++ b/psalm.xml @@ -2,7 +2,7 @@ + diff --git a/rector.php b/rector.php index ed2ea39f..ce4b8aeb 100644 --- a/rector.php +++ b/rector.php @@ -2,7 +2,6 @@ use Rector\CodeQuality\Rector\FunctionLike\SimplifyUselessVariableRector; use Rector\CodingStyle\Rector\Assign\SplitDoubleAssignRector; -use Rector\CodingStyle\Rector\Closure\StaticClosureRector; use Rector\CodingStyle\Rector\Encapsed\EncapsedStringsToSprintfRector; use Rector\CodingStyle\Rector\If_\NullableCompareToNullRector; use Rector\Config\RectorConfig; @@ -11,24 +10,19 @@ use Rector\Php81\Rector\Array_\FirstClassCallableRector; use Rector\Php81\Rector\FuncCall\NullToStrictStringFuncCallArgRector; use Rector\Php81\Rector\Property\ReadOnlyPropertyRector; -use Rector\Strict\Rector\BooleanNot\BooleanInBooleanNotRuleFixerRector; -use Rector\Strict\Rector\If_\BooleanInIfConditionRuleFixerRector; return RectorConfig::configure() ->withPaths(['src', 'tests']) - ->withPhpSets(php81: true) + ->withPhpSets(php82: true) ->withPreparedSets(deadCode: true, codingStyle: true, typeDeclarations: true) ->withSkip([ ReadOnlyPropertyRector::class, ClosureToArrowFunctionRector::class, FirstClassCallableRector::class, NullToStrictStringFuncCallArgRector::class, - BooleanInIfConditionRuleFixerRector::class, - BooleanInBooleanNotRuleFixerRector::class, RemoveUnusedPrivateMethodRector::class, SimplifyUselessVariableRector::class, NullableCompareToNullRector::class, EncapsedStringsToSprintfRector::class, - StaticClosureRector::class, SplitDoubleAssignRector::class, ]); diff --git a/src/Exceptions/UnknownApplicationConfiguration.php b/src/Exceptions/UnknownApplicationConfiguration.php new file mode 100644 index 00000000..6f0cf899 --- /dev/null +++ b/src/Exceptions/UnknownApplicationConfiguration.php @@ -0,0 +1,9 @@ +> */ private array $model_classes = []; - public function __construct(Filesystem $files, private SchemaAggregator $schema) + private SchemaAggregator $schema; + + /** + * While the setter of a required property is an anti-pattern, + * this is the only way to be less independent of changes in the parent ModelsCommand constructor. + */ + public function setSchemaAggregator(SchemaAggregator $schemaAggregator): void { - parent::__construct($files); + $this->schema = $schemaAggregator; } /** @return list> */ @@ -109,7 +115,6 @@ public function getPropertiesFromTable($model): void } if ($column->nullable) { - /** @psalm-suppress MixedArrayAssignment */ $this->nullableColumns[$column_name] = true; } diff --git a/src/Handlers/Eloquent/ModelFactoryTypeProvider.php b/src/Handlers/Eloquent/ModelFactoryTypeProvider.php new file mode 100644 index 00000000..a028d9e0 --- /dev/null +++ b/src/Handlers/Eloquent/ModelFactoryTypeProvider.php @@ -0,0 +1,61 @@ +> */ + #[\Override] + public static function getClassLikeNames(): array + { + return ModelStubProvider::getModelClasses(); + } + + #[\Override] + public static function getPropertyType(PropertyTypeProviderEvent $event): ?Type\Union + { + if ($event->getPropertyName() !== 'factory') { + return null; + } + + $source = $event->getSource(); + if ($source === null) { + return null; + } + + $classlike = $source->getCodebase()->classlike_storage_provider->get($event->getFqClasslikeName()); + + $usesHasFactory = isset($classlike->used_traits[strtolower(HasFactory::class)]); + if (! $usesHasFactory) { + return null; + } + + $hasFactoryProperty = isset($classlike->properties['factory']); + // Check for static $factory property + if ($hasFactoryProperty && isset($classlike->properties['factory']->type)) { + $factoryType = $classlike->properties['factory']->type; + foreach ($factoryType->getAtomicTypes() as $type) { + if ($type instanceof Type\Atomic\TNamedObject) { + return new Type\Union([new Type\Atomic\TNamedObject($type->value)]); + } + } + } + + // Default to Factory + return new Type\Union([ + new Type\Atomic\TGenericObject('Illuminate\Database\Eloquent\Factories\Factory', [ + new Type\Union([new Type\Atomic\TNamedObject($event->getFqClasslikeName())]) + ]) + ]); + } +} diff --git a/src/Handlers/Eloquent/ModelRelationshipPropertyHandler.php b/src/Handlers/Eloquent/ModelRelationshipPropertyHandler.php index 594306b4..503a7ba9 100644 --- a/src/Handlers/Eloquent/ModelRelationshipPropertyHandler.php +++ b/src/Handlers/Eloquent/ModelRelationshipPropertyHandler.php @@ -4,11 +4,11 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\BelongsToMany; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\Relations\MorphToMany; -use PhpParser; use Psalm\Codebase; use Psalm\LaravelPlugin\Providers\ModelStubProvider; use Psalm\Plugin\EventHandler\Event\PropertyExistenceProviderEvent; @@ -23,6 +23,7 @@ use Psalm\Type\Union; use function in_array; +use function is_a; class ModelRelationshipPropertyHandler implements PropertyExistenceProviderInterface, @@ -154,7 +155,25 @@ public static function getPropertyType(PropertyTypeProviderEvent $event): ?Union private static function relationExists(Codebase $codebase, string $fq_classlike_name, string $property_name): bool { - // @todo: ensure this is a relation method - return $codebase->methodExists($fq_classlike_name . '::' . $property_name); + $method = $fq_classlike_name . '::' . $property_name; + + if (!$codebase->methodExists($method)) { + return false; + } + + // ensure this is a relation method + + $return_type = $codebase->getMethodReturnType($method, $fq_classlike_name); + if (!$return_type) { + return false; + } + + foreach ($return_type->getAtomicTypes() as $type) { + if ($type instanceof TGenericObject && is_a($type->value, Relation::class, true)) { + return true; + } + } + + return false; } } diff --git a/src/Handlers/Eloquent/Schema/SchemaAggregator.php b/src/Handlers/Eloquent/Schema/SchemaAggregator.php index bd8fa246..727dbcdf 100644 --- a/src/Handlers/Eloquent/Schema/SchemaAggregator.php +++ b/src/Handlers/Eloquent/Schema/SchemaAggregator.php @@ -57,7 +57,6 @@ public function addStatements(array $stmts): void { $nodeFinder = new NodeFinder(); - /** @var PhpParser\Node\Stmt\Class_[] $classes */ $classes = $nodeFinder->findInstanceOf($stmts, PhpParser\Node\Stmt\Class_::class); foreach ($classes as $stmt) { @@ -236,7 +235,7 @@ private function processColumnUpdates(string $table_name, string $call_arg_name, * * Process this ->nullable(false) */ - if (count($root_var->args) > 0) { + if ($root_var->args !== []) { $first_argument_of_nullable = $root_var->args[0]; if ( $first_argument_of_nullable instanceof PhpParser\Node\Arg diff --git a/src/Plugin.php b/src/Plugin.php index 2da1512c..2cdd6764 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -10,6 +10,7 @@ use Psalm\LaravelPlugin\Handlers\Auth\RequestHandler; use Psalm\LaravelPlugin\Handlers\Eloquent\ModelMethodHandler; use Psalm\LaravelPlugin\Handlers\Eloquent\ModelPropertyAccessorHandler; +use Psalm\LaravelPlugin\Handlers\Eloquent\ModelFactoryTypeProvider; use Psalm\LaravelPlugin\Handlers\Eloquent\ModelRelationshipPropertyHandler; use Psalm\LaravelPlugin\Handlers\Eloquent\RelationsMethodHandler; use Psalm\LaravelPlugin\Handlers\Helpers\CacheHandler; @@ -43,7 +44,12 @@ public function __invoke(RegistrationInterface $registration, ?SimpleXMLElement ApplicationProvider::bootApp(); $this->generateStubFiles(); } catch (\Throwable $throwable) { - fwrite(\STDERR, "Laravel plugin error: “{$throwable->getMessage()}”\n"); + $failOnInternalError = ((string) $config?->failOnInternalError) === 'true'; + if ($failOnInternalError) { + throw $throwable; + } + + fwrite(\STDERR, "\nLaravel plugin error on generating stub files: \"{$throwable->getMessage()}\"\n"); return; } @@ -73,9 +79,9 @@ protected function getCommonStubs(): array /** @return list */ protected function getTaintAnalysisStubs(): array { - return array_merge( - glob(dirname(__DIR__) . '/stubs/TaintAnalysis/Http/*.stubphp'), - ); + return [ + ...glob(dirname(__DIR__) . '/stubs/common/TaintAnalysis/Http/*.stubphp') ?: [] + ]; } /** @return list */ @@ -83,10 +89,10 @@ protected function getStubsForVersion(string $version): array { [$majorVersion] = explode('.', $version); - return array_merge( - glob(dirname(__DIR__) . '/stubs/' . $majorVersion . '/*.stubphp'), - glob(dirname(__DIR__) . '/stubs/' . $majorVersion . '/**/*.stubphp'), - ); + return [ + ...glob(dirname(__DIR__) . '/stubs/' . $majorVersion . '/*.stubphp') ?: [], + ...glob(dirname(__DIR__) . '/stubs/' . $majorVersion . '/**/*.stubphp') ?: [], + ]; } private function registerStubs(RegistrationInterface $registration): void @@ -121,6 +127,8 @@ private function registerHandlers(RegistrationInterface $registration): void require_once 'Handlers/Eloquent/ModelRelationshipPropertyHandler.php'; $registration->registerHooksFromClass(ModelRelationshipPropertyHandler::class); + require_once 'Handlers/Eloquent/ModelFactoryTypeProvider.php'; + $registration->registerHooksFromClass(ModelFactoryTypeProvider::class); require_once 'Handlers/Eloquent/ModelPropertyAccessorHandler.php'; $registration->registerHooksFromClass(ModelPropertyAccessorHandler::class); require_once 'Handlers/Eloquent/RelationsMethodHandler.php'; diff --git a/src/Providers/ApplicationProvider.php b/src/Providers/ApplicationProvider.php index dacb4f15..5b6d0435 100644 --- a/src/Providers/ApplicationProvider.php +++ b/src/Providers/ApplicationProvider.php @@ -7,6 +7,10 @@ use Barryvdh\LaravelIdeHelper\IdeHelperServiceProvider; use Illuminate\Container\Container; use Illuminate\Contracts\Console\Kernel; +use Illuminate\View\Factory; +use Illuminate\View\FileViewFinder; +use Illuminate\View\Engines\EngineResolver; +use Illuminate\View\Engines\PhpEngine; use Illuminate\Foundation\Application as LaravelApplication; use Orchestra\Testbench\Concerns\CreatesApplication; @@ -14,7 +18,6 @@ use function defined; use function dirname; use function file_exists; -use function get_class; use function getcwd; use function microtime; @@ -26,16 +29,11 @@ final class ApplicationProvider public static function bootApp(): void { - $app = self::getApp(); - - /** @var \Illuminate\Contracts\Console\Kernel $consoleApp */ - $consoleApp = $app->make(Kernel::class); - // @todo do not bootstrap \Illuminate\Foundation\Bootstrap\HandleExceptions - $consoleApp->bootstrap(); - - $app->register(IdeHelperServiceProvider::class); + self::getApp(); } + private static bool $booted = false; + public static function getApp(): LaravelApplication { if (self::$app instanceof Container) { @@ -46,14 +44,14 @@ public static function getApp(): LaravelApplication define('LARAVEL_START', microtime(true)); } - if (file_exists($applicationPath = getcwd() . '/bootstrap/app.php')) { // Applications and Local Dev + if (file_exists($applicationPath = (getcwd() ?: '.') . '/bootstrap/app.php')) { // Applications and Local Dev /** @psalm-suppress MixedAssignment */ $app = require $applicationPath; } elseif (file_exists($applicationPath = dirname(__DIR__, 5) . '/bootstrap/app.php')) { // plugin installed to vendor /** @psalm-suppress MixedAssignment */ $app = require $applicationPath; } else { // Laravel Packages - $app = (new self())->createApplication(); // Orchestra\Testbench + $app = (new self())->createApplication(); // Orchestra\Testbench (e.g., test:type command) } if (! $app instanceof LaravelApplication) { @@ -62,6 +60,31 @@ public static function getApp(): LaravelApplication self::$app = $app; + // Initialize view system first + if (!$app->bound('view')) { + $filesystem = new \Illuminate\Filesystem\Filesystem(); + $viewFinder = new FileViewFinder($filesystem, []); + $engineResolver = new EngineResolver(); + $engineResolver->register('php', fn() => new PhpEngine($filesystem)); + $app->singleton('view', fn() => new Factory($engineResolver, $viewFinder, $app['events'])); + } + + if (!self::$booted) { + // Bootstrap console app + $consoleApp = $app->make(Kernel::class); + $app->bind('Illuminate\Foundation\Bootstrap\HandleExceptions', function () { + return new class { + public function bootstrap(): void + { + } + }; + }); + $consoleApp->bootstrap(); + + $app->register(IdeHelperServiceProvider::class); + self::$booted = true; + } + return $app; } @@ -82,24 +105,18 @@ protected function resolveApplicationBootstrappers(LaravelApplication $app): voi // we want to keep the default psalm exception handler, otherwise the Laravel one will always return exit codes // of 0 //$app->make('Illuminate\Foundation\Bootstrap\HandleExceptions')->bootstrap($app); - /** @psalm-suppress MixedMethodCall */ $app->make(\Illuminate\Foundation\Bootstrap\RegisterFacades::class)->bootstrap($app); - /** @psalm-suppress MixedMethodCall */ $app->make(\Illuminate\Foundation\Bootstrap\SetRequestForConsole::class)->bootstrap($app); - /** @psalm-suppress MixedMethodCall */ $app->make(\Illuminate\Foundation\Bootstrap\RegisterProviders::class)->bootstrap($app); $this->getEnvironmentSetUp($app); - /** @psalm-suppress MixedMethodCall */ $app->make(\Illuminate\Foundation\Bootstrap\BootProviders::class)->bootstrap($app); foreach ($this->getPackageBootstrappers($app) as $bootstrap) { - /** @psalm-suppress MixedMethodCall */ $app->make($bootstrap)->bootstrap($app); } - /** @psalm-suppress MixedMethodCall */ $app->make(\Illuminate\Contracts\Console\Kernel::class)->bootstrap(); /** @var \Illuminate\Routing\Router $router */ @@ -127,5 +144,23 @@ protected function getEnvironmentSetUp(LaravelApplication $app): void $config->set('ide-helper.model_locations', [ '../../../../tests/Application/app/Models', ]); + + // Set up view paths for ide-helper + $viewPath = dirname((new \ReflectionClass(IdeHelperServiceProvider::class))->getFileName(), 2) . '/resources/views'; + + if (!$app->bound('view')) { + $filesystem = new \Illuminate\Filesystem\Filesystem(); + + // Set up the view finder + $viewFinder = new FileViewFinder($filesystem, [$viewPath]); + + // Set up the engine resolver + $engineResolver = new EngineResolver(); + $engineResolver->register('php', fn() => new PhpEngine($filesystem)); + + // Create and bind the view factory + $app->singleton('view', fn() => new Factory($engineResolver, $viewFinder, $app['events'])); + } + $app['view']->addNamespace('ide-helper', $viewPath); } } diff --git a/src/Providers/ConfigRepositoryProvider.php b/src/Providers/ConfigRepositoryProvider.php index b3939401..b7afe9a4 100644 --- a/src/Providers/ConfigRepositoryProvider.php +++ b/src/Providers/ConfigRepositoryProvider.php @@ -8,10 +8,8 @@ final class ConfigRepositoryProvider { - /** @psalm-suppress MixedInferredReturnType */ public static function get(): Repository { - /** @psalm-suppress MixedReturnStatement */ return ApplicationProvider::getApp()->get(Repository::class); } } diff --git a/src/Providers/FacadeStubProvider.php b/src/Providers/FacadeStubProvider.php index d6773fa7..e40d946e 100644 --- a/src/Providers/FacadeStubProvider.php +++ b/src/Providers/FacadeStubProvider.php @@ -31,10 +31,15 @@ public static function generateStubFile(): void $fake_filesystem = new FakeFilesystem(); + /** + * @var \Illuminate\View\Factory $viewFactory + */ + $viewFactory = $app->make('view'); + $stubs_generator_command = new GeneratorCommand( $config, $fake_filesystem, - ViewFactoryProvider::get(), + $viewFactory ); $stubs_generator_command->setLaravel($app); diff --git a/src/Providers/ModelStubProvider.php b/src/Providers/ModelStubProvider.php index 326dbc19..c0ecebf7 100644 --- a/src/Providers/ModelStubProvider.php +++ b/src/Providers/ModelStubProvider.php @@ -3,6 +3,7 @@ namespace Psalm\LaravelPlugin\Providers; use Psalm\Internal\Analyzer\ProjectAnalyzer; +use Psalm\LaravelPlugin\Exceptions\UnknownApplicationConfiguration; use Psalm\LaravelPlugin\Fakes\FakeFilesystem; use Psalm\LaravelPlugin\Fakes\FakeModelsCommand; use Psalm\LaravelPlugin\Handlers\Eloquent\Schema\SchemaAggregator; @@ -12,6 +13,7 @@ use function glob; use function method_exists; use function unlink; +use function is_array; final class ModelStubProvider implements GeneratesStubs { @@ -33,7 +35,12 @@ public static function generateStubFile(): void $schema_aggregator = new SchemaAggregator(); - foreach (glob($migrations_directory . '*.php') as $file) { + $migrationFilePathnames = glob($migrations_directory . '*.php'); + if (! is_array($migrationFilePathnames)) { + throw new UnknownApplicationConfiguration("No migration files found in {$migrations_directory} directory."); + } + + foreach ($migrationFilePathnames as $file) { $schema_aggregator->addStatements($codebase->getStatementsForFile($file)); } @@ -41,9 +48,10 @@ public static function generateStubFile(): void $models_generator_command = new FakeModelsCommand( $fake_filesystem, - $schema_aggregator + $app->make(\Illuminate\Contracts\Config\Repository::class), + $app->make(\Illuminate\View\Factory::class) ); - + $models_generator_command->setSchemaAggregator($schema_aggregator); $models_generator_command->setLaravel($app); @unlink(self::getStubFileLocation()); diff --git a/src/Providers/ViewFactoryProvider.php b/src/Providers/ViewFactoryProvider.php deleted file mode 100644 index 8b3d1f60..00000000 --- a/src/Providers/ViewFactoryProvider.php +++ /dev/null @@ -1,39 +0,0 @@ -getFileName(); - - if ($file_path === false) { - throw new UnexpectedValueException('Service helper should have a file path'); - } - - $resolver = new EngineResolver(); - $fake_filesystem = new FakeFilesystem(); - $resolver->register('php', function () use ($fake_filesystem): PhpEngine { - return new PhpEngine($fake_filesystem); - }); - $finder = new FileViewFinder($fake_filesystem, [dirname($file_path) . '/../resources/views']); - $factory = new Factory($resolver, $finder, new Dispatcher()); - $factory->addExtension('php', 'php'); - return $factory; - } -} diff --git a/stubs/common/Database/Migrations/Migrator.stubphp b/stubs/common/Database/Migrations/Migrator.stubphp index a1580a7b..28da5fee 100644 --- a/stubs/common/Database/Migrations/Migrator.stubphp +++ b/stubs/common/Database/Migrations/Migrator.stubphp @@ -7,10 +7,10 @@ class Migrator /** * Execute the given callback using the given connection as the default connection. * - * @template TCalbackReturn + * @template TCallbackReturn * @param string $name - * @param callable(): TCalbackReturn $callback - * @return TCalbackReturn + * @param callable(): TCallbackReturn $callback + * @return TCallbackReturn */ public function usingConnection($name, callable $callback) {} } diff --git a/tests/Application/README.md b/tests/Application/README.md index da013e48..8573148c 100644 --- a/tests/Application/README.md +++ b/tests/Application/README.md @@ -4,6 +4,6 @@ Idea of application test: create an [almost] empty Laravel app and run Psalm ove ## FAQ - - Q1: How to update baselines - - A1: Inside .sh files change `--use-baseline` to `--set-baseline`, run sh files and revert changes in .sh files. + - Q1: How to update the baseline file for the Laravel Application test? + - A1: Run `laravel-test.sh` script with the `-u` (or `--update`) flag, e.g., `./laravel-test.sh -u`. ____ diff --git a/tests/Application/laravel-test-baseline.xml b/tests/Application/laravel-test-baseline.xml index 355f442c..24a684d8 100644 --- a/tests/Application/laravel-test-baseline.xml +++ b/tests/Application/laravel-test-baseline.xml @@ -1,136 +1,171 @@ - + - array|bool + - ExampleChannel + - CastsAttributes + - - $attributes - $attributes - - ExampleCast + - handle + + + + + + + + + + + - ExampleCommand + + + + + + - ExampleException + - - - $e - - - ExampleController + - ExampleMiddleware + - - - $except - - - - ]]> - + + + + + + + + + + + - ExampleRequest + ]]> - parent::toArray($request) + - ExampleResource + + + + + + - ExampleListener + - attachments - content - envelope + + + + + + + + + + + + + + + + + + + - ExampleScope + - $casts - $fillable - $hidden + + + + + + + + + + + + + - ExampleObserver + - ExamplePolicy + - - - $listen - + + + + - ExampleProvider + - - - user()?->id]]> - - - ExampleRule + - ExampleComponent - ExampleComponent + + - ExampleComponent + diff --git a/tests/Application/laravel-test-psalm.xml b/tests/Application/laravel-test-psalm.xml index 9c03cfce..d9fd584c 100644 --- a/tests/Application/laravel-test-psalm.xml +++ b/tests/Application/laravel-test-psalm.xml @@ -5,9 +5,9 @@ findUnusedBaselineEntry="false" findUnusedCode="true" resolveFromConfigFile="false" - xsi:schemaLocation="https://getpsalm.org/schema/config vendor/vimeo/psalm/config.xsd" + xsi:schemaLocation="https://getpsalm.org/schema/config ../../vendor/vimeo/psalm/config.xsd" errorBaseline="../../tests/Application/laravel-test-baseline.xml" - phpVersion="8.0" + phpVersion="8.2" > @@ -16,11 +16,14 @@ - + + true + + diff --git a/tests/Application/laravel-test.sh b/tests/Application/laravel-test.sh index b7e51137..b1d805e3 100755 --- a/tests/Application/laravel-test.sh +++ b/tests/Application/laravel-test.sh @@ -1,19 +1,117 @@ -#!/bin/bash +#!/usr/bin/env bash +# Laravel Test Environment Setup Script +# This script sets up a fresh Laravel installation and runs Psalm analysis + +# Exit on error. Append "|| true" if you expect an error. set -e +# Exit on error in any nested commands +set -o pipefail +# Catch the error in case a variable is not set +set -u + +# See https://github.com/laravel/laravel/tags for Laravel versions +LARAVEL_INSTALLER_VERSION=11.6 + +# Terminal colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Default values +UPDATE_BASELINE=false +VERBOSE=false +REMOVE=false + +# Function to display script usage +show_help() { + cat << EOF +Usage: $(basename "$0") [options] + +Sets up a fresh Laravel installation and runs Psalm analysis. + +Options: + -h, --help Show this help message + -u, --update Update Psalm baseline + -v, --verbose Enable verbose output + -r, --remove Remove Laravel project directory after execution + +Environment variables: + COMPOSER_MEMORY_LIMIT Memory limit for Composer (default: -1) +EOF +} +# Function to display error messages +error() { + echo -e "${RED}Error: $1${NC}" >&2 + exit 1 +} + +# Function to display info messages +info() { + echo -e "${GREEN}$1${NC}" +} + +# Function to display debug messages +debug() { + if [ "$VERBOSE" = true ]; then + echo -e "${YELLOW}Debug: $1${NC}" + fi +} + +# Cleanup function +cleanup() { + if [ -d "$APP_INSTALLATION_PATH" ]; then + info "Removing the installation directory..." + rm -rf "$APP_INSTALLATION_PATH" + fi +} + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) + show_help + exit 0 + ;; + -u|--update) + UPDATE_BASELINE=true + shift + ;; + -r|--remove) + REMOVE=true + shift + ;; + -v|--verbose) + VERBOSE=true + shift + ;; + *) + error "Unknown option: $1\nUse --help for usage information." + ;; + esac +done + +if [ "$REMOVE" = true ]; then + # Set up trap to clean up on script exit + trap cleanup EXIT +fi + +# Get absolute path of script directory CURRENT_SCRIPT_PATH="$( cd "$(dirname "$0")" ; pwd -P )" APP_INSTALLATION_PATH="$(dirname "$(dirname "$CURRENT_SCRIPT_PATH")")/tests-app/laravel-example" -echo "Cleaning up previous installation" -rm -rf $APP_INSTALLATION_PATH +if [ -d "$APP_INSTALLATION_PATH" ]; then + info "Removing previous installation" + rm -rf "$APP_INSTALLATION_PATH" +fi -echo "Installing Laravel" -# See https://github.com/laravel/laravel/tags for Laravel versions -composer create-project --quiet --prefer-dist laravel/laravel $APP_INSTALLATION_PATH 10.0 --quiet --prefer-dist -cd $APP_INSTALLATION_PATH +info "Creating a new Laravel project (installer v${LARAVEL_INSTALLER_VERSION})" +composer create-project --quiet --prefer-dist laravel/laravel "$APP_INSTALLATION_PATH" $LARAVEL_INSTALLER_VERSION +cd "$APP_INSTALLATION_PATH" -echo "Preparing Laravel" +info "Making different types of classes for Laravel" ./artisan make:cast ExampleCast ./artisan make:channel ExampleChannel ./artisan make:component ExampleComponent @@ -37,12 +135,26 @@ echo "Preparing Laravel" ./artisan make:scope ExampleScope ./artisan make:seeder ExampleSeeder -echo "Adding package from source" +info "Adding package from source" composer config repositories.0 '{"type": "path", "url": "../../"}' composer config minimum-stability 'dev' COMPOSER_MEMORY_LIMIT=-1 composer require --dev "psalm/plugin-laravel:*" --update-with-all-dependencies -echo "Analyzing Laravel" -./vendor/bin/psalm -c ../../tests/Application/laravel-test-psalm.xml --use-baseline=../../tests/Application/laravel-test-baseline.xml +info "Analyzing Laravel" +PSALM_CONFIG="../../tests/Application/laravel-test-psalm.xml" +PSALM_BASELINE="../../tests/Application/laravel-test-baseline.xml" + +if [ "$UPDATE_BASELINE" = true ]; then + info "Updating Psalm baseline" + ./vendor/bin/psalm --config="$PSALM_CONFIG" --set-baseline="$PSALM_BASELINE" + info "Baseline file $PSALM_BASELINE is updated, please check the changes and commit them." +else + info "Running Psalm analysis" + ./vendor/bin/psalm --config="$PSALM_CONFIG" --use-baseline="$PSALM_BASELINE" +fi + +echo -echo -e "\nA sample Laravel application installed at the $APP_INSTALLATION_PATH directory, feel free to remove it." +if [ "$REMOVE" = false ]; then + info "A sample Laravel application installed at the $APP_INSTALLATION_PATH directory, feel free to remove it." +fi diff --git a/tests/README.md b/tests/README.md index 219055f9..6c9f85c7 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,6 +1,6 @@ # Tests There are 3 types of tests: -1. Type (main one): Use phpt files to run Psalm over code snippets in the context of fake Laravel app [using orchestra/testbench] -2. Application: create an [almost] empty Laravel app and run Psalm over its codebase. -3. Unit: use PHPUnit to test some internal logic without running Psalm. +1. **Type** (the main one): uses .phpt files to run Psalm over code snippets in the context of a fake Laravel app [using orchestra/testbench] +2. **Application**: creates an empty Laravel app, adds some typical classes of different types and run Psalm over its codebase. +3. **Unit**: uses PHPUnit to test some internal logic without running Psalm. diff --git a/tests/Type/PsalmTest.php b/tests/Type/PsalmTest.php index 5060bf8d..5f3d2652 100644 --- a/tests/Type/PsalmTest.php +++ b/tests/Type/PsalmTest.php @@ -14,8 +14,8 @@ final class PsalmTest extends TestCase { private ?PsalmTester $psalmTester = null; - /** @return iterable */ - public static function providePhptFiles(): iterable + /** @return \Generator */ + public static function providePhptFiles(): \Generator { $baseDir = __DIR__ . \DIRECTORY_SEPARATOR . 'tests' . \DIRECTORY_SEPARATOR; $testExtension = 'phpt'; @@ -24,14 +24,11 @@ public static function providePhptFiles(): iterable $itr = new \RecursiveIteratorIterator($dirItr); $regItr = new \RegexIterator($itr, "/^.+.{$testExtension}\$/", \RegexIterator::GET_MATCH); - $filePaths = []; - foreach ($regItr as $file) { $filepath = $file[0]; - $filePaths[str_replace($baseDir, '', $filepath)] = [$filepath]; + $relativeFilepath = str_replace($baseDir, '', $filepath); + yield $relativeFilepath => [$filepath]; } - - return $filePaths; } #[DataProvider('providePhptFiles')] diff --git a/tests/Type/psalm.xml b/tests/Type/psalm.xml index af276794..ca05f200 100644 --- a/tests/Type/psalm.xml +++ b/tests/Type/psalm.xml @@ -11,9 +11,11 @@ sealAllMethods="true" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="https://getpsalm.org/schema/config" - xsi:schemaLocation="https://getpsalm.org/schema/config ../vendor/vimeo/psalm/config.xsd" + xsi:schemaLocation="https://getpsalm.org/schema/config ../../vendor/vimeo/psalm/config.xsd" > - + + true + diff --git a/tests/Type/tests/ContainerTest.phpt b/tests/Type/tests/ContainerTest.phpt index 3556126d..6f252b9e 100644 --- a/tests/Type/tests/ContainerTest.phpt +++ b/tests/Type/tests/ContainerTest.phpt @@ -64,5 +64,4 @@ function cannotResolveUnknownDependency(): \Illuminate\Log\LogManager ?> --EXPECTF-- UndefinedMagicMethod on line %d: Magic method Illuminate\Foundation\Application::undefined_method does not exist -MixedInferredReturnType on line %d: Could not verify return type 'Illuminate\Log\LogManager' for cannotResolveUnknownDependency MixedReturnStatement on line %d: Could not infer a return type