Skip to content

Commit

Permalink
feat(Testing): Add new attribute for mark interface for automatic gen…
Browse files Browse the repository at this point in the history
…erated expectation
  • Loading branch information
h4kuna committed Dec 20, 2023
1 parent d7912fb commit 1d7492e
Show file tree
Hide file tree
Showing 12 changed files with 309 additions and 35 deletions.
69 changes: 69 additions & 0 deletions src/Testing/Actions/ComposerAutoloadAbsoluteAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

declare(strict_types=1);

namespace LaraStrict\Testing\Actions;

use Illuminate\Filesystem\Filesystem;
use LaraStrict\Testing\Contracts\GetBasePathForStubsActionContract;
use LaraStrict\Testing\Services\ComposerJsonDataService;

final class ComposerAutoloadAbsoluteAction
{
/**
* @var array<string, string>
*/
private array $dirs;

public function __construct(
private readonly ComposerJsonDataService $getComposerJsonDataAction,
private readonly Filesystem $filesystem,
GetBasePathForStubsActionContract $getBasePathForStubsAction,
) {
$this->dirs = $this->makeDirs($getBasePathForStubsAction->execute());
}

/**
* @return array<string, string>
*/
public function execute(?string $path = null): array
{
if ($path !== null) {
$this->dirs += $this->loadNewComposer($path);
}

return $this->dirs;
}

/**
* @return array<string, string>
*/
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);
}
}
2 changes: 1 addition & 1 deletion src/Testing/Actions/GetDevNamespaceForStubsAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
13 changes: 4 additions & 9 deletions src/Testing/Actions/GetNamespaceForStubsAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();

Check failure on line 27 in src/Testing/Actions/GetNamespaceForStubsAction.php

View workflow job for this annotation

GitHub Actions / Code check / PHPStan (lowest)

Method LaraStrict\Testing\Services\ComposerJsonDataService::data() invoked with 0 parameters, 1 required.

Check failure on line 27 in src/Testing/Actions/GetNamespaceForStubsAction.php

View workflow job for this annotation

GitHub Actions / Code check / PHPStan (highest)

Method LaraStrict\Testing\Services\ComposerJsonDataService::data() invoked with 0 parameters, 1 required.
$autoLoad = $this->getComposerDevAutoLoad($composer);
if ($autoLoad !== []) {
if (count($autoLoad) === 1) {
Expand All @@ -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])
Expand Down
54 changes: 54 additions & 0 deletions src/Testing/Actions/PathToClassAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

declare(strict_types=1);

namespace LaraStrict\Testing\Actions;

use Exception;

final class PathToClassAction
{
public function __construct(
private readonly ComposerAutoloadAbsoluteAction $composerAutoloadAbsoluteAction,
) {
}

public function execute(string $path): string
{
$dirs = $this->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[/\\\](?<path>.*)\.php$~', preg_quote($dir, '~')),
static fn (array $matches) => $ns . strtr($matches['path'], [
'/' => '\\',
]),
$path
);
assert(is_string($class));

return $class;
}

return null;
}
}
12 changes: 12 additions & 0 deletions src/Testing/Attributes/TestAssert.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace LaraStrict\Testing\Attributes;

use Attribute;

#[Attribute]
final class TestAssert
{
}
105 changes: 82 additions & 23 deletions src/Testing/Commands/MakeExpectationCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,17 @@
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Str;
use LaraStrict\Testing\Actions\ParsePhpDocAction;
use LaraStrict\Testing\Actions\PathToClassAction;
use LaraStrict\Testing\Assert\AbstractExpectationCallsMap;
use LaraStrict\Testing\Attributes\TestAssert;
use LaraStrict\Testing\Constants\StubConstants;
use LaraStrict\Testing\Contracts\FinderFactoryContract;
use LaraStrict\Testing\Contracts\GetBasePathForStubsActionContract;
use LaraStrict\Testing\Contracts\GetNamespaceForStubsActionContract;
use LaraStrict\Testing\Entities\AssertFileStateEntity;
use LaraStrict\Testing\Entities\PhpDocEntity;
use LaraStrict\Testing\Enums\PhpType;
use LogicException;
use Nette\PhpGenerator\ClassLike;
use Nette\PhpGenerator\ClassType;
use Nette\PhpGenerator\Factory;
use Nette\PhpGenerator\Literal;
Expand All @@ -33,21 +35,24 @@
use ReflectionType;
use ReflectionUnionType;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Finder\Finder;

#[AsCommand(name: 'make:expectation', description: 'Make expectation class for given class')]
class MakeExpectationCommand extends Command
{
private const HookProperty = '_hook';

protected $signature = 'make:expectation
{class : Class name of path to class using PSR-4 specs}
{class : Class name of path to class using PSR-4 specs or use all keyword}
';

public function handle(
Filesystem $filesystem,
GetBasePathForStubsActionContract $getBasePathAction,
GetNamespaceForStubsActionContract $getFolderAndNamespaceForStubsAction,
ParsePhpDocAction $parsePhpDocAction,
FinderFactoryContract $finderFactory,
PathToClassAction $pathToClassAction,
): int {
if (class_exists(ClassType::class) === false) {
$message = 'First install package that is required:';
Expand All @@ -61,12 +66,43 @@ public function handle(

$basePath = $getBasePathAction->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);
Expand All @@ -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
Expand Down Expand Up @@ -175,8 +211,6 @@ className: $assertClassName,
fileContents: $printer->printFile($assertFileState->file)
);
}

return 0;
}

/**
Expand Down Expand Up @@ -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;

Expand All @@ -492,23 +540,34 @@ private function getInputClass(string $basePath, Filesystem $filesystem): ?strin
return null;
}

$file = PhpFile::fromCode($filesystem->get($fullPath));
return $pathToClassAction->execute($fullPath);

Check failure on line 543 in src/Testing/Commands/MakeExpectationCommand.php

View workflow job for this annotation

GitHub Actions / Code check / PHPStan (lowest)

Method LaraStrict\Testing\Commands\MakeExpectationCommand::normalizeToClass() should return class-string|null but returns string.

Check failure on line 543 in src/Testing/Commands/MakeExpectationCommand.php

View workflow job for this annotation

GitHub Actions / Code check / PHPStan (highest)

Method LaraStrict\Testing\Commands\MakeExpectationCommand::normalizeToClass() should return class-string|null but returns string.
}

/** @phpstan-var array<class-string, ClassLike> $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<class-string>
*/
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;
}
}
12 changes: 12 additions & 0 deletions src/Testing/Contracts/FinderFactoryContract.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace LaraStrict\Testing\Contracts;

use Symfony\Component\Finder\Finder;

interface FinderFactoryContract
{
public function create(): Finder;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@

interface GetNamespaceForStubsActionContract
{
public function execute(Command $command, string $basePath, string $inputClass): NamespaceEntity;
public function execute(Command $command, string $inputClass): NamespaceEntity;
}
Loading

0 comments on commit 1d7492e

Please sign in to comment.