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" | "psalm-var" | "phpstan-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 first_word = body_str.split_whitespace().next().unwrap_or("");
95 for class in first_word.split('|') {
96 if !class.is_empty() {
97 result.throws.push(class.to_string());
98 }
99 }
100 }
101 }
102 "deprecated" => {
103 result.is_deprecated = true;
104 result.deprecated = Some(body_text(&tag.body).unwrap_or_default().to_string());
105 }
106 "template" | "psalm-template" | "phpstan-template" => {
107 if let Some((name, bound, default)) =
108 parse_template_line(tag.name.as_str(), body_text(&tag.body))
109 {
110 if let Some(msg) = validate_type_str(&name, "template") {
111 result.invalid_annotations.push(msg);
112 }
113 if let Some(b) = &bound {
114 if let Some(msg) = validate_type_str(b, "template") {
115 result.invalid_annotations.push(msg);
116 }
117 }
118 result.templates.push((
119 name,
120 bound.map(|b| parse_type_string(&b)),
121 Variance::Invariant,
122 default.map(|d| parse_type_string(&d)),
123 ));
124 }
125 }
126 "template-covariant"
127 | "psalm-template-covariant"
128 | "phpstan-template-covariant" => {
129 if let Some((name, bound, default)) =
130 parse_template_line(tag.name.as_str(), body_text(&tag.body))
131 {
132 if let Some(msg) = validate_type_str(&name, "template-covariant") {
133 result.invalid_annotations.push(msg);
134 }
135 if let Some(b) = &bound {
136 if let Some(msg) = validate_type_str(b, "template-covariant") {
137 result.invalid_annotations.push(msg);
138 }
139 }
140 result.templates.push((
141 name,
142 bound.map(|b| parse_type_string(&b)),
143 Variance::Covariant,
144 default.map(|d| parse_type_string(&d)),
145 ));
146 }
147 }
148 "template-contravariant"
149 | "psalm-template-contravariant"
150 | "phpstan-template-contravariant" => {
151 if let Some((name, bound, default)) =
152 parse_template_line(tag.name.as_str(), body_text(&tag.body))
153 {
154 if let Some(msg) = validate_type_str(&name, "template-contravariant") {
155 result.invalid_annotations.push(msg);
156 }
157 if let Some(b) = &bound {
158 if let Some(msg) = validate_type_str(b, "template-contravariant") {
159 result.invalid_annotations.push(msg);
160 }
161 }
162 result.templates.push((
163 name,
164 bound.map(|b| parse_type_string(&b)),
165 Variance::Contravariant,
166 default.map(|d| parse_type_string(&d)),
167 ));
168 }
169 }
170 "extends" | "template-extends" | "phpstan-extends" => {
171 if let Some(body_str) = body_text(&tag.body) {
172 let trimmed = body_str.trim();
173 if let Some(msg) = validate_type_str(trimmed, "extends") {
174 result.invalid_annotations.push(msg);
175 }
176 result.extends.push(parse_type_string(trimmed));
177 }
178 }
179 "implements" | "template-implements" | "phpstan-implements" => {
180 if let Some(body_str) = body_text(&tag.body) {
181 let trimmed = body_str.trim();
182 if let Some(msg) = validate_type_str(trimmed, "implements") {
183 result.invalid_annotations.push(msg);
184 }
185 result.implements.push(parse_type_string(trimmed));
186 }
187 }
188 "use" | "template-use" | "psalm-use" | "phpstan-use" => {
189 if let Some(body_str) = body_text(&tag.body) {
190 let trimmed = body_str.trim();
191 if let Some(msg) = validate_type_str(trimmed, "use") {
192 result.invalid_annotations.push(msg);
193 }
194 result.uses.push(parse_type_string(trimmed));
195 }
196 }
197 "assert" | "psalm-assert" | "phpstan-assert" => {
198 if let Some(body_str) = body_text(&tag.body) {
199 if let Some((ty_str, name)) = parse_param_line(&body_str) {
200 let (ty, negated) = parse_assertion_type(&ty_str);
201 result.assertions.push((name, ty, negated));
202 }
203 }
204 }
205 "if-this-is" | "psalm-if-this-is" | "phpstan-if-this-is" => {
206 if let Some(body_str) = body_text(&tag.body) {
207 let trimmed = body_str.trim();
208 if !trimmed.is_empty() {
209 result.if_this_is = Some(parse_type_string(trimmed));
210 }
211 }
212 }
213 "self-out" | "psalm-self-out" | "phpstan-self-out" => {
214 if let Some(body_str) = body_text(&tag.body) {
215 let trimmed = body_str.trim();
216 if !trimmed.is_empty() {
217 result.self_out = Some(parse_type_string(trimmed));
218 }
219 }
220 }
221 "suppress" | "psalm-suppress" => {
222 if let Some(body_str) = body_text(&tag.body) {
223 for rule in body_str.split([',', ' ']) {
224 let rule = rule.trim().to_string();
225 if !rule.is_empty() {
226 result.suppressed_issues.push(rule);
227 }
228 }
229 }
230 }
231 "see" => {
232 if let Some(body_str) = body_text(&tag.body) {
233 result.see.push(body_str.to_string());
234 }
235 }
236 "link" => {
237 if let Some(body_str) = body_text(&tag.body) {
238 result.see.push(body_str.to_string());
239 }
240 }
241 "mixin" => {
242 if let Some(body_str) = body_text(&tag.body) {
243 let base_class =
244 body_str.split('<').next().unwrap_or(&body_str).to_string();
245 result.mixins.push(base_class);
246 }
247 }
248 "property" => {
249 if let Some(body_str) = body_text(&tag.body) {
250 if let Some((ty_str, name)) = parse_param_line(&body_str) {
251 result.properties.push(DocProperty {
252 type_hint: ty_str,
253 name: name.trim_start_matches('$').to_string(),
254 read_only: false,
255 write_only: false,
256 });
257 }
258 }
259 }
260 "property-read" => {
261 if let Some(body_str) = body_text(&tag.body) {
262 if let Some((ty_str, name)) = parse_param_line(&body_str) {
263 result.properties.push(DocProperty {
264 type_hint: ty_str,
265 name: name.trim_start_matches('$').to_string(),
266 read_only: true,
267 write_only: false,
268 });
269 }
270 }
271 }
272 "property-write" => {
273 if let Some(body_str) = body_text(&tag.body) {
274 if let Some((ty_str, name)) = parse_param_line(&body_str) {
275 result.properties.push(DocProperty {
276 type_hint: ty_str,
277 name: name.trim_start_matches('$').to_string(),
278 read_only: false,
279 write_only: true,
280 });
281 }
282 }
283 }
284 "method" | "psalm-method" => {
285 let body_str = body_text(&tag.body).unwrap_or_default().trim().to_string();
286 if let Some(err) = validate_method_body(&body_str) {
287 result.invalid_annotations.push(err);
288 } else if let Some(m) = parse_method_line(&body_str) {
289 result.methods.push(m);
290 }
291 }
292 "psalm-type" | "phpstan-type" => {
293 if let Some(body_str) = body_text(&tag.body) {
294 if let Some((name, type_expr)) = body_str.split_once('=') {
295 result.type_aliases.push(DocTypeAlias {
296 name: name.trim().to_string(),
297 type_expr: type_expr.trim().to_string(),
298 });
299 }
300 }
301 }
302 "psalm-import-type" | "phpstan-import-type" => {
303 if let Some(body_str) = body_text(&tag.body) {
304 if let Some(import) = parse_import_type(&body_str) {
305 result.import_types.push(import);
306 }
307 }
308 }
309 "since" if result.since.is_none() => {
310 if let Some(body_str) = body_text(&tag.body) {
311 let v = body_str.split_whitespace().next().unwrap_or("");
312 if !v.is_empty() {
313 result.since = Some(v.to_string());
314 }
315 }
316 }
317 "removed" if result.removed.is_none() => {
318 if let Some(body_str) = body_text(&tag.body) {
319 let v = body_str.split_whitespace().next().unwrap_or("");
320 if !v.is_empty() {
321 result.removed = Some(v.to_string());
322 }
323 }
324 }
325 "internal" => result.is_internal = true,
326 "pure" | "psalm-pure" | "phpstan-pure" => result.is_pure = true,
327 "seal-properties" | "psalm-seal-properties" => result.seal_properties = true,
328 "no-named-arguments" => result.no_named_arguments = true,
329 "mutation-free" | "psalm-mutation-free" | "phpstan-mutation-free" => {
330 result.is_mutation_free = true
331 }
332 "psalm-external-mutation-free" => result.is_external_mutation_free = true,
333 "immutable" | "psalm-immutable" => result.is_immutable = true,
334 "readonly" | "psalm-readonly" | "phpstan-readonly" => result.is_readonly = true,
335 "final" => result.is_final = true,
336 "inheritDoc" | "inheritdoc" => result.is_inherit_doc = true,
337 "api" | "psalm-api" => result.is_api = true,
338 "psalm-assert-if-true" | "phpstan-assert-if-true" => {
339 if let Some(body_str) = body_text(&tag.body) {
340 if let Some((ty_str, name)) = parse_param_line(&body_str) {
341 let (ty, negated) = parse_assertion_type(&ty_str);
342 result.assertions_if_true.push((name, ty, negated));
343 }
344 }
345 }
346 "psalm-assert-if-false" | "phpstan-assert-if-false" => {
347 if let Some(body_str) = body_text(&tag.body) {
348 if let Some((ty_str, name)) = parse_param_line(&body_str) {
349 let (ty, negated) = parse_assertion_type(&ty_str);
350 result.assertions_if_false.push((name, ty, negated));
351 }
352 }
353 }
354 "psalm-property" => {
355 if let Some(body_str) = body_text(&tag.body) {
356 if let Some((ty_str, name)) = parse_param_line(&body_str) {
357 result.properties.push(DocProperty {
358 type_hint: ty_str,
359 name,
360 read_only: false,
361 write_only: false,
362 });
363 }
364 }
365 }
366 "psalm-property-read" => {
367 if let Some(body_str) = body_text(&tag.body) {
368 if let Some((ty_str, name)) = parse_param_line(&body_str) {
369 result.properties.push(DocProperty {
370 type_hint: ty_str,
371 name,
372 read_only: true,
373 write_only: false,
374 });
375 }
376 }
377 }
378 "psalm-property-write" => {
379 if let Some(body_str) = body_text(&tag.body) {
380 if let Some((ty_str, name)) = parse_param_line(&body_str) {
381 result.properties.push(DocProperty {
382 type_hint: ty_str,
383 name,
384 read_only: false,
385 write_only: true,
386 });
387 }
388 }
389 }
390 "psalm-require-extends" | "phpstan-require-extends" => {
391 if let Some(body_str) = body_text(&tag.body) {
392 let cls = body_str
393 .split_whitespace()
394 .next()
395 .unwrap_or("")
396 .trim()
397 .to_string();
398 if !cls.is_empty() {
399 result.require_extends.push(cls);
400 }
401 }
402 }
403 "psalm-require-implements" | "phpstan-require-implements" => {
404 if let Some(body_str) = body_text(&tag.body) {
405 let cls = body_str
406 .split_whitespace()
407 .next()
408 .unwrap_or("")
409 .trim()
410 .to_string();
411 if !cls.is_empty() {
412 result.require_implements.push(cls);
413 }
414 }
415 }
416 "mir-check" => {
417 if let Some(body_str) = body_text(&tag.body) {
418 if let Some((expr_part, type_part)) = body_str.split_once(" is ") {
419 let expr_text = expr_part.trim().to_string();
423 let type_string = type_part.trim().to_string();
424 if !expr_text.is_empty() && !type_string.is_empty() {
425 result.mir_checks.push((expr_text, type_string));
426 }
427 }
428 }
429 }
430 "dataProvider" => {
431 if let Some(body_str) = body_text(&tag.body) {
432 let name = body_str
433 .trim()
434 .trim_end_matches("()")
435 .rsplit("::")
436 .next()
437 .unwrap_or("")
438 .trim();
439 if !name.is_empty() {
440 result.data_providers.push(name.to_string());
441 }
442 }
443 }
444 "trace" => {
445 if let Some(body_str) = body_text(&tag.body) {
446 for part in body_str.split([',', ' ']) {
448 let var_name = part.trim().trim_start_matches('$').to_string();
449 if !var_name.is_empty() {
450 result.trace_vars.push(var_name);
451 }
452 }
453 }
454 }
455 "taint-sink" => {
456 if let Some(body_str) = body_text(&tag.body) {
457 let mut tokens = body_str.split_whitespace();
459 if let Some(kind) = tokens.next() {
460 let kind = kind.to_string();
461 for param_token in tokens {
462 let param = param_token.trim_start_matches('$').to_string();
463 if !param.is_empty() {
464 result.taint_sinks.push((param, kind.clone()));
465 }
466 }
467 }
468 }
469 }
470 _ => {}
471 }
472 }
473
474 if text.to_ascii_lowercase().contains("{@inheritdoc}") {
475 result.is_inherit_doc = true;
476 }
477
478 result
479 }
480}
481
482#[derive(Debug, Default, Clone)]
487pub struct DocProperty {
488 pub type_hint: String,
489 pub name: String, pub read_only: bool, pub write_only: bool, }
493
494#[derive(Debug, Default, Clone)]
495pub struct DocMethod {
496 pub return_type: String,
497 pub name: String,
498 pub is_static: bool,
499 pub params: Vec<DocMethodParam>,
500}
501
502#[derive(Debug, Default, Clone)]
503pub struct DocMethodParam {
504 pub name: String,
505 pub type_hint: String,
506 pub is_variadic: bool,
507 pub is_byref: bool,
508 pub is_optional: bool,
509}
510
511#[derive(Debug, Default, Clone)]
512pub struct DocTypeAlias {
513 pub name: String,
514 pub type_expr: String,
515}
516
517#[derive(Debug, Default, Clone)]
518pub struct DocImportType {
519 pub original: String,
521 pub local: String,
523 pub from_class: String,
525}
526
527#[derive(Debug, Default, Clone)]
532pub struct ParsedDocblock {
533 pub params: Vec<(String, Type)>,
535 pub out_params: Vec<(String, Type)>,
538 pub return_type: Option<Type>,
540 pub var_type: Option<Type>,
542 pub var_name: Option<String>,
544 pub templates: Vec<(String, Option<Type>, Variance, Option<Type>)>,
547 pub extends: Vec<Type>,
551 pub implements: Vec<Type>,
553 pub uses: Vec<Type>,
556 pub throws: Vec<String>,
558 pub assertions: Vec<(String, Type, bool)>,
560 pub assertions_if_true: Vec<(String, Type, bool)>,
562 pub assertions_if_false: Vec<(String, Type, bool)>,
564 pub suppressed_issues: Vec<String>,
566 pub is_deprecated: bool,
567 pub is_internal: bool,
568 pub is_pure: bool,
569 pub is_mutation_free: bool,
570 pub is_external_mutation_free: bool,
571 pub no_named_arguments: bool,
572 pub is_immutable: bool,
573 pub is_readonly: bool,
574 pub is_api: bool,
575 pub is_final: bool,
577 pub is_inherit_doc: bool,
580 pub description: String,
582 pub deprecated: Option<String>,
584 pub see: Vec<String>,
586 pub mixins: Vec<String>,
588 pub properties: Vec<DocProperty>,
590 pub methods: Vec<DocMethod>,
592 pub type_aliases: Vec<DocTypeAlias>,
594 pub import_types: Vec<DocImportType>,
596 pub require_extends: Vec<String>,
598 pub require_implements: Vec<String>,
600 pub since: Option<String>,
602 pub removed: Option<String>,
604 pub invalid_annotations: Vec<String>,
606 pub mir_checks: Vec<(String, String)>,
611 pub trace_vars: Vec<String>,
613 pub taint_sinks: Vec<(String, String)>,
615 pub seal_properties: bool,
617 pub if_this_is: Option<Type>,
621 pub self_out: Option<Type>,
625 pub data_providers: Vec<String>,
628}
629
630impl ParsedDocblock {
631 pub fn get_param_type(&self, name: &str) -> Option<&Type> {
637 let name = name.trim_start_matches('$');
638 self.params
639 .iter()
640 .rfind(|(n, _)| n.trim_start_matches('$') == name)
641 .map(|(_, ty)| ty)
642 }
643
644 pub fn get_out_param_type(&self, name: &str) -> Option<&Type> {
647 let name = name.trim_start_matches('$');
648 self.out_params
649 .iter()
650 .rfind(|(n, _)| n.trim_start_matches('$') == name)
651 .map(|(_, ty)| ty)
652 }
653}
654
655#[cfg(test)]
660mod tests;
661mod types;
665mod validate;
666
667pub(crate) use types::SelfIntConstantsGuard;
668use types::*;
669use validate::*;
670
671pub(crate) use types::parse_type_string;