Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1<?php
2
3namespace SocialDept\AtpSchema\Generator;
4
5class EnumGenerator
6{
7 /**
8 * Naming converter for class/enum names.
9 */
10 protected NamingConverter $naming;
11
12 /**
13 * File writer for writing generated files.
14 */
15 protected FileWriter $fileWriter;
16
17 /**
18 * Base namespace for generated enums.
19 */
20 protected string $baseNamespace;
21
22 /**
23 * Output directory for generated files.
24 */
25 protected string $outputDirectory;
26
27 /**
28 * Create a new EnumGenerator.
29 */
30 public function __construct(
31 string $baseNamespace = 'App\\Lexicons',
32 string $outputDirectory = 'app/Lexicons',
33 ?NamingConverter $naming = null,
34 ?FileWriter $fileWriter = null
35 ) {
36 $this->baseNamespace = rtrim($baseNamespace, '\\');
37 $this->outputDirectory = rtrim($outputDirectory, '/');
38 $this->naming = $naming ?? new NamingConverter($baseNamespace);
39 $this->fileWriter = $fileWriter ?? new FileWriter();
40 }
41
42 /**
43 * Generate PHP enum from a string type with knownValues.
44 *
45 * @param string $nsid The NSID (e.g., "com.atproto.moderation.defs#reasonType")
46 * @param array $definition The lexicon definition
47 * @return string The generated enum code
48 */
49 public function generate(string $nsid, array $definition): string
50 {
51 $type = $definition['type'] ?? null;
52
53 if ($type !== 'string' || ! isset($definition['knownValues'])) {
54 throw new \InvalidArgumentException("Definition must be a string type with knownValues");
55 }
56
57 // Extract namespace and enum name from NSID
58 [$baseNsid, $defName] = $this->parseNsid($nsid);
59
60 $namespace = $this->naming->nsidToNamespace($baseNsid);
61 $enumName = $this->naming->toClassName($defName);
62
63 $description = $definition['description'] ?? '';
64 $knownValues = $definition['knownValues'];
65
66 // Generate enum cases
67 $cases = $this->generateCases($knownValues);
68
69 return $this->renderEnum($namespace, $enumName, $description, $cases);
70 }
71
72 /**
73 * Parse NSID into base NSID and definition name.
74 *
75 * @return array{0: string, 1: string}
76 */
77 protected function parseNsid(string $nsid): array
78 {
79 if (str_contains($nsid, '#')) {
80 [$baseNsid, $defName] = explode('#', $nsid, 2);
81
82 return [$baseNsid, $defName];
83 }
84
85 // If no fragment, use the last part of the NSID as the enum name
86 $parts = explode('.', $nsid);
87 $defName = array_pop($parts);
88 $baseNsid = implode('.', $parts);
89
90 return [$baseNsid, $defName];
91 }
92
93 /**
94 * Generate enum cases from known values.
95 *
96 * @param array<string> $knownValues
97 * @return array<array{name: string, value: string}>
98 */
99 protected function generateCases(array $knownValues): array
100 {
101 $cases = [];
102 $usedNames = [];
103
104 foreach ($knownValues as $value) {
105 // Extract the case name from the value
106 // e.g., "com.atproto.moderation.defs#reasonSpam" -> "REASON_SPAM"
107 $caseName = $this->valueToCaseName($value);
108
109 // Handle duplicate case names by prepending the source namespace
110 if (isset($usedNames[$caseName])) {
111 // Get the source namespace (e.g., "tools.ozone.report" from "tools.ozone.report.defs#reasonAppeal")
112 if (str_contains($value, '#')) {
113 $nsid = explode('#', $value)[0];
114 $parts = explode('.', $nsid);
115 // Use the second-to-last part as a differentiator (e.g., "Ozone", "Report")
116 $diff = ucfirst($parts[count($parts) - 2] ?? $parts[count($parts) - 1]);
117 $caseName = $diff . $caseName;
118 }
119 }
120
121 $usedNames[$caseName] = true;
122 $cases[] = [
123 'name' => $caseName,
124 'value' => $value,
125 ];
126 }
127
128 return $cases;
129 }
130
131 /**
132 * Convert a known value to an enum case name.
133 */
134 protected function valueToCaseName(string $value): string
135 {
136 // If it's an NSID reference, extract the fragment part
137 if (str_contains($value, '#')) {
138 $value = explode('#', $value)[1];
139 }
140
141 // Remove leading symbols (!, etc.)
142 $value = ltrim($value, '!@#$%^&*()-_=+[]{}|;:,.<>?/~`');
143
144 // Convert kebab-case and snake_case to PascalCase
145 // e.g., "no-promote" -> "NoPromote", "dmca-violation" -> "DmcaViolation"
146 $value = str_replace(['-', '_'], ' ', $value);
147 $value = ucwords($value);
148 $value = str_replace(' ', '', $value);
149
150 // Ensure first character is uppercase
151 return ucfirst($value);
152 }
153
154 /**
155 * Render the enum code.
156 *
157 * @param array<array{name: string, value: string}> $cases
158 */
159 protected function renderEnum(string $namespace, string $enumName, string $description, array $cases): string
160 {
161 $code = "<?php\n\n";
162 $code .= "namespace {$namespace};\n\n";
163
164 $code .= "/**\n";
165 $code .= " * GENERATED CODE - DO NOT EDIT\n";
166
167 if ($description) {
168 $code .= " *\n";
169 $code .= " * " . str_replace("\n", "\n * ", $description) . "\n";
170 }
171
172 $code .= " */\n";
173
174 $code .= "enum {$enumName}: string\n";
175 $code .= "{\n";
176
177 foreach ($cases as $case) {
178 $code .= " case {$case['name']} = '{$case['value']}';\n";
179 }
180
181 $code .= "}\n";
182
183 return $code;
184 }
185
186 /**
187 * Generate and save enum to disk.
188 */
189 public function generateAndSave(string $nsid, array $definition): string
190 {
191 $code = $this->generate($nsid, $definition);
192
193 [$baseNsid, $defName] = $this->parseNsid($nsid);
194 $namespace = $this->naming->nsidToNamespace($baseNsid);
195 $enumName = $this->naming->toClassName($defName);
196
197 $filePath = $this->getFilePath($namespace, $enumName);
198 $this->fileWriter->write($filePath, $code);
199
200 return $filePath;
201 }
202
203 /**
204 * Get the file path for a generated enum.
205 */
206 protected function getFilePath(string $namespace, string $enumName): string
207 {
208 // Remove base namespace from full namespace
209 $relativePath = str_replace($this->baseNamespace.'\\', '', $namespace);
210 $relativePath = str_replace('\\', '/', $relativePath);
211
212 return $this->outputDirectory.'/'.$relativePath.'/'.$enumName.'.php';
213 }
214}