Parse and validate AT Protocol Lexicons with DTO generation for Laravel
at dev 13 kB view raw
1<?php 2 3namespace SocialDept\AtpSchema\Generator; 4 5use SocialDept\AtpSchema\Data\LexiconDocument; 6use SocialDept\AtpSchema\Exceptions\GenerationException; 7use SocialDept\AtpSchema\Support\ExtensionManager; 8 9class ClassGenerator 10{ 11 /** 12 * Naming converter instance. 13 */ 14 protected NamingConverter $naming; 15 16 /** 17 * Type mapper instance. 18 */ 19 protected TypeMapper $typeMapper; 20 21 /** 22 * Stub renderer instance. 23 */ 24 protected StubRenderer $renderer; 25 26 /** 27 * Method generator instance. 28 */ 29 protected MethodGenerator $methodGenerator; 30 31 /** 32 * DocBlock generator instance. 33 */ 34 protected DocBlockGenerator $docBlockGenerator; 35 36 /** 37 * Extension manager instance. 38 */ 39 protected ExtensionManager $extensions; 40 41 /** 42 * Create a new ClassGenerator. 43 */ 44 public function __construct( 45 ?NamingConverter $naming = null, 46 ?TypeMapper $typeMapper = null, 47 ?StubRenderer $renderer = null, 48 ?MethodGenerator $methodGenerator = null, 49 ?DocBlockGenerator $docBlockGenerator = null, 50 ?ExtensionManager $extensions = null 51 ) { 52 $this->naming = $naming ?? new NamingConverter(); 53 $this->typeMapper = $typeMapper ?? new TypeMapper($this->naming); 54 $this->renderer = $renderer ?? new StubRenderer(); 55 $this->methodGenerator = $methodGenerator ?? new MethodGenerator($this->naming, $this->typeMapper, $this->renderer); 56 $this->docBlockGenerator = $docBlockGenerator ?? new DocBlockGenerator($this->typeMapper); 57 $this->extensions = $extensions ?? new ExtensionManager(); 58 } 59 60 /** 61 * Generate a complete PHP class from a lexicon document. 62 */ 63 public function generate(LexiconDocument $document): string 64 { 65 $nsid = $document->getNsid(); 66 $mainDef = $document->getMainDefinition(); 67 68 if ($mainDef === null) { 69 throw GenerationException::withContext('No main definition found', ['nsid' => $nsid]); 70 } 71 72 $type = $mainDef['type'] ?? null; 73 74 if (! in_array($type, ['record', 'object'])) { 75 throw GenerationException::withContext( 76 'Can only generate classes for record and object types', 77 ['nsid' => $nsid, 'type' => $type] 78 ); 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); 87 88 // Get class components 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, $className), $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); 96 97 // Render the class 98 $rendered = $this->renderer->render('class', [ 99 'namespace' => $namespace, 100 'imports' => $this->formatUseStatements($useStatements), 101 'docBlock' => $docBlock, 102 'className' => $className, 103 'extends' => ' extends Data', 104 'implements' => '', 105 'properties' => $properties, 106 'constructor' => $constructor, 107 'methods' => $methods, 108 ]); 109 110 // Fix blank lines when there's no constructor or properties 111 if (empty($properties) && empty($constructor)) { 112 // Remove double blank lines after class opening brace 113 $rendered = preg_replace('/\{\n\n\n/', "{\n", $rendered); 114 } 115 116 // Execute post-generation hooks 117 $this->extensions->execute('action:class:generated', $rendered, $document); 118 119 return $rendered; 120 } 121 122 /** 123 * Generate class properties. 124 * 125 * Since we use constructor property promotion, we don't need separate property declarations. 126 * This method returns empty string but is kept for compatibility. 127 * 128 * @param array<string, mixed> $definition 129 */ 130 protected function generateProperties(array $definition): string 131 { 132 // Properties are defined via constructor promotion 133 return ''; 134 } 135 136 /** 137 * Generate class constructor. 138 * 139 * @param array<string, mixed> $definition 140 */ 141 protected function generateConstructor(array $definition): string 142 { 143 $properties = $definition['properties'] ?? []; 144 $required = $definition['required'] ?? []; 145 146 if (empty($properties)) { 147 return ''; 148 } 149 150 // Build constructor parameters - required first, then optional 151 $requiredParams = []; 152 $optionalParams = []; 153 $requiredDocParams = []; 154 $optionalDocParams = []; 155 156 foreach ($properties as $name => $propDef) { 157 $isRequired = in_array($name, $required); 158 $phpType = $this->typeMapper->toPhpType($propDef, ! $isRequired); 159 $phpDocType = $this->typeMapper->toPhpDocType($propDef, ! $isRequired); 160 $description = $propDef['description'] ?? ''; 161 $param = ' public readonly '.$phpType.' $'.$name; 162 163 if ($isRequired) { 164 $requiredParams[] = $param.','; 165 if ($description) { 166 $requiredDocParams[] = ' * @param '.$phpDocType.' $'.$name.' '.$description; 167 } 168 } else { 169 $optionalParams[] = $param.' = null,'; 170 if ($description) { 171 $optionalDocParams[] = ' * @param '.$phpDocType.' $'.$name.' '.$description; 172 } 173 } 174 } 175 176 // Combine required and optional parameters 177 $params = array_merge($requiredParams, $optionalParams); 178 179 // Remove trailing comma from last parameter 180 if (! empty($params)) { 181 $params[count($params) - 1] = rtrim($params[count($params) - 1], ','); 182 } 183 184 // Build constructor DocBlock with parameter descriptions in the correct order 185 $docParams = array_merge($requiredDocParams, $optionalDocParams); 186 187 // Only add docblock if there are parameter descriptions 188 if (! empty($docParams)) { 189 $docLines = [' /**']; 190 $docLines = array_merge($docLines, $docParams); 191 $docLines[] = ' */'; 192 $docBlock = implode("\n", $docLines)."\n"; 193 } else { 194 $docBlock = ''; 195 } 196 197 return $docBlock." public function __construct(\n".implode("\n", $params)."\n ) {\n }"; 198 } 199 200 /** 201 * Generate class methods. 202 */ 203 protected function generateMethods(LexiconDocument $document): string 204 { 205 $methods = $this->methodGenerator->generateAll($document); 206 207 return implode("\n\n", $methods); 208 } 209 210 /** 211 * Generate class-level documentation block. 212 * 213 * @param array<string, mixed> $definition 214 */ 215 protected function generateClassDocBlock(LexiconDocument $document, array $definition): string 216 { 217 return $this->docBlockGenerator->generateClassDocBlock($document, $definition); 218 } 219 220 /** 221 * Collect all use statements needed for the class. 222 * 223 * @param array<string, mixed> $definition 224 * @return array<string> 225 */ 226 protected function collectUseStatements(array $definition, string $currentNamespace = '', string $currentClassName = ''): array 227 { 228 $uses = ['SocialDept\\AtpSchema\\Data\\Data']; 229 $properties = $definition['properties'] ?? []; 230 $hasUnions = false; 231 $localRefs = []; 232 233 foreach ($properties as $propDef) { 234 $propUses = $this->typeMapper->getUseStatements($propDef); 235 $uses = array_merge($uses, $propUses); 236 237 // Check if this property uses unions 238 if (isset($propDef['type']) && $propDef['type'] === 'union') { 239 $hasUnions = true; 240 } 241 242 // Collect local references for import 243 if (isset($propDef['type']) && $propDef['type'] === 'ref' && isset($propDef['ref'])) { 244 $ref = $propDef['ref']; 245 if (str_starts_with($ref, '#')) { 246 $localRefs[] = ltrim($ref, '#'); 247 } 248 } 249 250 // Handle array items 251 if (isset($propDef['items'])) { 252 $itemUses = $this->typeMapper->getUseStatements($propDef['items']); 253 $uses = array_merge($uses, $itemUses); 254 255 // Check for local refs in array items 256 if (isset($propDef['items']['type']) && $propDef['items']['type'] === 'ref' && isset($propDef['items']['ref'])) { 257 $ref = $propDef['items']['ref']; 258 if (str_starts_with($ref, '#')) { 259 $localRefs[] = ltrim($ref, '#'); 260 } 261 } 262 } 263 } 264 265 // Add local ref imports 266 // For local refs, check if they should be nested or siblings 267 if (! empty($localRefs) && $currentNamespace) { 268 foreach ($localRefs as $localRef) { 269 $refClassName = $this->naming->toClassName($localRef); 270 271 // If this is a nested definition (has currentClassName) and it's a record type, 272 // then local refs are nested under the record 273 if ($currentClassName && $definition['type'] === 'record') { 274 $uses[] = $currentNamespace . '\\' . $currentClassName . '\\' . $refClassName; 275 } else { 276 // For object definitions or defs lexicons, local refs are siblings 277 $uses[] = $currentNamespace . '\\' . $refClassName; 278 } 279 } 280 } 281 282 // Add UnionHelper if unions are used 283 if ($hasUnions) { 284 $uses[] = 'SocialDept\\AtpSchema\\Support\\UnionHelper'; 285 } 286 287 // Remove duplicates and sort 288 $uses = array_unique($uses); 289 290 // Filter out classes from the same namespace 291 if ($currentNamespace) { 292 $uses = array_filter($uses, function ($use) use ($currentNamespace) { 293 // Get namespace from FQCN by removing class name 294 $parts = explode('\\', ltrim($use, '\\')); 295 array_pop($parts); // Remove class name 296 $useNamespace = implode('\\', $parts); 297 298 return $useNamespace !== $currentNamespace; 299 }); 300 } 301 302 sort($uses); 303 304 return $uses; 305 } 306 307 /** 308 * Format use statements for output. 309 * 310 * @param array<string> $uses 311 */ 312 protected function formatUseStatements(array $uses): string 313 { 314 if (empty($uses)) { 315 return ''; 316 } 317 318 $lines = []; 319 foreach ($uses as $use) { 320 $lines[] = 'use '.ltrim($use, '\\').';'; 321 } 322 323 return implode("\n", $lines); 324 } 325 326 /** 327 * Build a map of local definitions for type resolution. 328 * 329 * Maps local references (#defName) to their generated class names. 330 * 331 * @return array<string, string> Map of local ref => class name 332 */ 333 protected function buildLocalDefinitionMap(LexiconDocument $document): array 334 { 335 $localDefs = []; 336 $allDefs = $document->defs ?? []; 337 338 foreach ($allDefs as $defName => $definition) { 339 // Skip the main definition 340 if ($defName === 'main') { 341 continue; 342 } 343 344 // Convert definition name to class name 345 $className = $this->naming->toClassName($defName); 346 $localDefs["#{$defName}"] = $className; 347 } 348 349 return $localDefs; 350 } 351 352 /** 353 * Get the naming converter. 354 */ 355 public function getNaming(): NamingConverter 356 { 357 return $this->naming; 358 } 359 360 /** 361 * Get the type mapper. 362 */ 363 public function getTypeMapper(): TypeMapper 364 { 365 return $this->typeMapper; 366 } 367 368 /** 369 * Get the stub renderer. 370 */ 371 public function getRenderer(): StubRenderer 372 { 373 return $this->renderer; 374 } 375 376 /** 377 * Get the method generator. 378 */ 379 public function getMethodGenerator(): MethodGenerator 380 { 381 return $this->methodGenerator; 382 } 383 384 /** 385 * Get the docblock generator. 386 */ 387 public function getDocBlockGenerator(): DocBlockGenerator 388 { 389 return $this->docBlockGenerator; 390 } 391 392 /** 393 * Get the extension manager. 394 */ 395 public function getExtensions(): ExtensionManager 396 { 397 return $this->extensions; 398 } 399}