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                        if let Some((name, type_expr)) = split_type_alias_decl(&body_str) {
452                            result.type_aliases.push(DocTypeAlias {
453                                name: name.to_string(),
454                                type_expr: type_expr.to_string(),
455                            });
456                        }
457                    }
458                }
459                "psalm-import-type" | "phpstan-import-type" => {
460                    if let Some(body_str) = body_text(&tag.body) {
461                        if let Some(import) = parse_import_type(&body_str) {
462                            result.import_types.push(import);
463                        }
464                    }
465                }
466                "since" if result.since.is_none() => {
467                    if let Some(body_str) = body_text(&tag.body) {
468                        let v = body_str.split_whitespace().next().unwrap_or("");
469                        if !v.is_empty() {
470                            result.since = Some(v.to_string());
471                        }
472                    }
473                }
474                "removed" if result.removed.is_none() => {
475                    if let Some(body_str) = body_text(&tag.body) {
476                        let v = body_str.split_whitespace().next().unwrap_or("");
477                        if !v.is_empty() {
478                            result.removed = Some(v.to_string());
479                        }
480                    }
481                }
482                "internal" | "psalm-internal" => result.is_internal = true,
483                "pure" | "psalm-pure" | "phpstan-pure" => result.is_pure = true,
484                "impure" | "psalm-impure" | "phpstan-impure" => result.is_impure = true,
485                "seal-properties" | "psalm-seal-properties" => result.seal_properties = true,
486                "no-named-arguments" => result.no_named_arguments = true,
487                "mutation-free" | "psalm-mutation-free" | "phpstan-mutation-free" => {
488                    result.is_mutation_free = true
489                }
490                "external-mutation-free"
491                | "psalm-external-mutation-free"
492                | "phpstan-external-mutation-free" => result.is_external_mutation_free = true,
493                "immutable" | "psalm-immutable" | "phpstan-immutable" => result.is_immutable = true,
494                "readonly" | "psalm-readonly" | "phpstan-readonly" => result.is_readonly = true,
495                "final" => result.is_final = true,
496                "inheritDoc" | "inheritdoc" => result.is_inherit_doc = true,
497                "api" | "psalm-api" => result.is_api = true,
498                "assert-if-true" | "psalm-assert-if-true" | "phpstan-assert-if-true" => {
499                    if let Some(body_str) = body_text(&tag.body) {
500                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
501                            let (ty, negated) = parse_assertion_type(&ty_str);
502                            let (name, key) = split_array_key_suffix(&name);
503                            result.assertions_if_true.push((name, key, ty, negated));
504                        }
505                    }
506                }
507                "assert-if-false" | "psalm-assert-if-false" | "phpstan-assert-if-false" => {
508                    if let Some(body_str) = body_text(&tag.body) {
509                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
510                            let (ty, negated) = parse_assertion_type(&ty_str);
511                            let (name, key) = split_array_key_suffix(&name);
512                            result.assertions_if_false.push((name, key, ty, negated));
513                        }
514                    }
515                }
516                "psalm-property" => {
517                    if let Some(body_str) = body_text(&tag.body) {
518                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
519                            result.properties.push(DocProperty {
520                                tag_span: tag_head_span(text, tag.span.start, tag.span.end),
521                                type_hint: ty_str,
522                                name,
523                                read_only: false,
524                                write_only: false,
525                            });
526                        }
527                    }
528                }
529                "psalm-property-read" => {
530                    if let Some(body_str) = body_text(&tag.body) {
531                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
532                            result.properties.push(DocProperty {
533                                tag_span: tag_head_span(text, tag.span.start, tag.span.end),
534                                type_hint: ty_str,
535                                name,
536                                read_only: true,
537                                write_only: false,
538                            });
539                        }
540                    }
541                }
542                "psalm-property-write" => {
543                    if let Some(body_str) = body_text(&tag.body) {
544                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
545                            result.properties.push(DocProperty {
546                                tag_span: tag_head_span(text, tag.span.start, tag.span.end),
547                                type_hint: ty_str,
548                                name,
549                                read_only: false,
550                                write_only: true,
551                            });
552                        }
553                    }
554                }
555                "psalm-require-extends" | "phpstan-require-extends" => {
556                    if let Some(body_str) = body_text(&tag.body) {
557                        let cls = body_str
558                            .split_whitespace()
559                            .next()
560                            .unwrap_or("")
561                            .trim()
562                            .to_string();
563                        if !cls.is_empty() {
564                            result.require_extends.push(cls);
565                        }
566                    }
567                }
568                "psalm-require-implements" | "phpstan-require-implements" => {
569                    if let Some(body_str) = body_text(&tag.body) {
570                        let cls = body_str
571                            .split_whitespace()
572                            .next()
573                            .unwrap_or("")
574                            .trim()
575                            .to_string();
576                        if !cls.is_empty() {
577                            result.require_implements.push(cls);
578                        }
579                    }
580                }
581                "mir-check" => {
582                    if let Some(body_str) = body_text(&tag.body) {
583                        if let Some((expr_part, type_part)) = body_str.split_once(" is ") {
584                            // Kept verbatim (including any leading `$`) — the
585                            // consumer parses this as a real PHP expression,
586                            // not just a bare variable name.
587                            let expr_text = expr_part.trim().to_string();
588                            let type_string = type_part.trim().to_string();
589                            if !expr_text.is_empty() && !type_string.is_empty() {
590                                result.mir_checks.push((expr_text, type_string));
591                            }
592                        }
593                    }
594                }
595                "dataProvider" => {
596                    if let Some(body_str) = body_text(&tag.body) {
597                        let name = body_str
598                            .trim()
599                            .trim_end_matches("()")
600                            .rsplit("::")
601                            .next()
602                            .unwrap_or("")
603                            .trim();
604                        if !name.is_empty() {
605                            result.data_providers.push(name.to_string());
606                        }
607                    }
608                }
609                "trace" => {
610                    if let Some(body_str) = body_text(&tag.body) {
611                        // Support both comma-separated and space-separated variable names
612                        for part in body_str.split([',', ' ']) {
613                            let var_name = part.trim().trim_start_matches('$').to_string();
614                            if !var_name.is_empty() {
615                                result.trace_vars.push(var_name);
616                            }
617                        }
618                    }
619                }
620                "taint-sink" => {
621                    if let Some(body_str) = body_text(&tag.body) {
622                        // Format: `kind $param` or `kind $param1 $param2`
623                        let mut tokens = body_str.split_whitespace();
624                        if let Some(kind) = tokens.next() {
625                            let kind = kind.to_string();
626                            for param_token in tokens {
627                                let param = param_token.trim_start_matches('$').to_string();
628                                if !param.is_empty() {
629                                    result.taint_sinks.push((param, kind.clone()));
630                                }
631                            }
632                        }
633                    }
634                }
635                "taint-source" => result.is_taint_source = true,
636                _ => {}
637            }
638        }
639
640        if text.to_ascii_lowercase().contains("{@inheritdoc}") {
641            result.is_inherit_doc = true;
642        }
643
644        result
645    }
646}
647
648/// Record the backslash-qualified non-class (`\int`) members of a docblock
649/// type spelling in `result` — the collector turns each into an
650/// `InvalidDocblockType` warning anchored at the token's own source line.
651fn record_backslash_keyword_types(
652    result: &mut ParsedDocblock,
653    tag_name: &str,
654    ty: &str,
655    tag_start: u32,
656    text: &str,
657) {
658    for token in backslash_keyword_members(ty) {
659        let (line, col) = token_line_col(text, tag_start, ty, &token);
660        result.backslash_keyword_types.push(BackslashKeywordType {
661            tag: tag_name.to_string(),
662            token,
663            line,
664            col,
665        });
666    }
667}
668
669/// 0-based `(line, col)` of `token` within the docblock `text`. `token` is a
670/// verbatim member of `ty`, which sits in the tag body that follows `@tag`;
671/// it is located relative to `tag_start` (the tag's offset in the text).
672/// Falls back to the tag start when the token cannot be found.
673fn token_line_col(text: &str, tag_start: u32, ty: &str, token: &str) -> (u32, u32) {
674    let start = (tag_start as usize).min(text.len());
675    let mut abs = start;
676    if let Some(rest) = text.get(abs..) {
677        if let Some(ty_off) = rest.find(ty) {
678            if let Some(ty_text) = text.get(abs + ty_off..) {
679                if let Some(tok_off) = ty_text.find(token) {
680                    abs = (abs + ty_off + tok_off).min(text.len());
681                }
682            }
683        }
684    }
685    let mut line = 0u32;
686    let mut line_start = 0usize;
687    for (i, b) in text.bytes().enumerate() {
688        if i >= abs {
689            break;
690        }
691        if b == b'\n' {
692            line += 1;
693            line_start = i + 1;
694        }
695    }
696    (line, (abs - line_start) as u32)
697}
698
699/// `self<T>`/`static<T>`/`parent<T>`/`$this<T>` written in a self-out
700/// annotation (`@psalm-self-out`, `@phpstan-self-out`, `@psalm-this-out`)
701/// needs its `<T>` kept intact for method-level template substitution (e.g.
702/// `@psalm-self-out self<U>` on a method with its own `@template U`) —
703/// unlike an ordinary `@return self<T>`, where the shared type parser
704/// deliberately drops the args and reattaches the receiver's own params
705/// instead (see `parse_generic`'s `self`/`static`/`parent` arms). Parsed
706/// here as a `TNamedObject` sentinel whose `fqcn` is the literal keyword
707/// (never a real PHP class name), which `substitute_static_atom` recognizes
708/// and resolves to the actual receiver class at call time while keeping
709/// `type_params` intact for the caller's later template substitution.
710fn parse_self_out_type(trimmed: &str) -> Type {
711    for keyword in ["self", "static", "parent", "$this"] {
712        let Some(rest) = strip_ascii_ci_prefix(trimmed, keyword) else {
713            continue;
714        };
715        let rest = rest.trim_start();
716        if let Some(inner) = rest.strip_prefix('<').and_then(|s| s.strip_suffix('>')) {
717            let params: Vec<Type> = split_generics(inner)
718                .iter()
719                .map(|p| parse_type_string(p.trim()))
720                .collect();
721            return Type::single(Atomic::TNamedObject {
722                fqcn: mir_types::Name::from(keyword),
723                type_params: mir_types::union::vec_to_type_params(params),
724            });
725        }
726    }
727    parse_type_string(trimmed)
728}
729
730// ---------------------------------------------------------------------------
731// ParsedDocblock support types
732// ---------------------------------------------------------------------------
733
734/// Byte range of a tag's first line, without trailing whitespace or the
735/// docblock's closing `*/`.
736fn tag_head_span(text: &str, start: impl TryInto<usize>, end: impl TryInto<usize>) -> (u32, u32) {
737    let (Ok(start), Ok(end)) = (start.try_into(), end.try_into()) else {
738        return (0, 0);
739    };
740    let end = end.min(text.len());
741    let Some(raw) = text.get(start..end) else {
742        return (start as u32, end as u32);
743    };
744    let head = raw.lines().next().unwrap_or(raw);
745    let head = head.trim_end().trim_end_matches("*/").trim_end();
746    (start as u32, (start + head.len()) as u32)
747}
748
749#[derive(Debug, Default, Clone)]
750pub struct DocProperty {
751    pub type_hint: String,
752    pub name: String,     // without leading $
753    pub read_only: bool,  // true for @property-read
754    pub write_only: bool, // true for @property-write
755    /// Byte range of the tag within the docblock text.
756    pub tag_span: (u32, u32),
757}
758
759#[derive(Debug, Default, Clone)]
760pub struct DocMethod {
761    pub return_type: String,
762    pub name: String,
763    pub is_static: bool,
764    pub params: Vec<DocMethodParam>,
765    /// Byte range of the tag within the docblock text.
766    pub tag_span: (u32, u32),
767}
768
769#[derive(Debug, Default, Clone)]
770pub struct DocMethodParam {
771    pub name: String,
772    pub type_hint: String,
773    pub is_variadic: bool,
774    pub is_byref: bool,
775    pub is_optional: bool,
776}
777
778#[derive(Debug, Default, Clone)]
779pub struct DocTypeAlias {
780    pub name: String,
781    pub type_expr: String,
782}
783
784/// A docblock type token written with a leading `\` (the fully-qualified
785/// class qualifier) on a non-class type keyword (`\int`, `?\string`, …).
786/// `line`/`col` are 0-based positions within the docblock text, so the
787/// collector can anchor the warning at the token's own source line.
788#[derive(Debug, Clone)]
789pub struct BackslashKeywordType {
790    /// The annotation tag carrying the type (`param`, `return`, `var`, …).
791    pub tag: String,
792    /// The offending type token, verbatim with its leading backslash.
793    pub token: String,
794    /// Line of the token within the docblock text.
795    pub line: u32,
796    /// Column of the token within its docblock-text line.
797    pub col: u32,
798}
799
800#[derive(Debug, Default, Clone)]
801pub struct DocImportType {
802    /// The name exported by the source class (the original alias name).
803    pub original: String,
804    /// The local name to use in this class (`as LocalAlias`); defaults to `original`.
805    pub local: String,
806    /// The FQCN of the class to import the type from.
807    pub from_class: String,
808}
809
810// ---------------------------------------------------------------------------
811// ParsedDocblock
812// ---------------------------------------------------------------------------
813
814#[derive(Debug, Default, Clone)]
815pub struct ParsedDocblock {
816    /// `@param Type $name`
817    pub params: Vec<(String, Type)>,
818    /// Raw `@param` type spellings keyed by parameter name. Kept so later
819    /// analysis can distinguish ambiguous pseudo-type aliases like
820    /// `integer`/`boolean` from a same-named class that should take
821    /// precedence once the full symbol table is available.
822    pub param_type_strings: Vec<(String, String)>,
823    /// `@param-out Type $name` / `@psalm-param-out Type $name` — the type written
824    /// back to the caller's by-ref argument after the call.
825    pub out_params: Vec<(String, Type)>,
826    /// `@return Type`
827    pub return_type: Option<Type>,
828    /// `@var Type` or `@var Type $name` — type and optional variable name (last tag wins)
829    pub var_type: Option<Type>,
830    /// Optional variable name from `@var Type $name`
831    pub var_name: Option<String>,
832    /// Every `@var` tag in docblock order, for statement-level annotations.
833    pub var_tags: Vec<(Option<String>, Type)>,
834    /// `@template T` / `@template T of Bound` / `@template-covariant T` / `@template-contravariant T`
835    /// The last element is the optional `@template T = Default` default type.
836    pub templates: Vec<(String, Option<Type>, Variance, Option<Type>)>,
837    /// `@extends ClassName<T>` — a class has at most one entry (its single
838    /// parent); an interface may have several, one per base interface named
839    /// in its native `extends A, B` clause.
840    pub extends: Vec<Type>,
841    /// `@implements InterfaceName<T>`
842    pub implements: Vec<Type>,
843    /// `@use TraitName<T>` — explicit type argument(s) for a `use`d trait's
844    /// own `@template`, mirroring `@implements` for interfaces.
845    pub uses: Vec<Type>,
846    /// `@throws ClassName`
847    pub throws: Vec<String>,
848    /// `(parameter name, prose)` for each `@param` that has a description.
849    pub param_descriptions: Vec<(String, String)>,
850    /// Prose after the type of `@return`.
851    pub return_description: Option<String>,
852    /// `(class as written, prose)` for each `@throws` that has a description.
853    pub throws_descriptions: Vec<(String, String)>,
854    /// `@psalm-assert Type $var` — the `bool` is true for the `!Type` negated
855    /// form; the key path is non-empty when the target is a specific
856    /// (possibly nested) array key of `$var` (`@psalm-assert Type
857    /// $var['a']['b']`) rather than the whole variable.
858    pub assertions: Vec<(String, Vec<ArrayKey>, Type, bool)>,
859    /// `@psalm-assert-if-true Type $var`
860    pub assertions_if_true: Vec<(String, Vec<ArrayKey>, Type, bool)>,
861    /// `@psalm-assert-if-false Type $var`
862    pub assertions_if_false: Vec<(String, Vec<ArrayKey>, Type, bool)>,
863    /// `@psalm-suppress IssueName`
864    pub suppressed_issues: Vec<String>,
865    pub is_deprecated: bool,
866    pub is_internal: bool,
867    pub is_pure: bool,
868    /// An explicit `@impure`/`@psalm-impure`/`@phpstan-impure` declaration.
869    /// This opts an implementation out of an inherited purity contract.
870    pub is_impure: bool,
871    pub is_mutation_free: bool,
872    pub is_external_mutation_free: bool,
873    pub no_named_arguments: bool,
874    pub is_immutable: bool,
875    pub is_readonly: bool,
876    pub is_api: bool,
877    /// `@final` — class should be treated as final even without the PHP `final` keyword.
878    pub is_final: bool,
879    /// `@inheritDoc` or `{@inheritDoc}` was present — documentation should be
880    /// inherited from the nearest ancestor that has a real docblock.
881    pub is_inherit_doc: bool,
882    /// Free text before first `@` tag — used for hover display
883    pub description: String,
884    /// `@deprecated message` — Some(message) or Some("") if no message
885    pub deprecated: Option<String>,
886    /// `@see ClassName` / `@link URL`
887    pub see: Vec<String>,
888    /// `@mixin ClassName`
889    pub mixins: Vec<String>,
890    /// `@property`, `@property-read`, `@property-write`
891    pub properties: Vec<DocProperty>,
892    /// `@method [static] ReturnType name([params])`
893    pub methods: Vec<DocMethod>,
894    /// `@psalm-type Alias = TypeExpr` / `@phpstan-type Alias = TypeExpr`
895    pub type_aliases: Vec<DocTypeAlias>,
896    /// `@psalm-import-type Alias from SourceClass` / `@phpstan-import-type ...`
897    pub import_types: Vec<DocImportType>,
898    /// `@psalm-require-extends ClassName` / `@phpstan-require-extends ClassName`
899    pub require_extends: Vec<String>,
900    /// `@psalm-require-implements InterfaceName` / `@phpstan-require-implements InterfaceName`
901    pub require_implements: Vec<String>,
902    /// `@since X.Y` — first PHP version this symbol exists in.
903    pub since: Option<String>,
904    /// `@removed X.Y` — first PHP version this symbol no longer exists in.
905    pub removed: Option<String>,
906    /// Malformed type annotations detected during parsing.
907    pub invalid_annotations: Vec<String>,
908    /// Docblock type spellings that use a leading `\` (the fully-qualified
909    /// class qualifier) on a non-class type keyword (`\int`, `?\string`, …);
910    /// the collector emits an `InvalidDocblockType` warning for each, anchored
911    /// at the token's own line.
912    pub backslash_keyword_types: Vec<BackslashKeywordType>,
913    /// `@mir-check EXPR is TYPE` — (expr_text, type_string). `expr_text` is
914    /// kept verbatim (e.g. `$h->status`, `self::$prop`, `$arr['key']`) and
915    /// parsed as a real PHP expression by the consumer, not just a bare
916    /// variable name.
917    pub mir_checks: Vec<(String, String)>,
918    /// `@trace $var1, $var2` or `@trace $var1 $var2` — variable names to trace
919    pub trace_vars: Vec<String>,
920    /// `@taint-sink <kind> $param` — (param_name_without_dollar, sink_kind_string)
921    pub taint_sinks: Vec<(String, String)>,
922    /// `@taint-source` — this function/method's return value is tainted.
923    pub is_taint_source: bool,
924    /// `@seal-properties` / `@psalm-seal-properties` — disallows undeclared property access.
925    pub seal_properties: bool,
926    /// `@if-this-is Type` / `@psalm-if-this-is Type` — the method may only be
927    /// called when `$this` satisfies this type. Stored as the raw parsed type;
928    /// class names are resolved later by the collector.
929    pub if_this_is: Option<Type>,
930    /// `@psalm-self-out Type` / `@phpstan-self-out Type` — the receiver's type
931    /// after this call returns. Stored as the raw parsed type; class names
932    /// (and `self`/`static`) are resolved later by the collector.
933    pub self_out: Option<Type>,
934    /// `@dataProvider methodName` (PHPUnit) — name of the method that supplies
935    /// this test's data, invoked by PHPUnit via reflection rather than a call.
936    pub data_providers: Vec<String>,
937}
938
939impl ParsedDocblock {
940    /// Returns the type for a given parameter name (strips leading `$`).
941    ///
942    /// Uses the **last** match so that `@psalm-param` / `@phpstan-param` (which
943    /// php-rs-parser maps to the same `Param` variant as `@param`) overrides a
944    /// preceding plain `@param` annotation.
945    pub fn get_param_type(&self, name: &str) -> Option<&Type> {
946        let name = name.trim_start_matches('$');
947        self.params
948            .iter()
949            .rfind(|(n, _)| n.trim_start_matches('$') == name)
950            .map(|(_, ty)| ty)
951    }
952
953    /// Returns the raw `@param` type spelling for a given parameter name.
954    /// Uses the **last** match, mirroring [`Self::get_param_type`].
955    pub fn get_param_type_string(&self, name: &str) -> Option<&str> {
956        let name = name.trim_start_matches('$');
957        self.param_type_strings
958            .iter()
959            .rfind(|(n, _)| n.trim_start_matches('$') == name)
960            .map(|(_, ty)| ty.as_str())
961    }
962
963    /// Returns the `@param-out` / `@psalm-param-out` type for a given parameter
964    /// name, if declared. Uses the **last** match.
965    pub fn get_out_param_type(&self, name: &str) -> Option<&Type> {
966        let name = name.trim_start_matches('$');
967        self.out_params
968            .iter()
969            .rfind(|(n, _)| n.trim_start_matches('$') == name)
970            .map(|(_, ty)| ty)
971    }
972}
973
974// ---------------------------------------------------------------------------
975// Type string parser
976// ---------------------------------------------------------------------------
977
978#[cfg(test)]
979mod tests;
980/// Parse a PHPDoc type expression string into a `Type`.
981/// Handles: `string`, `int|null`, `array<string>`, `list<int>`,
982/// `ClassName`, `?string` (nullable), `string[]` (array shorthand).
983mod types;
984mod validate;
985
986pub(crate) use types::SelfIntConstantsGuard;
987use types::*;
988use validate::*;
989
990pub(crate) use types::is_docblock_type_keyword;
991pub(crate) use types::parse_type_string;