From 805b361c33c0fecb91bb07f331ad636304b18986 Mon Sep 17 00:00:00 2001 From: Richard Anderson Date: Mon, 26 May 2025 00:54:44 +0100 Subject: [PATCH 1/4] Added ide-helper:actions for docblocks --- src/ActionableProvider.php | 2 + .../Commands/ActionsIdeHelperCommand.php | 127 +++++++ src/Services/ActionDocBlockService.php | 352 ++++++++++++++++++ 3 files changed, 481 insertions(+) create mode 100644 src/Console/Commands/ActionsIdeHelperCommand.php create mode 100644 src/Services/ActionDocBlockService.php diff --git a/src/ActionableProvider.php b/src/ActionableProvider.php index 2d30a8a..daed783 100644 --- a/src/ActionableProvider.php +++ b/src/ActionableProvider.php @@ -4,6 +4,7 @@ use Illuminate\Support\ServiceProvider; use LumoSolutions\Actionable\Console\BaseStubCommand; +use LumoSolutions\Actionable\Console\Commands\ActionsIdeHelperCommand; use LumoSolutions\Actionable\Console\Commands\MakeActionCommand; use LumoSolutions\Actionable\Console\Commands\MakeDtoCommand; @@ -17,6 +18,7 @@ public function boot(): void $this->commands([ MakeActionCommand::class, MakeDtoCommand::class, + ActionsIdeHelperCommand::class, ]); $this->publishes( diff --git a/src/Console/Commands/ActionsIdeHelperCommand.php b/src/Console/Commands/ActionsIdeHelperCommand.php new file mode 100644 index 0000000..bf769bb --- /dev/null +++ b/src/Console/Commands/ActionsIdeHelperCommand.php @@ -0,0 +1,127 @@ +service = $service; + } + + public function handle(): int + { + $namespace = $this->option('namespace'); + $dryRun = $this->option('dry-run'); + + $this->info("Scanning for Action classes in namespace: {$namespace} "); + + $files = $this->getPhpFiles($namespace); + + if (empty($files)) { + $this->error("No PHP files found in namespace: {$namespace}"); + + return self::FAILURE; + } + + $processedCount = 0; + $skippedCount = 0; + $errorCount = 0; + + foreach ($files as $file) { + $relativePath = $file->getRealPath(); + + try { + $result = $this->service->processFile($file->getPathname(), $dryRun); + + if ($result['processed']) { + $processedCount++; + + if ($dryRun) { + $this->line("Would update: {$relativePath}"); + $this->showDocBlockChanges($result['docBlocks']); + } else { + $this->info("Updated: {$relativePath}"); + } + } else { + $skippedCount++; + if ($this->output->isVerbose()) { + $this->line("Skipped: {$relativePath} - {$result['reason']}"); + } + } + } catch (Exception $e) { + $errorCount++; + $this->error("Error processing {$relativePath}: {$e->getMessage()}"); + + if ($this->output->isVeryVerbose()) { + $this->line($e->getTraceAsString()); + } + } + } + + $this->showSummary($processedCount, $skippedCount, $errorCount, $dryRun); + + return ($errorCount > 0) ? self::FAILURE : self::SUCCESS; + } + + private function getPhpFiles(string $namespace): array + { + $path = app_path(str_replace(['App\\', '\\'], ['', '/'], $namespace)); + + if (! is_dir($path)) { + return []; + } + + return collect(File::allFiles($path)) + ->filter(fn ($file) => $file->getExtension() === 'php') + ->values() + ->all(); + } + + private function showDocBlockChanges(array $docBlocks): void + { + if (empty($docBlocks)) { + return; + } + + $this->line(' Doc blocks to add:'); + foreach ($docBlocks as $docBlock) { + $this->line(" * {$docBlock}"); + } + } + + private function showSummary(int $processedCount, int $skippedCount, int $errorCount, bool $dryRun): void + { + $this->line(''); + $this->info('Summary:'); + + $action = $dryRun ? 'Would be updated' : 'Updated'; + $this->line(" {$action}: {$processedCount} files"); + + if ($skippedCount > 0 || $this->output->isVerbose()) { + $this->line(" Skipped: {$skippedCount} files"); + } + + if ($errorCount > 0) { + $this->line(" Errors: {$errorCount} files"); + } + + $this->line(''); + $status = $errorCount > 0 ? 'completed with errors' : 'completed successfully'; + $this->info("Process {$status}!"); + } +} diff --git a/src/Services/ActionDocBlockService.php b/src/Services/ActionDocBlockService.php new file mode 100644 index 0000000..e92e79b --- /dev/null +++ b/src/Services/ActionDocBlockService.php @@ -0,0 +1,352 @@ +extractClassInfo($content, $filePath); + + if (! $classInfo) { + return [ + 'processed' => false, + 'reason' => 'Could not extract class information', + 'docBlocks' => [], + ]; + } + + $traitInfo = $this->analyzeTraits($classInfo['className']); + + if (! $traitInfo['hasTargetTraits']) { + return [ + 'processed' => false, + 'reason' => 'Class does not use IsRunnable or IsDispatchable traits', + 'docBlocks' => [], + ]; + } + + $methodInfo = $this->analyzeHandleMethod($classInfo['className'], $classInfo['useStatements']); + + if (! $methodInfo) { + return [ + 'processed' => false, + 'reason' => 'Class missing handle method', + 'docBlocks' => [], + ]; + } + + $docBlocks = $this->generateDocBlocks( + $traitInfo['hasRunnable'], + $traitInfo['hasDispatchable'], + $methodInfo['parameters'], + $methodInfo['returnType'] + ); + + if (empty($docBlocks)) { + return [ + 'processed' => false, + 'reason' => 'No doc blocks to generate', + 'docBlocks' => [], + ]; + } + + $newContent = $this->updateClassDocBlock($content, $docBlocks); + + if ($newContent === $content) { + return [ + 'processed' => false, + 'reason' => 'Doc blocks already up to date', + 'docBlocks' => [], + ]; + } + + if (! $dryRun) { + File::put($filePath, $newContent); + } + + return [ + 'processed' => true, + 'reason' => 'Success', + 'docBlocks' => $docBlocks, + ]; + } + + private function extractClassInfo(string $content, string $filePath): ?array + { + $namespace = null; + if (preg_match('/namespace\s+([^;]+);/', $content, $matches)) { + $namespace = trim($matches[1]); + } + + $className = null; + if (preg_match('/class\s+(\w+)/', $content, $matches)) { + $className = $matches[1]; + } + + if (! $namespace || ! $className) { + return null; + } + + $fullClassName = '\\'.$namespace.'\\'.$className; + + if (! class_exists($fullClassName)) { + return null; + } + + $useStatements = $this->parseUseStatements($content); + + return [ + 'className' => $fullClassName, + 'useStatements' => $useStatements, + ]; + } + + private function parseUseStatements(string $content): array + { + $useStatements = []; + + $beforeClass = strstr($content, 'class ', true) ?: $content; + + if (preg_match_all('/use\s+([^;]+);/', $beforeClass, $matches)) { + foreach ($matches[1] as $useStatement) { + $useStatement = trim($useStatement); + + if (preg_match('/^(function|const)\s+/', $useStatement)) { + continue; + } + + if (preg_match('/^(.+?)\s+as\s+(\w+)$/', $useStatement, $aliasMatch)) { + $fullName = trim($aliasMatch[1]); + $alias = trim($aliasMatch[2]); + $useStatements[$alias] = $fullName; + } else { + $parts = explode('\\', $useStatement); + $className = end($parts); + $useStatements[$className] = $useStatement; + } + } + } + + return $useStatements; + } + + private function analyzeTraits(string $className): array + { + try { + $reflection = new ReflectionClass($className); + $traits = $reflection->getTraitNames(); + + $hasRunnable = in_array($this->targetTraits[0], $traits); + $hasDispatchable = in_array($this->targetTraits[1], $traits); + + return [ + 'hasTargetTraits' => $hasRunnable || $hasDispatchable, + 'hasRunnable' => $hasRunnable, + 'hasDispatchable' => $hasDispatchable, + ]; + } catch (Exception $e) { + return [ + 'hasTargetTraits' => false, + 'hasRunnable' => false, + 'hasDispatchable' => false, + ]; + } + } + + private function analyzeHandleMethod(string $className, array $useStatements): ?array + { + try { + $reflection = new ReflectionClass($className); + + if (! $reflection->hasMethod('handle')) { + return null; + } + + $handleMethod = $reflection->getMethod('handle'); + + return [ + 'parameters' => $this->buildParameterString($handleMethod, $useStatements), + 'returnType' => $this->getReturnTypeString($handleMethod, $useStatements), + ]; + } catch (Exception) { + return null; + } + } + + private function buildParameterString(ReflectionMethod $method, array $useStatements): string + { + $parameters = []; + + foreach ($method->getParameters() as $param) { + $paramString = ''; + + // Add type hint + if ($param->hasType()) { + $type = $param->getType(); + if ($type instanceof ReflectionNamedType) { + $typeName = $type->getName(); + $resolvedType = $this->resolveType($typeName, $useStatements); + + if ($type->allowsNull() && $typeName !== 'mixed') { + $paramString .= '?'; + } + + $paramString .= $resolvedType.' '; + } + } + + $paramString .= '$'.$param->getName(); + + if ($param->isDefaultValueAvailable()) { + $paramString .= ' = '.$this->formatDefaultValue($param->getDefaultValue()); + } + + $parameters[] = $paramString; + } + + return implode(', ', $parameters); + } + + private function getReturnTypeString(ReflectionMethod $method, array $useStatements): string + { + if (! $method->hasReturnType()) { + return 'mixed'; + } + + $returnType = $method->getReturnType(); + + if ($returnType instanceof ReflectionNamedType) { + $typeName = $returnType->getName(); + $resolvedType = $this->resolveType($typeName, $useStatements); + + if ($returnType->allowsNull() && $typeName !== 'mixed') { + return '?'.$resolvedType; + } + + return $resolvedType; + } + + return 'mixed'; + } + + private function resolveType(string $typeName, array $useStatements): string + { + if (in_array($typeName, $this->builtInTypes)) { + return $typeName; + } + + $cleanTypeName = ltrim($typeName, '\\'); + + foreach ($useStatements as $shortName => $fullName) { + $cleanFullName = ltrim($fullName, '\\'); + if ($cleanFullName === $cleanTypeName) { + return $shortName; + } + } + + if (isset($useStatements[$typeName])) { + return $typeName; + } + + if (strpos($typeName, '\\') === false) { + return $typeName; + } + + return $typeName; + } + + private function formatDefaultValue($value): string + { + if (is_null($value)) { + return 'null'; + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if (is_string($value)) { + return "'".addslashes($value)."'"; + } + + if (is_array($value)) { + return empty($value) ? '[]' : '[...]'; + } + + return (string) $value; + } + + private function generateDocBlocks(bool $hasRunnable, bool $hasDispatchable, string $parameters, string $returnType): array + { + $docBlocks = []; + + if ($hasRunnable) { + $docBlocks[] = "@method static {$returnType} run({$parameters})"; + } + + if ($hasDispatchable) { + $docBlocks[] = "@method static void dispatch({$parameters})"; + $docBlocks[] = "@method static void dispatchOn(string \$queue, {$parameters})"; + } + + return $docBlocks; + } + + private function updateClassDocBlock(string $content, array $docBlocks): string + { + $newDocBlockContent = implode("\n * ", $docBlocks); + + $pattern = '/\/\*\*\s*\n(.*?)\*\/\s*\n((?:(?:abstract|final)\s+)?class\s+\w+)/s'; + + if (preg_match($pattern, $content, $matches)) { + $existingDocBlock = $matches[1]; + $classDeclaration = $matches[2]; + + $cleanedDocBlock = preg_replace( + '/\s*\*\s*@method\s+static\s+[^\s]+\s+(run|dispatch|dispatchOn)\([^)]*\)\s*\n/m', + '', + $existingDocBlock + ); + + $cleanedDocBlock = preg_replace('/(\n\s*\*\s*\n){2,}/', "\n *\n", $cleanedDocBlock); + $cleanedDocBlock = rtrim($cleanedDocBlock); + + $contentOnly = preg_replace('/[\s*\n\r\t]/', '', $cleanedDocBlock); + $hasContent = ! empty($contentOnly); + + if ($hasContent) { + $updatedDocBlock = $cleanedDocBlock."\n *\n * ".$newDocBlockContent."\n"; + } else { + $updatedDocBlock = "\n * ".$newDocBlockContent."\n"; + } + + return str_replace($matches[0], "/**{$updatedDocBlock} */\n{$classDeclaration}", $content); + } else { + $pattern = '/((?:(?:abstract|final)\s+)?class\s+\w+)/'; + $newDocBlock = "/**\n * {$newDocBlockContent}\n */\n$1"; + + return preg_replace($pattern, $newDocBlock, $content, 1); + } + } +} From 359a832b32fdfdc84eafa2522632b6145f432250 Mon Sep 17 00:00:00 2001 From: Richard Anderson Date: Mon, 26 May 2025 10:00:48 +0100 Subject: [PATCH 2/4] Updated Test Cases --- .../Commands/ActionsIdeHelperCommand.php | 33 +-- src/Services/ActionDocBlockService.php | 57 ++-- .../Actions/DispatchableActionTest.php | 0 .../Actions/InvokableActionTest.php | 0 .../Actions/RunnableActionTest.php | 0 tests/{Unit => Feature}/ArchTest.php | 0 .../Console/ActionIdeHelperCommandTest.php | 262 ++++++++++++++++++ .../stubs/action.dispatchable.stub | 0 .../Configuration/stubs/action.invokable.stub | 0 .../Console/Configuration/stubs/action.stub | 0 .../Console/Configuration/stubs/dto.stub | 0 .../Console/MakeActionCommandTest.php | 0 .../Console/MakeDtoCommandTest.php | 0 .../Dtos/ArrayOfAttributeTest.php | 0 .../Dtos/DateFormatAttributeTest.php | 0 .../Dtos/FieldNameAttributeTest.php | 0 .../Dtos/IgnoreAttributeTest.php | 0 .../Dtos/NestedClassTest.php | 0 tests/Pest.php | 2 - tests/TestCase.php | 33 ++- 20 files changed, 315 insertions(+), 72 deletions(-) rename tests/{Unit => Feature}/Actions/DispatchableActionTest.php (100%) rename tests/{Unit => Feature}/Actions/InvokableActionTest.php (100%) rename tests/{Unit => Feature}/Actions/RunnableActionTest.php (100%) rename tests/{Unit => Feature}/ArchTest.php (100%) create mode 100644 tests/Feature/Console/ActionIdeHelperCommandTest.php rename tests/{Unit => Feature}/Console/Configuration/stubs/action.dispatchable.stub (100%) rename tests/{Unit => Feature}/Console/Configuration/stubs/action.invokable.stub (100%) rename tests/{Unit => Feature}/Console/Configuration/stubs/action.stub (100%) rename tests/{Unit => Feature}/Console/Configuration/stubs/dto.stub (100%) rename tests/{Unit => Feature}/Console/MakeActionCommandTest.php (100%) rename tests/{Unit => Feature}/Console/MakeDtoCommandTest.php (100%) rename tests/{Unit => Feature}/Dtos/ArrayOfAttributeTest.php (100%) rename tests/{Unit => Feature}/Dtos/DateFormatAttributeTest.php (100%) rename tests/{Unit => Feature}/Dtos/FieldNameAttributeTest.php (100%) rename tests/{Unit => Feature}/Dtos/IgnoreAttributeTest.php (100%) rename tests/{Unit => Feature}/Dtos/NestedClassTest.php (100%) diff --git a/src/Console/Commands/ActionsIdeHelperCommand.php b/src/Console/Commands/ActionsIdeHelperCommand.php index bf769bb..20ea24d 100644 --- a/src/Console/Commands/ActionsIdeHelperCommand.php +++ b/src/Console/Commands/ActionsIdeHelperCommand.php @@ -43,32 +43,23 @@ public function handle(): int $errorCount = 0; foreach ($files as $file) { - $relativePath = $file->getRealPath(); + $relativePath = str_replace(base_path() . DIRECTORY_SEPARATOR, '', $file->getRealPath()); - try { - $result = $this->service->processFile($file->getPathname(), $dryRun); + $result = $this->service->processFile($file->getPathname(), $dryRun); - if ($result['processed']) { - $processedCount++; + if ($result['processed']) { + $processedCount++; - if ($dryRun) { - $this->line("Would update: {$relativePath}"); - $this->showDocBlockChanges($result['docBlocks']); - } else { - $this->info("Updated: {$relativePath}"); - } + if ($dryRun) { + $this->line("Would update: {$relativePath}"); + $this->showDocBlockChanges($result['docBlocks']); } else { - $skippedCount++; - if ($this->output->isVerbose()) { - $this->line("Skipped: {$relativePath} - {$result['reason']}"); - } + $this->info("Updated: {$relativePath}"); } - } catch (Exception $e) { - $errorCount++; - $this->error("Error processing {$relativePath}: {$e->getMessage()}"); - - if ($this->output->isVeryVerbose()) { - $this->line($e->getTraceAsString()); + } else { + $skippedCount++; + if ($this->output->isVerbose()) { + $this->line("Skipped: {$relativePath} - {$result['reason']}"); } } } diff --git a/src/Services/ActionDocBlockService.php b/src/Services/ActionDocBlockService.php index e92e79b..f5cf7ae 100644 --- a/src/Services/ActionDocBlockService.php +++ b/src/Services/ActionDocBlockService.php @@ -63,14 +63,6 @@ public function processFile(string $filePath, bool $dryRun = false): array $methodInfo['returnType'] ); - if (empty($docBlocks)) { - return [ - 'processed' => false, - 'reason' => 'No doc blocks to generate', - 'docBlocks' => [], - ]; - } - $newContent = $this->updateClassDocBlock($content, $docBlocks); if ($newContent === $content) { @@ -92,6 +84,15 @@ public function processFile(string $filePath, bool $dryRun = false): array ]; } + private function ensureClassIsLoaded(string $className, string $filePath): void + { + if (! class_exists($className, false)) { + if (File::exists($filePath)) { + require_once $filePath; + } + } + } + private function extractClassInfo(string $content, string $filePath): ?array { $namespace = null; @@ -111,7 +112,7 @@ private function extractClassInfo(string $content, string $filePath): ?array $fullClassName = '\\'.$namespace.'\\'.$className; if (! class_exists($fullClassName)) { - return null; + $this->ensureClassIsLoaded($fullClassName, $filePath); } $useStatements = $this->parseUseStatements($content); @@ -153,25 +154,17 @@ private function parseUseStatements(string $content): array private function analyzeTraits(string $className): array { - try { - $reflection = new ReflectionClass($className); - $traits = $reflection->getTraitNames(); + $reflection = new ReflectionClass($className); + $traits = $reflection->getTraitNames(); - $hasRunnable = in_array($this->targetTraits[0], $traits); - $hasDispatchable = in_array($this->targetTraits[1], $traits); + $hasRunnable = in_array($this->targetTraits[0], $traits); + $hasDispatchable = in_array($this->targetTraits[1], $traits); - return [ - 'hasTargetTraits' => $hasRunnable || $hasDispatchable, - 'hasRunnable' => $hasRunnable, - 'hasDispatchable' => $hasDispatchable, - ]; - } catch (Exception $e) { - return [ - 'hasTargetTraits' => false, - 'hasRunnable' => false, - 'hasDispatchable' => false, - ]; - } + return [ + 'hasTargetTraits' => $hasRunnable || $hasDispatchable, + 'hasRunnable' => $hasRunnable, + 'hasDispatchable' => $hasDispatchable, + ]; } private function analyzeHandleMethod(string $className, array $useStatements): ?array @@ -265,15 +258,7 @@ private function resolveType(string $typeName, array $useStatements): string } } - if (isset($useStatements[$typeName])) { - return $typeName; - } - - if (strpos($typeName, '\\') === false) { - return $typeName; - } - - return $typeName; + return '\\' . $typeName; } private function formatDefaultValue($value): string @@ -307,7 +292,7 @@ private function generateDocBlocks(bool $hasRunnable, bool $hasDispatchable, str if ($hasDispatchable) { $docBlocks[] = "@method static void dispatch({$parameters})"; - $docBlocks[] = "@method static void dispatchOn(string \$queue, {$parameters})"; + $docBlocks[] = "@method static void dispatchOn(string \$queue" . ($parameters ? ", {$parameters}" : "") . ")"; } return $docBlocks; diff --git a/tests/Unit/Actions/DispatchableActionTest.php b/tests/Feature/Actions/DispatchableActionTest.php similarity index 100% rename from tests/Unit/Actions/DispatchableActionTest.php rename to tests/Feature/Actions/DispatchableActionTest.php diff --git a/tests/Unit/Actions/InvokableActionTest.php b/tests/Feature/Actions/InvokableActionTest.php similarity index 100% rename from tests/Unit/Actions/InvokableActionTest.php rename to tests/Feature/Actions/InvokableActionTest.php diff --git a/tests/Unit/Actions/RunnableActionTest.php b/tests/Feature/Actions/RunnableActionTest.php similarity index 100% rename from tests/Unit/Actions/RunnableActionTest.php rename to tests/Feature/Actions/RunnableActionTest.php diff --git a/tests/Unit/ArchTest.php b/tests/Feature/ArchTest.php similarity index 100% rename from tests/Unit/ArchTest.php rename to tests/Feature/ArchTest.php diff --git a/tests/Feature/Console/ActionIdeHelperCommandTest.php b/tests/Feature/Console/ActionIdeHelperCommandTest.php new file mode 100644 index 0000000..385de2a --- /dev/null +++ b/tests/Feature/Console/ActionIdeHelperCommandTest.php @@ -0,0 +1,262 @@ + 'Test1']); + + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('Scanning for Action classes in namespace: App\\Actions') + ->expectsOutputToContain(join_paths("app", "Actions", "Test1.php")); + }); + + it('identifies and documents run in action', function() { + Artisan::call(MakeActionCommand::class, ['name' => 'Test2']); + $actionPath = join_paths(app_path(), 'Actions', 'Test2.php'); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('@method static void run()') + ->doesntExpectOutputToContain('@method static void dispatch') + ->doesntExpectOutputToContain('@method static void dispatchOn'); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->toContain('@method static void run()') + ->and($fileContents)->not->toContain('@method static void dispatch') + ->and($fileContents)->not->toContain('@method static void dispatchOn'); + }); + + it('identifies and documents dispatch in action', function() { + Artisan::call(MakeActionCommand::class, ['name' => 'Test3', '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test3.php'); + + File::replaceInFile("use IsRunnable, IsDispatchable;", "use IsDispatchable;", $actionPath); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->doesntExpectOutputToContain('@method static void run()') + ->expectsOutputToContain('@method static void dispatch()') + ->expectsOutputToContain('@method static void dispatchOn(string $queue)'); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->not->toContain('@method static void run()') + ->and($fileContents)->toContain('@method static void dispatch()') + ->and($fileContents)->toContain('@method static void dispatchOn(string $queue)'); + }); + + it('identifies and documents both run and dispatch together', function() { + Artisan::call(MakeActionCommand::class, ['name' => 'Test4', '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test4.php'); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('@method static void run()') + ->expectsOutputToContain('@method static void dispatch()') + ->expectsOutputToContain('@method static void dispatchOn(string $queue)'); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->toContain('@method static void run()') + ->and($fileContents)->toContain('@method static void dispatch()') + ->and($fileContents)->toContain('@method static void dispatchOn(string $queue)'); + }); + + it('identifies and documents correct single parameters', function() { + Artisan::call(MakeActionCommand::class, ['name' => 'Test5', '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test5.php'); + + File::replaceInFile("public function handle(): void", "public function handle(string \$type): array", $actionPath); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('@method static array run(string $type)') + ->expectsOutputToContain('@method static void dispatch(string $type)') + ->expectsOutputToContain('@method static void dispatchOn(string $queue, string $type)'); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->toContain('@method static array run(string $type)') + ->and($fileContents)->toContain('@method static void dispatch(string $type)') + ->and($fileContents)->toContain('@method static void dispatchOn(string $queue, string $type)'); + }); + + it('identifies and documents correct multiple parameters', function() { + Artisan::call(MakeActionCommand::class, ['name' => 'Test6', '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test6.php'); + + File::replaceInFile("public function handle(): void", "public function handle(string \$type, Test6 \$action): array", $actionPath); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('@method static array run(string $type, \\App\\Actions\\Test6 $action)') + ->expectsOutputToContain('@method static void dispatch(string $type, \\App\\Actions\\Test6 $action)') + ->expectsOutputToContain('@method static void dispatchOn(string $queue, string $type, \\App\\Actions\\Test6 $action)'); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->toContain('@method static array run(string $type, \\App\\Actions\\Test6 $action)') + ->and($fileContents)->toContain('@method static void dispatch(string $type, \\App\\Actions\\Test6 $action)') + ->and($fileContents)->toContain('@method static void dispatchOn(string $queue, string $type, \\App\\Actions\\Test6 $action)'); + }); + + it('correctly uses usings for short-class usage', function() { + Artisan::call(MakeActionCommand::class, ['name' => 'Test7', '--dispatchable' => true]); + Artisan::call(MakeActionCommand::class, ['name' => sprintf('DifferentDir%sTest8', DIRECTORY_SEPARATOR), '--dispatchable' => true]); + Artisan::call(MakeActionCommand::class, ['name' => sprintf('AnotherFolder%sTest9', DIRECTORY_SEPARATOR), '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test7.php'); + + File::replaceInFile( + "use LumoSolutions\\Actionable\\Traits\\IsDispatchable;", + implode(PHP_EOL, [ + "use LumoSolutions\\Actionable\\Traits\\IsDispatchable;", + "use App\\Actions\\Testing\\Test8;", + "use App\\Actions\\AnotherFolder\\Test9 as DoesWork;" + ]), + $actionPath + ); + File::replaceInFile( + "public function handle(): void", + "public function handle(Test8 \$action, DoesWork \$test): void", + $actionPath + ); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('@method static void run(Test8 $action, DoesWork $test)') + ->expectsOutputToContain('@method static void dispatch(Test8 $action, DoesWork $test)') + ->expectsOutputToContain('@method static void dispatchOn(string $queue, Test8 $action, DoesWork $test)'); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->toContain('@method static void run(Test8 $action, DoesWork $test)') + ->and($fileContents)->toContain('@method static void dispatch(Test8 $action, DoesWork $test)') + ->and($fileContents)->toContain('@method static void dispatchOn(string $queue, Test8 $action, DoesWork $test)'); + }); + + it('correctly handles default values', function() { + Artisan::call(MakeActionCommand::class, ['name' => 'Test10', '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test10.php'); + + File::replaceInFile( + "public function handle(): void", + "public function handle(string \$default = 'test', string \$can_null = null, array \$arr = []): void", + $actionPath + ); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('@method static void run(string $default = \'test\', ?string $can_null = null, array $arr = [])') + ->expectsOutputToContain('@method static void dispatch(string $default = \'test\', ?string $can_null = null, array $arr = [])') + ->expectsOutputToContain('@method static void dispatchOn(string $queue, string $default = \'test\', ?string $can_null = null, array $arr = [])'); + }); + + it('correctly updates existing docblocks', function() { + Artisan::call(MakeActionCommand::class, ['name' => 'Test11', '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test11.php'); + + File::replaceInFile( + "class Test11", + implode(PHP_EOL, [ + "/**", + " * @method static void leave_this_one(string \$valid)", + " * @method static void run(string \$invalid)", + " * @method static void dispatch(string \$invalid)", + " * @method static void dispatchOn(string \$queue, string \$invalid)", + " */", + "class Test11", + ]), + $actionPath + ); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->toContain('@method static void leave_this_one(string $valid)') + ->and($fileContents)->not->toContain('@method static void run(string $invalid)') + ->and($fileContents)->toContain('@method static void run()') + ->and($fileContents)->not->toContain('@method static void dispatch(string $invalid)') + ->and($fileContents)->toContain('@method static void dispatch()') + ->and($fileContents)->not->toContain('@method static void dispatchOn(string $queue, string $invalid)') + ->and($fileContents)->toContain('@method static void dispatchOn(string $queue)'); + }); + + it('correctly ignores non-action files', function() { + Artisan::call(MakeActionCommand::class, ['name' => 'Test12']); + $actionPath = join_paths(app_path(), 'Actions', 'Test12.php'); + + File::replaceInFile("use IsRunnable;", "", $actionPath); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->not->toContain('@method static void run') + ->and($fileContents)->not->toContain('@method static void dispatch') + ->and($fileContents)->not->toContain('@method static void dispatchOn'); + }); + + it('handles no files', function() { + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions\\DoesNotExist']) + ->assertExitCode(1); + }); + + it('handles action without handle method', function() { + Artisan::call(MakeActionCommand::class, ['name' => 'Test13']); + $actionPath = join_paths(app_path(), 'Actions', 'Test13.php'); + + File::replaceInFile("public function handle(): void", "public function something_else(): void", $actionPath); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->not->toContain('@method static void run'); + }); + + }); +}); diff --git a/tests/Unit/Console/Configuration/stubs/action.dispatchable.stub b/tests/Feature/Console/Configuration/stubs/action.dispatchable.stub similarity index 100% rename from tests/Unit/Console/Configuration/stubs/action.dispatchable.stub rename to tests/Feature/Console/Configuration/stubs/action.dispatchable.stub diff --git a/tests/Unit/Console/Configuration/stubs/action.invokable.stub b/tests/Feature/Console/Configuration/stubs/action.invokable.stub similarity index 100% rename from tests/Unit/Console/Configuration/stubs/action.invokable.stub rename to tests/Feature/Console/Configuration/stubs/action.invokable.stub diff --git a/tests/Unit/Console/Configuration/stubs/action.stub b/tests/Feature/Console/Configuration/stubs/action.stub similarity index 100% rename from tests/Unit/Console/Configuration/stubs/action.stub rename to tests/Feature/Console/Configuration/stubs/action.stub diff --git a/tests/Unit/Console/Configuration/stubs/dto.stub b/tests/Feature/Console/Configuration/stubs/dto.stub similarity index 100% rename from tests/Unit/Console/Configuration/stubs/dto.stub rename to tests/Feature/Console/Configuration/stubs/dto.stub diff --git a/tests/Unit/Console/MakeActionCommandTest.php b/tests/Feature/Console/MakeActionCommandTest.php similarity index 100% rename from tests/Unit/Console/MakeActionCommandTest.php rename to tests/Feature/Console/MakeActionCommandTest.php diff --git a/tests/Unit/Console/MakeDtoCommandTest.php b/tests/Feature/Console/MakeDtoCommandTest.php similarity index 100% rename from tests/Unit/Console/MakeDtoCommandTest.php rename to tests/Feature/Console/MakeDtoCommandTest.php diff --git a/tests/Unit/Dtos/ArrayOfAttributeTest.php b/tests/Feature/Dtos/ArrayOfAttributeTest.php similarity index 100% rename from tests/Unit/Dtos/ArrayOfAttributeTest.php rename to tests/Feature/Dtos/ArrayOfAttributeTest.php diff --git a/tests/Unit/Dtos/DateFormatAttributeTest.php b/tests/Feature/Dtos/DateFormatAttributeTest.php similarity index 100% rename from tests/Unit/Dtos/DateFormatAttributeTest.php rename to tests/Feature/Dtos/DateFormatAttributeTest.php diff --git a/tests/Unit/Dtos/FieldNameAttributeTest.php b/tests/Feature/Dtos/FieldNameAttributeTest.php similarity index 100% rename from tests/Unit/Dtos/FieldNameAttributeTest.php rename to tests/Feature/Dtos/FieldNameAttributeTest.php diff --git a/tests/Unit/Dtos/IgnoreAttributeTest.php b/tests/Feature/Dtos/IgnoreAttributeTest.php similarity index 100% rename from tests/Unit/Dtos/IgnoreAttributeTest.php rename to tests/Feature/Dtos/IgnoreAttributeTest.php diff --git a/tests/Unit/Dtos/NestedClassTest.php b/tests/Feature/Dtos/NestedClassTest.php similarity index 100% rename from tests/Unit/Dtos/NestedClassTest.php rename to tests/Feature/Dtos/NestedClassTest.php diff --git a/tests/Pest.php b/tests/Pest.php index df2ced7..db984e7 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -1,7 +1,5 @@ in(__DIR__ . "/Unit/Console"); uses(TestCase::class)->in(__DIR__); diff --git a/tests/TestCase.php b/tests/TestCase.php index 15b51ad..66b68ae 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -17,19 +17,21 @@ protected function setUp(): void protected function tearDown(): void { - // Clean up the app/Actions directory if it exists - if (is_dir(app_path('Actions'))) { - File::deleteDirectory(app_path('Actions')); - } - - // Clean up the app/DTOs directory if it exists - if (is_dir(app_path('Dtos'))) { - File::deleteDirectory(app_path('Dtos')); - } + $directories = [ + app_path('Actions'), + app_path('Dtos'), + join_paths(base_path(), 'stubs', 'lumosolutions', 'actionable') + ]; - // Clean up the stubs directory if it exists - if (is_dir(base_path('stubs/lumosolutions/actionable'))) { - File::deleteDirectory(base_path('stubs/lumosolutions/actionable')); + foreach ($directories as $dir) { + if (is_dir($dir)) { + File::deleteDirectory($dir); + clearstatcache(); + if (is_dir($dir)) { + dump("Failed to delete directory: {$dir}"); + throw new \RuntimeException("Failed to delete directory: {$dir}"); + } + } } parent::tearDown(); @@ -37,7 +39,7 @@ protected function tearDown(): void protected function copyStubs(): void { - $source = join_paths(__DIR__, 'Unit', 'Console', 'Configuration', 'stubs'); + $source = join_paths(__DIR__, 'Feature', 'Console', 'Configuration', 'stubs'); $destination = join_paths(base_path(), 'stubs', 'lumosolutions', 'actionable'); if (! is_dir($destination)) { @@ -45,6 +47,11 @@ protected function copyStubs(): void } File::copyDirectory($source, $destination); + + if (! File::exists(join_paths($destination, 'action.stub'))) { + dump("Failed to copy stubs to {$destination}"); + throw new \RuntimeException("Failed to copy stubs to {$destination}"); + } } protected function getPackageProviders($app): array From 631892a59600265f4cf3fb8e1b639f893a205dc1 Mon Sep 17 00:00:00 2001 From: Richard Anderson Date: Mon, 26 May 2025 10:03:26 +0100 Subject: [PATCH 3/4] Fixed formatting and errors --- .../Commands/ActionsIdeHelperCommand.php | 33 +- src/Services/ActionDocBlockService.php | 4 +- .../Console/ActionIdeHelperCommandTest.php | 510 +++++++++--------- tests/TestCase.php | 2 +- 4 files changed, 279 insertions(+), 270 deletions(-) diff --git a/src/Console/Commands/ActionsIdeHelperCommand.php b/src/Console/Commands/ActionsIdeHelperCommand.php index 20ea24d..c6c6291 100644 --- a/src/Console/Commands/ActionsIdeHelperCommand.php +++ b/src/Console/Commands/ActionsIdeHelperCommand.php @@ -43,23 +43,32 @@ public function handle(): int $errorCount = 0; foreach ($files as $file) { - $relativePath = str_replace(base_path() . DIRECTORY_SEPARATOR, '', $file->getRealPath()); + $relativePath = str_replace(base_path().DIRECTORY_SEPARATOR, '', $file->getRealPath()); - $result = $this->service->processFile($file->getPathname(), $dryRun); + try { + $result = $this->service->processFile($file->getPathname(), $dryRun); - if ($result['processed']) { - $processedCount++; + if ($result['processed']) { + $processedCount++; - if ($dryRun) { - $this->line("Would update: {$relativePath}"); - $this->showDocBlockChanges($result['docBlocks']); + if ($dryRun) { + $this->line("Would update: {$relativePath}"); + $this->showDocBlockChanges($result['docBlocks']); + } else { + $this->info("Updated: {$relativePath}"); + } } else { - $this->info("Updated: {$relativePath}"); + $skippedCount++; + if ($this->output->isVerbose()) { + $this->line("Skipped: {$relativePath} - {$result['reason']}"); + } } - } else { - $skippedCount++; - if ($this->output->isVerbose()) { - $this->line("Skipped: {$relativePath} - {$result['reason']}"); + } catch (Exception $e) { + $errorCount++; + $this->error("Error processing {$relativePath}: {$e->getMessage()}"); + + if ($this->output->isVeryVerbose()) { + $this->line($e->getTraceAsString()); } } } diff --git a/src/Services/ActionDocBlockService.php b/src/Services/ActionDocBlockService.php index f5cf7ae..acceadb 100644 --- a/src/Services/ActionDocBlockService.php +++ b/src/Services/ActionDocBlockService.php @@ -258,7 +258,7 @@ private function resolveType(string $typeName, array $useStatements): string } } - return '\\' . $typeName; + return '\\'.$typeName; } private function formatDefaultValue($value): string @@ -292,7 +292,7 @@ private function generateDocBlocks(bool $hasRunnable, bool $hasDispatchable, str if ($hasDispatchable) { $docBlocks[] = "@method static void dispatch({$parameters})"; - $docBlocks[] = "@method static void dispatchOn(string \$queue" . ($parameters ? ", {$parameters}" : "") . ")"; + $docBlocks[] = '@method static void dispatchOn(string $queue'.($parameters ? ", {$parameters}" : '').')'; } return $docBlocks; diff --git a/tests/Feature/Console/ActionIdeHelperCommandTest.php b/tests/Feature/Console/ActionIdeHelperCommandTest.php index 385de2a..4244192 100644 --- a/tests/Feature/Console/ActionIdeHelperCommandTest.php +++ b/tests/Feature/Console/ActionIdeHelperCommandTest.php @@ -2,261 +2,261 @@ use LumoSolutions\Actionable\Console\Commands\ActionsIdeHelperCommand; use LumoSolutions\Actionable\Console\Commands\MakeActionCommand; + use function Illuminate\Filesystem\join_paths; -describe('Console', function() { - describe('ActionsIdeHelperCommand', function() { - it('identifies action', function() { - Artisan::call(MakeActionCommand::class, ['name' => 'Test1']); - - - $this->artisan(ActionsIdeHelperCommand::class, [ - '--namespace' => 'App\\Actions', - '--dry-run' => true, - ]) - ->assertExitCode(0) - ->expectsOutputToContain('Scanning for Action classes in namespace: App\\Actions') - ->expectsOutputToContain(join_paths("app", "Actions", "Test1.php")); - }); - - it('identifies and documents run in action', function() { - Artisan::call(MakeActionCommand::class, ['name' => 'Test2']); - $actionPath = join_paths(app_path(), 'Actions', 'Test2.php'); - - $this->artisan(ActionsIdeHelperCommand::class, [ - '--namespace' => 'App\\Actions', - '--dry-run' => true, - ]) - ->assertExitCode(0) - ->expectsOutputToContain('@method static void run()') - ->doesntExpectOutputToContain('@method static void dispatch') - ->doesntExpectOutputToContain('@method static void dispatchOn'); - - $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) - ->assertExitCode(0); - - $fileContents = file_get_contents($actionPath); - expect($fileContents)->toContain('@method static void run()') - ->and($fileContents)->not->toContain('@method static void dispatch') - ->and($fileContents)->not->toContain('@method static void dispatchOn'); - }); - - it('identifies and documents dispatch in action', function() { - Artisan::call(MakeActionCommand::class, ['name' => 'Test3', '--dispatchable' => true]); - $actionPath = join_paths(app_path(), 'Actions', 'Test3.php'); - - File::replaceInFile("use IsRunnable, IsDispatchable;", "use IsDispatchable;", $actionPath); - - $this->artisan(ActionsIdeHelperCommand::class, [ - '--namespace' => 'App\\Actions', - '--dry-run' => true, - ]) - ->assertExitCode(0) - ->doesntExpectOutputToContain('@method static void run()') - ->expectsOutputToContain('@method static void dispatch()') - ->expectsOutputToContain('@method static void dispatchOn(string $queue)'); - - $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) - ->assertExitCode(0); - - $fileContents = file_get_contents($actionPath); - expect($fileContents)->not->toContain('@method static void run()') - ->and($fileContents)->toContain('@method static void dispatch()') - ->and($fileContents)->toContain('@method static void dispatchOn(string $queue)'); - }); - - it('identifies and documents both run and dispatch together', function() { - Artisan::call(MakeActionCommand::class, ['name' => 'Test4', '--dispatchable' => true]); - $actionPath = join_paths(app_path(), 'Actions', 'Test4.php'); - - $this->artisan(ActionsIdeHelperCommand::class, [ - '--namespace' => 'App\\Actions', - '--dry-run' => true, - ]) - ->assertExitCode(0) - ->expectsOutputToContain('@method static void run()') - ->expectsOutputToContain('@method static void dispatch()') - ->expectsOutputToContain('@method static void dispatchOn(string $queue)'); - - $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) - ->assertExitCode(0); - - $fileContents = file_get_contents($actionPath); - expect($fileContents)->toContain('@method static void run()') - ->and($fileContents)->toContain('@method static void dispatch()') - ->and($fileContents)->toContain('@method static void dispatchOn(string $queue)'); - }); - - it('identifies and documents correct single parameters', function() { - Artisan::call(MakeActionCommand::class, ['name' => 'Test5', '--dispatchable' => true]); - $actionPath = join_paths(app_path(), 'Actions', 'Test5.php'); - - File::replaceInFile("public function handle(): void", "public function handle(string \$type): array", $actionPath); - - $this->artisan(ActionsIdeHelperCommand::class, [ - '--namespace' => 'App\\Actions', - '--dry-run' => true, - ]) - ->assertExitCode(0) - ->expectsOutputToContain('@method static array run(string $type)') - ->expectsOutputToContain('@method static void dispatch(string $type)') - ->expectsOutputToContain('@method static void dispatchOn(string $queue, string $type)'); - - $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) - ->assertExitCode(0); - - $fileContents = file_get_contents($actionPath); - expect($fileContents)->toContain('@method static array run(string $type)') - ->and($fileContents)->toContain('@method static void dispatch(string $type)') - ->and($fileContents)->toContain('@method static void dispatchOn(string $queue, string $type)'); - }); - - it('identifies and documents correct multiple parameters', function() { - Artisan::call(MakeActionCommand::class, ['name' => 'Test6', '--dispatchable' => true]); - $actionPath = join_paths(app_path(), 'Actions', 'Test6.php'); - - File::replaceInFile("public function handle(): void", "public function handle(string \$type, Test6 \$action): array", $actionPath); - - $this->artisan(ActionsIdeHelperCommand::class, [ - '--namespace' => 'App\\Actions', - '--dry-run' => true, - ]) - ->assertExitCode(0) - ->expectsOutputToContain('@method static array run(string $type, \\App\\Actions\\Test6 $action)') - ->expectsOutputToContain('@method static void dispatch(string $type, \\App\\Actions\\Test6 $action)') - ->expectsOutputToContain('@method static void dispatchOn(string $queue, string $type, \\App\\Actions\\Test6 $action)'); - - $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) - ->assertExitCode(0); - - $fileContents = file_get_contents($actionPath); - expect($fileContents)->toContain('@method static array run(string $type, \\App\\Actions\\Test6 $action)') - ->and($fileContents)->toContain('@method static void dispatch(string $type, \\App\\Actions\\Test6 $action)') - ->and($fileContents)->toContain('@method static void dispatchOn(string $queue, string $type, \\App\\Actions\\Test6 $action)'); - }); - - it('correctly uses usings for short-class usage', function() { - Artisan::call(MakeActionCommand::class, ['name' => 'Test7', '--dispatchable' => true]); - Artisan::call(MakeActionCommand::class, ['name' => sprintf('DifferentDir%sTest8', DIRECTORY_SEPARATOR), '--dispatchable' => true]); - Artisan::call(MakeActionCommand::class, ['name' => sprintf('AnotherFolder%sTest9', DIRECTORY_SEPARATOR), '--dispatchable' => true]); - $actionPath = join_paths(app_path(), 'Actions', 'Test7.php'); - - File::replaceInFile( - "use LumoSolutions\\Actionable\\Traits\\IsDispatchable;", - implode(PHP_EOL, [ - "use LumoSolutions\\Actionable\\Traits\\IsDispatchable;", - "use App\\Actions\\Testing\\Test8;", - "use App\\Actions\\AnotherFolder\\Test9 as DoesWork;" - ]), - $actionPath - ); - File::replaceInFile( - "public function handle(): void", - "public function handle(Test8 \$action, DoesWork \$test): void", - $actionPath - ); - - $this->artisan(ActionsIdeHelperCommand::class, [ - '--namespace' => 'App\\Actions', - '--dry-run' => true, - ]) - ->assertExitCode(0) - ->expectsOutputToContain('@method static void run(Test8 $action, DoesWork $test)') - ->expectsOutputToContain('@method static void dispatch(Test8 $action, DoesWork $test)') - ->expectsOutputToContain('@method static void dispatchOn(string $queue, Test8 $action, DoesWork $test)'); - - $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) - ->assertExitCode(0); - - $fileContents = file_get_contents($actionPath); - expect($fileContents)->toContain('@method static void run(Test8 $action, DoesWork $test)') - ->and($fileContents)->toContain('@method static void dispatch(Test8 $action, DoesWork $test)') - ->and($fileContents)->toContain('@method static void dispatchOn(string $queue, Test8 $action, DoesWork $test)'); - }); - - it('correctly handles default values', function() { - Artisan::call(MakeActionCommand::class, ['name' => 'Test10', '--dispatchable' => true]); - $actionPath = join_paths(app_path(), 'Actions', 'Test10.php'); - - File::replaceInFile( - "public function handle(): void", - "public function handle(string \$default = 'test', string \$can_null = null, array \$arr = []): void", - $actionPath - ); - - $this->artisan(ActionsIdeHelperCommand::class, [ - '--namespace' => 'App\\Actions', - '--dry-run' => true, - ]) - ->assertExitCode(0) - ->expectsOutputToContain('@method static void run(string $default = \'test\', ?string $can_null = null, array $arr = [])') - ->expectsOutputToContain('@method static void dispatch(string $default = \'test\', ?string $can_null = null, array $arr = [])') - ->expectsOutputToContain('@method static void dispatchOn(string $queue, string $default = \'test\', ?string $can_null = null, array $arr = [])'); - }); - - it('correctly updates existing docblocks', function() { - Artisan::call(MakeActionCommand::class, ['name' => 'Test11', '--dispatchable' => true]); - $actionPath = join_paths(app_path(), 'Actions', 'Test11.php'); - - File::replaceInFile( - "class Test11", - implode(PHP_EOL, [ - "/**", - " * @method static void leave_this_one(string \$valid)", - " * @method static void run(string \$invalid)", - " * @method static void dispatch(string \$invalid)", - " * @method static void dispatchOn(string \$queue, string \$invalid)", - " */", - "class Test11", - ]), - $actionPath - ); - - $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) - ->assertExitCode(0); - - $fileContents = file_get_contents($actionPath); - expect($fileContents)->toContain('@method static void leave_this_one(string $valid)') - ->and($fileContents)->not->toContain('@method static void run(string $invalid)') - ->and($fileContents)->toContain('@method static void run()') - ->and($fileContents)->not->toContain('@method static void dispatch(string $invalid)') - ->and($fileContents)->toContain('@method static void dispatch()') - ->and($fileContents)->not->toContain('@method static void dispatchOn(string $queue, string $invalid)') - ->and($fileContents)->toContain('@method static void dispatchOn(string $queue)'); - }); - - it('correctly ignores non-action files', function() { - Artisan::call(MakeActionCommand::class, ['name' => 'Test12']); - $actionPath = join_paths(app_path(), 'Actions', 'Test12.php'); - - File::replaceInFile("use IsRunnable;", "", $actionPath); - - $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) - ->assertExitCode(0); - - $fileContents = file_get_contents($actionPath); - expect($fileContents)->not->toContain('@method static void run') - ->and($fileContents)->not->toContain('@method static void dispatch') - ->and($fileContents)->not->toContain('@method static void dispatchOn'); - }); - - it('handles no files', function() { - $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions\\DoesNotExist']) - ->assertExitCode(1); - }); - - it('handles action without handle method', function() { - Artisan::call(MakeActionCommand::class, ['name' => 'Test13']); - $actionPath = join_paths(app_path(), 'Actions', 'Test13.php'); - - File::replaceInFile("public function handle(): void", "public function something_else(): void", $actionPath); - - $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) - ->assertExitCode(0); - - $fileContents = file_get_contents($actionPath); - expect($fileContents)->not->toContain('@method static void run'); - }); - - }); +describe('Console', function () { + describe('ActionsIdeHelperCommand', function () { + it('identifies action', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test1']); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('Scanning for Action classes in namespace: App\\Actions') + ->expectsOutputToContain(join_paths('app', 'Actions', 'Test1.php')); + }); + + it('identifies and documents run in action', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test2']); + $actionPath = join_paths(app_path(), 'Actions', 'Test2.php'); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('@method static void run()') + ->doesntExpectOutputToContain('@method static void dispatch') + ->doesntExpectOutputToContain('@method static void dispatchOn'); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->toContain('@method static void run()') + ->and($fileContents)->not->toContain('@method static void dispatch') + ->and($fileContents)->not->toContain('@method static void dispatchOn'); + }); + + it('identifies and documents dispatch in action', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test3', '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test3.php'); + + File::replaceInFile('use IsRunnable, IsDispatchable;', 'use IsDispatchable;', $actionPath); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->doesntExpectOutputToContain('@method static void run()') + ->expectsOutputToContain('@method static void dispatch()') + ->expectsOutputToContain('@method static void dispatchOn(string $queue)'); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->not->toContain('@method static void run()') + ->and($fileContents)->toContain('@method static void dispatch()') + ->and($fileContents)->toContain('@method static void dispatchOn(string $queue)'); + }); + + it('identifies and documents both run and dispatch together', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test4', '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test4.php'); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('@method static void run()') + ->expectsOutputToContain('@method static void dispatch()') + ->expectsOutputToContain('@method static void dispatchOn(string $queue)'); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->toContain('@method static void run()') + ->and($fileContents)->toContain('@method static void dispatch()') + ->and($fileContents)->toContain('@method static void dispatchOn(string $queue)'); + }); + + it('identifies and documents correct single parameters', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test5', '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test5.php'); + + File::replaceInFile('public function handle(): void', 'public function handle(string $type): array', $actionPath); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('@method static array run(string $type)') + ->expectsOutputToContain('@method static void dispatch(string $type)') + ->expectsOutputToContain('@method static void dispatchOn(string $queue, string $type)'); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->toContain('@method static array run(string $type)') + ->and($fileContents)->toContain('@method static void dispatch(string $type)') + ->and($fileContents)->toContain('@method static void dispatchOn(string $queue, string $type)'); + }); + + it('identifies and documents correct multiple parameters', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test6', '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test6.php'); + + File::replaceInFile('public function handle(): void', 'public function handle(string $type, Test6 $action): array', $actionPath); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('@method static array run(string $type, \\App\\Actions\\Test6 $action)') + ->expectsOutputToContain('@method static void dispatch(string $type, \\App\\Actions\\Test6 $action)') + ->expectsOutputToContain('@method static void dispatchOn(string $queue, string $type, \\App\\Actions\\Test6 $action)'); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->toContain('@method static array run(string $type, \\App\\Actions\\Test6 $action)') + ->and($fileContents)->toContain('@method static void dispatch(string $type, \\App\\Actions\\Test6 $action)') + ->and($fileContents)->toContain('@method static void dispatchOn(string $queue, string $type, \\App\\Actions\\Test6 $action)'); + }); + + it('correctly uses usings for short-class usage', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test7', '--dispatchable' => true]); + Artisan::call(MakeActionCommand::class, ['name' => sprintf('DifferentDir%sTest8', DIRECTORY_SEPARATOR), '--dispatchable' => true]); + Artisan::call(MakeActionCommand::class, ['name' => sprintf('AnotherFolder%sTest9', DIRECTORY_SEPARATOR), '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test7.php'); + + File::replaceInFile( + 'use LumoSolutions\\Actionable\\Traits\\IsDispatchable;', + implode(PHP_EOL, [ + 'use LumoSolutions\\Actionable\\Traits\\IsDispatchable;', + 'use App\\Actions\\Testing\\Test8;', + 'use App\\Actions\\AnotherFolder\\Test9 as DoesWork;', + ]), + $actionPath + ); + File::replaceInFile( + 'public function handle(): void', + 'public function handle(Test8 $action, DoesWork $test): void', + $actionPath + ); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('@method static void run(Test8 $action, DoesWork $test)') + ->expectsOutputToContain('@method static void dispatch(Test8 $action, DoesWork $test)') + ->expectsOutputToContain('@method static void dispatchOn(string $queue, Test8 $action, DoesWork $test)'); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->toContain('@method static void run(Test8 $action, DoesWork $test)') + ->and($fileContents)->toContain('@method static void dispatch(Test8 $action, DoesWork $test)') + ->and($fileContents)->toContain('@method static void dispatchOn(string $queue, Test8 $action, DoesWork $test)'); + }); + + it('correctly handles default values', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test10', '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test10.php'); + + File::replaceInFile( + 'public function handle(): void', + "public function handle(string \$default = 'test', string \$can_null = null, array \$arr = []): void", + $actionPath + ); + + $this->artisan(ActionsIdeHelperCommand::class, [ + '--namespace' => 'App\\Actions', + '--dry-run' => true, + ]) + ->assertExitCode(0) + ->expectsOutputToContain('@method static void run(string $default = \'test\', ?string $can_null = null, array $arr = [])') + ->expectsOutputToContain('@method static void dispatch(string $default = \'test\', ?string $can_null = null, array $arr = [])') + ->expectsOutputToContain('@method static void dispatchOn(string $queue, string $default = \'test\', ?string $can_null = null, array $arr = [])'); + }); + + it('correctly updates existing docblocks', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test11', '--dispatchable' => true]); + $actionPath = join_paths(app_path(), 'Actions', 'Test11.php'); + + File::replaceInFile( + 'class Test11', + implode(PHP_EOL, [ + '/**', + ' * @method static void leave_this_one(string $valid)', + ' * @method static void run(string $invalid)', + ' * @method static void dispatch(string $invalid)', + ' * @method static void dispatchOn(string $queue, string $invalid)', + ' */', + 'class Test11', + ]), + $actionPath + ); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->toContain('@method static void leave_this_one(string $valid)') + ->and($fileContents)->not->toContain('@method static void run(string $invalid)') + ->and($fileContents)->toContain('@method static void run()') + ->and($fileContents)->not->toContain('@method static void dispatch(string $invalid)') + ->and($fileContents)->toContain('@method static void dispatch()') + ->and($fileContents)->not->toContain('@method static void dispatchOn(string $queue, string $invalid)') + ->and($fileContents)->toContain('@method static void dispatchOn(string $queue)'); + }); + + it('correctly ignores non-action files', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test12']); + $actionPath = join_paths(app_path(), 'Actions', 'Test12.php'); + + File::replaceInFile('use IsRunnable;', '', $actionPath); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->not->toContain('@method static void run') + ->and($fileContents)->not->toContain('@method static void dispatch') + ->and($fileContents)->not->toContain('@method static void dispatchOn'); + }); + + it('handles no files', function () { + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions\\DoesNotExist']) + ->assertExitCode(1); + }); + + it('handles action without handle method', function () { + Artisan::call(MakeActionCommand::class, ['name' => 'Test13']); + $actionPath = join_paths(app_path(), 'Actions', 'Test13.php'); + + File::replaceInFile('public function handle(): void', 'public function something_else(): void', $actionPath); + + $this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions']) + ->assertExitCode(0); + + $fileContents = file_get_contents($actionPath); + expect($fileContents)->not->toContain('@method static void run'); + }); + + }); }); diff --git a/tests/TestCase.php b/tests/TestCase.php index 66b68ae..f16222a 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -20,7 +20,7 @@ protected function tearDown(): void $directories = [ app_path('Actions'), app_path('Dtos'), - join_paths(base_path(), 'stubs', 'lumosolutions', 'actionable') + join_paths(base_path(), 'stubs', 'lumosolutions', 'actionable'), ]; foreach ($directories as $dir) { From 73b8f68b1fcd9f1ae969343b722e7dd24652ba10 Mon Sep 17 00:00:00 2001 From: Richard Anderson Date: Mon, 26 May 2025 10:13:54 +0100 Subject: [PATCH 4/4] readme update --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index d15a30c..7ee9815 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,9 @@ Execute business logic with a single, expressive call. No more hunting through s ### 📬 **Dispatchable Actions** Seamlessly queue your actions for background processing. It's as easy as changing `run()` to `dispatch()`! +### 💡 **Smart Code Completion** +Full IntelliSense support with auto-completion for runnable and dispatchable actions across all major IDEs. + ### 🔄 **Smart Array Conversion** Convert between arrays and objects effortlessly with our powerful attribute system. Perfect for APIs! @@ -247,6 +250,9 @@ php artisan make:action CalculateShipping --invokable # DTO with array conversion php artisan make:dto OrderData + +# Enable Smart Code Completion +php artisan ide-helper:actions ``` ## 🌟 Real-World Examples