diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 7c3856518..3aa73eee7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -19,6 +19,7 @@ /packages/discovery/ @brendt @aidan-casey /packages/event-bus/ @brendt @aidan-casey /packages/generation/ @brendt +/packages/generation/src/TypeScript @innocenzi /packages/http/ @brendt @aidan-casey /packages/http-client/ @aidan-casey /packages/icon/ @innocenzi diff --git a/docs/2-features/18-typescript.md b/docs/2-features/18-typescript.md new file mode 100644 index 000000000..00965e198 --- /dev/null +++ b/docs/2-features/18-typescript.md @@ -0,0 +1,107 @@ +--- +title: TypeScript +description: "Tempest provides the ability to generate TypeScript interfaces from PHP classes to ease integration with TypeScript-based front-ends." +keywords: ["Experimental", "Generation"] +experimental: true +--- + +## Overview + +When building applications with TypeScript-based front-ends like [Inertia](https://inertiajs.com), keeping your client-side types synchronized with your PHP backend can be tedious and error-prone. + +Tempest solves this by automatically generating TypeScript definitions from your PHP value objects, data transfer objects, and enums. + +You can choose to output a single `.d.ts` declaration file or a directory tree of individual `.ts` modules, depending on your project's needs. + +## Generating types + +Mark any PHP class with the {b`#[Tempest\Generation\TypeScript\AsType]`} attribute to instruct Tempest that a matching TypeScript interface must be generated based on its public properties. + +By default, all application enums are also included automatically without needing an attribute. Generate your TypeScript definitions by running `generate:typescript-types`: + +```sh ">_ generate:typescript-types" +✓ // Generated 14 type definitions across 2 namespaces. +``` + +This command scans your marked classes, generates the corresponding TypeScript definitions, and writes them to your configured output location. + +## Customizing type resolution + +Tempest provides several built-in type resolvers for common types: strings, numbers, dates, enums and class references. + +You can add your own resolver by providing implementations of {b`Tempest\Generation\TypeScript\TypeResolvers\TypeResolver`}. This interface requires a `canResolve()` method to determine if the resolver can handle a given type, and a `resolve()` method to perform the actual resolution. + +The following is the actual implementation of the built-in resolver that handles scalar types: + +```php ScalarTypeResolver.php +#[Priority(Priority::LOW)] +final class ScalarTypeResolver implements TypeResolver +{ + public function canResolve(TypeReflector $type): bool + { + return $type->isBuiltIn() + && in_array($type->getName(), ['string', 'int', 'float', 'bool'], strict: true); + } + + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + { + return new ResolvedType(match ($type->getName()) { + 'string' => 'string', + 'int', 'float' => 'number', + 'bool' => 'boolean', + }); + } +} +``` + +:::info +Type resolvers are automatically [discovered](../1-essentials/05-discovery.md) and do not need to be registered manually. +::: + +## Configuring output location + +By default, Tempest generates a `types.d.ts` definition file at the root of the project, in which the generated types are organized by namespace. + +This may be configured by creating a `typescript.config.php` [configuration file](../1-essentials/06-configuration.md#configuration-files) and returning one of the available configuration objects. + +### Single file output + +To keep all of the TypeScript definitions in a single `.d.ts` declaration file, which is the default, return a {b`Tempest\Generation\TypeScript\Writers\NamespacedTypeScriptGenerationConfig`} object and specify the desired output filename. + +```php +use Tempest\Generation\TypeScript\Writers\NamespacedTypeScriptGenerationConfig; + +return new NamespacedTypeScriptGenerationConfig( + filename: 'types.d.ts', +); +``` + +The declaration file should be automatically picked up by TypeScript—if not, ensure that it's included in the `include` property of your `tsconfig.json`: + +```json +{ + "include": ["types.d.ts"] +} +``` + +You may then reference the generated types globally by using their namespaces: + +```ts +defineProps<{ + entry: Module.Changelog.ChangelogEntry +}>() +``` + +### Directory structure output + +If you prefer to mirror your PHP namespace structure in separate files, you may return a {b`Tempest\Generation\TypeScript\Writers\DirectoryTypeScriptGenerationConfig`} configuration object: + +```php +use Tempest\Generation\TypeScript\Writers\DirectoryTypeScriptGenerationConfig; + +return new DirectoryTypeScriptGenerationConfig( + directory: 'src/Web/types', +); +``` + +This creates a directory tree of individual `.ts` files, making it easier to navigate your types. Each namespace gets its own file, and imports between files are handled automatically. diff --git a/packages/generation/src/TypeScript/AsType.php b/packages/generation/src/TypeScript/AsType.php new file mode 100644 index 000000000..dd4fc72e4 --- /dev/null +++ b/packages/generation/src/TypeScript/AsType.php @@ -0,0 +1,13 @@ +console->writeln(); + + $output = $this->generator->generate(); + + if ($output->isEmpty()) { + $this->console->warning('No types found to generate.'); + return; + } + + $writer = $this->container->get($this->config->writer); + $writer->write($output); + + $this->console->success(sprintf( + 'Generated %d type definitions across %d namespaces.', + count($output->getAllDefinitions()), + count($output->getNamespaces()), + )); + } +} diff --git a/packages/generation/src/TypeScript/GenericTypeScriptGenerator.php b/packages/generation/src/TypeScript/GenericTypeScriptGenerator.php new file mode 100644 index 000000000..993eb5866 --- /dev/null +++ b/packages/generation/src/TypeScript/GenericTypeScriptGenerator.php @@ -0,0 +1,62 @@ +repository = new TypesRepository(); + + foreach ($this->config->sources as $className) { + $this->include($className); + } + + $grouped = []; + + foreach ($this->repository->getAll() as $definition) { + $namespace = $definition->namespace; + $grouped[$namespace] ??= []; + $grouped[$namespace][] = $definition; + } + + ksort($grouped); + + return new TypeScriptOutput( + namespaces: $grouped, + ); + } + + public function include(string $className): void + { + if ($this->repository->has($className)) { + return; + } + + $type = new TypeReflector($className); + + if ($type->isEnum()) { + $this->repository->add($this->enumResolver->resolve($type, $this)); + return; + } + + if ($type->isClass() || $type->isInterface()) { + $this->repository->add($this->classResolver->resolve($type, $this)); + return; + } + } +} diff --git a/packages/generation/src/TypeScript/InterfaceDefinition.php b/packages/generation/src/TypeScript/InterfaceDefinition.php new file mode 100644 index 000000000..0abb35162 --- /dev/null +++ b/packages/generation/src/TypeScript/InterfaceDefinition.php @@ -0,0 +1,33 @@ +class, '\\')) { + return ''; + } + + return Str\before_last($this->class, '\\'); + } + } + + /** + * @param PropertyDefinition[] $properties + */ + public function __construct( + public string $class, + public TypeReflector $originalType, + public array $properties, + ) {} +} diff --git a/packages/generation/src/TypeScript/PropertyDefinition.php b/packages/generation/src/TypeScript/PropertyDefinition.php new file mode 100644 index 000000000..a6ad3feb8 --- /dev/null +++ b/packages/generation/src/TypeScript/PropertyDefinition.php @@ -0,0 +1,23 @@ +asClass(); + $properties = []; + + foreach ($class->getPublicProperties() as $property) { + $properties[] = $this->resolveProperty($property, $generator); + } + + return new InterfaceDefinition( + class: $type->getName(), + originalType: $type, + properties: $properties, + ); + } + + private function resolveProperty(PropertyReflector $property, TypeScriptGenerator $generator): PropertyDefinition + { + $type = $property->getType(); + + if ($type->isIterable()) { + $elementTypeReflector = $property->getIterableType(); + + if ($elementTypeReflector !== null) { + $result = $this->resolveType($elementTypeReflector, $generator); + + return new PropertyDefinition( + name: $property->getName(), + definition: $result->type . '[]', + isNullable: $property->isNullable(), + fqcn: $result->fqcn, + ); + } + + return new PropertyDefinition( + name: $property->getName(), + definition: 'any[]', + isNullable: $property->isNullable(), + ); + } + + if ($type->isUnion() || $type->isIntersection()) { + $parts = $type->split(); + $resolvedTypes = []; + $referencedClasses = []; + + foreach ($parts as $part) { + if ($part->getName() === 'null') { + continue; + } + + $result = $this->resolveType($part, $generator); + $resolvedTypes[] = $result->type; + + if ($result->fqcn !== null) { + $referencedClasses[] = $result->fqcn; + } + } + + $symbol = $type->isIntersection() ? '&' : '|'; + + return new PropertyDefinition( + name: $property->getName(), + definition: implode(" {$symbol} ", $resolvedTypes), + isNullable: $property->isNullable(), + fqcn: count($referencedClasses) === 1 ? $referencedClasses[0] : null, + ); + } + + $result = $this->resolveType($type, $generator); + + return new PropertyDefinition( + name: $property->getName(), + definition: $result->type, + isNullable: $property->isNullable(), + fqcn: $result->fqcn, + ); + } + + private function resolveType(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + { + foreach ($this->config->resolvers as $resolverClass) { + $resolver = $this->container->get($resolverClass); + + if ($resolver->canResolve($type)) { + return $resolver->resolve($type, $generator); + } + } + + return new ResolvedType('any'); + } +} diff --git a/packages/generation/src/TypeScript/StructureResolvers/EnumStructureResolver.php b/packages/generation/src/TypeScript/StructureResolvers/EnumStructureResolver.php new file mode 100644 index 000000000..7293fec25 --- /dev/null +++ b/packages/generation/src/TypeScript/StructureResolvers/EnumStructureResolver.php @@ -0,0 +1,57 @@ + $this->resolveType(new TypeReflector($case), $generator), + array: $type->asEnum()->getReflectionCases(), + ), + ); + + return new TypeDefinition( + class: $type->getName(), + originalType: $type, + definition: $typeScriptType, + isNullable: $type->isNullable(), + ); + } + + private function resolveType(TypeReflector $type, TypeScriptGenerator $generator): string + { + foreach ($this->config->resolvers as $resolverClass) { + $resolver = $this->container->get($resolverClass); + + if ($resolver->canResolve($type)) { + return $resolver->resolve($type, $generator)->type; + } + } + + throw new RuntimeException('No suitable type resolver found for type: ' . $type->getName()); + } +} diff --git a/packages/generation/src/TypeScript/TypeDefinition.php b/packages/generation/src/TypeScript/TypeDefinition.php new file mode 100644 index 000000000..5c08b0443 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeDefinition.php @@ -0,0 +1,31 @@ +class, '\\')) { + return ''; + } + + return Str\before_last($this->class, '\\'); + } + } + + public function __construct( + public string $class, + public TypeReflector $originalType, + public string $definition, + public bool $isNullable, + ) {} +} diff --git a/packages/generation/src/TypeScript/TypeDefinitionGenerator.php b/packages/generation/src/TypeScript/TypeDefinitionGenerator.php new file mode 100644 index 000000000..6fe0264a3 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeDefinitionGenerator.php @@ -0,0 +1,21 @@ +implements(TypeResolver::class)) { + $this->discoveryItems->add($location, [ + $class->getName(), + $class->getAttribute(Priority::class)->priority ?? Priority::NORMAL, + ]); + } + } + + public function apply(): void + { + $this->config->resolvers = arr([...$this->discoveryItems]) + ->sortByCallback(fn (array $a, array $b) => $a[1] <=> $b[1]) + ->map(fn (array $item) => $item[0]) + ->toArray(); + } +} diff --git a/packages/generation/src/TypeScript/TypeResolvers/ClassReferenceTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/ClassReferenceTypeResolver.php new file mode 100644 index 000000000..60857e4a7 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeResolvers/ClassReferenceTypeResolver.php @@ -0,0 +1,37 @@ +isEnum() || $type->isEnumCase()) { + return false; + } + + return $type->isClass() || $type->isInterface(); + } + + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + { + $generator->include($type->getName()); + + return new ResolvedType( + type: $type->getShortName(), + fqcn: $type->getName(), + ); + } +} diff --git a/packages/generation/src/TypeScript/TypeResolvers/DateTimeTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/DateTimeTypeResolver.php new file mode 100644 index 000000000..16afb2713 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeResolvers/DateTimeTypeResolver.php @@ -0,0 +1,27 @@ +matches(DateTimeInterface::class) || $type->matches(NativeDateTimeInterface::class); + } + + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + { + return new ResolvedType('string'); + } +} diff --git a/packages/generation/src/TypeScript/TypeResolvers/EnumCaseTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/EnumCaseTypeResolver.php new file mode 100644 index 000000000..c6ed14ccb --- /dev/null +++ b/packages/generation/src/TypeScript/TypeResolvers/EnumCaseTypeResolver.php @@ -0,0 +1,31 @@ +isEnumCase(); + } + + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + { + $case = $type->asEnumCase()->getValue(); + $value = $case instanceof BackedEnum + ? $case->value + : $case->name; + + return new ResolvedType(is_string($value) ? "'{$value}'" : $value); + } +} diff --git a/packages/generation/src/TypeScript/TypeResolvers/EnumReferenceTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/EnumReferenceTypeResolver.php new file mode 100644 index 000000000..b5ef92dfd --- /dev/null +++ b/packages/generation/src/TypeScript/TypeResolvers/EnumReferenceTypeResolver.php @@ -0,0 +1,33 @@ +isEnum() && ! $type->isEnumCase(); + } + + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + { + $generator->include($type->getName()); + + return new ResolvedType( + type: $type->getShortName(), + fqcn: $type->getName(), + ); + } +} diff --git a/packages/generation/src/TypeScript/TypeResolvers/MixedTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/MixedTypeResolver.php new file mode 100644 index 000000000..ffda7b3a5 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeResolvers/MixedTypeResolver.php @@ -0,0 +1,28 @@ +isBuiltIn() && in_array($type->getName(), ['string', 'int', 'float', 'bool'], strict: true); + } + + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + { + return new ResolvedType(match ($type->getName()) { + 'string' => 'string', + 'int', 'float' => 'number', + 'bool' => 'boolean', + }); + } +} diff --git a/packages/generation/src/TypeScript/TypeScriptGenerationConfig.php b/packages/generation/src/TypeScript/TypeScriptGenerationConfig.php new file mode 100644 index 000000000..fdcc7c892 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeScriptGenerationConfig.php @@ -0,0 +1,37 @@ + + */ + public string $writer { + get; + } + + /** + * The list of source classes to generate types for. + * + * @var array + */ + public array $sources { + get; + set; + } + + /** + * The list of type resolvers for property-level type mapping. + * + * @var array> + */ + public array $resolvers { + get; + set; + } +} diff --git a/packages/generation/src/TypeScript/TypeScriptGenerator.php b/packages/generation/src/TypeScript/TypeScriptGenerator.php new file mode 100644 index 000000000..b07862a2e --- /dev/null +++ b/packages/generation/src/TypeScript/TypeScriptGenerator.php @@ -0,0 +1,21 @@ +get(TypeScriptGenerationConfig::class), + classResolver: $container->get(ClassStructureResolver::class), + enumResolver: $container->get(EnumStructureResolver::class), + ); + } +} diff --git a/packages/generation/src/TypeScript/TypeScriptOutput.php b/packages/generation/src/TypeScript/TypeScriptOutput.php new file mode 100644 index 000000000..2c8040880 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeScriptOutput.php @@ -0,0 +1,64 @@ + $namespaces Type definitions grouped by namespace + * @param array $imports Additional import statements to include + */ + public function __construct( + public array $namespaces = [], + public array $imports = [], + ) {} + + /** + * Gets all type definitions across all namespaces. + * + * @return TypeDefinition[] + */ + public function getAllDefinitions(): array + { + $definitions = []; + + foreach ($this->namespaces as $namespace => $namespaceDefinitions) { + $definitions = [...$definitions, ...$namespaceDefinitions]; + } + + return $definitions; + } + + /** + * Gets all namespace names. + * + * @return string[] + */ + public function getNamespaces(): array + { + return array_keys($this->namespaces); + } + + /** + * Gets definitions for a specific namespace. + * + * @return TypeDefinition[] + */ + public function getDefinitionsForNamespace(string $namespace): array + { + return $this->namespaces[$namespace] ?? []; + } + + /** + * Checks if output has any definitions. + */ + public function isEmpty(): bool + { + return $this->namespaces === []; + } +} diff --git a/packages/generation/src/TypeScript/TypeScriptWriter.php b/packages/generation/src/TypeScript/TypeScriptWriter.php new file mode 100644 index 000000000..1fa8f1593 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeScriptWriter.php @@ -0,0 +1,16 @@ +getAttribute(AsType::class)) { + $this->discoveryItems->add($location, [$class->getName()]); + } + + // TODO(innocenzi): other heuristics for implicit opt-in + // eg. automatically convert DTOs, excluding vendor ones + + if ($location->isVendor()) { + return; + } + + if ($class->implements(UnitEnum::class)) { + $this->discoveryItems->add($location, [$class->getName()]); + } + } + + public function apply(): void + { + foreach ($this->discoveryItems as [$className]) { + $this->config->sources[] = $className; + } + } +} diff --git a/packages/generation/src/TypeScript/TypesRepository.php b/packages/generation/src/TypeScript/TypesRepository.php new file mode 100644 index 000000000..2f83ba309 --- /dev/null +++ b/packages/generation/src/TypeScript/TypesRepository.php @@ -0,0 +1,47 @@ + + */ + private array $definitions = []; + + /** + * Adds a type definition to the repository. + */ + public function add(TypeDefinition|InterfaceDefinition $definition): void + { + $this->definitions[$definition->class] = $definition; + } + + /** + * Gets a type definition by class name. + */ + public function get(string $class): TypeDefinition|InterfaceDefinition|null + { + return $this->definitions[$class] ?? null; + } + + /** + * Checks if a definition exists for the given class. + */ + public function has(string $class): bool + { + return isset($this->definitions[$class]); + } + + /** + * Gets all type definitions. + * + * @return array + */ + public function getAll(): array + { + return array_values($this->definitions); + } +} diff --git a/packages/generation/src/TypeScript/Writers/DirectoryTypeScriptGenerationConfig.php b/packages/generation/src/TypeScript/Writers/DirectoryTypeScriptGenerationConfig.php new file mode 100644 index 000000000..98fccddae --- /dev/null +++ b/packages/generation/src/TypeScript/Writers/DirectoryTypeScriptGenerationConfig.php @@ -0,0 +1,29 @@ + */ + public array $sources = []; + + /** @var array> */ + public array $resolvers = []; + + /** + * @param string $directory The output directory for the generated TypeScript files. + */ + public function __construct( + public readonly string $directory, + ) {} +} diff --git a/packages/generation/src/TypeScript/Writers/DirectoryWriter.php b/packages/generation/src/TypeScript/Writers/DirectoryWriter.php new file mode 100644 index 000000000..647d0cbf8 --- /dev/null +++ b/packages/generation/src/TypeScript/Writers/DirectoryWriter.php @@ -0,0 +1,193 @@ +config->directory); + Filesystem\ensure_directory_empty($this->config->directory); + + $fileGroups = []; + + foreach ($output->namespaces as $namespace => $definitions) { + $filename = $this->namespaceToFilePath($namespace); + + $fileGroups[$filename] ??= []; + $fileGroups[$filename][$namespace] = $definitions; + } + + foreach ($fileGroups as $filename => $namespaces) { + Filesystem\write_file( + filename: $filename, + content: $this->generateFileContent($namespaces, $output), + ); + } + } + + /** + * @param array> $namespaces + */ + private function generateFileContent(array $namespaces, TypeScriptOutput $output): string + { + $lines = []; + $lines[] = '/*'; + $lines[] = '|----------------------------------------------------------------'; + $lines[] = '| This file contains TypeScript definitions generated by Tempest.'; + $lines[] = '|----------------------------------------------------------------'; + $lines[] = '*/'; + $lines[] = ''; + + $imports = $this->collectImports($namespaces, $output); + + if ($imports !== []) { + foreach ($imports as $import) { + $lines[] = $import; + } + + $lines[] = ''; + } + + foreach ($namespaces as $namespace => $definitions) { + foreach ($definitions as $definition) { + $lines[] = $this->generateDefinition($definition, $namespace); + $lines[] = ''; + } + } + + return (string) Arr\implode($lines, glue: "\n"); + } + + /** + * @param array> $namespaces + * @return array + */ + private function collectImports(array $namespaces, TypeScriptOutput $output): array + { + $imports = []; + $currentNamespaces = array_keys($namespaces); + + foreach ($namespaces as $namespace => $definitions) { + foreach ($definitions as $definition) { + if ($definition instanceof InterfaceDefinition) { + foreach ($definition->properties as $property) { + if ($property->fqcn === null) { + continue; + } + + $targetNamespace = Str\before_last($property->fqcn, '\\'); + + if (in_array($targetNamespace, $currentNamespaces, strict: true)) { + continue; + } + + $typeName = Str\after_last($property->fqcn, '\\'); + $importPath = $this->computeImportPath($namespace, $targetNamespace); + $importKey = "{$importPath}::{$typeName}"; + + $imports[$importKey] ??= "import type { {$typeName} } from '{$importPath}';"; + } + } + } + } + + return array_values($imports); + } + + private function generateDefinition(TypeDefinition|InterfaceDefinition $definition, string $currentNamespace): string + { + $typeName = Str\after_last($definition->class, '\\'); + + if ($definition instanceof TypeDefinition) { + return "export type {$typeName} = {$definition->definition};"; + } + + $lines = []; + $lines[] = "export interface {$typeName} {"; + + foreach ($definition->properties as $property) { + $lines[] = sprintf( + ' %s%s: %s;', + $property->name, + $property->isNullable ? '?' : '', + $this->resolveTypeReference($property), + ); + } + + $lines[] = '}'; + + return (string) Arr\implode($lines, glue: "\n"); + } + + private function resolveTypeReference(PropertyDefinition $property): string + { + if ($property->fqcn === null) { + return $property->definition; + } + + $targetTypeName = Str\after_last($property->fqcn, '\\'); + $arrayBrackets = Str\ends_with($property->definition, '[]') ? '[]' : ''; + + return $targetTypeName . $arrayBrackets; + } + + private function namespaceToFilePath(string $namespace): string + { + $parts = explode('\\', $namespace); + $kebabParts = Arr\map($parts, fn (string $part) => Str\to_kebab_case($part)); + $path = (string) Arr\implode($kebabParts, glue: '/'); + + return $this->config->directory . '/' . $path . '/index.ts'; + } + + private function computeImportPath(string $sourceNamespace, string $targetNamespace): string + { + $sourceParts = explode('\\', $sourceNamespace); + $targetParts = explode('\\', $targetNamespace); + + $commonLength = 0; + $minLength = min(count($sourceParts), count($targetParts)); + + for ($i = 0; $i < $minLength; $i++) { + if ($sourceParts[$i] !== $targetParts[$i]) { + break; + } + + $commonLength++; + } + + $upLevels = count($sourceParts) - $commonLength; + $targetDiff = array_slice($targetParts, $commonLength); + $targetKebab = Arr\map($targetDiff, fn (string $part) => Str\to_kebab_case($part)); + + if ($upLevels === 0 && count($targetKebab) === 0) { + return './'; + } + + $upPath = $upLevels > 0 ? str_repeat('../', $upLevels) : './'; + $downPath = count($targetKebab) > 0 ? (string) Arr\implode($targetKebab, glue: '/') : ''; + + $fullPath = rtrim($upPath . $downPath, '/'); + + return $fullPath; + } +} diff --git a/packages/generation/src/TypeScript/Writers/NamespacedFileWriter.php b/packages/generation/src/TypeScript/Writers/NamespacedFileWriter.php new file mode 100644 index 000000000..c8c5a7a98 --- /dev/null +++ b/packages/generation/src/TypeScript/Writers/NamespacedFileWriter.php @@ -0,0 +1,145 @@ +config->filename); + Filesystem\write_file($this->config->filename, $this->generateContent($output)); + } + + private function generateContent(TypeScriptOutput $output): string + { + $lines = []; + $lines[] = '/*'; + $lines[] = '|----------------------------------------------------------------'; + $lines[] = '| This file contains TypeScript definitions generated by Tempest.'; + $lines[] = '|----------------------------------------------------------------'; + $lines[] = '*/'; + $lines[] = ''; + + if ($output->imports !== []) { + foreach ($output->imports as $import) { + $lines[] = $import; + } + + $lines[] = ''; + } + + foreach ($output->namespaces as $namespace => $definitions) { + $lines[] = $this->generateNamespace($namespace, $definitions); + } + + return (string) Arr\implode($lines, glue: "\n"); + } + + /** + * @param TypeDefinition[]|InterfaceDefinition[] $definitions + */ + private function generateNamespace(string $namespace, array $definitions): string + { + $lines = []; + $lines[] = sprintf('export namespace %s {', Str\replace($namespace, '\\', '.')); + + foreach ($definitions as $definition) { + $lines[] = $this->generateDefinition($definition); + } + + $lines[] = '}'; + $lines[] = ''; + + return (string) Arr\implode($lines, glue: "\n"); + } + + private function generateDefinition(TypeDefinition|InterfaceDefinition $definition): string + { + $typeName = Str\after_last($definition->class, '\\'); + + if ($definition instanceof TypeDefinition) { + return " export type {$typeName} = {$definition->definition};"; + } + + $lines = []; + $lines[] = " export interface {$typeName} {"; + + foreach ($definition->properties as $property) { + $lines[] = sprintf( + ' %s%s: %s;', + $property->name, + $property->isNullable ? '?' : '', + $this->resolveTypeReference($property, $definition), + ); + } + + $lines[] = ' }'; + + return (string) Arr\implode($lines, glue: "\n"); + } + + private function resolveTypeReference( + PropertyDefinition $property, + InterfaceDefinition $sourceInterface, + ): string { + if ($property->fqcn === null) { + return $property->definition; + } + + $targetNamespace = Str\before_last($property->fqcn, '\\'); + $targetTypeName = Str\after_last($property->fqcn, '\\'); + $arrayBrackets = Str\ends_with($property->definition, '[]') ? '[]' : ''; + + // Same namespace, use short name + if ($sourceInterface->namespace === $targetNamespace) { + return $targetTypeName . $arrayBrackets; + } + + // Different namespace, relative path + $relativePath = $this->computeRelativeNamespacePath($sourceInterface->namespace, $targetNamespace); + + return $relativePath . $targetTypeName . $arrayBrackets; + } + + private function computeRelativeNamespacePath(string $sourceNamespace, string $targetNamespace): string + { + $commonLength = 0; + $sourceParts = explode('\\', $sourceNamespace); + $targetParts = explode('\\', $targetNamespace); + $minLength = min(count($sourceParts), count($targetParts)); + + for ($i = 0; $i < $minLength; $i++) { + if ($sourceParts[$i] !== $targetParts[$i]) { + break; + } + + $commonLength++; + } + + $targetDiff = array_slice($targetParts, $commonLength); + + if ($targetDiff === []) { + return ''; + } + + return implode('.', $targetDiff) . '.'; + } +} diff --git a/packages/generation/src/TypeScript/Writers/NamespacedTypeScriptGenerationConfig.php b/packages/generation/src/TypeScript/Writers/NamespacedTypeScriptGenerationConfig.php new file mode 100644 index 000000000..311096b23 --- /dev/null +++ b/packages/generation/src/TypeScript/Writers/NamespacedTypeScriptGenerationConfig.php @@ -0,0 +1,29 @@ + */ + public array $sources = []; + + /** @var array> */ + public array $resolvers = []; + + /** + * @param string $filename The output filename for the generated TypeScript definitions. + */ + public function __construct( + public readonly string $filename, + ) {} +} diff --git a/packages/generation/src/TypeScript/typescript.config.php b/packages/generation/src/TypeScript/typescript.config.php new file mode 100644 index 000000000..a413341f6 --- /dev/null +++ b/packages/generation/src/TypeScript/typescript.config.php @@ -0,0 +1,9 @@ +directory = sys_get_temp_dir() . '/tempest_typescript_integration_' . uniqid(); + + Filesystem\ensure_directory_exists($this->directory); + } + + #[PostCondition] + protected function cleanup(): void + { + Filesystem\delete($this->directory); + } + + #[Test] + public function generates_types(): void + { + $path = $this->directory . '/types.d.ts'; + + $container = new GenericContainer(); + $config = new NamespacedTypeScriptGenerationConfig(filename: $path); + $config->resolvers = [ + EnumCaseTypeResolver::class, + ScalarTypeResolver::class, + DateTimeTypeResolver::class, + EnumReferenceTypeResolver::class, + ClassReferenceTypeResolver::class, + MixedTypeResolver::class, + ]; + $config->sources = [User::class]; + + $generator = new GenericTypeScriptGenerator( + config: $config, + classResolver: new ClassStructureResolver($config, $container), + enumResolver: new EnumStructureResolver($config, $container), + ); + + new NamespacedFileWriter($config)->write($generator->generate()); + $content = Filesystem\read_file($path); + + $this->assertStringContainsString('export namespace Tempest.Generation.Tests.TypeScript.Fixtures {', $content); + $this->assertStringContainsString('export interface User {', $content); + $this->assertStringContainsString('full_name: string;', $content); + $this->assertStringContainsString('email: string;', $content); + $this->assertStringContainsString('created_at: string;', $content); + $this->assertStringContainsString('roles: Security.Role[];', $content); + $this->assertStringContainsString('settings: Settings;', $content); + $this->assertStringContainsString('export interface Settings {', $content); + $this->assertStringContainsString('theme: Theme;', $content); + $this->assertStringContainsString('sidebar_open: boolean;', $content); + $this->assertStringContainsString("export type Theme = 'dark' | 'light';", $content); + $this->assertStringContainsString('export namespace Tempest.Generation.Tests.TypeScript.Fixtures.Security {', $content); + $this->assertStringContainsString('export interface Role {', $content); + $this->assertStringContainsString('name: string;', $content); + $this->assertStringContainsString('permissions: Permission[];', $content); + } +} diff --git a/packages/generation/tests/TypeScript/StructureResolvers/ClassStructureResolverTest.php b/packages/generation/tests/TypeScript/StructureResolvers/ClassStructureResolverTest.php new file mode 100644 index 000000000..578ca75fd --- /dev/null +++ b/packages/generation/tests/TypeScript/StructureResolvers/ClassStructureResolverTest.php @@ -0,0 +1,82 @@ +resolvers = [ + ScalarTypeResolver::class, + DateTimeTypeResolver::class, + EnumReferenceTypeResolver::class, + ClassReferenceTypeResolver::class, + MixedTypeResolver::class, + ]; + + $this->resolver = new ClassStructureResolver($config, $container); + $this->generator = new GenericTypeScriptGenerator( + config: $config, + classResolver: $this->resolver, + enumResolver: new EnumStructureResolver($config, $container), + ); + } + + #[Test] + public function resolves_class_to_interface_definition(): void + { + $type = new TypeReflector(Badge::class); + + $result = $this->resolver->resolve($type, $this->generator); + + $this->assertInstanceOf(InterfaceDefinition::class, $result); + $this->assertSame(Badge::class, $result->class); + $this->assertCount(2, $result->properties); + } + + #[Test] + public function resolves_scalar_properties(): void + { + $type = new TypeReflector(Badge::class); + + $result = $this->resolver->resolve($type, $this->generator); + + $this->assertSame('name', $result->properties[0]->name); + $this->assertSame('string', $result->properties[0]->definition); + $this->assertFalse($result->properties[0]->isNullable); + + $this->assertSame('value', $result->properties[1]->name); + $this->assertSame('number', $result->properties[1]->definition); + $this->assertFalse($result->properties[1]->isNullable); + } +} + +final class Badge +{ + public string $name; + public int $value; +} diff --git a/packages/generation/tests/TypeScript/TypeScriptGenerationTest.php b/packages/generation/tests/TypeScript/TypeScriptGenerationTest.php new file mode 100644 index 000000000..764d0331f --- /dev/null +++ b/packages/generation/tests/TypeScript/TypeScriptGenerationTest.php @@ -0,0 +1,88 @@ +directory = sys_get_temp_dir() . '/tempest_typescript_integration_' . uniqid(); + + Filesystem\ensure_directory_exists($this->directory); + } + + #[PostCondition] + protected function cleanup(): void + { + Filesystem\delete($this->directory); + } + + #[Test] + public function generates_types(): void + { + $path = $this->directory . '/types.d.ts'; + + $container = new GenericContainer(); + $config = new NamespacedTypeScriptGenerationConfig(filename: $path); + $config->resolvers = [ + ScalarTypeResolver::class, + DateTimeTypeResolver::class, + EnumCaseTypeResolver::class, + EnumReferenceTypeResolver::class, + ClassReferenceTypeResolver::class, + MixedTypeResolver::class, + ]; + $config->sources = [User::class]; + + $generator = new GenericTypeScriptGenerator( + config: $config, + classResolver: new ClassStructureResolver($config, $container), + enumResolver: new EnumStructureResolver($config, $container), + ); + + new NamespacedFileWriter($config)->write($generator->generate()); + + $content = Filesystem\read_file($path); + + $this->assertStringContainsString('export namespace Tempest.Generation.Tests.TypeScript.Fixtures {', $content); + $this->assertStringContainsString('export interface User {', $content); + $this->assertStringContainsString('full_name: string;', $content); + $this->assertStringContainsString('email: string;', $content); + $this->assertStringContainsString('age: number;', $content); + $this->assertStringContainsString('created_at: string;', $content); + $this->assertStringContainsString('roles: Security.Role[];', $content); + $this->assertStringContainsString('settings: Settings;', $content); + $this->assertStringContainsString('export interface Settings {', $content); + $this->assertStringContainsString('theme: Theme;', $content); + $this->assertStringContainsString('sidebar_open: boolean;', $content); + $this->assertStringContainsString("export type Theme = 'dark' | 'light';", $content); + $this->assertStringContainsString('export namespace Tempest.Generation.Tests.TypeScript.Fixtures.Security {', $content); + $this->assertStringContainsString('export interface Role {', $content); + $this->assertStringContainsString('name: string;', $content); + $this->assertStringContainsString('permissions: Permission[];', $content); + } +} diff --git a/packages/generation/tests/TypeScript/Writers/DirectoryWriterTest.php b/packages/generation/tests/TypeScript/Writers/DirectoryWriterTest.php new file mode 100644 index 000000000..090d8b079 --- /dev/null +++ b/packages/generation/tests/TypeScript/Writers/DirectoryWriterTest.php @@ -0,0 +1,123 @@ +directory = sys_get_temp_dir() . '/tempest_typescript_directory_' . uniqid(); + + Filesystem\ensure_directory_exists($this->directory); + } + + #[PostCondition] + protected function cleanup(): void + { + Filesystem\delete($this->directory); + } + + #[Test] + public function writes_types_in_directories(): void + { + $directory = $this->directory . '/types'; + $config = new DirectoryTypeScriptGenerationConfig(directory: $directory); + $writer = new DirectoryWriter($config); + + $userInterface = new InterfaceDefinition( + class: 'App\\Models\\User', + originalType: new TypeReflector('string'), + properties: [ + new PropertyDefinition( + name: 'id', + definition: 'number', + isNullable: true, + ), + new PropertyDefinition( + name: 'name', + definition: 'string', + isNullable: false, + ), + new PropertyDefinition( + name: 'role', + definition: 'Role', + isNullable: false, + fqcn: 'App\\Security\\Role', + ), + ], + ); + + $postType = new TypeDefinition( + class: 'App\\Models\\Post', + originalType: new TypeReflector('string'), + definition: 'string', + isNullable: false, + ); + + $roleInterface = new InterfaceDefinition( + class: 'App\\Security\\Role', + originalType: new TypeReflector('string'), + properties: [ + new PropertyDefinition( + name: 'name', + definition: 'string', + isNullable: false, + ), + ], + ); + + $themeEnum = new TypeDefinition( + class: 'App\\Models\\Theme', + originalType: new TypeReflector('string'), + definition: "'dark' | 'light'", + isNullable: false, + ); + + $output = new TypeScriptOutput( + namespaces: [ + 'App\\Models' => [$userInterface, $postType, $themeEnum], + 'App\\Security' => [$roleInterface], + ], + ); + + $writer->write($output); + + $this->assertFileExists($directory . '/app/models/index.ts'); + $this->assertFileExists($directory . '/app/security/index.ts'); + + $models = Filesystem\read_file($directory . '/app/models/index.ts'); + + $this->assertStringContainsString('This file contains TypeScript definitions generated by Tempest', $models); + $this->assertStringContainsString("import type { Role } from '../security';", $models); + $this->assertStringContainsString('export interface User {', $models); + $this->assertStringContainsString('id?: number;', $models); + $this->assertStringContainsString('name: string;', $models); + $this->assertStringContainsString('role: Role;', $models); + $this->assertStringContainsString('export type Post = string;', $models); + $this->assertStringContainsString("export type Theme = 'dark' | 'light';", $models); + + $security = Filesystem\read_file($directory . '/app/security/index.ts'); + + $this->assertStringContainsString('export interface Role {', $security); + $this->assertStringContainsString('name: string;', $security); + $this->assertStringNotContainsString('import', $security); + } +} diff --git a/packages/generation/tests/TypeScript/Writers/NamespacedFileWriterTest.php b/packages/generation/tests/TypeScript/Writers/NamespacedFileWriterTest.php new file mode 100644 index 000000000..b4f590467 --- /dev/null +++ b/packages/generation/tests/TypeScript/Writers/NamespacedFileWriterTest.php @@ -0,0 +1,121 @@ +directory = sys_get_temp_dir() . '/tempest_typescript_integration_' . uniqid(); + + Filesystem\ensure_directory_exists($this->directory); + } + + #[PostCondition] + protected function cleanup(): void + { + Filesystem\delete($this->directory); + } + + #[Test] + public function writes_types_file(): void + { + $outputPath = $this->directory . '/types.d.ts'; + $config = new NamespacedTypeScriptGenerationConfig(filename: $outputPath); + $writer = new NamespacedFileWriter($config); + + $userInterface = new InterfaceDefinition( + class: 'App\\Models\\User', + originalType: new TypeReflector('string'), + properties: [ + new PropertyDefinition( + name: 'id', + definition: 'number', + isNullable: false, + ), + new PropertyDefinition( + name: 'username', + definition: 'string', + isNullable: false, + ), + new PropertyDefinition( + name: 'email', + definition: 'string', + isNullable: true, + ), + ], + ); + + $arrayType = new TypeDefinition( + class: 'App\\Models\\Tags', + originalType: new TypeReflector('array'), + definition: 'Array', + isNullable: false, + ); + + $unionType = new TypeDefinition( + class: 'App\\Models\\Role', + originalType: new TypeReflector('string'), + definition: "'admin' | 'user' | 'guest'", + isNullable: false, + ); + + $intersectionType = new TypeDefinition( + class: 'App\\Models\\AdminUser', + originalType: new TypeReflector('object'), + definition: 'User & { permissions: Array }', + isNullable: false, + ); + + $controller = new TypeDefinition( + class: 'App\\Controllers\\HomeController', + originalType: new TypeReflector('bool'), + definition: 'boolean', + isNullable: false, + ); + + $output = new TypeScriptOutput( + namespaces: [ + 'App\\Models' => [ + $userInterface, + $arrayType, + $unionType, + $intersectionType, + ], + 'App\\Controllers' => [$controller], + ], + ); + + $writer->write($output); + $content = Filesystem\read_file($outputPath); + + $this->assertStringContainsString('export namespace App.Models {', $content); + $this->assertStringContainsString('export namespace App.Controllers {', $content); + $this->assertStringContainsString('export interface User {', $content); + $this->assertStringContainsString('id: number;', $content); + $this->assertStringContainsString('username: string;', $content); + $this->assertStringContainsString('email?: string;', $content); + $this->assertStringContainsString('export type Tags = Array;', $content); + $this->assertStringContainsString("export type Role = 'admin' | 'user' | 'guest';", $content); + $this->assertStringContainsString('export type AdminUser = User & { permissions: Array };', $content); + $this->assertStringContainsString('export type HomeController = boolean;', $content); + } +} diff --git a/packages/reflection/src/EnumReflector.php b/packages/reflection/src/EnumReflector.php new file mode 100644 index 000000000..c5fdd3539 --- /dev/null +++ b/packages/reflection/src/EnumReflector.php @@ -0,0 +1,143 @@ +|TEnumName|PHPReflectionEnum $reflectionEnum + */ + public function __construct(string|object $reflectionEnum) + { + if (is_string($reflectionEnum)) { + $reflectionEnum = new PHPReflectionEnum($reflectionEnum); + } elseif ($reflectionEnum instanceof self) { + $reflectionEnum = $reflectionEnum->getReflection(); + } elseif (! $reflectionEnum instanceof PHPReflectionEnum) { + $reflectionEnum = new PHPReflectionEnum($reflectionEnum); + } + + $this->reflectionEnum = $reflectionEnum; + } + + public function getReflection(): PHPReflectionEnum + { + return $this->reflectionEnum; + } + + /** + * @return class-string + */ + public function getName(): string + { + return $this->reflectionEnum->getName(); + } + + public function getShortName(): string + { + return $this->reflectionEnum->getShortName(); + } + + public function getFileName(): string|false + { + return $this->reflectionEnum->getFileName(); + } + + public function getType(): TypeReflector + { + return new TypeReflector($this->reflectionEnum); + } + + public function isBacked(): bool + { + return $this->reflectionEnum->isBacked(); + } + + public function getBackingType(): ?TypeReflector + { + $backingType = $this->reflectionEnum->getBackingType(); + + if ($backingType === null) { + return null; + } + + return new TypeReflector($backingType); + } + + /** + * @return UnitEnum[] + */ + public function getCases(): array + { + return $this->memoize( + key: 'cases', + closure: fn () => array_map( + callback: fn (ReflectionEnumUnitCase $case) => $case->getValue(), + array: $this->getReflectionCases(), + ), + ); + } + + /** + * @return \ReflectionEnumUnitCase[]|\ReflectionEnumBackedCase[] + */ + public function getReflectionCases(): array + { + return $this->reflectionEnum->getCases(); + } + + public function hasCase(string $name): bool + { + return $this->reflectionEnum->hasCase($name); + } + + public function getCase(string $name): UnitEnum + { + return $this->reflectionEnum->getCase($name)->getValue(); + } + + public function is(string $className): bool + { + return $this->getType()->matches($className); + } + + public function implements(string $interface): bool + { + return $this->getType()->matches($interface); + } + + private function memoize(string $key, Closure $closure): mixed + { + if (! array_key_exists($key, $this->memoize)) { + $this->memoize[$key] = $closure(); + } + + return $this->memoize[$key]; + } + + public function __serialize(): array + { + return ['name' => $this->getName()]; + } + + public function __unserialize(array $data): void + { + $this->reflectionEnum = new PHPReflectionEnum($data['name']); + } +} diff --git a/packages/reflection/src/TypeReflector.php b/packages/reflection/src/TypeReflector.php index 64429ba19..b951adc2e 100644 --- a/packages/reflection/src/TypeReflector.php +++ b/packages/reflection/src/TypeReflector.php @@ -9,6 +9,8 @@ use Generator; use Iterator; use ReflectionClass as PHPReflectionClass; +use ReflectionClassConstant as PHPReflectionClassConstant; +use ReflectionEnumUnitCase as PHPReflectionEnumUnitCase; use ReflectionIntersectionType as PHPReflectionIntersectionType; use ReflectionNamedType as PHPReflectionNamedType; use ReflectionParameter as PHPReflectionParameter; @@ -65,6 +67,11 @@ public function asClass(): ClassReflector return new ClassReflector($this->cleanDefinition); } + public function asEnum(): EnumReflector + { + return new EnumReflector($this->cleanDefinition); + } + public function equals(string|TypeReflector $type): bool { if (is_string($type)) { @@ -157,6 +164,16 @@ public function isEnum(): bool return $this->isUnitEnum() || $this->isBackedEnum(); } + public function isEnumCase(): bool + { + return $this->reflector instanceof PHPReflectionEnumUnitCase; + } + + public function asEnumCase(): PHPReflectionEnumUnitCase + { + return $this->reflector; + } + public function isUnitEnum(): bool { return $this->matches(UnitEnum::class); @@ -244,6 +261,10 @@ private function resolveDefinition(PHPReflector|PHPReflectionType|string $reflec return $reflector->getName(); } + if ($reflector instanceof PHPReflectionClassConstant) { + return $reflector->getDeclaringClass()->getName(); + } + if ($reflector instanceof PHPReflectionUnionType) { return implode('|', array_map( fn (PHPReflectionType $reflectionType) => $this->resolveDefinition($reflectionType), diff --git a/packages/reflection/tests/EnumReflectorTest.php b/packages/reflection/tests/EnumReflectorTest.php new file mode 100644 index 000000000..398da38d1 --- /dev/null +++ b/packages/reflection/tests/EnumReflectorTest.php @@ -0,0 +1,262 @@ +assertEquals(new ReflectionEnum(TestUnitEnum::class), $reflector->getReflection()); + } + + #[Test] + public function getting_name(): void + { + $reflector = new EnumReflector(TestUnitEnum::class); + $reflection = new ReflectionEnum(TestUnitEnum::class); + + $this->assertSame($reflector->getName(), $reflection->getName()); + } + + #[Test] + public function getting_short_name(): void + { + $reflector = new EnumReflector(TestUnitEnum::class); + $reflection = new ReflectionEnum(TestUnitEnum::class); + + $this->assertSame($reflector->getShortName(), $reflection->getShortName()); + } + + #[Test] + public function getting_file_name(): void + { + $reflector = new EnumReflector(TestUnitEnum::class); + + $this->assertNotFalse($reflector->getFileName()); + $this->assertStringEndsWith('EnumReflectorTest.php', $reflector->getFileName()); + } + + #[Test] + public function getting_type(): void + { + $reflector = new EnumReflector(TestBackedEnum::class); + $type = $reflector->getType(); + + $this->assertInstanceOf(TypeReflector::class, $type); + $this->assertSame(TestBackedEnum::class, $type->getName()); + } + + #[Test] + public function is_backed_for_unit_enum(): void + { + $reflector = new EnumReflector(TestUnitEnum::class); + + $this->assertFalse($reflector->isBacked()); + } + + #[Test] + public function is_backed_for_backed_enum(): void + { + $reflector = new EnumReflector(TestBackedEnum::class); + + $this->assertTrue($reflector->isBacked()); + } + + #[Test] + public function get_backing_type_for_unit_enum(): void + { + $reflector = new EnumReflector(TestUnitEnum::class); + + $this->assertNull($reflector->getBackingType()); + } + + #[Test] + public function get_backing_type_for_backed_enum(): void + { + $reflector = new EnumReflector(TestBackedEnum::class); + $backingType = $reflector->getBackingType(); + + $this->assertInstanceOf(TypeReflector::class, $backingType); + $this->assertSame('string', $backingType->getName()); + } + + #[Test] + public function get_backing_type_for_int_backed_enum(): void + { + $reflector = new EnumReflector(TestIntBackedEnum::class); + $backingType = $reflector->getBackingType(); + + $this->assertInstanceOf(TypeReflector::class, $backingType); + $this->assertSame('int', $backingType->getName()); + } + + #[Test] + public function get_cases(): void + { + $reflector = new EnumReflector(TestUnitEnum::class); + $cases = $reflector->getCases(); + + $this->assertCount(3, $cases); + $this->assertSame(TestUnitEnum::ONE, $cases[0]); + $this->assertSame(TestUnitEnum::TWO, $cases[1]); + $this->assertSame(TestUnitEnum::THREE, $cases[2]); + } + + #[Test] + public function get_cases_for_backed_enum(): void + { + $reflector = new EnumReflector(TestBackedEnum::class); + $cases = $reflector->getCases(); + + $this->assertCount(2, $cases); + $this->assertSame(TestBackedEnum::ACTIVE, $cases[0]); + $this->assertSame(TestBackedEnum::INACTIVE, $cases[1]); + } + + #[Test] + public function has_case(): void + { + $reflector = new EnumReflector(TestUnitEnum::class); + + $this->assertTrue($reflector->hasCase('ONE')); + $this->assertTrue($reflector->hasCase('TWO')); + $this->assertTrue($reflector->hasCase('THREE')); + $this->assertFalse($reflector->hasCase('FOUR')); + } + + #[Test] + public function get_case(): void + { + $reflector = new EnumReflector(TestUnitEnum::class); + + $this->assertSame(TestUnitEnum::ONE, $reflector->getCase('ONE')); + $this->assertSame(TestUnitEnum::TWO, $reflector->getCase('TWO')); + $this->assertSame(TestUnitEnum::THREE, $reflector->getCase('THREE')); + } + + #[Test] + public function is_method(): void + { + $reflector = new EnumReflector(TestBackedEnum::class); + + $this->assertTrue($reflector->is(\BackedEnum::class)); + $this->assertTrue($reflector->is(\UnitEnum::class)); + $this->assertFalse($reflector->is(\stdClass::class)); + } + + #[Test] + public function implements_method(): void + { + $reflector = new EnumReflector(TestEnumWithInterface::class); + + $this->assertTrue($reflector->implements(TestInterface::class)); + $this->assertFalse($reflector->implements(\JsonSerializable::class)); + } + + #[Test] + public function serialize(): void + { + $reflector = new EnumReflector(TestUnitEnum::class); + + $serialized = serialize($reflector); + $unserialized = unserialize($serialized); + + $this->assertEquals($reflector, $unserialized); + } + + #[Test] + public function from_type_reflector(): void + { + $typeReflector = new TypeReflector(TestBackedEnum::class); + $enumReflector = $typeReflector->asEnum(); + + $this->assertInstanceOf(EnumReflector::class, $enumReflector); + $this->assertSame(TestBackedEnum::class, $enumReflector->getName()); + $this->assertTrue($enumReflector->isBacked()); + } + + #[Test] + public function constructor_with_enum_instance(): void + { + $reflector = new EnumReflector(TestUnitEnum::ONE); + + $this->assertSame(TestUnitEnum::class, $reflector->getName()); + } + + #[Test] + public function constructor_with_enum_reflector(): void + { + $reflector1 = new EnumReflector(TestUnitEnum::class); + $reflector2 = new EnumReflector($reflector1); + + $this->assertEquals($reflector1, $reflector2); + } + + #[Test] + public function attribute_support(): void + { + $reflector = new EnumReflector(TestEnumWithAttribute::class); + + $this->assertTrue($reflector->hasAttribute(TestAttribute::class)); + $attribute = $reflector->getAttribute(TestAttribute::class); + $this->assertInstanceOf(TestAttribute::class, $attribute); + $this->assertSame('test-value', $attribute->value); + } +} + +enum TestUnitEnum +{ + case ONE; + case TWO; + case THREE; +} + +enum TestBackedEnum: string +{ + case ACTIVE = 'active'; + case INACTIVE = 'inactive'; +} + +enum TestIntBackedEnum: int +{ + case FIRST = 1; + case SECOND = 2; +} + +interface TestInterface +{ +} + +enum TestEnumWithInterface: string implements TestInterface +{ + case VALUE = 'value'; +} + +#[\Attribute] +class TestAttribute +{ + public function __construct( + public string $value, + ) {} +} + +#[TestAttribute('test-value')] +enum TestEnumWithAttribute +{ + case OPTION_A; + case OPTION_B; +}