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