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