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