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