Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1<?php
2
3namespace SocialDept\AtpSchema\Parser;
4
5use SocialDept\AtpSchema\Data\LexiconDocument;
6use SocialDept\AtpSchema\Data\TypeDefinition;
7use SocialDept\AtpSchema\Exceptions\TypeResolutionException;
8
9class TypeParser
10{
11 /**
12 * Primitive type parser.
13 */
14 protected PrimitiveParser $primitiveParser;
15
16 /**
17 * Complex type parser.
18 */
19 protected ComplexTypeParser $complexParser;
20
21 /**
22 * Schema loader for resolving external references.
23 */
24 protected ?SchemaLoader $schemaLoader;
25
26 /**
27 * Cache of resolved types to prevent infinite loops.
28 *
29 * @var array<string, TypeDefinition>
30 */
31 protected array $resolvedTypes = [];
32
33 /**
34 * Current resolution chain to detect circular references.
35 *
36 * @var array<string>
37 */
38 protected array $resolutionChain = [];
39
40 /**
41 * Create a new TypeParser.
42 */
43 public function __construct(
44 ?PrimitiveParser $primitiveParser = null,
45 ?ComplexTypeParser $complexParser = null,
46 ?SchemaLoader $schemaLoader = null
47 ) {
48 $this->primitiveParser = $primitiveParser ?? new PrimitiveParser();
49 $this->complexParser = $complexParser ?? new ComplexTypeParser($this->primitiveParser);
50 $this->schemaLoader = $schemaLoader;
51 }
52
53 /**
54 * Parse a type definition from array data.
55 *
56 * @throws TypeResolutionException
57 */
58 public function parse(array $data, ?LexiconDocument $context = null): TypeDefinition
59 {
60 $type = $data['type'] ?? null;
61
62 if ($type === null) {
63 throw TypeResolutionException::unknownType('(missing type field)');
64 }
65
66 // Handle primitive types
67 if ($this->primitiveParser->isPrimitive($type)) {
68 return $this->primitiveParser->parse($data);
69 }
70
71 // Handle complex types
72 if ($this->complexParser->isComplex($type)) {
73 return $this->complexParser->parse($data);
74 }
75
76 throw TypeResolutionException::unknownType($type);
77 }
78
79 /**
80 * Resolve a reference to its actual type definition.
81 *
82 * @throws TypeResolutionException
83 */
84 public function resolveReference(string $ref, LexiconDocument $context): TypeDefinition
85 {
86 // Check if already resolved
87 $cacheKey = $context->getNsid().':'.$ref;
88
89 if (isset($this->resolvedTypes[$cacheKey])) {
90 return $this->resolvedTypes[$cacheKey];
91 }
92
93 // Check for circular reference
94 if (in_array($cacheKey, $this->resolutionChain)) {
95 throw TypeResolutionException::circularReference($ref, $this->resolutionChain);
96 }
97
98 $this->resolutionChain[] = $cacheKey;
99
100 try {
101 $type = $this->resolveReferenceInternal($ref, $context);
102 $this->resolvedTypes[$cacheKey] = $type;
103
104 return $type;
105 } finally {
106 array_pop($this->resolutionChain);
107 }
108 }
109
110 /**
111 * Internal reference resolution logic.
112 *
113 * @throws TypeResolutionException
114 */
115 protected function resolveReferenceInternal(string $ref, LexiconDocument $context): TypeDefinition
116 {
117 // Local reference (#defName)
118 if (str_starts_with($ref, '#')) {
119 $defName = substr($ref, 1);
120
121 if (! $context->hasDefinition($defName)) {
122 throw TypeResolutionException::unresolvableReference($ref, $context->getNsid());
123 }
124
125 $defData = $context->getDefinition($defName);
126
127 return $this->parse($defData, $context);
128 }
129
130 // External reference (nsid#defName or just nsid for #main)
131 if ($this->schemaLoader === null) {
132 throw new \RuntimeException('Cannot resolve external reference without SchemaLoader');
133 }
134
135 [$nsid, $defName] = $this->parseExternalReference($ref);
136
137 // Load external schema
138 $externalSchema = $this->schemaLoader->load($nsid);
139 $externalDoc = LexiconDocument::fromArray($externalSchema);
140
141 // Get the definition
142 if (! $externalDoc->hasDefinition($defName)) {
143 throw TypeResolutionException::unresolvableReference($ref, $context->getNsid());
144 }
145
146 $defData = $externalDoc->getDefinition($defName);
147
148 return $this->parse($defData, $externalDoc);
149 }
150
151 /**
152 * Parse an external reference into NSID and definition name.
153 *
154 * @return array{0: string, 1: string}
155 */
156 protected function parseExternalReference(string $ref): array
157 {
158 if (str_contains($ref, '#')) {
159 [$nsid, $defName] = explode('#', $ref, 2);
160
161 return [$nsid, $defName];
162 }
163
164 // If no # is present, default to 'main'
165 return [$ref, 'main'];
166 }
167
168 /**
169 * Clear the resolution cache.
170 */
171 public function clearCache(): void
172 {
173 $this->resolvedTypes = [];
174 $this->resolutionChain = [];
175 }
176
177 /**
178 * Get the resolved types cache.
179 *
180 * @return array<string, TypeDefinition>
181 */
182 public function getResolvedTypes(): array
183 {
184 return $this->resolvedTypes;
185 }
186}