1use mir_types::{ArrayKey, Atomic, Type, Variance};
2use std::sync::Arc;
5
6use indexmap::IndexMap;
7use phpdoc_parser::{body_text, parse as parse_phpdoc};
8
9pub struct DocblockParser;
14
15impl DocblockParser {
16 pub fn parse(text: &str) -> ParsedDocblock {
17 let doc = parse_phpdoc(text);
18 let mut result = ParsedDocblock {
19 description: extract_description(text),
20 ..Default::default()
21 };
22
23 for tag in &doc.tags {
24 match tag.name.as_str() {
25 "param-out" | "psalm-param-out" | "phpstan-param-out" => {
26 if let Some(body_str) = body_text(&tag.body) {
27 if let Some((ty_s, name)) = parse_param_line(&body_str) {
28 if let Some(msg) = validate_type_str(&ty_s, "param-out") {
29 result.invalid_annotations.push(msg);
30 } else {
31 result.out_params.push((
32 name.trim_start_matches('$').to_string(),
33 parse_type_string(&ty_s),
34 ));
35 }
36 }
37 }
38 }
39 "param" | "psalm-param" | "phpstan-param" => {
40 if let Some(body_str) = body_text(&tag.body) {
41 if let Some((ty_s, name)) = parse_param_line(&body_str) {
42 if is_inside_generics(&ty_s) {
44 if let Some(msg) = validate_type_str(&body_str, "param") {
46 result.invalid_annotations.push(msg);
47 }
48 } else if let Some(msg) = validate_type_str(&ty_s, "param") {
49 result.invalid_annotations.push(msg);
51 } else {
52 result.params.push((
53 name.trim_start_matches('$').to_string(),
54 parse_type_string(&ty_s),
55 ));
56 }
57 } else if let Some(msg) = validate_type_str(&body_str, "param") {
58 result.invalid_annotations.push(msg);
60 }
61 }
62 }
63 "return" | "psalm-return" | "phpstan-return" => {
64 if let Some(body_str) = body_text(&tag.body) {
65 let ty_s = extract_return_type(&body_str);
66 if let Some(msg) = validate_type_str(&ty_s, "return") {
67 result.invalid_annotations.push(msg);
68 }
69 result.return_type = Some(parse_type_string(&ty_s));
70 }
71 }
72 "var" => {
73 if let Some(body_str) = body_text(&tag.body) {
74 if let Some((ty_s, name)) = parse_param_line(&body_str) {
75 if let Some(msg) = validate_type_str(&ty_s, "var") {
76 result.invalid_annotations.push(msg);
77 }
78 result.var_type = Some(parse_type_string(&ty_s));
79 result.var_name = Some(name.trim_start_matches('$').to_string());
80 } else {
81 let ty_s = extract_type_prefix(body_str.trim());
85 if let Some(msg) = validate_type_str(ty_s, "var") {
86 result.invalid_annotations.push(msg);
87 }
88 result.var_type = Some(parse_type_string(ty_s));
89 }
90 }
91 }
92 "throws" => {
93 if let Some(body_str) = body_text(&tag.body) {
94 let class = body_str.split_whitespace().next().unwrap_or("").to_string();
95 if !class.is_empty() {
96 result.throws.push(class);
97 }
98 }
99 }
100 "deprecated" => {
101 result.is_deprecated = true;
102 result.deprecated = Some(body_text(&tag.body).unwrap_or_default().to_string());
103 }
104 "template" => {
105 if let Some((name, bound)) =
106 parse_template_line(tag.name.as_str(), body_text(&tag.body))
107 {
108 if let Some(b) = &bound {
109 if let Some(msg) = validate_type_str(b, "template") {
110 result.invalid_annotations.push(msg);
111 }
112 }
113 result.templates.push((
114 name,
115 bound.map(|b| parse_type_string(&b)),
116 Variance::Invariant,
117 ));
118 }
119 }
120 "template-covariant" => {
121 if let Some((name, bound)) =
122 parse_template_line(tag.name.as_str(), body_text(&tag.body))
123 {
124 if let Some(b) = &bound {
125 if let Some(msg) = validate_type_str(b, "template-covariant") {
126 result.invalid_annotations.push(msg);
127 }
128 }
129 result.templates.push((
130 name,
131 bound.map(|b| parse_type_string(&b)),
132 Variance::Covariant,
133 ));
134 }
135 }
136 "template-contravariant" => {
137 if let Some((name, bound)) =
138 parse_template_line(tag.name.as_str(), body_text(&tag.body))
139 {
140 if let Some(b) = &bound {
141 if let Some(msg) = validate_type_str(b, "template-contravariant") {
142 result.invalid_annotations.push(msg);
143 }
144 }
145 result.templates.push((
146 name,
147 bound.map(|b| parse_type_string(&b)),
148 Variance::Contravariant,
149 ));
150 }
151 }
152 "extends" | "template-extends" | "phpstan-extends" => {
153 if let Some(body_str) = body_text(&tag.body) {
154 result.extends = Some(parse_type_string(body_str.trim()));
155 }
156 }
157 "implements" | "template-implements" | "phpstan-implements" => {
158 if let Some(body_str) = body_text(&tag.body) {
159 result.implements.push(parse_type_string(body_str.trim()));
160 }
161 }
162 "assert" | "psalm-assert" | "phpstan-assert" => {
163 if let Some(body_str) = body_text(&tag.body) {
164 if let Some((ty_str, name)) = parse_param_line(&body_str) {
165 result.assertions.push((name, parse_type_string(&ty_str)));
166 }
167 }
168 }
169 "if-this-is" | "psalm-if-this-is" | "phpstan-if-this-is" => {
170 if let Some(body_str) = body_text(&tag.body) {
171 let trimmed = body_str.trim();
172 if !trimmed.is_empty() {
173 result.if_this_is = Some(parse_type_string(trimmed));
174 }
175 }
176 }
177 "suppress" | "psalm-suppress" => {
178 if let Some(body_str) = body_text(&tag.body) {
179 for rule in body_str.split([',', ' ']) {
180 let rule = rule.trim().to_string();
181 if !rule.is_empty() {
182 result.suppressed_issues.push(rule);
183 }
184 }
185 }
186 }
187 "see" => {
188 if let Some(body_str) = body_text(&tag.body) {
189 result.see.push(body_str.to_string());
190 }
191 }
192 "link" => {
193 if let Some(body_str) = body_text(&tag.body) {
194 result.see.push(body_str.to_string());
195 }
196 }
197 "mixin" => {
198 if let Some(body_str) = body_text(&tag.body) {
199 let base_class =
200 body_str.split('<').next().unwrap_or(&body_str).to_string();
201 result.mixins.push(base_class);
202 }
203 }
204 "property" => {
205 if let Some(body_str) = body_text(&tag.body) {
206 if let Some((ty_str, name)) = parse_param_line(&body_str) {
207 result.properties.push(DocProperty {
208 type_hint: ty_str,
209 name: name.trim_start_matches('$').to_string(),
210 read_only: false,
211 write_only: false,
212 });
213 }
214 }
215 }
216 "property-read" => {
217 if let Some(body_str) = body_text(&tag.body) {
218 if let Some((ty_str, name)) = parse_param_line(&body_str) {
219 result.properties.push(DocProperty {
220 type_hint: ty_str,
221 name: name.trim_start_matches('$').to_string(),
222 read_only: true,
223 write_only: false,
224 });
225 }
226 }
227 }
228 "property-write" => {
229 if let Some(body_str) = body_text(&tag.body) {
230 if let Some((ty_str, name)) = parse_param_line(&body_str) {
231 result.properties.push(DocProperty {
232 type_hint: ty_str,
233 name: name.trim_start_matches('$').to_string(),
234 read_only: false,
235 write_only: true,
236 });
237 }
238 }
239 }
240 "method" | "psalm-method" => {
241 let body_str = body_text(&tag.body).unwrap_or_default().trim().to_string();
242 if let Some(err) = validate_method_body(&body_str) {
243 result.invalid_annotations.push(err);
244 } else if let Some(m) = parse_method_line(&body_str) {
245 result.methods.push(m);
246 }
247 }
248 "psalm-type" | "phpstan-type" => {
249 if let Some(body_str) = body_text(&tag.body) {
250 if let Some((name, type_expr)) = body_str.split_once('=') {
251 result.type_aliases.push(DocTypeAlias {
252 name: name.trim().to_string(),
253 type_expr: type_expr.trim().to_string(),
254 });
255 }
256 }
257 }
258 "psalm-import-type" | "phpstan-import-type" => {
259 if let Some(body_str) = body_text(&tag.body) {
260 if let Some(import) = parse_import_type(&body_str) {
261 result.import_types.push(import);
262 }
263 }
264 }
265 "since" if result.since.is_none() => {
266 if let Some(body_str) = body_text(&tag.body) {
267 let v = body_str.split_whitespace().next().unwrap_or("");
268 if !v.is_empty() {
269 result.since = Some(v.to_string());
270 }
271 }
272 }
273 "removed" if result.removed.is_none() => {
274 if let Some(body_str) = body_text(&tag.body) {
275 let v = body_str.split_whitespace().next().unwrap_or("");
276 if !v.is_empty() {
277 result.removed = Some(v.to_string());
278 }
279 }
280 }
281 "internal" => result.is_internal = true,
282 "pure" => result.is_pure = true,
283 "seal-properties" | "psalm-seal-properties" => result.seal_properties = true,
284 "no-named-arguments" => result.no_named_arguments = true,
285 "immutable" | "psalm-immutable" => result.is_immutable = true,
286 "readonly" => result.is_readonly = true,
287 "final" => result.is_final = true,
288 "inheritDoc" | "inheritdoc" => result.is_inherit_doc = true,
289 "api" | "psalm-api" => result.is_api = true,
290 "psalm-assert-if-true" | "phpstan-assert-if-true" => {
291 if let Some(body_str) = body_text(&tag.body) {
292 if let Some((ty_str, name)) = parse_param_line(&body_str) {
293 result
294 .assertions_if_true
295 .push((name, parse_type_string(&ty_str)));
296 }
297 }
298 }
299 "psalm-assert-if-false" | "phpstan-assert-if-false" => {
300 if let Some(body_str) = body_text(&tag.body) {
301 if let Some((ty_str, name)) = parse_param_line(&body_str) {
302 result
303 .assertions_if_false
304 .push((name, parse_type_string(&ty_str)));
305 }
306 }
307 }
308 "psalm-property" => {
309 if let Some(body_str) = body_text(&tag.body) {
310 if let Some((ty_str, name)) = parse_param_line(&body_str) {
311 result.properties.push(DocProperty {
312 type_hint: ty_str,
313 name,
314 read_only: false,
315 write_only: false,
316 });
317 }
318 }
319 }
320 "psalm-property-read" => {
321 if let Some(body_str) = body_text(&tag.body) {
322 if let Some((ty_str, name)) = parse_param_line(&body_str) {
323 result.properties.push(DocProperty {
324 type_hint: ty_str,
325 name,
326 read_only: true,
327 write_only: false,
328 });
329 }
330 }
331 }
332 "psalm-property-write" => {
333 if let Some(body_str) = body_text(&tag.body) {
334 if let Some((ty_str, name)) = parse_param_line(&body_str) {
335 result.properties.push(DocProperty {
336 type_hint: ty_str,
337 name,
338 read_only: false,
339 write_only: true,
340 });
341 }
342 }
343 }
344 "psalm-require-extends" | "phpstan-require-extends" => {
345 if let Some(body_str) = body_text(&tag.body) {
346 let cls = body_str
347 .split_whitespace()
348 .next()
349 .unwrap_or("")
350 .trim()
351 .to_string();
352 if !cls.is_empty() {
353 result.require_extends.push(cls);
354 }
355 }
356 }
357 "psalm-require-implements" | "phpstan-require-implements" => {
358 if let Some(body_str) = body_text(&tag.body) {
359 let cls = body_str
360 .split_whitespace()
361 .next()
362 .unwrap_or("")
363 .trim()
364 .to_string();
365 if !cls.is_empty() {
366 result.require_implements.push(cls);
367 }
368 }
369 }
370 "mir-check" => {
371 if let Some(body_str) = body_text(&tag.body) {
372 if let Some((var_part, type_part)) = body_str.split_once(" is ") {
373 let var_name = var_part.trim().trim_start_matches('$').to_string();
374 let type_string = type_part.trim().to_string();
375 if !var_name.is_empty() && !type_string.is_empty() {
376 result.mir_checks.push((var_name, type_string));
377 }
378 }
379 }
380 }
381 "trace" => {
382 if let Some(body_str) = body_text(&tag.body) {
383 for part in body_str.split([',', ' ']) {
385 let var_name = part.trim().trim_start_matches('$').to_string();
386 if !var_name.is_empty() {
387 result.trace_vars.push(var_name);
388 }
389 }
390 }
391 }
392 "taint-sink" => {
393 if let Some(body_str) = body_text(&tag.body) {
394 let mut tokens = body_str.split_whitespace();
396 if let Some(kind) = tokens.next() {
397 let kind = kind.to_string();
398 for param_token in tokens {
399 let param = param_token.trim_start_matches('$').to_string();
400 if !param.is_empty() {
401 result.taint_sinks.push((param, kind.clone()));
402 }
403 }
404 }
405 }
406 }
407 _ => {}
408 }
409 }
410
411 if text.to_ascii_lowercase().contains("{@inheritdoc}") {
412 result.is_inherit_doc = true;
413 }
414
415 result
416 }
417}
418
419#[derive(Debug, Default, Clone)]
424pub struct DocProperty {
425 pub type_hint: String,
426 pub name: String, pub read_only: bool, pub write_only: bool, }
430
431#[derive(Debug, Default, Clone)]
432pub struct DocMethod {
433 pub return_type: String,
434 pub name: String,
435 pub is_static: bool,
436 pub params: Vec<DocMethodParam>,
437}
438
439#[derive(Debug, Default, Clone)]
440pub struct DocMethodParam {
441 pub name: String,
442 pub type_hint: String,
443 pub is_variadic: bool,
444 pub is_byref: bool,
445 pub is_optional: bool,
446}
447
448#[derive(Debug, Default, Clone)]
449pub struct DocTypeAlias {
450 pub name: String,
451 pub type_expr: String,
452}
453
454#[derive(Debug, Default, Clone)]
455pub struct DocImportType {
456 pub original: String,
458 pub local: String,
460 pub from_class: String,
462}
463
464#[derive(Debug, Default, Clone)]
469pub struct ParsedDocblock {
470 pub params: Vec<(String, Type)>,
472 pub out_params: Vec<(String, Type)>,
475 pub return_type: Option<Type>,
477 pub var_type: Option<Type>,
479 pub var_name: Option<String>,
481 pub templates: Vec<(String, Option<Type>, Variance)>,
483 pub extends: Option<Type>,
485 pub implements: Vec<Type>,
487 pub throws: Vec<String>,
489 pub assertions: Vec<(String, Type)>,
491 pub assertions_if_true: Vec<(String, Type)>,
493 pub assertions_if_false: Vec<(String, Type)>,
495 pub suppressed_issues: Vec<String>,
497 pub is_deprecated: bool,
498 pub is_internal: bool,
499 pub is_pure: bool,
500 pub no_named_arguments: bool,
501 pub is_immutable: bool,
502 pub is_readonly: bool,
503 pub is_api: bool,
504 pub is_final: bool,
506 pub is_inherit_doc: bool,
509 pub description: String,
511 pub deprecated: Option<String>,
513 pub see: Vec<String>,
515 pub mixins: Vec<String>,
517 pub properties: Vec<DocProperty>,
519 pub methods: Vec<DocMethod>,
521 pub type_aliases: Vec<DocTypeAlias>,
523 pub import_types: Vec<DocImportType>,
525 pub require_extends: Vec<String>,
527 pub require_implements: Vec<String>,
529 pub since: Option<String>,
531 pub removed: Option<String>,
533 pub invalid_annotations: Vec<String>,
535 pub mir_checks: Vec<(String, String)>,
537 pub trace_vars: Vec<String>,
539 pub taint_sinks: Vec<(String, String)>,
541 pub seal_properties: bool,
543 pub if_this_is: Option<Type>,
547}
548
549impl ParsedDocblock {
550 pub fn get_param_type(&self, name: &str) -> Option<&Type> {
556 let name = name.trim_start_matches('$');
557 self.params
558 .iter()
559 .rfind(|(n, _)| n.trim_start_matches('$') == name)
560 .map(|(_, ty)| ty)
561 }
562
563 pub fn get_out_param_type(&self, name: &str) -> Option<&Type> {
566 let name = name.trim_start_matches('$');
567 self.out_params
568 .iter()
569 .rfind(|(n, _)| n.trim_start_matches('$') == name)
570 .map(|(_, ty)| ty)
571 }
572}
573
574#[cfg(test)]
579mod tests;
580mod types;
584mod validate;
585
586use types::*;
587use validate::*;
588
589pub(crate) use types::parse_type_string;