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