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