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