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