From f6da12700ed787f21ce2e0a1b85e797d596b5ec1 Mon Sep 17 00:00:00 2001 From: Daniel Cannon Date: Mon, 23 Mar 2026 15:00:24 +0000 Subject: [PATCH 1/3] Refactor command logic to improve readability and efficiency, update workflow matrix, and add integration tests. --- .github/workflows/php-test.yaml | 2 +- Dockerfile | 8 +- src/Commands/BaseCommand.php | 526 ++++++++++++++++-- src/Commands/GenerateFormRequestsCommand.php | 41 +- .../GenerateInterfaceUnionsCommand.php | 31 +- .../GenerateInterfaceUnionsCommandTest.php | 101 +++- tests/TestCase.php | 2 +- ...TestableGenerateInterfaceUnionsCommand.php | 16 + .../app/Interfaces/CompanionInterface.php | 7 + workbench/app/Models/AliasAnimal.php | 9 + workbench/app/Models/FullyQualifiedAnimal.php | 7 + workbench/app/Models/GroupedUseAnimal.php | 9 + workbench/app/Models/InheritedAnimal.php | 7 + workbench/app/Models/ParentAnimal.php | 9 + ...sNameThatCrossesTheLegacyChunkBoundary.php | 12 + 15 files changed, 718 insertions(+), 69 deletions(-) create mode 100644 tests/TestableGenerateInterfaceUnionsCommand.php create mode 100644 workbench/app/Interfaces/CompanionInterface.php create mode 100644 workbench/app/Models/AliasAnimal.php create mode 100644 workbench/app/Models/FullyQualifiedAnimal.php create mode 100644 workbench/app/Models/GroupedUseAnimal.php create mode 100644 workbench/app/Models/InheritedAnimal.php create mode 100644 workbench/app/Models/ParentAnimal.php create mode 100644 workbench/app/Models/SomeLongClassNameThatCrossesTheLegacyChunkBoundary.php diff --git a/.github/workflows/php-test.yaml b/.github/workflows/php-test.yaml index eaa8b78..7309148 100644 --- a/.github/workflows/php-test.yaml +++ b/.github/workflows/php-test.yaml @@ -8,7 +8,7 @@ jobs: strategy: fail-fast: false matrix: - laravel: [9, 10, 11, 12] + laravel: [11, 12] php: [8.2, 8.3, 8.4] steps: diff --git a/Dockerfile b/Dockerfile index 9b2211b..2871ed0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,9 @@ ARG PHP_VERSION=8.2 FROM php:$PHP_VERSION-cli-alpine -RUN apk add git zip unzip autoconf make g++ +RUN apk add git zip unzip autoconf make g++ libxml2-dev + +RUN docker-php-ext-install simplexml # apparently newer xdebug needs these now? RUN apk add --update linux-headers @@ -21,8 +23,8 @@ USER dev COPY --chown=dev composer.json ./ -ARG LARAVEL=9 -RUN composer require laravel/framework ^$LARAVEL.0 +ARG LARAVEL=12 +RUN composer require --no-interaction "laravel/framework:^${LARAVEL}.0" COPY --chown=dev . . diff --git a/src/Commands/BaseCommand.php b/src/Commands/BaseCommand.php index 7c27c6e..82b8055 100644 --- a/src/Commands/BaseCommand.php +++ b/src/Commands/BaseCommand.php @@ -3,7 +3,7 @@ namespace SynergiTech\ExportTypes\Commands; use Illuminate\Console\Command; -use Illuminate\Filesystem\Filesystem; +use Illuminate\Filesystem\Filesystem; abstract class BaseCommand extends Command { @@ -17,10 +17,11 @@ public function __construct( parent::__construct(); } - protected function preprocess(): void { - $path = $this->option('output'); + protected function preprocess(): void + { + $path = $this->option('output'); - $this->files->ensureDirectoryExists(dirname($this->option('input'))); + $this->files->ensureDirectoryExists(dirname($this->option('input'))); if ($this->files->exists($path)) { $this->files->deleteDirectory($path); @@ -32,7 +33,8 @@ protected function preprocess(): void { ); } - protected function runPostProcessingHooks(): void { + protected function runPostProcessingHooks(): void + { if ($this->option('format')) { $this->runPrettier($this->option('output')); } @@ -42,10 +44,10 @@ abstract protected function process(): void; public function handle(): void { - $this->preprocess(); - $this->process(); - $this->runPostProcessingHooks(); - $this->done(); + $this->preprocess(); + $this->process(); + $this->runPostProcessingHooks(); + $this->done(); } protected function done(): void @@ -53,13 +55,12 @@ protected function done(): void $path = $this->option('output'); $this->info("Wrote types to {$this->tsFilePath($path)}!"); } - + protected function tsFilePath(string $path): string { return $this->joinPaths($path, 'index.d.ts'); } - protected function runPrettier(string $path, string $prettierCommand = 'npm exec prettier -- '): void { $prettier = $this->option('prettier') ?: $prettierCommand; @@ -99,47 +100,506 @@ protected function determineRootNamespace(array $classes): string protected function fqcnFromPath(string $path): string { - $namespace = $class = $buffer = ''; + return $this->classInfoFromPath($path)['fqcn']; + } + + /** + * @return array{namespace:string,class:string,fqcn:string,extends:string,implements:array} + */ + protected function classInfoFromPath(string $path): array + { + $declaration = $this->readClassDeclaration($path); + + if ($declaration === '') { + return [ + 'namespace' => '', + 'class' => '', + 'fqcn' => '\\', + 'extends' => '', + 'implements' => [], + ]; + } + + $namespace = ''; + $class = ''; + $extends = ''; + $implements = []; + $imports = []; + + $tokens = token_get_all($declaration); + $classIndex = $this->findClassTokenIndex($tokens); + + if ($classIndex === null) { + return [ + 'namespace' => '', + 'class' => '', + 'fqcn' => '\\', + 'extends' => '', + 'implements' => [], + ]; + } + + for ($index = 0; $index < $classIndex; $index++) { + $token = $tokens[$index]; + + if (!is_array($token)) { + continue; + } + + if ($token[0] === T_NAMESPACE) { + $namespace = $this->parseQualifiedName($tokens, $index + 1); + continue; + } + + if ($token[0] === T_USE) { + $imports = array_merge($imports, $this->parseUseStatement($tokens, $index)); + } + } + + $class = $this->parseClassName($tokens, $classIndex + 1); + $extends = $this->parseExtendedClass($tokens, $classIndex + 1, $namespace, $imports); + $implements = $this->parseImplementedInterfaces($tokens, $classIndex + 1, $namespace, $imports); + + return [ + 'namespace' => $namespace, + 'class' => $class, + 'fqcn' => $namespace . '\\' . $class, + 'extends' => $extends, + 'implements' => $implements, + ]; + } + + protected function findClassTokenIndex(array $tokens): ?int + { + foreach ($tokens as $index => $token) { + if (!is_array($token) || $token[0] !== T_CLASS) { + continue; + } + + return $index; + } + + return null; + } + + protected function parseClassName(array $tokens, int $index): string + { + for (; isset($tokens[$index]); $index++) { + $token = $tokens[$index]; + + if (!is_array($token)) { + continue; + } + + if (in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + + if ($token[0] === T_STRING) { + return $token[1]; + } + + break; + } + + return ''; + } + + protected function readClassDeclaration(string $path): string + { + $buffer = ''; + $classOffset = null; + $braceOffset = null; + $state = 'code'; + $currentWord = ''; + $currentWordOffset = null; + $lastWord = ''; + $previousCharacter = ''; + $scanOffset = 0; $handle = fopen($path, 'r'); while (!feof($handle)) { $buffer .= fread($handle, 512); - // Suppress warnings for cases where `$buffer` ends in the middle of a PHP comment. - $tokens = @token_get_all($buffer); + $length = strlen($buffer); + for ($index = $scanOffset; $index < $length; $index++) { + $character = $buffer[$index]; + $nextCharacter = $buffer[$index + 1] ?? ''; + + if ($state === 'line_comment') { + if ($character === "\n") { + $state = 'code'; + } + + continue; + } + + if ($state === 'block_comment') { + if ($previousCharacter === '*' && $character === '/') { + $state = 'code'; + } + + $previousCharacter = $character; + continue; + } + + if ($state === 'single_quote') { + if ($character === '\'' && $previousCharacter !== '\\') { + $state = 'code'; + } + + $previousCharacter = $character; + continue; + } + + if ($state === 'double_quote') { + if ($character === '"' && $previousCharacter !== '\\') { + $state = 'code'; + } + + $previousCharacter = $character; + continue; + } + + if ($character === '/' && ($nextCharacter === '/' || $nextCharacter === '*')) { + $state = $nextCharacter === '/' ? 'line_comment' : 'block_comment'; + $previousCharacter = $character; + $index++; + continue; + } + + if ($character === '#' && $nextCharacter !== '[') { + $state = 'line_comment'; + continue; + } + + if ($character === '\'' && $previousCharacter !== '\\') { + $state = 'single_quote'; + $previousCharacter = $character; + continue; + } + + if ($character === '"' && $previousCharacter !== '\\') { + $state = 'double_quote'; + $previousCharacter = $character; + continue; + } + + if (ctype_alnum($character) || $character === '_') { + if ($currentWord === '') { + $currentWordOffset = $index; + } + + $currentWord .= $character; + $previousCharacter = $character; + continue; + } + + if ($currentWord !== '') { + if ($currentWord === 'class' && $lastWord !== 'new') { + $classOffset = $currentWordOffset; + } + + $lastWord = $currentWord; + $currentWord = ''; + $currentWordOffset = null; + } + + if ($classOffset !== null && $character === '{') { + $braceOffset = $index; + break 2; + } + + if (!ctype_space($character)) { + $lastWord = ''; + } + + $previousCharacter = $character; + } + + $scanOffset = $length; + } + + fclose($handle); + + if ($braceOffset === null) { + return ''; + } + + return substr($buffer, 0, $braceOffset + 1); + } + + protected function parseQualifiedName(array $tokens, int $index): string + { + $name = ''; + + for (; isset($tokens[$index]); $index++) { + $token = $tokens[$index]; + + if (!is_array($token)) { + if ($token === ';' || $token === '{') { + break; + } + + continue; + } + + if (in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + + if (in_array($token[0], [T_STRING, T_NAME_QUALIFIED, T_NS_SEPARATOR], true)) { + $name .= $token[1]; + continue; + } - // Filter out whitespace and comments from the tokens, as they are irrelevant. - $tokens = array_filter($tokens, fn($token) => $token[0] !== T_WHITESPACE && $token[0] !== T_COMMENT); + break; + } - // Reset array indexes after filtering. - $tokens = array_values($tokens); + return $name; + } - foreach ($tokens as $index => $token) { - // The namespace is a `T_NAME_QUALIFIED` that is immediately preceded by a `T_NAMESPACE`. - if ( - $token[0] === T_NAMESPACE && isset($tokens[$index + 1]) - && $tokens[$index + 1][0] === T_NAME_QUALIFIED - ) { - $namespace = $tokens[$index + 1][1]; + protected function parseUseStatement(array $tokens, int &$index): array + { + $imports = []; + $prefix = ''; + $name = ''; + $alias = ''; + $mode = 'name'; + $grouped = false; + $statementStarted = false; + + for ($index++; isset($tokens[$index]); $index++) { + $token = $tokens[$index]; + + if (!is_array($token)) { + if ($token === '{') { + $grouped = true; + $prefix = trim($name, '\\'); + $name = ''; + $alias = ''; + $mode = 'name'; continue; } - // The class name is a `T_STRING` which makes it unreliable to match against, so check if we have a - // `T_CLASS` token with a `T_STRING` token ahead of it. - if ($token[0] === T_CLASS && isset($tokens[$index + 1]) && $tokens[$index + 1][0] === T_STRING) { - $class = $tokens[$index + 1][1]; + if ($token === ',') { + $this->appendImport($imports, $prefix, $name, $alias, $grouped); + $name = ''; + $alias = ''; + $mode = 'name'; + continue; } + + if ($token === '}') { + $this->appendImport($imports, $prefix, $name, $alias, $grouped); + $name = ''; + $alias = ''; + $mode = 'name'; + continue; + } + + if ($token === ';') { + $this->appendImport($imports, $prefix, $name, $alias, $grouped); + break; + } + + continue; + } + + if (in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + continue; } - if ($namespace && $class) { - // We've found both the namespace and the class, we can now stop reading and parsing the file. + if (!$statementStarted && in_array($token[0], [T_FUNCTION, T_CONST], true)) { + while (isset($tokens[$index]) && $tokens[$index] !== ';') { + $index++; + } + break; } + + $statementStarted = true; + + if ($token[0] === T_AS) { + $mode = 'alias'; + continue; + } + + if (!$this->isNameToken($token)) { + continue; + } + + if ($mode === 'alias') { + $alias .= $token[1]; + continue; + } + + $name .= $token[1]; } - fclose($handle); - return $namespace . '\\' . $class; + return $imports; + } + + protected function appendImport(array &$imports, string $prefix, string $name, string $alias, bool $grouped): void + { + if ($name === '') { + return; + } + + $fqcn = $grouped + ? trim($prefix . '\\' . ltrim($name, '\\'), '\\') + : trim($name, '\\'); + + if ($fqcn === '') { + return; + } + + $resolvedAlias = $alias !== '' + ? $alias + : substr($fqcn, strrpos($fqcn, '\\') + 1); + + $imports[$resolvedAlias] = $fqcn; + } + + protected function parseImplementedInterfaces(array $tokens, int $index, string $namespace, array $imports): array + { + $implements = []; + $name = ''; + $parsingImplements = false; + + for (; isset($tokens[$index]); $index++) { + $token = $tokens[$index]; + + if (!is_array($token)) { + if ($token === ',') { + $this->appendResolvedInterface($implements, $name, $namespace, $imports); + $name = ''; + continue; + } + + if ($token === '{') { + $this->appendResolvedInterface($implements, $name, $namespace, $imports); + break; + } + + continue; + } + + if (in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + + if ($token[0] === T_IMPLEMENTS) { + $parsingImplements = true; + continue; + } + + if (!$parsingImplements) { + continue; + } + + if (!$this->isNameToken($token)) { + continue; + } + + $name .= $token[1]; + } + + return array_values(array_unique($implements)); + } + + protected function parseExtendedClass(array $tokens, int $index, string $namespace, array $imports): string + { + $name = ''; + $parsingExtends = false; + + for (; isset($tokens[$index]); $index++) { + $token = $tokens[$index]; + + if (!is_array($token)) { + if ($token === '{' || $token === ',') { + break; + } + + continue; + } + + if (in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + + if ($token[0] === T_EXTENDS) { + $parsingExtends = true; + continue; + } + + if ($token[0] === T_IMPLEMENTS) { + break; + } + + if (!$parsingExtends) { + continue; + } + + if (!$this->isNameToken($token)) { + continue; + } + + $name .= $token[1]; + } + + return $this->resolveImportedName($name, $namespace, $imports); + } + + protected function appendResolvedInterface( + array &$implements, + string $name, + string $namespace, + array $imports + ): void { + $resolved = $this->resolveImportedName($name, $namespace, $imports); + + if ($resolved !== '') { + $implements[] = $resolved; + } + } + + protected function resolveImportedName(string $name, string $namespace, array $imports): string + { + $name = trim($name); + + if ($name === '') { + return ''; + } + + if (str_starts_with($name, '\\')) { + return ltrim($name, '\\'); + } + + $segments = explode('\\', $name); + $root = $segments[0]; + + if (isset($imports[$root])) { + $suffix = array_slice($segments, 1); + + return implode('\\', array_filter([$imports[$root], ...$suffix])); + } + + if (str_contains($name, '\\')) { + return trim($namespace . '\\' . $name, '\\'); + } + + if (isset($imports[$name])) { + return $imports[$name]; + } + + return trim($namespace . '\\' . $name, '\\'); + } + + protected function isNameToken(array $token): bool + { + return in_array($token[0], [T_STRING, T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED, T_NS_SEPARATOR], true); } // Laravel < 11 doesn't have Str::chopStart diff --git a/src/Commands/GenerateFormRequestsCommand.php b/src/Commands/GenerateFormRequestsCommand.php index b813909..327e8cf 100644 --- a/src/Commands/GenerateFormRequestsCommand.php +++ b/src/Commands/GenerateFormRequestsCommand.php @@ -1,7 +1,7 @@ option('output'); $tsContent = ''; - $formRequests = $this->readFormRequests($this->base()); + $formRequests = $this->readFormRequests($this->base()); $formRequests ->groupBy(function ($formRequest) { @@ -133,7 +133,8 @@ protected function process(): void $this->files->put($this->tsFilePath($path), $tsContent); } - protected function parseRules(FormRequest $formRequest) { + protected function parseRules(FormRequest $formRequest) + { $arrayOfRulesOrString = function ($rules) { if (is_string($rules)) { return explode('|', $rules); @@ -145,7 +146,7 @@ protected function parseRules(FormRequest $formRequest) { ->flatMap(function ($rule) { return str_contains($rule, '|') ? explode('|', $rule) : [$rule]; }) - ->map(fn($rule) => trim($rule)) + ->map(fn ($rule) => trim($rule)) ->values() ->toArray(); }; @@ -167,9 +168,12 @@ protected function parseRules(FormRequest $formRequest) { if (in_array('array', $adjusted)) { // Find all rules for keys like 'field.*' $children = collect($fields) - ->filter(fn ($v, $k) => str_starts_with($k, $field . '.') && preg_match('/^' . preg_quote($field, '/') . '\.\*$/', $k)) + ->filter( + fn ($v, $k) => str_starts_with($k, $field . '.') + && preg_match('/^' . preg_quote($field, '/') . '\.\*$/', $k) + ) ->map($arrayOfRulesOrString); - + if ($children->isNotEmpty()) { return [ 'rules' => $adjusted, @@ -182,20 +186,20 @@ protected function parseRules(FormRequest $formRequest) { }) */ // Remove child keys from the top level - ->reject(fn ($v, $k) => $childKeys->contains($k)) + ->reject(fn ($v, $k) => $childKeys->contains($k)) ->toArray(); } protected function readFormRequests(string $path) { $classes = collect(iterator_to_array(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)))) - ->reject(fn ($i) => - $i->isDir() - || str_ends_with($i->getRealPath(), '/..') - || ! str_ends_with($i->getRealPath(), '.php') + ->reject( + fn ($i) => $i->isDir() + || str_ends_with($i->getRealPath(), '/..') + || ! str_ends_with($i->getRealPath(), '.php') ) ->map(fn ($item) => $this->fqcnFromPath($item->getRealPath())) - ->filter( fn($class) => is_subclass_of($class, FormRequest::class)) + ->filter(fn ($class) => is_subclass_of($class, FormRequest::class)) ->values(); $rootNamespace = $this->determineRootNamespace($classes->toArray()); @@ -205,12 +209,11 @@ protected function readFormRequests(string $path) $classKey = Str::of($this->chopStart($class, $rootNamespace)) ->replace('\\', '') ->toString(); - - return [ - 'entity' => $classKey, - 'rules' => $this->parseRules(new $class()), - 'class' => $class - ]; - }) ; + return [ + 'entity' => $classKey, + 'rules' => $this->parseRules(new $class()), + 'class' => $class, + ]; + }); } } diff --git a/src/Commands/GenerateInterfaceUnionsCommand.php b/src/Commands/GenerateInterfaceUnionsCommand.php index 9b0e6fb..9c5da35 100644 --- a/src/Commands/GenerateInterfaceUnionsCommand.php +++ b/src/Commands/GenerateInterfaceUnionsCommand.php @@ -1,7 +1,7 @@ option('output'); @@ -54,16 +54,33 @@ protected function readInterfaces(string $path, array $interfaces) $paths = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($path)); $rootNamespace = $this->determineRootNamespace($interfaces); - + + $classes = collect($paths) + ->reject(fn ($i) => !$i->isFile() || !str_ends_with($i->getRealPath(), '.php')) + ->map(fn ($item) => $this->classInfoFromPath($item->getRealPath())) + ->keyBy('fqcn'); + return collect($interfaces) ->mapWithKeys(fn ($interface) => [ - $this->chopStart($interface, $rootNamespace) => collect($paths) - ->reject(fn ($i) => !$i->isFile() || !str_ends_with($i->getRealPath(), '.php')) - ->map(fn ($item) => $this->fqcnFromPath($item->getRealPath())) - ->filter(fn ($i) => is_subclass_of($i, $interface)) + $this->chopStart($interface, $rootNamespace) => $classes + ->filter(fn ($info) => $this->classImplementsInterface($info, $interface, $classes)) + ->map(fn ($info) => $info['fqcn']) ->values() ->toArray() ]) ->toArray(); } + + protected function classImplementsInterface(array $info, string $interface, $classes): bool + { + if (in_array($interface, $info['implements'], true)) { + return true; + } + + if ($info['extends'] === '' || !$classes->has($info['extends'])) { + return false; + } + + return $this->classImplementsInterface($classes->get($info['extends']), $interface, $classes); + } } diff --git a/tests/Commands/GenerateInterfaceUnionsCommandTest.php b/tests/Commands/GenerateInterfaceUnionsCommandTest.php index 0aceb6f..025ea0e 100644 --- a/tests/Commands/GenerateInterfaceUnionsCommandTest.php +++ b/tests/Commands/GenerateInterfaceUnionsCommandTest.php @@ -2,19 +2,110 @@ namespace SynergiTech\ExportTypes\Tests\Commands; -use SynergiTech\ExportTypes\Tests\TestCase; - +use Illuminate\Filesystem\Filesystem; use Illuminate\Testing\PendingCommand; use Symfony\Component\Console\Command\Command as SymfonyCommand; +use SynergiTech\ExportTypes\Tests\TestCase; +use SynergiTech\ExportTypes\Tests\TestableGenerateInterfaceUnionsCommand; class GenerateInterfaceUnionsCommandTest extends TestCase { public function testCommandExports(): void - { - $command = $this->artisan('export-interface-unions:generate:generate --input=app/models --output=models'); + { + $command = $this->artisan('synergi-types:interface-unions --input=app/Models --output=models'); $this->assertInstanceOf(PendingCommand::class, $command); $result = $command->run(); $this->assertSame(SymfonyCommand::SUCCESS, $result); - $this->assertFileExists('./models/index.ts'); + $this->assertFileExists('./models/index.d.ts'); + + $output = file_get_contents('./models/index.d.ts'); + + $this->assertIsString($output); + $this->assertStringContainsString('"App\\\\Models\\\\AliasAnimal"', $output); + $this->assertStringContainsString('"App\\\\Models\\\\FullyQualifiedAnimal"', $output); + $this->assertStringContainsString('"App\\\\Models\\\\GroupedUseAnimal"', $output); + $this->assertStringContainsString('"App\\\\Models\\\\InheritedAnimal"', $output); + } + + public function testCommandExportsFullClassNameWhenDeclarationCrossesChunkBoundary(): void + { + $fixture = './workbench/app/Models/SomeLongClassNameThatCrossesTheLegacyChunkBoundary.php'; + $contents = file_get_contents($fixture); + $classPosition = strpos($contents ?: '', 'class SomeLongClassNameThatCrossesTheLegacyChunkBoundary'); + $bracePosition = strpos($contents ?: '', '{'); + + $this->assertIsString($contents); + $this->assertNotFalse($classPosition); + $this->assertNotFalse($bracePosition); + $this->assertLessThan(512, $classPosition); + $this->assertGreaterThan(512, $bracePosition); + + $command = $this->artisan('synergi-types:interface-unions --input=app/Models --output=models-boundary'); + $this->assertInstanceOf(PendingCommand::class, $command); + + $result = $command->run(); + + $this->assertSame(SymfonyCommand::SUCCESS, $result); + $this->assertFileExists('./models-boundary/index.d.ts'); + + $output = file_get_contents('./models-boundary/index.d.ts'); + + $this->assertIsString($output); + $this->assertStringContainsString( + '"App\\\\Models\\\\SomeLongClassNameThatCrossesTheLegacyChunkBoundary"', + $output + ); + } + + public function testParserExtractsNamespaceClassAndImplementedInterfaces(): void + { + $command = $this->makeParserCommand(); + + $info = $command->classInfo('./workbench/app/Models/SomeLongClassNameThatCrossesTheLegacyChunkBoundary.php'); + + $this->assertSame('App\\Models', $info['namespace']); + $this->assertSame('SomeLongClassNameThatCrossesTheLegacyChunkBoundary', $info['class']); + $this->assertSame( + 'App\\Models\\SomeLongClassNameThatCrossesTheLegacyChunkBoundary', + $info['fqcn'] + ); + $this->assertSame(['App\\Interfaces\\AnimalInterface'], $info['implements']); + } + + public function testParserResolvesAliasedImportedInterfaces(): void + { + $info = $this->makeParserCommand()->classInfo('./workbench/app/Models/AliasAnimal.php'); + + $this->assertSame(['App\\Interfaces\\AnimalInterface'], $info['implements']); + } + + public function testParserResolvesFullyQualifiedImplementedInterfaces(): void + { + $info = $this->makeParserCommand()->classInfo('./workbench/app/Models/FullyQualifiedAnimal.php'); + + $this->assertSame(['App\\Interfaces\\AnimalInterface'], $info['implements']); + } + + public function testParserResolvesGroupedUseImportsAndMultipleInterfaces(): void + { + $info = $this->makeParserCommand()->classInfo('./workbench/app/Models/GroupedUseAnimal.php'); + + $this->assertSame( + ['App\\Interfaces\\AnimalInterface', 'App\\Interfaces\\CompanionInterface'], + $info['implements'] + ); + } + + public function testParserExtractsInheritedParentClass(): void + { + $info = $this->makeParserCommand()->classInfo('./workbench/app/Models/InheritedAnimal.php'); + + $this->assertSame('App\\Models\\ParentAnimal', $info['extends']); + $this->assertSame([], $info['implements']); + } + + protected function makeParserCommand(): TestableGenerateInterfaceUnionsCommand + { + return new TestableGenerateInterfaceUnionsCommand(new Filesystem()); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index c6ab8c1..f4d063d 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,8 +2,8 @@ namespace SynergiTech\ExportTypes\Tests; -use Orchestra\Testbench\TestCase as OrchestraTestCase; use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase as OrchestraTestCase; class TestCase extends OrchestraTestCase { diff --git a/tests/TestableGenerateInterfaceUnionsCommand.php b/tests/TestableGenerateInterfaceUnionsCommand.php new file mode 100644 index 0000000..bac6e24 --- /dev/null +++ b/tests/TestableGenerateInterfaceUnionsCommand.php @@ -0,0 +1,16 @@ +} + */ + public function classInfo(string $path): array + { + return $this->classInfoFromPath($path); + } +} diff --git a/workbench/app/Interfaces/CompanionInterface.php b/workbench/app/Interfaces/CompanionInterface.php new file mode 100644 index 0000000..cadc5af --- /dev/null +++ b/workbench/app/Interfaces/CompanionInterface.php @@ -0,0 +1,7 @@ + Date: Mon, 23 Mar 2026 15:14:42 +0000 Subject: [PATCH 2/3] Refactor command logic for improved reliability and readability, update dependencies, and add new tests for edge cases. --- app/.gitkeep | 0 composer.json | 6 ++--- src/Commands/BaseCommand.php | 24 +++++++++++++---- .../GenerateInterfaceUnionsCommand.php | 16 ++++++++++-- .../GenerateInterfaceUnionsCommandTest.php | 18 +++++++++++++ .../Models/GlobalImportArrayAccessible.php | 26 +++++++++++++++++++ 6 files changed, 80 insertions(+), 10 deletions(-) create mode 100644 app/.gitkeep create mode 100644 workbench/app/Models/GlobalImportArrayAccessible.php diff --git a/app/.gitkeep b/app/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/composer.json b/composer.json index aa48bf4..91f2fb7 100644 --- a/composer.json +++ b/composer.json @@ -20,11 +20,11 @@ }, "require": { "php": "^8.2", - "laravel/framework": ">=9.0" + "laravel/framework": "^11.0|^12.0" }, "require-dev": { "larastan/larastan": "^2.0|^3.0", - "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0", + "orchestra/testbench": "^9.0|^10.0", "php-parallel-lint/php-parallel-lint": "^1.4", "phpstan/extension-installer": "^1.4", "phpunit/phpunit": "^9.0|^10.0|^11.0", @@ -79,4 +79,4 @@ "phpstan/extension-installer": true } } -} \ No newline at end of file +} diff --git a/src/Commands/BaseCommand.php b/src/Commands/BaseCommand.php index 82b8055..bd1da41 100644 --- a/src/Commands/BaseCommand.php +++ b/src/Commands/BaseCommand.php @@ -20,8 +20,11 @@ public function __construct( protected function preprocess(): void { $path = $this->option('output'); + $basePath = $this->base(); - $this->files->ensureDirectoryExists(dirname($this->option('input'))); + if (!$this->files->isDirectory($basePath) || !$this->files->isReadable($basePath)) { + throw new \RuntimeException("Input path [{$basePath}] does not exist or is not readable."); + } if ($this->files->exists($path)) { $this->files->deleteDirectory($path); @@ -114,7 +117,7 @@ protected function classInfoFromPath(string $path): array return [ 'namespace' => '', 'class' => '', - 'fqcn' => '\\', + 'fqcn' => '', 'extends' => '', 'implements' => [], ]; @@ -133,7 +136,7 @@ protected function classInfoFromPath(string $path): array return [ 'namespace' => '', 'class' => '', - 'fqcn' => '\\', + 'fqcn' => '', 'extends' => '', 'implements' => [], ]; @@ -163,7 +166,7 @@ protected function classInfoFromPath(string $path): array return [ 'namespace' => $namespace, 'class' => $class, - 'fqcn' => $namespace . '\\' . $class, + 'fqcn' => trim($namespace . '\\' . $class, '\\'), 'extends' => $extends, 'implements' => $implements, ]; @@ -219,6 +222,10 @@ protected function readClassDeclaration(string $path): string $handle = fopen($path, 'r'); + if ($handle === false) { + return ''; + } + while (!feof($handle)) { $buffer .= fread($handle, 512); @@ -457,11 +464,18 @@ protected function appendImport(array &$imports, string $prefix, string $name, s $resolvedAlias = $alias !== '' ? $alias - : substr($fqcn, strrpos($fqcn, '\\') + 1); + : $this->defaultImportAlias($fqcn); $imports[$resolvedAlias] = $fqcn; } + protected function defaultImportAlias(string $fqcn): string + { + $position = strrpos($fqcn, '\\'); + + return $position === false ? $fqcn : substr($fqcn, $position + 1); + } + protected function parseImplementedInterfaces(array $tokens, int $index, string $namespace, array $imports): array { $implements = []; diff --git a/src/Commands/GenerateInterfaceUnionsCommand.php b/src/Commands/GenerateInterfaceUnionsCommand.php index 9c5da35..c58350b 100644 --- a/src/Commands/GenerateInterfaceUnionsCommand.php +++ b/src/Commands/GenerateInterfaceUnionsCommand.php @@ -56,8 +56,20 @@ protected function readInterfaces(string $path, array $interfaces) $rootNamespace = $this->determineRootNamespace($interfaces); $classes = collect($paths) - ->reject(fn ($i) => !$i->isFile() || !str_ends_with($i->getRealPath(), '.php')) - ->map(fn ($item) => $this->classInfoFromPath($item->getRealPath())) + ->map(function ($item) { + if (!$item->isFile()) { + return null; + } + + $realPath = $item->getRealPath(); + + if ($realPath === false || !str_ends_with($realPath, '.php')) { + return null; + } + + return $this->classInfoFromPath($realPath); + }) + ->filter(fn ($info) => is_array($info) && $info['fqcn'] !== '') ->keyBy('fqcn'); return collect($interfaces) diff --git a/tests/Commands/GenerateInterfaceUnionsCommandTest.php b/tests/Commands/GenerateInterfaceUnionsCommandTest.php index 025ea0e..c8938ab 100644 --- a/tests/Commands/GenerateInterfaceUnionsCommandTest.php +++ b/tests/Commands/GenerateInterfaceUnionsCommandTest.php @@ -104,6 +104,24 @@ public function testParserExtractsInheritedParentClass(): void $this->assertSame([], $info['implements']); } + public function testParserResolvesSingleSegmentImports(): void + { + $info = $this->makeParserCommand()->classInfo('./workbench/app/Models/GlobalImportArrayAccessible.php'); + + $this->assertSame(['ArrayAccess'], $info['implements']); + } + + public function testCommandFailsForInvalidInputPath(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('does not exist or is not readable'); + + $command = $this->artisan('synergi-types:interface-unions --input=app/DoesNotExist --output=invalid-models'); + + $this->assertInstanceOf(PendingCommand::class, $command); + $command->run(); + } + protected function makeParserCommand(): TestableGenerateInterfaceUnionsCommand { return new TestableGenerateInterfaceUnionsCommand(new Filesystem()); diff --git a/workbench/app/Models/GlobalImportArrayAccessible.php b/workbench/app/Models/GlobalImportArrayAccessible.php new file mode 100644 index 0000000..d688823 --- /dev/null +++ b/workbench/app/Models/GlobalImportArrayAccessible.php @@ -0,0 +1,26 @@ + Date: Mon, 23 Mar 2026 15:27:08 +0000 Subject: [PATCH 3/3] Refactor `GenerateInterfaceUnionsCommand` to improve type safety and extend functionality, update parsing logic in `BaseCommand`, and add tests for new attribute handling. --- src/Commands/BaseCommand.php | 18 +++++++++++++++++- .../GenerateInterfaceUnionsCommand.php | 11 ++++++++++- .../GenerateInterfaceUnionsCommandTest.php | 10 ++++++++++ .../Models/AttributeClassReferenceAnimal.php | 10 ++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 workbench/app/Models/AttributeClassReferenceAnimal.php diff --git a/src/Commands/BaseCommand.php b/src/Commands/BaseCommand.php index bd1da41..0423511 100644 --- a/src/Commands/BaseCommand.php +++ b/src/Commands/BaseCommand.php @@ -179,7 +179,23 @@ protected function findClassTokenIndex(array $tokens): ?int continue; } - return $index; + for ($lookahead = $index + 1; isset($tokens[$lookahead]); $lookahead++) { + $nextToken = $tokens[$lookahead]; + + if (!is_array($nextToken)) { + continue; + } + + if (in_array($nextToken[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + + if ($nextToken[0] === T_STRING) { + return $index; + } + + break; + } } return null; diff --git a/src/Commands/GenerateInterfaceUnionsCommand.php b/src/Commands/GenerateInterfaceUnionsCommand.php index c58350b..2345116 100644 --- a/src/Commands/GenerateInterfaceUnionsCommand.php +++ b/src/Commands/GenerateInterfaceUnionsCommand.php @@ -3,6 +3,7 @@ namespace SynergiTech\ExportTypes\Commands; use Illuminate\Filesystem\Filesystem; +use Illuminate\Support\Collection; use Illuminate\Support\Str; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; @@ -55,6 +56,7 @@ protected function readInterfaces(string $path, array $interfaces) $rootNamespace = $this->determineRootNamespace($interfaces); + /** @var Collection}> $classes */ $classes = collect($paths) ->map(function ($item) { if (!$item->isFile()) { @@ -83,7 +85,14 @@ protected function readInterfaces(string $path, array $interfaces) ->toArray(); } - protected function classImplementsInterface(array $info, string $interface, $classes): bool + /** + * @param array{namespace:string,class:string,fqcn:string,extends:string,implements:array} $info + * @param Collection< + * string, + * array{namespace:string,class:string,fqcn:string,extends:string,implements:array} + * > $classes + */ + protected function classImplementsInterface(array $info, string $interface, Collection $classes): bool { if (in_array($interface, $info['implements'], true)) { return true; diff --git a/tests/Commands/GenerateInterfaceUnionsCommandTest.php b/tests/Commands/GenerateInterfaceUnionsCommandTest.php index c8938ab..9bf3ee2 100644 --- a/tests/Commands/GenerateInterfaceUnionsCommandTest.php +++ b/tests/Commands/GenerateInterfaceUnionsCommandTest.php @@ -22,6 +22,7 @@ public function testCommandExports(): void $this->assertIsString($output); $this->assertStringContainsString('"App\\\\Models\\\\AliasAnimal"', $output); + $this->assertStringContainsString('"App\\\\Models\\\\AttributeClassReferenceAnimal"', $output); $this->assertStringContainsString('"App\\\\Models\\\\FullyQualifiedAnimal"', $output); $this->assertStringContainsString('"App\\\\Models\\\\GroupedUseAnimal"', $output); $this->assertStringContainsString('"App\\\\Models\\\\InheritedAnimal"', $output); @@ -111,6 +112,15 @@ public function testParserResolvesSingleSegmentImports(): void $this->assertSame(['ArrayAccess'], $info['implements']); } + public function testParserIgnoresClassConstantReferencesBeforeClassDeclaration(): void + { + $info = $this->makeParserCommand()->classInfo('./workbench/app/Models/AttributeClassReferenceAnimal.php'); + + $this->assertSame('AttributeClassReferenceAnimal', $info['class']); + $this->assertSame('App\\Models\\AttributeClassReferenceAnimal', $info['fqcn']); + $this->assertSame(['App\\Interfaces\\AnimalInterface'], $info['implements']); + } + public function testCommandFailsForInvalidInputPath(): void { $this->expectException(\RuntimeException::class); diff --git a/workbench/app/Models/AttributeClassReferenceAnimal.php b/workbench/app/Models/AttributeClassReferenceAnimal.php new file mode 100644 index 0000000..a6124f4 --- /dev/null +++ b/workbench/app/Models/AttributeClassReferenceAnimal.php @@ -0,0 +1,10 @@ +