diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..eea346a --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +* text +.* export-ignore +composer.lock text -diff +phpunit.* export-ignore +tests export-ignore +phpunit.xml export-ignore +pint.json export-ignore \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..b5f48ec --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,39 @@ +## Summary + + + +## Type of Change + +- [ ] ๐Ÿ› Bug fix (non-breaking change which fixes an issue) +- [ ] โœจ New feature (non-breaking change which adds functionality) +- [ ] ๐Ÿ’ฅ Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] ๐Ÿ“š Documentation update +- [ ] โšก Performance improvement +- [ ] โ™ป๏ธ Code refactoring +- [ ] ๐Ÿงช Test improvements +- [ ] ๐Ÿ”ง Chore (build process, dependencies, etc.) + +## Related Issues + + + + + +Closes # + +## Pre-Submission Checklist + + + +### Code Quality + +- [ ] **PHPStan analysis passes** +- [ ] **Coding standards pass** +- [ ] **All tests pass** +- [ ] **No debug code** (var_dump, error_log, etc.) left in production code +- [ ] **No commented-out code** unless specifically needed for reference +- [ ] **Documentation updated** for any new features or changed behavior +- [ ] **New tests** have been added for new functionality +- [ ] **All existing tests** continue to pass + +**๐Ÿ“– Read the full contributing guidelines: [CONTRIBUTING.md](/.github/CONTRIBUTING.md)** diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 0000000..977c1d9 --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,28 @@ +name: PHPStan + +on: + pull_request: + push: + branches: + - main + +jobs: + phpstan: + name: Static Analysis + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run PHPStan + run: composer test:phpstan diff --git a/.github/workflows/pint.yml b/.github/workflows/pint.yml new file mode 100644 index 0000000..f326c7b --- /dev/null +++ b/.github/workflows/pint.yml @@ -0,0 +1,31 @@ +name: fix code styling + +on: + pull_request: + push: + branches: + - main + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + tools: composer:v2, laravel/pint + extensions: json, dom, curl, libxml, mbstring + coverage: none + + - name: Run Pint + run: pint + + - name: Commit linted files + uses: stefanzweifel/git-auto-commit-action@v7 + with: + commit_message: 'chore: fix code styling' \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..1083160 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,38 @@ +name: Tests + +on: + pull_request: + push: + branches: + - main + +jobs: + tests: + name: PHP ${{ matrix.php }} + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: + - '7.4' + - '8.0' + - '8.1' + - '8.2' + - '8.3' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run tests + run: vendor/bin/phpunit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f51263 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +composer.lock +vendor/ +tests/_support/_generated + +# Dev tools +.buildpath +*.iml +.project +.idea/ +.vscode/ + +# Tests +.phpunit.cache + +.DS_Store +DOCKER_ENV +Dockerfile-* +output.log +docker_tag + +CLAUDE.md \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ffd982b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Change Log + +All notable changes to this project will be documented in this file. This project adhere to the [Semantic Versioning](http://semver.org/) standard. + +## [0.1.0] 2025-12-11 + +* Feature - Initial release of PHPStan Container Extensions. + +[0.1.0]: https://github.com/dpanta94/phpstan-containers/releases/tag/0.1.0 diff --git a/README.md b/README.md new file mode 100644 index 0000000..99b585a --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# PHPStan Container Extensions + +[![PHPStan](https://img.shields.io/badge/PHPStan-enabled-239922)](https://github.com/phpstan/phpstan) + +PHPStan extensions for dependency injection containers. Provides dynamic return type resolution so PHPStan understands that `$container->get(Foo::class)` returns `Foo`. + +## Supported Containers + +- [StellarWP Container Contract](https://github.com/stellarwp/container-contract) (`StellarWP\ContainerContract\ContainerInterface`) +- [PSR-11 Container](https://www.php-fig.org/psr/psr-11/) (`Psr\Container\ContainerInterface`) + +## Requirements + +- PHP 7.4 or higher +- PHPStan 2.0 or higher + +## Installation + +```bash +composer require --dev dpanta94/phpstan-containers +``` + +If you use [phpstan/extension-installer](https://github.com/phpstan/extension-installer), you're all set! + +### Manual Installation + +If you don't use the extension installer, add the extension to your `phpstan.neon`: + +```neon +includes: + - vendor/dpanta94/phpstan-containers/extension.neon +``` + +## Usage + +Once installed, PHPStan will automatically understand container return types: + +```php +use Psr\Container\ContainerInterface; + +class MyService { + public function __construct(private ContainerInterface $container) {} + + public function doSomething(): void { + // PHPStan knows $logger is an instance of Logger + $logger = $this->container->get(Logger::class); + $logger->info('Hello world'); + + // PHPStan knows $mailer is an instance of MailerInterface + $mailer = $this->container->get(MailerInterface::class); + $mailer->send($message); + } +} +``` + +The extension resolves types when: + +- The argument to `get()` is a class-string constant (e.g., `Foo::class`) +- The class or interface exists in the codebase + +When using string service IDs (e.g., `$container->get('mailer')`), the extension falls back to the default `mixed` return type. + +## License + +MIT + +## Credits + +This package is inspired by [Phil Nelson](https://github.com/phil-nelson)'s [phpstan-container-extension](https://packagist.org/packages/phil-nelson/phpstan-container-extension). diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..7778144 --- /dev/null +++ b/composer.json @@ -0,0 +1,53 @@ +{ + "name": "dpanta94/phpstan-containers", + "description": "PHPStan extensions for Containers", + "license": "MIT", + "type": "phpstan-extension", + "keywords": [ + "static analysis", + "code analysis", + "code analyze", + "container", + "dependency injection", + "phpstan", + "psr-11" + ], + "require": { + "php": "^7.4 || ^8.0", + "phpstan/phpstan": "^2.0" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.1", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.5 || ^10.0", + "psr/container": "^1.0 || ^2.0", + "stellarwp/container-contract": "^1.0" + }, + "minimum-stability": "dev", + "prefer-stable": true, + "autoload": { + "psr-4": { + "DPanta\\PHPStan\\Containers\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "DPanta\\PHPStan\\Containers\\Tests\\": "tests/", + "DPanta\\PHPStan\\Containers\\Tests\\Data\\": "tests/data/" + } + }, + "config": { + "sort-packages": true + }, + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + } + }, + "scripts": { + "test": "phpunit", + "test:phpstan": "phpstan analyze" + } +} diff --git a/extension.neon b/extension.neon new file mode 100644 index 0000000..fc0a394 --- /dev/null +++ b/extension.neon @@ -0,0 +1,9 @@ +services: + - + class: DPanta\PHPStan\Containers\StellarWpContainerDynamicReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension + - + class: DPanta\PHPStan\Containers\PsrContainerDynamicReturnTypeExtension + tags: + - phpstan.broker.dynamicMethodReturnTypeExtension diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..176a929 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,7 @@ +includes: + - vendor/phpstan/phpstan-strict-rules/rules.neon + +parameters: + level: max + paths: + - src/ diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..888af67 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,22 @@ + + + + + tests + + + + + + src + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..f255aa6 --- /dev/null +++ b/pint.json @@ -0,0 +1,7 @@ +{ + "preset": "psr12", + "exclude": [ + "vendor", + "tests" + ] +} \ No newline at end of file diff --git a/src/AbstractContainerDynamicReturnTypeExtension.php b/src/AbstractContainerDynamicReturnTypeExtension.php new file mode 100644 index 0000000..9d49129 --- /dev/null +++ b/src/AbstractContainerDynamicReturnTypeExtension.php @@ -0,0 +1,84 @@ +getName() === 'get'; + } + + /** + * Get the return type for the method call. + * + * If the first argument is a class-string constant (e.g., Foo::class), + * this returns an ObjectType for that class. Otherwise, returns null + * to fall back to PHPStan's default behavior. + * + * @param MethodReflection $methodReflection The method reflection. + * @param MethodCall $methodCall The method call node. + * @param Scope $scope The analysis scope. + * + * @return Type + */ + public function getTypeFromMethodCall( + MethodReflection $methodReflection, + MethodCall $methodCall, + Scope $scope + ): Type { + $args = $methodCall->getArgs(); + if (count($args) !== 1) { + return ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(); + } + + $argType = $scope->getType($args[0]->value); + if (count($argType->getConstantStrings()) === 0) { + return ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(); + } + + if ($argType->isClassString()->no()) { + return ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(); + } + + if (!method_exists($argType, 'getValue')) { + return ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(); + } + + $value = $argType->getValue(); + if (!is_string($value)) { + return ParametersAcceptorSelector::selectFromArgs($scope, $methodCall->getArgs(), $methodReflection->getVariants())->getReturnType(); + } + + return new ObjectType($value); + } +} diff --git a/src/PsrContainerDynamicReturnTypeExtension.php b/src/PsrContainerDynamicReturnTypeExtension.php new file mode 100644 index 0000000..e898bee --- /dev/null +++ b/src/PsrContainerDynamicReturnTypeExtension.php @@ -0,0 +1,29 @@ + + */ + public static function dataFileAsserts(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/data/psr-container-types.php'); + } + + /** + * @dataProvider dataFileAsserts + * @param mixed ...$args + */ + public function testFileAsserts( + string $assertType, + string $file, + ...$args + ): void { + $this->assertFileAsserts($assertType, $file, ...$args); + } + + /** + * @return string[] + */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-test.neon']; + } +} diff --git a/tests/StellarWpContainerDynamicReturnTypeExtensionTest.php b/tests/StellarWpContainerDynamicReturnTypeExtensionTest.php new file mode 100644 index 0000000..accc0d3 --- /dev/null +++ b/tests/StellarWpContainerDynamicReturnTypeExtensionTest.php @@ -0,0 +1,38 @@ + + */ + public static function dataFileAsserts(): iterable + { + yield from self::gatherAssertTypes(__DIR__ . '/data/stellarwp-container-types.php'); + } + + /** + * @dataProvider dataFileAsserts + * @param mixed ...$args + */ + public function testFileAsserts( + string $assertType, + string $file, + ...$args + ): void { + $this->assertFileAsserts($assertType, $file, ...$args); + } + + /** + * @return string[] + */ + public static function getAdditionalConfigFiles(): array + { + return [__DIR__ . '/phpstan-test.neon']; + } +} diff --git a/tests/data/psr-container-types.php b/tests/data/psr-container-types.php new file mode 100644 index 0000000..70784b8 --- /dev/null +++ b/tests/data/psr-container-types.php @@ -0,0 +1,56 @@ +get(\stdClass::class); + assertType('stdClass', $service); +} + +/** + * @param ContainerInterface $container + */ +function testPsrContainerGetWithDateTimeInterface(ContainerInterface $container): void +{ + $service = $container->get(\DateTimeInterface::class); + assertType('DateTimeInterface', $service); +} + +/** + * @param ContainerInterface $container + */ +function testPsrContainerGetWithDateTime(ContainerInterface $container): void +{ + $service = $container->get(\DateTime::class); + assertType('DateTime', $service); +} + +/** + * @param ContainerInterface $container + */ +function testPsrContainerGetWithStringId(ContainerInterface $container): void +{ + // When using a non-class string, the extension should return null and fall back to default behavior + $service = $container->get('some.service.id'); + assertType('mixed', $service); +} + +/** + * @param ContainerInterface $container + * @param string $dynamicId + */ +function testPsrContainerGetWithDynamicId(ContainerInterface $container, string $dynamicId): void +{ + // When using a dynamic string, the extension cannot determine the type + $service = $container->get($dynamicId); + assertType('mixed', $service); +} diff --git a/tests/data/stellarwp-container-types.php b/tests/data/stellarwp-container-types.php new file mode 100644 index 0000000..232fbe1 --- /dev/null +++ b/tests/data/stellarwp-container-types.php @@ -0,0 +1,56 @@ +get(\stdClass::class); + assertType('stdClass', $service); +} + +/** + * @param ContainerInterface $container + */ +function testStellarWpContainerGetWithDateTimeInterface(ContainerInterface $container): void +{ + $service = $container->get(\DateTimeInterface::class); + assertType('DateTimeInterface', $service); +} + +/** + * @param ContainerInterface $container + */ +function testStellarWpContainerGetWithDateTime(ContainerInterface $container): void +{ + $service = $container->get(\DateTime::class); + assertType('DateTime', $service); +} + +/** + * @param ContainerInterface $container + */ +function testStellarWpContainerGetWithStringId(ContainerInterface $container): void +{ + // When using a non-class string, the extension should return null and fall back to default behavior + $service = $container->get('some.service.id'); + assertType('mixed', $service); +} + +/** + * @param ContainerInterface $container + * @param string $dynamicId + */ +function testStellarWpContainerGetWithDynamicId(ContainerInterface $container, string $dynamicId): void +{ + // When using a dynamic string, the extension cannot determine the type + $service = $container->get($dynamicId); + assertType('mixed', $service); +} diff --git a/tests/phpstan-test.neon b/tests/phpstan-test.neon new file mode 100644 index 0000000..02cbca4 --- /dev/null +++ b/tests/phpstan-test.neon @@ -0,0 +1,6 @@ +includes: + - ../extension.neon + +parameters: + scanDirectories: + - %currentWorkingDirectory%/tests/data/