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\Exceptions\GenerationException;
7use SocialDept\AtpSchema\Support\ExtensionManager;
8
9class ClassGenerator
10{
11 /**
12 * Naming converter instance.
13 */
14 protected NamingConverter $naming;
15
16 /**
17 * Type mapper instance.
18 */
19 protected TypeMapper $typeMapper;
20
21 /**
22 * Stub renderer instance.
23 */
24 protected StubRenderer $renderer;
25
26 /**
27 * Method generator instance.
28 */
29 protected MethodGenerator $methodGenerator;
30
31 /**
32 * DocBlock generator instance.
33 */
34 protected DocBlockGenerator $docBlockGenerator;
35
36 /**
37 * Extension manager instance.
38 */
39 protected ExtensionManager $extensions;
40
41 /**
42 * Create a new ClassGenerator.
43 */
44 public function __construct(
45 ?NamingConverter $naming = null,
46 ?TypeMapper $typeMapper = null,
47 ?StubRenderer $renderer = null,
48 ?MethodGenerator $methodGenerator = null,
49 ?DocBlockGenerator $docBlockGenerator = null,
50 ?ExtensionManager $extensions = null
51 ) {
52 $this->naming = $naming ?? new NamingConverter();
53 $this->typeMapper = $typeMapper ?? new TypeMapper($this->naming);
54 $this->renderer = $renderer ?? new StubRenderer();
55 $this->methodGenerator = $methodGenerator ?? new MethodGenerator($this->naming, $this->typeMapper, $this->renderer);
56 $this->docBlockGenerator = $docBlockGenerator ?? new DocBlockGenerator($this->typeMapper);
57 $this->extensions = $extensions ?? new ExtensionManager();
58 }
59
60 /**
61 * Generate a complete PHP class from a lexicon document.
62 */
63 public function generate(LexiconDocument $document): string
64 {
65 $nsid = $document->getNsid();
66 $mainDef = $document->getMainDefinition();
67
68 if ($mainDef === null) {
69 throw GenerationException::withContext('No main definition found', ['nsid' => $nsid]);
70 }
71
72 $type = $mainDef['type'] ?? null;
73
74 if (! in_array($type, ['record', 'object'])) {
75 throw GenerationException::withContext(
76 'Can only generate classes for record and object types',
77 ['nsid' => $nsid, 'type' => $type]
78 );
79 }
80
81 // For record types, extract the actual record definition
82 $recordDef = $type === 'record' ? ($mainDef['record'] ?? []) : $mainDef;
83
84 // Build local definition map for type resolution
85 $localDefinitions = $this->buildLocalDefinitionMap($document);
86 $this->typeMapper->setLocalDefinitions($localDefinitions);
87
88 // Get class components
89 $namespace = $this->extensions->filter('filter:class:namespace', $this->naming->nsidToNamespace($nsid), $document);
90 $className = $this->extensions->filter('filter:class:className', $this->naming->toClassName($document->id->getName()), $document);
91 $useStatements = $this->extensions->filter('filter:class:useStatements', $this->collectUseStatements($recordDef, $namespace, $className), $document, $recordDef);
92 $properties = $this->extensions->filter('filter:class:properties', $this->generateProperties($recordDef), $document, $recordDef);
93 $constructor = $this->extensions->filter('filter:class:constructor', $this->generateConstructor($recordDef), $document, $recordDef);
94 $methods = $this->extensions->filter('filter:class:methods', $this->generateMethods($document), $document);
95 $docBlock = $this->extensions->filter('filter:class:docBlock', $this->generateClassDocBlock($document, $mainDef), $document, $mainDef);
96
97 // Render the class
98 $rendered = $this->renderer->render('class', [
99 'namespace' => $namespace,
100 'imports' => $this->formatUseStatements($useStatements),
101 'docBlock' => $docBlock,
102 'className' => $className,
103 'extends' => ' extends Data',
104 'implements' => '',
105 'properties' => $properties,
106 'constructor' => $constructor,
107 'methods' => $methods,
108 ]);
109
110 // Fix blank lines when there's no constructor or properties
111 if (empty($properties) && empty($constructor)) {
112 // Remove double blank lines after class opening brace
113 $rendered = preg_replace('/\{\n\n\n/', "{\n", $rendered);
114 }
115
116 // Execute post-generation hooks
117 $this->extensions->execute('action:class:generated', $rendered, $document);
118
119 return $rendered;
120 }
121
122 /**
123 * Generate class properties.
124 *
125 * Since we use constructor property promotion, we don't need separate property declarations.
126 * This method returns empty string but is kept for compatibility.
127 *
128 * @param array<string, mixed> $definition
129 */
130 protected function generateProperties(array $definition): string
131 {
132 // Properties are defined via constructor promotion
133 return '';
134 }
135
136 /**
137 * Generate class constructor.
138 *
139 * @param array<string, mixed> $definition
140 */
141 protected function generateConstructor(array $definition): string
142 {
143 $properties = $definition['properties'] ?? [];
144 $required = $definition['required'] ?? [];
145
146 if (empty($properties)) {
147 return '';
148 }
149
150 // Build constructor parameters - required first, then optional
151 $requiredParams = [];
152 $optionalParams = [];
153 $requiredDocParams = [];
154 $optionalDocParams = [];
155
156 foreach ($properties as $name => $propDef) {
157 $isRequired = in_array($name, $required);
158 $phpType = $this->typeMapper->toPhpType($propDef, ! $isRequired);
159 $phpDocType = $this->typeMapper->toPhpDocType($propDef, ! $isRequired);
160 $description = $propDef['description'] ?? '';
161 $param = ' public readonly '.$phpType.' $'.$name;
162
163 if ($isRequired) {
164 $requiredParams[] = $param.',';
165 if ($description) {
166 $requiredDocParams[] = ' * @param '.$phpDocType.' $'.$name.' '.$description;
167 }
168 } else {
169 $optionalParams[] = $param.' = null,';
170 if ($description) {
171 $optionalDocParams[] = ' * @param '.$phpDocType.' $'.$name.' '.$description;
172 }
173 }
174 }
175
176 // Combine required and optional parameters
177 $params = array_merge($requiredParams, $optionalParams);
178
179 // Remove trailing comma from last parameter
180 if (! empty($params)) {
181 $params[count($params) - 1] = rtrim($params[count($params) - 1], ',');
182 }
183
184 // Build constructor DocBlock with parameter descriptions in the correct order
185 $docParams = array_merge($requiredDocParams, $optionalDocParams);
186
187 // Only add docblock if there are parameter descriptions
188 if (! empty($docParams)) {
189 $docLines = [' /**'];
190 $docLines = array_merge($docLines, $docParams);
191 $docLines[] = ' */';
192 $docBlock = implode("\n", $docLines)."\n";
193 } else {
194 $docBlock = '';
195 }
196
197 return $docBlock." public function __construct(\n".implode("\n", $params)."\n ) {\n }";
198 }
199
200 /**
201 * Generate class methods.
202 */
203 protected function generateMethods(LexiconDocument $document): string
204 {
205 $methods = $this->methodGenerator->generateAll($document);
206
207 return implode("\n\n", $methods);
208 }
209
210 /**
211 * Generate class-level documentation block.
212 *
213 * @param array<string, mixed> $definition
214 */
215 protected function generateClassDocBlock(LexiconDocument $document, array $definition): string
216 {
217 return $this->docBlockGenerator->generateClassDocBlock($document, $definition);
218 }
219
220 /**
221 * Collect all use statements needed for the class.
222 *
223 * @param array<string, mixed> $definition
224 * @return array<string>
225 */
226 protected function collectUseStatements(array $definition, string $currentNamespace = '', string $currentClassName = ''): array
227 {
228 $uses = ['SocialDept\\AtpSchema\\Data\\Data'];
229 $properties = $definition['properties'] ?? [];
230 $hasUnions = false;
231 $localRefs = [];
232
233 foreach ($properties as $propDef) {
234 $propUses = $this->typeMapper->getUseStatements($propDef);
235 $uses = array_merge($uses, $propUses);
236
237 // Check if this property uses unions
238 if (isset($propDef['type']) && $propDef['type'] === 'union') {
239 $hasUnions = true;
240 }
241
242 // Collect local references for import
243 if (isset($propDef['type']) && $propDef['type'] === 'ref' && isset($propDef['ref'])) {
244 $ref = $propDef['ref'];
245 if (str_starts_with($ref, '#')) {
246 $localRefs[] = ltrim($ref, '#');
247 }
248 }
249
250 // Handle array items
251 if (isset($propDef['items'])) {
252 $itemUses = $this->typeMapper->getUseStatements($propDef['items']);
253 $uses = array_merge($uses, $itemUses);
254
255 // Check for local refs in array items
256 if (isset($propDef['items']['type']) && $propDef['items']['type'] === 'ref' && isset($propDef['items']['ref'])) {
257 $ref = $propDef['items']['ref'];
258 if (str_starts_with($ref, '#')) {
259 $localRefs[] = ltrim($ref, '#');
260 }
261 }
262 }
263 }
264
265 // Add local ref imports
266 // For local refs, check if they should be nested or siblings
267 if (! empty($localRefs) && $currentNamespace) {
268 foreach ($localRefs as $localRef) {
269 $refClassName = $this->naming->toClassName($localRef);
270
271 // If this is a nested definition (has currentClassName) and it's a record type,
272 // then local refs are nested under the record
273 if ($currentClassName && $definition['type'] === 'record') {
274 $uses[] = $currentNamespace . '\\' . $currentClassName . '\\' . $refClassName;
275 } else {
276 // For object definitions or defs lexicons, local refs are siblings
277 $uses[] = $currentNamespace . '\\' . $refClassName;
278 }
279 }
280 }
281
282 // Add UnionHelper if unions are used
283 if ($hasUnions) {
284 $uses[] = 'SocialDept\\AtpSchema\\Support\\UnionHelper';
285 }
286
287 // Remove duplicates and sort
288 $uses = array_unique($uses);
289
290 // Filter out classes from the same namespace
291 if ($currentNamespace) {
292 $uses = array_filter($uses, function ($use) use ($currentNamespace) {
293 // Get namespace from FQCN by removing class name
294 $parts = explode('\\', ltrim($use, '\\'));
295 array_pop($parts); // Remove class name
296 $useNamespace = implode('\\', $parts);
297
298 return $useNamespace !== $currentNamespace;
299 });
300 }
301
302 sort($uses);
303
304 return $uses;
305 }
306
307 /**
308 * Format use statements for output.
309 *
310 * @param array<string> $uses
311 */
312 protected function formatUseStatements(array $uses): string
313 {
314 if (empty($uses)) {
315 return '';
316 }
317
318 $lines = [];
319 foreach ($uses as $use) {
320 $lines[] = 'use '.ltrim($use, '\\').';';
321 }
322
323 return implode("\n", $lines);
324 }
325
326 /**
327 * Build a map of local definitions for type resolution.
328 *
329 * Maps local references (#defName) to their generated class names.
330 *
331 * @return array<string, string> Map of local ref => class name
332 */
333 protected function buildLocalDefinitionMap(LexiconDocument $document): array
334 {
335 $localDefs = [];
336 $allDefs = $document->defs ?? [];
337
338 foreach ($allDefs as $defName => $definition) {
339 // Skip the main definition
340 if ($defName === 'main') {
341 continue;
342 }
343
344 // Convert definition name to class name
345 $className = $this->naming->toClassName($defName);
346 $localDefs["#{$defName}"] = $className;
347 }
348
349 return $localDefs;
350 }
351
352 /**
353 * Get the naming converter.
354 */
355 public function getNaming(): NamingConverter
356 {
357 return $this->naming;
358 }
359
360 /**
361 * Get the type mapper.
362 */
363 public function getTypeMapper(): TypeMapper
364 {
365 return $this->typeMapper;
366 }
367
368 /**
369 * Get the stub renderer.
370 */
371 public function getRenderer(): StubRenderer
372 {
373 return $this->renderer;
374 }
375
376 /**
377 * Get the method generator.
378 */
379 public function getMethodGenerator(): MethodGenerator
380 {
381 return $this->methodGenerator;
382 }
383
384 /**
385 * Get the docblock generator.
386 */
387 public function getDocBlockGenerator(): DocBlockGenerator
388 {
389 return $this->docBlockGenerator;
390 }
391
392 /**
393 * Get the extension manager.
394 */
395 public function getExtensions(): ExtensionManager
396 {
397 return $this->extensions;
398 }
399}