Parse and validate AT Protocol Lexicons with DTO generation for Laravel

Add class and namespace generator

Changed files
+639
src
Generator
tests
Unit
+319
src/Generator/ClassGenerator.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Generator; 4 + 5 + use SocialDept\Schema\Data\LexiconDocument; 6 + use SocialDept\Schema\Exceptions\GenerationException; 7 + 8 + class ClassGenerator 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 + * Create a new ClassGenerator. 27 + */ 28 + public function __construct( 29 + ?NamingConverter $naming = null, 30 + ?TypeMapper $typeMapper = null, 31 + ?StubRenderer $renderer = null 32 + ) { 33 + $this->naming = $naming ?? new NamingConverter; 34 + $this->typeMapper = $typeMapper ?? new TypeMapper($this->naming); 35 + $this->renderer = $renderer ?? new StubRenderer; 36 + } 37 + 38 + /** 39 + * Generate a complete PHP class from a lexicon document. 40 + */ 41 + public function generate(LexiconDocument $document): string 42 + { 43 + $nsid = $document->getNsid(); 44 + $mainDef = $document->getMainDefinition(); 45 + 46 + if ($mainDef === null) { 47 + throw GenerationException::withContext('No main definition found', ['nsid' => $nsid]); 48 + } 49 + 50 + $type = $mainDef['type'] ?? null; 51 + 52 + if (! in_array($type, ['record', 'object'])) { 53 + throw GenerationException::withContext( 54 + 'Can only generate classes for record and object types', 55 + ['nsid' => $nsid, 'type' => $type] 56 + ); 57 + } 58 + 59 + // Get class components 60 + $namespace = $this->naming->nsidToNamespace($nsid); 61 + $className = $this->naming->toClassName($document->id->getName()); 62 + $useStatements = $this->collectUseStatements($mainDef); 63 + $properties = $this->generateProperties($mainDef); 64 + $constructor = $this->generateConstructor($mainDef); 65 + $methods = $this->generateMethods($document); 66 + $docBlock = $this->generateClassDocBlock($document, $mainDef); 67 + 68 + // Render the class 69 + return $this->renderer->render('class', [ 70 + 'namespace' => $namespace, 71 + 'imports' => $this->formatUseStatements($useStatements), 72 + 'docBlock' => $docBlock, 73 + 'className' => $className, 74 + 'extends' => ' extends \\SocialDept\\Schema\\Data\\Data', 75 + 'implements' => '', 76 + 'properties' => $properties, 77 + 'constructor' => $constructor, 78 + 'methods' => $methods, 79 + ]); 80 + } 81 + 82 + /** 83 + * Generate class properties. 84 + * 85 + * @param array<string, mixed> $definition 86 + */ 87 + protected function generateProperties(array $definition): string 88 + { 89 + $properties = $definition['properties'] ?? []; 90 + $required = $definition['required'] ?? []; 91 + 92 + if (empty($properties)) { 93 + return ''; 94 + } 95 + 96 + $lines = []; 97 + 98 + foreach ($properties as $name => $propDef) { 99 + $isRequired = in_array($name, $required); 100 + $phpType = $this->typeMapper->toPhpType($propDef, ! $isRequired); 101 + $docType = $this->typeMapper->toPhpDocType($propDef, ! $isRequired); 102 + $description = $propDef['description'] ?? null; 103 + 104 + // Build property doc comment 105 + $docLines = [' /**']; 106 + if ($description) { 107 + $docLines[] = ' * '.$description; 108 + $docLines[] = ' *'; 109 + } 110 + $docLines[] = ' * @var '.$docType; 111 + $docLines[] = ' */'; 112 + 113 + $lines[] = implode("\n", $docLines); 114 + $lines[] = ' public readonly '.$phpType.' $'.$name.';'; 115 + $lines[] = ''; 116 + } 117 + 118 + return rtrim(implode("\n", $lines)); 119 + } 120 + 121 + /** 122 + * Generate class constructor. 123 + * 124 + * @param array<string, mixed> $definition 125 + */ 126 + protected function generateConstructor(array $definition): string 127 + { 128 + $properties = $definition['properties'] ?? []; 129 + $required = $definition['required'] ?? []; 130 + 131 + if (empty($properties)) { 132 + return ''; 133 + } 134 + 135 + $params = []; 136 + 137 + foreach ($properties as $name => $propDef) { 138 + $isRequired = in_array($name, $required); 139 + $phpType = $this->typeMapper->toPhpType($propDef, ! $isRequired); 140 + $default = ! $isRequired ? ' = null' : ''; 141 + 142 + $params[] = ' public readonly '.$phpType.' $'.$name.$default.','; 143 + } 144 + 145 + // Remove trailing comma from last parameter 146 + if (! empty($params)) { 147 + $params[count($params) - 1] = rtrim($params[count($params) - 1], ','); 148 + } 149 + 150 + return " public function __construct(\n".implode("\n", $params)."\n ) {\n }"; 151 + } 152 + 153 + /** 154 + * Generate class methods. 155 + */ 156 + protected function generateMethods(LexiconDocument $document): string 157 + { 158 + $methods = []; 159 + 160 + // Generate getLexicon method 161 + $methods[] = $this->generateGetLexiconMethod($document); 162 + 163 + // Generate fromArray method 164 + $methods[] = $this->generateFromArrayMethod($document); 165 + 166 + return implode("\n\n", $methods); 167 + } 168 + 169 + /** 170 + * Generate getLexicon method. 171 + */ 172 + protected function generateGetLexiconMethod(LexiconDocument $document): string 173 + { 174 + $nsid = $document->getNsid(); 175 + 176 + return " public static function getLexicon(): string\n". 177 + " {\n". 178 + " return '{$nsid}';\n". 179 + " }"; 180 + } 181 + 182 + /** 183 + * Generate fromArray method. 184 + */ 185 + protected function generateFromArrayMethod(LexiconDocument $document): string 186 + { 187 + $mainDef = $document->getMainDefinition(); 188 + $properties = $mainDef['properties'] ?? []; 189 + 190 + if (empty($properties)) { 191 + return " public static function fromArray(array \$data): static\n". 192 + " {\n". 193 + " return new static();\n". 194 + " }"; 195 + } 196 + 197 + $assignments = []; 198 + foreach ($properties as $name => $propDef) { 199 + $type = $propDef['type'] ?? 'unknown'; 200 + 201 + // Handle nested objects/refs 202 + if ($type === 'ref' && isset($propDef['ref'])) { 203 + $refClass = $this->naming->nsidToClassName($propDef['ref']); 204 + $refClassName = basename(str_replace('\\', '/', $refClass)); 205 + $assignments[] = " {$name}: isset(\$data['{$name}']) ? {$refClassName}::fromArray(\$data['{$name}']) : null,"; 206 + } elseif ($type === 'array' && isset($propDef['items']['type']) && $propDef['items']['type'] === 'ref') { 207 + $refClass = $this->naming->nsidToClassName($propDef['items']['ref']); 208 + $refClassName = basename(str_replace('\\', '/', $refClass)); 209 + $assignments[] = " {$name}: isset(\$data['{$name}']) ? array_map(fn (\$item) => {$refClassName}::fromArray(\$item), \$data['{$name}']) : [],"; 210 + } else { 211 + $assignments[] = " {$name}: \$data['{$name}'] ?? null,"; 212 + } 213 + } 214 + 215 + return " public static function fromArray(array \$data): static\n". 216 + " {\n". 217 + " return new static(\n". 218 + implode("\n", $assignments)."\n". 219 + " );\n". 220 + " }"; 221 + } 222 + 223 + /** 224 + * Generate class-level documentation block. 225 + * 226 + * @param array<string, mixed> $definition 227 + */ 228 + protected function generateClassDocBlock(LexiconDocument $document, array $definition): string 229 + { 230 + $lines = ['/**']; 231 + 232 + if ($document->description) { 233 + $lines[] = ' * '.$document->description; 234 + $lines[] = ' *'; 235 + } 236 + 237 + $lines[] = ' * Lexicon: '.$document->getNsid(); 238 + 239 + if (isset($definition['type'])) { 240 + $lines[] = ' * Type: '.$definition['type']; 241 + } 242 + 243 + $lines[] = ' */'; 244 + 245 + return implode("\n", $lines); 246 + } 247 + 248 + /** 249 + * Collect all use statements needed for the class. 250 + * 251 + * @param array<string, mixed> $definition 252 + * @return array<string> 253 + */ 254 + protected function collectUseStatements(array $definition): array 255 + { 256 + $uses = ['SocialDept\\Schema\\Data\\Data']; 257 + $properties = $definition['properties'] ?? []; 258 + 259 + foreach ($properties as $propDef) { 260 + $propUses = $this->typeMapper->getUseStatements($propDef); 261 + $uses = array_merge($uses, $propUses); 262 + 263 + // Handle array items 264 + if (isset($propDef['items'])) { 265 + $itemUses = $this->typeMapper->getUseStatements($propDef['items']); 266 + $uses = array_merge($uses, $itemUses); 267 + } 268 + } 269 + 270 + // Remove duplicates and sort 271 + $uses = array_unique($uses); 272 + sort($uses); 273 + 274 + return $uses; 275 + } 276 + 277 + /** 278 + * Format use statements for output. 279 + * 280 + * @param array<string> $uses 281 + */ 282 + protected function formatUseStatements(array $uses): string 283 + { 284 + if (empty($uses)) { 285 + return ''; 286 + } 287 + 288 + $lines = []; 289 + foreach ($uses as $use) { 290 + $lines[] = 'use '.ltrim($use, '\\').';'; 291 + } 292 + 293 + return implode("\n", $lines); 294 + } 295 + 296 + /** 297 + * Get the naming converter. 298 + */ 299 + public function getNaming(): NamingConverter 300 + { 301 + return $this->naming; 302 + } 303 + 304 + /** 305 + * Get the type mapper. 306 + */ 307 + public function getTypeMapper(): TypeMapper 308 + { 309 + return $this->typeMapper; 310 + } 311 + 312 + /** 313 + * Get the stub renderer. 314 + */ 315 + public function getRenderer(): StubRenderer 316 + { 317 + return $this->renderer; 318 + } 319 + }
+320
tests/Unit/Generator/ClassGeneratorTest.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Tests\Unit\Generator; 4 + 5 + use Orchestra\Testbench\TestCase; 6 + use SocialDept\Schema\Data\LexiconDocument; 7 + use SocialDept\Schema\Exceptions\GenerationException; 8 + use SocialDept\Schema\Generator\ClassGenerator; 9 + use SocialDept\Schema\Parser\Nsid; 10 + 11 + class ClassGeneratorTest extends TestCase 12 + { 13 + protected ClassGenerator $generator; 14 + 15 + protected function setUp(): void 16 + { 17 + parent::setUp(); 18 + 19 + $this->generator = new ClassGenerator; 20 + } 21 + 22 + public function test_it_generates_simple_record_class(): void 23 + { 24 + $document = $this->createDocument('app.test.post', [ 25 + 'type' => 'record', 26 + 'properties' => [ 27 + 'text' => ['type' => 'string'], 28 + 'createdAt' => ['type' => 'string'], 29 + ], 30 + 'required' => ['text', 'createdAt'], 31 + ]); 32 + 33 + $code = $this->generator->generate($document); 34 + 35 + $this->assertStringContainsString('namespace App\\Lexicon\\Test\\App;', $code); 36 + $this->assertStringContainsString('class Post extends \\SocialDept\\Schema\\Data\\Data', $code); 37 + $this->assertStringContainsString('public readonly string $text;', $code); 38 + $this->assertStringContainsString('public readonly string $createdAt;', $code); 39 + $this->assertStringContainsString('public static function getLexicon(): string', $code); 40 + $this->assertStringContainsString("return 'app.test.post';", $code); 41 + } 42 + 43 + public function test_it_handles_optional_properties(): void 44 + { 45 + $document = $this->createDocument('app.test.post', [ 46 + 'type' => 'record', 47 + 'properties' => [ 48 + 'title' => ['type' => 'string'], 49 + 'subtitle' => ['type' => 'string'], 50 + ], 51 + 'required' => ['title'], 52 + ]); 53 + 54 + $code = $this->generator->generate($document); 55 + 56 + $this->assertStringContainsString('public readonly string $title', $code); 57 + $this->assertStringContainsString('public readonly ?string $subtitle', $code); 58 + } 59 + 60 + public function test_it_generates_constructor_with_parameters(): void 61 + { 62 + $document = $this->createDocument('app.test.user', [ 63 + 'type' => 'record', 64 + 'properties' => [ 65 + 'name' => ['type' => 'string'], 66 + 'age' => ['type' => 'integer'], 67 + ], 68 + 'required' => ['name'], 69 + ]); 70 + 71 + $code = $this->generator->generate($document); 72 + 73 + $this->assertStringContainsString('public function __construct(', $code); 74 + $this->assertStringContainsString('public readonly string $name', $code); 75 + $this->assertStringContainsString('public readonly ?int $age = null', $code); 76 + } 77 + 78 + public function test_it_generates_from_array_method(): void 79 + { 80 + $document = $this->createDocument('app.test.post', [ 81 + 'type' => 'record', 82 + 'properties' => [ 83 + 'text' => ['type' => 'string'], 84 + ], 85 + 'required' => ['text'], 86 + ]); 87 + 88 + $code = $this->generator->generate($document); 89 + 90 + $this->assertStringContainsString('public static function fromArray(array $data): static', $code); 91 + $this->assertStringContainsString('return new static(', $code); 92 + $this->assertStringContainsString("text: \$data['text'] ?? null", $code); 93 + } 94 + 95 + public function test_it_includes_use_statements(): void 96 + { 97 + $document = $this->createDocument('app.test.post', [ 98 + 'type' => 'record', 99 + 'properties' => [ 100 + 'text' => ['type' => 'string'], 101 + ], 102 + 'required' => ['text'], 103 + ]); 104 + 105 + $code = $this->generator->generate($document); 106 + 107 + $this->assertStringContainsString('use SocialDept\\Schema\\Data\\Data;', $code); 108 + } 109 + 110 + public function test_it_includes_ref_use_statements(): void 111 + { 112 + $document = $this->createDocument('app.test.post', [ 113 + 'type' => 'record', 114 + 'properties' => [ 115 + 'author' => [ 116 + 'type' => 'ref', 117 + 'ref' => 'app.test.author', 118 + ], 119 + ], 120 + 'required' => ['author'], 121 + ]); 122 + 123 + $code = $this->generator->generate($document); 124 + 125 + $this->assertStringContainsString('use App\\Lexicon\\Test\\App\\Author;', $code); 126 + } 127 + 128 + public function test_it_includes_blob_use_statements(): void 129 + { 130 + $document = $this->createDocument('app.test.post', [ 131 + 'type' => 'record', 132 + 'properties' => [ 133 + 'image' => ['type' => 'blob'], 134 + ], 135 + 'required' => [], 136 + ]); 137 + 138 + $code = $this->generator->generate($document); 139 + 140 + $this->assertStringContainsString('use SocialDept\\Schema\\Data\\BlobReference;', $code); 141 + } 142 + 143 + public function test_it_generates_class_docblock(): void 144 + { 145 + $document = $this->createDocument('app.test.post', [ 146 + 'type' => 'record', 147 + 'description' => 'A social media post', 148 + 'properties' => ['text' => ['type' => 'string']], 149 + 'required' => ['text'], 150 + ], 'A social media post'); 151 + 152 + $code = $this->generator->generate($document); 153 + 154 + $this->assertStringContainsString('/**', $code); 155 + $this->assertStringContainsString('* A social media post', $code); 156 + $this->assertStringContainsString('* Lexicon: app.test.post', $code); 157 + $this->assertStringContainsString('* Type: record', $code); 158 + } 159 + 160 + public function test_it_generates_property_docblocks(): void 161 + { 162 + $document = $this->createDocument('app.test.post', [ 163 + 'type' => 'record', 164 + 'properties' => [ 165 + 'text' => [ 166 + 'type' => 'string', 167 + 'description' => 'The post content', 168 + ], 169 + ], 170 + 'required' => ['text'], 171 + ]); 172 + 173 + $code = $this->generator->generate($document); 174 + 175 + $this->assertStringContainsString('* The post content', $code); 176 + $this->assertStringContainsString('* @var string', $code); 177 + } 178 + 179 + public function test_it_throws_when_no_main_definition(): void 180 + { 181 + $document = new LexiconDocument( 182 + lexicon: 1, 183 + id: Nsid::parse('app.test.empty'), 184 + defs: [], 185 + description: null, 186 + source: null, 187 + raw: [] 188 + ); 189 + 190 + $this->expectException(GenerationException::class); 191 + $this->expectExceptionMessage('No main definition found'); 192 + 193 + $this->generator->generate($document); 194 + } 195 + 196 + public function test_it_throws_for_non_record_types(): void 197 + { 198 + $document = $this->createDocument('app.test.query', [ 199 + 'type' => 'query', 200 + ]); 201 + 202 + $this->expectException(GenerationException::class); 203 + $this->expectExceptionMessage('Can only generate classes for record and object types'); 204 + 205 + $this->generator->generate($document); 206 + } 207 + 208 + public function test_it_handles_empty_properties(): void 209 + { 210 + $document = $this->createDocument('app.test.empty', [ 211 + 'type' => 'record', 212 + 'properties' => [], 213 + 'required' => [], 214 + ]); 215 + 216 + $code = $this->generator->generate($document); 217 + 218 + $this->assertStringContainsString('class Empty', $code); 219 + $this->assertStringContainsString('public static function fromArray(array $data): static', $code); 220 + $this->assertStringContainsString('return new static();', $code); 221 + } 222 + 223 + public function test_it_handles_array_of_refs(): void 224 + { 225 + $document = $this->createDocument('app.test.feed', [ 226 + 'type' => 'record', 227 + 'properties' => [ 228 + 'posts' => [ 229 + 'type' => 'array', 230 + 'items' => [ 231 + 'type' => 'ref', 232 + 'ref' => 'app.test.post', 233 + ], 234 + ], 235 + ], 236 + 'required' => ['posts'], 237 + ]); 238 + 239 + $code = $this->generator->generate($document); 240 + 241 + $this->assertStringContainsString('use App\\Lexicon\\Test\\App\\Post;', $code); 242 + $this->assertStringContainsString('public readonly array $posts', $code); 243 + $this->assertStringContainsString('array_map(fn ($item) => Post::fromArray($item)', $code); 244 + } 245 + 246 + public function test_it_generates_object_type(): void 247 + { 248 + $document = $this->createDocument('app.test.config', [ 249 + 'type' => 'object', 250 + 'properties' => [ 251 + 'enabled' => ['type' => 'boolean'], 252 + ], 253 + 'required' => ['enabled'], 254 + ]); 255 + 256 + $code = $this->generator->generate($document); 257 + 258 + $this->assertStringContainsString('class Config', $code); 259 + $this->assertStringContainsString('public readonly bool $enabled', $code); 260 + } 261 + 262 + public function test_it_sorts_use_statements(): void 263 + { 264 + $document = $this->createDocument('app.test.complex', [ 265 + 'type' => 'record', 266 + 'properties' => [ 267 + 'image' => ['type' => 'blob'], 268 + 'author' => [ 269 + 'type' => 'ref', 270 + 'ref' => 'app.test.author', 271 + ], 272 + ], 273 + 'required' => [], 274 + ]); 275 + 276 + $code = $this->generator->generate($document); 277 + 278 + // Use statements should be sorted 279 + $dataPos = strpos($code, 'use App\\Lexicon\\Test\\App\\Author;'); 280 + $blobPos = strpos($code, 'use SocialDept\\Schema\\Data\\BlobReference;'); 281 + $basePos = strpos($code, 'use SocialDept\\Schema\\Data\\Data;'); 282 + 283 + $this->assertNotFalse($dataPos); 284 + $this->assertNotFalse($blobPos); 285 + $this->assertNotFalse($basePos); 286 + $this->assertLessThan($blobPos, $dataPos); // App before SocialDept 287 + } 288 + 289 + public function test_it_provides_accessor_methods(): void 290 + { 291 + $naming = $this->generator->getNaming(); 292 + $typeMapper = $this->generator->getTypeMapper(); 293 + $renderer = $this->generator->getRenderer(); 294 + 295 + $this->assertInstanceOf(\SocialDept\Schema\Generator\NamingConverter::class, $naming); 296 + $this->assertInstanceOf(\SocialDept\Schema\Generator\TypeMapper::class, $typeMapper); 297 + $this->assertInstanceOf(\SocialDept\Schema\Generator\StubRenderer::class, $renderer); 298 + } 299 + 300 + /** 301 + * Helper to create a test document. 302 + * 303 + * @param array<string, mixed> $mainDef 304 + */ 305 + protected function createDocument(string $nsid, array $mainDef, ?string $description = null): LexiconDocument 306 + { 307 + return new LexiconDocument( 308 + lexicon: 1, 309 + id: Nsid::parse($nsid), 310 + defs: ['main' => $mainDef], 311 + description: $description, 312 + source: null, 313 + raw: [ 314 + 'lexicon' => 1, 315 + 'id' => $nsid, 316 + 'defs' => ['main' => $mainDef], 317 + ] 318 + ); 319 + } 320 + }