Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1<?php
2
3namespace SocialDept\AtpSchema\Generator;
4
5use SocialDept\AtpSchema\Contracts\DataGenerator;
6use SocialDept\AtpSchema\Data\LexiconDocument;
7use SocialDept\AtpSchema\Parser\SchemaLoader;
8use SocialDept\AtpSchema\Parser\TypeParser;
9
10class DTOGenerator implements DataGenerator
11{
12 /**
13 * Schema loader for loading lexicon documents.
14 */
15 protected SchemaLoader $schemaLoader;
16
17 /**
18 * Type parser for parsing type definitions.
19 */
20 protected TypeParser $typeParser;
21
22 /**
23 * Namespace resolver for converting NSIDs to PHP namespaces.
24 */
25 protected NamespaceResolver $namespaceResolver;
26
27 /**
28 * Template renderer for generating PHP code.
29 */
30 protected TemplateRenderer $templateRenderer;
31
32 /**
33 * File writer for writing generated files.
34 */
35 protected FileWriter $fileWriter;
36
37 /**
38 * Class generator for generating PHP classes.
39 */
40 protected ClassGenerator $classGenerator;
41
42 /**
43 * Base namespace for generated classes.
44 */
45 protected string $baseNamespace;
46
47 /**
48 * Output directory for generated files.
49 */
50 protected string $outputDirectory;
51
52 /**
53 * Create a new DTOGenerator.
54 */
55 public function __construct(
56 SchemaLoader $schemaLoader,
57 string $baseNamespace = 'App\\Lexicons',
58 string $outputDirectory = 'app/Lexicons',
59 ?TypeParser $typeParser = null,
60 ?NamespaceResolver $namespaceResolver = null,
61 ?TemplateRenderer $templateRenderer = null,
62 ?FileWriter $fileWriter = null,
63 ?ClassGenerator $classGenerator = null
64 ) {
65 $this->schemaLoader = $schemaLoader;
66 $this->baseNamespace = rtrim($baseNamespace, '\\');
67 $this->outputDirectory = rtrim($outputDirectory, '/');
68 $this->typeParser = $typeParser ?? new TypeParser(schemaLoader: $schemaLoader);
69 $this->namespaceResolver = $namespaceResolver ?? new NamespaceResolver($baseNamespace);
70 $this->templateRenderer = $templateRenderer ?? new TemplateRenderer();
71 $this->fileWriter = $fileWriter ?? new FileWriter();
72
73 // Initialize ClassGenerator with proper naming converter
74 $naming = new NamingConverter($this->baseNamespace);
75 $this->classGenerator = $classGenerator ?? new ClassGenerator($naming);
76 }
77
78 /**
79 * Generate PHP class files from Lexicon definition.
80 */
81 public function generate(LexiconDocument $schema): string
82 {
83 return $this->classGenerator->generate($schema);
84 }
85
86 /**
87 * Generate and write class file to disk.
88 */
89 public function generateAndSave(LexiconDocument $schema, string $outputPath): string
90 {
91 $code = $this->generate($schema);
92 $this->fileWriter->write($outputPath, $code);
93
94 return $outputPath;
95 }
96
97 /**
98 * Generate class content without writing to disk.
99 */
100 public function preview(LexiconDocument $schema): string
101 {
102 return $this->generate($schema);
103 }
104
105 /**
106 * Set the base namespace for generated classes.
107 */
108 public function setBaseNamespace(string $namespace): void
109 {
110 $this->baseNamespace = rtrim($namespace, '\\');
111 $this->namespaceResolver = new NamespaceResolver($this->baseNamespace);
112 }
113
114 /**
115 * Set the output path for generated classes.
116 */
117 public function setOutputPath(string $path): void
118 {
119 $this->outputDirectory = rtrim($path, '/');
120 }
121
122 /**
123 * Generate DTO classes from NSID.
124 */
125 public function generateByNsid(string $nsid, array $options = []): array
126 {
127 $document = $this->schemaLoader->load($nsid);
128
129 return $this->generateFromDocument($document, $options);
130 }
131
132 /**
133 * Generate DTO classes from a lexicon document.
134 */
135 public function generateFromDocument(LexiconDocument $document, array $options = []): array
136 {
137 $generatedFiles = [];
138
139 // Generate main class if it's a record or object
140 $mainDef = $document->getMainDefinition();
141 $mainType = $mainDef['type'] ?? null;
142
143 if ($document->isRecord()) {
144 $file = $this->generateRecordClass($document, $options);
145 $generatedFiles[] = $file;
146 } elseif ($mainType === 'object') {
147 // Generate for standalone object types (like strongRef)
148 $file = $this->generateRecordClass($document, $options);
149 $generatedFiles[] = $file;
150 }
151
152 // Generate classes for other definitions
153 foreach ($document->getDefinitionNames() as $defName) {
154 if ($defName === 'main') {
155 continue;
156 }
157
158 $definition = $document->getDefinition($defName);
159
160 if (isset($definition['type']) && $definition['type'] === 'object') {
161 $file = $this->generateDefinitionClass($document, $defName, $options);
162 $generatedFiles[] = $file;
163 }
164 }
165
166 return $generatedFiles;
167 }
168
169 /**
170 * Generate code for a record (without writing to disk).
171 */
172 protected function generateRecordCode(LexiconDocument $document): string
173 {
174 $namespace = $this->namespaceResolver->resolveNamespace($document->getNsid());
175 $className = $this->namespaceResolver->resolveClassName($document->getNsid());
176
177 $mainDef = $document->getMainDefinition();
178 $recordSchema = $mainDef['record'] ?? [];
179
180 $properties = $this->extractProperties($recordSchema, $document);
181
182 return $this->templateRenderer->render('record', [
183 'namespace' => $namespace,
184 'className' => $className,
185 'nsid' => $document->getNsid(),
186 'description' => $document->description,
187 'properties' => $properties,
188 ]);
189 }
190
191 /**
192 * Generate a record class from a lexicon document.
193 */
194 protected function generateRecordClass(LexiconDocument $document, array $options = []): string
195 {
196 // Use ClassGenerator for proper code generation
197 $code = $this->classGenerator->generate($document);
198
199 $naming = $this->classGenerator->getNaming();
200 $namespace = $naming->nsidToNamespace($document->getNsid());
201 $className = $naming->toClassName($document->id->getName());
202 $filePath = $this->getFilePath($namespace, $className);
203
204 if (! ($options['dryRun'] ?? false)) {
205 $this->fileWriter->write($filePath, $code);
206 }
207
208 return $filePath;
209 }
210
211 /**
212 * Generate a class for a specific definition.
213 */
214 protected function generateDefinitionClass(LexiconDocument $document, string $defName, array $options = []): string
215 {
216 // Create a temporary document for this specific definition
217 $definition = $document->getDefinition($defName);
218
219 // Build a temporary lexicon document for the object definition
220 $objectNsid = $document->getNsid().'.'.$defName;
221 $tempSchema = [
222 'id' => $objectNsid,
223 'lexicon' => 1,
224 'description' => $definition['description'] ?? null,
225 'defs' => [
226 'main' => [
227 'type' => 'object',
228 'properties' => $definition['properties'] ?? [],
229 'required' => $definition['required'] ?? [],
230 'description' => $definition['description'] ?? null,
231 ],
232 ],
233 ];
234
235 $tempDocument = \SocialDept\AtpSchema\Data\LexiconDocument::fromArray($tempSchema);
236
237 // Use ClassGenerator for proper code generation
238 $code = $this->classGenerator->generate($tempDocument);
239
240 $naming = $this->classGenerator->getNaming();
241 $namespace = $naming->nsidToNamespace($objectNsid);
242 $className = $naming->toClassName($defName);
243 $filePath = $this->getFilePath($namespace, $className);
244
245 if (! ($options['dryRun'] ?? false)) {
246 $this->fileWriter->write($filePath, $code);
247 }
248
249 return $filePath;
250 }
251
252 /**
253 * Extract properties from a schema definition.
254 *
255 * @return array<array{name: string, type: string, phpType: string, required: bool, description: ?string}>
256 */
257 protected function extractProperties(array $schema, LexiconDocument $document): array
258 {
259 $properties = [];
260 $schemaProperties = $schema['properties'] ?? [];
261 $required = $schema['required'] ?? [];
262
263 foreach ($schemaProperties as $name => $propSchema) {
264 $properties[] = [
265 'name' => $name,
266 'type' => $propSchema['type'] ?? 'unknown',
267 'phpType' => $this->mapToPhpType($propSchema),
268 'required' => in_array($name, $required),
269 'description' => $propSchema['description'] ?? null,
270 ];
271 }
272
273 return $properties;
274 }
275
276 /**
277 * Map lexicon type to PHP type.
278 */
279 protected function mapToPhpType(array $typeSchema): string
280 {
281 $type = $typeSchema['type'] ?? 'unknown';
282
283 return match ($type) {
284 'null' => 'null',
285 'boolean' => 'bool',
286 'integer' => 'int',
287 'string' => 'string',
288 'bytes' => 'string',
289 'array' => 'array',
290 'object' => 'array',
291 'unknown' => 'mixed',
292 default => 'mixed',
293 };
294 }
295
296 /**
297 * Get the file path for a generated class.
298 */
299 protected function getFilePath(string $namespace, string $className): string
300 {
301 // Remove base namespace from full namespace
302 $relativePath = str_replace($this->baseNamespace.'\\', '', $namespace);
303 $relativePath = str_replace('\\', '/', $relativePath);
304
305 return $this->outputDirectory.'/'.$relativePath.'/'.$className.'.php';
306 }
307
308 /**
309 * Validate generated code.
310 */
311 public function validate(string $code): bool
312 {
313 // Basic syntax check using token_get_all
314 $tokens = @token_get_all($code);
315
316 return $tokens !== false;
317 }
318
319 /**
320 * Get generated file metadata.
321 */
322 public function getMetadata(string $nsid): array
323 {
324 $document = $this->schemaLoader->load($nsid);
325
326 $namespace = $this->namespaceResolver->resolveNamespace($document->getNsid());
327 $className = $this->namespaceResolver->resolveClassName($document->getNsid());
328
329 return [
330 'nsid' => $nsid,
331 'namespace' => $namespace,
332 'className' => $className,
333 'fullyQualifiedName' => $namespace.'\\'.$className,
334 'type' => $document->isRecord() ? 'record' : 'object',
335 ];
336 }
337
338 /**
339 * Set output options.
340 */
341 public function setOptions(array $options): void
342 {
343 if (isset($options['baseNamespace'])) {
344 $this->baseNamespace = rtrim($options['baseNamespace'], '\\');
345 $this->namespaceResolver = new NamespaceResolver($this->baseNamespace);
346 }
347
348 if (isset($options['outputDirectory'])) {
349 $this->outputDirectory = rtrim($options['outputDirectory'], '/');
350 }
351 }
352}