Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1<?php
2
3namespace SocialDept\AtpSchema\Generator;
4
5use SocialDept\AtpSchema\Support\ExtensionManager;
6
7class TypeMapper
8{
9 /**
10 * Naming converter instance.
11 */
12 protected NamingConverter $naming;
13
14 /**
15 * Local definition map for resolving #refs.
16 *
17 * @var array<string, string>
18 */
19 protected array $localDefinitions = [];
20
21 /**
22 * Extension manager instance.
23 */
24 protected ExtensionManager $extensions;
25
26 /**
27 * Create a new TypeMapper.
28 */
29 public function __construct(?NamingConverter $naming = null, ?ExtensionManager $extensions = null)
30 {
31 $this->naming = $naming ?? new NamingConverter();
32 $this->extensions = $extensions ?? new ExtensionManager();
33 }
34
35 /**
36 * Set local definitions for resolving local references.
37 *
38 * @param array<string, string> $localDefinitions Map of #ref => class name
39 */
40 public function setLocalDefinitions(array $localDefinitions): void
41 {
42 $this->localDefinitions = $localDefinitions;
43 }
44
45 /**
46 * Map lexicon type to PHP type.
47 *
48 * @param array<string, mixed> $definition
49 */
50 public function toPhpType(array $definition, bool $nullable = false): string
51 {
52 $type = $definition['type'] ?? 'unknown';
53
54 $phpType = match ($type) {
55 'string' => $this->mapStringType($definition),
56 'integer' => 'int',
57 'boolean' => 'bool',
58 'number' => 'float',
59 'array' => $this->mapArrayType($definition),
60 'object' => $this->mapObjectType($definition),
61 'blob' => 'BlobReference',
62 'bytes' => 'string',
63 'cid-link' => 'string',
64 'unknown' => 'mixed',
65 'ref' => $this->mapRefType($definition),
66 'union' => $this->mapUnionType($definition),
67 default => 'mixed',
68 };
69
70 if ($nullable && $phpType !== 'mixed') {
71 $phpType = '?'.$phpType;
72 }
73
74 return $this->extensions->filter('filter:type:phpType', $phpType, $definition, $nullable);
75 }
76
77 /**
78 * Map lexicon type to PHPDoc type.
79 *
80 * @param array<string, mixed> $definition
81 */
82 public function toPhpDocType(array $definition, bool $nullable = false): string
83 {
84 $type = $definition['type'] ?? 'unknown';
85
86 $docType = match ($type) {
87 'string' => $this->mapStringType($definition),
88 'integer' => 'int',
89 'boolean' => 'bool',
90 'number' => 'float',
91 'array' => $this->mapArrayDocType($definition),
92 'object' => $this->mapObjectDocType($definition),
93 'blob' => 'BlobReference',
94 'bytes' => 'string',
95 'cid-link' => 'string',
96 'unknown' => 'mixed',
97 'ref' => $this->mapRefDocType($definition),
98 'union' => $this->mapUnionDocType($definition),
99 default => 'mixed',
100 };
101
102 if ($nullable && $docType !== 'mixed') {
103 $docType = $docType.'|null';
104 }
105
106 return $this->extensions->filter('filter:type:phpDocType', $docType, $definition, $nullable);
107 }
108
109 /**
110 * Map string type.
111 *
112 * @param array<string, mixed> $definition
113 */
114 protected function mapStringType(array $definition): string
115 {
116 // Check for datetime format
117 if (isset($definition['format']) && $definition['format'] === 'datetime') {
118 return 'Carbon';
119 }
120
121 return 'string';
122 }
123
124 /**
125 * Map array type.
126 *
127 * @param array<string, mixed> $definition
128 */
129 protected function mapArrayType(array $definition): string
130 {
131 return 'array';
132 }
133
134 /**
135 * Map array type for PHPDoc.
136 *
137 * @param array<string, mixed> $definition
138 */
139 protected function mapArrayDocType(array $definition): string
140 {
141 if (! isset($definition['items'])) {
142 return 'array';
143 }
144
145 $itemType = $this->toPhpDocType($definition['items']);
146
147 // array<mixed> is redundant, just use array
148 if ($itemType === 'mixed') {
149 return 'array';
150 }
151
152 return "array<{$itemType}>";
153 }
154
155 /**
156 * Map object type.
157 *
158 * @param array<string, mixed> $definition
159 */
160 protected function mapObjectType(array $definition): string
161 {
162 return 'array';
163 }
164
165 /**
166 * Map object type for PHPDoc.
167 *
168 * @param array<string, mixed> $definition
169 */
170 protected function mapObjectDocType(array $definition): string
171 {
172 if (! isset($definition['properties'])) {
173 return 'array';
174 }
175
176 // Build array shape annotation
177 $properties = [];
178 foreach ($definition['properties'] as $key => $propDef) {
179 $propType = $this->toPhpDocType($propDef);
180 $properties[] = "{$key}: {$propType}";
181 }
182
183 if (empty($properties)) {
184 return 'array';
185 }
186
187 return 'array{'.implode(', ', $properties).'}';
188 }
189
190 /**
191 * Map reference type.
192 *
193 * @param array<string, mixed> $definition
194 */
195 protected function mapRefType(array $definition): string
196 {
197 if (! isset($definition['ref'])) {
198 return 'mixed';
199 }
200
201 $ref = $definition['ref'];
202
203 // Resolve local references using the local definitions map
204 if (str_starts_with($ref, '#')) {
205 return $this->localDefinitions[$ref] ?? 'mixed';
206 }
207
208 // Handle NSID fragments (e.g., com.atproto.label.defs#selfLabels)
209 // Convert fragment to class name
210 if (str_contains($ref, '#')) {
211 [$baseNsid, $fragment] = explode('#', $ref, 2);
212
213 return $this->naming->toClassName($fragment);
214 }
215
216 // Convert NSID reference to fully qualified class name
217 $fqcn = $this->naming->nsidToClassName($ref);
218
219 // Extract short class name (last part after final backslash)
220 $parts = explode('\\', $fqcn);
221
222 return end($parts);
223 }
224
225 /**
226 * Map reference type for PHPDoc.
227 *
228 * @param array<string, mixed> $definition
229 */
230 protected function mapRefDocType(array $definition): string
231 {
232 return $this->mapRefType($definition);
233 }
234
235 /**
236 * Map union type.
237 *
238 * @param array<string, mixed> $definition
239 */
240 protected function mapUnionType(array $definition): string
241 {
242 // Open unions (closed=false or not set) should always be mixed
243 // because future schema versions could add more types
244 $isClosed = $definition['closed'] ?? false;
245
246 if (! $isClosed) {
247 return 'mixed';
248 }
249
250 // For closed unions, extract external refs
251 $refs = $definition['refs'] ?? [];
252 $externalRefs = array_values(array_filter($refs, fn ($ref) => ! str_starts_with($ref, '#')));
253
254 if (empty($externalRefs)) {
255 return 'mixed';
256 }
257
258 // Build union type with all variants
259 $types = [];
260 foreach ($externalRefs as $ref) {
261 // Handle NSID fragments - convert fragment to class name
262 if (str_contains($ref, '#')) {
263 [$baseNsid, $fragment] = explode('#', $ref, 2);
264 $types[] = $this->naming->toClassName($fragment);
265 } else {
266 // Convert to fully qualified class name, then extract short name
267 $fqcn = $this->naming->nsidToClassName($ref);
268 $parts = explode('\\', $fqcn);
269 $types[] = end($parts);
270 }
271 }
272
273 // Return union type (e.g., "Theme|ThemeV2" or just "Theme" for single ref)
274 return implode('|', $types);
275 }
276
277 /**
278 * Map union type for PHPDoc.
279 *
280 * @param array<string, mixed> $definition
281 */
282 protected function mapUnionDocType(array $definition): string
283 {
284 if (! isset($definition['refs'])) {
285 return 'mixed';
286 }
287
288 // Open unions should be typed as mixed since future types could be added
289 $isClosed = $definition['closed'] ?? false;
290 if (! $isClosed) {
291 return 'mixed';
292 }
293
294 // For closed unions, list all the specific types
295 $types = [];
296 foreach ($definition['refs'] as $ref) {
297 // Resolve local references using the local definitions map
298 if (str_starts_with($ref, '#')) {
299 $types[] = $this->localDefinitions[$ref] ?? 'mixed';
300
301 continue;
302 }
303
304 // Handle NSID fragments - convert fragment to class name
305 if (str_contains($ref, '#')) {
306 [$baseNsid, $fragment] = explode('#', $ref, 2);
307 $types[] = $this->naming->toClassName($fragment);
308
309 continue;
310 }
311
312 // Convert to fully qualified class name, then extract short name
313 $fqcn = $this->naming->nsidToClassName($ref);
314 $parts = explode('\\', $fqcn);
315 $types[] = end($parts);
316 }
317
318 return implode('|', $types);
319 }
320
321 /**
322 * Check if type is nullable based on definition.
323 *
324 * @param array<string, mixed> $definition
325 */
326 public function isNullable(array $definition, array $required = []): bool
327 {
328 // Check if explicitly marked as required
329 if (isset($definition['required']) && $definition['required'] === true) {
330 return false;
331 }
332
333 // Check if in required array
334 if (! empty($required)) {
335 return false;
336 }
337
338 // Default to nullable for optional fields
339 return true;
340 }
341
342 /**
343 * Get default value for a type.
344 *
345 * @param array<string, mixed> $definition
346 */
347 public function getDefaultValue(array $definition): ?string
348 {
349 if (! array_key_exists('default', $definition)) {
350 return null;
351 }
352
353 $default = $definition['default'];
354
355 if ($default === null) {
356 return 'null';
357 }
358
359 if (is_string($default)) {
360 return "'".addslashes($default)."'";
361 }
362
363 if (is_bool($default)) {
364 return $default ? 'true' : 'false';
365 }
366
367 if (is_numeric($default)) {
368 return (string) $default;
369 }
370
371 if (is_array($default)) {
372 return '[]';
373 }
374
375 return null;
376 }
377
378 /**
379 * Check if type needs use statement.
380 *
381 * @param array<string, mixed> $definition
382 */
383 public function needsUseStatement(array $definition): bool
384 {
385 $type = $definition['type'] ?? 'unknown';
386
387 // Check for datetime format on strings
388 if ($type === 'string' && isset($definition['format']) && $definition['format'] === 'datetime') {
389 return true;
390 }
391
392 return in_array($type, ['ref', 'blob']);
393 }
394
395 /**
396 * Get use statements for type.
397 *
398 * @param array<string, mixed> $definition
399 * @return array<string>
400 */
401 public function getUseStatements(array $definition): array
402 {
403 $type = $definition['type'] ?? 'unknown';
404
405 if ($type === 'string' && isset($definition['format']) && $definition['format'] === 'datetime') {
406 return ['Carbon\\Carbon'];
407 }
408
409 if ($type === 'blob') {
410 return ['SocialDept\\AtpSchema\\Data\\BlobReference'];
411 }
412
413 if ($type === 'ref' && isset($definition['ref'])) {
414 $ref = $definition['ref'];
415
416 // Skip local references (starting with #)
417 if (str_starts_with($ref, '#')) {
418 return [];
419 }
420
421 // Handle NSID fragments - convert fragment to class name
422 if (str_contains($ref, '#')) {
423 [$baseNsid, $fragment] = explode('#', $ref, 2);
424 // For fragments, we need to include ALL segments of the base NSID
425 // Parse the NSID and convert each segment to PascalCase
426 $nsid = \SocialDept\AtpSchema\Parser\Nsid::parse($baseNsid);
427 $segments = $nsid->getSegments();
428 $namespaceParts = array_map(
429 fn ($part) => $this->naming->toPascalCase($part),
430 $segments
431 );
432 $namespace = $this->naming->getBaseNamespace() . '\\' . implode('\\', $namespaceParts);
433 $className = $this->naming->toClassName($fragment);
434
435 return [$namespace . '\\' . $className];
436 }
437
438 return [$this->naming->nsidToClassName($ref)];
439 }
440
441 if ($type === 'union' && isset($definition['refs'])) {
442 // Open unions don't need use statements since they're typed as mixed
443 $isClosed = $definition['closed'] ?? false;
444 if (! $isClosed) {
445 return [];
446 }
447
448 // For closed unions, import the referenced classes
449 $classes = [];
450
451 foreach ($definition['refs'] as $ref) {
452 // Skip local references
453 if (str_starts_with($ref, '#')) {
454 continue;
455 }
456
457 // Handle NSID fragments - convert fragment to class name
458 if (str_contains($ref, '#')) {
459 [$baseNsid, $fragment] = explode('#', $ref, 2);
460 // For fragments, we need to include ALL segments of the base NSID
461 $nsid = \SocialDept\AtpSchema\Parser\Nsid::parse($baseNsid);
462 $segments = $nsid->getSegments();
463 $namespaceParts = array_map(
464 fn ($part) => $this->naming->toPascalCase($part),
465 $segments
466 );
467 $namespace = $this->naming->getBaseNamespace() . '\\' . implode('\\', $namespaceParts);
468 $className = $this->naming->toClassName($fragment);
469 $classes[] = $namespace . '\\' . $className;
470 } else {
471 $classes[] = $this->naming->nsidToClassName($ref);
472 }
473 }
474
475 return $classes;
476 }
477
478 $uses = [];
479
480 return $this->extensions->filter('filter:type:useStatements', $uses, $definition);
481 }
482
483 /**
484 * Get the extension manager.
485 */
486 public function getExtensions(): ExtensionManager
487 {
488 return $this->extensions;
489 }
490}