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" | "phan-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
53 .param_type_strings
54 .push((name.trim_start_matches('$').to_string(), ty_s.clone()));
55 result.params.push((
56 name.trim_start_matches('$').to_string(),
57 parse_type_string(&ty_s),
58 ));
59 }
60 } else if let Some(msg) = validate_type_str(&body_str, "param") {
61 // If parsing failed, validate the full body to provide better error context
62 result.invalid_annotations.push(msg);
63 }
64 }
65 }
66 "return" | "psalm-return" | "phpstan-return" => {
67 if let Some(body_str) = body_text(&tag.body) {
68 let ty_s = extract_return_type(&body_str);
69 if let Some(msg) = validate_type_str(&ty_s, "return") {
70 result.invalid_annotations.push(msg);
71 }
72 result.return_type = Some(parse_type_string(&ty_s));
73 }
74 }
75 "var" | "psalm-var" | "phpstan-var" => {
76 if let Some(body_str) = body_text(&tag.body) {
77 if let Some((ty_s, name)) = parse_var_line(&body_str) {
78 if let Some(msg) = validate_type_str(&ty_s, "var") {
79 result.invalid_annotations.push(msg);
80 }
81 result.var_type = Some(parse_type_string(&ty_s));
82 result.var_name = Some(name.trim_start_matches('$').to_string());
83 } else {
84 // Spaces inside PHP types only appear within <…> generics.
85 // Stop at top-level whitespace to exclude description text that
86 // follows the type in multi-line @var bodies.
87 let ty_s = extract_type_prefix(body_str.trim());
88 if let Some(msg) = validate_type_str(ty_s, "var") {
89 result.invalid_annotations.push(msg);
90 }
91 result.var_type = Some(parse_type_string(ty_s));
92 }
93 }
94 }
95 "throws" => {
96 if let Some(body_str) = body_text(&tag.body) {
97 let first_word = body_str.split_whitespace().next().unwrap_or("");
98 for class in first_word.split('|') {
99 if !class.is_empty() {
100 result.throws.push(class.to_string());
101 }
102 }
103 }
104 }
105 "deprecated" => {
106 result.is_deprecated = true;
107 result.deprecated = Some(body_text(&tag.body).unwrap_or_default().to_string());
108 }
109 "template" | "psalm-template" | "phpstan-template" => {
110 if let Some((name, bound, default)) =
111 parse_template_line(tag.name.as_str(), body_text(&tag.body))
112 {
113 if let Some(msg) = validate_type_str(&name, "template") {
114 result.invalid_annotations.push(msg);
115 }
116 if let Some(b) = &bound {
117 if let Some(msg) = validate_type_str(b, "template") {
118 result.invalid_annotations.push(msg);
119 }
120 }
121 result.templates.push((
122 name,
123 bound.map(|b| parse_type_string(&b)),
124 Variance::Invariant,
125 default.map(|d| parse_type_string(&d)),
126 ));
127 }
128 }
129 "template-covariant"
130 | "psalm-template-covariant"
131 | "phpstan-template-covariant" => {
132 if let Some((name, bound, default)) =
133 parse_template_line(tag.name.as_str(), body_text(&tag.body))
134 {
135 if let Some(msg) = validate_type_str(&name, "template-covariant") {
136 result.invalid_annotations.push(msg);
137 }
138 if let Some(b) = &bound {
139 if let Some(msg) = validate_type_str(b, "template-covariant") {
140 result.invalid_annotations.push(msg);
141 }
142 }
143 result.templates.push((
144 name,
145 bound.map(|b| parse_type_string(&b)),
146 Variance::Covariant,
147 default.map(|d| parse_type_string(&d)),
148 ));
149 }
150 }
151 "template-contravariant"
152 | "psalm-template-contravariant"
153 | "phpstan-template-contravariant" => {
154 if let Some((name, bound, default)) =
155 parse_template_line(tag.name.as_str(), body_text(&tag.body))
156 {
157 if let Some(msg) = validate_type_str(&name, "template-contravariant") {
158 result.invalid_annotations.push(msg);
159 }
160 if let Some(b) = &bound {
161 if let Some(msg) = validate_type_str(b, "template-contravariant") {
162 result.invalid_annotations.push(msg);
163 }
164 }
165 result.templates.push((
166 name,
167 bound.map(|b| parse_type_string(&b)),
168 Variance::Contravariant,
169 default.map(|d| parse_type_string(&d)),
170 ));
171 }
172 }
173 "extends" | "template-extends" | "psalm-extends" | "phpstan-extends" => {
174 if let Some(body_str) = body_text(&tag.body) {
175 let trimmed = body_str.trim();
176 if let Some(msg) = validate_type_str(trimmed, "extends") {
177 result.invalid_annotations.push(msg);
178 }
179 result.extends.push(parse_type_string(trimmed));
180 }
181 }
182 "implements"
183 | "template-implements"
184 | "psalm-implements"
185 | "phpstan-implements" => {
186 if let Some(body_str) = body_text(&tag.body) {
187 let trimmed = body_str.trim();
188 if let Some(msg) = validate_type_str(trimmed, "implements") {
189 result.invalid_annotations.push(msg);
190 }
191 result.implements.push(parse_type_string(trimmed));
192 }
193 }
194 "use" | "template-use" | "psalm-use" | "phpstan-use" => {
195 if let Some(body_str) = body_text(&tag.body) {
196 let trimmed = body_str.trim();
197 if let Some(msg) = validate_type_str(trimmed, "use") {
198 result.invalid_annotations.push(msg);
199 }
200 result.uses.push(parse_type_string(trimmed));
201 }
202 }
203 "assert" | "psalm-assert" | "phpstan-assert" => {
204 if let Some(body_str) = body_text(&tag.body) {
205 if let Some((ty_str, name)) = parse_param_line(&body_str) {
206 let (ty, negated) = parse_assertion_type(&ty_str);
207 let (name, key) = split_array_key_suffix(&name);
208 result.assertions.push((name, key, ty, negated));
209 }
210 }
211 }
212 "if-this-is" | "psalm-if-this-is" | "phpstan-if-this-is" => {
213 if let Some(body_str) = body_text(&tag.body) {
214 let trimmed = body_str.trim();
215 if !trimmed.is_empty() {
216 result.if_this_is = Some(parse_type_string(trimmed));
217 }
218 }
219 }
220 "self-out" | "psalm-self-out" | "phpstan-self-out" | "this-out"
221 | "psalm-this-out" | "phpstan-this-out" => {
222 if let Some(body_str) = body_text(&tag.body) {
223 let trimmed = body_str.trim();
224 if !trimmed.is_empty() {
225 result.self_out = Some(parse_self_out_type(trimmed));
226 }
227 }
228 }
229 "suppress" | "psalm-suppress" => {
230 if let Some(body_str) = body_text(&tag.body) {
231 for rule in body_str.split([',', ' ']) {
232 let rule = rule.trim().to_string();
233 if !rule.is_empty() {
234 result.suppressed_issues.push(rule);
235 }
236 }
237 }
238 }
239 "see" => {
240 if let Some(body_str) = body_text(&tag.body) {
241 result.see.push(body_str.to_string());
242 }
243 }
244 "link" => {
245 if let Some(body_str) = body_text(&tag.body) {
246 result.see.push(body_str.to_string());
247 }
248 }
249 "mixin" => {
250 if let Some(body_str) = body_text(&tag.body) {
251 let base_class =
252 body_str.split('<').next().unwrap_or(&body_str).to_string();
253 result.mixins.push(base_class);
254 }
255 }
256 "property" => {
257 if let Some(body_str) = body_text(&tag.body) {
258 if let Some((ty_str, name)) = parse_param_line(&body_str) {
259 result.properties.push(DocProperty {
260 type_hint: ty_str,
261 name: name.trim_start_matches('$').to_string(),
262 read_only: false,
263 write_only: false,
264 });
265 }
266 }
267 }
268 "property-read" => {
269 if let Some(body_str) = body_text(&tag.body) {
270 if let Some((ty_str, name)) = parse_param_line(&body_str) {
271 result.properties.push(DocProperty {
272 type_hint: ty_str,
273 name: name.trim_start_matches('$').to_string(),
274 read_only: true,
275 write_only: false,
276 });
277 }
278 }
279 }
280 "property-write" => {
281 if let Some(body_str) = body_text(&tag.body) {
282 if let Some((ty_str, name)) = parse_param_line(&body_str) {
283 result.properties.push(DocProperty {
284 type_hint: ty_str,
285 name: name.trim_start_matches('$').to_string(),
286 read_only: false,
287 write_only: true,
288 });
289 }
290 }
291 }
292 "method" | "psalm-method" => {
293 let body_str = body_text(&tag.body).unwrap_or_default().trim().to_string();
294 if let Some(err) = validate_method_body(&body_str) {
295 result.invalid_annotations.push(err);
296 } else if let Some(m) = parse_method_line(&body_str) {
297 result.methods.push(m);
298 }
299 }
300 "psalm-type" | "phpstan-type" => {
301 if let Some(body_str) = body_text(&tag.body) {
302 // Real Psalm's `@psalm-type Name = Expr` requires the `=`;
303 // real PHPStan's `@phpstan-type Name Expr` doesn't — split
304 // on the first whitespace instead when there's no `=` and
305 // this is the PHPStan spelling. A `phpstan-type` written
306 // WITH `=` (some codebases mix conventions) still works via
307 // the primary split.
308 let split = body_str.split_once('=').or_else(|| {
309 (tag.name == "phpstan-type")
310 .then(|| body_str.split_once(char::is_whitespace))
311 .flatten()
312 });
313 if let Some((name, type_expr)) = split {
314 // A generic alias name (`ListOf<T> = array<int, T>`)
315 // kept the `<T>` suffix verbatim, so even a BARE
316 // (non-parameterized) use site's lookup by the
317 // plain name (`ListOf`) never matched — the alias
318 // was silently 100% dead. Strip the suffix so at
319 // least bare usage resolves; substituting T at a
320 // parameterized use site (`ListOf<int>`) stays a
321 // separate, not-yet-modeled problem (the template
322 // parameter list itself is discarded here, same
323 // as before).
324 let raw_name = name.trim();
325 let name = raw_name
326 .split('<')
327 .next()
328 .unwrap_or(raw_name)
329 .trim()
330 .to_string();
331 result.type_aliases.push(DocTypeAlias {
332 name,
333 type_expr: type_expr.trim().to_string(),
334 });
335 }
336 }
337 }
338 "psalm-import-type" | "phpstan-import-type" => {
339 if let Some(body_str) = body_text(&tag.body) {
340 if let Some(import) = parse_import_type(&body_str) {
341 result.import_types.push(import);
342 }
343 }
344 }
345 "since" if result.since.is_none() => {
346 if let Some(body_str) = body_text(&tag.body) {
347 let v = body_str.split_whitespace().next().unwrap_or("");
348 if !v.is_empty() {
349 result.since = Some(v.to_string());
350 }
351 }
352 }
353 "removed" if result.removed.is_none() => {
354 if let Some(body_str) = body_text(&tag.body) {
355 let v = body_str.split_whitespace().next().unwrap_or("");
356 if !v.is_empty() {
357 result.removed = Some(v.to_string());
358 }
359 }
360 }
361 "internal" | "psalm-internal" => result.is_internal = true,
362 "pure" | "psalm-pure" | "phpstan-pure" => result.is_pure = true,
363 "seal-properties" | "psalm-seal-properties" => result.seal_properties = true,
364 "no-named-arguments" => result.no_named_arguments = true,
365 "mutation-free" | "psalm-mutation-free" | "phpstan-mutation-free" => {
366 result.is_mutation_free = true
367 }
368 "external-mutation-free"
369 | "psalm-external-mutation-free"
370 | "phpstan-external-mutation-free" => result.is_external_mutation_free = true,
371 "immutable" | "psalm-immutable" | "phpstan-immutable" => result.is_immutable = true,
372 "readonly" | "psalm-readonly" | "phpstan-readonly" => result.is_readonly = true,
373 "final" => result.is_final = true,
374 "inheritDoc" | "inheritdoc" => result.is_inherit_doc = true,
375 "api" | "psalm-api" => result.is_api = true,
376 "assert-if-true" | "psalm-assert-if-true" | "phpstan-assert-if-true" => {
377 if let Some(body_str) = body_text(&tag.body) {
378 if let Some((ty_str, name)) = parse_param_line(&body_str) {
379 let (ty, negated) = parse_assertion_type(&ty_str);
380 let (name, key) = split_array_key_suffix(&name);
381 result.assertions_if_true.push((name, key, ty, negated));
382 }
383 }
384 }
385 "assert-if-false" | "psalm-assert-if-false" | "phpstan-assert-if-false" => {
386 if let Some(body_str) = body_text(&tag.body) {
387 if let Some((ty_str, name)) = parse_param_line(&body_str) {
388 let (ty, negated) = parse_assertion_type(&ty_str);
389 let (name, key) = split_array_key_suffix(&name);
390 result.assertions_if_false.push((name, key, ty, negated));
391 }
392 }
393 }
394 "psalm-property" => {
395 if let Some(body_str) = body_text(&tag.body) {
396 if let Some((ty_str, name)) = parse_param_line(&body_str) {
397 result.properties.push(DocProperty {
398 type_hint: ty_str,
399 name,
400 read_only: false,
401 write_only: false,
402 });
403 }
404 }
405 }
406 "psalm-property-read" => {
407 if let Some(body_str) = body_text(&tag.body) {
408 if let Some((ty_str, name)) = parse_param_line(&body_str) {
409 result.properties.push(DocProperty {
410 type_hint: ty_str,
411 name,
412 read_only: true,
413 write_only: false,
414 });
415 }
416 }
417 }
418 "psalm-property-write" => {
419 if let Some(body_str) = body_text(&tag.body) {
420 if let Some((ty_str, name)) = parse_param_line(&body_str) {
421 result.properties.push(DocProperty {
422 type_hint: ty_str,
423 name,
424 read_only: false,
425 write_only: true,
426 });
427 }
428 }
429 }
430 "psalm-require-extends" | "phpstan-require-extends" => {
431 if let Some(body_str) = body_text(&tag.body) {
432 let cls = body_str
433 .split_whitespace()
434 .next()
435 .unwrap_or("")
436 .trim()
437 .to_string();
438 if !cls.is_empty() {
439 result.require_extends.push(cls);
440 }
441 }
442 }
443 "psalm-require-implements" | "phpstan-require-implements" => {
444 if let Some(body_str) = body_text(&tag.body) {
445 let cls = body_str
446 .split_whitespace()
447 .next()
448 .unwrap_or("")
449 .trim()
450 .to_string();
451 if !cls.is_empty() {
452 result.require_implements.push(cls);
453 }
454 }
455 }
456 "mir-check" => {
457 if let Some(body_str) = body_text(&tag.body) {
458 if let Some((expr_part, type_part)) = body_str.split_once(" is ") {
459 // Kept verbatim (including any leading `$`) — the
460 // consumer parses this as a real PHP expression,
461 // not just a bare variable name.
462 let expr_text = expr_part.trim().to_string();
463 let type_string = type_part.trim().to_string();
464 if !expr_text.is_empty() && !type_string.is_empty() {
465 result.mir_checks.push((expr_text, type_string));
466 }
467 }
468 }
469 }
470 "dataProvider" => {
471 if let Some(body_str) = body_text(&tag.body) {
472 let name = body_str
473 .trim()
474 .trim_end_matches("()")
475 .rsplit("::")
476 .next()
477 .unwrap_or("")
478 .trim();
479 if !name.is_empty() {
480 result.data_providers.push(name.to_string());
481 }
482 }
483 }
484 "trace" => {
485 if let Some(body_str) = body_text(&tag.body) {
486 // Support both comma-separated and space-separated variable names
487 for part in body_str.split([',', ' ']) {
488 let var_name = part.trim().trim_start_matches('$').to_string();
489 if !var_name.is_empty() {
490 result.trace_vars.push(var_name);
491 }
492 }
493 }
494 }
495 "taint-sink" => {
496 if let Some(body_str) = body_text(&tag.body) {
497 // Format: `kind $param` or `kind $param1 $param2`
498 let mut tokens = body_str.split_whitespace();
499 if let Some(kind) = tokens.next() {
500 let kind = kind.to_string();
501 for param_token in tokens {
502 let param = param_token.trim_start_matches('$').to_string();
503 if !param.is_empty() {
504 result.taint_sinks.push((param, kind.clone()));
505 }
506 }
507 }
508 }
509 }
510 "taint-source" => result.is_taint_source = true,
511 _ => {}
512 }
513 }
514
515 if text.to_ascii_lowercase().contains("{@inheritdoc}") {
516 result.is_inherit_doc = true;
517 }
518
519 result
520 }
521}
522
523/// `self<T>`/`static<T>`/`parent<T>`/`$this<T>` written in a self-out
524/// annotation (`@psalm-self-out`, `@phpstan-self-out`, `@psalm-this-out`)
525/// needs its `<T>` kept intact for method-level template substitution (e.g.
526/// `@psalm-self-out self<U>` on a method with its own `@template U`) —
527/// unlike an ordinary `@return self<T>`, where the shared type parser
528/// deliberately drops the args and reattaches the receiver's own params
529/// instead (see `parse_generic`'s `self`/`static`/`parent` arms). Parsed
530/// here as a `TNamedObject` sentinel whose `fqcn` is the literal keyword
531/// (never a real PHP class name), which `substitute_static_atom` recognizes
532/// and resolves to the actual receiver class at call time while keeping
533/// `type_params` intact for the caller's later template substitution.
534fn parse_self_out_type(trimmed: &str) -> Type {
535 for keyword in ["self", "static", "parent", "$this"] {
536 let Some(rest) = strip_ascii_ci_prefix(trimmed, keyword) else {
537 continue;
538 };
539 let rest = rest.trim_start();
540 if let Some(inner) = rest.strip_prefix('<').and_then(|s| s.strip_suffix('>')) {
541 let params: Vec<Type> = split_generics(inner)
542 .iter()
543 .map(|p| parse_type_string(p.trim()))
544 .collect();
545 return Type::single(Atomic::TNamedObject {
546 fqcn: mir_types::Name::from(keyword),
547 type_params: mir_types::union::vec_to_type_params(params),
548 });
549 }
550 }
551 parse_type_string(trimmed)
552}
553
554// ---------------------------------------------------------------------------
555// ParsedDocblock support types
556// ---------------------------------------------------------------------------
557
558#[derive(Debug, Default, Clone)]
559pub struct DocProperty {
560 pub type_hint: String,
561 pub name: String, // without leading $
562 pub read_only: bool, // true for @property-read
563 pub write_only: bool, // true for @property-write
564}
565
566#[derive(Debug, Default, Clone)]
567pub struct DocMethod {
568 pub return_type: String,
569 pub name: String,
570 pub is_static: bool,
571 pub params: Vec<DocMethodParam>,
572}
573
574#[derive(Debug, Default, Clone)]
575pub struct DocMethodParam {
576 pub name: String,
577 pub type_hint: String,
578 pub is_variadic: bool,
579 pub is_byref: bool,
580 pub is_optional: bool,
581}
582
583#[derive(Debug, Default, Clone)]
584pub struct DocTypeAlias {
585 pub name: String,
586 pub type_expr: String,
587}
588
589#[derive(Debug, Default, Clone)]
590pub struct DocImportType {
591 /// The name exported by the source class (the original alias name).
592 pub original: String,
593 /// The local name to use in this class (`as LocalAlias`); defaults to `original`.
594 pub local: String,
595 /// The FQCN of the class to import the type from.
596 pub from_class: String,
597}
598
599// ---------------------------------------------------------------------------
600// ParsedDocblock
601// ---------------------------------------------------------------------------
602
603#[derive(Debug, Default, Clone)]
604pub struct ParsedDocblock {
605 /// `@param Type $name`
606 pub params: Vec<(String, Type)>,
607 /// Raw `@param` type spellings keyed by parameter name. Kept so later
608 /// analysis can distinguish ambiguous pseudo-type aliases like
609 /// `integer`/`boolean` from a same-named class that should take
610 /// precedence once the full symbol table is available.
611 pub param_type_strings: Vec<(String, String)>,
612 /// `@param-out Type $name` / `@psalm-param-out Type $name` — the type written
613 /// back to the caller's by-ref argument after the call.
614 pub out_params: Vec<(String, Type)>,
615 /// `@return Type`
616 pub return_type: Option<Type>,
617 /// `@var Type` or `@var Type $name` — type and optional variable name
618 pub var_type: Option<Type>,
619 /// Optional variable name from `@var Type $name`
620 pub var_name: Option<String>,
621 /// `@template T` / `@template T of Bound` / `@template-covariant T` / `@template-contravariant T`
622 /// The last element is the optional `@template T = Default` default type.
623 pub templates: Vec<(String, Option<Type>, Variance, Option<Type>)>,
624 /// `@extends ClassName<T>` — a class has at most one entry (its single
625 /// parent); an interface may have several, one per base interface named
626 /// in its native `extends A, B` clause.
627 pub extends: Vec<Type>,
628 /// `@implements InterfaceName<T>`
629 pub implements: Vec<Type>,
630 /// `@use TraitName<T>` — explicit type argument(s) for a `use`d trait's
631 /// own `@template`, mirroring `@implements` for interfaces.
632 pub uses: Vec<Type>,
633 /// `@throws ClassName`
634 pub throws: Vec<String>,
635 /// `@psalm-assert Type $var` — the `bool` is true for the `!Type` negated
636 /// form; the key path is non-empty when the target is a specific
637 /// (possibly nested) array key of `$var` (`@psalm-assert Type
638 /// $var['a']['b']`) rather than the whole variable.
639 pub assertions: Vec<(String, Vec<ArrayKey>, Type, bool)>,
640 /// `@psalm-assert-if-true Type $var`
641 pub assertions_if_true: Vec<(String, Vec<ArrayKey>, Type, bool)>,
642 /// `@psalm-assert-if-false Type $var`
643 pub assertions_if_false: Vec<(String, Vec<ArrayKey>, Type, bool)>,
644 /// `@psalm-suppress IssueName`
645 pub suppressed_issues: Vec<String>,
646 pub is_deprecated: bool,
647 pub is_internal: bool,
648 pub is_pure: bool,
649 pub is_mutation_free: bool,
650 pub is_external_mutation_free: bool,
651 pub no_named_arguments: bool,
652 pub is_immutable: bool,
653 pub is_readonly: bool,
654 pub is_api: bool,
655 /// `@final` — class should be treated as final even without the PHP `final` keyword.
656 pub is_final: bool,
657 /// `@inheritDoc` or `{@inheritDoc}` was present — documentation should be
658 /// inherited from the nearest ancestor that has a real docblock.
659 pub is_inherit_doc: bool,
660 /// Free text before first `@` tag — used for hover display
661 pub description: String,
662 /// `@deprecated message` — Some(message) or Some("") if no message
663 pub deprecated: Option<String>,
664 /// `@see ClassName` / `@link URL`
665 pub see: Vec<String>,
666 /// `@mixin ClassName`
667 pub mixins: Vec<String>,
668 /// `@property`, `@property-read`, `@property-write`
669 pub properties: Vec<DocProperty>,
670 /// `@method [static] ReturnType name([params])`
671 pub methods: Vec<DocMethod>,
672 /// `@psalm-type Alias = TypeExpr` / `@phpstan-type Alias = TypeExpr`
673 pub type_aliases: Vec<DocTypeAlias>,
674 /// `@psalm-import-type Alias from SourceClass` / `@phpstan-import-type ...`
675 pub import_types: Vec<DocImportType>,
676 /// `@psalm-require-extends ClassName` / `@phpstan-require-extends ClassName`
677 pub require_extends: Vec<String>,
678 /// `@psalm-require-implements InterfaceName` / `@phpstan-require-implements InterfaceName`
679 pub require_implements: Vec<String>,
680 /// `@since X.Y` — first PHP version this symbol exists in.
681 pub since: Option<String>,
682 /// `@removed X.Y` — first PHP version this symbol no longer exists in.
683 pub removed: Option<String>,
684 /// Malformed type annotations detected during parsing.
685 pub invalid_annotations: Vec<String>,
686 /// `@mir-check EXPR is TYPE` — (expr_text, type_string). `expr_text` is
687 /// kept verbatim (e.g. `$h->status`, `self::$prop`, `$arr['key']`) and
688 /// parsed as a real PHP expression by the consumer, not just a bare
689 /// variable name.
690 pub mir_checks: Vec<(String, String)>,
691 /// `@trace $var1, $var2` or `@trace $var1 $var2` — variable names to trace
692 pub trace_vars: Vec<String>,
693 /// `@taint-sink <kind> $param` — (param_name_without_dollar, sink_kind_string)
694 pub taint_sinks: Vec<(String, String)>,
695 /// `@taint-source` — this function/method's return value is tainted.
696 pub is_taint_source: bool,
697 /// `@seal-properties` / `@psalm-seal-properties` — disallows undeclared property access.
698 pub seal_properties: bool,
699 /// `@if-this-is Type` / `@psalm-if-this-is Type` — the method may only be
700 /// called when `$this` satisfies this type. Stored as the raw parsed type;
701 /// class names are resolved later by the collector.
702 pub if_this_is: Option<Type>,
703 /// `@psalm-self-out Type` / `@phpstan-self-out Type` — the receiver's type
704 /// after this call returns. Stored as the raw parsed type; class names
705 /// (and `self`/`static`) are resolved later by the collector.
706 pub self_out: Option<Type>,
707 /// `@dataProvider methodName` (PHPUnit) — name of the method that supplies
708 /// this test's data, invoked by PHPUnit via reflection rather than a call.
709 pub data_providers: Vec<String>,
710}
711
712impl ParsedDocblock {
713 /// Returns the type for a given parameter name (strips leading `$`).
714 ///
715 /// Uses the **last** match so that `@psalm-param` / `@phpstan-param` (which
716 /// php-rs-parser maps to the same `Param` variant as `@param`) overrides a
717 /// preceding plain `@param` annotation.
718 pub fn get_param_type(&self, name: &str) -> Option<&Type> {
719 let name = name.trim_start_matches('$');
720 self.params
721 .iter()
722 .rfind(|(n, _)| n.trim_start_matches('$') == name)
723 .map(|(_, ty)| ty)
724 }
725
726 /// Returns the raw `@param` type spelling for a given parameter name.
727 /// Uses the **last** match, mirroring [`get_param_type`].
728 pub fn get_param_type_string(&self, name: &str) -> Option<&str> {
729 let name = name.trim_start_matches('$');
730 self.param_type_strings
731 .iter()
732 .rfind(|(n, _)| n.trim_start_matches('$') == name)
733 .map(|(_, ty)| ty.as_str())
734 }
735
736 /// Returns the `@param-out` / `@psalm-param-out` type for a given parameter
737 /// name, if declared. Uses the **last** match.
738 pub fn get_out_param_type(&self, name: &str) -> Option<&Type> {
739 let name = name.trim_start_matches('$');
740 self.out_params
741 .iter()
742 .rfind(|(n, _)| n.trim_start_matches('$') == name)
743 .map(|(_, ty)| ty)
744 }
745}
746
747// ---------------------------------------------------------------------------
748// Type string parser
749// ---------------------------------------------------------------------------
750
751#[cfg(test)]
752mod tests;
753/// Parse a PHPDoc type expression string into a `Type`.
754/// Handles: `string`, `int|null`, `array<string>`, `list<int>`,
755/// `ClassName`, `?string` (nullable), `string[]` (array shorthand).
756mod types;
757mod validate;
758
759pub(crate) use types::SelfIntConstantsGuard;
760use types::*;
761use validate::*;
762
763pub(crate) use types::parse_type_string;