From 5b2eddaf2f930ed48dcd884c88ead1a94186824a Mon Sep 17 00:00:00 2001 From: Richard Anderson Date: Sat, 7 Jun 2025 17:11:20 +0100 Subject: [PATCH 1/3] Refactor complete --- composer.json | 3 +- src/Actions/Console/UpdateActionDocBlocks.php | 123 +++++++ src/Actions/Generation/GenerateDocBlocks.php | 89 +++++ .../Generation/UpdateClassDocBlock.php | 137 +++++++ src/Attributes/Ignore.php | 2 +- .../Commands/ActionsIdeHelperCommand.php | 146 +++----- src/Dtos/Class/MetadataDto.php | 26 ++ src/Dtos/Class/RelationDto.php | 14 + src/Dtos/Generation/DocBlockGenDto.php | 23 ++ src/Dtos/Generation/DocBlockUpdateDto.php | 17 + src/Dtos/MethodDto.php | 25 ++ src/Dtos/ParameterDto.php | 24 ++ src/Services/ActionDocBlockService.php | 337 ------------------ src/Support/ClassAnalyser.php | 222 ++++++++++++ src/Support/DocBlockHelper.php | 220 ++++++++++++ src/Support/DocBlockProcessor.php | 64 ++++ src/Support/UseStatementParser.php | 250 +++++++++++++ src/Traits/ArrayConvertible.php | 6 + .../Console/ActionIdeHelperCommandTest.php | 290 ++++++++++++++- tests/Feature/Dtos/IgnoreAttributeTest.php | 11 + 20 files changed, 1594 insertions(+), 435 deletions(-) create mode 100644 src/Actions/Console/UpdateActionDocBlocks.php create mode 100644 src/Actions/Generation/GenerateDocBlocks.php create mode 100644 src/Actions/Generation/UpdateClassDocBlock.php create mode 100644 src/Dtos/Class/MetadataDto.php create mode 100644 src/Dtos/Class/RelationDto.php create mode 100644 src/Dtos/Generation/DocBlockGenDto.php create mode 100644 src/Dtos/Generation/DocBlockUpdateDto.php create mode 100644 src/Dtos/MethodDto.php create mode 100644 src/Dtos/ParameterDto.php delete mode 100644 src/Services/ActionDocBlockService.php create mode 100644 src/Support/ClassAnalyser.php create mode 100644 src/Support/DocBlockHelper.php create mode 100644 src/Support/DocBlockProcessor.php create mode 100644 src/Support/UseStatementParser.php 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(); + }); + }); }); From dc49f827533b5f734d16e675e426a84e4404c11a Mon Sep 17 00:00:00 2001 From: Richard Anderson Date: Sat, 7 Jun 2025 17:13:28 +0100 Subject: [PATCH 2/3] Bump PHP version in the build pipeline --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 2d3b81c..87d615e 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 From 0d59ceb58c270150b8eca671d14075e5f468cf7e Mon Sep 17 00:00:00 2001 From: Richard Anderson Date: Sat, 7 Jun 2025 17:15:27 +0100 Subject: [PATCH 3/3] Bump PHP version on all tasks --- .github/workflows/build.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 87d615e..aa765bd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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