Parse and validate AT Protocol Lexicons with DTO generation for Laravel
1<?php
2
3namespace SocialDept\AtpSchema\Generator;
4
5use SocialDept\AtpSchema\Data\LexiconDocument;
6
7class DocBlockGenerator
8{
9 /**
10 * Type mapper instance.
11 */
12 protected TypeMapper $typeMapper;
13
14 /**
15 * Create a new DocBlockGenerator.
16 */
17 public function __construct(?TypeMapper $typeMapper = null)
18 {
19 $this->typeMapper = $typeMapper ?? new TypeMapper();
20 }
21
22 /**
23 * Generate a class-level docblock with rich annotations.
24 *
25 * @param array<string, mixed> $definition
26 */
27 public function generateClassDocBlock(
28 LexiconDocument $document,
29 array $definition
30 ): string {
31 $lines = ['/**'];
32
33 // Add generated code warning
34 $lines[] = ' * GENERATED CODE - DO NOT EDIT';
35 $lines[] = ' *';
36
37 // Add description
38 if ($document->description) {
39 $lines = array_merge($lines, $this->wrapDescription($document->description));
40 $lines[] = ' *';
41 }
42
43 // Add lexicon metadata
44 $lines[] = ' * Lexicon: '.$document->getNsid();
45
46 if (isset($definition['type'])) {
47 $lines[] = ' * Type: '.$definition['type'];
48 }
49
50 // Add @property tags for magic access
51 $properties = $definition['properties'] ?? [];
52 $required = $definition['required'] ?? [];
53
54 if (! empty($properties)) {
55 $lines[] = ' *';
56 foreach ($properties as $name => $propDef) {
57 $isRequired = in_array($name, $required);
58 $docType = $this->typeMapper->toPhpDocType($propDef, ! $isRequired);
59 $desc = $propDef['description'] ?? '';
60
61 if ($desc) {
62 $lines[] = ' * @property '.$docType.' $'.$name.' '.$desc;
63 } else {
64 $lines[] = ' * @property '.$docType.' $'.$name;
65 }
66 }
67 }
68
69 // Add validation constraints as annotations
70 if (! empty($properties)) {
71 $constraints = $this->extractConstraints($properties, $required);
72 if (! empty($constraints)) {
73 $lines[] = ' *';
74 $lines[] = ' * Constraints:';
75 foreach ($constraints as $constraint) {
76 $lines[] = ' * - '.$constraint;
77 }
78 }
79 }
80
81 $lines[] = ' */';
82
83 return implode("\n", $lines);
84 }
85
86 /**
87 * Generate a property-level docblock.
88 *
89 * @param array<string, mixed> $definition
90 */
91 public function generatePropertyDocBlock(
92 string $name,
93 array $definition,
94 bool $isRequired
95 ): string {
96 $lines = [' /**'];
97
98 // Add description
99 if (isset($definition['description'])) {
100 $lines = array_merge($lines, $this->wrapDescription($definition['description'], ' * '));
101 $lines[] = ' *';
102 }
103
104 // Add type annotation
105 $docType = $this->typeMapper->toPhpDocType($definition, ! $isRequired);
106 $lines[] = ' * @var '.$docType;
107
108 // Add validation constraints
109 $constraints = $this->extractPropertyConstraints($definition);
110 if (! empty($constraints)) {
111 $lines[] = ' *';
112 foreach ($constraints as $constraint) {
113 $lines[] = ' * '.$constraint;
114 }
115 }
116
117 $lines[] = ' */';
118
119 return implode("\n", $lines);
120 }
121
122 /**
123 * Generate a method-level docblock.
124 *
125 * @param array<array{name: string, type: string, description?: string}> $params
126 */
127 public function generateMethodDocBlock(
128 ?string $description,
129 ?string $returnType,
130 array $params = [],
131 ?string $throws = null
132 ): string {
133 $lines = [' /**'];
134
135 // Add description
136 if ($description) {
137 $lines = array_merge($lines, $this->wrapDescription($description, ' * '));
138 }
139
140 // Add blank line if we have params or return
141 if ((! empty($params) || $returnType) && $description) {
142 $lines[] = ' *';
143 }
144
145 // Add parameters
146 foreach ($params as $param) {
147 $desc = $param['description'] ?? '';
148 if ($desc) {
149 $lines[] = ' * @param '.$param['type'].' $'.$param['name'].' '.$desc;
150 } else {
151 $lines[] = ' * @param '.$param['type'].' $'.$param['name'];
152 }
153 }
154
155 // Add return type
156 if ($returnType && $returnType !== 'void') {
157 $lines[] = ' * @return '.$returnType;
158 }
159
160 // Add throws
161 if ($throws) {
162 $lines[] = ' * @throws '.$throws;
163 }
164
165 $lines[] = ' */';
166
167 return implode("\n", $lines);
168 }
169
170 /**
171 * Wrap a long description into multiple lines.
172 *
173 * @return array<string>
174 */
175 protected function wrapDescription(string $description, string $prefix = ' * '): array
176 {
177 $maxWidth = 80 - strlen($prefix);
178 $words = explode(' ', $description);
179 $lines = [];
180 $currentLine = '';
181
182 foreach ($words as $word) {
183 if (empty($currentLine)) {
184 $currentLine = $word;
185 } elseif (strlen($currentLine.' '.$word) <= $maxWidth) {
186 $currentLine .= ' '.$word;
187 } else {
188 $lines[] = $prefix.$currentLine;
189 $currentLine = $word;
190 }
191 }
192
193 if (! empty($currentLine)) {
194 $lines[] = $prefix.$currentLine;
195 }
196
197 return $lines;
198 }
199
200 /**
201 * Extract validation constraints from properties.
202 *
203 * @param array<string, array<string, mixed>> $properties
204 * @param array<string> $required
205 * @return array<string>
206 */
207 protected function extractConstraints(array $properties, array $required): array
208 {
209 $constraints = [];
210
211 // Required fields
212 if (! empty($required)) {
213 $constraints[] = 'Required: '.implode(', ', $required);
214 }
215
216 // Property-specific constraints
217 foreach ($properties as $name => $definition) {
218 $propConstraints = $this->extractPropertyConstraints($definition);
219 foreach ($propConstraints as $constraint) {
220 $constraints[] = $name.': '.trim(str_replace('@constraint', '', $constraint));
221 }
222 }
223
224 return $constraints;
225 }
226
227 /**
228 * Extract validation constraints for a single property.
229 *
230 * @param array<string, mixed> $definition
231 * @return array<string>
232 */
233 protected function extractPropertyConstraints(array $definition): array
234 {
235 $constraints = [];
236
237 // String constraints
238 if (isset($definition['maxLength'])) {
239 $constraints[] = '@constraint Max length: '.$definition['maxLength'];
240 }
241
242 if (isset($definition['minLength'])) {
243 $constraints[] = '@constraint Min length: '.$definition['minLength'];
244 }
245
246 if (isset($definition['maxGraphemes'])) {
247 $constraints[] = '@constraint Max graphemes: '.$definition['maxGraphemes'];
248 }
249
250 if (isset($definition['minGraphemes'])) {
251 $constraints[] = '@constraint Min graphemes: '.$definition['minGraphemes'];
252 }
253
254 // Number constraints
255 if (isset($definition['maximum'])) {
256 $constraints[] = '@constraint Maximum: '.$definition['maximum'];
257 }
258
259 if (isset($definition['minimum'])) {
260 $constraints[] = '@constraint Minimum: '.$definition['minimum'];
261 }
262
263 // Array constraints
264 if (isset($definition['maxItems'])) {
265 $constraints[] = '@constraint Max items: '.$definition['maxItems'];
266 }
267
268 if (isset($definition['minItems'])) {
269 $constraints[] = '@constraint Min items: '.$definition['minItems'];
270 }
271
272 // Enum constraints
273 if (isset($definition['enum'])) {
274 $values = is_array($definition['enum']) ? implode(', ', $definition['enum']) : $definition['enum'];
275 $constraints[] = '@constraint Enum: '.$values;
276 }
277
278 // Format constraints
279 if (isset($definition['format'])) {
280 $constraints[] = '@constraint Format: '.$definition['format'];
281 }
282
283 // Const constraint
284 if (isset($definition['const'])) {
285 $value = is_bool($definition['const']) ? ($definition['const'] ? 'true' : 'false') : $definition['const'];
286 $constraints[] = '@constraint Const: '.$value;
287 }
288
289 return $constraints;
290 }
291
292 /**
293 * Generate a simple docblock.
294 */
295 public function generateSimple(string $description): string
296 {
297 return " /**\n * {$description}\n */";
298 }
299
300 /**
301 * Generate a one-line docblock.
302 */
303 public function generateOneLine(string $text): string
304 {
305 return " /** {$text} */";
306 }
307}