Parse and validate AT Protocol Lexicons with DTO generation for Laravel
at main 16 kB view raw
1<?php 2 3namespace SocialDept\AtpSchema\Generator; 4 5use SocialDept\AtpSchema\Data\LexiconDocument; 6use SocialDept\AtpSchema\Support\ExtensionManager; 7 8class MethodGenerator 9{ 10 /** 11 * Naming converter instance. 12 */ 13 protected NamingConverter $naming; 14 15 /** 16 * Type mapper instance. 17 */ 18 protected TypeMapper $typeMapper; 19 20 /** 21 * Stub renderer instance. 22 */ 23 protected StubRenderer $renderer; 24 25 /** 26 * Model mapper instance. 27 */ 28 protected ModelMapper $modelMapper; 29 30 /** 31 * Extension manager instance. 32 */ 33 protected ExtensionManager $extensions; 34 35 /** 36 * Create a new MethodGenerator. 37 */ 38 public function __construct( 39 ?NamingConverter $naming = null, 40 ?TypeMapper $typeMapper = null, 41 ?StubRenderer $renderer = null, 42 ?ModelMapper $modelMapper = null, 43 ?ExtensionManager $extensions = null 44 ) { 45 $this->naming = $naming ?? new NamingConverter(); 46 $this->typeMapper = $typeMapper ?? new TypeMapper($this->naming); 47 $this->renderer = $renderer ?? new StubRenderer(); 48 $this->modelMapper = $modelMapper ?? new ModelMapper($this->naming, $this->typeMapper); 49 $this->extensions = $extensions ?? new ExtensionManager(); 50 } 51 52 /** 53 * Generate all standard methods for a data class. 54 * 55 * @return array<string> 56 */ 57 public function generateAll(LexiconDocument $document): array 58 { 59 return [ 60 $this->generateGetLexicon($document), 61 $this->generateFromArray($document), 62 ]; 63 } 64 65 /** 66 * Generate getLexicon method. 67 */ 68 public function generateGetLexicon(LexiconDocument $document): string 69 { 70 $nsid = $document->getNsid(); 71 72 $method = $this->renderer->render('method', [ 73 'docBlock' => $this->generateDocBlock('Get the lexicon NSID for this data type.', 'string'), 74 'visibility' => 'public ', 75 'static' => 'static ', 76 'name' => 'getLexicon', 77 'parameters' => '', 78 'returnType' => ': string', 79 'body' => " return '{$nsid}';", 80 ]); 81 82 return $this->extensions->filter('filter:method:getLexicon', $method, $document); 83 } 84 85 /** 86 * Generate fromArray method. 87 */ 88 public function generateFromArray(LexiconDocument $document): string 89 { 90 $mainDef = $document->getMainDefinition(); 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 } 103 104 if (empty($properties)) { 105 return $this->generateEmptyFromArray(); 106 } 107 108 $assignments = $this->generateFromArrayAssignments($properties, $required); 109 $body = " return new static(\n".$assignments."\n );"; 110 111 $method = $this->renderer->render('method', [ 112 'docBlock' => $this->generateDocBlock('Create an instance from an array.', 'static', [ 113 ['name' => 'data', 'type' => 'array', 'description' => 'The data array'], 114 ]), 115 'visibility' => 'public ', 116 'static' => 'static ', 117 'name' => 'fromArray', 118 'parameters' => 'array $data', 119 'returnType' => ': static', 120 'body' => $body, 121 ]); 122 123 return $this->extensions->filter('filter:method:fromArray', $method, $document, $properties, $required); 124 } 125 126 /** 127 * Generate fromArray for empty properties. 128 */ 129 protected function generateEmptyFromArray(): string 130 { 131 return $this->renderer->render('method', [ 132 'docBlock' => $this->generateDocBlock('Create an instance from an array.', 'static', [ 133 ['name' => 'data', 'type' => 'array', 'description' => 'The data array'], 134 ]), 135 'visibility' => 'public ', 136 'static' => 'static ', 137 'name' => 'fromArray', 138 'parameters' => 'array $data', 139 'returnType' => ': static', 140 'body' => ' return new static();', 141 ]); 142 } 143 144 /** 145 * Generate assignments for fromArray method. 146 * 147 * @param array<string, array<string, mixed>> $properties 148 * @param array<string> $required 149 */ 150 protected function generateFromArrayAssignments(array $properties, array $required): string 151 { 152 $lines = []; 153 154 // Generate required parameters first 155 foreach ($properties as $name => $definition) { 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 } 170 } 171 172 // Remove trailing comma from last line 173 if (! empty($lines)) { 174 $lines[count($lines) - 1] = rtrim($lines[count($lines) - 1], ','); 175 } 176 177 return implode("\n", $lines); 178 } 179 180 /** 181 * Generate assignment for a property in fromArray. 182 * 183 * @param array<string, mixed> $definition 184 * @param array<string> $required 185 */ 186 protected function generatePropertyAssignment(string $name, array $definition, string $type, array $required): string 187 { 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); 203 204 // Handle reference types 205 if ($type === 'ref' && isset($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 219 if (str_contains($ref, '#')) { 220 [$baseNsid, $fragment] = explode('#', $ref, 2); 221 $className = $this->naming->toClassName($fragment); 222 } else { 223 $refClass = $this->naming->nsidToClassName($ref); 224 $className = basename(str_replace('\\', '/', $refClass)); 225 } 226 227 if ($isRequired) { 228 return "{$className}::fromArray(\$data['{$name}'])"; 229 } 230 231 return "isset(\$data['{$name}']) ? {$className}::fromArray(\$data['{$name}']) : null"; 232 } 233 234 // Handle arrays of references 235 if ($type === 'array' && isset($definition['items']['type']) && $definition['items']['type'] === 'ref') { 236 $ref = $definition['items']['ref']; 237 238 // Skip local references - treat array as mixed 239 if (str_starts_with($ref, '#')) { 240 return "\$data['{$name}'] ?? []"; 241 } 242 243 // Handle NSID fragments 244 if (str_contains($ref, '#')) { 245 [$baseNsid, $fragment] = explode('#', $ref, 2); 246 $className = $this->naming->toClassName($fragment); 247 } else { 248 $refClass = $this->naming->nsidToClassName($ref); 249 $className = basename(str_replace('\\', '/', $refClass)); 250 } 251 252 return "isset(\$data['{$name}']) ? array_map(fn (\$item) => {$className}::fromArray(\$item), \$data['{$name}']) : []"; 253 } 254 255 // Handle arrays of objects 256 if ($type === 'array' && isset($definition['items']['type']) && $definition['items']['type'] === 'object') { 257 return "\$data['{$name}'] ?? []"; 258 } 259 260 // Handle DateTime types (if string format matches ISO8601) 261 if ($type === 'string' && isset($definition['format']) && $definition['format'] === 'datetime') { 262 if ($isRequired) { 263 return "Carbon::parse(\$data['{$name}'])"; 264 } 265 266 return "isset(\$data['{$name}']) ? Carbon::parse(\$data['{$name}']) : null"; 267 } 268 269 // Handle union types with refs 270 if ($type === 'union' && isset($definition['refs']) && is_array($definition['refs'])) { 271 $refs = $definition['refs']; 272 $isClosed = $definition['closed'] ?? false; 273 274 // Filter out local references 275 $externalRefs = array_values(array_filter($refs, fn ($ref) => ! str_starts_with($ref, '#'))); 276 277 // Handle closed unions - use UnionHelper for discrimination 278 if ($isClosed && ! empty($externalRefs)) { 279 // Build array of variant class names 280 $variantClasses = []; 281 foreach ($externalRefs as $ref) { 282 // Handle NSID fragments 283 if (str_contains($ref, '#')) { 284 [$baseNsid, $fragment] = explode('#', $ref, 2); 285 $className = $this->naming->toClassName($fragment); 286 } else { 287 $refClass = $this->naming->nsidToClassName($ref); 288 $className = basename(str_replace('\\', '/', $refClass)); 289 } 290 $variantClasses[] = "{$className}::class"; 291 } 292 293 $variantsArray = '['.implode(', ', $variantClasses).']'; 294 295 if ($isRequired) { 296 return "UnionHelper::resolveClosedUnion(\$data['{$name}'], {$variantsArray})"; 297 } 298 299 return "isset(\$data['{$name}']) ? UnionHelper::resolveClosedUnion(\$data['{$name}'], {$variantsArray}) : null"; 300 } 301 302 // Open unions - validate $type presence using UnionHelper 303 if (! $isClosed) { 304 if ($isRequired) { 305 return "UnionHelper::validateOpenUnion(\$data['{$name}'])"; 306 } 307 308 return "isset(\$data['{$name}']) ? UnionHelper::validateOpenUnion(\$data['{$name}']) : null"; 309 } 310 311 // Fallback for unions with only local refs 312 if ($isRequired) { 313 return "\$data['{$name}']"; 314 } 315 316 return "\$data['{$name}'] ?? null"; 317 } 318 319 // Handle blob types (already converted to BlobReference by the protocol) 320 if ($type === 'blob') { 321 if ($isRequired) { 322 return "\$data['{$name}']"; 323 } 324 325 return "\$data['{$name}'] ?? null"; 326 } 327 328 // Default: simple property access 329 if ($isRequired) { 330 return "\$data['{$name}']"; 331 } 332 333 return "\$data['{$name}'] ?? null"; 334 } 335 336 /** 337 * Generate a generic method. 338 * 339 * @param array<array{name: string, type: string, description?: string}> $params 340 */ 341 public function generate( 342 string $name, 343 string $returnType, 344 string $body, 345 ?string $description = null, 346 array $params = [], 347 bool $isStatic = false 348 ): string { 349 $parameters = $this->formatParameters($params); 350 351 return $this->renderer->render('method', [ 352 'docBlock' => $this->generateDocBlock($description, $returnType, $params), 353 'visibility' => 'public ', 354 'static' => $isStatic ? 'static ' : '', 355 'name' => $name, 356 'parameters' => $parameters, 357 'returnType' => $returnType ? ': '.$returnType : '', 358 'body' => $body, 359 ]); 360 } 361 362 /** 363 * Generate method documentation block. 364 * 365 * @param array<array{name: string, type: string, description?: string}> $params 366 */ 367 protected function generateDocBlock(?string $description, ?string $returnType, array $params = []): string 368 { 369 $lines = [' /**']; 370 371 if ($description) { 372 $lines[] = ' * '.$description; 373 374 if (! empty($params) || $returnType) { 375 $lines[] = ' *'; 376 } 377 } 378 379 // Add parameter docs 380 foreach ($params as $param) { 381 $desc = $param['description'] ?? ''; 382 if ($desc) { 383 $lines[] = ' * @param '.$param['type'].' $'.$param['name'].' '.$desc; 384 } else { 385 $lines[] = ' * @param '.$param['type'].' $'.$param['name']; 386 } 387 } 388 389 // Add return type 390 if ($returnType && $returnType !== 'void') { 391 $lines[] = ' * @return '.$returnType; 392 } 393 394 $lines[] = ' */'; 395 396 return implode("\n", $lines); 397 } 398 399 /** 400 * Format method parameters. 401 * 402 * @param array<array{name: string, type: string, description?: string}> $params 403 */ 404 protected function formatParameters(array $params): string 405 { 406 if (empty($params)) { 407 return ''; 408 } 409 410 $formatted = []; 411 foreach ($params as $param) { 412 $formatted[] = $param['type'].' $'.$param['name']; 413 } 414 415 return implode(', ', $formatted); 416 } 417 418 /** 419 * Generate toModel method. 420 * 421 * @param array<string, array<string, mixed>> $properties 422 */ 423 public function generateToModel(array $properties, string $modelClass = 'Model'): string 424 { 425 $body = $this->modelMapper->generateToModelBody($properties, $modelClass); 426 427 return $this->renderer->render('method', [ 428 'docBlock' => $this->generateDocBlock( 429 'Convert to a Laravel model instance.', 430 $modelClass, 431 [] 432 ), 433 'visibility' => 'public ', 434 'static' => '', 435 'name' => 'toModel', 436 'parameters' => '', 437 'returnType' => ': '.$modelClass, 438 'body' => $body, 439 ]); 440 } 441 442 /** 443 * Generate fromModel method. 444 * 445 * @param array<string, array<string, mixed>> $properties 446 */ 447 public function generateFromModel(array $properties, string $modelClass = 'Model'): string 448 { 449 $body = $this->modelMapper->generateFromModelBody($properties); 450 451 return $this->renderer->render('method', [ 452 'docBlock' => $this->generateDocBlock( 453 'Create an instance from a Laravel model.', 454 'static', 455 [ 456 ['name' => 'model', 'type' => $modelClass, 'description' => 'The model instance'], 457 ] 458 ), 459 'visibility' => 'public ', 460 'static' => 'static ', 461 'name' => 'fromModel', 462 'parameters' => $modelClass.' $model', 463 'returnType' => ': static', 464 'body' => $body, 465 ]); 466 } 467 468 /** 469 * Get the model mapper. 470 */ 471 public function getModelMapper(): ModelMapper 472 { 473 return $this->modelMapper; 474 } 475 476 /** 477 * Get the extension manager. 478 */ 479 public function getExtensions(): ExtensionManager 480 { 481 return $this->extensions; 482 } 483}