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
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..c6c6291
--- /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 = str_replace(base_path().DIRECTORY_SEPARATOR, '', $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..acceadb
--- /dev/null
+++ b/src/Services/ActionDocBlockService.php
@@ -0,0 +1,337 @@
+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']
+ );
+
+ $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 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;
+ 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)) {
+ $this->ensureClassIsLoaded($fullClassName, $filePath);
+ }
+
+ $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
+ {
+ $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,
+ ];
+ }
+
+ 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;
+ }
+ }
+
+ 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 ? ", {$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);
+ }
+ }
+}
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..4244192
--- /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..f16222a 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