mir_analyzer/parser/docblock/mod.rs
1use mir_types::{ArrayKey, Atomic, Type, Variance};
2/// Docblock parser — delegates to `phpdoc_parser` for tag extraction,
3/// then converts tags into mir's `ParsedDocblock` with resolved types.
4use std::sync::Arc;
5
6use indexmap::IndexMap;
7use phpdoc_parser::{body_text, parse as parse_phpdoc};
8
9// ---------------------------------------------------------------------------
10// DocblockParser
11// ---------------------------------------------------------------------------
12
13pub struct DocblockParser;
14
15impl DocblockParser {
16 pub fn parse(text: &str) -> ParsedDocblock {
17 Self::parse_inner(text, false)
18 }
19
20 /// Like `parse`, also filling `@param`/`@return`/`@throws` descriptions.
21 pub fn parse_with_descriptions(text: &str) -> ParsedDocblock {
22 Self::parse_inner(text, true)
23 }
24
25 fn parse_inner(text: &str, capture_descriptions: bool) -> ParsedDocblock {
26 let doc = parse_phpdoc(text);
27 let mut result = ParsedDocblock {
28 description: extract_description(text),
29 ..Default::default()
30 };
31
32 let mut prev_tag_span = (0, 0);
33 for tag in &doc.tags {
34 result.fill_invalid_annotation_spans(prev_tag_span);
35 prev_tag_span = tag_head_span(text, tag.span.start, tag.span.end);
36 match tag.name.as_str() {
37 "param-out" | "psalm-param-out" | "phpstan-param-out" => {
38 if let Some(body_str) = body_text(&tag.body) {
39 if let Some((ty_s, name)) = parse_param_line(&body_str) {
40 record_backslash_keyword_types(
41 &mut result,
42 "param-out",
43 &ty_s,
44 tag.span.start,
45 text,
46 );
47 if let Some(msg) = validate_type_str(&ty_s, "param-out") {
48 result.invalid_annotations.push(msg);
49 } else {
50 result.out_params.push((
51 name.trim_start_matches('$').to_string(),
52 parse_type_string(&ty_s),
53 ));
54 }
55 }
56 }
57 }
58 "param-closure-this"
59 | "psalm-param-closure-this"
60 | "phpstan-param-closure-this" => {
61 if let Some((ty_s, name)) = body_text(&tag.body)
62 .as_deref()
63 .and_then(parse_param_line)
64 .filter(|(ty_s, _)| validate_type_str(ty_s, "param-closure-this").is_none())
65 {
66 result.closure_this_params.push((
67 name.trim_start_matches('$').to_string(),
68 parse_type_string(&ty_s),
69 ));
70 }
71 }
72 "param" | "psalm-param" | "phpstan-param" | "phan-param" => {
73 if let Some(body_str) = body_text(&tag.body) {
74 if let Some((ty_s, name)) = parse_param_line(&body_str) {
75 record_backslash_keyword_types(
76 &mut result,
77 "param",
78 &ty_s,
79 tag.span.start,
80 text,
81 );
82 // Check if the parsed type is valid
83 if is_inside_generics(&ty_s) {
84 // For unclosed generics, report the full body for context
85 if let Some(msg) = validate_type_str(&body_str, "param") {
86 result.invalid_annotations.push(msg);
87 }
88 } else if let Some(msg) = validate_type_str(&ty_s, "param") {
89 // For other errors, report the parsed type
90 result.invalid_annotations.push(msg);
91 } else {
92 result
93 .param_type_strings
94 .push((name.trim_start_matches('$').to_string(), ty_s.clone()));
95 let bare = name.trim_start_matches('$').to_string();
96 if capture_descriptions {
97 let desc = param_description(&body_str, &ty_s, &bare);
98 if !desc.is_empty() {
99 result.param_descriptions.push((bare.clone(), desc));
100 }
101 }
102 result.params.push((bare, parse_type_string(&ty_s)));
103 }
104 } else if let Some(msg) = validate_type_str(&body_str, "param") {
105 // If parsing failed, validate the full body to provide better error context
106 result.invalid_annotations.push(msg);
107 record_backslash_keyword_types(
108 &mut result,
109 "param",
110 &body_str,
111 tag.span.start,
112 text,
113 );
114 }
115 }
116 }
117 "return" | "psalm-return" | "phpstan-return" => {
118 if let Some(body_str) = body_text(&tag.body) {
119 let ty_s = extract_return_type(&body_str);
120 record_backslash_keyword_types(
121 &mut result,
122 "return",
123 &ty_s,
124 tag.span.start,
125 text,
126 );
127 if let Some(msg) = validate_type_str(&ty_s, "return") {
128 result.invalid_annotations.push(msg);
129 }
130 result.return_type = Some(parse_type_string(&ty_s));
131 if capture_descriptions {
132 let desc = description_after(&body_str, &ty_s);
133 if !desc.is_empty() {
134 result.return_description = Some(desc);
135 }
136 }
137 }
138 }
139 "var" | "psalm-var" | "phpstan-var" => {
140 if let Some(body_str) = body_text(&tag.body) {
141 if let Some((ty_s, name)) = parse_var_line(&body_str) {
142 record_backslash_keyword_types(
143 &mut result,
144 "var",
145 &ty_s,
146 tag.span.start,
147 text,
148 );
149 if let Some(msg) = validate_type_str(&ty_s, "var") {
150 result.invalid_annotations.push(msg);
151 }
152 let name = name.trim_start_matches('$').to_string();
153 let ty = parse_type_string(&ty_s);
154 result.var_tags.push((Some(name.clone()), ty.clone()));
155 result.var_type = Some(ty);
156 result.var_name = Some(name);
157 } else {
158 // Spaces inside PHP types only appear within <…> generics.
159 // Stop at top-level whitespace to exclude description text that
160 // follows the type in multi-line @var bodies.
161 let ty_s = extract_type_prefix(body_str.trim());
162 record_backslash_keyword_types(
163 &mut result,
164 "var",
165 ty_s,
166 tag.span.start,
167 text,
168 );
169 if let Some(msg) = validate_type_str(ty_s, "var") {
170 result.invalid_annotations.push(msg);
171 }
172 let ty = parse_type_string(ty_s);
173 result.var_tags.push((None, ty.clone()));
174 result.var_type = Some(ty);
175 }
176 }
177 }
178 "throws" => {
179 if let Some(body_str) = body_text(&tag.body) {
180 let first_word = body_str.split_whitespace().next().unwrap_or("");
181 let desc = if capture_descriptions {
182 description_after(&body_str, first_word)
183 } else {
184 String::new()
185 };
186 for class in first_word.split('|') {
187 if !class.is_empty() {
188 record_backslash_keyword_types(
189 &mut result,
190 "throws",
191 class,
192 tag.span.start,
193 text,
194 );
195 result.throws.push(class.to_string());
196 if !desc.is_empty() {
197 result
198 .throws_descriptions
199 .push((class.to_string(), desc.clone()));
200 }
201 }
202 }
203 }
204 }
205 "deprecated" => {
206 result.is_deprecated = true;
207 result.deprecated = Some(body_text(&tag.body).unwrap_or_default().to_string());
208 }
209 "template" | "psalm-template" | "phpstan-template" => {
210 if let Some((name, bound, default)) =
211 parse_template_line(tag.name.as_str(), body_text(&tag.body))
212 {
213 if let Some(msg) = validate_type_str(&name, "template") {
214 result.invalid_annotations.push(msg);
215 }
216 if let Some(b) = &bound {
217 record_backslash_keyword_types(
218 &mut result,
219 "template",
220 b,
221 tag.span.start,
222 text,
223 );
224 if let Some(msg) = validate_type_str(b, "template") {
225 result.invalid_annotations.push(msg);
226 }
227 }
228 result.template_spans.push(tag_head_span(
229 text,
230 tag.span.start,
231 tag.span.end,
232 ));
233 result.templates.push((
234 name,
235 bound.map(|b| parse_type_string(&b)),
236 Variance::Invariant,
237 default.map(|d| parse_type_string(&d)),
238 ));
239 }
240 }
241 "template-covariant"
242 | "psalm-template-covariant"
243 | "phpstan-template-covariant" => {
244 if let Some((name, bound, default)) =
245 parse_template_line(tag.name.as_str(), body_text(&tag.body))
246 {
247 if let Some(msg) = validate_type_str(&name, "template-covariant") {
248 result.invalid_annotations.push(msg);
249 }
250 if let Some(b) = &bound {
251 record_backslash_keyword_types(
252 &mut result,
253 "template-covariant",
254 b,
255 tag.span.start,
256 text,
257 );
258 if let Some(msg) = validate_type_str(b, "template-covariant") {
259 result.invalid_annotations.push(msg);
260 }
261 }
262 result.template_spans.push(tag_head_span(
263 text,
264 tag.span.start,
265 tag.span.end,
266 ));
267 result.templates.push((
268 name,
269 bound.map(|b| parse_type_string(&b)),
270 Variance::Covariant,
271 default.map(|d| parse_type_string(&d)),
272 ));
273 }
274 }
275 "template-contravariant"
276 | "psalm-template-contravariant"
277 | "phpstan-template-contravariant" => {
278 if let Some((name, bound, default)) =
279 parse_template_line(tag.name.as_str(), body_text(&tag.body))
280 {
281 if let Some(msg) = validate_type_str(&name, "template-contravariant") {
282 result.invalid_annotations.push(msg);
283 }
284 if let Some(b) = &bound {
285 record_backslash_keyword_types(
286 &mut result,
287 "template-contravariant",
288 b,
289 tag.span.start,
290 text,
291 );
292 if let Some(msg) = validate_type_str(b, "template-contravariant") {
293 result.invalid_annotations.push(msg);
294 }
295 }
296 result.template_spans.push(tag_head_span(
297 text,
298 tag.span.start,
299 tag.span.end,
300 ));
301 result.templates.push((
302 name,
303 bound.map(|b| parse_type_string(&b)),
304 Variance::Contravariant,
305 default.map(|d| parse_type_string(&d)),
306 ));
307 }
308 }
309 "extends" | "template-extends" | "psalm-extends" | "phpstan-extends" => {
310 if let Some(body_str) = body_text(&tag.body) {
311 let trimmed = body_str.trim();
312 record_backslash_keyword_types(
313 &mut result,
314 "extends",
315 trimmed,
316 tag.span.start,
317 text,
318 );
319 if let Some(msg) = validate_type_str(trimmed, "extends") {
320 result.invalid_annotations.push(msg);
321 }
322 result.extends.push(parse_type_string(trimmed));
323 }
324 }
325 "implements"
326 | "template-implements"
327 | "psalm-implements"
328 | "phpstan-implements" => {
329 if let Some(body_str) = body_text(&tag.body) {
330 let trimmed = body_str.trim();
331 record_backslash_keyword_types(
332 &mut result,
333 "implements",
334 trimmed,
335 tag.span.start,
336 text,
337 );
338 if let Some(msg) = validate_type_str(trimmed, "implements") {
339 result.invalid_annotations.push(msg);
340 }
341 result.implements.push(parse_type_string(trimmed));
342 }
343 }
344 "use" | "template-use" | "psalm-use" | "phpstan-use" => {
345 if let Some(body_str) = body_text(&tag.body) {
346 let trimmed = body_str.trim();
347 record_backslash_keyword_types(
348 &mut result,
349 "use",
350 trimmed,
351 tag.span.start,
352 text,
353 );
354 if let Some(msg) = validate_type_str(trimmed, "use") {
355 result.invalid_annotations.push(msg);
356 }
357 result.uses.push(parse_type_string(trimmed));
358 }
359 }
360 "assert" | "psalm-assert" | "phpstan-assert" => {
361 if let Some(body_str) = body_text(&tag.body) {
362 if let Some((ty_str, name)) = parse_param_line(&body_str) {
363 let (ty, negated) = parse_assertion_type(&ty_str);
364 let (name, key) = split_array_key_suffix(&name);
365 result.assertions.push((name, key, ty, negated));
366 }
367 }
368 }
369 "if-this-is" | "psalm-if-this-is" | "phpstan-if-this-is" => {
370 if let Some(body_str) = body_text(&tag.body) {
371 let trimmed = body_str.trim();
372 if !trimmed.is_empty() {
373 result.if_this_is = Some(parse_type_string(trimmed));
374 }
375 }
376 }
377 "self-out" | "psalm-self-out" | "phpstan-self-out" | "this-out"
378 | "psalm-this-out" | "phpstan-this-out" => {
379 if let Some(body_str) = body_text(&tag.body) {
380 let trimmed = body_str.trim();
381 if !trimmed.is_empty() {
382 result.self_out = Some(parse_self_out_type(trimmed));
383 }
384 }
385 }
386 "suppress" | "psalm-suppress" => {
387 if let Some(body_str) = body_text(&tag.body) {
388 for rule in body_str.split([',', ' ']) {
389 let rule = rule.trim().to_string();
390 if !rule.is_empty() {
391 result.suppressed_issues.push(rule);
392 }
393 }
394 }
395 }
396 "see" => {
397 if let Some(body_str) = body_text(&tag.body) {
398 result.see.push(body_str.to_string());
399 }
400 }
401 "link" => {
402 if let Some(body_str) = body_text(&tag.body) {
403 result.see.push(body_str.to_string());
404 }
405 }
406 "mixin" => {
407 if let Some(body_str) = body_text(&tag.body) {
408 let base_class =
409 body_str.split('<').next().unwrap_or(&body_str).to_string();
410 result.mixins.push(base_class);
411 result
412 .mixin_spans
413 .push(tag_head_span(text, tag.span.start, tag.span.end));
414 }
415 }
416 "property" => {
417 if let Some(body_str) = body_text(&tag.body) {
418 if let Some((ty_str, name)) = parse_param_line(&body_str) {
419 record_backslash_keyword_types(
420 &mut result,
421 "property",
422 &ty_str,
423 tag.span.start,
424 text,
425 );
426 result.properties.push(DocProperty {
427 tag_span: tag_head_span(text, tag.span.start, tag.span.end),
428 type_hint: ty_str,
429 name: name.trim_start_matches('$').to_string(),
430 read_only: false,
431 write_only: false,
432 });
433 }
434 }
435 }
436 "property-read" => {
437 if let Some(body_str) = body_text(&tag.body) {
438 if let Some((ty_str, name)) = parse_param_line(&body_str) {
439 record_backslash_keyword_types(
440 &mut result,
441 "property-read",
442 &ty_str,
443 tag.span.start,
444 text,
445 );
446 result.properties.push(DocProperty {
447 tag_span: tag_head_span(text, tag.span.start, tag.span.end),
448 type_hint: ty_str,
449 name: name.trim_start_matches('$').to_string(),
450 read_only: true,
451 write_only: false,
452 });
453 }
454 }
455 }
456 "property-write" => {
457 if let Some(body_str) = body_text(&tag.body) {
458 if let Some((ty_str, name)) = parse_param_line(&body_str) {
459 record_backslash_keyword_types(
460 &mut result,
461 "property-write",
462 &ty_str,
463 tag.span.start,
464 text,
465 );
466 result.properties.push(DocProperty {
467 tag_span: tag_head_span(text, tag.span.start, tag.span.end),
468 type_hint: ty_str,
469 name: name.trim_start_matches('$').to_string(),
470 read_only: false,
471 write_only: true,
472 });
473 }
474 }
475 }
476 "method" | "psalm-method" => {
477 let body_str = body_text(&tag.body).unwrap_or_default().trim().to_string();
478 if let Some(err) = validate_method_body(&body_str) {
479 result.invalid_annotations.push(err);
480 } else if let Some(mut m) = parse_method_line(&body_str) {
481 m.tag_span = tag_head_span(text, tag.span.start, tag.span.end);
482 for p in &m.params {
483 record_backslash_keyword_types(
484 &mut result,
485 "method",
486 &p.type_hint,
487 tag.span.start,
488 text,
489 );
490 }
491 record_backslash_keyword_types(
492 &mut result,
493 "method",
494 &m.return_type,
495 tag.span.start,
496 text,
497 );
498 result.methods.push(m);
499 }
500 }
501 "psalm-type" | "phpstan-type" => {
502 if let Some(body_str) = body_text(&tag.body) {
503 if let Some((name, type_expr)) = split_type_alias_decl(&body_str) {
504 result.type_aliases.push(DocTypeAlias {
505 name: name.to_string(),
506 type_expr: type_expr.to_string(),
507 });
508 }
509 }
510 }
511 "psalm-import-type" | "phpstan-import-type" => {
512 if let Some(body_str) = body_text(&tag.body) {
513 if let Some(import) = parse_import_type(&body_str) {
514 result.import_types.push(import);
515 }
516 }
517 }
518 "since" if result.since.is_none() => {
519 if let Some(body_str) = body_text(&tag.body) {
520 let v = body_str.split_whitespace().next().unwrap_or("");
521 if !v.is_empty() {
522 result.since = Some(v.to_string());
523 }
524 }
525 }
526 "removed" if result.removed.is_none() => {
527 if let Some(body_str) = body_text(&tag.body) {
528 let v = body_str.split_whitespace().next().unwrap_or("");
529 if !v.is_empty() {
530 result.removed = Some(v.to_string());
531 }
532 }
533 }
534 "internal" | "psalm-internal" => result.is_internal = true,
535 "pure" | "psalm-pure" | "phpstan-pure" => result.is_pure = true,
536 "impure" | "psalm-impure" | "phpstan-impure" => result.is_impure = true,
537 "seal-properties" | "psalm-seal-properties" => result.seal_properties = true,
538 "no-named-arguments" => result.no_named_arguments = true,
539 "mutation-free" | "psalm-mutation-free" | "phpstan-mutation-free" => {
540 result.is_mutation_free = true
541 }
542 "external-mutation-free"
543 | "psalm-external-mutation-free"
544 | "phpstan-external-mutation-free" => result.is_external_mutation_free = true,
545 "immutable" | "psalm-immutable" | "phpstan-immutable" => result.is_immutable = true,
546 "readonly" | "psalm-readonly" | "phpstan-readonly" => result.is_readonly = true,
547 "final" => result.is_final = true,
548 "inheritDoc" | "inheritdoc" => result.is_inherit_doc = true,
549 "api" | "psalm-api" => result.is_api = true,
550 "assert-if-true" | "psalm-assert-if-true" | "phpstan-assert-if-true" => {
551 if let Some(body_str) = body_text(&tag.body) {
552 if let Some((ty_str, name)) = parse_param_line(&body_str) {
553 let (ty, negated) = parse_assertion_type(&ty_str);
554 let (name, key) = split_array_key_suffix(&name);
555 result.assertions_if_true.push((name, key, ty, negated));
556 }
557 }
558 }
559 "assert-if-false" | "psalm-assert-if-false" | "phpstan-assert-if-false" => {
560 if let Some(body_str) = body_text(&tag.body) {
561 if let Some((ty_str, name)) = parse_param_line(&body_str) {
562 let (ty, negated) = parse_assertion_type(&ty_str);
563 let (name, key) = split_array_key_suffix(&name);
564 result.assertions_if_false.push((name, key, ty, negated));
565 }
566 }
567 }
568 "psalm-property" => {
569 if let Some(body_str) = body_text(&tag.body) {
570 if let Some((ty_str, name)) = parse_param_line(&body_str) {
571 result.properties.push(DocProperty {
572 tag_span: tag_head_span(text, tag.span.start, tag.span.end),
573 type_hint: ty_str,
574 name,
575 read_only: false,
576 write_only: false,
577 });
578 }
579 }
580 }
581 "psalm-property-read" => {
582 if let Some(body_str) = body_text(&tag.body) {
583 if let Some((ty_str, name)) = parse_param_line(&body_str) {
584 result.properties.push(DocProperty {
585 tag_span: tag_head_span(text, tag.span.start, tag.span.end),
586 type_hint: ty_str,
587 name,
588 read_only: true,
589 write_only: false,
590 });
591 }
592 }
593 }
594 "psalm-property-write" => {
595 if let Some(body_str) = body_text(&tag.body) {
596 if let Some((ty_str, name)) = parse_param_line(&body_str) {
597 result.properties.push(DocProperty {
598 tag_span: tag_head_span(text, tag.span.start, tag.span.end),
599 type_hint: ty_str,
600 name,
601 read_only: false,
602 write_only: true,
603 });
604 }
605 }
606 }
607 "psalm-require-extends" | "phpstan-require-extends" => {
608 if let Some(body_str) = body_text(&tag.body) {
609 let cls = body_str
610 .split_whitespace()
611 .next()
612 .unwrap_or("")
613 .trim()
614 .to_string();
615 if !cls.is_empty() {
616 result.require_extends.push(cls);
617 }
618 }
619 }
620 "psalm-require-implements" | "phpstan-require-implements" => {
621 if let Some(body_str) = body_text(&tag.body) {
622 let cls = body_str
623 .split_whitespace()
624 .next()
625 .unwrap_or("")
626 .trim()
627 .to_string();
628 if !cls.is_empty() {
629 result.require_implements.push(cls);
630 }
631 }
632 }
633 "mir-check" => {
634 if let Some(body_str) = body_text(&tag.body) {
635 if let Some((expr_part, type_part)) = body_str.split_once(" is ") {
636 // Kept verbatim (including any leading `$`) — the
637 // consumer parses this as a real PHP expression,
638 // not just a bare variable name.
639 let expr_text = expr_part.trim().to_string();
640 let type_string = type_part.trim().to_string();
641 if !expr_text.is_empty() && !type_string.is_empty() {
642 result.mir_checks.push((expr_text, type_string));
643 }
644 }
645 }
646 }
647 "dataProvider" => {
648 if let Some(body_str) = body_text(&tag.body) {
649 let name = body_str
650 .trim()
651 .trim_end_matches("()")
652 .rsplit("::")
653 .next()
654 .unwrap_or("")
655 .trim();
656 if !name.is_empty() {
657 result.data_providers.push(name.to_string());
658 }
659 }
660 }
661 "trace" => {
662 if let Some(body_str) = body_text(&tag.body) {
663 // Support both comma-separated and space-separated variable names
664 for part in body_str.split([',', ' ']) {
665 let var_name = part.trim().trim_start_matches('$').to_string();
666 if !var_name.is_empty() {
667 result.trace_vars.push(var_name);
668 }
669 }
670 }
671 }
672 "taint-sink" => {
673 if let Some(body_str) = body_text(&tag.body) {
674 // Format: `kind $param` or `kind $param1 $param2`
675 let mut tokens = body_str.split_whitespace();
676 if let Some(kind) = tokens.next() {
677 let kind = kind.to_string();
678 for param_token in tokens {
679 let param = param_token.trim_start_matches('$').to_string();
680 if !param.is_empty() {
681 result.taint_sinks.push((param, kind.clone()));
682 }
683 }
684 }
685 }
686 }
687 "taint-source" => result.is_taint_source = true,
688 _ => {}
689 }
690 }
691 result.fill_invalid_annotation_spans(prev_tag_span);
692
693 if text.to_ascii_lowercase().contains("{@inheritdoc}") {
694 result.is_inherit_doc = true;
695 }
696
697 result
698 }
699}
700
701/// Record the backslash-qualified non-class (`\int`) members of a docblock
702/// type spelling in `result` — the collector turns each into an
703/// `InvalidDocblockType` warning anchored at the token's own source line.
704fn record_backslash_keyword_types(
705 result: &mut ParsedDocblock,
706 tag_name: &str,
707 ty: &str,
708 tag_start: u32,
709 text: &str,
710) {
711 for token in backslash_keyword_members(ty) {
712 let (line, col) = token_line_col(text, tag_start, ty, &token);
713 result.backslash_keyword_types.push(BackslashKeywordType {
714 tag: tag_name.to_string(),
715 token,
716 line,
717 col,
718 });
719 }
720}
721
722/// 0-based `(line, col)` of `token` within the docblock `text`. `token` is a
723/// verbatim member of `ty`, which sits in the tag body that follows `@tag`;
724/// it is located relative to `tag_start` (the tag's offset in the text).
725/// Falls back to the tag start when the token cannot be found.
726fn token_line_col(text: &str, tag_start: u32, ty: &str, token: &str) -> (u32, u32) {
727 let start = (tag_start as usize).min(text.len());
728 let mut abs = start;
729 if let Some(rest) = text.get(abs..) {
730 if let Some(ty_off) = rest.find(ty) {
731 if let Some(ty_text) = text.get(abs + ty_off..) {
732 if let Some(tok_off) = ty_text.find(token) {
733 abs = (abs + ty_off + tok_off).min(text.len());
734 }
735 }
736 }
737 }
738 let mut line = 0u32;
739 let mut line_start = 0usize;
740 for (i, b) in text.bytes().enumerate() {
741 if i >= abs {
742 break;
743 }
744 if b == b'\n' {
745 line += 1;
746 line_start = i + 1;
747 }
748 }
749 (line, (abs - line_start) as u32)
750}
751
752/// `self<T>`/`static<T>`/`parent<T>`/`$this<T>` written in a self-out
753/// annotation (`@psalm-self-out`, `@phpstan-self-out`, `@psalm-this-out`)
754/// needs its `<T>` kept intact for method-level template substitution (e.g.
755/// `@psalm-self-out self<U>` on a method with its own `@template U`) —
756/// unlike an ordinary `@return self<T>`, where the shared type parser
757/// deliberately drops the args and reattaches the receiver's own params
758/// instead (see `parse_generic`'s `self`/`static`/`parent` arms). Parsed
759/// here as a `TNamedObject` sentinel whose `fqcn` is the literal keyword
760/// (never a real PHP class name), which `substitute_static_atom` recognizes
761/// and resolves to the actual receiver class at call time while keeping
762/// `type_params` intact for the caller's later template substitution.
763fn parse_self_out_type(trimmed: &str) -> Type {
764 for keyword in ["self", "static", "parent", "$this"] {
765 let Some(rest) = strip_ascii_ci_prefix(trimmed, keyword) else {
766 continue;
767 };
768 let rest = rest.trim_start();
769 if let Some(inner) = rest.strip_prefix('<').and_then(|s| s.strip_suffix('>')) {
770 let params: Vec<Type> = split_generics(inner)
771 .iter()
772 .map(|p| parse_type_string(p.trim()))
773 .collect();
774 return Type::single(Atomic::TNamedObject {
775 fqcn: mir_types::Name::from(keyword),
776 type_params: mir_types::union::vec_to_type_params(params),
777 });
778 }
779 }
780 parse_type_string(trimmed)
781}
782
783// ---------------------------------------------------------------------------
784// ParsedDocblock support types
785// ---------------------------------------------------------------------------
786
787/// Byte range of a tag's first line, without trailing whitespace or the
788/// docblock's closing `*/`.
789fn tag_head_span(text: &str, start: impl TryInto<usize>, end: impl TryInto<usize>) -> (u32, u32) {
790 let (Ok(start), Ok(end)) = (start.try_into(), end.try_into()) else {
791 return (0, 0);
792 };
793 let end = end.min(text.len());
794 let Some(raw) = text.get(start..end) else {
795 return (start as u32, end as u32);
796 };
797 let head = raw.lines().next().unwrap_or(raw);
798 let head = head.trim_end().trim_end_matches("*/").trim_end();
799 (start as u32, (start + head.len()) as u32)
800}
801
802#[derive(Debug, Default, Clone)]
803pub struct DocProperty {
804 pub type_hint: String,
805 pub name: String, // without leading $
806 pub read_only: bool, // true for @property-read
807 pub write_only: bool, // true for @property-write
808 /// Byte range of the tag within the docblock text.
809 pub tag_span: (u32, u32),
810}
811
812#[derive(Debug, Default, Clone)]
813pub struct DocMethod {
814 pub return_type: String,
815 pub name: String,
816 pub is_static: bool,
817 pub params: Vec<DocMethodParam>,
818 /// Byte range of the tag within the docblock text.
819 pub tag_span: (u32, u32),
820}
821
822#[derive(Debug, Default, Clone)]
823pub struct DocMethodParam {
824 pub name: String,
825 pub type_hint: String,
826 pub is_variadic: bool,
827 pub is_byref: bool,
828 pub is_optional: bool,
829}
830
831#[derive(Debug, Default, Clone)]
832pub struct DocTypeAlias {
833 pub name: String,
834 pub type_expr: String,
835}
836
837/// A docblock type token written with a leading `\` (the fully-qualified
838/// class qualifier) on a non-class type keyword (`\int`, `?\string`, …).
839/// `line`/`col` are 0-based positions within the docblock text, so the
840/// collector can anchor the warning at the token's own source line.
841#[derive(Debug, Clone)]
842pub struct BackslashKeywordType {
843 /// The annotation tag carrying the type (`param`, `return`, `var`, …).
844 pub tag: String,
845 /// The offending type token, verbatim with its leading backslash.
846 pub token: String,
847 /// Line of the token within the docblock text.
848 pub line: u32,
849 /// Column of the token within its docblock-text line.
850 pub col: u32,
851}
852
853#[derive(Debug, Default, Clone)]
854pub struct DocImportType {
855 /// The name exported by the source class (the original alias name).
856 pub original: String,
857 /// The local name to use in this class (`as LocalAlias`); defaults to `original`.
858 pub local: String,
859 /// The FQCN of the class to import the type from.
860 pub from_class: String,
861}
862
863// ---------------------------------------------------------------------------
864// ParsedDocblock
865// ---------------------------------------------------------------------------
866
867#[derive(Debug, Default, Clone)]
868pub struct ParsedDocblock {
869 /// `@param Type $name`
870 pub params: Vec<(String, Type)>,
871 /// Raw `@param` type spellings keyed by parameter name. Kept so later
872 /// analysis can distinguish ambiguous pseudo-type aliases like
873 /// `integer`/`boolean` from a same-named class that should take
874 /// precedence once the full symbol table is available.
875 pub param_type_strings: Vec<(String, String)>,
876 /// `@param-out Type $name` / `@psalm-param-out Type $name` — the type written
877 /// back to the caller's by-ref argument after the call.
878 pub out_params: Vec<(String, Type)>,
879 /// `@param-closure-this Type $name` — `$this` type inside a closure literal passed for `$name`.
880 pub closure_this_params: Vec<(String, Type)>,
881 /// `@return Type`
882 pub return_type: Option<Type>,
883 /// `@var Type` or `@var Type $name` — type and optional variable name (last tag wins)
884 pub var_type: Option<Type>,
885 /// Optional variable name from `@var Type $name`
886 pub var_name: Option<String>,
887 /// Every `@var` tag in docblock order, for statement-level annotations.
888 pub var_tags: Vec<(Option<String>, Type)>,
889 /// `@template T` / `@template T of Bound` / `@template-covariant T` / `@template-contravariant T`
890 /// The last element is the optional `@template T = Default` default type.
891 pub templates: Vec<(String, Option<Type>, Variance, Option<Type>)>,
892 /// Byte range within the docblock text of each `templates` entry's tag.
893 pub template_spans: Vec<(u32, u32)>,
894 /// `@extends ClassName<T>` — a class has at most one entry (its single
895 /// parent); an interface may have several, one per base interface named
896 /// in its native `extends A, B` clause.
897 pub extends: Vec<Type>,
898 /// `@implements InterfaceName<T>`
899 pub implements: Vec<Type>,
900 /// `@use TraitName<T>` — explicit type argument(s) for a `use`d trait's
901 /// own `@template`, mirroring `@implements` for interfaces.
902 pub uses: Vec<Type>,
903 /// `@throws ClassName`
904 pub throws: Vec<String>,
905 /// `(parameter name, prose)` for each `@param` that has a description.
906 pub param_descriptions: Vec<(String, String)>,
907 /// Prose after the type of `@return`.
908 pub return_description: Option<String>,
909 /// `(class as written, prose)` for each `@throws` that has a description.
910 pub throws_descriptions: Vec<(String, String)>,
911 /// `@psalm-assert Type $var` — the `bool` is true for the `!Type` negated
912 /// form; the key path is non-empty when the target is a specific
913 /// (possibly nested) array key of `$var` (`@psalm-assert Type
914 /// $var['a']['b']`) rather than the whole variable.
915 pub assertions: Vec<(String, Vec<ArrayKey>, Type, bool)>,
916 /// `@psalm-assert-if-true Type $var`
917 pub assertions_if_true: Vec<(String, Vec<ArrayKey>, Type, bool)>,
918 /// `@psalm-assert-if-false Type $var`
919 pub assertions_if_false: Vec<(String, Vec<ArrayKey>, Type, bool)>,
920 /// `@psalm-suppress IssueName`
921 pub suppressed_issues: Vec<String>,
922 pub is_deprecated: bool,
923 pub is_internal: bool,
924 pub is_pure: bool,
925 /// An explicit `@impure`/`@psalm-impure`/`@phpstan-impure` declaration.
926 /// This opts an implementation out of an inherited purity contract.
927 pub is_impure: bool,
928 pub is_mutation_free: bool,
929 pub is_external_mutation_free: bool,
930 pub no_named_arguments: bool,
931 pub is_immutable: bool,
932 pub is_readonly: bool,
933 pub is_api: bool,
934 /// `@final` — class should be treated as final even without the PHP `final` keyword.
935 pub is_final: bool,
936 /// `@inheritDoc` or `{@inheritDoc}` was present — documentation should be
937 /// inherited from the nearest ancestor that has a real docblock.
938 pub is_inherit_doc: bool,
939 /// Free text before first `@` tag — used for hover display
940 pub description: String,
941 /// `@deprecated message` — Some(message) or Some("") if no message
942 pub deprecated: Option<String>,
943 /// `@see ClassName` / `@link URL`
944 pub see: Vec<String>,
945 /// `@mixin ClassName`
946 pub mixins: Vec<String>,
947 /// Byte range within the docblock text of each `mixins` entry's tag.
948 pub mixin_spans: Vec<(u32, u32)>,
949 /// `@property`, `@property-read`, `@property-write`
950 pub properties: Vec<DocProperty>,
951 /// `@method [static] ReturnType name([params])`
952 pub methods: Vec<DocMethod>,
953 /// `@psalm-type Alias = TypeExpr` / `@phpstan-type Alias = TypeExpr`
954 pub type_aliases: Vec<DocTypeAlias>,
955 /// `@psalm-import-type Alias from SourceClass` / `@phpstan-import-type ...`
956 pub import_types: Vec<DocImportType>,
957 /// `@psalm-require-extends ClassName` / `@phpstan-require-extends ClassName`
958 pub require_extends: Vec<String>,
959 /// `@psalm-require-implements InterfaceName` / `@phpstan-require-implements InterfaceName`
960 pub require_implements: Vec<String>,
961 /// `@since X.Y` — first PHP version this symbol exists in.
962 pub since: Option<String>,
963 /// `@removed X.Y` — first PHP version this symbol no longer exists in.
964 pub removed: Option<String>,
965 /// Malformed type annotations detected during parsing.
966 pub invalid_annotations: Vec<String>,
967 /// Byte range within the docblock text of the tag behind each `invalid_annotations` entry.
968 pub invalid_annotation_spans: Vec<(u32, u32)>,
969 /// Docblock type spellings that use a leading `\` (the fully-qualified
970 /// class qualifier) on a non-class type keyword (`\int`, `?\string`, …);
971 /// the collector emits an `InvalidDocblockType` warning for each, anchored
972 /// at the token's own line.
973 pub backslash_keyword_types: Vec<BackslashKeywordType>,
974 /// `@mir-check EXPR is TYPE` — (expr_text, type_string). `expr_text` is
975 /// kept verbatim (e.g. `$h->status`, `self::$prop`, `$arr['key']`) and
976 /// parsed as a real PHP expression by the consumer, not just a bare
977 /// variable name.
978 pub mir_checks: Vec<(String, String)>,
979 /// `@trace $var1, $var2` or `@trace $var1 $var2` — variable names to trace
980 pub trace_vars: Vec<String>,
981 /// `@taint-sink <kind> $param` — (param_name_without_dollar, sink_kind_string)
982 pub taint_sinks: Vec<(String, String)>,
983 /// `@taint-source` — this function/method's return value is tainted.
984 pub is_taint_source: bool,
985 /// `@seal-properties` / `@psalm-seal-properties` — disallows undeclared property access.
986 pub seal_properties: bool,
987 /// `@if-this-is Type` / `@psalm-if-this-is Type` — the method may only be
988 /// called when `$this` satisfies this type. Stored as the raw parsed type;
989 /// class names are resolved later by the collector.
990 pub if_this_is: Option<Type>,
991 /// `@psalm-self-out Type` / `@phpstan-self-out Type` — the receiver's type
992 /// after this call returns. Stored as the raw parsed type; class names
993 /// (and `self`/`static`) are resolved later by the collector.
994 pub self_out: Option<Type>,
995 /// `@dataProvider methodName` (PHPUnit) — name of the method that supplies
996 /// this test's data, invoked by PHPUnit via reflection rather than a call.
997 pub data_providers: Vec<String>,
998}
999
1000impl ParsedDocblock {
1001 fn fill_invalid_annotation_spans(&mut self, span: (u32, u32)) {
1002 self.invalid_annotation_spans
1003 .resize(self.invalid_annotations.len(), span);
1004 }
1005
1006 /// Returns the type for a given parameter name (strips leading `$`).
1007 ///
1008 /// Uses the **last** match so that `@psalm-param` / `@phpstan-param` (which
1009 /// php-rs-parser maps to the same `Param` variant as `@param`) overrides a
1010 /// preceding plain `@param` annotation.
1011 pub fn get_param_type(&self, name: &str) -> Option<&Type> {
1012 let name = name.trim_start_matches('$');
1013 self.params
1014 .iter()
1015 .rfind(|(n, _)| n.trim_start_matches('$') == name)
1016 .map(|(_, ty)| ty)
1017 }
1018
1019 /// Returns the raw `@param` type spelling for a given parameter name.
1020 /// Uses the **last** match, mirroring [`Self::get_param_type`].
1021 pub fn get_param_type_string(&self, name: &str) -> Option<&str> {
1022 let name = name.trim_start_matches('$');
1023 self.param_type_strings
1024 .iter()
1025 .rfind(|(n, _)| n.trim_start_matches('$') == name)
1026 .map(|(_, ty)| ty.as_str())
1027 }
1028
1029 pub fn get_closure_this_type(&self, name: &str) -> Option<&Type> {
1030 let name = name.trim_start_matches('$');
1031 self.closure_this_params
1032 .iter()
1033 .rfind(|(n, _)| n.trim_start_matches('$') == name)
1034 .map(|(_, ty)| ty)
1035 }
1036
1037 /// Returns the `@param-out` / `@psalm-param-out` type for a given parameter
1038 /// name, if declared. Uses the **last** match.
1039 pub fn get_out_param_type(&self, name: &str) -> Option<&Type> {
1040 let name = name.trim_start_matches('$');
1041 self.out_params
1042 .iter()
1043 .rfind(|(n, _)| n.trim_start_matches('$') == name)
1044 .map(|(_, ty)| ty)
1045 }
1046}
1047
1048// ---------------------------------------------------------------------------
1049// Type string parser
1050// ---------------------------------------------------------------------------
1051
1052#[cfg(test)]
1053mod tests;
1054/// Parse a PHPDoc type expression string into a `Type`.
1055/// Handles: `string`, `int|null`, `array<string>`, `list<int>`,
1056/// `ClassName`, `?string` (nullable), `string[]` (array shorthand).
1057mod types;
1058mod validate;
1059
1060pub(crate) use types::SelfIntConstantsGuard;
1061use types::*;
1062use validate::*;
1063
1064pub(crate) use types::is_docblock_type_keyword;
1065pub(crate) use types::parse_type_string;