Skip to main content

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;