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