Parse and validate AT Protocol Lexicons with DTO generation for Laravel
at dev 11 kB view raw
1<?php 2 3namespace SocialDept\AtpSchema\Generator; 4 5use SocialDept\AtpSchema\Contracts\DataGenerator; 6use SocialDept\AtpSchema\Data\LexiconDocument; 7use SocialDept\AtpSchema\Parser\SchemaLoader; 8use SocialDept\AtpSchema\Parser\TypeParser; 9 10class DTOGenerator implements DataGenerator 11{ 12 /** 13 * Schema loader for loading lexicon documents. 14 */ 15 protected SchemaLoader $schemaLoader; 16 17 /** 18 * Type parser for parsing type definitions. 19 */ 20 protected TypeParser $typeParser; 21 22 /** 23 * Namespace resolver for converting NSIDs to PHP namespaces. 24 */ 25 protected NamespaceResolver $namespaceResolver; 26 27 /** 28 * Template renderer for generating PHP code. 29 */ 30 protected TemplateRenderer $templateRenderer; 31 32 /** 33 * File writer for writing generated files. 34 */ 35 protected FileWriter $fileWriter; 36 37 /** 38 * Class generator for generating PHP classes. 39 */ 40 protected ClassGenerator $classGenerator; 41 42 /** 43 * Base namespace for generated classes. 44 */ 45 protected string $baseNamespace; 46 47 /** 48 * Output directory for generated files. 49 */ 50 protected string $outputDirectory; 51 52 /** 53 * Create a new DTOGenerator. 54 */ 55 public function __construct( 56 SchemaLoader $schemaLoader, 57 string $baseNamespace = 'App\\Lexicons', 58 string $outputDirectory = 'app/Lexicons', 59 ?TypeParser $typeParser = null, 60 ?NamespaceResolver $namespaceResolver = null, 61 ?TemplateRenderer $templateRenderer = null, 62 ?FileWriter $fileWriter = null, 63 ?ClassGenerator $classGenerator = null 64 ) { 65 $this->schemaLoader = $schemaLoader; 66 $this->baseNamespace = rtrim($baseNamespace, '\\'); 67 $this->outputDirectory = rtrim($outputDirectory, '/'); 68 $this->typeParser = $typeParser ?? new TypeParser(schemaLoader: $schemaLoader); 69 $this->namespaceResolver = $namespaceResolver ?? new NamespaceResolver($baseNamespace); 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); 76 } 77 78 /** 79 * Generate PHP class files from Lexicon definition. 80 */ 81 public function generate(LexiconDocument $schema): string 82 { 83 return $this->classGenerator->generate($schema); 84 } 85 86 /** 87 * Generate and write class file to disk. 88 */ 89 public function generateAndSave(LexiconDocument $schema, string $outputPath): string 90 { 91 $code = $this->generate($schema); 92 $this->fileWriter->write($outputPath, $code); 93 94 return $outputPath; 95 } 96 97 /** 98 * Generate class content without writing to disk. 99 */ 100 public function preview(LexiconDocument $schema): string 101 { 102 return $this->generate($schema); 103 } 104 105 /** 106 * Set the base namespace for generated classes. 107 */ 108 public function setBaseNamespace(string $namespace): void 109 { 110 $this->baseNamespace = rtrim($namespace, '\\'); 111 $this->namespaceResolver = new NamespaceResolver($this->baseNamespace); 112 } 113 114 /** 115 * Set the output path for generated classes. 116 */ 117 public function setOutputPath(string $path): void 118 { 119 $this->outputDirectory = rtrim($path, '/'); 120 } 121 122 /** 123 * Generate DTO classes from NSID. 124 */ 125 public function generateByNsid(string $nsid, array $options = []): array 126 { 127 $document = $this->schemaLoader->load($nsid); 128 129 return $this->generateFromDocument($document, $options); 130 } 131 132 /** 133 * Generate DTO classes from a lexicon document. 134 */ 135 public function generateFromDocument(LexiconDocument $document, array $options = []): array 136 { 137 $generatedFiles = []; 138 139 // Generate main class if it's a record or object 140 $mainDef = $document->getMainDefinition(); 141 $mainType = $mainDef['type'] ?? null; 142 143 if ($document->isRecord()) { 144 $file = $this->generateRecordClass($document, $options); 145 $generatedFiles[] = $file; 146 } elseif ($mainType === 'object') { 147 // Generate for standalone object types (like strongRef) 148 $file = $this->generateRecordClass($document, $options); 149 $generatedFiles[] = $file; 150 } 151 152 // Generate classes for other definitions 153 foreach ($document->getDefinitionNames() as $defName) { 154 if ($defName === 'main') { 155 continue; 156 } 157 158 $definition = $document->getDefinition($defName); 159 160 if (isset($definition['type']) && $definition['type'] === 'object') { 161 $file = $this->generateDefinitionClass($document, $defName, $options); 162 $generatedFiles[] = $file; 163 } 164 } 165 166 return $generatedFiles; 167 } 168 169 /** 170 * Generate code for a record (without writing to disk). 171 */ 172 protected function generateRecordCode(LexiconDocument $document): string 173 { 174 $namespace = $this->namespaceResolver->resolveNamespace($document->getNsid()); 175 $className = $this->namespaceResolver->resolveClassName($document->getNsid()); 176 177 $mainDef = $document->getMainDefinition(); 178 $recordSchema = $mainDef['record'] ?? []; 179 180 $properties = $this->extractProperties($recordSchema, $document); 181 182 return $this->templateRenderer->render('record', [ 183 'namespace' => $namespace, 184 'className' => $className, 185 'nsid' => $document->getNsid(), 186 'description' => $document->description, 187 'properties' => $properties, 188 ]); 189 } 190 191 /** 192 * Generate a record class from a lexicon document. 193 */ 194 protected function generateRecordClass(LexiconDocument $document, array $options = []): string 195 { 196 // Use ClassGenerator for proper code generation 197 $code = $this->classGenerator->generate($document); 198 199 $naming = $this->classGenerator->getNaming(); 200 $namespace = $naming->nsidToNamespace($document->getNsid()); 201 $className = $naming->toClassName($document->id->getName()); 202 $filePath = $this->getFilePath($namespace, $className); 203 204 if (! ($options['dryRun'] ?? false)) { 205 $this->fileWriter->write($filePath, $code); 206 } 207 208 return $filePath; 209 } 210 211 /** 212 * Generate a class for a specific definition. 213 */ 214 protected function generateDefinitionClass(LexiconDocument $document, string $defName, array $options = []): string 215 { 216 // Create a temporary document for this specific definition 217 $definition = $document->getDefinition($defName); 218 219 // Build a temporary lexicon document for the object definition 220 $objectNsid = $document->getNsid().'.'.$defName; 221 $tempSchema = [ 222 'id' => $objectNsid, 223 'lexicon' => 1, 224 'description' => $definition['description'] ?? null, 225 'defs' => [ 226 'main' => [ 227 'type' => 'object', 228 'properties' => $definition['properties'] ?? [], 229 'required' => $definition['required'] ?? [], 230 'description' => $definition['description'] ?? null, 231 ], 232 ], 233 ]; 234 235 $tempDocument = \SocialDept\AtpSchema\Data\LexiconDocument::fromArray($tempSchema); 236 237 // Use ClassGenerator for proper code generation 238 $code = $this->classGenerator->generate($tempDocument); 239 240 $naming = $this->classGenerator->getNaming(); 241 $namespace = $naming->nsidToNamespace($objectNsid); 242 $className = $naming->toClassName($defName); 243 $filePath = $this->getFilePath($namespace, $className); 244 245 if (! ($options['dryRun'] ?? false)) { 246 $this->fileWriter->write($filePath, $code); 247 } 248 249 return $filePath; 250 } 251 252 /** 253 * Extract properties from a schema definition. 254 * 255 * @return array<array{name: string, type: string, phpType: string, required: bool, description: ?string}> 256 */ 257 protected function extractProperties(array $schema, LexiconDocument $document): array 258 { 259 $properties = []; 260 $schemaProperties = $schema['properties'] ?? []; 261 $required = $schema['required'] ?? []; 262 263 foreach ($schemaProperties as $name => $propSchema) { 264 $properties[] = [ 265 'name' => $name, 266 'type' => $propSchema['type'] ?? 'unknown', 267 'phpType' => $this->mapToPhpType($propSchema), 268 'required' => in_array($name, $required), 269 'description' => $propSchema['description'] ?? null, 270 ]; 271 } 272 273 return $properties; 274 } 275 276 /** 277 * Map lexicon type to PHP type. 278 */ 279 protected function mapToPhpType(array $typeSchema): string 280 { 281 $type = $typeSchema['type'] ?? 'unknown'; 282 283 return match ($type) { 284 'null' => 'null', 285 'boolean' => 'bool', 286 'integer' => 'int', 287 'string' => 'string', 288 'bytes' => 'string', 289 'array' => 'array', 290 'object' => 'array', 291 'unknown' => 'mixed', 292 default => 'mixed', 293 }; 294 } 295 296 /** 297 * Get the file path for a generated class. 298 */ 299 protected function getFilePath(string $namespace, string $className): string 300 { 301 // Remove base namespace from full namespace 302 $relativePath = str_replace($this->baseNamespace.'\\', '', $namespace); 303 $relativePath = str_replace('\\', '/', $relativePath); 304 305 return $this->outputDirectory.'/'.$relativePath.'/'.$className.'.php'; 306 } 307 308 /** 309 * Validate generated code. 310 */ 311 public function validate(string $code): bool 312 { 313 // Basic syntax check using token_get_all 314 $tokens = @token_get_all($code); 315 316 return $tokens !== false; 317 } 318 319 /** 320 * Get generated file metadata. 321 */ 322 public function getMetadata(string $nsid): array 323 { 324 $document = $this->schemaLoader->load($nsid); 325 326 $namespace = $this->namespaceResolver->resolveNamespace($document->getNsid()); 327 $className = $this->namespaceResolver->resolveClassName($document->getNsid()); 328 329 return [ 330 'nsid' => $nsid, 331 'namespace' => $namespace, 332 'className' => $className, 333 'fullyQualifiedName' => $namespace.'\\'.$className, 334 'type' => $document->isRecord() ? 'record' : 'object', 335 ]; 336 } 337 338 /** 339 * Set output options. 340 */ 341 public function setOptions(array $options): void 342 { 343 if (isset($options['baseNamespace'])) { 344 $this->baseNamespace = rtrim($options['baseNamespace'], '\\'); 345 $this->namespaceResolver = new NamespaceResolver($this->baseNamespace); 346 } 347 348 if (isset($options['outputDirectory'])) { 349 $this->outputDirectory = rtrim($options['outputDirectory'], '/'); 350 } 351 } 352}