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                "mutation-free" | "psalm-mutation-free" | "phpstan-mutation-free" => {
286                    result.is_mutation_free = true
287                }
288                "psalm-external-mutation-free" => result.is_external_mutation_free = true,
289                "immutable" | "psalm-immutable" => result.is_immutable = true,
290                "readonly" => result.is_readonly = true,
291                "final" => result.is_final = true,
292                "inheritDoc" | "inheritdoc" => result.is_inherit_doc = true,
293                "api" | "psalm-api" => result.is_api = true,
294                "psalm-assert-if-true" | "phpstan-assert-if-true" => {
295                    if let Some(body_str) = body_text(&tag.body) {
296                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
297                            result
298                                .assertions_if_true
299                                .push((name, parse_type_string(&ty_str)));
300                        }
301                    }
302                }
303                "psalm-assert-if-false" | "phpstan-assert-if-false" => {
304                    if let Some(body_str) = body_text(&tag.body) {
305                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
306                            result
307                                .assertions_if_false
308                                .push((name, parse_type_string(&ty_str)));
309                        }
310                    }
311                }
312                "psalm-property" => {
313                    if let Some(body_str) = body_text(&tag.body) {
314                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
315                            result.properties.push(DocProperty {
316                                type_hint: ty_str,
317                                name,
318                                read_only: false,
319                                write_only: false,
320                            });
321                        }
322                    }
323                }
324                "psalm-property-read" => {
325                    if let Some(body_str) = body_text(&tag.body) {
326                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
327                            result.properties.push(DocProperty {
328                                type_hint: ty_str,
329                                name,
330                                read_only: true,
331                                write_only: false,
332                            });
333                        }
334                    }
335                }
336                "psalm-property-write" => {
337                    if let Some(body_str) = body_text(&tag.body) {
338                        if let Some((ty_str, name)) = parse_param_line(&body_str) {
339                            result.properties.push(DocProperty {
340                                type_hint: ty_str,
341                                name,
342                                read_only: false,
343                                write_only: true,
344                            });
345                        }
346                    }
347                }
348                "psalm-require-extends" | "phpstan-require-extends" => {
349                    if let Some(body_str) = body_text(&tag.body) {
350                        let cls = body_str
351                            .split_whitespace()
352                            .next()
353                            .unwrap_or("")
354                            .trim()
355                            .to_string();
356                        if !cls.is_empty() {
357                            result.require_extends.push(cls);
358                        }
359                    }
360                }
361                "psalm-require-implements" | "phpstan-require-implements" => {
362                    if let Some(body_str) = body_text(&tag.body) {
363                        let cls = body_str
364                            .split_whitespace()
365                            .next()
366                            .unwrap_or("")
367                            .trim()
368                            .to_string();
369                        if !cls.is_empty() {
370                            result.require_implements.push(cls);
371                        }
372                    }
373                }
374                "mir-check" => {
375                    if let Some(body_str) = body_text(&tag.body) {
376                        if let Some((var_part, type_part)) = body_str.split_once(" is ") {
377                            let var_name = var_part.trim().trim_start_matches('$').to_string();
378                            let type_string = type_part.trim().to_string();
379                            if !var_name.is_empty() && !type_string.is_empty() {
380                                result.mir_checks.push((var_name, type_string));
381                            }
382                        }
383                    }
384                }
385                "trace" => {
386                    if let Some(body_str) = body_text(&tag.body) {
387                        // Support both comma-separated and space-separated variable names
388                        for part in body_str.split([',', ' ']) {
389                            let var_name = part.trim().trim_start_matches('$').to_string();
390                            if !var_name.is_empty() {
391                                result.trace_vars.push(var_name);
392                            }
393                        }
394                    }
395                }
396                "taint-sink" => {
397                    if let Some(body_str) = body_text(&tag.body) {
398                        // Format: `kind $param` or `kind $param1 $param2`
399                        let mut tokens = body_str.split_whitespace();
400                        if let Some(kind) = tokens.next() {
401                            let kind = kind.to_string();
402                            for param_token in tokens {
403                                let param = param_token.trim_start_matches('$').to_string();
404                                if !param.is_empty() {
405                                    result.taint_sinks.push((param, kind.clone()));
406                                }
407                            }
408                        }
409                    }
410                }
411                _ => {}
412            }
413        }
414
415        if text.to_ascii_lowercase().contains("{@inheritdoc}") {
416            result.is_inherit_doc = true;
417        }
418
419        result
420    }
421}
422
423// ---------------------------------------------------------------------------
424// ParsedDocblock support types
425// ---------------------------------------------------------------------------
426
427#[derive(Debug, Default, Clone)]
428pub struct DocProperty {
429    pub type_hint: String,
430    pub name: String,     // without leading $
431    pub read_only: bool,  // true for @property-read
432    pub write_only: bool, // true for @property-write
433}
434
435#[derive(Debug, Default, Clone)]
436pub struct DocMethod {
437    pub return_type: String,
438    pub name: String,
439    pub is_static: bool,
440    pub params: Vec<DocMethodParam>,
441}
442
443#[derive(Debug, Default, Clone)]
444pub struct DocMethodParam {
445    pub name: String,
446    pub type_hint: String,
447    pub is_variadic: bool,
448    pub is_byref: bool,
449    pub is_optional: bool,
450}
451
452#[derive(Debug, Default, Clone)]
453pub struct DocTypeAlias {
454    pub name: String,
455    pub type_expr: String,
456}
457
458#[derive(Debug, Default, Clone)]
459pub struct DocImportType {
460    /// The name exported by the source class (the original alias name).
461    pub original: String,
462    /// The local name to use in this class (`as LocalAlias`); defaults to `original`.
463    pub local: String,
464    /// The FQCN of the class to import the type from.
465    pub from_class: String,
466}
467
468// ---------------------------------------------------------------------------
469// ParsedDocblock
470// ---------------------------------------------------------------------------
471
472#[derive(Debug, Default, Clone)]
473pub struct ParsedDocblock {
474    /// `@param Type $name`
475    pub params: Vec<(String, Type)>,
476    /// `@param-out Type $name` / `@psalm-param-out Type $name` — the type written
477    /// back to the caller's by-ref argument after the call.
478    pub out_params: Vec<(String, Type)>,
479    /// `@return Type`
480    pub return_type: Option<Type>,
481    /// `@var Type` or `@var Type $name` — type and optional variable name
482    pub var_type: Option<Type>,
483    /// Optional variable name from `@var Type $name`
484    pub var_name: Option<String>,
485    /// `@template T` / `@template T of Bound` / `@template-covariant T` / `@template-contravariant T`
486    pub templates: Vec<(String, Option<Type>, Variance)>,
487    /// `@extends ClassName<T>`
488    pub extends: Option<Type>,
489    /// `@implements InterfaceName<T>`
490    pub implements: Vec<Type>,
491    /// `@throws ClassName`
492    pub throws: Vec<String>,
493    /// `@psalm-assert Type $var`
494    pub assertions: Vec<(String, Type)>,
495    /// `@psalm-assert-if-true Type $var`
496    pub assertions_if_true: Vec<(String, Type)>,
497    /// `@psalm-assert-if-false Type $var`
498    pub assertions_if_false: Vec<(String, Type)>,
499    /// `@psalm-suppress IssueName`
500    pub suppressed_issues: Vec<String>,
501    pub is_deprecated: bool,
502    pub is_internal: bool,
503    pub is_pure: bool,
504    pub is_mutation_free: bool,
505    pub is_external_mutation_free: bool,
506    pub no_named_arguments: bool,
507    pub is_immutable: bool,
508    pub is_readonly: bool,
509    pub is_api: bool,
510    /// `@final` — class should be treated as final even without the PHP `final` keyword.
511    pub is_final: bool,
512    /// `@inheritDoc` or `{@inheritDoc}` was present — documentation should be
513    /// inherited from the nearest ancestor that has a real docblock.
514    pub is_inherit_doc: bool,
515    /// Free text before first `@` tag — used for hover display
516    pub description: String,
517    /// `@deprecated message` — Some(message) or Some("") if no message
518    pub deprecated: Option<String>,
519    /// `@see ClassName` / `@link URL`
520    pub see: Vec<String>,
521    /// `@mixin ClassName`
522    pub mixins: Vec<String>,
523    /// `@property`, `@property-read`, `@property-write`
524    pub properties: Vec<DocProperty>,
525    /// `@method [static] ReturnType name([params])`
526    pub methods: Vec<DocMethod>,
527    /// `@psalm-type Alias = TypeExpr` / `@phpstan-type Alias = TypeExpr`
528    pub type_aliases: Vec<DocTypeAlias>,
529    /// `@psalm-import-type Alias from SourceClass` / `@phpstan-import-type ...`
530    pub import_types: Vec<DocImportType>,
531    /// `@psalm-require-extends ClassName` / `@phpstan-require-extends ClassName`
532    pub require_extends: Vec<String>,
533    /// `@psalm-require-implements InterfaceName` / `@phpstan-require-implements InterfaceName`
534    pub require_implements: Vec<String>,
535    /// `@since X.Y` — first PHP version this symbol exists in.
536    pub since: Option<String>,
537    /// `@removed X.Y` — first PHP version this symbol no longer exists in.
538    pub removed: Option<String>,
539    /// Malformed type annotations detected during parsing.
540    pub invalid_annotations: Vec<String>,
541    /// `@mir-check $var is TYPE` — (var_name_without_dollar, type_string)
542    pub mir_checks: Vec<(String, String)>,
543    /// `@trace $var1, $var2` or `@trace $var1 $var2` — variable names to trace
544    pub trace_vars: Vec<String>,
545    /// `@taint-sink <kind> $param` — (param_name_without_dollar, sink_kind_string)
546    pub taint_sinks: Vec<(String, String)>,
547    /// `@seal-properties` / `@psalm-seal-properties` — disallows undeclared property access.
548    pub seal_properties: bool,
549    /// `@if-this-is Type` / `@psalm-if-this-is Type` — the method may only be
550    /// called when `$this` satisfies this type. Stored as the raw parsed type;
551    /// class names are resolved later by the collector.
552    pub if_this_is: Option<Type>,
553}
554
555impl ParsedDocblock {
556    /// Returns the type for a given parameter name (strips leading `$`).
557    ///
558    /// Uses the **last** match so that `@psalm-param` / `@phpstan-param` (which
559    /// php-rs-parser maps to the same `Param` variant as `@param`) overrides a
560    /// preceding plain `@param` annotation.
561    pub fn get_param_type(&self, name: &str) -> Option<&Type> {
562        let name = name.trim_start_matches('$');
563        self.params
564            .iter()
565            .rfind(|(n, _)| n.trim_start_matches('$') == name)
566            .map(|(_, ty)| ty)
567    }
568
569    /// Returns the `@param-out` / `@psalm-param-out` type for a given parameter
570    /// name, if declared. Uses the **last** match.
571    pub fn get_out_param_type(&self, name: &str) -> Option<&Type> {
572        let name = name.trim_start_matches('$');
573        self.out_params
574            .iter()
575            .rfind(|(n, _)| n.trim_start_matches('$') == name)
576            .map(|(_, ty)| ty)
577    }
578}
579
580// ---------------------------------------------------------------------------
581// Type string parser
582// ---------------------------------------------------------------------------
583
584#[cfg(test)]
585mod tests;
586/// Parse a PHPDoc type expression string into a `Type`.
587/// Handles: `string`, `int|null`, `array<string>`, `list<int>`,
588/// `ClassName`, `?string` (nullable), `string[]` (array shorthand).
589mod types;
590mod validate;
591
592use types::*;
593use validate::*;
594
595pub(crate) use types::parse_type_string;