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 record_backslash_keyword_types(
29 &mut result,
30 "param-out",
31 &ty_s,
32 tag.span.start,
33 text,
34 );
35 if let Some(msg) = validate_type_str(&ty_s, "param-out") {
36 result.invalid_annotations.push(msg);
37 } else {
38 result.out_params.push((
39 name.trim_start_matches('$').to_string(),
40 parse_type_string(&ty_s),
41 ));
42 }
43 }
44 }
45 }
46 "param" | "psalm-param" | "phpstan-param" | "phan-param" => {
47 if let Some(body_str) = body_text(&tag.body) {
48 if let Some((ty_s, name)) = parse_param_line(&body_str) {
49 record_backslash_keyword_types(
50 &mut result,
51 "param",
52 &ty_s,
53 tag.span.start,
54 text,
55 );
56 // Check if the parsed type is valid
57 if is_inside_generics(&ty_s) {
58 // For unclosed generics, report the full body for context
59 if let Some(msg) = validate_type_str(&body_str, "param") {
60 result.invalid_annotations.push(msg);
61 }
62 } else if let Some(msg) = validate_type_str(&ty_s, "param") {
63 // For other errors, report the parsed type
64 result.invalid_annotations.push(msg);
65 } else {
66 result
67 .param_type_strings
68 .push((name.trim_start_matches('$').to_string(), ty_s.clone()));
69 let bare = name.trim_start_matches('$').to_string();
70 let desc = param_description(&body_str, &ty_s, &bare);
71 if !desc.is_empty() {
72 result.param_descriptions.push((bare.clone(), desc));
73 }
74 result.params.push((bare, parse_type_string(&ty_s)));
75 }
76 } else if let Some(msg) = validate_type_str(&body_str, "param") {
77 // If parsing failed, validate the full body to provide better error context
78 result.invalid_annotations.push(msg);
79 record_backslash_keyword_types(
80 &mut result,
81 "param",
82 &body_str,
83 tag.span.start,
84 text,
85 );
86 }
87 }
88 }
89 "return" | "psalm-return" | "phpstan-return" => {
90 if let Some(body_str) = body_text(&tag.body) {
91 let ty_s = extract_return_type(&body_str);
92 record_backslash_keyword_types(
93 &mut result,
94 "return",
95 &ty_s,
96 tag.span.start,
97 text,
98 );
99 if let Some(msg) = validate_type_str(&ty_s, "return") {
100 result.invalid_annotations.push(msg);
101 }
102 result.return_type = Some(parse_type_string(&ty_s));
103 let desc = description_after(&body_str, &ty_s);
104 if !desc.is_empty() {
105 result.return_description = Some(desc);
106 }
107 }
108 }
109 "var" | "psalm-var" | "phpstan-var" => {
110 if let Some(body_str) = body_text(&tag.body) {
111 if let Some((ty_s, name)) = parse_var_line(&body_str) {
112 record_backslash_keyword_types(
113 &mut result,
114 "var",
115 &ty_s,
116 tag.span.start,
117 text,
118 );
119 if let Some(msg) = validate_type_str(&ty_s, "var") {
120 result.invalid_annotations.push(msg);
121 }
122 let name = name.trim_start_matches('$').to_string();
123 let ty = parse_type_string(&ty_s);
124 result.var_tags.push((Some(name.clone()), ty.clone()));
125 result.var_type = Some(ty);
126 result.var_name = Some(name);
127 } else {
128 // Spaces inside PHP types only appear within <…> generics.
129 // Stop at top-level whitespace to exclude description text that
130 // follows the type in multi-line @var bodies.
131 let ty_s = extract_type_prefix(body_str.trim());
132 record_backslash_keyword_types(
133 &mut result,
134 "var",
135 ty_s,
136 tag.span.start,
137 text,
138 );
139 if let Some(msg) = validate_type_str(ty_s, "var") {
140 result.invalid_annotations.push(msg);
141 }
142 let ty = parse_type_string(ty_s);
143 result.var_tags.push((None, ty.clone()));
144 result.var_type = Some(ty);
145 }
146 }
147 }
148 "throws" => {
149 if let Some(body_str) = body_text(&tag.body) {
150 let first_word = body_str.split_whitespace().next().unwrap_or("");
151 let desc = description_after(&body_str, first_word);
152 for class in first_word.split('|') {
153 if !class.is_empty() {
154 record_backslash_keyword_types(
155 &mut result,
156 "throws",
157 class,
158 tag.span.start,
159 text,
160 );
161 result.throws.push(class.to_string());
162 if !desc.is_empty() {
163 result
164 .throws_descriptions
165 .push((class.to_string(), desc.clone()));
166 }
167 }
168 }
169 }
170 }
171 "deprecated" => {
172 result.is_deprecated = true;
173 result.deprecated = Some(body_text(&tag.body).unwrap_or_default().to_string());
174 }
175 "template" | "psalm-template" | "phpstan-template" => {
176 if let Some((name, bound, default)) =
177 parse_template_line(tag.name.as_str(), body_text(&tag.body))
178 {
179 if let Some(msg) = validate_type_str(&name, "template") {
180 result.invalid_annotations.push(msg);
181 }
182 if let Some(b) = &bound {
183 record_backslash_keyword_types(
184 &mut result,
185 "template",
186 b,
187 tag.span.start,
188 text,
189 );
190 if let Some(msg) = validate_type_str(b, "template") {
191 result.invalid_annotations.push(msg);
192 }
193 }
194 result.templates.push((
195 name,
196 bound.map(|b| parse_type_string(&b)),
197 Variance::Invariant,
198 default.map(|d| parse_type_string(&d)),
199 ));
200 }
201 }
202 "template-covariant"
203 | "psalm-template-covariant"
204 | "phpstan-template-covariant" => {
205 if let Some((name, bound, default)) =
206 parse_template_line(tag.name.as_str(), body_text(&tag.body))
207 {
208 if let Some(msg) = validate_type_str(&name, "template-covariant") {
209 result.invalid_annotations.push(msg);
210 }
211 if let Some(b) = &bound {
212 record_backslash_keyword_types(
213 &mut result,
214 "template-covariant",
215 b,
216 tag.span.start,
217 text,
218 );
219 if let Some(msg) = validate_type_str(b, "template-covariant") {
220 result.invalid_annotations.push(msg);
221 }
222 }
223 result.templates.push((
224 name,
225 bound.map(|b| parse_type_string(&b)),
226 Variance::Covariant,
227 default.map(|d| parse_type_string(&d)),
228 ));
229 }
230 }
231 "template-contravariant"
232 | "psalm-template-contravariant"
233 | "phpstan-template-contravariant" => {
234 if let Some((name, bound, default)) =
235 parse_template_line(tag.name.as_str(), body_text(&tag.body))
236 {
237 if let Some(msg) = validate_type_str(&name, "template-contravariant") {
238 result.invalid_annotations.push(msg);
239 }
240 if let Some(b) = &bound {
241 record_backslash_keyword_types(
242 &mut result,
243 "template-contravariant",
244 b,
245 tag.span.start,
246 text,
247 );
248 if let Some(msg) = validate_type_str(b, "template-contravariant") {
249 result.invalid_annotations.push(msg);
250 }
251 }
252 result.templates.push((
253 name,
254 bound.map(|b| parse_type_string(&b)),
255 Variance::Contravariant,
256 default.map(|d| parse_type_string(&d)),
257 ));
258 }
259 }
260 "extends" | "template-extends" | "psalm-extends" | "phpstan-extends" => {
261 if let Some(body_str) = body_text(&tag.body) {
262 let trimmed = body_str.trim();
263 record_backslash_keyword_types(
264 &mut result,
265 "extends",
266 trimmed,
267 tag.span.start,
268 text,
269 );
270 if let Some(msg) = validate_type_str(trimmed, "extends") {
271 result.invalid_annotations.push(msg);
272 }
273 result.extends.push(parse_type_string(trimmed));
274 }
275 }
276 "implements"
277 | "template-implements"
278 | "psalm-implements"
279 | "phpstan-implements" => {
280 if let Some(body_str) = body_text(&tag.body) {
281 let trimmed = body_str.trim();
282 record_backslash_keyword_types(
283 &mut result,
284 "implements",
285 trimmed,
286 tag.span.start,
287 text,
288 );
289 if let Some(msg) = validate_type_str(trimmed, "implements") {
290 result.invalid_annotations.push(msg);
291 }
292 result.implements.push(parse_type_string(trimmed));
293 }
294 }
295 "use" | "template-use" | "psalm-use" | "phpstan-use" => {
296 if let Some(body_str) = body_text(&tag.body) {
297 let trimmed = body_str.trim();
298 record_backslash_keyword_types(
299 &mut result,
300 "use",
301 trimmed,
302 tag.span.start,
303 text,
304 );
305 if let Some(msg) = validate_type_str(trimmed, "use") {
306 result.invalid_annotations.push(msg);
307 }
308 result.uses.push(parse_type_string(trimmed));
309 }
310 }
311 "assert" | "psalm-assert" | "phpstan-assert" => {
312 if let Some(body_str) = body_text(&tag.body) {
313 if let Some((ty_str, name)) = parse_param_line(&body_str) {
314 let (ty, negated) = parse_assertion_type(&ty_str);
315 let (name, key) = split_array_key_suffix(&name);
316 result.assertions.push((name, key, ty, negated));
317 }
318 }
319 }
320 "if-this-is" | "psalm-if-this-is" | "phpstan-if-this-is" => {
321 if let Some(body_str) = body_text(&tag.body) {
322 let trimmed = body_str.trim();
323 if !trimmed.is_empty() {
324 result.if_this_is = Some(parse_type_string(trimmed));
325 }
326 }
327 }
328 "self-out" | "psalm-self-out" | "phpstan-self-out" | "this-out"
329 | "psalm-this-out" | "phpstan-this-out" => {
330 if let Some(body_str) = body_text(&tag.body) {
331 let trimmed = body_str.trim();
332 if !trimmed.is_empty() {
333 result.self_out = Some(parse_self_out_type(trimmed));
334 }
335 }
336 }
337 "suppress" | "psalm-suppress" => {
338 if let Some(body_str) = body_text(&tag.body) {
339 for rule in body_str.split([',', ' ']) {
340 let rule = rule.trim().to_string();
341 if !rule.is_empty() {
342 result.suppressed_issues.push(rule);
343 }
344 }
345 }
346 }
347 "see" => {
348 if let Some(body_str) = body_text(&tag.body) {
349 result.see.push(body_str.to_string());
350 }
351 }
352 "link" => {
353 if let Some(body_str) = body_text(&tag.body) {
354 result.see.push(body_str.to_string());
355 }
356 }
357 "mixin" => {
358 if let Some(body_str) = body_text(&tag.body) {
359 let base_class =
360 body_str.split('<').next().unwrap_or(&body_str).to_string();
361 result.mixins.push(base_class);
362 }
363 }
364 "property" => {
365 if let Some(body_str) = body_text(&tag.body) {
366 if let Some((ty_str, name)) = parse_param_line(&body_str) {
367 record_backslash_keyword_types(
368 &mut result,
369 "property",
370 &ty_str,
371 tag.span.start,
372 text,
373 );
374 result.properties.push(DocProperty {
375 tag_span: tag_head_span(text, tag.span.start, tag.span.end),
376 type_hint: ty_str,
377 name: name.trim_start_matches('$').to_string(),
378 read_only: false,
379 write_only: false,
380 });
381 }
382 }
383 }
384 "property-read" => {
385 if let Some(body_str) = body_text(&tag.body) {
386 if let Some((ty_str, name)) = parse_param_line(&body_str) {
387 record_backslash_keyword_types(
388 &mut result,
389 "property-read",
390 &ty_str,
391 tag.span.start,
392 text,
393 );
394 result.properties.push(DocProperty {
395 tag_span: tag_head_span(text, tag.span.start, tag.span.end),
396 type_hint: ty_str,
397 name: name.trim_start_matches('$').to_string(),
398 read_only: true,
399 write_only: false,
400 });
401 }
402 }
403 }
404 "property-write" => {
405 if let Some(body_str) = body_text(&tag.body) {
406 if let Some((ty_str, name)) = parse_param_line(&body_str) {
407 record_backslash_keyword_types(
408 &mut result,
409 "property-write",
410 &ty_str,
411 tag.span.start,
412 text,
413 );
414 result.properties.push(DocProperty {
415 tag_span: tag_head_span(text, tag.span.start, tag.span.end),
416 type_hint: ty_str,
417 name: name.trim_start_matches('$').to_string(),
418 read_only: false,
419 write_only: true,
420 });
421 }
422 }
423 }
424 "method" | "psalm-method" => {
425 let body_str = body_text(&tag.body).unwrap_or_default().trim().to_string();
426 if let Some(err) = validate_method_body(&body_str) {
427 result.invalid_annotations.push(err);
428 } else if let Some(mut m) = parse_method_line(&body_str) {
429 m.tag_span = tag_head_span(text, tag.span.start, tag.span.end);
430 for p in &m.params {
431 record_backslash_keyword_types(
432 &mut result,
433 "method",
434 &p.type_hint,
435 tag.span.start,
436 text,
437 );
438 }
439 record_backslash_keyword_types(
440 &mut result,
441 "method",
442 &m.return_type,
443 tag.span.start,
444 text,
445 );
446 result.methods.push(m);
447 }
448 }
449 "psalm-type" | "phpstan-type" => {
450 if let Some(body_str) = body_text(&tag.body) {
451 // Real Psalm's `@psalm-type Name = Expr` requires the `=`;
452 // real PHPStan's `@phpstan-type Name Expr` doesn't — split
453 // on the first whitespace instead when there's no `=` and
454 // this is the PHPStan spelling. A `phpstan-type` written
455 // WITH `=` (some codebases mix conventions) still works via
456 // the primary split.
457 let split = body_str.split_once('=').or_else(|| {
458 (tag.name == "phpstan-type")
459 .then(|| body_str.split_once(char::is_whitespace))
460 .flatten()
461 });
462 if let Some((name, type_expr)) = split {
463 // Strip a generic alias name's `<T>` suffix
464 // (`ListOf<T> = array<int, T>`) so bare use sites
465 // (`ListOf`) resolve. Substituting T at a
466 // parameterized use site (`ListOf<int>`) isn't
467 // modeled: the template parameter list is
468 // discarded here.
469 let raw_name = name.trim();
470 let name = raw_name
471 .split('<')
472 .next()
473 .unwrap_or(raw_name)
474 .trim()
475 .to_string();
476 result.type_aliases.push(DocTypeAlias {
477 name,
478 type_expr: type_expr.trim().to_string(),
479 });
480 }
481 }
482 }
483 "psalm-import-type" | "phpstan-import-type" => {
484 if let Some(body_str) = body_text(&tag.body) {
485 if let Some(import) = parse_import_type(&body_str) {
486 result.import_types.push(import);
487 }
488 }
489 }
490 "since" if result.since.is_none() => {
491 if let Some(body_str) = body_text(&tag.body) {
492 let v = body_str.split_whitespace().next().unwrap_or("");
493 if !v.is_empty() {
494 result.since = Some(v.to_string());
495 }
496 }
497 }
498 "removed" if result.removed.is_none() => {
499 if let Some(body_str) = body_text(&tag.body) {
500 let v = body_str.split_whitespace().next().unwrap_or("");
501 if !v.is_empty() {
502 result.removed = Some(v.to_string());
503 }
504 }
505 }
506 "internal" | "psalm-internal" => result.is_internal = true,
507 "pure" | "psalm-pure" | "phpstan-pure" => result.is_pure = true,
508 "impure" | "psalm-impure" | "phpstan-impure" => result.is_impure = true,
509 "seal-properties" | "psalm-seal-properties" => result.seal_properties = true,
510 "no-named-arguments" => result.no_named_arguments = true,
511 "mutation-free" | "psalm-mutation-free" | "phpstan-mutation-free" => {
512 result.is_mutation_free = true
513 }
514 "external-mutation-free"
515 | "psalm-external-mutation-free"
516 | "phpstan-external-mutation-free" => result.is_external_mutation_free = true,
517 "immutable" | "psalm-immutable" | "phpstan-immutable" => result.is_immutable = true,
518 "readonly" | "psalm-readonly" | "phpstan-readonly" => result.is_readonly = true,
519 "final" => result.is_final = true,
520 "inheritDoc" | "inheritdoc" => result.is_inherit_doc = true,
521 "api" | "psalm-api" => result.is_api = true,
522 "assert-if-true" | "psalm-assert-if-true" | "phpstan-assert-if-true" => {
523 if let Some(body_str) = body_text(&tag.body) {
524 if let Some((ty_str, name)) = parse_param_line(&body_str) {
525 let (ty, negated) = parse_assertion_type(&ty_str);
526 let (name, key) = split_array_key_suffix(&name);
527 result.assertions_if_true.push((name, key, ty, negated));
528 }
529 }
530 }
531 "assert-if-false" | "psalm-assert-if-false" | "phpstan-assert-if-false" => {
532 if let Some(body_str) = body_text(&tag.body) {
533 if let Some((ty_str, name)) = parse_param_line(&body_str) {
534 let (ty, negated) = parse_assertion_type(&ty_str);
535 let (name, key) = split_array_key_suffix(&name);
536 result.assertions_if_false.push((name, key, ty, negated));
537 }
538 }
539 }
540 "psalm-property" => {
541 if let Some(body_str) = body_text(&tag.body) {
542 if let Some((ty_str, name)) = parse_param_line(&body_str) {
543 result.properties.push(DocProperty {
544 tag_span: tag_head_span(text, tag.span.start, tag.span.end),
545 type_hint: ty_str,
546 name,
547 read_only: false,
548 write_only: false,
549 });
550 }
551 }
552 }
553 "psalm-property-read" => {
554 if let Some(body_str) = body_text(&tag.body) {
555 if let Some((ty_str, name)) = parse_param_line(&body_str) {
556 result.properties.push(DocProperty {
557 tag_span: tag_head_span(text, tag.span.start, tag.span.end),
558 type_hint: ty_str,
559 name,
560 read_only: true,
561 write_only: false,
562 });
563 }
564 }
565 }
566 "psalm-property-write" => {
567 if let Some(body_str) = body_text(&tag.body) {
568 if let Some((ty_str, name)) = parse_param_line(&body_str) {
569 result.properties.push(DocProperty {
570 tag_span: tag_head_span(text, tag.span.start, tag.span.end),
571 type_hint: ty_str,
572 name,
573 read_only: false,
574 write_only: true,
575 });
576 }
577 }
578 }
579 "psalm-require-extends" | "phpstan-require-extends" => {
580 if let Some(body_str) = body_text(&tag.body) {
581 let cls = body_str
582 .split_whitespace()
583 .next()
584 .unwrap_or("")
585 .trim()
586 .to_string();
587 if !cls.is_empty() {
588 result.require_extends.push(cls);
589 }
590 }
591 }
592 "psalm-require-implements" | "phpstan-require-implements" => {
593 if let Some(body_str) = body_text(&tag.body) {
594 let cls = body_str
595 .split_whitespace()
596 .next()
597 .unwrap_or("")
598 .trim()
599 .to_string();
600 if !cls.is_empty() {
601 result.require_implements.push(cls);
602 }
603 }
604 }
605 "mir-check" => {
606 if let Some(body_str) = body_text(&tag.body) {
607 if let Some((expr_part, type_part)) = body_str.split_once(" is ") {
608 // Kept verbatim (including any leading `$`) — the
609 // consumer parses this as a real PHP expression,
610 // not just a bare variable name.
611 let expr_text = expr_part.trim().to_string();
612 let type_string = type_part.trim().to_string();
613 if !expr_text.is_empty() && !type_string.is_empty() {
614 result.mir_checks.push((expr_text, type_string));
615 }
616 }
617 }
618 }
619 "dataProvider" => {
620 if let Some(body_str) = body_text(&tag.body) {
621 let name = body_str
622 .trim()
623 .trim_end_matches("()")
624 .rsplit("::")
625 .next()
626 .unwrap_or("")
627 .trim();
628 if !name.is_empty() {
629 result.data_providers.push(name.to_string());
630 }
631 }
632 }
633 "trace" => {
634 if let Some(body_str) = body_text(&tag.body) {
635 // Support both comma-separated and space-separated variable names
636 for part in body_str.split([',', ' ']) {
637 let var_name = part.trim().trim_start_matches('$').to_string();
638 if !var_name.is_empty() {
639 result.trace_vars.push(var_name);
640 }
641 }
642 }
643 }
644 "taint-sink" => {
645 if let Some(body_str) = body_text(&tag.body) {
646 // Format: `kind $param` or `kind $param1 $param2`
647 let mut tokens = body_str.split_whitespace();
648 if let Some(kind) = tokens.next() {
649 let kind = kind.to_string();
650 for param_token in tokens {
651 let param = param_token.trim_start_matches('$').to_string();
652 if !param.is_empty() {
653 result.taint_sinks.push((param, kind.clone()));
654 }
655 }
656 }
657 }
658 }
659 "taint-source" => result.is_taint_source = true,
660 _ => {}
661 }
662 }
663
664 if text.to_ascii_lowercase().contains("{@inheritdoc}") {
665 result.is_inherit_doc = true;
666 }
667
668 result
669 }
670}
671
672/// Record the backslash-qualified non-class (`\int`) members of a docblock
673/// type spelling in `result` — the collector turns each into an
674/// `InvalidDocblockType` warning anchored at the token's own source line.
675fn record_backslash_keyword_types(
676 result: &mut ParsedDocblock,
677 tag_name: &str,
678 ty: &str,
679 tag_start: u32,
680 text: &str,
681) {
682 for token in backslash_keyword_members(ty) {
683 let (line, col) = token_line_col(text, tag_start, ty, &token);
684 result.backslash_keyword_types.push(BackslashKeywordType {
685 tag: tag_name.to_string(),
686 token,
687 line,
688 col,
689 });
690 }
691}
692
693/// 0-based `(line, col)` of `token` within the docblock `text`. `token` is a
694/// verbatim member of `ty`, which sits in the tag body that follows `@tag`;
695/// it is located relative to `tag_start` (the tag's offset in the text).
696/// Falls back to the tag start when the token cannot be found.
697fn token_line_col(text: &str, tag_start: u32, ty: &str, token: &str) -> (u32, u32) {
698 let start = (tag_start as usize).min(text.len());
699 let mut abs = start;
700 if let Some(rest) = text.get(abs..) {
701 if let Some(ty_off) = rest.find(ty) {
702 if let Some(ty_text) = text.get(abs + ty_off..) {
703 if let Some(tok_off) = ty_text.find(token) {
704 abs = (abs + ty_off + tok_off).min(text.len());
705 }
706 }
707 }
708 }
709 let mut line = 0u32;
710 let mut line_start = 0usize;
711 for (i, b) in text.bytes().enumerate() {
712 if i >= abs {
713 break;
714 }
715 if b == b'\n' {
716 line += 1;
717 line_start = i + 1;
718 }
719 }
720 (line, (abs - line_start) as u32)
721}
722
723/// `self<T>`/`static<T>`/`parent<T>`/`$this<T>` written in a self-out
724/// annotation (`@psalm-self-out`, `@phpstan-self-out`, `@psalm-this-out`)
725/// needs its `<T>` kept intact for method-level template substitution (e.g.
726/// `@psalm-self-out self<U>` on a method with its own `@template U`) —
727/// unlike an ordinary `@return self<T>`, where the shared type parser
728/// deliberately drops the args and reattaches the receiver's own params
729/// instead (see `parse_generic`'s `self`/`static`/`parent` arms). Parsed
730/// here as a `TNamedObject` sentinel whose `fqcn` is the literal keyword
731/// (never a real PHP class name), which `substitute_static_atom` recognizes
732/// and resolves to the actual receiver class at call time while keeping
733/// `type_params` intact for the caller's later template substitution.
734fn parse_self_out_type(trimmed: &str) -> Type {
735 for keyword in ["self", "static", "parent", "$this"] {
736 let Some(rest) = strip_ascii_ci_prefix(trimmed, keyword) else {
737 continue;
738 };
739 let rest = rest.trim_start();
740 if let Some(inner) = rest.strip_prefix('<').and_then(|s| s.strip_suffix('>')) {
741 let params: Vec<Type> = split_generics(inner)
742 .iter()
743 .map(|p| parse_type_string(p.trim()))
744 .collect();
745 return Type::single(Atomic::TNamedObject {
746 fqcn: mir_types::Name::from(keyword),
747 type_params: mir_types::union::vec_to_type_params(params),
748 });
749 }
750 }
751 parse_type_string(trimmed)
752}
753
754// ---------------------------------------------------------------------------
755// ParsedDocblock support types
756// ---------------------------------------------------------------------------
757
758/// Byte range of a tag's first line, without trailing whitespace or the
759/// docblock's closing `*/`.
760fn tag_head_span(text: &str, start: impl TryInto<usize>, end: impl TryInto<usize>) -> (u32, u32) {
761 let (Ok(start), Ok(end)) = (start.try_into(), end.try_into()) else {
762 return (0, 0);
763 };
764 let end = end.min(text.len());
765 let Some(raw) = text.get(start..end) else {
766 return (start as u32, end as u32);
767 };
768 let head = raw.lines().next().unwrap_or(raw);
769 let head = head.trim_end().trim_end_matches("*/").trim_end();
770 (start as u32, (start + head.len()) as u32)
771}
772
773#[derive(Debug, Default, Clone)]
774pub struct DocProperty {
775 pub type_hint: String,
776 pub name: String, // without leading $
777 pub read_only: bool, // true for @property-read
778 pub write_only: bool, // true for @property-write
779 /// Byte range of the tag within the docblock text.
780 pub tag_span: (u32, u32),
781}
782
783#[derive(Debug, Default, Clone)]
784pub struct DocMethod {
785 pub return_type: String,
786 pub name: String,
787 pub is_static: bool,
788 pub params: Vec<DocMethodParam>,
789 /// Byte range of the tag within the docblock text.
790 pub tag_span: (u32, u32),
791}
792
793#[derive(Debug, Default, Clone)]
794pub struct DocMethodParam {
795 pub name: String,
796 pub type_hint: String,
797 pub is_variadic: bool,
798 pub is_byref: bool,
799 pub is_optional: bool,
800}
801
802#[derive(Debug, Default, Clone)]
803pub struct DocTypeAlias {
804 pub name: String,
805 pub type_expr: String,
806}
807
808/// A docblock type token written with a leading `\` (the fully-qualified
809/// class qualifier) on a non-class type keyword (`\int`, `?\string`, …).
810/// `line`/`col` are 0-based positions within the docblock text, so the
811/// collector can anchor the warning at the token's own source line.
812#[derive(Debug, Clone)]
813pub struct BackslashKeywordType {
814 /// The annotation tag carrying the type (`param`, `return`, `var`, …).
815 pub tag: String,
816 /// The offending type token, verbatim with its leading backslash.
817 pub token: String,
818 /// Line of the token within the docblock text.
819 pub line: u32,
820 /// Column of the token within its docblock-text line.
821 pub col: u32,
822}
823
824#[derive(Debug, Default, Clone)]
825pub struct DocImportType {
826 /// The name exported by the source class (the original alias name).
827 pub original: String,
828 /// The local name to use in this class (`as LocalAlias`); defaults to `original`.
829 pub local: String,
830 /// The FQCN of the class to import the type from.
831 pub from_class: String,
832}
833
834// ---------------------------------------------------------------------------
835// ParsedDocblock
836// ---------------------------------------------------------------------------
837
838#[derive(Debug, Default, Clone)]
839pub struct ParsedDocblock {
840 /// `@param Type $name`
841 pub params: Vec<(String, Type)>,
842 /// Raw `@param` type spellings keyed by parameter name. Kept so later
843 /// analysis can distinguish ambiguous pseudo-type aliases like
844 /// `integer`/`boolean` from a same-named class that should take
845 /// precedence once the full symbol table is available.
846 pub param_type_strings: Vec<(String, String)>,
847 /// `@param-out Type $name` / `@psalm-param-out Type $name` — the type written
848 /// back to the caller's by-ref argument after the call.
849 pub out_params: Vec<(String, Type)>,
850 /// `@return Type`
851 pub return_type: Option<Type>,
852 /// `@var Type` or `@var Type $name` — type and optional variable name (last tag wins)
853 pub var_type: Option<Type>,
854 /// Optional variable name from `@var Type $name`
855 pub var_name: Option<String>,
856 /// Every `@var` tag in docblock order, for statement-level annotations.
857 pub var_tags: Vec<(Option<String>, Type)>,
858 /// `@template T` / `@template T of Bound` / `@template-covariant T` / `@template-contravariant T`
859 /// The last element is the optional `@template T = Default` default type.
860 pub templates: Vec<(String, Option<Type>, Variance, Option<Type>)>,
861 /// `@extends ClassName<T>` — a class has at most one entry (its single
862 /// parent); an interface may have several, one per base interface named
863 /// in its native `extends A, B` clause.
864 pub extends: Vec<Type>,
865 /// `@implements InterfaceName<T>`
866 pub implements: Vec<Type>,
867 /// `@use TraitName<T>` — explicit type argument(s) for a `use`d trait's
868 /// own `@template`, mirroring `@implements` for interfaces.
869 pub uses: Vec<Type>,
870 /// `@throws ClassName`
871 pub throws: Vec<String>,
872 /// `(parameter name, prose)` for each `@param` that has a description.
873 pub param_descriptions: Vec<(String, String)>,
874 /// Prose after the type of `@return`.
875 pub return_description: Option<String>,
876 /// `(class as written, prose)` for each `@throws` that has a description.
877 pub throws_descriptions: Vec<(String, String)>,
878 /// `@psalm-assert Type $var` — the `bool` is true for the `!Type` negated
879 /// form; the key path is non-empty when the target is a specific
880 /// (possibly nested) array key of `$var` (`@psalm-assert Type
881 /// $var['a']['b']`) rather than the whole variable.
882 pub assertions: Vec<(String, Vec<ArrayKey>, Type, bool)>,
883 /// `@psalm-assert-if-true Type $var`
884 pub assertions_if_true: Vec<(String, Vec<ArrayKey>, Type, bool)>,
885 /// `@psalm-assert-if-false Type $var`
886 pub assertions_if_false: Vec<(String, Vec<ArrayKey>, Type, bool)>,
887 /// `@psalm-suppress IssueName`
888 pub suppressed_issues: Vec<String>,
889 pub is_deprecated: bool,
890 pub is_internal: bool,
891 pub is_pure: bool,
892 /// An explicit `@impure`/`@psalm-impure`/`@phpstan-impure` declaration.
893 /// This opts an implementation out of an inherited purity contract.
894 pub is_impure: bool,
895 pub is_mutation_free: bool,
896 pub is_external_mutation_free: bool,
897 pub no_named_arguments: bool,
898 pub is_immutable: bool,
899 pub is_readonly: bool,
900 pub is_api: bool,
901 /// `@final` — class should be treated as final even without the PHP `final` keyword.
902 pub is_final: bool,
903 /// `@inheritDoc` or `{@inheritDoc}` was present — documentation should be
904 /// inherited from the nearest ancestor that has a real docblock.
905 pub is_inherit_doc: bool,
906 /// Free text before first `@` tag — used for hover display
907 pub description: String,
908 /// `@deprecated message` — Some(message) or Some("") if no message
909 pub deprecated: Option<String>,
910 /// `@see ClassName` / `@link URL`
911 pub see: Vec<String>,
912 /// `@mixin ClassName`
913 pub mixins: Vec<String>,
914 /// `@property`, `@property-read`, `@property-write`
915 pub properties: Vec<DocProperty>,
916 /// `@method [static] ReturnType name([params])`
917 pub methods: Vec<DocMethod>,
918 /// `@psalm-type Alias = TypeExpr` / `@phpstan-type Alias = TypeExpr`
919 pub type_aliases: Vec<DocTypeAlias>,
920 /// `@psalm-import-type Alias from SourceClass` / `@phpstan-import-type ...`
921 pub import_types: Vec<DocImportType>,
922 /// `@psalm-require-extends ClassName` / `@phpstan-require-extends ClassName`
923 pub require_extends: Vec<String>,
924 /// `@psalm-require-implements InterfaceName` / `@phpstan-require-implements InterfaceName`
925 pub require_implements: Vec<String>,
926 /// `@since X.Y` — first PHP version this symbol exists in.
927 pub since: Option<String>,
928 /// `@removed X.Y` — first PHP version this symbol no longer exists in.
929 pub removed: Option<String>,
930 /// Malformed type annotations detected during parsing.
931 pub invalid_annotations: Vec<String>,
932 /// Docblock type spellings that use a leading `\` (the fully-qualified
933 /// class qualifier) on a non-class type keyword (`\int`, `?\string`, …);
934 /// the collector emits an `InvalidDocblockType` warning for each, anchored
935 /// at the token's own line.
936 pub backslash_keyword_types: Vec<BackslashKeywordType>,
937 /// `@mir-check EXPR is TYPE` — (expr_text, type_string). `expr_text` is
938 /// kept verbatim (e.g. `$h->status`, `self::$prop`, `$arr['key']`) and
939 /// parsed as a real PHP expression by the consumer, not just a bare
940 /// variable name.
941 pub mir_checks: Vec<(String, String)>,
942 /// `@trace $var1, $var2` or `@trace $var1 $var2` — variable names to trace
943 pub trace_vars: Vec<String>,
944 /// `@taint-sink <kind> $param` — (param_name_without_dollar, sink_kind_string)
945 pub taint_sinks: Vec<(String, String)>,
946 /// `@taint-source` — this function/method's return value is tainted.
947 pub is_taint_source: bool,
948 /// `@seal-properties` / `@psalm-seal-properties` — disallows undeclared property access.
949 pub seal_properties: bool,
950 /// `@if-this-is Type` / `@psalm-if-this-is Type` — the method may only be
951 /// called when `$this` satisfies this type. Stored as the raw parsed type;
952 /// class names are resolved later by the collector.
953 pub if_this_is: Option<Type>,
954 /// `@psalm-self-out Type` / `@phpstan-self-out Type` — the receiver's type
955 /// after this call returns. Stored as the raw parsed type; class names
956 /// (and `self`/`static`) are resolved later by the collector.
957 pub self_out: Option<Type>,
958 /// `@dataProvider methodName` (PHPUnit) — name of the method that supplies
959 /// this test's data, invoked by PHPUnit via reflection rather than a call.
960 pub data_providers: Vec<String>,
961}
962
963impl ParsedDocblock {
964 /// Returns the type for a given parameter name (strips leading `$`).
965 ///
966 /// Uses the **last** match so that `@psalm-param` / `@phpstan-param` (which
967 /// php-rs-parser maps to the same `Param` variant as `@param`) overrides a
968 /// preceding plain `@param` annotation.
969 pub fn get_param_type(&self, name: &str) -> Option<&Type> {
970 let name = name.trim_start_matches('$');
971 self.params
972 .iter()
973 .rfind(|(n, _)| n.trim_start_matches('$') == name)
974 .map(|(_, ty)| ty)
975 }
976
977 /// Returns the raw `@param` type spelling for a given parameter name.
978 /// Uses the **last** match, mirroring [`Self::get_param_type`].
979 pub fn get_param_type_string(&self, name: &str) -> Option<&str> {
980 let name = name.trim_start_matches('$');
981 self.param_type_strings
982 .iter()
983 .rfind(|(n, _)| n.trim_start_matches('$') == name)
984 .map(|(_, ty)| ty.as_str())
985 }
986
987 /// Returns the `@param-out` / `@psalm-param-out` type for a given parameter
988 /// name, if declared. Uses the **last** match.
989 pub fn get_out_param_type(&self, name: &str) -> Option<&Type> {
990 let name = name.trim_start_matches('$');
991 self.out_params
992 .iter()
993 .rfind(|(n, _)| n.trim_start_matches('$') == name)
994 .map(|(_, ty)| ty)
995 }
996}
997
998// ---------------------------------------------------------------------------
999// Type string parser
1000// ---------------------------------------------------------------------------
1001
1002#[cfg(test)]
1003mod tests;
1004/// Parse a PHPDoc type expression string into a `Type`.
1005/// Handles: `string`, `int|null`, `array<string>`, `list<int>`,
1006/// `ClassName`, `?string` (nullable), `string[]` (array shorthand).
1007mod types;
1008mod validate;
1009
1010pub(crate) use types::SelfIntConstantsGuard;
1011use types::*;
1012use validate::*;
1013
1014pub(crate) use types::is_docblock_type_keyword;
1015pub(crate) use types::parse_type_string;