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                            if let Some(msg) = validate_type_str(&ty_s, "param-out") {
29                                result.invalid_annotations.push(msg);
30                            } else {
31                                result.out_params.push((
32                                    name.trim_start_matches('$').to_string(),
33                                    parse_type_string(&ty_s),
34                                ));
35                            }
36                        }
37                    }
38                }
39                "param" | "psalm-param" | "phpstan-param" | "phan-param" => {
40                    if let Some(body_str) = body_text(&tag.body) {
41                        if let Some((ty_s, name)) = parse_param_line(&body_str) {
42                            // Check if the parsed type is valid
43                            if is_inside_generics(&ty_s) {
44                                // For unclosed generics, report the full body for context
45                                if let Some(msg) = validate_type_str(&body_str, "param") {
46                                    result.invalid_annotations.push(msg);
47                                }
48                            } else if let Some(msg) = validate_type_str(&ty_s, "param") {
49                                // For other errors, report the parsed type
50                                result.invalid_annotations.push(msg);
51                            } else {
52                                result
53                                    .param_type_strings
54                                    .push((name.trim_start_matches('$').to_string(), ty_s.clone()));
55                                result.params.push((
56                                    name.trim_start_matches('$').to_string(),
57                                    parse_type_string(&ty_s),
58                                ));
59                            }
60                        } else if let Some(msg) = validate_type_str(&body_str, "param") {
61                            // If parsing failed, validate the full body to provide better error context
62                            result.invalid_annotations.push(msg);
63                        }
64                    }
65                }
66                "return" | "psalm-return" | "phpstan-return" => {
67                    if let Some(body_str) = body_text(&tag.body) {
68                        let ty_s = extract_return_type(&body_str);
69                        if let Some(msg) = validate_type_str(&ty_s, "return") {
70                            result.invalid_annotations.push(msg);
71                        }
72                        result.return_type = Some(parse_type_string(&ty_s));
73                    }
74                }
75                "var" | "psalm-var" | "phpstan-var" => {
76                    if let Some(body_str) = body_text(&tag.body) {
77                        if let Some((ty_s, name)) = parse_var_line(&body_str) {
78                            if let Some(msg) = validate_type_str(&ty_s, "var") {
79                                result.invalid_annotations.push(msg);
80                            }
81                            result.var_type = Some(parse_type_string(&ty_s));
82                            result.var_name = Some(name.trim_start_matches('$').to_string());
83                        } else {
84                            // Spaces inside PHP types only appear within <…> generics.
85                            // Stop at top-level whitespace to exclude description text that
86                            // follows the type in multi-line @var bodies.
87                            let ty_s = extract_type_prefix(body_str.trim());
88                            if let Some(msg) = validate_type_str(ty_s, "var") {
89                                result.invalid_annotations.push(msg);
90                            }
91                            result.var_type = Some(parse_type_string(ty_s));
92                        }
93                    }
94                }
95                "throws" => {
96                    if let Some(body_str) = body_text(&tag.body) {
97                        let first_word = body_str.split_whitespace().next().unwrap_or("");
98                        for class in first_word.split('|') {
99                            if !class.is_empty() {
100                                result.throws.push(class.to_string());
101                            }
102                        }
103                    }
104                }
105                "deprecated" => {
106                    result.is_deprecated = true;
107                    result.deprecated = Some(body_text(&tag.body).unwrap_or_default().to_string());
108                }
109                "template" | "psalm-template" | "phpstan-template" => {
110                    if let Some((name, bound, default)) =
111                        parse_template_line(tag.name.as_str(), body_text(&tag.body))
112                    {
113                        if let Some(msg) = validate_type_str(&name, "template") {
114                            result.invalid_annotations.push(msg);
115                        }
116                        if let Some(b) = &bound {
117                            if let Some(msg) = validate_type_str(b, "template") {
118                                result.invalid_annotations.push(msg);
119                            }
120                        }
121                        result.templates.push((
122                            name,
123                            bound.map(|b| parse_type_string(&b)),
124                            Variance::Invariant,
125                            default.map(|d| parse_type_string(&d)),
126                        ));
127                    }
128                }
129                "template-covariant"
130                | "psalm-template-covariant"
131                | "phpstan-template-covariant" => {
132                    if let Some((name, bound, default)) =
133                        parse_template_line(tag.name.as_str(), body_text(&tag.body))
134                    {
135                        if let Some(msg) = validate_type_str(&name, "template-covariant") {
136                            result.invalid_annotations.push(msg);
137                        }
138                        if let Some(b) = &bound {
139                            if let Some(msg) = validate_type_str(b, "template-covariant") {
140                                result.invalid_annotations.push(msg);
141                            }
142                        }
143                        result.templates.push((
144                            name,
145                            bound.map(|b| parse_type_string(&b)),
146                            Variance::Covariant,
147                            default.map(|d| parse_type_string(&d)),
148                        ));
149                    }
150                }
151                "template-contravariant"
152                | "psalm-template-contravariant"
153                | "phpstan-template-contravariant" => {
154                    if let Some((name, bound, default)) =
155                        parse_template_line(tag.name.as_str(), body_text(&tag.body))
156                    {
157                        if let Some(msg) = validate_type_str(&name, "template-contravariant") {
158                            result.invalid_annotations.push(msg);
159                        }
160                        if let Some(b) = &bound {
161                            if let Some(msg) = validate_type_str(b, "template-contravariant") {
162                                result.invalid_annotations.push(msg);
163                            }
164                        }
165                        result.templates.push((
166                            name,
167                            bound.map(|b| parse_type_string(&b)),
168                            Variance::Contravariant,
169                            default.map(|d| parse_type_string(&d)),
170                        ));
171                    }
172                }
173                "extends" | "template-extends" | "psalm-extends" | "phpstan-extends" => {
174                    if let Some(body_str) = body_text(&tag.body) {
175                        let trimmed = body_str.trim();
176                        if let Some(msg) = validate_type_str(trimmed, "extends") {
177                            result.invalid_annotations.push(msg);
178                        }
179                        result.extends.push(parse_type_string(trimmed));
180                    }
181                }
182                "implements"
183                | "template-implements"
184                | "psalm-implements"
185                | "phpstan-implements" => {
186                    if let Some(body_str) = body_text(&tag.body) {
187                        let trimmed = body_str.trim();
188                        if let Some(msg) = validate_type_str(trimmed, "implements") {
189                            result.invalid_annotations.push(msg);
190                        }
191                        result.implements.push(parse_type_string(trimmed));
192                    }
193                }
194                "use" | "template-use" | "psalm-use" | "phpstan-use" => {
195                    if let Some(body_str) = body_text(&tag.body) {
196                        let trimmed = body_str.trim();
197                        if let Some(msg) = validate_type_str(trimmed, "use") {
198                            result.invalid_annotations.push(msg);
199                        }
200                        result.uses.push(parse_type_string(trimmed));
201                    }
202                }
203                "assert" | "psalm-assert" | "phpstan-assert" => {
204                    if let Some(body_str) = body_text(&tag.body) {
205                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
206                            let (ty, negated) = parse_assertion_type(&ty_str);
207                            let (name, key) = split_array_key_suffix(&name);
208                            result.assertions.push((name, key, ty, negated));
209                        }
210                    }
211                }
212                "if-this-is" | "psalm-if-this-is" | "phpstan-if-this-is" => {
213                    if let Some(body_str) = body_text(&tag.body) {
214                        let trimmed = body_str.trim();
215                        if !trimmed.is_empty() {
216                            result.if_this_is = Some(parse_type_string(trimmed));
217                        }
218                    }
219                }
220                "self-out" | "psalm-self-out" | "phpstan-self-out" | "this-out"
221                | "psalm-this-out" | "phpstan-this-out" => {
222                    if let Some(body_str) = body_text(&tag.body) {
223                        let trimmed = body_str.trim();
224                        if !trimmed.is_empty() {
225                            result.self_out = Some(parse_self_out_type(trimmed));
226                        }
227                    }
228                }
229                "suppress" | "psalm-suppress" => {
230                    if let Some(body_str) = body_text(&tag.body) {
231                        for rule in body_str.split([',', ' ']) {
232                            let rule = rule.trim().to_string();
233                            if !rule.is_empty() {
234                                result.suppressed_issues.push(rule);
235                            }
236                        }
237                    }
238                }
239                "see" => {
240                    if let Some(body_str) = body_text(&tag.body) {
241                        result.see.push(body_str.to_string());
242                    }
243                }
244                "link" => {
245                    if let Some(body_str) = body_text(&tag.body) {
246                        result.see.push(body_str.to_string());
247                    }
248                }
249                "mixin" => {
250                    if let Some(body_str) = body_text(&tag.body) {
251                        let base_class =
252                            body_str.split('<').next().unwrap_or(&body_str).to_string();
253                        result.mixins.push(base_class);
254                    }
255                }
256                "property" => {
257                    if let Some(body_str) = body_text(&tag.body) {
258                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
259                            result.properties.push(DocProperty {
260                                type_hint: ty_str,
261                                name: name.trim_start_matches('$').to_string(),
262                                read_only: false,
263                                write_only: false,
264                            });
265                        }
266                    }
267                }
268                "property-read" => {
269                    if let Some(body_str) = body_text(&tag.body) {
270                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
271                            result.properties.push(DocProperty {
272                                type_hint: ty_str,
273                                name: name.trim_start_matches('$').to_string(),
274                                read_only: true,
275                                write_only: false,
276                            });
277                        }
278                    }
279                }
280                "property-write" => {
281                    if let Some(body_str) = body_text(&tag.body) {
282                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
283                            result.properties.push(DocProperty {
284                                type_hint: ty_str,
285                                name: name.trim_start_matches('$').to_string(),
286                                read_only: false,
287                                write_only: true,
288                            });
289                        }
290                    }
291                }
292                "method" | "psalm-method" => {
293                    let body_str = body_text(&tag.body).unwrap_or_default().trim().to_string();
294                    if let Some(err) = validate_method_body(&body_str) {
295                        result.invalid_annotations.push(err);
296                    } else if let Some(m) = parse_method_line(&body_str) {
297                        result.methods.push(m);
298                    }
299                }
300                "psalm-type" | "phpstan-type" => {
301                    if let Some(body_str) = body_text(&tag.body) {
302                        // Real Psalm's `@psalm-type Name = Expr` requires the `=`;
303                        // real PHPStan's `@phpstan-type Name Expr` doesn't — split
304                        // on the first whitespace instead when there's no `=` and
305                        // this is the PHPStan spelling. A `phpstan-type` written
306                        // WITH `=` (some codebases mix conventions) still works via
307                        // the primary split.
308                        let split = body_str.split_once('=').or_else(|| {
309                            (tag.name == "phpstan-type")
310                                .then(|| body_str.split_once(char::is_whitespace))
311                                .flatten()
312                        });
313                        if let Some((name, type_expr)) = split {
314                            // A generic alias name (`ListOf<T> = array<int, T>`)
315                            // kept the `<T>` suffix verbatim, so even a BARE
316                            // (non-parameterized) use site's lookup by the
317                            // plain name (`ListOf`) never matched — the alias
318                            // was silently 100% dead. Strip the suffix so at
319                            // least bare usage resolves; substituting T at a
320                            // parameterized use site (`ListOf<int>`) stays a
321                            // separate, not-yet-modeled problem (the template
322                            // parameter list itself is discarded here, same
323                            // as before).
324                            let raw_name = name.trim();
325                            let name = raw_name
326                                .split('<')
327                                .next()
328                                .unwrap_or(raw_name)
329                                .trim()
330                                .to_string();
331                            result.type_aliases.push(DocTypeAlias {
332                                name,
333                                type_expr: type_expr.trim().to_string(),
334                            });
335                        }
336                    }
337                }
338                "psalm-import-type" | "phpstan-import-type" => {
339                    if let Some(body_str) = body_text(&tag.body) {
340                        if let Some(import) = parse_import_type(&body_str) {
341                            result.import_types.push(import);
342                        }
343                    }
344                }
345                "since" if result.since.is_none() => {
346                    if let Some(body_str) = body_text(&tag.body) {
347                        let v = body_str.split_whitespace().next().unwrap_or("");
348                        if !v.is_empty() {
349                            result.since = Some(v.to_string());
350                        }
351                    }
352                }
353                "removed" if result.removed.is_none() => {
354                    if let Some(body_str) = body_text(&tag.body) {
355                        let v = body_str.split_whitespace().next().unwrap_or("");
356                        if !v.is_empty() {
357                            result.removed = Some(v.to_string());
358                        }
359                    }
360                }
361                "internal" | "psalm-internal" => result.is_internal = true,
362                "pure" | "psalm-pure" | "phpstan-pure" => result.is_pure = true,
363                "seal-properties" | "psalm-seal-properties" => result.seal_properties = true,
364                "no-named-arguments" => result.no_named_arguments = true,
365                "mutation-free" | "psalm-mutation-free" | "phpstan-mutation-free" => {
366                    result.is_mutation_free = true
367                }
368                "external-mutation-free"
369                | "psalm-external-mutation-free"
370                | "phpstan-external-mutation-free" => result.is_external_mutation_free = true,
371                "immutable" | "psalm-immutable" | "phpstan-immutable" => result.is_immutable = true,
372                "readonly" | "psalm-readonly" | "phpstan-readonly" => result.is_readonly = true,
373                "final" => result.is_final = true,
374                "inheritDoc" | "inheritdoc" => result.is_inherit_doc = true,
375                "api" | "psalm-api" => result.is_api = true,
376                "assert-if-true" | "psalm-assert-if-true" | "phpstan-assert-if-true" => {
377                    if let Some(body_str) = body_text(&tag.body) {
378                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
379                            let (ty, negated) = parse_assertion_type(&ty_str);
380                            let (name, key) = split_array_key_suffix(&name);
381                            result.assertions_if_true.push((name, key, ty, negated));
382                        }
383                    }
384                }
385                "assert-if-false" | "psalm-assert-if-false" | "phpstan-assert-if-false" => {
386                    if let Some(body_str) = body_text(&tag.body) {
387                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
388                            let (ty, negated) = parse_assertion_type(&ty_str);
389                            let (name, key) = split_array_key_suffix(&name);
390                            result.assertions_if_false.push((name, key, ty, negated));
391                        }
392                    }
393                }
394                "psalm-property" => {
395                    if let Some(body_str) = body_text(&tag.body) {
396                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
397                            result.properties.push(DocProperty {
398                                type_hint: ty_str,
399                                name,
400                                read_only: false,
401                                write_only: false,
402                            });
403                        }
404                    }
405                }
406                "psalm-property-read" => {
407                    if let Some(body_str) = body_text(&tag.body) {
408                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
409                            result.properties.push(DocProperty {
410                                type_hint: ty_str,
411                                name,
412                                read_only: true,
413                                write_only: false,
414                            });
415                        }
416                    }
417                }
418                "psalm-property-write" => {
419                    if let Some(body_str) = body_text(&tag.body) {
420                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
421                            result.properties.push(DocProperty {
422                                type_hint: ty_str,
423                                name,
424                                read_only: false,
425                                write_only: true,
426                            });
427                        }
428                    }
429                }
430                "psalm-require-extends" | "phpstan-require-extends" => {
431                    if let Some(body_str) = body_text(&tag.body) {
432                        let cls = body_str
433                            .split_whitespace()
434                            .next()
435                            .unwrap_or("")
436                            .trim()
437                            .to_string();
438                        if !cls.is_empty() {
439                            result.require_extends.push(cls);
440                        }
441                    }
442                }
443                "psalm-require-implements" | "phpstan-require-implements" => {
444                    if let Some(body_str) = body_text(&tag.body) {
445                        let cls = body_str
446                            .split_whitespace()
447                            .next()
448                            .unwrap_or("")
449                            .trim()
450                            .to_string();
451                        if !cls.is_empty() {
452                            result.require_implements.push(cls);
453                        }
454                    }
455                }
456                "mir-check" => {
457                    if let Some(body_str) = body_text(&tag.body) {
458                        if let Some((expr_part, type_part)) = body_str.split_once(" is ") {
459                            // Kept verbatim (including any leading `$`) — the
460                            // consumer parses this as a real PHP expression,
461                            // not just a bare variable name.
462                            let expr_text = expr_part.trim().to_string();
463                            let type_string = type_part.trim().to_string();
464                            if !expr_text.is_empty() && !type_string.is_empty() {
465                                result.mir_checks.push((expr_text, type_string));
466                            }
467                        }
468                    }
469                }
470                "dataProvider" => {
471                    if let Some(body_str) = body_text(&tag.body) {
472                        let name = body_str
473                            .trim()
474                            .trim_end_matches("()")
475                            .rsplit("::")
476                            .next()
477                            .unwrap_or("")
478                            .trim();
479                        if !name.is_empty() {
480                            result.data_providers.push(name.to_string());
481                        }
482                    }
483                }
484                "trace" => {
485                    if let Some(body_str) = body_text(&tag.body) {
486                        // Support both comma-separated and space-separated variable names
487                        for part in body_str.split([',', ' ']) {
488                            let var_name = part.trim().trim_start_matches('$').to_string();
489                            if !var_name.is_empty() {
490                                result.trace_vars.push(var_name);
491                            }
492                        }
493                    }
494                }
495                "taint-sink" => {
496                    if let Some(body_str) = body_text(&tag.body) {
497                        // Format: `kind $param` or `kind $param1 $param2`
498                        let mut tokens = body_str.split_whitespace();
499                        if let Some(kind) = tokens.next() {
500                            let kind = kind.to_string();
501                            for param_token in tokens {
502                                let param = param_token.trim_start_matches('$').to_string();
503                                if !param.is_empty() {
504                                    result.taint_sinks.push((param, kind.clone()));
505                                }
506                            }
507                        }
508                    }
509                }
510                "taint-source" => result.is_taint_source = true,
511                _ => {}
512            }
513        }
514
515        if text.to_ascii_lowercase().contains("{@inheritdoc}") {
516            result.is_inherit_doc = true;
517        }
518
519        result
520    }
521}
522
523/// `self<T>`/`static<T>`/`parent<T>`/`$this<T>` written in a self-out
524/// annotation (`@psalm-self-out`, `@phpstan-self-out`, `@psalm-this-out`)
525/// needs its `<T>` kept intact for method-level template substitution (e.g.
526/// `@psalm-self-out self<U>` on a method with its own `@template U`) —
527/// unlike an ordinary `@return self<T>`, where the shared type parser
528/// deliberately drops the args and reattaches the receiver's own params
529/// instead (see `parse_generic`'s `self`/`static`/`parent` arms). Parsed
530/// here as a `TNamedObject` sentinel whose `fqcn` is the literal keyword
531/// (never a real PHP class name), which `substitute_static_atom` recognizes
532/// and resolves to the actual receiver class at call time while keeping
533/// `type_params` intact for the caller's later template substitution.
534fn parse_self_out_type(trimmed: &str) -> Type {
535    for keyword in ["self", "static", "parent", "$this"] {
536        let Some(rest) = strip_ascii_ci_prefix(trimmed, keyword) else {
537            continue;
538        };
539        let rest = rest.trim_start();
540        if let Some(inner) = rest.strip_prefix('<').and_then(|s| s.strip_suffix('>')) {
541            let params: Vec<Type> = split_generics(inner)
542                .iter()
543                .map(|p| parse_type_string(p.trim()))
544                .collect();
545            return Type::single(Atomic::TNamedObject {
546                fqcn: mir_types::Name::from(keyword),
547                type_params: mir_types::union::vec_to_type_params(params),
548            });
549        }
550    }
551    parse_type_string(trimmed)
552}
553
554// ---------------------------------------------------------------------------
555// ParsedDocblock support types
556// ---------------------------------------------------------------------------
557
558#[derive(Debug, Default, Clone)]
559pub struct DocProperty {
560    pub type_hint: String,
561    pub name: String,     // without leading $
562    pub read_only: bool,  // true for @property-read
563    pub write_only: bool, // true for @property-write
564}
565
566#[derive(Debug, Default, Clone)]
567pub struct DocMethod {
568    pub return_type: String,
569    pub name: String,
570    pub is_static: bool,
571    pub params: Vec<DocMethodParam>,
572}
573
574#[derive(Debug, Default, Clone)]
575pub struct DocMethodParam {
576    pub name: String,
577    pub type_hint: String,
578    pub is_variadic: bool,
579    pub is_byref: bool,
580    pub is_optional: bool,
581}
582
583#[derive(Debug, Default, Clone)]
584pub struct DocTypeAlias {
585    pub name: String,
586    pub type_expr: String,
587}
588
589#[derive(Debug, Default, Clone)]
590pub struct DocImportType {
591    /// The name exported by the source class (the original alias name).
592    pub original: String,
593    /// The local name to use in this class (`as LocalAlias`); defaults to `original`.
594    pub local: String,
595    /// The FQCN of the class to import the type from.
596    pub from_class: String,
597}
598
599// ---------------------------------------------------------------------------
600// ParsedDocblock
601// ---------------------------------------------------------------------------
602
603#[derive(Debug, Default, Clone)]
604pub struct ParsedDocblock {
605    /// `@param Type $name`
606    pub params: Vec<(String, Type)>,
607    /// Raw `@param` type spellings keyed by parameter name. Kept so later
608    /// analysis can distinguish ambiguous pseudo-type aliases like
609    /// `integer`/`boolean` from a same-named class that should take
610    /// precedence once the full symbol table is available.
611    pub param_type_strings: Vec<(String, String)>,
612    /// `@param-out Type $name` / `@psalm-param-out Type $name` — the type written
613    /// back to the caller's by-ref argument after the call.
614    pub out_params: Vec<(String, Type)>,
615    /// `@return Type`
616    pub return_type: Option<Type>,
617    /// `@var Type` or `@var Type $name` — type and optional variable name
618    pub var_type: Option<Type>,
619    /// Optional variable name from `@var Type $name`
620    pub var_name: Option<String>,
621    /// `@template T` / `@template T of Bound` / `@template-covariant T` / `@template-contravariant T`
622    /// The last element is the optional `@template T = Default` default type.
623    pub templates: Vec<(String, Option<Type>, Variance, Option<Type>)>,
624    /// `@extends ClassName<T>` — a class has at most one entry (its single
625    /// parent); an interface may have several, one per base interface named
626    /// in its native `extends A, B` clause.
627    pub extends: Vec<Type>,
628    /// `@implements InterfaceName<T>`
629    pub implements: Vec<Type>,
630    /// `@use TraitName<T>` — explicit type argument(s) for a `use`d trait's
631    /// own `@template`, mirroring `@implements` for interfaces.
632    pub uses: Vec<Type>,
633    /// `@throws ClassName`
634    pub throws: Vec<String>,
635    /// `@psalm-assert Type $var` — the `bool` is true for the `!Type` negated
636    /// form; the key path is non-empty when the target is a specific
637    /// (possibly nested) array key of `$var` (`@psalm-assert Type
638    /// $var['a']['b']`) rather than the whole variable.
639    pub assertions: Vec<(String, Vec<ArrayKey>, Type, bool)>,
640    /// `@psalm-assert-if-true Type $var`
641    pub assertions_if_true: Vec<(String, Vec<ArrayKey>, Type, bool)>,
642    /// `@psalm-assert-if-false Type $var`
643    pub assertions_if_false: Vec<(String, Vec<ArrayKey>, Type, bool)>,
644    /// `@psalm-suppress IssueName`
645    pub suppressed_issues: Vec<String>,
646    pub is_deprecated: bool,
647    pub is_internal: bool,
648    pub is_pure: bool,
649    pub is_mutation_free: bool,
650    pub is_external_mutation_free: bool,
651    pub no_named_arguments: bool,
652    pub is_immutable: bool,
653    pub is_readonly: bool,
654    pub is_api: bool,
655    /// `@final` — class should be treated as final even without the PHP `final` keyword.
656    pub is_final: bool,
657    /// `@inheritDoc` or `{@inheritDoc}` was present — documentation should be
658    /// inherited from the nearest ancestor that has a real docblock.
659    pub is_inherit_doc: bool,
660    /// Free text before first `@` tag — used for hover display
661    pub description: String,
662    /// `@deprecated message` — Some(message) or Some("") if no message
663    pub deprecated: Option<String>,
664    /// `@see ClassName` / `@link URL`
665    pub see: Vec<String>,
666    /// `@mixin ClassName`
667    pub mixins: Vec<String>,
668    /// `@property`, `@property-read`, `@property-write`
669    pub properties: Vec<DocProperty>,
670    /// `@method [static] ReturnType name([params])`
671    pub methods: Vec<DocMethod>,
672    /// `@psalm-type Alias = TypeExpr` / `@phpstan-type Alias = TypeExpr`
673    pub type_aliases: Vec<DocTypeAlias>,
674    /// `@psalm-import-type Alias from SourceClass` / `@phpstan-import-type ...`
675    pub import_types: Vec<DocImportType>,
676    /// `@psalm-require-extends ClassName` / `@phpstan-require-extends ClassName`
677    pub require_extends: Vec<String>,
678    /// `@psalm-require-implements InterfaceName` / `@phpstan-require-implements InterfaceName`
679    pub require_implements: Vec<String>,
680    /// `@since X.Y` — first PHP version this symbol exists in.
681    pub since: Option<String>,
682    /// `@removed X.Y` — first PHP version this symbol no longer exists in.
683    pub removed: Option<String>,
684    /// Malformed type annotations detected during parsing.
685    pub invalid_annotations: Vec<String>,
686    /// `@mir-check EXPR is TYPE` — (expr_text, type_string). `expr_text` is
687    /// kept verbatim (e.g. `$h->status`, `self::$prop`, `$arr['key']`) and
688    /// parsed as a real PHP expression by the consumer, not just a bare
689    /// variable name.
690    pub mir_checks: Vec<(String, String)>,
691    /// `@trace $var1, $var2` or `@trace $var1 $var2` — variable names to trace
692    pub trace_vars: Vec<String>,
693    /// `@taint-sink <kind> $param` — (param_name_without_dollar, sink_kind_string)
694    pub taint_sinks: Vec<(String, String)>,
695    /// `@taint-source` — this function/method's return value is tainted.
696    pub is_taint_source: bool,
697    /// `@seal-properties` / `@psalm-seal-properties` — disallows undeclared property access.
698    pub seal_properties: bool,
699    /// `@if-this-is Type` / `@psalm-if-this-is Type` — the method may only be
700    /// called when `$this` satisfies this type. Stored as the raw parsed type;
701    /// class names are resolved later by the collector.
702    pub if_this_is: Option<Type>,
703    /// `@psalm-self-out Type` / `@phpstan-self-out Type` — the receiver's type
704    /// after this call returns. Stored as the raw parsed type; class names
705    /// (and `self`/`static`) are resolved later by the collector.
706    pub self_out: Option<Type>,
707    /// `@dataProvider methodName` (PHPUnit) — name of the method that supplies
708    /// this test's data, invoked by PHPUnit via reflection rather than a call.
709    pub data_providers: Vec<String>,
710}
711
712impl ParsedDocblock {
713    /// Returns the type for a given parameter name (strips leading `$`).
714    ///
715    /// Uses the **last** match so that `@psalm-param` / `@phpstan-param` (which
716    /// php-rs-parser maps to the same `Param` variant as `@param`) overrides a
717    /// preceding plain `@param` annotation.
718    pub fn get_param_type(&self, name: &str) -> Option<&Type> {
719        let name = name.trim_start_matches('$');
720        self.params
721            .iter()
722            .rfind(|(n, _)| n.trim_start_matches('$') == name)
723            .map(|(_, ty)| ty)
724    }
725
726    /// Returns the raw `@param` type spelling for a given parameter name.
727    /// Uses the **last** match, mirroring [`get_param_type`].
728    pub fn get_param_type_string(&self, name: &str) -> Option<&str> {
729        let name = name.trim_start_matches('$');
730        self.param_type_strings
731            .iter()
732            .rfind(|(n, _)| n.trim_start_matches('$') == name)
733            .map(|(_, ty)| ty.as_str())
734    }
735
736    /// Returns the `@param-out` / `@psalm-param-out` type for a given parameter
737    /// name, if declared. Uses the **last** match.
738    pub fn get_out_param_type(&self, name: &str) -> Option<&Type> {
739        let name = name.trim_start_matches('$');
740        self.out_params
741            .iter()
742            .rfind(|(n, _)| n.trim_start_matches('$') == name)
743            .map(|(_, ty)| ty)
744    }
745}
746
747// ---------------------------------------------------------------------------
748// Type string parser
749// ---------------------------------------------------------------------------
750
751#[cfg(test)]
752mod tests;
753/// Parse a PHPDoc type expression string into a `Type`.
754/// Handles: `string`, `int|null`, `array<string>`, `list<int>`,
755/// `ClassName`, `?string` (nullable), `string[]` (array shorthand).
756mod types;
757mod validate;
758
759pub(crate) use types::SelfIntConstantsGuard;
760use types::*;
761use validate::*;
762
763pub(crate) use types::parse_type_string;