Parse and validate AT Protocol Lexicons with DTO generation for Laravel

Refactor generators to produce proper fromArray implementations

+117 -47
src/Generator/ClassGenerator.php
··· 4 4 5 5 use SocialDept\Schema\Data\LexiconDocument; 6 6 use SocialDept\Schema\Exceptions\GenerationException; 7 + use SocialDept\Schema\Support\ExtensionManager; 7 8 8 9 class ClassGenerator 9 10 { ··· 33 34 protected DocBlockGenerator $docBlockGenerator; 34 35 35 36 /** 37 + * Extension manager instance. 38 + */ 39 + protected ExtensionManager $extensions; 40 + 41 + /** 36 42 * Create a new ClassGenerator. 37 43 */ 38 44 public function __construct( ··· 40 46 ?TypeMapper $typeMapper = null, 41 47 ?StubRenderer $renderer = null, 42 48 ?MethodGenerator $methodGenerator = null, 43 - ?DocBlockGenerator $docBlockGenerator = null 49 + ?DocBlockGenerator $docBlockGenerator = null, 50 + ?ExtensionManager $extensions = null 44 51 ) { 45 - $this->naming = $naming ?? new NamingConverter(); 52 + $this->naming = $naming ?? new NamingConverter; 46 53 $this->typeMapper = $typeMapper ?? new TypeMapper($this->naming); 47 - $this->renderer = $renderer ?? new StubRenderer(); 54 + $this->renderer = $renderer ?? new StubRenderer; 48 55 $this->methodGenerator = $methodGenerator ?? new MethodGenerator($this->naming, $this->typeMapper, $this->renderer); 49 56 $this->docBlockGenerator = $docBlockGenerator ?? new DocBlockGenerator($this->typeMapper); 57 + $this->extensions = $extensions ?? new ExtensionManager; 50 58 } 51 59 52 60 /** ··· 69 77 ['nsid' => $nsid, 'type' => $type] 70 78 ); 71 79 } 80 + 81 + // For record types, extract the actual record definition 82 + $recordDef = $type === 'record' ? ($mainDef['record'] ?? []) : $mainDef; 83 + 84 + // Build local definition map for type resolution 85 + $localDefinitions = $this->buildLocalDefinitionMap($document); 86 + $this->typeMapper->setLocalDefinitions($localDefinitions); 72 87 73 88 // Get class components 74 - $namespace = $this->naming->nsidToNamespace($nsid); 75 - $className = $this->naming->toClassName($document->id->getName()); 76 - $useStatements = $this->collectUseStatements($mainDef); 77 - $properties = $this->generateProperties($mainDef); 78 - $constructor = $this->generateConstructor($mainDef); 79 - $methods = $this->generateMethods($document); 80 - $docBlock = $this->generateClassDocBlock($document, $mainDef); 89 + $namespace = $this->extensions->filter('filter:class:namespace', $this->naming->nsidToNamespace($nsid), $document); 90 + $className = $this->extensions->filter('filter:class:className', $this->naming->toClassName($document->id->getName()), $document); 91 + $useStatements = $this->extensions->filter('filter:class:useStatements', $this->collectUseStatements($recordDef, $namespace), $document, $recordDef); 92 + $properties = $this->extensions->filter('filter:class:properties', $this->generateProperties($recordDef), $document, $recordDef); 93 + $constructor = $this->extensions->filter('filter:class:constructor', $this->generateConstructor($recordDef), $document, $recordDef); 94 + $methods = $this->extensions->filter('filter:class:methods', $this->generateMethods($document), $document); 95 + $docBlock = $this->extensions->filter('filter:class:docBlock', $this->generateClassDocBlock($document, $mainDef), $document, $mainDef); 81 96 82 97 // Render the class 83 - return $this->renderer->render('class', [ 98 + $rendered = $this->renderer->render('class', [ 84 99 'namespace' => $namespace, 85 100 'imports' => $this->formatUseStatements($useStatements), 86 101 'docBlock' => $docBlock, 87 102 'className' => $className, 88 - 'extends' => ' extends \\SocialDept\\Schema\\Data\\Data', 103 + 'extends' => ' extends Data', 89 104 'implements' => '', 90 105 'properties' => $properties, 91 106 'constructor' => $constructor, 92 107 'methods' => $methods, 93 108 ]); 109 + 110 + // Execute post-generation hooks 111 + $this->extensions->execute('action:class:generated', $rendered, $document); 112 + 113 + return $rendered; 94 114 } 95 115 96 116 /** 97 117 * Generate class properties. 118 + * 119 + * Since we use constructor property promotion, we don't need separate property declarations. 120 + * This method returns empty string but is kept for compatibility. 98 121 * 99 122 * @param array<string, mixed> $definition 100 123 */ 101 124 protected function generateProperties(array $definition): string 102 125 { 103 - $properties = $definition['properties'] ?? []; 104 - $required = $definition['required'] ?? []; 105 - 106 - if (empty($properties)) { 107 - return ''; 108 - } 109 - 110 - $lines = []; 111 - 112 - foreach ($properties as $name => $propDef) { 113 - $isRequired = in_array($name, $required); 114 - $phpType = $this->typeMapper->toPhpType($propDef, ! $isRequired); 115 - $docType = $this->typeMapper->toPhpDocType($propDef, ! $isRequired); 116 - $description = $propDef['description'] ?? null; 117 - 118 - // Build property doc comment 119 - $docLines = [' /**']; 120 - if ($description) { 121 - $docLines[] = ' * '.$description; 122 - $docLines[] = ' *'; 123 - } 124 - $docLines[] = ' * @var '.$docType; 125 - $docLines[] = ' */'; 126 - 127 - $lines[] = implode("\n", $docLines); 128 - $lines[] = ' public readonly '.$phpType.' $'.$name.';'; 129 - $lines[] = ''; 130 - } 131 - 132 - return rtrim(implode("\n", $lines)); 126 + // Properties are defined via constructor promotion 127 + return ''; 133 128 } 134 129 135 130 /** ··· 146 141 return ''; 147 142 } 148 143 149 - $params = []; 144 + // Build constructor parameters - required first, then optional 145 + $requiredParams = []; 146 + $optionalParams = []; 147 + $requiredDocParams = []; 148 + $optionalDocParams = []; 150 149 151 150 foreach ($properties as $name => $propDef) { 152 151 $isRequired = in_array($name, $required); 153 152 $phpType = $this->typeMapper->toPhpType($propDef, ! $isRequired); 154 - $default = ! $isRequired ? ' = null' : ''; 153 + $phpDocType = $this->typeMapper->toPhpDocType($propDef, ! $isRequired); 154 + $description = $propDef['description'] ?? ''; 155 + $param = ' public readonly '.$phpType.' $'.$name; 155 156 156 - $params[] = ' public readonly '.$phpType.' $'.$name.$default.','; 157 + if ($isRequired) { 158 + $requiredParams[] = $param.','; 159 + if ($description) { 160 + $requiredDocParams[] = ' * @param '.$phpDocType.' $'.$name.' '.$description; 161 + } 162 + } else { 163 + $optionalParams[] = $param.' = null,'; 164 + if ($description) { 165 + $optionalDocParams[] = ' * @param '.$phpDocType.' $'.$name.' '.$description; 166 + } 167 + } 157 168 } 158 169 170 + // Combine required and optional parameters 171 + $params = array_merge($requiredParams, $optionalParams); 172 + 159 173 // Remove trailing comma from last parameter 160 174 if (! empty($params)) { 161 175 $params[count($params) - 1] = rtrim($params[count($params) - 1], ','); 162 176 } 163 177 164 - return " public function __construct(\n".implode("\n", $params)."\n ) {\n }"; 178 + // Build constructor DocBlock with parameter descriptions in the correct order 179 + $docParams = array_merge($requiredDocParams, $optionalDocParams); 180 + $docLines = [' /**']; 181 + if (! empty($docParams)) { 182 + $docLines = array_merge($docLines, $docParams); 183 + } 184 + $docLines[] = ' */'; 185 + $docBlock = implode("\n", $docLines); 186 + 187 + return "\n".$docBlock."\n public function __construct(\n".implode("\n", $params)."\n ) {}"; 165 188 } 166 189 167 190 /** ··· 190 213 * @param array<string, mixed> $definition 191 214 * @return array<string> 192 215 */ 193 - protected function collectUseStatements(array $definition): array 216 + protected function collectUseStatements(array $definition, string $currentNamespace = ''): array 194 217 { 195 218 $uses = ['SocialDept\\Schema\\Data\\Data']; 196 219 $properties = $definition['properties'] ?? []; ··· 208 231 209 232 // Remove duplicates and sort 210 233 $uses = array_unique($uses); 234 + 235 + // Filter out classes from the same namespace 236 + if ($currentNamespace) { 237 + $uses = array_filter($uses, function ($use) use ($currentNamespace) { 238 + // Get namespace from FQCN by removing class name 239 + $parts = explode('\\', ltrim($use, '\\')); 240 + array_pop($parts); // Remove class name 241 + $useNamespace = implode('\\', $parts); 242 + 243 + return $useNamespace !== $currentNamespace; 244 + }); 245 + } 246 + 211 247 sort($uses); 212 248 213 249 return $uses; ··· 233 269 } 234 270 235 271 /** 272 + * Build a map of local definitions for type resolution. 273 + * 274 + * Maps local references (#defName) to their generated class names. 275 + * 276 + * @return array<string, string> Map of local ref => class name 277 + */ 278 + protected function buildLocalDefinitionMap(LexiconDocument $document): array 279 + { 280 + $localDefs = []; 281 + $allDefs = $document->defs ?? []; 282 + 283 + foreach ($allDefs as $defName => $definition) { 284 + // Skip the main definition 285 + if ($defName === 'main') { 286 + continue; 287 + } 288 + 289 + // Convert definition name to class name 290 + $className = $this->naming->toClassName($defName); 291 + $localDefs["#{$defName}"] = $className; 292 + } 293 + 294 + return $localDefs; 295 + } 296 + 297 + /** 236 298 * Get the naming converter. 237 299 */ 238 300 public function getNaming(): NamingConverter ··· 270 332 public function getDocBlockGenerator(): DocBlockGenerator 271 333 { 272 334 return $this->docBlockGenerator; 335 + } 336 + 337 + /** 338 + * Get the extension manager. 339 + */ 340 + public function getExtensions(): ExtensionManager 341 + { 342 + return $this->extensions; 273 343 } 274 344 }
+3 -3
src/Generator/ConstructorGenerator.php
··· 21 21 ?PropertyGenerator $propertyGenerator = null, 22 22 ?StubRenderer $renderer = null 23 23 ) { 24 - $this->propertyGenerator = $propertyGenerator ?? new PropertyGenerator(); 25 - $this->renderer = $renderer ?? new StubRenderer(); 24 + $this->propertyGenerator = $propertyGenerator ?? new PropertyGenerator; 25 + $this->renderer = $renderer ?? new StubRenderer; 26 26 } 27 27 28 28 /** ··· 162 162 $params."\n". 163 163 " ) {\n". 164 164 $body."\n". 165 - " }"; 165 + ' }'; 166 166 } 167 167 168 168 /**
+44 -32
src/Generator/DTOGenerator.php
··· 35 35 protected FileWriter $fileWriter; 36 36 37 37 /** 38 + * Class generator for generating PHP classes. 39 + */ 40 + protected ClassGenerator $classGenerator; 41 + 42 + /** 38 43 * Base namespace for generated classes. 39 44 */ 40 45 protected string $baseNamespace; ··· 54 59 ?TypeParser $typeParser = null, 55 60 ?NamespaceResolver $namespaceResolver = null, 56 61 ?TemplateRenderer $templateRenderer = null, 57 - ?FileWriter $fileWriter = null 62 + ?FileWriter $fileWriter = null, 63 + ?ClassGenerator $classGenerator = null 58 64 ) { 59 65 $this->schemaLoader = $schemaLoader; 60 66 $this->baseNamespace = rtrim($baseNamespace, '\\'); 61 67 $this->outputDirectory = rtrim($outputDirectory, '/'); 62 68 $this->typeParser = $typeParser ?? new TypeParser(schemaLoader: $schemaLoader); 63 69 $this->namespaceResolver = $namespaceResolver ?? new NamespaceResolver($baseNamespace); 64 - $this->templateRenderer = $templateRenderer ?? new TemplateRenderer(); 65 - $this->fileWriter = $fileWriter ?? new FileWriter(); 70 + $this->templateRenderer = $templateRenderer ?? new TemplateRenderer; 71 + $this->fileWriter = $fileWriter ?? new FileWriter; 72 + 73 + // Initialize ClassGenerator with proper naming converter 74 + $naming = new NamingConverter($this->baseNamespace); 75 + $this->classGenerator = $classGenerator ?? new ClassGenerator($naming); 66 76 } 67 77 68 78 /** ··· 70 80 */ 71 81 public function generate(LexiconDocument $schema): string 72 82 { 73 - return $this->generateRecordCode($schema); 83 + return $this->classGenerator->generate($schema); 74 84 } 75 85 76 86 /** ··· 114 124 */ 115 125 public function generateByNsid(string $nsid, array $options = []): array 116 126 { 117 - $schema = $this->schemaLoader->load($nsid); 118 - $document = LexiconDocument::fromArray($schema); 127 + $document = $this->schemaLoader->load($nsid); 119 128 120 129 return $this->generateFromDocument($document, $options); 121 130 } ··· 177 186 */ 178 187 protected function generateRecordClass(LexiconDocument $document, array $options = []): string 179 188 { 180 - $namespace = $this->namespaceResolver->resolveNamespace($document->getNsid()); 181 - $className = $this->namespaceResolver->resolveClassName($document->getNsid()); 182 - 183 - $mainDef = $document->getMainDefinition(); 184 - $recordSchema = $mainDef['record'] ?? []; 185 - 186 - $properties = $this->extractProperties($recordSchema, $document); 187 - 188 - $code = $this->templateRenderer->render('record', [ 189 - 'namespace' => $namespace, 190 - 'className' => $className, 191 - 'nsid' => $document->getNsid(), 192 - 'description' => $document->description, 193 - 'properties' => $properties, 194 - ]); 189 + // Use ClassGenerator for proper code generation 190 + $code = $this->classGenerator->generate($document); 195 191 192 + $naming = $this->classGenerator->getNaming(); 193 + $namespace = $naming->nsidToNamespace($document->getNsid()); 194 + $className = $naming->toClassName($document->id->getName()); 196 195 $filePath = $this->getFilePath($namespace, $className); 197 196 198 197 if (! ($options['dryRun'] ?? false)) { ··· 207 206 */ 208 207 protected function generateDefinitionClass(LexiconDocument $document, string $defName, array $options = []): string 209 208 { 210 - $namespace = $this->namespaceResolver->resolveNamespace($document->getNsid()); 211 - $className = $this->namespaceResolver->resolveClassName($document->getNsid(), $defName); 212 - 209 + // Create a temporary document for this specific definition 213 210 $definition = $document->getDefinition($defName); 214 - $properties = $this->extractProperties($definition, $document); 215 211 216 - $code = $this->templateRenderer->render('object', [ 217 - 'namespace' => $namespace, 218 - 'className' => $className, 212 + // Build a temporary lexicon document for the object definition 213 + $objectNsid = $document->getNsid().'.'.$defName; 214 + $tempSchema = [ 215 + 'id' => $objectNsid, 216 + 'lexicon' => 1, 219 217 'description' => $definition['description'] ?? null, 220 - 'properties' => $properties, 221 - ]); 218 + 'defs' => [ 219 + 'main' => [ 220 + 'type' => 'object', 221 + 'properties' => $definition['properties'] ?? [], 222 + 'required' => $definition['required'] ?? [], 223 + 'description' => $definition['description'] ?? null, 224 + ], 225 + ], 226 + ]; 227 + 228 + $tempDocument = \SocialDept\Schema\Data\LexiconDocument::fromArray($tempSchema); 222 229 230 + // Use ClassGenerator for proper code generation 231 + $code = $this->classGenerator->generate($tempDocument); 232 + 233 + $naming = $this->classGenerator->getNaming(); 234 + $namespace = $naming->nsidToNamespace($objectNsid); 235 + $className = $naming->toClassName($defName); 223 236 $filePath = $this->getFilePath($namespace, $className); 224 237 225 238 if (! ($options['dryRun'] ?? false)) { ··· 301 314 */ 302 315 public function getMetadata(string $nsid): array 303 316 { 304 - $schema = $this->schemaLoader->load($nsid); 305 - $document = LexiconDocument::fromArray($schema); 317 + $document = $this->schemaLoader->load($nsid); 306 318 307 319 $namespace = $this->namespaceResolver->resolveNamespace($document->getNsid()); 308 320 $className = $this->namespaceResolver->resolveClassName($document->getNsid());
+1 -1
src/Generator/DocBlockGenerator.php
··· 16 16 */ 17 17 public function __construct(?TypeMapper $typeMapper = null) 18 18 { 19 - $this->typeMapper = $typeMapper ?? new TypeMapper(); 19 + $this->typeMapper = $typeMapper ?? new TypeMapper; 20 20 } 21 21 22 22 /**
+157 -14
src/Generator/MethodGenerator.php
··· 3 3 namespace SocialDept\Schema\Generator; 4 4 5 5 use SocialDept\Schema\Data\LexiconDocument; 6 + use SocialDept\Schema\Support\ExtensionManager; 6 7 7 8 class MethodGenerator 8 9 { ··· 27 28 protected ModelMapper $modelMapper; 28 29 29 30 /** 31 + * Extension manager instance. 32 + */ 33 + protected ExtensionManager $extensions; 34 + 35 + /** 30 36 * Create a new MethodGenerator. 31 37 */ 32 38 public function __construct( 33 39 ?NamingConverter $naming = null, 34 40 ?TypeMapper $typeMapper = null, 35 41 ?StubRenderer $renderer = null, 36 - ?ModelMapper $modelMapper = null 42 + ?ModelMapper $modelMapper = null, 43 + ?ExtensionManager $extensions = null 37 44 ) { 38 - $this->naming = $naming ?? new NamingConverter(); 45 + $this->naming = $naming ?? new NamingConverter; 39 46 $this->typeMapper = $typeMapper ?? new TypeMapper($this->naming); 40 - $this->renderer = $renderer ?? new StubRenderer(); 47 + $this->renderer = $renderer ?? new StubRenderer; 41 48 $this->modelMapper = $modelMapper ?? new ModelMapper($this->naming, $this->typeMapper); 49 + $this->extensions = $extensions ?? new ExtensionManager; 42 50 } 43 51 44 52 /** ··· 61 69 { 62 70 $nsid = $document->getNsid(); 63 71 64 - return $this->renderer->render('method', [ 72 + $method = $this->renderer->render('method', [ 65 73 'docBlock' => $this->generateDocBlock('Get the lexicon NSID for this data type.', 'string'), 66 74 'visibility' => 'public ', 67 75 'static' => 'static ', ··· 70 78 'returnType' => ': string', 71 79 'body' => " return '{$nsid}';", 72 80 ]); 81 + 82 + return $this->extensions->filter('filter:method:getLexicon', $method, $document); 73 83 } 74 84 75 85 /** ··· 78 88 public function generateFromArray(LexiconDocument $document): string 79 89 { 80 90 $mainDef = $document->getMainDefinition(); 81 - $properties = $mainDef['properties'] ?? []; 82 - $required = $mainDef['required'] ?? []; 91 + 92 + // For record types, properties are nested under 'record' 93 + $type = $mainDef['type'] ?? null; 94 + if ($type === 'record') { 95 + $recordDef = $mainDef['record'] ?? []; 96 + $properties = $recordDef['properties'] ?? []; 97 + $required = $recordDef['required'] ?? []; 98 + } else { 99 + // For object types, properties are at the top level 100 + $properties = $mainDef['properties'] ?? []; 101 + $required = $mainDef['required'] ?? []; 102 + } 83 103 84 104 if (empty($properties)) { 85 105 return $this->generateEmptyFromArray(); ··· 88 108 $assignments = $this->generateFromArrayAssignments($properties, $required); 89 109 $body = " return new static(\n".$assignments."\n );"; 90 110 91 - return $this->renderer->render('method', [ 111 + $method = $this->renderer->render('method', [ 92 112 'docBlock' => $this->generateDocBlock('Create an instance from an array.', 'static', [ 93 113 ['name' => 'data', 'type' => 'array', 'description' => 'The data array'], 94 114 ]), ··· 99 119 'returnType' => ': static', 100 120 'body' => $body, 101 121 ]); 122 + 123 + return $this->extensions->filter('filter:method:fromArray', $method, $document, $properties, $required); 102 124 } 103 125 104 126 /** ··· 129 151 { 130 152 $lines = []; 131 153 154 + // Generate required parameters first 132 155 foreach ($properties as $name => $definition) { 133 - $type = $definition['type'] ?? 'unknown'; 134 - $assignment = $this->generatePropertyAssignment($name, $definition, $type, $required); 135 - $lines[] = ' '.$name.': '.$assignment.','; 156 + if (in_array($name, $required)) { 157 + $type = $definition['type'] ?? 'unknown'; 158 + $assignment = $this->generatePropertyAssignment($name, $definition, $type, $required); 159 + $lines[] = ' '.$name.': '.$assignment.','; 160 + } 161 + } 162 + 163 + // Then generate optional parameters 164 + foreach ($properties as $name => $definition) { 165 + if (! in_array($name, $required)) { 166 + $type = $definition['type'] ?? 'unknown'; 167 + $assignment = $this->generatePropertyAssignment($name, $definition, $type, $required); 168 + $lines[] = ' '.$name.': '.$assignment.','; 169 + } 136 170 } 137 171 138 172 // Remove trailing comma from last line ··· 152 186 protected function generatePropertyAssignment(string $name, array $definition, string $type, array $required): string 153 187 { 154 188 $isRequired = in_array($name, $required); 189 + $assignment = $this->generatePropertyAssignmentInternal($name, $definition, $type, $required); 190 + 191 + return $this->extensions->filter('filter:method:propertyAssignment', $assignment, $name, $definition, $type, $required); 192 + } 193 + 194 + /** 195 + * Internal property assignment generation logic. 196 + * 197 + * @param array<string, mixed> $definition 198 + * @param array<string> $required 199 + */ 200 + protected function generatePropertyAssignmentInternal(string $name, array $definition, string $type, array $required): string 201 + { 202 + $isRequired = in_array($name, $required); 155 203 156 204 // Handle reference types 157 205 if ($type === 'ref' && isset($definition['ref'])) { 158 - $refClass = $this->naming->nsidToClassName($definition['ref']); 206 + $ref = $definition['ref']; 207 + 208 + // Skip local references (starting with #) - treat as mixed 209 + if (str_starts_with($ref, '#')) { 210 + // Local references don't need conversion, just return the data 211 + if ($isRequired) { 212 + return "\$data['{$name}']"; 213 + } 214 + 215 + return "\$data['{$name}'] ?? null"; 216 + } 217 + 218 + // Handle NSID fragments - extract just the NSID part 219 + if (str_contains($ref, '#')) { 220 + $ref = explode('#', $ref)[0]; 221 + } 222 + 223 + $refClass = $this->naming->nsidToClassName($ref); 159 224 $className = basename(str_replace('\\', '/', $refClass)); 160 225 161 226 if ($isRequired) { ··· 167 232 168 233 // Handle arrays of references 169 234 if ($type === 'array' && isset($definition['items']['type']) && $definition['items']['type'] === 'ref') { 170 - $refClass = $this->naming->nsidToClassName($definition['items']['ref']); 235 + $ref = $definition['items']['ref']; 236 + 237 + // Skip local references - treat array as mixed 238 + if (str_starts_with($ref, '#')) { 239 + return "\$data['{$name}'] ?? []"; 240 + } 241 + 242 + // Handle NSID fragments 243 + if (str_contains($ref, '#')) { 244 + $ref = explode('#', $ref)[0]; 245 + } 246 + 247 + $refClass = $this->naming->nsidToClassName($ref); 171 248 $className = basename(str_replace('\\', '/', $refClass)); 172 249 173 250 return "isset(\$data['{$name}']) ? array_map(fn (\$item) => {$className}::fromArray(\$item), \$data['{$name}']) : []"; ··· 181 258 // Handle DateTime types (if string format matches ISO8601) 182 259 if ($type === 'string' && isset($definition['format']) && $definition['format'] === 'datetime') { 183 260 if ($isRequired) { 184 - return "new \\DateTime(\$data['{$name}'])"; 261 + return "Carbon::parse(\$data['{$name}'])"; 262 + } 263 + 264 + return "isset(\$data['{$name}']) ? Carbon::parse(\$data['{$name}']) : null"; 265 + } 266 + 267 + // Handle union types with refs 268 + if ($type === 'union' && isset($definition['refs']) && is_array($definition['refs'])) { 269 + $refs = $definition['refs']; 270 + $isClosed = $definition['closed'] ?? false; 271 + 272 + // Filter out local references 273 + $externalRefs = array_values(array_filter($refs, fn ($ref) => ! str_starts_with($ref, '#'))); 274 + 275 + // Handle closed unions - use UnionHelper for discrimination 276 + if ($isClosed && ! empty($externalRefs)) { 277 + // Build array of variant class names 278 + $variantClasses = []; 279 + foreach ($externalRefs as $ref) { 280 + // Handle NSID fragments 281 + if (str_contains($ref, '#')) { 282 + $ref = explode('#', $ref)[0]; 283 + } 284 + 285 + $refClass = $this->naming->nsidToClassName($ref); 286 + $className = basename(str_replace('\\', '/', $refClass)); 287 + $variantClasses[] = "{$className}::class"; 288 + } 289 + 290 + $variantsArray = '['.implode(', ', $variantClasses).']'; 291 + 292 + if ($isRequired) { 293 + return "\\SocialDept\\Schema\\Support\\UnionHelper::resolveClosedUnion(\$data['{$name}'], {$variantsArray})"; 294 + } 295 + 296 + return "isset(\$data['{$name}']) ? \\SocialDept\\Schema\\Support\\UnionHelper::resolveClosedUnion(\$data['{$name}'], {$variantsArray}) : null"; 185 297 } 186 298 187 - return "isset(\$data['{$name}']) ? new \\DateTime(\$data['{$name}']) : null"; 299 + // Open unions - validate $type presence using UnionHelper 300 + if (! $isClosed) { 301 + if ($isRequired) { 302 + return "\\SocialDept\\Schema\\Support\\UnionHelper::validateOpenUnion(\$data['{$name}'])"; 303 + } 304 + 305 + return "isset(\$data['{$name}']) ? \\SocialDept\\Schema\\Support\\UnionHelper::validateOpenUnion(\$data['{$name}']) : null"; 306 + } 307 + 308 + // Fallback for unions with only local refs 309 + if ($isRequired) { 310 + return "\$data['{$name}']"; 311 + } 312 + 313 + return "\$data['{$name}'] ?? null"; 314 + } 315 + 316 + // Handle blob types (already converted to BlobReference by the protocol) 317 + if ($type === 'blob') { 318 + if ($isRequired) { 319 + return "\$data['{$name}']"; 320 + } 321 + 322 + return "\$data['{$name}'] ?? null"; 188 323 } 189 324 190 325 // Default: simple property access ··· 333 468 public function getModelMapper(): ModelMapper 334 469 { 335 470 return $this->modelMapper; 471 + } 472 + 473 + /** 474 + * Get the extension manager. 475 + */ 476 + public function getExtensions(): ExtensionManager 477 + { 478 + return $this->extensions; 336 479 } 337 480 }
+1 -2
src/Generator/ModelMapper.php
··· 19 19 */ 20 20 public function __construct(?NamingConverter $naming = null, ?TypeMapper $typeMapper = null) 21 21 { 22 - $this->naming = $naming ?? new NamingConverter(); 22 + $this->naming = $naming ?? new NamingConverter; 23 23 $this->typeMapper = $typeMapper ?? new TypeMapper($this->naming); 24 24 } 25 25 ··· 27 27 * Generate toModel method body. 28 28 * 29 29 * @param array<string, array<string, mixed>> $properties 30 - * @param string $modelClass 31 30 */ 32 31 public function generateToModelBody(array $properties, string $modelClass = 'Model'): string 33 32 {
+2 -2
src/Generator/NamingConverter.php
··· 38 38 { 39 39 $nsid = Nsid::parse($nsidString); 40 40 41 - // Split authority into parts (e.g., "app.bsky" -> ["app", "bsky"]) 42 - $authorityParts = array_reverse(explode('.', $nsid->getAuthority())); 41 + // Split authority into parts (e.g., "blog.pckt" -> ["blog", "pckt"]) 42 + $authorityParts = explode('.', $nsid->getAuthority()); 43 43 44 44 // Convert each part to PascalCase 45 45 $namespaceParts = array_map(
+2 -2
src/Generator/PropertyGenerator.php
··· 19 19 */ 20 20 public function __construct(?TypeMapper $typeMapper = null, ?StubRenderer $renderer = null) 21 21 { 22 - $this->typeMapper = $typeMapper ?? new TypeMapper(); 23 - $this->renderer = $renderer ?? new StubRenderer(); 22 + $this->typeMapper = $typeMapper ?? new TypeMapper; 23 + $this->renderer = $renderer ?? new StubRenderer; 24 24 } 25 25 26 26 /**
+1 -1
src/Generator/TemplateRenderer.php
··· 100 100 } 101 101 102 102 $propertiesCode[] = sprintf( 103 - "%s public readonly %s $%s;", 103 + '%s public readonly %s $%s;', 104 104 $docComment, 105 105 $typeHint, 106 106 $prop['name']
+190 -24
src/Generator/TypeMapper.php
··· 2 2 3 3 namespace SocialDept\Schema\Generator; 4 4 5 + use SocialDept\Schema\Support\ExtensionManager; 6 + 5 7 class TypeMapper 6 8 { 7 9 /** 8 10 * Naming converter instance. 9 11 */ 10 12 protected NamingConverter $naming; 13 + 14 + /** 15 + * Local definition map for resolving #refs. 16 + * 17 + * @var array<string, string> 18 + */ 19 + protected array $localDefinitions = []; 20 + 21 + /** 22 + * Extension manager instance. 23 + */ 24 + protected ExtensionManager $extensions; 11 25 12 26 /** 13 27 * Create a new TypeMapper. 14 28 */ 15 - public function __construct(?NamingConverter $naming = null) 29 + public function __construct(?NamingConverter $naming = null, ?ExtensionManager $extensions = null) 16 30 { 17 - $this->naming = $naming ?? new NamingConverter(); 31 + $this->naming = $naming ?? new NamingConverter; 32 + $this->extensions = $extensions ?? new ExtensionManager; 33 + } 34 + 35 + /** 36 + * Set local definitions for resolving local references. 37 + * 38 + * @param array<string, string> $localDefinitions Map of #ref => class name 39 + */ 40 + public function setLocalDefinitions(array $localDefinitions): void 41 + { 42 + $this->localDefinitions = $localDefinitions; 18 43 } 19 44 20 45 /** ··· 27 52 $type = $definition['type'] ?? 'unknown'; 28 53 29 54 $phpType = match ($type) { 30 - 'string' => 'string', 55 + 'string' => $this->mapStringType($definition), 31 56 'integer' => 'int', 32 57 'boolean' => 'bool', 33 58 'number' => 'float', 34 59 'array' => $this->mapArrayType($definition), 35 60 'object' => $this->mapObjectType($definition), 36 - 'blob' => '\\SocialDept\\Schema\\Data\\BlobReference', 61 + 'blob' => 'BlobReference', 37 62 'bytes' => 'string', 38 63 'cid-link' => 'string', 39 64 'unknown' => 'mixed', ··· 43 68 }; 44 69 45 70 if ($nullable && $phpType !== 'mixed') { 46 - return '?'.$phpType; 71 + $phpType = '?'.$phpType; 47 72 } 48 73 49 - return $phpType; 74 + return $this->extensions->filter('filter:type:phpType', $phpType, $definition, $nullable); 50 75 } 51 76 52 77 /** ··· 59 84 $type = $definition['type'] ?? 'unknown'; 60 85 61 86 $docType = match ($type) { 62 - 'string' => 'string', 87 + 'string' => $this->mapStringType($definition), 63 88 'integer' => 'int', 64 89 'boolean' => 'bool', 65 90 'number' => 'float', 66 91 'array' => $this->mapArrayDocType($definition), 67 92 'object' => $this->mapObjectDocType($definition), 68 - 'blob' => '\\SocialDept\\Schema\\Data\\BlobReference', 93 + 'blob' => 'BlobReference', 69 94 'bytes' => 'string', 70 95 'cid-link' => 'string', 71 96 'unknown' => 'mixed', ··· 75 100 }; 76 101 77 102 if ($nullable && $docType !== 'mixed') { 78 - return $docType.'|null'; 103 + $docType = $docType.'|null'; 104 + } 105 + 106 + return $this->extensions->filter('filter:type:phpDocType', $docType, $definition, $nullable); 107 + } 108 + 109 + /** 110 + * Map string type. 111 + * 112 + * @param array<string, mixed> $definition 113 + */ 114 + protected function mapStringType(array $definition): string 115 + { 116 + // Check for datetime format 117 + if (isset($definition['format']) && $definition['format'] === 'datetime') { 118 + return 'Carbon'; 79 119 } 80 120 81 - return $docType; 121 + return 'string'; 82 122 } 83 123 84 124 /** ··· 103 143 } 104 144 105 145 $itemType = $this->toPhpDocType($definition['items']); 146 + 147 + // array<mixed> is redundant, just use array 148 + if ($itemType === 'mixed') { 149 + return 'array'; 150 + } 106 151 107 152 return "array<{$itemType}>"; 108 153 } ··· 153 198 return 'mixed'; 154 199 } 155 200 156 - // Convert NSID reference to class name 157 - return '\\'.$this->naming->nsidToClassName($definition['ref']); 201 + $ref = $definition['ref']; 202 + 203 + // Resolve local references using the local definitions map 204 + if (str_starts_with($ref, '#')) { 205 + return $this->localDefinitions[$ref] ?? 'mixed'; 206 + } 207 + 208 + // Handle NSID fragments (e.g., com.atproto.label.defs#selfLabels) 209 + // Extract just the NSID part for class resolution 210 + if (str_contains($ref, '#')) { 211 + $ref = explode('#', $ref)[0]; 212 + } 213 + 214 + // Convert NSID reference to fully qualified class name 215 + $fqcn = $this->naming->nsidToClassName($ref); 216 + 217 + // Extract short class name (last part after final backslash) 218 + $parts = explode('\\', $fqcn); 219 + 220 + return end($parts); 158 221 } 159 222 160 223 /** ··· 174 237 */ 175 238 protected function mapUnionType(array $definition): string 176 239 { 177 - // For runtime type hints, unions of different types must be 'mixed' 178 - return 'mixed'; 240 + // Open unions (closed=false or not set) should always be mixed 241 + // because future schema versions could add more types 242 + $isClosed = $definition['closed'] ?? false; 243 + 244 + if (! $isClosed) { 245 + return 'mixed'; 246 + } 247 + 248 + // For closed unions, extract external refs 249 + $refs = $definition['refs'] ?? []; 250 + $externalRefs = array_values(array_filter($refs, fn ($ref) => ! str_starts_with($ref, '#'))); 251 + 252 + if (empty($externalRefs)) { 253 + return 'mixed'; 254 + } 255 + 256 + // Build union type with all variants 257 + $types = []; 258 + foreach ($externalRefs as $ref) { 259 + // Handle NSID fragments - extract just the NSID part 260 + if (str_contains($ref, '#')) { 261 + $ref = explode('#', $ref)[0]; 262 + } 263 + 264 + // Convert to fully qualified class name, then extract short name 265 + $fqcn = $this->naming->nsidToClassName($ref); 266 + $parts = explode('\\', $fqcn); 267 + $types[] = end($parts); 268 + } 269 + 270 + // Return union type (e.g., "Theme|ThemeV2" or just "Theme" for single ref) 271 + return implode('|', $types); 179 272 } 180 273 181 274 /** ··· 189 282 return 'mixed'; 190 283 } 191 284 192 - $types = array_map( 193 - fn ($ref) => '\\'.$this->naming->nsidToClassName($ref), 194 - $definition['refs'] 195 - ); 285 + // Open unions should be typed as mixed since future types could be added 286 + $isClosed = $definition['closed'] ?? false; 287 + if (! $isClosed) { 288 + return 'mixed'; 289 + } 290 + 291 + // For closed unions, list all the specific types 292 + $types = []; 293 + foreach ($definition['refs'] as $ref) { 294 + // Resolve local references using the local definitions map 295 + if (str_starts_with($ref, '#')) { 296 + $types[] = $this->localDefinitions[$ref] ?? 'mixed'; 297 + 298 + continue; 299 + } 300 + 301 + // Handle NSID fragments - extract just the NSID part 302 + if (str_contains($ref, '#')) { 303 + $ref = explode('#', $ref)[0]; 304 + } 305 + 306 + // Convert to fully qualified class name, then extract short name 307 + $fqcn = $this->naming->nsidToClassName($ref); 308 + $parts = explode('\\', $fqcn); 309 + $types[] = end($parts); 310 + } 196 311 197 312 return implode('|', $types); 198 313 } ··· 263 378 { 264 379 $type = $definition['type'] ?? 'unknown'; 265 380 381 + // Check for datetime format on strings 382 + if ($type === 'string' && isset($definition['format']) && $definition['format'] === 'datetime') { 383 + return true; 384 + } 385 + 266 386 return in_array($type, ['ref', 'blob']); 267 387 } 268 388 ··· 276 396 { 277 397 $type = $definition['type'] ?? 'unknown'; 278 398 399 + if ($type === 'string' && isset($definition['format']) && $definition['format'] === 'datetime') { 400 + return ['Carbon\\Carbon']; 401 + } 402 + 279 403 if ($type === 'blob') { 280 404 return ['SocialDept\\Schema\\Data\\BlobReference']; 281 405 } 282 406 283 407 if ($type === 'ref' && isset($definition['ref'])) { 284 - return [$this->naming->nsidToClassName($definition['ref'])]; 408 + $ref = $definition['ref']; 409 + 410 + // Skip local references (starting with #) 411 + if (str_starts_with($ref, '#')) { 412 + return []; 413 + } 414 + 415 + // Handle NSID fragments - extract just the NSID part 416 + if (str_contains($ref, '#')) { 417 + $ref = explode('#', $ref)[0]; 418 + } 419 + 420 + return [$this->naming->nsidToClassName($ref)]; 285 421 } 286 422 287 423 if ($type === 'union' && isset($definition['refs'])) { 288 - return array_map( 289 - fn ($ref) => $this->naming->nsidToClassName($ref), 290 - $definition['refs'] 291 - ); 424 + // Open unions don't need use statements since they're typed as mixed 425 + $isClosed = $definition['closed'] ?? false; 426 + if (! $isClosed) { 427 + return []; 428 + } 429 + 430 + // For closed unions, import the referenced classes 431 + $classes = []; 432 + 433 + foreach ($definition['refs'] as $ref) { 434 + // Skip local references 435 + if (str_starts_with($ref, '#')) { 436 + continue; 437 + } 438 + 439 + // Handle NSID fragments - extract just the NSID part 440 + if (str_contains($ref, '#')) { 441 + $ref = explode('#', $ref)[0]; 442 + } 443 + 444 + $classes[] = $this->naming->nsidToClassName($ref); 445 + } 446 + 447 + return $classes; 292 448 } 293 449 294 - return []; 450 + $uses = []; 451 + 452 + return $this->extensions->filter('filter:type:useStatements', $uses, $definition); 453 + } 454 + 455 + /** 456 + * Get the extension manager. 457 + */ 458 + public function getExtensions(): ExtensionManager 459 + { 460 + return $this->extensions; 295 461 } 296 462 }
+1 -3
stubs/class.stub
··· 7 7 {{ docBlock }} 8 8 class {{ className }}{{ extends }}{{ implements }} 9 9 { 10 - {{ properties }} 11 - 12 - {{ constructor }} 10 + {{ properties }}{{ constructor }} 13 11 14 12 {{ methods }} 15 13 }
+3 -3
stubs/method.stub
··· 1 1 {{ docBlock }} 2 - {{ visibility }}{{ static }}function {{ name }}({{ parameters }}){{ returnType }} 3 - { 2 + {{ visibility }}{{ static }}function {{ name }}({{ parameters }}){{ returnType }} 3 + { 4 4 {{ body }} 5 - } 5 + }