diff --git a/docs/2-features/18-typescript.md b/docs/2-features/18-typescript.md index 00965e1986..8ac60e4af6 100644 --- a/docs/2-features/18-typescript.md +++ b/docs/2-features/18-typescript.md @@ -29,31 +29,33 @@ This command scans your marked classes, generates the corresponding TypeScript d 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. +You can add your own resolver by implementing {b`Tempest\Generation\TypeScript\TypeResolver`}. This interface requires a `canResolve()` method to determine if the resolver can handle a given type, and a `resolve()` method that returns a type node. -The following is the actual implementation of the built-in resolver that handles scalar types: +The following is the actual implementation of the built-in resolver that handles enum cases: -```php ScalarTypeResolver.php +```php EnumCaseTypeResolver.php #[Priority(Priority::LOW)] -final class ScalarTypeResolver implements TypeResolver +final class EnumCaseTypeResolver implements TypeResolver { public function canResolve(TypeReflector $type): bool { - return $type->isBuiltIn() - && in_array($type->getName(), ['string', 'int', 'float', 'bool'], strict: true); + return $type->isEnumCase(); } - public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): TypeNode { - return new ResolvedType(match ($type->getName()) { - 'string' => 'string', - 'int', 'float' => 'number', - 'bool' => 'boolean', - }); + $case = $type->asEnumCase()->getValue(); + $value = $case instanceof BackedEnum + ? $case->value + : $case->name; + + return new LiteralTypeNode($value); } } ``` +Resolvers may return any supported semantic node depending on your use case, such as {b`Tempest\Generation\TypeScript\TypeNodes\PrimitiveTypeNode`}, {b`Tempest\Generation\TypeScript\TypeNodes\LiteralTypeNode`}, {b`Tempest\Generation\TypeScript\TypeNodes\SymbolTypeNode`}, {b`Tempest\Generation\TypeScript\TypeNodes\ArrayTypeNode`}, {b`Tempest\Generation\TypeScript\TypeNodes\UnionTypeNode`}, {b`Tempest\Generation\TypeScript\TypeNodes\IntersectionTypeNode`}, {b`Tempest\Generation\TypeScript\TypeNodes\ObjectTypeNode`} or {b`Tempest\Generation\TypeScript\TypeNodes\RawTypeNode`}. + :::info Type resolvers are automatically [discovered](../1-essentials/05-discovery.md) and do not need to be registered manually. ::: diff --git a/packages/generation/src/TypeScript/PropertyDefinition.php b/packages/generation/src/TypeScript/PropertyDefinition.php index a6ad3feb84..94efc5d6fe 100644 --- a/packages/generation/src/TypeScript/PropertyDefinition.php +++ b/packages/generation/src/TypeScript/PropertyDefinition.php @@ -4,6 +4,8 @@ namespace Tempest\Generation\TypeScript; +use Tempest\Generation\TypeScript\TypeNodes\TypeNode; + /** * Represents a property in a TypeScript interface. */ @@ -11,13 +13,12 @@ { /** * @param string $name The name of the property. - * @param string $definition The TypeScript definition of the property. - * @param null|string $fqcn The PHP FQCN of the original type. + * @param TypeNode $type The TypeScript type of the property. + * @param bool $isNullable Whether the property is nullable. */ public function __construct( public string $name, - public string $definition, + public TypeNode $type, public bool $isNullable, - public ?string $fqcn = null, ) {} } diff --git a/packages/generation/src/TypeScript/ResolvedType.php b/packages/generation/src/TypeScript/ResolvedType.php deleted file mode 100644 index 93151bd91b..0000000000 --- a/packages/generation/src/TypeScript/ResolvedType.php +++ /dev/null @@ -1,20 +0,0 @@ -getIterableType(); if ($elementTypeReflector instanceof TypeReflector) { - $result = $this->resolveType($elementTypeReflector, $generator); + $resolvedType = $this->resolveType($elementTypeReflector, $generator); return new PropertyDefinition( name: $property->getName(), - definition: $result->type . '[]', + type: new ArrayTypeNode($resolvedType), isNullable: $property->isNullable(), - fqcn: $result->fqcn, ); } return new PropertyDefinition( name: $property->getName(), - definition: 'any[]', + type: new ArrayTypeNode(new PrimitiveTypeNode('any')), isNullable: $property->isNullable(), ); } @@ -68,42 +71,37 @@ private function resolveProperty(PropertyReflector $property, TypeScriptGenerato 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; - } + $resolvedTypes[] = $this->resolveType($part, $generator); } - $symbol = $type->isIntersection() ? '&' : '|'; - return new PropertyDefinition( name: $property->getName(), - definition: implode(" {$symbol} ", $resolvedTypes), + type: match (true) { + $resolvedTypes === [] => new PrimitiveTypeNode('any'), + count($resolvedTypes) === 1 => $resolvedTypes[0], + $type->isIntersection() => new IntersectionTypeNode($resolvedTypes), + default => new UnionTypeNode($resolvedTypes), + }, isNullable: $property->isNullable(), - fqcn: count($referencedClasses) === 1 ? $referencedClasses[0] : null, ); } - $result = $this->resolveType($type, $generator); + $resolvedType = $this->resolveType($type, $generator); return new PropertyDefinition( name: $property->getName(), - definition: $result->type, + type: $resolvedType, isNullable: $property->isNullable(), - fqcn: $result->fqcn, ); } - private function resolveType(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + private function resolveType(TypeReflector $type, TypeScriptGenerator $generator): TypeNode { foreach ($this->config->resolvers as $resolverClass) { $resolver = $this->container->get($resolverClass); @@ -113,6 +111,6 @@ private function resolveType(TypeReflector $type, TypeScriptGenerator $generator } } - return new ResolvedType('any'); + return new PrimitiveTypeNode('any'); } } diff --git a/packages/generation/src/TypeScript/StructureResolvers/EnumStructureResolver.php b/packages/generation/src/TypeScript/StructureResolvers/EnumStructureResolver.php index a0151d97fc..b204343957 100644 --- a/packages/generation/src/TypeScript/StructureResolvers/EnumStructureResolver.php +++ b/packages/generation/src/TypeScript/StructureResolvers/EnumStructureResolver.php @@ -10,6 +10,8 @@ use Tempest\Container\Container; use Tempest\Generation\TypeScript\StructureResolver; use Tempest\Generation\TypeScript\TypeDefinition; +use Tempest\Generation\TypeScript\TypeNodes\TypeNode; +use Tempest\Generation\TypeScript\TypeNodes\UnionTypeNode; use Tempest\Generation\TypeScript\TypeScriptGenerationConfig; use Tempest\Generation\TypeScript\TypeScriptGenerator; use Tempest\Reflection\TypeReflector; @@ -26,29 +28,30 @@ public function __construct( public function resolve(TypeReflector $type, TypeScriptGenerator $generator): TypeDefinition { - $typeScriptType = implode( - separator: ' | ', - array: array_map( - callback: fn (ReflectionEnumUnitCase|ReflectionEnumBackedCase $case) => $this->resolveType(new TypeReflector($case), $generator), - array: $type->asEnum()->getReflectionCases(), - ), + $types = array_map( + callback: fn (ReflectionEnumUnitCase|ReflectionEnumBackedCase $case) => $this->resolveType(new TypeReflector($case), $generator), + array: $type->asEnum()->getReflectionCases(), ); + $resolvedType = count($types) === 1 + ? $types[0] + : new UnionTypeNode($types); + return new TypeDefinition( class: $type->getName(), originalType: $type, - definition: $typeScriptType, + type: $resolvedType, isNullable: $type->isNullable(), ); } - private function resolveType(TypeReflector $type, TypeScriptGenerator $generator): string + private function resolveType(TypeReflector $type, TypeScriptGenerator $generator): TypeNode { foreach ($this->config->resolvers as $resolverClass) { $resolver = $this->container->get($resolverClass); if ($resolver->canResolve($type)) { - return $resolver->resolve($type, $generator)->type; + return $resolver->resolve($type, $generator); } } diff --git a/packages/generation/src/TypeScript/TypeDefinition.php b/packages/generation/src/TypeScript/TypeDefinition.php index 5c08b0443b..79b103ea2a 100644 --- a/packages/generation/src/TypeScript/TypeDefinition.php +++ b/packages/generation/src/TypeScript/TypeDefinition.php @@ -4,6 +4,7 @@ namespace Tempest\Generation\TypeScript; +use Tempest\Generation\TypeScript\TypeNodes\TypeNode; use Tempest\Reflection\TypeReflector; use Tempest\Support\Str; @@ -25,7 +26,7 @@ final class TypeDefinition public function __construct( public string $class, public TypeReflector $originalType, - public string $definition, + public TypeNode $type, public bool $isNullable, ) {} } diff --git a/packages/generation/src/TypeScript/TypeNodeRenderer.php b/packages/generation/src/TypeScript/TypeNodeRenderer.php new file mode 100644 index 0000000000..5bf1bed5ee --- /dev/null +++ b/packages/generation/src/TypeScript/TypeNodeRenderer.php @@ -0,0 +1,85 @@ + $type->name, + $type instanceof RawTypeNode => $type->expression, + $type instanceof LiteralTypeNode => $this->renderLiteral($type), + $type instanceof SymbolTypeNode => $symbolRenderer($type->fqcn), + $type instanceof ArrayTypeNode => $this->render($type->type, $symbolRenderer) . '[]', + $type instanceof ObjectTypeNode => $this->renderObjectType($type, $symbolRenderer), + $type instanceof UnionTypeNode => implode(' | ', array_map( + callback: fn (TypeNode $part): string => $this->render($part, $symbolRenderer), + array: $type->types, + )), + $type instanceof IntersectionTypeNode => implode(' & ', array_map( + callback: fn (TypeNode $part): string => $this->render($part, $symbolRenderer), + array: $type->types, + )), + default => 'any', + }; + } + + private function renderLiteral(LiteralTypeNode $type): string + { + if (is_string($type->value)) { + return sprintf("'%s'", addcslashes($type->value, "\\'\n\r\t\v\f")); + } + + if (is_bool($type->value)) { + return $type->value ? 'true' : 'false'; + } + + return (string) $type->value; + } + + /** + * @param callable(string):string $symbolRenderer + */ + private function renderObjectType(ObjectTypeNode $type, callable $symbolRenderer): string + { + $properties = array_map( + callback: fn (ObjectTypePropertyNode $property): string => vsprintf('%s%s: %s', [ + $this->renderObjectPropertyName($property->name), + $property->optional ? '?' : '', + $this->render($property->type, $symbolRenderer), + ]), + array: $type->properties, + ); + + return '{ ' . implode('; ', $properties) . '; }'; + } + + private function renderObjectPropertyName(string $name): string + { + if (preg_match('/^[A-Za-z_$][A-Za-z0-9_$]*$/', $name) === 1) { + return $name; + } + + return sprintf("'%s'", addcslashes($name, "\\'\n\r\t\v\f")); + } +} diff --git a/packages/generation/src/TypeScript/TypeNodes/ArrayTypeNode.php b/packages/generation/src/TypeScript/TypeNodes/ArrayTypeNode.php new file mode 100644 index 0000000000..9cffc01e0b --- /dev/null +++ b/packages/generation/src/TypeScript/TypeNodes/ArrayTypeNode.php @@ -0,0 +1,16 @@ + $this->type->references; + } +} diff --git a/packages/generation/src/TypeScript/TypeNodes/IntersectionTypeNode.php b/packages/generation/src/TypeScript/TypeNodes/IntersectionTypeNode.php new file mode 100644 index 0000000000..38769b2026 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeNodes/IntersectionTypeNode.php @@ -0,0 +1,27 @@ +types as $type) { + $fqcns = [...$fqcns, ...$type->references]; + } + + return array_values(array_unique($fqcns)); + } + } +} diff --git a/packages/generation/src/TypeScript/TypeNodes/LiteralTypeNode.php b/packages/generation/src/TypeScript/TypeNodes/LiteralTypeNode.php new file mode 100644 index 0000000000..e10608720d --- /dev/null +++ b/packages/generation/src/TypeScript/TypeNodes/LiteralTypeNode.php @@ -0,0 +1,16 @@ + []; + } +} diff --git a/packages/generation/src/TypeScript/TypeNodes/ObjectTypeNode.php b/packages/generation/src/TypeScript/TypeNodes/ObjectTypeNode.php new file mode 100644 index 0000000000..106cb4ef23 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeNodes/ObjectTypeNode.php @@ -0,0 +1,27 @@ +properties as $property) { + $references = [...$references, ...$property->type->references]; + } + + return array_values(array_unique($references)); + } + } +} diff --git a/packages/generation/src/TypeScript/TypeNodes/ObjectTypePropertyNode.php b/packages/generation/src/TypeScript/TypeNodes/ObjectTypePropertyNode.php new file mode 100644 index 0000000000..3bfc540152 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeNodes/ObjectTypePropertyNode.php @@ -0,0 +1,14 @@ + []; + } +} diff --git a/packages/generation/src/TypeScript/TypeNodes/RawTypeNode.php b/packages/generation/src/TypeScript/TypeNodes/RawTypeNode.php new file mode 100644 index 0000000000..8ca612912e --- /dev/null +++ b/packages/generation/src/TypeScript/TypeNodes/RawTypeNode.php @@ -0,0 +1,16 @@ + []; + } +} diff --git a/packages/generation/src/TypeScript/TypeNodes/SymbolTypeNode.php b/packages/generation/src/TypeScript/TypeNodes/SymbolTypeNode.php new file mode 100644 index 0000000000..5ed8181228 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeNodes/SymbolTypeNode.php @@ -0,0 +1,16 @@ + [$this->fqcn]; + } +} diff --git a/packages/generation/src/TypeScript/TypeNodes/TypeNode.php b/packages/generation/src/TypeScript/TypeNodes/TypeNode.php new file mode 100644 index 0000000000..99c94decd0 --- /dev/null +++ b/packages/generation/src/TypeScript/TypeNodes/TypeNode.php @@ -0,0 +1,18 @@ +types as $type) { + $fqcns = [...$fqcns, ...$type->references]; + } + + return array_values(array_unique($fqcns)); + } + } +} diff --git a/packages/generation/src/TypeScript/TypeResolver.php b/packages/generation/src/TypeScript/TypeResolver.php index 6ad2af3bc2..bb249b8953 100644 --- a/packages/generation/src/TypeScript/TypeResolver.php +++ b/packages/generation/src/TypeScript/TypeResolver.php @@ -4,6 +4,7 @@ namespace Tempest\Generation\TypeScript; +use Tempest\Generation\TypeScript\TypeNodes\TypeNode; use Tempest\Reflection\TypeReflector; /** @@ -17,7 +18,7 @@ interface TypeResolver public function canResolve(TypeReflector $type): bool; /** - * Resolves a PHP type into a TypeScript type string. May include a referenced class for cross-namespace resolution. + * Resolves a PHP type into a semantic TypeScript type node. */ - public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType; + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): TypeNode; } diff --git a/packages/generation/src/TypeScript/TypeResolvers/ClassReferenceTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/ClassReferenceTypeResolver.php index 60857e4a77..ab2074ecf0 100644 --- a/packages/generation/src/TypeScript/TypeResolvers/ClassReferenceTypeResolver.php +++ b/packages/generation/src/TypeScript/TypeResolvers/ClassReferenceTypeResolver.php @@ -5,7 +5,8 @@ namespace Tempest\Generation\TypeScript\TypeResolvers; use Tempest\Core\Priority; -use Tempest\Generation\TypeScript\ResolvedType; +use Tempest\Generation\TypeScript\TypeNodes\SymbolTypeNode; +use Tempest\Generation\TypeScript\TypeNodes\TypeNode; use Tempest\Generation\TypeScript\TypeResolver; use Tempest\Generation\TypeScript\TypeScriptGenerator; use Tempest\Reflection\TypeReflector; @@ -25,13 +26,10 @@ public function canResolve(TypeReflector $type): bool return $type->isClass() || $type->isInterface(); } - public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): TypeNode { $generator->include($type->getName()); - return new ResolvedType( - type: $type->getShortName(), - fqcn: $type->getName(), - ); + return new SymbolTypeNode($type->getName()); } } diff --git a/packages/generation/src/TypeScript/TypeResolvers/DateTimeTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/DateTimeTypeResolver.php index 16afb27136..6cb5f772be 100644 --- a/packages/generation/src/TypeScript/TypeResolvers/DateTimeTypeResolver.php +++ b/packages/generation/src/TypeScript/TypeResolvers/DateTimeTypeResolver.php @@ -7,7 +7,8 @@ use DateTimeInterface as NativeDateTimeInterface; use Tempest\Core\Priority; use Tempest\DateTime\DateTimeInterface; -use Tempest\Generation\TypeScript\ResolvedType; +use Tempest\Generation\TypeScript\TypeNodes\PrimitiveTypeNode; +use Tempest\Generation\TypeScript\TypeNodes\TypeNode; use Tempest\Generation\TypeScript\TypeResolver; use Tempest\Generation\TypeScript\TypeScriptGenerator; use Tempest\Reflection\TypeReflector; @@ -20,8 +21,8 @@ public function canResolve(TypeReflector $type): bool return $type->matches(DateTimeInterface::class) || $type->matches(NativeDateTimeInterface::class); } - public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): TypeNode { - return new ResolvedType('string'); + return new PrimitiveTypeNode('string'); } } diff --git a/packages/generation/src/TypeScript/TypeResolvers/EnumCaseTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/EnumCaseTypeResolver.php index c6ed14ccbb..965516bfa7 100644 --- a/packages/generation/src/TypeScript/TypeResolvers/EnumCaseTypeResolver.php +++ b/packages/generation/src/TypeScript/TypeResolvers/EnumCaseTypeResolver.php @@ -6,7 +6,8 @@ use BackedEnum; use Tempest\Core\Priority; -use Tempest\Generation\TypeScript\ResolvedType; +use Tempest\Generation\TypeScript\TypeNodes\LiteralTypeNode; +use Tempest\Generation\TypeScript\TypeNodes\TypeNode; use Tempest\Generation\TypeScript\TypeResolver; use Tempest\Generation\TypeScript\TypeScriptGenerator; use Tempest\Reflection\TypeReflector; @@ -19,13 +20,13 @@ public function canResolve(TypeReflector $type): bool return $type->isEnumCase(); } - public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): TypeNode { $case = $type->asEnumCase()->getValue(); $value = $case instanceof BackedEnum ? $case->value : $case->name; - return new ResolvedType(is_string($value) ? "'{$value}'" : $value); + return new LiteralTypeNode($value); } } diff --git a/packages/generation/src/TypeScript/TypeResolvers/EnumReferenceTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/EnumReferenceTypeResolver.php index b5ef92dfd0..4d04eca307 100644 --- a/packages/generation/src/TypeScript/TypeResolvers/EnumReferenceTypeResolver.php +++ b/packages/generation/src/TypeScript/TypeResolvers/EnumReferenceTypeResolver.php @@ -5,7 +5,8 @@ namespace Tempest\Generation\TypeScript\TypeResolvers; use Tempest\Core\Priority; -use Tempest\Generation\TypeScript\ResolvedType; +use Tempest\Generation\TypeScript\TypeNodes\SymbolTypeNode; +use Tempest\Generation\TypeScript\TypeNodes\TypeNode; use Tempest\Generation\TypeScript\TypeResolver; use Tempest\Generation\TypeScript\TypeScriptGenerator; use Tempest\Reflection\TypeReflector; @@ -21,13 +22,10 @@ public function canResolve(TypeReflector $type): bool return $type->isEnum() && ! $type->isEnumCase(); } - public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): TypeNode { $generator->include($type->getName()); - return new ResolvedType( - type: $type->getShortName(), - fqcn: $type->getName(), - ); + return new SymbolTypeNode($type->getName()); } } diff --git a/packages/generation/src/TypeScript/TypeResolvers/MixedTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/MixedTypeResolver.php index ffda7b3a57..bd01c71930 100644 --- a/packages/generation/src/TypeScript/TypeResolvers/MixedTypeResolver.php +++ b/packages/generation/src/TypeScript/TypeResolvers/MixedTypeResolver.php @@ -5,7 +5,8 @@ namespace Tempest\Generation\TypeScript\TypeResolvers; use Tempest\Core\Priority; -use Tempest\Generation\TypeScript\ResolvedType; +use Tempest\Generation\TypeScript\TypeNodes\PrimitiveTypeNode; +use Tempest\Generation\TypeScript\TypeNodes\TypeNode; use Tempest\Generation\TypeScript\TypeResolver; use Tempest\Generation\TypeScript\TypeScriptGenerator; use Tempest\Reflection\TypeReflector; @@ -21,8 +22,8 @@ public function canResolve(TypeReflector $type): bool return true; } - public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): TypeNode { - return new ResolvedType('any'); + return new PrimitiveTypeNode('any'); } } diff --git a/packages/generation/src/TypeScript/TypeResolvers/ScalarTypeResolver.php b/packages/generation/src/TypeScript/TypeResolvers/ScalarTypeResolver.php index 322434cde0..ce4fac3148 100644 --- a/packages/generation/src/TypeScript/TypeResolvers/ScalarTypeResolver.php +++ b/packages/generation/src/TypeScript/TypeResolvers/ScalarTypeResolver.php @@ -6,7 +6,8 @@ use LogicException; use Tempest\Core\Priority; -use Tempest\Generation\TypeScript\ResolvedType; +use Tempest\Generation\TypeScript\TypeNodes\PrimitiveTypeNode; +use Tempest\Generation\TypeScript\TypeNodes\TypeNode; use Tempest\Generation\TypeScript\TypeResolver; use Tempest\Generation\TypeScript\TypeScriptGenerator; use Tempest\Reflection\TypeReflector; @@ -26,10 +27,10 @@ public function canResolve(TypeReflector $type): bool return $type->isBuiltIn() && isset(self::SCALAR_TYPE_MAP[$type->getName()]); } - public function resolve(TypeReflector $type, TypeScriptGenerator $generator): ResolvedType + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): TypeNode { $type = self::SCALAR_TYPE_MAP[$type->getName()] ?? throw new LogicException(sprintf('Unsupported scalar type "%s".', $type->getName())); - return new ResolvedType($type); + return new PrimitiveTypeNode($type); } } diff --git a/packages/generation/src/TypeScript/Writers/DirectoryWriter.php b/packages/generation/src/TypeScript/Writers/DirectoryWriter.php index 3005b5d52c..619bf893b9 100644 --- a/packages/generation/src/TypeScript/Writers/DirectoryWriter.php +++ b/packages/generation/src/TypeScript/Writers/DirectoryWriter.php @@ -5,8 +5,8 @@ namespace Tempest\Generation\TypeScript\Writers; use Tempest\Generation\TypeScript\InterfaceDefinition; -use Tempest\Generation\TypeScript\PropertyDefinition; use Tempest\Generation\TypeScript\TypeDefinition; +use Tempest\Generation\TypeScript\TypeNodeRenderer; use Tempest\Generation\TypeScript\TypeScriptOutput; use Tempest\Generation\TypeScript\TypeScriptWriter; use Tempest\Support\Arr; @@ -16,10 +16,14 @@ /** * Writes TypeScript definitions to separate .ts files organized by namespace in a directory structure. */ -final readonly class DirectoryWriter implements TypeScriptWriter +final class DirectoryWriter implements TypeScriptWriter { + private TypeNodeRenderer $renderer { + get => $this->renderer ??= new TypeNodeRenderer(); + } + public function __construct( - private DirectoryTypeScriptGenerationConfig $config, + private readonly DirectoryTypeScriptGenerationConfig $config, ) {} public function write(TypeScriptOutput $output): void @@ -39,7 +43,7 @@ public function write(TypeScriptOutput $output): void foreach ($fileGroups as $filename => $namespaces) { Filesystem\write_file( filename: $filename, - content: $this->generateFileContent($namespaces, $output), + content: $this->generateFileContent($namespaces), ); } } @@ -47,7 +51,7 @@ public function write(TypeScriptOutput $output): void /** * @param array> $namespaces */ - private function generateFileContent(array $namespaces, TypeScriptOutput $output): string + private function generateFileContent(array $namespaces): string { $lines = []; $lines[] = '/*'; @@ -57,7 +61,7 @@ private function generateFileContent(array $namespaces, TypeScriptOutput $output $lines[] = '*/'; $lines[] = ''; - $imports = $this->collectImports($namespaces, $output); + $imports = $this->collectImports($namespaces); if ($imports !== []) { foreach ($imports as $import) { @@ -67,9 +71,9 @@ private function generateFileContent(array $namespaces, TypeScriptOutput $output $lines[] = ''; } - foreach ($namespaces as $namespace => $definitions) { + foreach ($namespaces as $definitions) { foreach ($definitions as $definition) { - $lines[] = $this->generateDefinition($definition, $namespace); + $lines[] = $this->generateDefinition($definition); $lines[] = ''; } } @@ -81,29 +85,25 @@ private function generateFileContent(array $namespaces, TypeScriptOutput $output * @param array> $namespaces * @return array */ - private function collectImports(array $namespaces, TypeScriptOutput $output): array + private function collectImports(array $namespaces): array { $imports = []; $currentNamespaces = array_keys($namespaces); foreach ($namespaces as $namespace => $definitions) { foreach ($definitions as $definition) { - if (! $definition instanceof InterfaceDefinition) { - continue; - } - - foreach ($definition->properties as $property) { - if ($property->fqcn === null) { - continue; - } + $references = $definition instanceof TypeDefinition + ? $definition->type->references + : $this->collectInterfaceReferences($definition); - $targetNamespace = Str\before_last($property->fqcn, '\\'); + foreach ($references as $fqcn) { + $targetNamespace = Str\before_last($fqcn, '\\'); if (in_array($targetNamespace, $currentNamespaces, strict: true)) { continue; } - $typeName = Str\after_last($property->fqcn, '\\'); + $typeName = Str\after_last($fqcn, '\\'); $importPath = $this->computeImportPath($namespace, $targetNamespace); $importKey = "{$importPath}::{$typeName}"; @@ -115,24 +115,46 @@ private function collectImports(array $namespaces, TypeScriptOutput $output): ar return array_values($imports); } - private function generateDefinition(TypeDefinition|InterfaceDefinition $definition, string $currentNamespace): string + /** + * @return string[] + */ + private function collectInterfaceReferences(InterfaceDefinition $definition): array + { + $references = []; + + foreach ($definition->properties as $property) { + $references = [...$references, ...$property->type->references]; + } + + return array_values(array_unique($references)); + } + + private function generateDefinition(TypeDefinition|InterfaceDefinition $definition): string { $typeName = Str\after_last($definition->class, '\\'); if ($definition instanceof TypeDefinition) { - return "export type {$typeName} = {$definition->definition};"; + return vsprintf('export type %s = %s;', [ + $typeName, + $this->renderer->render( + type: $definition->type, + symbolRenderer: static fn (string $fqcn): string => Str\after_last($fqcn, '\\'), + ), + ]); } $lines = []; $lines[] = "export interface {$typeName} {"; foreach ($definition->properties as $property) { - $lines[] = sprintf( - ' %s%s: %s;', + $lines[] = vsprintf(' %s%s: %s;', [ $property->name, $property->isNullable ? '?' : '', - $this->resolveTypeReference($property), - ); + $this->renderer->render( + type: $property->type, + symbolRenderer: static fn (string $fqcn): string => Str\after_last($fqcn, '\\'), + ), + ]); } $lines[] = '}'; @@ -140,18 +162,6 @@ private function generateDefinition(TypeDefinition|InterfaceDefinition $definiti 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); diff --git a/packages/generation/src/TypeScript/Writers/NamespacedFileWriter.php b/packages/generation/src/TypeScript/Writers/NamespacedFileWriter.php index f593fc478f..bac72fb716 100644 --- a/packages/generation/src/TypeScript/Writers/NamespacedFileWriter.php +++ b/packages/generation/src/TypeScript/Writers/NamespacedFileWriter.php @@ -5,8 +5,8 @@ namespace Tempest\Generation\TypeScript\Writers; use Tempest\Generation\TypeScript\InterfaceDefinition; -use Tempest\Generation\TypeScript\PropertyDefinition; use Tempest\Generation\TypeScript\TypeDefinition; +use Tempest\Generation\TypeScript\TypeNodeRenderer; use Tempest\Generation\TypeScript\TypeScriptOutput; use Tempest\Generation\TypeScript\TypeScriptWriter; use Tempest\Support\Arr; @@ -16,10 +16,14 @@ /** * Writes TypeScript definitions to a single .d.ts file using TypeScript namespaces. */ -final readonly class NamespacedFileWriter implements TypeScriptWriter +final class NamespacedFileWriter implements TypeScriptWriter { + private TypeNodeRenderer $renderer { + get => $this->renderer ??= new TypeNodeRenderer(); + } + public function __construct( - private NamespacedTypeScriptGenerationConfig $config, + private readonly NamespacedTypeScriptGenerationConfig $config, ) {} public function write(TypeScriptOutput $output): void @@ -76,19 +80,27 @@ private function generateDefinition(TypeDefinition|InterfaceDefinition $definiti $typeName = Str\after_last($definition->class, '\\'); if ($definition instanceof TypeDefinition) { - return " export type {$typeName} = {$definition->definition};"; + return vsprintf(' export type %s = %s;', [ + $typeName, + $this->renderer->render( + $definition->type, + fn (string $fqcn): string => $this->resolveSymbolForNamespace($fqcn, $definition->namespace), + ), + ]); } $lines = []; $lines[] = " export interface {$typeName} {"; foreach ($definition->properties as $property) { - $lines[] = sprintf( - ' %s%s: %s;', + $lines[] = vsprintf(' %s%s: %s;', [ $property->name, $property->isNullable ? '?' : '', - $this->resolveTypeReference($property, $definition), - ); + $this->renderer->render( + type: $property->type, + symbolRenderer: fn (string $fqcn): string => $this->resolveSymbolForNamespace($fqcn, $definition->namespace), + ), + ]); } $lines[] = ' }'; @@ -96,27 +108,12 @@ private function generateDefinition(TypeDefinition|InterfaceDefinition $definiti 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); + private function resolveSymbolForNamespace(string $fqcn, string $sourceNamespace): string + { + $targetNamespace = Str\before_last($fqcn, '\\'); + $targetTypeName = Str\after_last($fqcn, '\\'); - return $relativePath . $targetTypeName . $arrayBrackets; + return $this->computeRelativeNamespacePath($sourceNamespace, $targetNamespace) . $targetTypeName; } private function computeRelativeNamespacePath(string $sourceNamespace, string $targetNamespace): string diff --git a/packages/generation/tests/TypeScript/Fixtures/EnumMetadataTypeResolver.php b/packages/generation/tests/TypeScript/Fixtures/EnumMetadataTypeResolver.php new file mode 100644 index 0000000000..a3e1c047f1 --- /dev/null +++ b/packages/generation/tests/TypeScript/Fixtures/EnumMetadataTypeResolver.php @@ -0,0 +1,55 @@ +isEnumCase()) { + return false; + } + + return $type->asEnumCase()->getAttributes(EnumMetadata::class, PHPReflectionAttribute::IS_INSTANCEOF) !== []; + } + + public function resolve(TypeReflector $type, TypeScriptGenerator $generator): TypeNode + { + $enumCase = $type->asEnumCase()->getValue(); + $caseValue = $enumCase instanceof BackedEnum + ? $enumCase->value + : $enumCase->name; + + $properties = [ + new ObjectTypePropertyNode( + name: 'value', + type: new LiteralTypeNode((string) $caseValue), + ), + ]; + + foreach ($type->asEnumCase()->getAttributes(EnumMetadata::class, PHPReflectionAttribute::IS_INSTANCEOF) as $attribute) { + /** @var EnumMetadata $metadata */ + $metadata = $attribute->newInstance(); + + $properties[] = new ObjectTypePropertyNode( + name: $metadata->key, + type: new LiteralTypeNode($metadata->value), + ); + } + + return new ObjectTypeNode($properties); + } +} diff --git a/packages/generation/tests/TypeScript/Fixtures/Metadata/Description.php b/packages/generation/tests/TypeScript/Fixtures/Metadata/Description.php new file mode 100644 index 0000000000..7b4b5a2d14 --- /dev/null +++ b/packages/generation/tests/TypeScript/Fixtures/Metadata/Description.php @@ -0,0 +1,17 @@ + 'description'; + } + + public function __construct( + public readonly string $value, + ) {} +} diff --git a/packages/generation/tests/TypeScript/Fixtures/Metadata/EnumMetadata.php b/packages/generation/tests/TypeScript/Fixtures/Metadata/EnumMetadata.php new file mode 100644 index 0000000000..71aa11c448 --- /dev/null +++ b/packages/generation/tests/TypeScript/Fixtures/Metadata/EnumMetadata.php @@ -0,0 +1,10 @@ +directory . '/types.d.ts'; $container = new GenericContainer(); + $config = new NamespacedTypeScriptGenerationConfig(filename: $path); + $config->sources = [User::class]; $config->resolvers = [ EnumCaseTypeResolver::class, ScalarTypeResolver::class, @@ -56,7 +58,6 @@ public function generates_types(): void ClassReferenceTypeResolver::class, MixedTypeResolver::class, ]; - $config->sources = [User::class]; $generator = new GenericTypeScriptGenerator( config: $config, @@ -65,6 +66,7 @@ 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); diff --git a/packages/generation/tests/TypeScript/StructureResolvers/ClassStructureResolverTest.php b/packages/generation/tests/TypeScript/StructureResolvers/ClassStructureResolverTest.php index dbadca2132..75ff24b3bd 100644 --- a/packages/generation/tests/TypeScript/StructureResolvers/ClassStructureResolverTest.php +++ b/packages/generation/tests/TypeScript/StructureResolvers/ClassStructureResolverTest.php @@ -12,6 +12,7 @@ use Tempest\Generation\TypeScript\InterfaceDefinition; use Tempest\Generation\TypeScript\StructureResolvers\ClassStructureResolver; use Tempest\Generation\TypeScript\StructureResolvers\EnumStructureResolver; +use Tempest\Generation\TypeScript\TypeNodes\PrimitiveTypeNode; use Tempest\Generation\TypeScript\TypeResolvers\ClassReferenceTypeResolver; use Tempest\Generation\TypeScript\TypeResolvers\DateTimeTypeResolver; use Tempest\Generation\TypeScript\TypeResolvers\EnumReferenceTypeResolver; @@ -67,11 +68,13 @@ public function resolves_scalar_properties(): void $result = $this->resolver->resolve($type, $this->generator); $this->assertSame('name', $result->properties[0]->name); - $this->assertSame('string', $result->properties[0]->definition); + $this->assertInstanceOf(PrimitiveTypeNode::class, $result->properties[0]->type); + $this->assertSame('string', $result->properties[0]->type->name); $this->assertFalse($result->properties[0]->isNullable); $this->assertSame('value', $result->properties[1]->name); - $this->assertSame('number', $result->properties[1]->definition); + $this->assertInstanceOf(PrimitiveTypeNode::class, $result->properties[1]->type); + $this->assertSame('number', $result->properties[1]->type->name); $this->assertFalse($result->properties[1]->isNullable); } } diff --git a/packages/generation/tests/TypeScript/TypeScriptGenerationTest.php b/packages/generation/tests/TypeScript/TypeScriptGenerationTest.php index 764d0331f9..6becd2f923 100644 --- a/packages/generation/tests/TypeScript/TypeScriptGenerationTest.php +++ b/packages/generation/tests/TypeScript/TypeScriptGenerationTest.php @@ -9,6 +9,11 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; use Tempest\Container\GenericContainer; +use Tempest\Generation\Tests\TypeScript\Fixtures\EnumMetadataTypeResolver; +use Tempest\Generation\Tests\TypeScript\Fixtures\Metadata\RoleWithMetadata; +use Tempest\Generation\Tests\TypeScript\Fixtures\MultilineEnum; +use Tempest\Generation\Tests\TypeScript\Fixtures\QuotedEnum; +use Tempest\Generation\Tests\TypeScript\Fixtures\UnionHolder; use Tempest\Generation\Tests\TypeScript\Fixtures\User; use Tempest\Generation\TypeScript\GenericTypeScriptGenerator; use Tempest\Generation\TypeScript\StructureResolvers\ClassStructureResolver; @@ -56,7 +61,12 @@ public function generates_types(): void ClassReferenceTypeResolver::class, MixedTypeResolver::class, ]; - $config->sources = [User::class]; + $config->sources = [ + User::class, + QuotedEnum::class, + MultilineEnum::class, + UnionHolder::class, + ]; $generator = new GenericTypeScriptGenerator( config: $config, @@ -80,9 +90,47 @@ enumResolver: new EnumStructureResolver($config, $container), $this->assertStringContainsString('theme: Theme;', $content); $this->assertStringContainsString('sidebar_open: boolean;', $content); $this->assertStringContainsString("export type Theme = 'dark' | 'light';", $content); + $this->assertStringContainsString("export type QuotedEnum = 'O\\'Reilly';", $content); + $this->assertStringContainsString("export type MultilineEnum = 'first\\nsecond';", $content); + $this->assertStringContainsString('export interface UnionHolder {', $content); + $this->assertStringContainsString('value: Security.Role | Security.Permission;', $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); } + + #[Test] + public function generates_enum_metadata_with_custom_resolver(): void + { + $path = $this->directory . '/types-metadata.d.ts'; + + $container = new GenericContainer(); + $config = new NamespacedTypeScriptGenerationConfig(filename: $path); + $config->sources = [RoleWithMetadata::class]; + $config->resolvers = [ + EnumMetadataTypeResolver::class, + ScalarTypeResolver::class, + DateTimeTypeResolver::class, + EnumCaseTypeResolver::class, + EnumReferenceTypeResolver::class, + ClassReferenceTypeResolver::class, + MixedTypeResolver::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 type RoleWithMetadata = { value: 'admin'; description: 'An administrator'; } | { value: 'user'; description: 'A normal user'; } | { value: 'guest'; description: 'A guest'; };", + $content, + ); + } } diff --git a/packages/generation/tests/TypeScript/Writers/DirectoryWriterTest.php b/packages/generation/tests/TypeScript/Writers/DirectoryWriterTest.php index 090d8b0792..ee8b2976f3 100644 --- a/packages/generation/tests/TypeScript/Writers/DirectoryWriterTest.php +++ b/packages/generation/tests/TypeScript/Writers/DirectoryWriterTest.php @@ -11,6 +11,10 @@ use Tempest\Generation\TypeScript\InterfaceDefinition; use Tempest\Generation\TypeScript\PropertyDefinition; use Tempest\Generation\TypeScript\TypeDefinition; +use Tempest\Generation\TypeScript\TypeNodes\LiteralTypeNode; +use Tempest\Generation\TypeScript\TypeNodes\PrimitiveTypeNode; +use Tempest\Generation\TypeScript\TypeNodes\SymbolTypeNode; +use Tempest\Generation\TypeScript\TypeNodes\UnionTypeNode; use Tempest\Generation\TypeScript\TypeScriptOutput; use Tempest\Generation\TypeScript\Writers\DirectoryTypeScriptGenerationConfig; use Tempest\Generation\TypeScript\Writers\DirectoryWriter; @@ -48,19 +52,18 @@ class: 'App\\Models\\User', properties: [ new PropertyDefinition( name: 'id', - definition: 'number', + type: new PrimitiveTypeNode('number'), isNullable: true, ), new PropertyDefinition( name: 'name', - definition: 'string', + type: new PrimitiveTypeNode('string'), isNullable: false, ), new PropertyDefinition( name: 'role', - definition: 'Role', + type: new SymbolTypeNode('App\\Security\\Role'), isNullable: false, - fqcn: 'App\\Security\\Role', ), ], ); @@ -68,7 +71,7 @@ class: 'App\\Models\\User', $postType = new TypeDefinition( class: 'App\\Models\\Post', originalType: new TypeReflector('string'), - definition: 'string', + type: new PrimitiveTypeNode('string'), isNullable: false, ); @@ -78,7 +81,7 @@ class: 'App\\Security\\Role', properties: [ new PropertyDefinition( name: 'name', - definition: 'string', + type: new PrimitiveTypeNode('string'), isNullable: false, ), ], @@ -87,7 +90,10 @@ class: 'App\\Security\\Role', $themeEnum = new TypeDefinition( class: 'App\\Models\\Theme', originalType: new TypeReflector('string'), - definition: "'dark' | 'light'", + type: new UnionTypeNode([ + new LiteralTypeNode('dark'), + new LiteralTypeNode('light'), + ]), isNullable: false, ); diff --git a/packages/generation/tests/TypeScript/Writers/NamespacedFileWriterTest.php b/packages/generation/tests/TypeScript/Writers/NamespacedFileWriterTest.php index b4f5904670..25b2a6973f 100644 --- a/packages/generation/tests/TypeScript/Writers/NamespacedFileWriterTest.php +++ b/packages/generation/tests/TypeScript/Writers/NamespacedFileWriterTest.php @@ -11,6 +11,12 @@ use Tempest\Generation\TypeScript\InterfaceDefinition; use Tempest\Generation\TypeScript\PropertyDefinition; use Tempest\Generation\TypeScript\TypeDefinition; +use Tempest\Generation\TypeScript\TypeNodes\IntersectionTypeNode; +use Tempest\Generation\TypeScript\TypeNodes\LiteralTypeNode; +use Tempest\Generation\TypeScript\TypeNodes\PrimitiveTypeNode; +use Tempest\Generation\TypeScript\TypeNodes\RawTypeNode; +use Tempest\Generation\TypeScript\TypeNodes\SymbolTypeNode; +use Tempest\Generation\TypeScript\TypeNodes\UnionTypeNode; use Tempest\Generation\TypeScript\TypeScriptOutput; use Tempest\Generation\TypeScript\Writers\NamespacedFileWriter; use Tempest\Generation\TypeScript\Writers\NamespacedTypeScriptGenerationConfig; @@ -48,17 +54,17 @@ class: 'App\\Models\\User', properties: [ new PropertyDefinition( name: 'id', - definition: 'number', + type: new PrimitiveTypeNode('number'), isNullable: false, ), new PropertyDefinition( name: 'username', - definition: 'string', + type: new PrimitiveTypeNode('string'), isNullable: false, ), new PropertyDefinition( name: 'email', - definition: 'string', + type: new PrimitiveTypeNode('string'), isNullable: true, ), ], @@ -67,28 +73,35 @@ class: 'App\\Models\\User', $arrayType = new TypeDefinition( class: 'App\\Models\\Tags', originalType: new TypeReflector('array'), - definition: 'Array', + type: new RawTypeNode('Array'), isNullable: false, ); $unionType = new TypeDefinition( class: 'App\\Models\\Role', originalType: new TypeReflector('string'), - definition: "'admin' | 'user' | 'guest'", + type: new UnionTypeNode([ + new LiteralTypeNode('admin'), + new LiteralTypeNode('user'), + new LiteralTypeNode('guest'), + ]), isNullable: false, ); $intersectionType = new TypeDefinition( class: 'App\\Models\\AdminUser', originalType: new TypeReflector('object'), - definition: 'User & { permissions: Array }', + type: new IntersectionTypeNode([ + new SymbolTypeNode('App\\Models\\User'), + new RawTypeNode('{ permissions: Array }'), + ]), isNullable: false, ); - $controller = new TypeDefinition( - class: 'App\\Controllers\\HomeController', + $customBoolean = new TypeDefinition( + class: 'App\\Scalars\\CustomBoolean', originalType: new TypeReflector('bool'), - definition: 'boolean', + type: new PrimitiveTypeNode('boolean'), isNullable: false, ); @@ -100,7 +113,7 @@ class: 'App\\Controllers\\HomeController', $unionType, $intersectionType, ], - 'App\\Controllers' => [$controller], + 'App\\Scalars' => [$customBoolean], ], ); @@ -108,7 +121,7 @@ class: 'App\\Controllers\\HomeController', $content = Filesystem\read_file($outputPath); $this->assertStringContainsString('export namespace App.Models {', $content); - $this->assertStringContainsString('export namespace App.Controllers {', $content); + $this->assertStringContainsString('export namespace App.Scalars {', $content); $this->assertStringContainsString('export interface User {', $content); $this->assertStringContainsString('id: number;', $content); $this->assertStringContainsString('username: string;', $content); @@ -116,6 +129,6 @@ class: 'App\\Controllers\\HomeController', $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); + $this->assertStringContainsString('export type CustomBoolean = boolean;', $content); } }