diff --git a/src/Testing/Actions/ComposerAutoloadAbsoluteAction.php b/src/Testing/Actions/ComposerAutoloadAbsoluteAction.php new file mode 100644 index 00000000..d2b7914b --- /dev/null +++ b/src/Testing/Actions/ComposerAutoloadAbsoluteAction.php @@ -0,0 +1,69 @@ + + */ + private array $dirs; + + public function __construct( + private readonly ComposerJsonDataService $getComposerJsonDataAction, + private readonly Filesystem $filesystem, + GetBasePathForStubsActionContract $getBasePathForStubsAction, + ) { + $this->dirs = $this->makeDirs($getBasePathForStubsAction->execute()); + } + + /** + * @return array + */ + public function execute(?string $path = null): array + { + if ($path !== null) { + $this->dirs += $this->loadNewComposer($path); + } + + return $this->dirs; + } + + /** + * @return array + */ + private function makeDirs(string $basePath): array + { + $data = $this->getComposerJsonDataAction->data($basePath); + $dirs = []; + + if (isset($data['autoload']['psr-4']) && is_array($data['autoload']['psr-4'])) { + foreach ($data['autoload']['psr-4'] as $ns => $path) { + $dirs[$ns] = $basePath . DIRECTORY_SEPARATOR . trim((string) $path, '\\/'); + } + } + + return $dirs; + } + + private function loadNewComposer(string $path): array + { + if ($this->filesystem->isFile($path)) { + $path = $this->filesystem->dirname($path); + } elseif ($this->filesystem->isDirectory($path) === false) { + throw new \Exception(sprintf('The path is not dir "%s".', $path)); + } + + while ($this->getComposerJsonDataAction->isExist($path) === false) { + $path = $this->filesystem->dirname($path); + } + + return $this->makeDirs($path); + } +} diff --git a/src/Testing/Actions/GetDevNamespaceForStubsAction.php b/src/Testing/Actions/GetDevNamespaceForStubsAction.php index fbeba36c..a77df073 100644 --- a/src/Testing/Actions/GetDevNamespaceForStubsAction.php +++ b/src/Testing/Actions/GetDevNamespaceForStubsAction.php @@ -11,7 +11,7 @@ class GetDevNamespaceForStubsAction implements GetNamespaceForStubsActionContract { - public function execute(Command $command, string $basePath, string $inputClass): NamespaceEntity + public function execute(Command $command, string $inputClass): NamespaceEntity { // We want to place Laravel assert / expectations to Laravel Folder. diff --git a/src/Testing/Actions/GetNamespaceForStubsAction.php b/src/Testing/Actions/GetNamespaceForStubsAction.php index 5e64d34e..96d99678 100644 --- a/src/Testing/Actions/GetNamespaceForStubsAction.php +++ b/src/Testing/Actions/GetNamespaceForStubsAction.php @@ -5,10 +5,10 @@ namespace LaraStrict\Testing\Actions; use Illuminate\Console\Command; -use Illuminate\Filesystem\Filesystem; use LaraStrict\Testing\Constants\StubConstants; use LaraStrict\Testing\Contracts\GetNamespaceForStubsActionContract; use LaraStrict\Testing\Entities\NamespaceEntity; +use LaraStrict\Testing\Services\ComposerJsonDataService; use LogicException; class GetNamespaceForStubsAction implements GetNamespaceForStubsActionContract @@ -17,14 +17,14 @@ class GetNamespaceForStubsAction implements GetNamespaceForStubsActionContract final public const ComposerPsr4 = 'psr-4'; public function __construct( - private readonly Filesystem $filesystem, + private readonly ComposerJsonDataService $getComposerJsonDataAction, ) { } - public function execute(Command $command, string $basePath, string $inputClass): NamespaceEntity + public function execute(Command $command, string $inputClass): NamespaceEntity { // Ask for which namespace which to use for "tests" - $composer = $this->getComposerJsonData($basePath); + $composer = $this->getComposerJsonDataAction->data(); $autoLoad = $this->getComposerDevAutoLoad($composer); if ($autoLoad !== []) { if (count($autoLoad) === 1) { @@ -47,11 +47,6 @@ public function execute(Command $command, string $basePath, string $inputClass): return new NamespaceEntity($folder, $baseNamespace); } - protected function getComposerJsonData(string $basePath): mixed - { - return json_decode($this->filesystem->get($basePath . '/composer.json'), true, 512, JSON_THROW_ON_ERROR); - } - private function getComposerDevAutoLoad(array $composer): array { if (isset($composer[self::ComposerAutoLoadDev]) diff --git a/src/Testing/Actions/PathToClassAction.php b/src/Testing/Actions/PathToClassAction.php new file mode 100644 index 00000000..34339ce9 --- /dev/null +++ b/src/Testing/Actions/PathToClassAction.php @@ -0,0 +1,54 @@ +composerAutoloadAbsoluteAction->execute(); + + $class = $this->replacePathToClass($dirs, $path); + if ($class === null) { + $dirs = $this->composerAutoloadAbsoluteAction->execute($path); + $class = $this->replacePathToClass($dirs, $path); + + if ($class === null) { + throw new Exception(sprintf('Path "%s" not found in composer psr-4.', $path)); + } + } + + return $class; + } + + private function replacePathToClass(array $dirs, string $path): ?string + { + foreach ($dirs as $ns => $dir) { + if (str_starts_with($path, $dir) === false) { + continue; + } + + $class = preg_replace_callback( + sprintf('~^%s[/\\\](?.*)\.php$~', preg_quote($dir, '~')), + static fn (array $matches) => $ns . strtr($matches['path'], [ + '/' => '\\', + ]), + $path + ); + assert(is_string($class)); + + return $class; + } + + return null; + } +} diff --git a/src/Testing/Attributes/TestAssert.php b/src/Testing/Attributes/TestAssert.php new file mode 100644 index 00000000..8cc112b6 --- /dev/null +++ b/src/Testing/Attributes/TestAssert.php @@ -0,0 +1,12 @@ +execute(); - $inputClass = $this->getInputClass($basePath, $filesystem); + /** @phpstan-var class-string|string $class */ + $class = (string) $this->input->getArgument('class'); + + if ($class === 'all') { + $inputClasses = $this->findAllClasses($finderFactory->create(), $pathToClassAction); + } else { + $inClass = $this->normalizeToClass($class, $basePath, $filesystem, $pathToClassAction); + $inputClasses = $inClass === null ? [] : [$inClass]; + } - if ($inputClass === null) { + if ($inputClasses === []) { return 1; } + foreach ($inputClasses as $inputClass) { + $this->generateExpectationFiles( + $inputClass, + $getFolderAndNamespaceForStubsAction, + $basePath, + $filesystem, + $parsePhpDocAction + ); + } + + return 0; + } + + /** + * @param class-string $inputClass + */ + public function generateExpectationFiles( + string $inputClass, + GetNamespaceForStubsActionContract $getFolderAndNamespaceForStubsAction, + string $basePath, + Filesystem $filesystem, + ParsePhpDocAction $parsePhpDocAction + ): void { $class = new ReflectionClass($inputClass); $methods = $class->getMethods(ReflectionMethod::IS_PUBLIC); @@ -76,7 +112,7 @@ public function handle( } // Ask for which namespace which to use for "tests" - $namespace = $getFolderAndNamespaceForStubsAction->execute($this, $basePath, $inputClass); + $namespace = $getFolderAndNamespaceForStubsAction->execute($this, $inputClass); // 1. The first part of namespace should is in 99% App => app. We need to create a valid // namespace in tests folder, lets remove the first namespace and rebuild the correct @@ -175,8 +211,6 @@ className: $assertClassName, fileContents: $printer->printFile($assertFileState->file) ); } - - return 0; } /** @@ -479,11 +513,25 @@ protected function canReturnExpectation(ReflectionNamedType $returnType): bool /** * @return class-string|null */ - private function getInputClass(string $basePath, Filesystem $filesystem): ?string + private function checkInterface(string $class): ?string { - /** @phpstan-var class-string|string $class */ - $class = (string) $this->input->getArgument('class'); + if (class_exists($class) === false && interface_exists($class) === false) { + $this->writeError(sprintf('Provided class does not exists [%s]', $class)); + return null; + } + + return $class; + } + /** + * @return class-string|null + */ + private function normalizeToClass( + string $class, + string $basePath, + Filesystem $filesystem, + PathToClassAction $pathToClassAction + ): ?string { if (str_ends_with($class, '.php')) { $fullPath = $basePath . '/' . $class; @@ -492,23 +540,34 @@ private function getInputClass(string $basePath, Filesystem $filesystem): ?strin return null; } - $file = PhpFile::fromCode($filesystem->get($fullPath)); + return $pathToClassAction->execute($fullPath); + } - /** @phpstan-var array $classes */ - $classes = $file->getClasses(); - if ($classes === []) { - $this->writeError(sprintf('Provided file does not contain any class [%s]', $class)); - return null; - } + return $this->checkInterface($class); + } - $class = array_keys($classes)[0]; - } + /** + * @return array + */ + private function findAllClasses(Finder $finder, PathToClassAction $pathToClassAction): array + { + $classes = []; + foreach ($finder as $file) { + $interface = $pathToClassAction->execute($file->getRealPath()); + require_once $file->getPathname(); - if (class_exists($class) === false && interface_exists($class) === false) { - $this->writeError(sprintf('Provided class does not exists [%s]', $class)); - return null; + if (interface_exists($interface, false) === false) { + continue; + } + + $classReflection = new ReflectionClass($interface); + $attributes = $classReflection->getAttributes(TestAssert::class); + if ($attributes === []) { + continue; + } + $classes[] = $interface; } - return $class; + return $classes; } } diff --git a/src/Testing/Contracts/FinderFactoryContract.php b/src/Testing/Contracts/FinderFactoryContract.php new file mode 100644 index 00000000..5a61a97b --- /dev/null +++ b/src/Testing/Contracts/FinderFactoryContract.php @@ -0,0 +1,12 @@ +files() + ->name('*.php') + ->in($this->composerAutoloadAbsoluteAction->execute()) + ->notName('*.blade.php'); + } +} diff --git a/src/Testing/Services/ComposerJsonDataService.php b/src/Testing/Services/ComposerJsonDataService.php new file mode 100644 index 00000000..7cb4ffb7 --- /dev/null +++ b/src/Testing/Services/ComposerJsonDataService.php @@ -0,0 +1,45 @@ +filesystem->isFile(self::composerJson($basePath)); + } + + public function data(string $basePath): mixed + { + $path = realpath($basePath); + return $this->cacheMeServiceContract->get( + key: "larasctrict.composer.{$path}", + getValue: function () use ($path): mixed { + return json_decode( + $this->filesystem->get(self::composerJson($path)), + true, + 512, + JSON_THROW_ON_ERROR + ); + }, + strategy: CacheMeStrategy::Memory, + ); + } + + private static function composerJson(string $path): string + { + return $path . '/composer.json'; + } +} diff --git a/src/Testing/TestServiceProvider.php b/src/Testing/TestServiceProvider.php index 2fa845c7..ada45da2 100644 --- a/src/Testing/TestServiceProvider.php +++ b/src/Testing/TestServiceProvider.php @@ -11,15 +11,18 @@ use LaraStrict\Testing\Actions\GetBasePathForStubsAction; use LaraStrict\Testing\Actions\GetNamespaceForStubsAction; use LaraStrict\Testing\Commands\MakeExpectationCommand; +use LaraStrict\Testing\Contracts\FinderFactoryContract; use LaraStrict\Testing\Contracts\GetBasePathForStubsActionContract; use LaraStrict\Testing\Contracts\GetNamespaceForStubsActionContract; use LaraStrict\Testing\Core\Services\NoSleepService; +use LaraStrict\Testing\Factories\FinderFactory; class TestServiceProvider extends ServiceProvider { public array $bindings = [ GetBasePathForStubsActionContract::class => GetBasePathForStubsAction::class, GetNamespaceForStubsActionContract::class => GetNamespaceForStubsAction::class, + FinderFactoryContract::class => FinderFactory::class, ]; public function register(): void diff --git a/tests/Unit/Testing/Actions/GetDevNamespaceForStubsActionTest.php b/tests/Unit/Testing/Actions/GetDevNamespaceForStubsActionTest.php index 197948a7..256a889f 100644 --- a/tests/Unit/Testing/Actions/GetDevNamespaceForStubsActionTest.php +++ b/tests/Unit/Testing/Actions/GetDevNamespaceForStubsActionTest.php @@ -33,7 +33,7 @@ public function data(): array */ public function testNamespace(string $class, string $expectedBaseNamespace, string $expectedFolder): void { - $result = (new GetDevNamespaceForStubsAction())->execute(new Command(), 'test', $class); + $result = (new GetDevNamespaceForStubsAction())->execute(new Command(), $class); $this->assertEquals($expectedFolder, $result->folder); $this->assertEquals($expectedBaseNamespace, $result->baseNamespace); }