Parse and validate AT Protocol Lexicons with DTO generation for Laravel
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}