diff --git a/README.md b/README.md index 5205132..c6d58db 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,7 @@ php artisan enumify:sync --quiet ### enumify:refactor -Scan your codebase for hardcoded enum values and refactor them to use proper enum references. This command also supports normalizing enum case names to UPPERCASE and updating all references throughout your application. +Scan your codebase for hardcoded enum values and refactor them to use proper enum references. Only columns with enum casts in models will be refactored. This command also supports normalizing enum case names to UPPERCASE and updating all references throughout your application. #### Available Options @@ -361,7 +361,6 @@ Scan your codebase for hardcoded enum values and refactor them to use proper enu | `--backup` | | Create backups before applying changes | | `--include=` | | File patterns to include (e.g., `*.php`) | | `--exclude=` | | Paths or patterns to exclude from scanning | -| `--strict` | | Strict matching (column name must match enum context) | | `--report=` | | Export report to file (formats: `json`, `csv`, `md`) | | `--detailed` | | Show detailed output with code context | | `--normalize-keys` | | Convert enum keys to UPPERCASE and fix all references | @@ -388,6 +387,8 @@ php artisan enumify:refactor --path=app/Services php artisan enumify:refactor --report=refactor-report.md ``` +**Note:** The refactor command only processes columns that have an enum cast defined in a model. If no cast is available for a column, it will skip refactoring for that column. + #### Key Normalization (UPPERCASE) The `--normalize-keys` flag converts enum case names from any case format to UPPERCASE and updates all references in your codebase: diff --git a/config/enumify.php b/config/enumify.php index f9b1454..8db2c66 100644 --- a/config/enumify.php +++ b/config/enumify.php @@ -14,6 +14,9 @@ 'paths' => [ // Directories to scan for PHP enums (relative to base_path) 'enums' => ['app/Enums'], + // Directories to scan for Laravel models (relative to base_path) + // Used by enumify:refactor to detect enum casts + 'models' => ['app/Models'], // Output directory for generated TypeScript files (relative to base_path) 'output' => 'resources/js/enums', ], diff --git a/src/Commands/RefactorCommand.php b/src/Commands/RefactorCommand.php index 8bae29e..b2a4701 100644 --- a/src/Commands/RefactorCommand.php +++ b/src/Commands/RefactorCommand.php @@ -38,7 +38,6 @@ final class RefactorCommand extends Command {--backup : Create backup before applying changes} {--include=* : File patterns to include (e.g., *.php)} {--exclude=* : Paths/patterns to exclude} - {--strict : Strict matching (requires column name to match enum context)} {--report= : Export report to file (formats: json, csv, md)} {--detailed : Show detailed output with code context} {--normalize-keys : Convert enum keys to UPPERCASE and fix all references}'; @@ -48,7 +47,7 @@ final class RefactorCommand extends Command * * @var string */ - protected $description = 'Scan and refactor hardcoded enum values. Supports dry-run, interactive mode, key normalization, and comprehensive reporting.'; + protected $description = 'Scan and refactor hardcoded enum values based on model casts. Only columns with enum casts will be refactored.'; /** * @var array, class: string, path: string}> @@ -56,7 +55,7 @@ final class RefactorCommand extends Command private array $enums = []; /** - * @var array + * @var array */ private array $issues = []; @@ -70,6 +69,13 @@ final class RefactorCommand extends Command */ private array $backups = []; + /** + * Model casts mapping: model class => [column => enum class] + * + * @var array> + */ + private array $modelCasts = []; + /** * Patterns to detect hardcoded enum values. * @@ -79,8 +85,6 @@ final class RefactorCommand extends Command 'where' => '/->where\([\'"](\w+)[\'"]\s*,\s*[\'"]([a-zA-Z0-9_-]+)[\'"]\)/', 'orWhere' => '/->orWhere\([\'"](\w+)[\'"]\s*,\s*[\'"]([a-zA-Z0-9_-]+)[\'"]\)/', 'whereNot' => '/->whereNot\([\'"](\w+)[\'"]\s*,\s*[\'"]([a-zA-Z0-9_-]+)[\'"]\)/', - 'update' => '/->update\(\[[\'"](\w+)[\'"]\s*=>\s*[\'"]([a-zA-Z0-9_-]+)[\'"]/', - 'create' => '/->create\(\[[\'"](\w+)[\'"]\s*=>\s*[\'"]([a-zA-Z0-9_-]+)[\'"]/', 'array' => '/[\'"](status|type|state|category|priority|role|level|method|direction|source|channel)[\'"]\s*=>\s*[\'"]([a-zA-Z0-9_-]+)[\'"]/', 'comparison' => '/\$\w+->(status|type|state|category|priority|role|level)\s*===?\s*[\'"]([a-zA-Z0-9_-]+)[\'"]/', 'validation' => '/Rule::in\(\[([^\]]+)\]\)/', @@ -125,6 +129,8 @@ public function handle(): int return self::FAILURE; } + $this->loadModelCasts(); + // --fix applies changes, --dry-run previews changes, default is scan if ($isFix) { return $this->fix(dryRun: false); @@ -166,6 +172,8 @@ private function runInteractive(): int return self::FAILURE; } + $this->loadModelCasts(); + // Select mode $mode = select( label: 'What would you like to do?', @@ -407,6 +415,198 @@ private function loadEnumsWithPaths(): void $this->loadEnums(); } + /** + * Load model casts to determine which columns have enum casts. + */ + private function loadModelCasts(): void + { + /** @var array $modelPaths */ + $modelPaths = config('enumify.paths.models', ['app/Models']); + + $this->info('📦 Loading model casts...'); + + foreach ($modelPaths as $path) { + $fullPath = $this->isAbsolutePath($path) ? $path : base_path($path); + + // @codeCoverageIgnoreStart + if (! is_dir($fullPath)) { + continue; + } + // @codeCoverageIgnoreEnd + + $files = File::allFiles($fullPath); + + foreach ($files as $file) { + // @codeCoverageIgnoreStart + if ($file->getExtension() !== 'php') { + continue; + } + // @codeCoverageIgnoreEnd + + $this->extractModelCasts($file->getPathname()); + } + } + + $castCount = array_sum(array_map('count', $this->modelCasts)); + $modelCount = count($this->modelCasts); + $this->info("✅ Found {$castCount} enum cast".($castCount !== 1 ? 's' : '')." in {$modelCount} model".($modelCount !== 1 ? 's' : '')); + $this->newLine(); + } + + /** + * Extract casts from a model file. + */ + private function extractModelCasts(string $filePath): void + { + $content = file_get_contents($filePath); + + // Get the model class name + $className = $this->getModelClassFromFile($filePath); + // @codeCoverageIgnoreStart + if (! $className) { + return; + } + // @codeCoverageIgnoreEnd + + // Check if it's a Model (extends Model or has casts) + // @codeCoverageIgnoreStart + if (! str_contains($content, 'extends Model') && ! str_contains($content, 'casts')) { + return; + } + // @codeCoverageIgnoreEnd + + $castsContent = null; + + // Try to match property style: protected $casts = [...]; + // @codeCoverageIgnoreStart + if (preg_match('/protected\s+\$casts\s*=\s*\[([^\]]+)\]/s', $content, $match)) { + $castsContent = $match[1]; + } + // @codeCoverageIgnoreEnd + // Try to match method style: protected function casts(): array { return [...]; } + elseif (preg_match('/function\s+casts\s*\(\s*\)\s*:\s*array\s*\{\s*return\s*\[([^\]]+)\]/s', $content, $match)) { + $castsContent = $match[1]; + } + + // @codeCoverageIgnoreStart + if (! $castsContent) { + return; + } + // @codeCoverageIgnoreEnd + + // Extract column => EnumClass pairs + // Matches: 'status' => StatusEnum::class or 'status' => \App\Enums\StatusEnum::class + preg_match_all('/[\'"](\w+)[\'"]\s*=>\s*([\\\\]?[\w\\\\]+)::class/', $castsContent, $matches); + + if (! empty($matches[1])) { + $this->modelCasts[$className] = []; + + foreach ($matches[1] as $index => $column) { + $enumClass = $matches[2][$index]; + + // Resolve short class name to full class name if needed + $resolvedEnum = $this->resolveEnumClass($enumClass, $content); + + if ($resolvedEnum) { + $this->modelCasts[$className][$column] = $resolvedEnum; + } + } + } + } + + /** + * Resolve an enum class name to its full class path. + */ + private function resolveEnumClass(string $enumClass, string $fileContent): ?string + { + // If already a full path (starts with \) + // @codeCoverageIgnoreStart + if (str_starts_with($enumClass, '\\')) { + return ltrim($enumClass, '\\'); + } + // @codeCoverageIgnoreEnd + + // Check if it's one of our loaded enums + foreach ($this->enums as $fullClass => $data) { + if ($data['name'] === $enumClass || $fullClass === $enumClass) { + return $fullClass; + } + } + + // Try to resolve from use statements + // @codeCoverageIgnoreStart + if (preg_match('/use\s+([\\\\]?[\w\\\\]+\\\\'.preg_quote($enumClass, '/').')\s*;/', $fileContent, $useMatch)) { + return ltrim($useMatch[1], '\\'); + } + + return null; + // @codeCoverageIgnoreEnd + } + + /** + * Get the enum cast for a column from a specific model or any matching model. + * + * @return array{hasCast: bool, enumClass: string|null, enumName: string|null} + */ + private function getColumnEnumCast(string $column, ?string $modelName = null): array + { + // If model name provided, search for exact match first + // @codeCoverageIgnoreStart + if ($modelName) { + foreach ($this->modelCasts as $modelClass => $casts) { + // Check if model class ends with the model name (e.g., App\Models\LibraryMember matches LibraryMember) + if (class_basename($modelClass) === $modelName && isset($casts[$column])) { + $enumClass = $casts[$column]; + $enumName = isset($this->enums[$enumClass]) ? $this->enums[$enumClass]['name'] : class_basename($enumClass); + + return ['hasCast' => true, 'enumClass' => $enumClass, 'enumName' => $enumName]; + } + } + } + // @codeCoverageIgnoreEnd + + // Fall back to any model with this column cast + foreach ($this->modelCasts as $modelClass => $casts) { + if (isset($casts[$column])) { + $enumClass = $casts[$column]; + $enumName = isset($this->enums[$enumClass]) ? $this->enums[$enumClass]['name'] : class_basename($enumClass); + + return ['hasCast' => true, 'enumClass' => $enumClass, 'enumName' => $enumName]; + } + } + + return ['hasCast' => false, 'enumClass' => null, 'enumName' => null]; + } + + /** + * Extract model name from code context. + * Detects patterns like: Model::query(), Model::where(), $model->where() + */ + private function extractModelFromContext(string $context): ?string + { + // Match static method calls: LibraryMember::query(), LibraryMember::where() + if (preg_match('/([A-Z][a-zA-Z0-9_]+)::(?:query|where|orWhere|whereNot|find|create|update)\s*\(/', $context, $match)) { + // Exclude common non-model classes + $excludes = ['Auth', 'DB', 'Cache', 'Log', 'Route', 'Request', 'Response', 'Session', 'View', 'Config', 'File', 'Storage', 'Rule']; + if (! in_array($match[1], $excludes)) { + return $match[1]; + } + } + + // Match model variable patterns: $libraryMember->where(), $member->update() + // Try to infer from variable name (e.g., $libraryMember suggests LibraryMember model) + // @codeCoverageIgnoreStart + if (preg_match('/\$(\w+)->(?:where|orWhere|whereNot|update|save)\s*\(/', $context, $match)) { + // Convert camelCase/snake_case to PascalCase + $varName = $match[1]; + + return str_replace(' ', '', ucwords(str_replace(['_', '-'], ' ', $varName))); + } + // @codeCoverageIgnoreEnd + + return null; + } + /** * Check if a path is absolute. */ @@ -422,7 +622,7 @@ private function isAbsolutePath(string $path): bool } /** - * Get FQCN from a PHP file. + * Get FQCN from a PHP file (for enums). */ private function getClassFromFile(string $path): ?string { @@ -440,6 +640,29 @@ private function getClassFromFile(string $path): ?string return $namespaceMatch[1].'\\'.$enumMatch[1]; } + /** + * Get FQCN from a PHP model file (for classes). + */ + private function getModelClassFromFile(string $path): ?string + { + $content = file_get_contents($path); + + // @codeCoverageIgnoreStart + if (! preg_match('/namespace\s+([^;]+);/', $content, $namespaceMatch)) { + return null; + } + // @codeCoverageIgnoreEnd + + // Match class declaration (handles final, abstract, readonly modifiers) + // @codeCoverageIgnoreStart + if (! preg_match('/^(?:final\s+|abstract\s+|readonly\s+)*class\s+(\w+)/m', $content, $classMatch)) { + return null; + } + // @codeCoverageIgnoreEnd + + return $namespaceMatch[1].'\\'.$classMatch[1]; + } + /** * Find non-uppercase enum keys. */ @@ -808,14 +1031,17 @@ private function scanValidationRules(string $file, string $content, array $lines */ private function getContext(array $lines, int $lineNumber): string { - $start = max(0, $lineNumber - 2); - $end = min(count($lines), $lineNumber + 1); + // $lineNumber is 1-based, convert to 0-based for array access + $lineIndex = $lineNumber - 1; + $start = max(0, $lineIndex - 3); // Get 3 lines before for better context + $end = min(count($lines), $lineIndex + 2); return implode("\n", array_slice($lines, $start, $end - $start)); } /** - * Check if a value matches an enum and add to issues. + * Check if a value matches an enum cast and add to issues. + * Only columns with enum casts in models will be refactored. * * @param array|null $targetEnums */ @@ -829,40 +1055,51 @@ private function checkAndAddIssue( string $context, ?array $targetEnums = null ): void { - $isStrict = $this->option('strict'); + // Extract model name from context for precise cast lookup + $modelName = $this->extractModelFromContext($context); - foreach ($this->enums as $enumClass => $enumData) { - if ($targetEnums && ! in_array($enumData['name'], $targetEnums)) { - // @codeCoverageIgnoreStart - continue; - // @codeCoverageIgnoreEnd - } + // Check if this column has an enum cast in the detected model or any model + $castInfo = $this->getColumnEnumCast($column, $modelName); - foreach ($enumData['cases'] as $caseName => $caseValue) { - if ($caseValue === $value) { - if ($isStrict) { - $enumLower = mb_strtolower($enumData['name']); - $columnLower = mb_strtolower($column); - if (! str_contains($enumLower, $columnLower) && ! str_contains($columnLower, $enumLower)) { - continue; - } - } + // Only refactor if column has an enum cast - no cast means no refactoring + if (! $castInfo['hasCast'] || ! $castInfo['enumClass']) { + return; + } - $this->issues[] = [ - 'file' => $file, - 'line' => $line, - 'type' => $type, - 'column' => $column, - 'value' => $value, - 'code' => $code, - 'enum' => $enumData['name'], - 'case' => $caseName, - 'class' => $enumClass, - 'context' => $context, - ]; + $enumClass = $castInfo['enumClass']; - return; - } + // @codeCoverageIgnoreStart + if (! isset($this->enums[$enumClass])) { + return; + } + // @codeCoverageIgnoreEnd + + $enumData = $this->enums[$enumClass]; + + // @codeCoverageIgnoreStart + if ($targetEnums && ! in_array($enumData['name'], $targetEnums)) { + return; + } + // @codeCoverageIgnoreEnd + + // Find the matching case in the cast enum + foreach ($enumData['cases'] as $caseName => $caseValue) { + if ($caseValue === $value) { + $this->issues[] = [ + 'file' => $file, + 'line' => $line, + 'type' => $type, + 'column' => $column, + 'value' => $value, + 'code' => $code, + 'enum' => $enumData['name'], + 'case' => $caseName, + 'class' => $enumClass, + 'context' => $context, + 'hasCast' => true, + ]; + + return; } } } @@ -895,7 +1132,8 @@ private function displayResults(): void $this->line(" → {$suggestion}"); if ($this->option('detailed')) { - $this->line(" Enum: {$issue['class']}::{$issue['case']}"); + $castStatus = $issue['hasCast'] ? '✓ cast' : '✗ no cast'; + $this->line(" Enum: {$issue['class']}::{$issue['case']} [{$castStatus}]"); } } @@ -948,17 +1186,35 @@ private function generateSuggestion(array $issue): string { $enum = $issue['enum']; $case = $issue['case']; + $hasCast = $issue['hasCast'] ?? false; + + // Handle comparison specially to preserve the variable name + if ($issue['type'] === 'comparison') { + // Extract the variable name from the matched code (e.g., $admissionCycle from "$admissionCycle->status === 'open'") + if (preg_match('/(\$\w+)->/', $issue['code'], $varMatch)) { + // If column has enum cast, model returns enum instance, compare directly + // If no cast, model returns string, need to use ->value + $enumRef = $hasCast ? "{$enum}::{$case}" : "{$enum}::{$case}->value"; + + return "{$varMatch[1]}->{$issue['column']} === {$enumRef}"; + } + + // @codeCoverageIgnoreStart + $enumRef = $hasCast ? "{$enum}::{$case}" : "{$enum}::{$case}->value"; + + return "\$...->{$issue['column']} === {$enumRef}"; + // @codeCoverageIgnoreEnd + } + // For Eloquent where clauses, Laravel handles enum->value conversion automatically + // For array assignments in create/update, Laravel also handles it when column is casted return match ($issue['type']) { 'where' => "->where('{$issue['column']}', {$enum}::{$case})", 'orWhere' => "->orWhere('{$issue['column']}', {$enum}::{$case})", 'whereNot' => "->whereNot('{$issue['column']}', {$enum}::{$case})", - 'update' => "->update(['{$issue['column']}' => {$enum}::{$case}])", - 'create' => "->create(['{$issue['column']}' => {$enum}::{$case}])", // @codeCoverageIgnore - 'array' => "['{$issue['column']}' => {$enum}::{$case}]", - 'comparison' => "\$...->{$issue['column']} === {$enum}::{$case}", - 'validation' => "Rule::enum({$enum}::class)", - default => "{$enum}::{$case}", + 'array' => "'{$issue['column']}' => {$enum}::{$case}", + 'validation' => "Rule::enum({$enum}::class)", // @codeCoverageIgnore + default => "{$enum}::{$case}", // @codeCoverageIgnore }; } diff --git a/tests/Feature/RefactorCommandTest.php b/tests/Feature/RefactorCommandTest.php index 85391b5..002e46e 100644 --- a/tests/Feature/RefactorCommandTest.php +++ b/tests/Feature/RefactorCommandTest.php @@ -8,6 +8,7 @@ // Create temp directories for testing $this->tempDir = sys_get_temp_dir().'/enumify-refactor-test-'.uniqid(); $this->appDir = $this->tempDir.'/app'; + $this->modelsDir = $this->tempDir.'/app/Models'; $this->backupDir = storage_path('app/enumify-refactor-backups'); // Use the real fixtures path (already autoloaded) @@ -15,6 +16,29 @@ mkdir($this->tempDir, 0755, true); mkdir($this->appDir, 0755, true); + mkdir($this->modelsDir, 0755, true); + + // Create model with enum cast so refactoring will work + $orderModel = <<<'PHP' + OrderStatus::class, + ]; + } +} +PHP; + + file_put_contents($this->modelsDir.'/Order.php', $orderModel); // Create test files with hardcoded enum values using existing fixture enums // Note: The refactor command patterns match ->where() not ::where() (method chains, not static calls) @@ -102,8 +126,9 @@ public function getInProgress() file_put_contents($this->appDir.'/StatusService.php', $testService); - // Configure enumify to use the real fixtures path + // Configure enumify to use the real fixtures path and models path config()->set('enumify.paths.enums', [$this->enumPath]); + config()->set('enumify.paths.models', [$this->modelsDir]); config()->set('enumify.refactor.exclude', []); }); @@ -194,13 +219,6 @@ public function isDefault(): bool ])->assertSuccessful(); }); - it('supports strict mode with --strict option', function () { - $this->artisan('enumify:refactor', [ - '--path' => $this->appDir, - '--strict' => true, - ])->assertSuccessful(); - }); - it('shows detailed output with --detailed option', function () { $this->artisan('enumify:refactor', [ '--path' => $this->appDir,