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