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