diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 2d3b81c..aa765bd 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -18,7 +18,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: '8.2'
+ php-version: '8.3'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
- name: Cache Composer dependencies
@@ -44,7 +44,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: '8.2'
+ php-version: '8.3'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
- name: Cache Composer dependencies
@@ -70,7 +70,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: '8.2'
+ php-version: '8.3'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv
- name: Cache Composer dependencies
@@ -96,7 +96,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
- php-version: '8.2'
+ php-version: '8.3'
extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, xdebug
coverage: xdebug
diff --git a/composer.json b/composer.json
index 895e33a..8583f5d 100644
--- a/composer.json
+++ b/composer.json
@@ -25,6 +25,7 @@
},
"autoload-dev": {
"psr-4": {
+ "App\\": "vendor/orchestra/testbench-core/laravel/app",
"LumoSolutions\\Actionable\\Tests\\": "tests/"
}
},
@@ -37,7 +38,7 @@
"format": "vendor/bin/pint"
},
"require": {
- "php": ">=8.2"
+ "php": ">=8.3"
},
"require-dev": {
"larastan/larastan": "^2.9||^3.0",
diff --git a/src/Actions/Console/UpdateActionDocBlocks.php b/src/Actions/Console/UpdateActionDocBlocks.php
new file mode 100644
index 0000000..ae05299
--- /dev/null
+++ b/src/Actions/Console/UpdateActionDocBlocks.php
@@ -0,0 +1,123 @@
+findClassesInNamespace($namespace);
+ $results = [];
+
+ foreach ($classes as $className) {
+ try {
+ $diff = $this->processClass($className, $dryRun);
+
+ if (! empty($diff)) {
+ $results[$className] = $diff;
+ }
+ } catch (Exception $e) {
+ continue;
+ }
+ }
+
+ return $results;
+ }
+
+ public function findClassesInNamespace(string $namespace): array
+ {
+ $classes = [];
+ $namespacePath = str_replace('\\', '/', $namespace);
+
+ $searchPaths = [];
+ if (str_starts_with($namespace, 'App')) {
+ $relativePath = str_replace('App', '', $namespacePath);
+ $relativePath = ltrim($relativePath, '/');
+ $searchPaths[] = app_path($relativePath);
+ }
+
+ $searchPaths[] = base_path('src/'.$namespacePath);
+ $searchPaths[] = base_path($namespacePath);
+
+ foreach ($searchPaths as $path) {
+ if (! is_dir($path)) {
+ continue;
+ }
+
+ $files = File::allFiles($path);
+ foreach ($files as $file) {
+ if ($file->getExtension() !== 'php') {
+ continue;
+ }
+
+ $relativePath = $file->getRelativePathname();
+ $relativePath = str_replace(['/', '.php'], ['\\', ''], $relativePath);
+ $classes[] = $namespace.$relativePath;
+ }
+ }
+
+ return array_unique($classes);
+ }
+
+ public function processClass(string $className, bool $dryRun): array|bool
+ {
+ // Analyze the class
+ $data = rescue(
+ fn () => $this->classAnalyser->analyse($className),
+ fn ($e) => null,
+ false
+ );
+
+ if ($data == null) {
+ return false;
+ }
+
+ $actionableTraits = collect($data->traits)
+ ->filter(fn ($trait) => $trait->namespace === 'LumoSolutions\\Actionable\\Traits');
+
+ if ($actionableTraits->isEmpty()) {
+ return $dryRun ? [] : false;
+ }
+
+ $currentDocBlocks = ! empty($data->docBlock)
+ ? DocBlockHelper::extract($data->docBlock)
+ : [];
+
+ $newBlocks = GenerateDocBlocks::run(
+ new DocBlockGenDto(
+ isRunnable: (bool) $actionableTraits->firstWhere('name', 'IsRunnable'),
+ isDispatchable: (bool) $actionableTraits->firstWhere('name', 'IsDispatchable'),
+ handle: collect($data->methods)->firstWhere('name', 'handle'),
+ docBlocks: $currentDocBlocks,
+ usings: $data->includes ?? []
+ )
+ );
+
+ return UpdateClassDocBlock::run(
+ new DocBlockUpdateDto(
+ filePath: $data->filePath,
+ className: $data->className,
+ currentDocBlocks: $currentDocBlocks,
+ newDocBlocks: $newBlocks
+ ),
+ $dryRun
+ );
+ }
+}
diff --git a/src/Actions/Generation/GenerateDocBlocks.php b/src/Actions/Generation/GenerateDocBlocks.php
new file mode 100644
index 0000000..00b155b
--- /dev/null
+++ b/src/Actions/Generation/GenerateDocBlocks.php
@@ -0,0 +1,89 @@
+docBlocks);
+
+ $processor->removeMethodsIf(self::METHOD_RUN, ! $dto->isRunnable || ! $dto->handle);
+ $processor->removeMethodsIf(self::METHOD_DISPATCH, ! $dto->isDispatchable || ! $dto->handle);
+ $processor->removeMethodsIf(self::METHOD_DISPATCH_ON, ! $dto->isDispatchable || ! $dto->handle);
+
+ if ($dto->handle) {
+ if ($dto->isRunnable) {
+ $processor->addOrReplaceMethod(self::METHOD_RUN, $this->buildRunMethod($dto));
+ }
+
+ if ($dto->isDispatchable) {
+ $processor->addOrReplaceMethod(self::METHOD_DISPATCH, $this->buildDispatchMethod($dto));
+ $processor->addOrReplaceMethod(self::METHOD_DISPATCH_ON, $this->buildDispatchOnMethod($dto));
+ }
+ }
+
+ return $processor->getDocBlocks();
+ }
+
+ protected function buildRunMethod(DocBlockGenDto $dto): ?string
+ {
+ return DocBlockHelper::buildMethodLine(
+ 'static',
+ DocBlockHelper::formatReturnType(
+ $dto->handle->returnTypes,
+ $dto->usings
+ ),
+ self::METHOD_RUN,
+ DocBlockHelper::formatParameters(
+ $dto->handle->parameters,
+ $dto->usings
+ )
+ );
+ }
+
+ protected function buildDispatchMethod(DocBlockGenDto $dto): ?string
+ {
+ return DocBlockHelper::buildMethodLine(
+ 'static',
+ 'void',
+ self::METHOD_DISPATCH,
+ DocBlockHelper::formatParameters(
+ $dto->handle->parameters,
+ $dto->usings
+ )
+ );
+ }
+
+ protected function buildDispatchOnMethod(DocBlockGenDto $dto): ?string
+ {
+ $parameters = DocBlockHelper::formatParameters($dto->handle->parameters, $dto->usings);
+ $queueParameter = 'string $queue';
+
+ return DocBlockHelper::buildMethodLine(
+ 'static',
+ 'void',
+ self::METHOD_DISPATCH_ON,
+ $parameters
+ ? $queueParameter.', '.$parameters
+ : $queueParameter
+ );
+ }
+}
diff --git a/src/Actions/Generation/UpdateClassDocBlock.php b/src/Actions/Generation/UpdateClassDocBlock.php
new file mode 100644
index 0000000..b023946
--- /dev/null
+++ b/src/Actions/Generation/UpdateClassDocBlock.php
@@ -0,0 +1,137 @@
+currentDocBlocks == $dto->newDocBlocks) {
+ return $dryRun ? [] : false;
+ }
+
+ if ($dryRun) {
+ return $this->calculateDiff($dto->currentDocBlocks, $dto->newDocBlocks);
+ }
+
+ return $this->updateFile($dto);
+ }
+
+ protected function calculateDiff(array $current, array $new): array
+ {
+ $diff = [];
+
+ $removed = array_diff($current, $new);
+ foreach ($removed as $line) {
+ $diff[] = [
+ 'type' => '-',
+ 'line' => $line,
+ ];
+ }
+
+ $added = array_diff($new, $current);
+ foreach ($added as $line) {
+ $diff[] = [
+ 'type' => '+',
+ 'line' => $line,
+ ];
+ }
+
+ return $diff;
+ }
+
+ protected function updateFile(DocBlockUpdateDto $dto): bool
+ {
+ $fileContent = file_get_contents($dto->filePath);
+
+ $classPattern = '/^([ \t]*)((?:abstract\s+|final\s+)?class\s+'.preg_quote($dto->className, '/').'\b)/m';
+
+ preg_match($classPattern, $fileContent, $matches, PREG_OFFSET_CAPTURE);
+ $classDeclarationOffset = $matches[0][1];
+ $indentation = $matches[1][0];
+
+ $newDocBlock = $this->buildDocBlock($dto->newDocBlocks, $indentation);
+ $newDocBlock = $newDocBlock !== null
+ ? $newDocBlock."\n".$indentation
+ : $indentation;
+
+ $existingDocBlockPattern = '/(\/\*\*.*?\*\/)\s*\n\s*(?=(?:abstract\s+|final\s+)?class\s+'.preg_quote($dto->className, '/').'\b)/s';
+
+ if (preg_match($existingDocBlockPattern, $fileContent, $docBlockMatch, PREG_OFFSET_CAPTURE)) {
+ // Replace existing docblock
+ $fileContent = substr_replace(
+ $fileContent,
+ $newDocBlock,
+ $docBlockMatch[0][1],
+ strlen($docBlockMatch[0][0])
+ );
+ } else {
+ $fileContent = substr_replace(
+ $fileContent,
+ $newDocBlock,
+ $classDeclarationOffset,
+ 0
+ );
+ }
+
+ return file_put_contents($dto->filePath, $fileContent) !== false;
+ }
+
+ protected function buildDocBlock(array $docBlockLines, string $indentation): ?string
+ {
+ if (empty($docBlockLines)) {
+ return null;
+ }
+
+ $lines = [];
+
+ $firstLine = trim($docBlockLines[0]);
+ if (str_starts_with($firstLine, '/**')) {
+ $firstLine = trim(substr($firstLine, 3));
+ }
+
+ if (str_ends_with($firstLine, '*/')) {
+ $firstLine = trim(substr($firstLine, 0, -2));
+ }
+
+ if (! empty($firstLine) && ! str_starts_with($firstLine, '@')) {
+ $line = $indentation.'/** '.$firstLine;
+ if (count($docBlockLines) == 1) {
+ $line .= ' */';
+
+ return $line;
+ }
+ $lines[] = $line;
+ } elseif (! empty($firstLine)) {
+ $lines[] = $indentation.'/** ';
+ $lines[] = $indentation.' * '.$firstLine;
+ } else {
+ $lines[] = $indentation.'/** ';
+ }
+ $startIndex = 1;
+
+ for ($i = $startIndex; $i < count($docBlockLines); $i++) {
+ $trimmedLine = trim($docBlockLines[$i]);
+
+ if (str_starts_with($trimmedLine, '/**')) {
+ $trimmedLine = trim(substr($trimmedLine, 3));
+ }
+ if (str_ends_with($trimmedLine, '*/')) {
+ $trimmedLine = trim(substr($trimmedLine, 0, -2));
+ }
+
+ if (! empty($trimmedLine)) {
+ $lines[] = $indentation.' * '.$trimmedLine;
+ }
+ }
+
+ $lines[] = $indentation.' */';
+
+ return implode("\n", $lines);
+ }
+}
diff --git a/src/Attributes/Ignore.php b/src/Attributes/Ignore.php
index bb2a327..4658305 100644
--- a/src/Attributes/Ignore.php
+++ b/src/Attributes/Ignore.php
@@ -4,5 +4,5 @@
use Attribute;
-#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER)]
+#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_PARAMETER | Attribute::TARGET_METHOD)]
readonly class Ignore {}
diff --git a/src/Console/Commands/ActionsIdeHelperCommand.php b/src/Console/Commands/ActionsIdeHelperCommand.php
index c6c6291..28ee97e 100644
--- a/src/Console/Commands/ActionsIdeHelperCommand.php
+++ b/src/Console/Commands/ActionsIdeHelperCommand.php
@@ -2,10 +2,8 @@
namespace LumoSolutions\Actionable\Console\Commands;
-use Exception;
use Illuminate\Console\Command;
-use Illuminate\Support\Facades\File;
-use LumoSolutions\Actionable\Services\ActionDocBlockService;
+use LumoSolutions\Actionable\Actions\Console\UpdateActionDocBlocks;
class ActionsIdeHelperCommand extends Command
{
@@ -15,113 +13,85 @@ class ActionsIdeHelperCommand extends Command
protected $description = 'Generate IDE helper doc blocks for Action classes using IsRunnable and IsDispatchable traits';
- protected ActionDocBlockService $service;
-
- public function __construct(ActionDocBlockService $service)
- {
- parent::__construct();
- $this->service = $service;
- }
-
public function handle(): int
{
- $namespace = $this->option('namespace');
+ $namespace = rtrim($this->option('namespace'), '\\').'\\';
$dryRun = $this->option('dry-run');
- $this->info("Scanning for Action classes in namespace: {$namespace} ");
+ $this->info('Scanning actions in namespace: '.$namespace.($dryRun ? ' (dry-run mode)' : ' '));
+ $this->newLine();
- $files = $this->getPhpFiles($namespace);
+ $response = UpdateActionDocBlocks::run($namespace, $dryRun);
- if (empty($files)) {
- $this->error("No PHP files found in namespace: {$namespace}");
+ if (empty($response)) {
+ $this->info('No actions found or no changes needed.');
- return self::FAILURE;
+ return self::SUCCESS;
}
- $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());
- }
- }
+ if ($dryRun) {
+ $this->displayDryRunResults($response);
+ } else {
+ $this->displayUpdateResults($response);
}
- $this->showSummary($processedCount, $skippedCount, $errorCount, $dryRun);
-
- return ($errorCount > 0) ? self::FAILURE : self::SUCCESS;
+ return self::SUCCESS;
}
- private function getPhpFiles(string $namespace): array
+ /**
+ * Display the results when in dry-run mode
+ */
+ protected function displayDryRunResults(array $response): void
{
- $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();
- }
+ $this->comment('🔍 Dry-run mode - No files will be modified');
+ $this->newLine();
+
+ $totalChanges = 0;
+
+ foreach ($response as $className => $changes) {
+ $changeCount = count($changes);
+ $totalChanges += $changeCount;
+
+ $this->line("$className> ($changeCount changes>)");
+
+ foreach ($changes as $change) {
+ $type = $change['type'];
+ $line = $change['line'];
+
+ switch ($type) {
+ case '+':
+ $this->line(" + $line>");
+ break;
+ case '-':
+ $this->line(" - $line>");
+ break;
+ default:
+ $this->line(" $type $line");
+ }
+ }
- private function showDocBlockChanges(array $docBlocks): void
- {
- if (empty($docBlocks)) {
- return;
+ $this->newLine();
}
- $this->line(' Doc blocks to add:');
- foreach ($docBlocks as $docBlock) {
- $this->line(" * {$docBlock}");
- }
+ $this->info("📊 Summary: $totalChanges changes would be made across ".count($response).' files');
+ $this->comment('Run without --dry-run to apply these changes.');
}
- private function showSummary(int $processedCount, int $skippedCount, int $errorCount, bool $dryRun): void
+ /**
+ * Display the results after actual updates
+ */
+ protected function displayUpdateResults(array $response): void
{
- $this->line('');
- $this->info('Summary:');
-
- $action = $dryRun ? 'Would be updated' : 'Updated';
- $this->line(" {$action}: {$processedCount} files");
+ $successful = 0;
- if ($skippedCount > 0 || $this->output->isVerbose()) {
- $this->line(" Skipped: {$skippedCount} files");
- }
-
- if ($errorCount > 0) {
- $this->line(" Errors: {$errorCount} files");
+ foreach ($response as $className => $result) {
+ if ($result === true) {
+ $successful++;
+ $this->line("✓> $className");
+ }
}
- $this->line('');
- $status = $errorCount > 0 ? 'completed with errors' : 'completed successfully';
- $this->info("Process {$status}!");
+ $this->newLine();
+ $this->info("✨ Successfully updated $successful action files!");
}
}
diff --git a/src/Dtos/Class/MetadataDto.php b/src/Dtos/Class/MetadataDto.php
new file mode 100644
index 0000000..d152cc3
--- /dev/null
+++ b/src/Dtos/Class/MetadataDto.php
@@ -0,0 +1,26 @@
+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/src/Support/ClassAnalyser.php b/src/Support/ClassAnalyser.php
new file mode 100644
index 0000000..2d8a3b5
--- /dev/null
+++ b/src/Support/ClassAnalyser.php
@@ -0,0 +1,222 @@
+getReflection($className);
+
+ return new MetadataDto(
+ className: $reflection->getShortName(),
+ namespace: $reflection->getNamespaceName() ?: '',
+ filePath: $reflection->getFileName() ?: '',
+ docBlock: $reflection->getDocComment() ?: null,
+ extends: $this->getParentClass($reflection),
+ includes: $this->getIncludes($reflection),
+ traits: $this->getTraits($reflection),
+ methods: $this->getMethods($reflection)
+ );
+ }
+
+ /**
+ * Get a ReflectionClass instance for the given class name.
+ *
+ * @param string $className Fully qualified class name
+ *
+ * @throws InvalidArgumentException|ReflectionException if the class does not exist or cannot be reflected
+ */
+ private function getReflection(string $className): ReflectionClass
+ {
+ return rescue(
+ fn () => new ReflectionClass($className),
+ fn ($e) => null,
+ );
+ }
+
+ /**
+ * Get the parent class of the given ReflectionClass.
+ */
+ public function getParentClass(ReflectionClass $reflection): ?RelationDto
+ {
+ $parentClass = $reflection->getParentClass();
+ if (! $parentClass) {
+ return null;
+ }
+
+ return new RelationDto(
+ name: $parentClass->getShortName(),
+ namespace: $parentClass->getNamespaceName(),
+ );
+ }
+
+ /**
+ * Get all use statements (includes) from the class file.
+ *
+ * @return RelationDto[]
+ */
+ public function getIncludes(ReflectionClass $reflection): array
+ {
+ $filePath = $reflection->getFileName();
+ if (! $filePath) {
+ return [];
+ }
+
+ return $this->fileService->getUseStatements($filePath);
+ }
+
+ /**
+ * Get all traits used by the given ReflectionClass.
+ *
+ * @return RelationDto[]
+ */
+ public function getTraits(ReflectionClass $reflection): array
+ {
+ $traits = [];
+ foreach ($reflection->getTraits() as $trait) {
+ $traits[] = new RelationDto(
+ name: $trait->getShortName(),
+ namespace: $trait->getNamespaceName(),
+ );
+ }
+
+ return $traits;
+ }
+
+ /**
+ * Get all methods of the given ReflectionClass.
+ *
+ * @return MethodDto[]
+ */
+ public function getMethods(ReflectionClass $reflection): array
+ {
+ $methods = [];
+
+ foreach ($reflection->getMethods() as $method) {
+ $parameters = [];
+ foreach ($method->getParameters() as $index => $param) {
+ $rawType = $param->hasType()
+ ? $param->getType()->__toString()
+ : 'mixed';
+
+ $parameters[] = new ParameterDto(
+ name: $param->getName(),
+ rawType: $rawType,
+ types: $this->parseUnionType($rawType),
+ isOptional: $param->isOptional(),
+ isVariadic: $param->isVariadic(),
+ isReference: $param->isPassedByReference(),
+ hasDefaultValue: $param->isDefaultValueAvailable(),
+ defaultValue: $param->isDefaultValueAvailable() ? $param->getDefaultValue() : null,
+ position: $index,
+ );
+ }
+
+ $rawReturnType = $method->hasReturnType() ? $method->getReturnType()->__toString() : 'mixed';
+
+ // Handle constructors specially - they don't have explicit return types
+ if ($method->getName() === '__construct') {
+ $rawReturnType = 'void';
+ }
+
+ $methods[] = new MethodDto(
+ name: $method->getName(),
+ rawReturnType: $rawReturnType,
+ returnTypes: $this->parseUnionType($rawReturnType),
+ visibility: $this->getVisibility($method),
+ isStatic: $method->isStatic(),
+ isAbstract: $method->isAbstract(),
+ isFinal: $method->isFinal(),
+ parameters: $parameters
+ );
+ }
+
+ return $methods;
+ }
+
+ /**
+ * Get the visibility of a reflection member (property or method).
+ *
+ * @return string 'public', 'protected', or 'private'
+ */
+ private function getVisibility(ReflectionProperty|ReflectionMethod $member): string
+ {
+ return match (true) {
+ $member->isPrivate() => 'private',
+ $member->isProtected() => 'protected',
+ default => 'public'
+ };
+ }
+
+ /**
+ * Parse a union type string and return an array of RelationDto objects.
+ *
+ * @param string $type The union type string (e.g., "string|int|null")
+ * @return RelationDto[]
+ */
+ private function parseUnionType(string $type): array
+ {
+ $relations = [];
+
+ // Handle nullable types (e.g., ?string)
+ $isNullable = str_starts_with($type, '?');
+ $cleanType = ltrim($type, '?');
+
+ // Split union types (e.g., "string|int|null")
+ $typeList = explode('|', $cleanType);
+
+ foreach ($typeList as $singleType) {
+ $singleType = trim($singleType);
+
+ // Check if it's a built-in type
+ $builtInTypes = ['string', 'int', 'float', 'bool', 'array', 'object', 'mixed', 'void', 'null', 'callable', 'iterable'];
+ $isBuiltIn = in_array(strtolower($singleType), $builtInTypes);
+
+ // Parse namespace and class name
+ $namespace = null;
+ $className = $singleType;
+
+ if (! $isBuiltIn) {
+ if (str_contains($singleType, '\\')) {
+ $parts = explode('\\', $singleType);
+ $className = array_pop($parts);
+ $namespace = implode('\\', $parts);
+ }
+ }
+
+ // Determine if this specific type is nullable
+ $typeIsNullable = $isNullable || strtolower($singleType) === 'null';
+
+ $relations[] = new RelationDto(
+ name: $className,
+ namespace: $isBuiltIn ? null : $namespace,
+ isNullable: $typeIsNullable,
+ isBuiltIn: $isBuiltIn
+ );
+ }
+
+ return $relations;
+ }
+}
diff --git a/src/Support/DocBlockHelper.php b/src/Support/DocBlockHelper.php
new file mode 100644
index 0000000..c5628c3
--- /dev/null
+++ b/src/Support/DocBlockHelper.php
@@ -0,0 +1,220 @@
+ $line) {
+ if (preg_match($pattern, $line)) {
+ $matches[] = $index;
+ }
+ }
+
+ return $matches;
+ }
+
+ public static function buildMethodLine(string $visibility, string $returnType, string $methodName, string $parameters): string
+ {
+ return "@method {$visibility} {$returnType} {$methodName}({$parameters})";
+ }
+
+ public static function formatParameters(array $parameters, array $usings = []): string
+ {
+ $params = [];
+
+ foreach ($parameters as $parameter) {
+ $paramStr = '';
+
+ // Format the type using imports
+ if (! empty($parameter->rawType)) {
+ $paramStr .= self::formatTypeWithImports($parameter->rawType, $usings).' ';
+ }
+
+ // Add parameter name
+ $paramStr .= '$'.$parameter->name;
+
+ // Add default value if exists
+ if ($parameter->hasDefaultValue) {
+ $paramStr .= ' = '.self::formatDefaultValue($parameter->defaultValue);
+ }
+
+ $params[] = $paramStr;
+ }
+
+ return implode(', ', $params);
+ }
+
+ public static function formatReturnType(array $returnTypes, array $usings = []): string
+ {
+ if (empty($returnTypes)) {
+ return 'mixed';
+ }
+
+ $types = [];
+ foreach ($returnTypes as $returnType) {
+ // Use formatTypeWithImports for consistency
+ $formattedType = self::formatSingleType($returnType, $usings);
+
+ if ($returnType->isNullable && $returnType->name !== 'null') {
+ $types[] = $formattedType;
+ $types[] = 'null';
+ } else {
+ $types[] = $formattedType;
+ }
+ }
+
+ // Remove duplicates and join
+ $types = array_unique($types);
+
+ return implode('|', $types);
+ }
+
+ /**
+ * Format a raw type string considering imports
+ */
+ public static function formatTypeWithImports(string $rawType, array $usings = []): string
+ {
+ // Handle nullable types
+ $isNullable = str_starts_with($rawType, '?');
+ $cleanType = ltrim($rawType, '?');
+
+ // Handle union types
+ if (str_contains($cleanType, '|')) {
+ $types = explode('|', $cleanType);
+ $formattedTypes = [];
+
+ foreach ($types as $type) {
+ $type = trim($type);
+ $formattedTypes[] = self::formatSingleTypeString($type, $usings);
+ }
+
+ $result = implode('|', $formattedTypes);
+
+ return $isNullable ? '?'.$result : $result;
+ }
+
+ // Single type
+ $formatted = self::formatSingleTypeString($cleanType, $usings);
+
+ return $isNullable ? '?'.$formatted : $formatted;
+ }
+
+ /**
+ * Format a single type string
+ */
+ private static function formatSingleTypeString(string $type, array $usings = []): string
+ {
+ // Check if it's a built-in type
+ $builtInTypes = ['string', 'int', 'float', 'bool', 'array', 'object', 'mixed', 'void', 'null', 'callable', 'iterable', 'self', 'parent', 'static'];
+ if (in_array(strtolower($type), $builtInTypes)) {
+ return $type;
+ }
+
+ // If it doesn't contain a namespace separator, check if it's already imported
+ if (! str_contains($type, '\\')) {
+ return $type;
+ }
+
+ // Extract namespace and class name
+ $parts = explode('\\', $type);
+ $className = array_pop($parts);
+ $namespace = implode('\\', $parts);
+
+ // Remove leading backslash if present
+ $namespace = ltrim($namespace, '\\');
+
+ // Check if this type is imported
+ foreach ($usings as $using) {
+ if ($using instanceof RelationDto) {
+ $importedNamespace = ltrim($using->namespace ?? '', '\\');
+
+ // Check for exact match
+ if ($using->name === $className && $importedNamespace === $namespace) {
+ // Use alias if available, otherwise use the class name
+ return $using->alias ?? $className;
+ }
+ }
+ }
+
+ // Not imported, return with leading backslash
+ return '\\'.$type;
+ }
+
+ /**
+ * Format a single RelationDto type
+ */
+ private static function formatSingleType(RelationDto $type, array $usings = []): string
+ {
+ // Built-in types don't need formatting
+ if ($type->isBuiltIn) {
+ return $type->name;
+ }
+
+ // Build the full type name
+ $fullType = '';
+ if ($type->namespace) {
+ $fullType = $type->namespace.'\\'.$type->name;
+ } else {
+ $fullType = $type->name;
+ }
+
+ return self::formatSingleTypeString($fullType, $usings);
+ }
+
+ public static 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 '[]';
+ }
+
+ return (string) $value;
+ }
+}
diff --git a/src/Support/DocBlockProcessor.php b/src/Support/DocBlockProcessor.php
new file mode 100644
index 0000000..2a1e9b9
--- /dev/null
+++ b/src/Support/DocBlockProcessor.php
@@ -0,0 +1,64 @@
+docBlocks = $docBlocks;
+ }
+
+ public function removeMethodsIf(string $methodName, bool $condition): void
+ {
+ if ($condition) {
+ $this->removeMethod($methodName);
+ }
+ }
+
+ public function removeMethod(string $methodName): void
+ {
+ $indices = DocBlockHelper::findMethodLines($this->docBlocks, $methodName);
+
+ // Remove in reverse order to maintain indices
+ rsort($indices);
+ foreach ($indices as $index) {
+ unset($this->docBlocks[$index]);
+ }
+
+ // Re-index array
+ $this->docBlocks = array_values($this->docBlocks);
+ }
+
+ public function addOrReplaceMethod(string $methodName, ?string $methodLine): void
+ {
+ if ($methodLine === null) {
+ return;
+ }
+
+ $existingIndices = DocBlockHelper::findMethodLines($this->docBlocks, $methodName);
+
+ if (! empty($existingIndices)) {
+ // Replace the first occurrence
+ $this->docBlocks[$existingIndices[0]] = $methodLine;
+
+ // Remove any additional occurrences
+ for ($i = 1; $i < count($existingIndices); $i++) {
+ unset($this->docBlocks[$existingIndices[$i]]);
+ }
+
+ // Re-index array
+ $this->docBlocks = array_values($this->docBlocks);
+ } else {
+ // Add new method line
+ $this->docBlocks[] = $methodLine;
+ }
+ }
+
+ public function getDocBlocks(): array
+ {
+ return $this->docBlocks;
+ }
+}
diff --git a/src/Support/UseStatementParser.php b/src/Support/UseStatementParser.php
new file mode 100644
index 0000000..bc74a72
--- /dev/null
+++ b/src/Support/UseStatementParser.php
@@ -0,0 +1,250 @@
+readFileLines($filePath);
+
+ return $this->parseUseStatements($fileContent);
+ }
+
+ /**
+ * Read and validate the file content.
+ */
+ private function readFileLines(string $filePath): ?array
+ {
+ $content = file(
+ $filePath,
+ FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES
+ );
+
+ return $content !== false ? $content : null;
+ }
+
+ /**
+ * Parse use statements from file content.
+ *
+ * @return RelationDto[]
+ */
+ private function parseUseStatements(array $lines): array
+ {
+ $relations = [];
+
+ foreach ($lines as $line) {
+ $trimmedLine = trim($line);
+ if ($this->shouldSkipLine($trimmedLine)) {
+ continue;
+ }
+
+ if ($this->isClassDeclaration($trimmedLine)) {
+ break;
+ }
+
+ if ($this->isUseStatement($trimmedLine)) {
+ $useStatements = $this->extractUseStatement($trimmedLine);
+ $relations = array_merge($relations, $useStatements);
+ }
+ }
+
+ return $relations;
+ }
+
+ /**
+ * Check if a line should be skipped during parsing.
+ */
+ private function shouldSkipLine(string $line): bool
+ {
+ if (empty($line)) {
+ return true;
+ }
+
+ $commentPrefixes = ['//', '*', '/*'];
+ foreach ($commentPrefixes as $prefix) {
+ if (str_starts_with($line, $prefix)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Check if a line contains a class declaration.
+ */
+ private function isClassDeclaration(string $line): bool
+ {
+ return preg_match(self::CLASS_DECLARATION_PATTERN, $line) === 1;
+ }
+
+ /**
+ * Check if a line contains a use statement.
+ */
+ private function isUseStatement(string $line): bool
+ {
+ return preg_match(self::USE_STATEMENT_PATTERN, $line) === 1;
+ }
+
+ /**
+ * Extract and parse use statements from a line.
+ *
+ * @return RelationDto[]
+ */
+ private function extractUseStatement(string $line): array
+ {
+ if (! preg_match(self::USE_STATEMENT_PATTERN, $line, $matches)) {
+ return [];
+ }
+
+ $useStatement = trim($matches[1]);
+
+ // Handle grouped use statements: use Namespace\{ClassA, ClassB as B};
+ if ($this->isGroupedUseStatement($useStatement)) {
+ return $this->parseGroupedUseStatement($useStatement);
+ }
+
+ // Handle multiple imports in one line: use A, B, C;
+ if (str_contains($useStatement, ',')) {
+ return $this->parseMultipleUseStatements($useStatement);
+ }
+
+ // Handle single use statement
+ $relation = $this->parseSingleUseStatement($useStatement);
+
+ return $relation ? [$relation] : [];
+ }
+
+ /**
+ * Check if a use statement is grouped (contains curly braces).
+ */
+ private function isGroupedUseStatement(string $useStatement): bool
+ {
+ return preg_match(self::GROUPED_USE_PATTERN, $useStatement) === 1;
+ }
+
+ /**
+ * Parse grouped use statements like: Namespace\{ClassA, ClassB as B} or {DateTime, DateTimeImmutable}
+ *
+ * @return RelationDto[]
+ */
+ private function parseGroupedUseStatement(string $useStatement): array
+ {
+ if (! preg_match(self::GROUPED_USE_PATTERN, $useStatement, $matches)) {
+ return [];
+ }
+
+ $baseNamespace = trim($matches[1], '\\');
+ $groupedImports = trim($matches[2]);
+
+ $relations = [];
+ $imports = array_map('trim', explode(',', $groupedImports));
+
+ foreach ($imports as $import) {
+ // If there's no base namespace (global namespace), use the import as-is
+ $fullName = empty($baseNamespace) ? $import : $baseNamespace.'\\'.$import;
+ $relation = $this->parseSingleUseStatement($fullName);
+ if ($relation) {
+ $relations[] = $relation;
+ }
+ }
+
+ return $relations;
+ }
+
+ /**
+ * Parse multiple use statements separated by commas.
+ *
+ * @return RelationDto[]
+ */
+ private function parseMultipleUseStatements(string $useStatement): array
+ {
+ $imports = array_map('trim', explode(',', $useStatement));
+ $relations = [];
+
+ foreach ($imports as $import) {
+ $relation = $this->parseSingleUseStatement($import);
+ if ($relation) {
+ $relations[] = $relation;
+ }
+ }
+
+ return $relations;
+ }
+
+ /**
+ * Parse a single use statement and return a RelationDto.
+ */
+ private function parseSingleUseStatement(string $useStatement): ?RelationDto
+ {
+ $useStatement = trim($useStatement);
+
+ // Handle aliased imports: Full\Namespace\Class as Alias
+ if (preg_match(self::ALIASED_IMPORT_PATTERN, $useStatement, $matches)) {
+ $fullName = trim($matches[1]);
+ $alias = trim($matches[2]);
+
+ return $this->createRelationFromFullName($fullName, $alias);
+ }
+
+ // Handle regular imports: Full\Namespace\Class
+ return $this->createRelationFromFullName($useStatement, null);
+ }
+
+ /**
+ * Create a RelationDto from a full class name and optional alias.
+ */
+ private function createRelationFromFullName(string $fullName, ?string $alias): ?RelationDto
+ {
+ $fullName = trim($fullName, '\\');
+
+ if (empty($fullName)) {
+ return null;
+ }
+
+ [$namespace, $className] = $this->splitType($fullName);
+
+ return new RelationDto(
+ name: $className,
+ namespace: $namespace,
+ alias: $alias,
+ isNullable: false,
+ isBuiltIn: false
+ );
+ }
+
+ /**
+ * Split a full type name into namespace and class name.
+ *
+ * @return array{string|null, string}
+ */
+ private function splitType(string $fullName): array
+ {
+ if (! str_contains($fullName, '\\')) {
+ return [null, $fullName];
+ }
+
+ $parts = explode('\\', $fullName);
+ $className = array_pop($parts);
+ $namespace = implode('\\', $parts);
+
+ return [$namespace, $className];
+ }
+}
diff --git a/src/Traits/ArrayConvertible.php b/src/Traits/ArrayConvertible.php
index a2f0c56..01d94a5 100644
--- a/src/Traits/ArrayConvertible.php
+++ b/src/Traits/ArrayConvertible.php
@@ -2,6 +2,7 @@
namespace LumoSolutions\Actionable\Traits;
+use Illuminate\Support\Collection;
use LumoSolutions\Actionable\Conversion\DataConverter;
trait ArrayConvertible
@@ -15,4 +16,9 @@ public function toArray(): array
{
return DataConverter::toArray($this);
}
+
+ public function collect(): Collection
+ {
+ return collect($this->toArray());
+ }
}
diff --git a/tests/Feature/Console/ActionIdeHelperCommandTest.php b/tests/Feature/Console/ActionIdeHelperCommandTest.php
index 4244192..76a2257 100644
--- a/tests/Feature/Console/ActionIdeHelperCommandTest.php
+++ b/tests/Feature/Console/ActionIdeHelperCommandTest.php
@@ -15,8 +15,8 @@
'--dry-run' => true,
])
->assertExitCode(0)
- ->expectsOutputToContain('Scanning for Action classes in namespace: App\\Actions')
- ->expectsOutputToContain(join_paths('app', 'Actions', 'Test1.php'));
+ ->expectsOutputToContain('Scanning actions in namespace: App\\Actions\\')
+ ->expectsOutputToContain('App\\Actions\\Test1');
});
it('identifies and documents run in action', function () {
@@ -145,14 +145,14 @@
'use LumoSolutions\\Actionable\\Traits\\IsDispatchable;',
implode(PHP_EOL, [
'use LumoSolutions\\Actionable\\Traits\\IsDispatchable;',
- 'use App\\Actions\\Testing\\Test8;',
+ 'use App\\Actions\\DifferentDir\\Test8;',
'use App\\Actions\\AnotherFolder\\Test9 as DoesWork;',
]),
$actionPath
);
File::replaceInFile(
'public function handle(): void',
- 'public function handle(Test8 $action, DoesWork $test): void',
+ 'public function handle(Test8 $action, DoesWork $test): DoesWork',
$actionPath
);
@@ -161,7 +161,7 @@
'--dry-run' => true,
])
->assertExitCode(0)
- ->expectsOutputToContain('@method static void run(Test8 $action, DoesWork $test)')
+ ->expectsOutputToContain('@method static DoesWork 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)');
@@ -169,7 +169,7 @@
->assertExitCode(0);
$fileContents = file_get_contents($actionPath);
- expect($fileContents)->toContain('@method static void run(Test8 $action, DoesWork $test)')
+ expect($fileContents)->toContain('@method static DoesWork 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)');
});
@@ -180,7 +180,7 @@
File::replaceInFile(
'public function handle(): void',
- "public function handle(string \$default = 'test', string \$can_null = null, array \$arr = []): void",
+ "public function handle(string \$default = 'test', ?string \$can_null = null, array \$arr = []): void",
$actionPath
);
@@ -242,7 +242,7 @@
it('handles no files', function () {
$this->artisan(ActionsIdeHelperCommand::class, ['--namespace' => 'App\\Actions\\DoesNotExist'])
- ->assertExitCode(1);
+ ->assertExitCode(0);
});
it('handles action without handle method', function () {
@@ -258,5 +258,279 @@
expect($fileContents)->not->toContain('@method static void run');
});
+ it('handles notification of removed docblocks', function () {
+ Artisan::call(MakeActionCommand::class, ['name' => 'Test14', '--dispatchable' => true]);
+ $actionPath = join_paths(app_path(), 'Actions', 'Test14.php');
+
+ File::replaceInFile(
+ 'public function handle(): void',
+ 'public function handle(string $type): void',
+ $actionPath
+ );
+
+ File::replaceInFile(
+ 'class Test14',
+ implode(PHP_EOL, [
+ '/**',
+ ' * @method static void run(string $type_wrong)',
+ ' * @method static void dispatch(string $type_wrong)',
+ ' * @method static void dispatchOn(string $queue, string $type_wrong)',
+ ' */',
+ 'class Test14',
+ ]),
+ $actionPath
+ );
+
+ $this->artisan(ActionsIdeHelperCommand::class, [
+ '--namespace' => 'App\\Actions',
+ '--dry-run' => true]
+ )
+ ->assertExitCode(0)
+ ->expectsOutputToContain('- @method static void run(string $type_wrong)')
+ ->expectsOutputToContain('- @method static void dispatch(string $type_wrong)')
+ ->expectsOutputToContain('- @method static void dispatchOn(string $queue, string $type_wrong)')
+ ->expectsOutputToContain('+ @method static void run(string $type)')
+ ->expectsOutputToContain('+ @method static void dispatch(string $type)')
+ ->expectsOutputToContain('+ @method static void dispatchOn(string $queue, string $type)');
+ });
+
+ it('handles actions with no handle method', function () {
+ Artisan::call(MakeActionCommand::class, ['name' => 'Test15', '--dispatchable' => true]);
+ $actionPath = join_paths(app_path(), 'Actions', 'Test15.php');
+
+ File::replaceInFile(
+ 'public function handle(): void',
+ 'public function somethingElse(string $type): void',
+ $actionPath
+ );
+
+ $this->artisan(ActionsIdeHelperCommand::class, [
+ '--namespace' => 'App\\Actions',
+ '--dry-run' => true,
+ ])
+ ->assertExitCode(0)
+ ->expectsOutputToContain('No actions found or no changes needed.');
+ });
+
+ it('skips non php files', function () {
+ Artisan::call(MakeActionCommand::class, ['name' => 'Test16', '--dispatchable' => true]);
+ $actionPath = join_paths(app_path(), 'Actions', 'Test16.php');
+
+ File::move($actionPath, str_replace('.php', '.txt', $actionPath));
+
+ $this->artisan(ActionsIdeHelperCommand::class, [
+ '--namespace' => 'App\\Actions',
+ '--dry-run' => true,
+ ])
+ ->assertExitCode(0)
+ ->expectsOutputToContain('No actions found or no changes needed.');
+ });
+
+ it('handles text comments on a single line', function () {
+ Artisan::call(MakeActionCommand::class, ['name' => 'Test17', '--dispatchable' => true]);
+ $actionPath = join_paths(app_path(), 'Actions', 'Test17.php');
+
+ File::replaceInFile(
+ 'public function handle(): void',
+ 'public function something(string $type): void',
+ $actionPath
+ );
+
+ File::replaceInFile(
+ 'class Test17',
+ implode(PHP_EOL, [
+ '/** This is a test action',
+ ' * @method static void run(string $type)',
+ ' * @method static void dispatch(string $type)',
+ ' * @method static void dispatchOn(string $queue, string $type)',
+ ' */',
+ 'class Test17',
+ ]),
+ $actionPath
+ );
+
+ $this->artisan(ActionsIdeHelperCommand::class)
+ ->assertExitCode(0);
+
+ $content = file_get_contents($actionPath);
+ expect($content)->toContain('/** This is a test action */')
+ ->and($content)->not->toContain('@method static void run(string $type)')
+ ->and($content)->not->toContain('@method static void dispatch(string $type)')
+ ->and($content)->not->toContain('@method static void dispatchOn(string $queue, string $type)');
+ });
+
+ it('handles nullable return type', function () {
+ Artisan::call(MakeActionCommand::class, ['name' => 'Test18', '--dispatchable' => true]);
+ $actionPath = join_paths(app_path(), 'Actions', 'Test18.php');
+
+ File::replaceInFile(
+ 'public function handle(): void',
+ 'public function handle(?string $type): ?string',
+ $actionPath
+ );
+
+ $this->artisan(ActionsIdeHelperCommand::class, [
+ '--namespace' => 'App\\Actions',
+ '--dry-run' => true,
+ ])
+ ->assertExitCode(0)
+ ->expectsOutputToContain('@method static string|null run(?string $type)')
+ ->expectsOutputToContain('@method static void dispatch(?string $type)')
+ ->expectsOutputToContain('@method static void dispatchOn(string $queue, ?string $type)');
+ });
+
+ it('handles multiple return types', function () {
+ Artisan::call(MakeActionCommand::class, ['name' => 'Test19', '--dispatchable' => true]);
+ $actionPath = join_paths(app_path(), 'Actions', 'Test19.php');
+
+ File::replaceInFile(
+ 'public function handle(): void',
+ 'public function handle(?string $type): string|bool',
+ $actionPath
+ );
+
+ $this->artisan(ActionsIdeHelperCommand::class, [
+ '--namespace' => 'App\\Actions',
+ '--dry-run' => true,
+ ])
+ ->assertExitCode(0)
+ ->expectsOutputToContain('@method static string|bool run(?string $type)')
+ ->expectsOutputToContain('@method static void dispatch(?string $type)')
+ ->expectsOutputToContain('@method static void dispatchOn(string $queue, ?string $type)');
+ });
+
+ it('handles multiple nullable return types', function () {
+ Artisan::call(MakeActionCommand::class, ['name' => 'Test20', '--dispatchable' => true]);
+ $actionPath = join_paths(app_path(), 'Actions', 'Test20.php');
+
+ File::replaceInFile(
+ 'public function handle(): void',
+ 'public function handle(string|int $type): string|int|null',
+ $actionPath
+ );
+
+ $this->artisan(ActionsIdeHelperCommand::class, [
+ '--namespace' => 'App\\Actions',
+ '--dry-run' => true,
+ ])
+ ->assertExitCode(0)
+ ->expectsOutputToContain('@method static string|int|null run(string|int $type)')
+ ->expectsOutputToContain('@method static void dispatch(string|int $type)')
+ ->expectsOutputToContain('@method static void dispatchOn(string $queue, string|int $type)');
+ });
+
+ it('handles full namespaces', function () {
+ Artisan::call(MakeActionCommand::class, ['name' => 'Test21', '--dispatchable' => true]);
+ Artisan::call(MakeActionCommand::class, ['name' => 'Diff\\Test22', '--dispatchable' => true]);
+ $actionPath = join_paths(app_path(), 'Actions', 'Test21.php');
+
+ File::replaceInFile(
+ 'public function handle(): void',
+ 'public function handle(\\App\\Actions\\Diff\\Test22 $type): void',
+ $actionPath
+ );
+
+ $this->artisan(ActionsIdeHelperCommand::class, [
+ '--namespace' => 'App\\Actions',
+ '--dry-run' => true,
+ ])
+ ->assertExitCode(0)
+ ->expectsOutputToContain('@method static void run(\\App\\Actions\\Diff\\Test22 $type)')
+ ->expectsOutputToContain('@method static void dispatch(\\App\\Actions\\Diff\\Test22 $type)')
+ ->expectsOutputToContain('@method static void dispatchOn(string $queue, \\App\\Actions\\Diff\\Test22 $type)');
+ });
+
+ it('handles full namespaces when included', function () {
+ Artisan::call(MakeActionCommand::class, ['name' => 'Test23', '--dispatchable' => true]);
+ Artisan::call(MakeActionCommand::class, ['name' => 'Diff\\Test24', '--dispatchable' => true]);
+ $actionPath = join_paths(app_path(), 'Actions', 'Test23.php');
+
+ File::replaceInFile(
+ 'use LumoSolutions\\Actionable\\Traits\\IsDispatchable;',
+ implode(PHP_EOL, [
+ 'use LumoSolutions\\Actionable\\Traits\\IsDispatchable;',
+ 'use App\\Actions\\Diff\\Test24;',
+ ]),
+ $actionPath
+ );
+
+ File::replaceInFile(
+ 'public function handle(): void',
+ 'public function handle(\\App\\Actions\\Diff\\Test24 $type): \\App\\Actions\\Diff\\Test24',
+ $actionPath
+ );
+
+ $this->artisan(ActionsIdeHelperCommand::class, [
+ '--namespace' => 'App\\Actions',
+ '--dry-run' => true,
+ ])
+ ->assertExitCode(0)
+ ->expectsOutputToContain('@method static Test24 run(Test24 $type)')
+ ->expectsOutputToContain('@method static void dispatch(Test24 $type)')
+ ->expectsOutputToContain('@method static void dispatchOn(string $queue, Test24 $type)');
+ });
+
+ it('handles grouped using statements', function () {
+ Artisan::call(MakeActionCommand::class, ['name' => 'Test25', '--dispatchable' => true]);
+ Artisan::call(MakeActionCommand::class, ['name' => 'Diff\\Test26', '--dispatchable' => true]);
+ Artisan::call(MakeActionCommand::class, ['name' => 'Diff\\Test27', '--dispatchable' => true]);
+ Artisan::call(MakeActionCommand::class, ['name' => 'Diff\\Test28', '--dispatchable' => true]);
+ $actionPath = join_paths(app_path(), 'Actions', 'Test25.php');
+
+ File::replaceInFile(
+ 'use LumoSolutions\\Actionable\\Traits\\IsDispatchable;',
+ implode(PHP_EOL, [
+ 'use LumoSolutions\\Actionable\\Traits\\IsDispatchable;',
+ 'use App\\Actions\\Diff\\{Test26, Test27, Test28};',
+ ]),
+ $actionPath
+ );
+
+ File::replaceInFile(
+ 'public function handle(): void',
+ 'public function handle(\\App\\Actions\\Diff\\Test26 $type): \\App\\Actions\\Diff\\Test27',
+ $actionPath
+ );
+
+ $this->artisan(ActionsIdeHelperCommand::class, [
+ '--namespace' => 'App\\Actions',
+ '--dry-run' => true,
+ ])
+ ->assertExitCode(0)
+ ->expectsOutputToContain('@method static Test27 run(Test26 $type)')
+ ->expectsOutputToContain('@method static void dispatch(Test26 $type)')
+ ->expectsOutputToContain('@method static void dispatchOn(string $queue, Test26 $type)');
+ });
+
+ it('handles single line use statements', function () {
+ Artisan::call(MakeActionCommand::class, ['name' => 'Test29', '--dispatchable' => true]);
+ Artisan::call(MakeActionCommand::class, ['name' => 'Diff\\Test30', '--dispatchable' => true]);
+ Artisan::call(MakeActionCommand::class, ['name' => 'Diff\\Test31', '--dispatchable' => true]);
+ $actionPath = join_paths(app_path(), 'Actions', 'Test29.php');
+
+ File::replaceInFile(
+ 'use LumoSolutions\\Actionable\\Traits\\IsDispatchable;',
+ implode(PHP_EOL, [
+ 'use LumoSolutions\\Actionable\\Traits\\IsDispatchable;',
+ 'use App\\Actions\\Diff\\Test30, App\\Actions\\Diff\\Test31;',
+ ]),
+ $actionPath
+ );
+
+ File::replaceInFile(
+ 'public function handle(): void',
+ 'public function handle(\\App\\Actions\\Diff\\Test30 $type): \\App\\Actions\\Diff\\Test31',
+ $actionPath
+ );
+
+ $this->artisan(ActionsIdeHelperCommand::class, [
+ '--namespace' => 'App\\Actions',
+ '--dry-run' => true,
+ ])
+ ->assertExitCode(0)
+ ->expectsOutputToContain('@method static Test31 run(Test30 $type)')
+ ->expectsOutputToContain('@method static void dispatch(Test30 $type)')
+ ->expectsOutputToContain('@method static void dispatchOn(string $queue, Test30 $type)');
+ });
});
});
diff --git a/tests/Feature/Dtos/IgnoreAttributeTest.php b/tests/Feature/Dtos/IgnoreAttributeTest.php
index 0a40830..2fb9fae 100644
--- a/tests/Feature/Dtos/IgnoreAttributeTest.php
+++ b/tests/Feature/Dtos/IgnoreAttributeTest.php
@@ -26,5 +26,16 @@
expect($dto->name)->toBe('company')
->and($dto->secret)->toBe('api_key');
});
+
+ it('can collect and not see secret', function () {
+ $dto = IgnoreDto::fromArray([
+ 'name' => 'company', 'secret' => 'api_key',
+ ]);
+
+ $collection = $dto->collect();
+
+ expect($collection->has('secret'))->toBeFalse();
+ });
+
});
});