Parse and validate AT Protocol Lexicons with DTO generation for Laravel

Implement PHPDoc generation

+648 -17
+17 -17
src/Generator/ClassGenerator.php
··· 28 28 protected MethodGenerator $methodGenerator; 29 29 30 30 /** 31 + * DocBlock generator instance. 32 + */ 33 + protected DocBlockGenerator $docBlockGenerator; 34 + 35 + /** 31 36 * Create a new ClassGenerator. 32 37 */ 33 38 public function __construct( 34 39 ?NamingConverter $naming = null, 35 40 ?TypeMapper $typeMapper = null, 36 41 ?StubRenderer $renderer = null, 37 - ?MethodGenerator $methodGenerator = null 42 + ?MethodGenerator $methodGenerator = null, 43 + ?DocBlockGenerator $docBlockGenerator = null 38 44 ) { 39 45 $this->naming = $naming ?? new NamingConverter; 40 46 $this->typeMapper = $typeMapper ?? new TypeMapper($this->naming); 41 47 $this->renderer = $renderer ?? new StubRenderer; 42 48 $this->methodGenerator = $methodGenerator ?? new MethodGenerator($this->naming, $this->typeMapper, $this->renderer); 49 + $this->docBlockGenerator = $docBlockGenerator ?? new DocBlockGenerator($this->typeMapper); 43 50 } 44 51 45 52 /** ··· 174 181 */ 175 182 protected function generateClassDocBlock(LexiconDocument $document, array $definition): string 176 183 { 177 - $lines = ['/**']; 178 - 179 - if ($document->description) { 180 - $lines[] = ' * '.$document->description; 181 - $lines[] = ' *'; 182 - } 183 - 184 - $lines[] = ' * Lexicon: '.$document->getNsid(); 185 - 186 - if (isset($definition['type'])) { 187 - $lines[] = ' * Type: '.$definition['type']; 188 - } 189 - 190 - $lines[] = ' */'; 191 - 192 - return implode("\n", $lines); 184 + return $this->docBlockGenerator->generateClassDocBlock($document, $definition); 193 185 } 194 186 195 187 /** ··· 270 262 public function getMethodGenerator(): MethodGenerator 271 263 { 272 264 return $this->methodGenerator; 265 + } 266 + 267 + /** 268 + * Get the docblock generator. 269 + */ 270 + public function getDocBlockGenerator(): DocBlockGenerator 271 + { 272 + return $this->docBlockGenerator; 273 273 } 274 274 }
+303
src/Generator/DocBlockGenerator.php
··· 1 + <?php 2 + 3 + namespace SocialDept\Schema\Generator; 4 + 5 + use SocialDept\Schema\Data\LexiconDocument; 6 + 7 + class DocBlockGenerator 8 + { 9 + /** 10 + * Type mapper instance. 11 + */ 12 + protected TypeMapper $typeMapper; 13 + 14 + /** 15 + * Create a new DocBlockGenerator. 16 + */ 17 + public function __construct(?TypeMapper $typeMapper = null) 18 + { 19 + $this->typeMapper = $typeMapper ?? new TypeMapper; 20 + } 21 + 22 + /** 23 + * Generate a class-level docblock with rich annotations. 24 + * 25 + * @param array<string, mixed> $definition 26 + */ 27 + public function generateClassDocBlock( 28 + LexiconDocument $document, 29 + array $definition 30 + ): string { 31 + $lines = ['/**']; 32 + 33 + // Add description 34 + if ($document->description) { 35 + $lines = array_merge($lines, $this->wrapDescription($document->description)); 36 + $lines[] = ' *'; 37 + } 38 + 39 + // Add lexicon metadata 40 + $lines[] = ' * Lexicon: '.$document->getNsid(); 41 + 42 + if (isset($definition['type'])) { 43 + $lines[] = ' * Type: '.$definition['type']; 44 + } 45 + 46 + // Add @property tags for magic access 47 + $properties = $definition['properties'] ?? []; 48 + $required = $definition['required'] ?? []; 49 + 50 + if (! empty($properties)) { 51 + $lines[] = ' *'; 52 + foreach ($properties as $name => $propDef) { 53 + $isRequired = in_array($name, $required); 54 + $docType = $this->typeMapper->toPhpDocType($propDef, ! $isRequired); 55 + $desc = $propDef['description'] ?? ''; 56 + 57 + if ($desc) { 58 + $lines[] = ' * @property '.$docType.' $'.$name.' '.$desc; 59 + } else { 60 + $lines[] = ' * @property '.$docType.' $'.$name; 61 + } 62 + } 63 + } 64 + 65 + // Add validation constraints as annotations 66 + if (! empty($properties)) { 67 + $constraints = $this->extractConstraints($properties, $required); 68 + if (! empty($constraints)) { 69 + $lines[] = ' *'; 70 + $lines[] = ' * Constraints:'; 71 + foreach ($constraints as $constraint) { 72 + $lines[] = ' * - '.$constraint; 73 + } 74 + } 75 + } 76 + 77 + $lines[] = ' */'; 78 + 79 + return implode("\n", $lines); 80 + } 81 + 82 + /** 83 + * Generate a property-level docblock. 84 + * 85 + * @param array<string, mixed> $definition 86 + */ 87 + public function generatePropertyDocBlock( 88 + string $name, 89 + array $definition, 90 + bool $isRequired 91 + ): string { 92 + $lines = [' /**']; 93 + 94 + // Add description 95 + if (isset($definition['description'])) { 96 + $lines = array_merge($lines, $this->wrapDescription($definition['description'], ' * ')); 97 + $lines[] = ' *'; 98 + } 99 + 100 + // Add type annotation 101 + $docType = $this->typeMapper->toPhpDocType($definition, ! $isRequired); 102 + $lines[] = ' * @var '.$docType; 103 + 104 + // Add validation constraints 105 + $constraints = $this->extractPropertyConstraints($definition); 106 + if (! empty($constraints)) { 107 + $lines[] = ' *'; 108 + foreach ($constraints as $constraint) { 109 + $lines[] = ' * '.$constraint; 110 + } 111 + } 112 + 113 + $lines[] = ' */'; 114 + 115 + return implode("\n", $lines); 116 + } 117 + 118 + /** 119 + * Generate a method-level docblock. 120 + * 121 + * @param array<array{name: string, type: string, description?: string}> $params 122 + */ 123 + public function generateMethodDocBlock( 124 + ?string $description, 125 + ?string $returnType, 126 + array $params = [], 127 + ?string $throws = null 128 + ): string { 129 + $lines = [' /**']; 130 + 131 + // Add description 132 + if ($description) { 133 + $lines = array_merge($lines, $this->wrapDescription($description, ' * ')); 134 + } 135 + 136 + // Add blank line if we have params or return 137 + if ((! empty($params) || $returnType) && $description) { 138 + $lines[] = ' *'; 139 + } 140 + 141 + // Add parameters 142 + foreach ($params as $param) { 143 + $desc = $param['description'] ?? ''; 144 + if ($desc) { 145 + $lines[] = ' * @param '.$param['type'].' $'.$param['name'].' '.$desc; 146 + } else { 147 + $lines[] = ' * @param '.$param['type'].' $'.$param['name']; 148 + } 149 + } 150 + 151 + // Add return type 152 + if ($returnType && $returnType !== 'void') { 153 + $lines[] = ' * @return '.$returnType; 154 + } 155 + 156 + // Add throws 157 + if ($throws) { 158 + $lines[] = ' * @throws '.$throws; 159 + } 160 + 161 + $lines[] = ' */'; 162 + 163 + return implode("\n", $lines); 164 + } 165 + 166 + /** 167 + * Wrap a long description into multiple lines. 168 + * 169 + * @return array<string> 170 + */ 171 + protected function wrapDescription(string $description, string $prefix = ' * '): array 172 + { 173 + $maxWidth = 80 - strlen($prefix); 174 + $words = explode(' ', $description); 175 + $lines = []; 176 + $currentLine = ''; 177 + 178 + foreach ($words as $word) { 179 + if (empty($currentLine)) { 180 + $currentLine = $word; 181 + } elseif (strlen($currentLine.' '.$word) <= $maxWidth) { 182 + $currentLine .= ' '.$word; 183 + } else { 184 + $lines[] = $prefix.$currentLine; 185 + $currentLine = $word; 186 + } 187 + } 188 + 189 + if (! empty($currentLine)) { 190 + $lines[] = $prefix.$currentLine; 191 + } 192 + 193 + return $lines; 194 + } 195 + 196 + /** 197 + * Extract validation constraints from properties. 198 + * 199 + * @param array<string, array<string, mixed>> $properties 200 + * @param array<string> $required 201 + * @return array<string> 202 + */ 203 + protected function extractConstraints(array $properties, array $required): array 204 + { 205 + $constraints = []; 206 + 207 + // Required fields 208 + if (! empty($required)) { 209 + $constraints[] = 'Required: '.implode(', ', $required); 210 + } 211 + 212 + // Property-specific constraints 213 + foreach ($properties as $name => $definition) { 214 + $propConstraints = $this->extractPropertyConstraints($definition); 215 + foreach ($propConstraints as $constraint) { 216 + $constraints[] = $name.': '.trim(str_replace('@constraint', '', $constraint)); 217 + } 218 + } 219 + 220 + return $constraints; 221 + } 222 + 223 + /** 224 + * Extract validation constraints for a single property. 225 + * 226 + * @param array<string, mixed> $definition 227 + * @return array<string> 228 + */ 229 + protected function extractPropertyConstraints(array $definition): array 230 + { 231 + $constraints = []; 232 + 233 + // String constraints 234 + if (isset($definition['maxLength'])) { 235 + $constraints[] = '@constraint Max length: '.$definition['maxLength']; 236 + } 237 + 238 + if (isset($definition['minLength'])) { 239 + $constraints[] = '@constraint Min length: '.$definition['minLength']; 240 + } 241 + 242 + if (isset($definition['maxGraphemes'])) { 243 + $constraints[] = '@constraint Max graphemes: '.$definition['maxGraphemes']; 244 + } 245 + 246 + if (isset($definition['minGraphemes'])) { 247 + $constraints[] = '@constraint Min graphemes: '.$definition['minGraphemes']; 248 + } 249 + 250 + // Number constraints 251 + if (isset($definition['maximum'])) { 252 + $constraints[] = '@constraint Maximum: '.$definition['maximum']; 253 + } 254 + 255 + if (isset($definition['minimum'])) { 256 + $constraints[] = '@constraint Minimum: '.$definition['minimum']; 257 + } 258 + 259 + // Array constraints 260 + if (isset($definition['maxItems'])) { 261 + $constraints[] = '@constraint Max items: '.$definition['maxItems']; 262 + } 263 + 264 + if (isset($definition['minItems'])) { 265 + $constraints[] = '@constraint Min items: '.$definition['minItems']; 266 + } 267 + 268 + // Enum constraints 269 + if (isset($definition['enum'])) { 270 + $values = is_array($definition['enum']) ? implode(', ', $definition['enum']) : $definition['enum']; 271 + $constraints[] = '@constraint Enum: '.$values; 272 + } 273 + 274 + // Format constraints 275 + if (isset($definition['format'])) { 276 + $constraints[] = '@constraint Format: '.$definition['format']; 277 + } 278 + 279 + // Const constraint 280 + if (isset($definition['const'])) { 281 + $value = is_bool($definition['const']) ? ($definition['const'] ? 'true' : 'false') : $definition['const']; 282 + $constraints[] = '@constraint Const: '.$value; 283 + } 284 + 285 + return $constraints; 286 + } 287 + 288 + /** 289 + * Generate a simple docblock. 290 + */ 291 + public function generateSimple(string $description): string 292 + { 293 + return " /**\n * {$description}\n */"; 294 + } 295 + 296 + /** 297 + * Generate a one-line docblock. 298 + */ 299 + public function generateOneLine(string $text): string 300 + { 301 + return " /** {$text} */"; 302 + } 303 + }
+328
tests/Unit/Generator/DocBlockGeneratorTest.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\Generator\DocBlockGenerator; 8 + use SocialDept\Schema\Parser\Nsid; 9 + 10 + class DocBlockGeneratorTest extends TestCase 11 + { 12 + protected DocBlockGenerator $generator; 13 + 14 + protected function setUp(): void 15 + { 16 + parent::setUp(); 17 + 18 + $this->generator = new DocBlockGenerator; 19 + } 20 + 21 + public function test_it_generates_class_docblock_with_description(): void 22 + { 23 + $document = $this->createDocument('app.test.post', [ 24 + 'type' => 'record', 25 + 'description' => 'A social media post', 26 + 'properties' => [], 27 + ], 'A social media post'); 28 + 29 + $docBlock = $this->generator->generateClassDocBlock($document, $document->getMainDefinition()); 30 + 31 + $this->assertStringContainsString('/**', $docBlock); 32 + $this->assertStringContainsString('* A social media post', $docBlock); 33 + $this->assertStringContainsString('* Lexicon: app.test.post', $docBlock); 34 + $this->assertStringContainsString('* Type: record', $docBlock); 35 + } 36 + 37 + public function test_it_generates_class_docblock_with_property_tags(): void 38 + { 39 + $document = $this->createDocument('app.test.user', [ 40 + 'type' => 'record', 41 + 'properties' => [ 42 + 'name' => [ 43 + 'type' => 'string', 44 + 'description' => 'User name', 45 + ], 46 + 'age' => [ 47 + 'type' => 'integer', 48 + ], 49 + ], 50 + 'required' => ['name'], 51 + ]); 52 + 53 + $docBlock = $this->generator->generateClassDocBlock($document, $document->getMainDefinition()); 54 + 55 + $this->assertStringContainsString('@property string $name User name', $docBlock); 56 + $this->assertStringContainsString('@property int|null $age', $docBlock); 57 + } 58 + 59 + public function test_it_includes_validation_constraints_in_class_docblock(): void 60 + { 61 + $document = $this->createDocument('app.test.post', [ 62 + 'type' => 'record', 63 + 'properties' => [ 64 + 'text' => [ 65 + 'type' => 'string', 66 + 'maxLength' => 280, 67 + ], 68 + ], 69 + 'required' => ['text'], 70 + ]); 71 + 72 + $docBlock = $this->generator->generateClassDocBlock($document, $document->getMainDefinition()); 73 + 74 + $this->assertStringContainsString('Constraints:', $docBlock); 75 + $this->assertStringContainsString('Required: text', $docBlock); 76 + $this->assertStringContainsString('text: Max length: 280', $docBlock); 77 + } 78 + 79 + public function test_it_generates_property_docblock(): void 80 + { 81 + $docBlock = $this->generator->generatePropertyDocBlock( 82 + 'title', 83 + [ 84 + 'type' => 'string', 85 + 'description' => 'The post title', 86 + ], 87 + true 88 + ); 89 + 90 + $this->assertStringContainsString('/**', $docBlock); 91 + $this->assertStringContainsString('* The post title', $docBlock); 92 + $this->assertStringContainsString('* @var string', $docBlock); 93 + $this->assertStringContainsString('*/', $docBlock); 94 + } 95 + 96 + public function test_it_includes_constraints_in_property_docblock(): void 97 + { 98 + $docBlock = $this->generator->generatePropertyDocBlock( 99 + 'text', 100 + [ 101 + 'type' => 'string', 102 + 'maxLength' => 280, 103 + 'minLength' => 1, 104 + ], 105 + true 106 + ); 107 + 108 + $this->assertStringContainsString('@constraint Max length: 280', $docBlock); 109 + $this->assertStringContainsString('@constraint Min length: 1', $docBlock); 110 + } 111 + 112 + public function test_it_generates_method_docblock(): void 113 + { 114 + $docBlock = $this->generator->generateMethodDocBlock( 115 + 'Create a new post', 116 + 'static', 117 + [ 118 + ['name' => 'text', 'type' => 'string', 'description' => 'Post text'], 119 + ['name' => 'author', 'type' => 'string'], 120 + ] 121 + ); 122 + 123 + $this->assertStringContainsString('* Create a new post', $docBlock); 124 + $this->assertStringContainsString('* @param string $text Post text', $docBlock); 125 + $this->assertStringContainsString('* @param string $author', $docBlock); 126 + $this->assertStringContainsString('* @return static', $docBlock); 127 + } 128 + 129 + public function test_it_handles_void_return_type(): void 130 + { 131 + $docBlock = $this->generator->generateMethodDocBlock( 132 + 'Process data', 133 + 'void', 134 + [] 135 + ); 136 + 137 + $this->assertStringNotContainsString('@return', $docBlock); 138 + } 139 + 140 + public function test_it_includes_throws_annotation(): void 141 + { 142 + $docBlock = $this->generator->generateMethodDocBlock( 143 + 'Validate data', 144 + 'bool', 145 + [], 146 + '\\InvalidArgumentException' 147 + ); 148 + 149 + $this->assertStringContainsString('@throws \\InvalidArgumentException', $docBlock); 150 + } 151 + 152 + public function test_it_wraps_long_descriptions(): void 153 + { 154 + $longDescription = 'This is a very long description that should be wrapped across multiple lines when it exceeds the maximum line width of eighty characters including the comment prefix and should definitely span more than one line'; 155 + 156 + $docBlock = $this->generator->generatePropertyDocBlock( 157 + 'description', 158 + [ 159 + 'type' => 'string', 160 + 'description' => $longDescription, 161 + ], 162 + true 163 + ); 164 + 165 + // Just verify the long description is present in the docblock 166 + $this->assertStringContainsString('This is a very long description', $docBlock); 167 + 168 + // And that it doesn't exceed reasonable line lengths 169 + $lines = explode("\n", $docBlock); 170 + foreach ($lines as $line) { 171 + $this->assertLessThan(120, strlen($line), 'Line too long: '.$line); 172 + } 173 + } 174 + 175 + public function test_it_extracts_max_length_constraint(): void 176 + { 177 + $constraints = $this->invokeMethod('extractPropertyConstraints', [ 178 + ['maxLength' => 100], 179 + ]); 180 + 181 + $this->assertContains('@constraint Max length: 100', $constraints); 182 + } 183 + 184 + public function test_it_extracts_min_length_constraint(): void 185 + { 186 + $constraints = $this->invokeMethod('extractPropertyConstraints', [ 187 + ['minLength' => 5], 188 + ]); 189 + 190 + $this->assertContains('@constraint Min length: 5', $constraints); 191 + } 192 + 193 + public function test_it_extracts_grapheme_constraints(): void 194 + { 195 + $constraints = $this->invokeMethod('extractPropertyConstraints', [ 196 + ['maxGraphemes' => 280, 'minGraphemes' => 1], 197 + ]); 198 + 199 + $this->assertContains('@constraint Max graphemes: 280', $constraints); 200 + $this->assertContains('@constraint Min graphemes: 1', $constraints); 201 + } 202 + 203 + public function test_it_extracts_number_constraints(): void 204 + { 205 + $constraints = $this->invokeMethod('extractPropertyConstraints', [ 206 + ['maximum' => 100, 'minimum' => 0], 207 + ]); 208 + 209 + $this->assertContains('@constraint Maximum: 100', $constraints); 210 + $this->assertContains('@constraint Minimum: 0', $constraints); 211 + } 212 + 213 + public function test_it_extracts_array_constraints(): void 214 + { 215 + $constraints = $this->invokeMethod('extractPropertyConstraints', [ 216 + ['maxItems' => 10, 'minItems' => 1], 217 + ]); 218 + 219 + $this->assertContains('@constraint Max items: 10', $constraints); 220 + $this->assertContains('@constraint Min items: 1', $constraints); 221 + } 222 + 223 + public function test_it_extracts_enum_constraint(): void 224 + { 225 + $constraints = $this->invokeMethod('extractPropertyConstraints', [ 226 + ['enum' => ['active', 'inactive', 'pending']], 227 + ]); 228 + 229 + $this->assertContains('@constraint Enum: active, inactive, pending', $constraints); 230 + } 231 + 232 + public function test_it_extracts_format_constraint(): void 233 + { 234 + $constraints = $this->invokeMethod('extractPropertyConstraints', [ 235 + ['format' => 'datetime'], 236 + ]); 237 + 238 + $this->assertContains('@constraint Format: datetime', $constraints); 239 + } 240 + 241 + public function test_it_extracts_const_constraint(): void 242 + { 243 + $constraints = $this->invokeMethod('extractPropertyConstraints', [ 244 + ['const' => true], 245 + ]); 246 + 247 + $this->assertContains('@constraint Const: true', $constraints); 248 + } 249 + 250 + public function test_it_generates_simple_docblock(): void 251 + { 252 + $docBlock = $this->generator->generateSimple('A simple description'); 253 + 254 + $this->assertSame(" /**\n * A simple description\n */", $docBlock); 255 + } 256 + 257 + public function test_it_generates_one_line_docblock(): void 258 + { 259 + $docBlock = $this->generator->generateOneLine('Quick note'); 260 + 261 + $this->assertSame(' /** Quick note */', $docBlock); 262 + } 263 + 264 + public function test_it_handles_empty_properties(): void 265 + { 266 + $document = $this->createDocument('app.test.empty', [ 267 + 'type' => 'record', 268 + 'properties' => [], 269 + 'required' => [], 270 + ]); 271 + 272 + $docBlock = $this->generator->generateClassDocBlock($document, $document->getMainDefinition()); 273 + 274 + $this->assertStringContainsString('Lexicon: app.test.empty', $docBlock); 275 + $this->assertStringNotContainsString('@property', $docBlock); 276 + } 277 + 278 + public function test_it_handles_nullable_properties(): void 279 + { 280 + $document = $this->createDocument('app.test.post', [ 281 + 'type' => 'record', 282 + 'properties' => [ 283 + 'subtitle' => ['type' => 'string'], 284 + ], 285 + 'required' => [], 286 + ]); 287 + 288 + $docBlock = $this->generator->generateClassDocBlock($document, $document->getMainDefinition()); 289 + 290 + $this->assertStringContainsString('@property string|null $subtitle', $docBlock); 291 + } 292 + 293 + /** 294 + * Helper to create a test document. 295 + * 296 + * @param array<string, mixed> $mainDef 297 + */ 298 + protected function createDocument(string $nsid, array $mainDef, ?string $description = null): LexiconDocument 299 + { 300 + return new LexiconDocument( 301 + lexicon: 1, 302 + id: Nsid::parse($nsid), 303 + defs: ['main' => $mainDef], 304 + description: $description, 305 + source: null, 306 + raw: [ 307 + 'lexicon' => 1, 308 + 'id' => $nsid, 309 + 'defs' => ['main' => $mainDef], 310 + ] 311 + ); 312 + } 313 + 314 + /** 315 + * Helper to invoke protected method. 316 + * 317 + * @param array<mixed> $args 318 + * @return mixed 319 + */ 320 + protected function invokeMethod(string $methodName, array $args) 321 + { 322 + $reflection = new \ReflectionClass($this->generator); 323 + $method = $reflection->getMethod($methodName); 324 + $method->setAccessible(true); 325 + 326 + return $method->invokeArgs($this->generator, $args); 327 + } 328 + }