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