1use mir_types::{ArrayKey, Atomic, Union, Variance};
2use std::sync::Arc;
5
6use indexmap::IndexMap;
7use phpdoc_parser::{body_text, parse as parse_phpdoc};
8
9pub 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" | "psalm-param" | "phpstan-param" => {
26 if let Some(body_str) = body_text(&tag.body) {
27 if let Some((ty_s, name)) = parse_param_line(&body_str) {
28 if is_inside_generics(&ty_s) {
30 if let Some(msg) = validate_type_str(&body_str, "param") {
32 result.invalid_annotations.push(msg);
33 }
34 } else if let Some(msg) = validate_type_str(&ty_s, "param") {
35 result.invalid_annotations.push(msg);
37 } else {
38 result.params.push((
39 name.trim_start_matches('$').to_string(),
40 parse_type_string(&ty_s),
41 ));
42 }
43 } else if let Some(msg) = validate_type_str(&body_str, "param") {
44 result.invalid_annotations.push(msg);
46 }
47 }
48 }
49 "return" | "psalm-return" | "phpstan-return" => {
50 if let Some(body_str) = body_text(&tag.body) {
51 let ty_s = extract_return_type(&body_str);
52 if let Some(msg) = validate_type_str(&ty_s, "return") {
53 result.invalid_annotations.push(msg);
54 }
55 result.return_type = Some(parse_type_string(&ty_s));
56 }
57 }
58 "var" => {
59 if let Some(body_str) = body_text(&tag.body) {
60 if let Some((ty_s, name)) = parse_param_line(&body_str) {
61 if let Some(msg) = validate_type_str(&ty_s, "var") {
62 result.invalid_annotations.push(msg);
63 }
64 result.var_type = Some(parse_type_string(&ty_s));
65 result.var_name = Some(name.trim_start_matches('$').to_string());
66 } else {
67 let ty_s = body_str.trim();
68 if let Some(msg) = validate_type_str(ty_s, "var") {
69 result.invalid_annotations.push(msg);
70 }
71 result.var_type = Some(parse_type_string(ty_s));
72 }
73 }
74 }
75 "throws" => {
76 if let Some(body_str) = body_text(&tag.body) {
77 let class = body_str.split_whitespace().next().unwrap_or("").to_string();
78 if !class.is_empty() {
79 result.throws.push(class);
80 }
81 }
82 }
83 "deprecated" => {
84 result.is_deprecated = true;
85 result.deprecated = Some(body_text(&tag.body).unwrap_or_default().to_string());
86 }
87 "template" => {
88 if let Some((name, bound)) =
89 parse_template_line(tag.name.as_str(), body_text(&tag.body))
90 {
91 if let Some(b) = &bound {
92 if let Some(msg) = validate_type_str(b, "template") {
93 result.invalid_annotations.push(msg);
94 }
95 }
96 result.templates.push((
97 name,
98 bound.map(|b| parse_type_string(&b)),
99 Variance::Invariant,
100 ));
101 }
102 }
103 "template-covariant" => {
104 if let Some((name, bound)) =
105 parse_template_line(tag.name.as_str(), body_text(&tag.body))
106 {
107 if let Some(b) = &bound {
108 if let Some(msg) = validate_type_str(b, "template-covariant") {
109 result.invalid_annotations.push(msg);
110 }
111 }
112 result.templates.push((
113 name,
114 bound.map(|b| parse_type_string(&b)),
115 Variance::Covariant,
116 ));
117 }
118 }
119 "template-contravariant" => {
120 if let Some((name, bound)) =
121 parse_template_line(tag.name.as_str(), body_text(&tag.body))
122 {
123 if let Some(b) = &bound {
124 if let Some(msg) = validate_type_str(b, "template-contravariant") {
125 result.invalid_annotations.push(msg);
126 }
127 }
128 result.templates.push((
129 name,
130 bound.map(|b| parse_type_string(&b)),
131 Variance::Contravariant,
132 ));
133 }
134 }
135 "extends" => {
136 if let Some(body_str) = body_text(&tag.body) {
137 result.extends = Some(parse_type_string(body_str.trim()));
138 }
139 }
140 "implements" => {
141 if let Some(body_str) = body_text(&tag.body) {
142 result.implements.push(parse_type_string(body_str.trim()));
143 }
144 }
145 "assert" | "psalm-assert" | "phpstan-assert" => {
146 if let Some(body_str) = body_text(&tag.body) {
147 if let Some((ty_str, name)) = parse_param_line(&body_str) {
148 result.assertions.push((name, parse_type_string(&ty_str)));
149 }
150 }
151 }
152 "suppress" | "psalm-suppress" => {
153 if let Some(body_str) = body_text(&tag.body) {
154 for rule in body_str.split([',', ' ']) {
155 let rule = rule.trim().to_string();
156 if !rule.is_empty() {
157 result.suppressed_issues.push(rule);
158 }
159 }
160 }
161 }
162 "see" => {
163 if let Some(body_str) = body_text(&tag.body) {
164 result.see.push(body_str.to_string());
165 }
166 }
167 "link" => {
168 if let Some(body_str) = body_text(&tag.body) {
169 result.see.push(body_str.to_string());
170 }
171 }
172 "mixin" => {
173 if let Some(body_str) = body_text(&tag.body) {
174 let base_class =
175 body_str.split('<').next().unwrap_or(&body_str).to_string();
176 result.mixins.push(base_class);
177 }
178 }
179 "property" => {
180 if let Some(body_str) = body_text(&tag.body) {
181 if let Some((ty_str, name)) = parse_param_line(&body_str) {
182 result.properties.push(DocProperty {
183 type_hint: ty_str,
184 name: name.trim_start_matches('$').to_string(),
185 read_only: false,
186 write_only: false,
187 });
188 }
189 }
190 }
191 "property-read" => {
192 if let Some(body_str) = body_text(&tag.body) {
193 if let Some((ty_str, name)) = parse_param_line(&body_str) {
194 result.properties.push(DocProperty {
195 type_hint: ty_str,
196 name: name.trim_start_matches('$').to_string(),
197 read_only: true,
198 write_only: false,
199 });
200 }
201 }
202 }
203 "property-write" => {
204 if let Some(body_str) = body_text(&tag.body) {
205 if let Some((ty_str, name)) = parse_param_line(&body_str) {
206 result.properties.push(DocProperty {
207 type_hint: ty_str,
208 name: name.trim_start_matches('$').to_string(),
209 read_only: false,
210 write_only: true,
211 });
212 }
213 }
214 }
215 "method" | "psalm-method" => {
216 if let Some(body_str) = body_text(&tag.body) {
217 if let Some(m) = parse_method_line(&body_str) {
218 result.methods.push(m);
219 }
220 }
221 }
222 "psalm-type" | "phpstan-type" => {
223 if let Some(body_str) = body_text(&tag.body) {
224 if let Some((name, type_expr)) = body_str.split_once('=') {
225 result.type_aliases.push(DocTypeAlias {
226 name: name.trim().to_string(),
227 type_expr: type_expr.trim().to_string(),
228 });
229 }
230 }
231 }
232 "psalm-import-type" | "phpstan-import-type" => {
233 if let Some(body_str) = body_text(&tag.body) {
234 if let Some(import) = parse_import_type(&body_str) {
235 result.import_types.push(import);
236 }
237 }
238 }
239 "since" if result.since.is_none() => {
240 if let Some(body_str) = body_text(&tag.body) {
241 let v = body_str.split_whitespace().next().unwrap_or("");
242 if !v.is_empty() {
243 result.since = Some(v.to_string());
244 }
245 }
246 }
247 "removed" if result.removed.is_none() => {
248 if let Some(body_str) = body_text(&tag.body) {
249 let v = body_str.split_whitespace().next().unwrap_or("");
250 if !v.is_empty() {
251 result.removed = Some(v.to_string());
252 }
253 }
254 }
255 "internal" => result.is_internal = true,
256 "pure" => result.is_pure = true,
257 "immutable" => result.is_immutable = true,
258 "readonly" => result.is_readonly = true,
259 "inheritDoc" | "inheritdoc" => result.is_inherit_doc = true,
260 "api" | "psalm-api" => result.is_api = true,
261 "psalm-assert-if-true" | "phpstan-assert-if-true" => {
262 if let Some(body_str) = body_text(&tag.body) {
263 if let Some((ty_str, name)) = parse_param_line(&body_str) {
264 result
265 .assertions_if_true
266 .push((name, parse_type_string(&ty_str)));
267 }
268 }
269 }
270 "psalm-assert-if-false" | "phpstan-assert-if-false" => {
271 if let Some(body_str) = body_text(&tag.body) {
272 if let Some((ty_str, name)) = parse_param_line(&body_str) {
273 result
274 .assertions_if_false
275 .push((name, parse_type_string(&ty_str)));
276 }
277 }
278 }
279 "psalm-property" => {
280 if let Some(body_str) = body_text(&tag.body) {
281 if let Some((ty_str, name)) = parse_param_line(&body_str) {
282 result.properties.push(DocProperty {
283 type_hint: ty_str,
284 name,
285 read_only: false,
286 write_only: false,
287 });
288 }
289 }
290 }
291 "psalm-property-read" => {
292 if let Some(body_str) = body_text(&tag.body) {
293 if let Some((ty_str, name)) = parse_param_line(&body_str) {
294 result.properties.push(DocProperty {
295 type_hint: ty_str,
296 name,
297 read_only: true,
298 write_only: false,
299 });
300 }
301 }
302 }
303 "psalm-property-write" => {
304 if let Some(body_str) = body_text(&tag.body) {
305 if let Some((ty_str, name)) = parse_param_line(&body_str) {
306 result.properties.push(DocProperty {
307 type_hint: ty_str,
308 name,
309 read_only: false,
310 write_only: true,
311 });
312 }
313 }
314 }
315 "psalm-require-extends" | "phpstan-require-extends" => {
316 if let Some(body_str) = body_text(&tag.body) {
317 let cls = body_str
318 .split_whitespace()
319 .next()
320 .unwrap_or("")
321 .trim()
322 .to_string();
323 if !cls.is_empty() {
324 result.require_extends.push(cls);
325 }
326 }
327 }
328 "psalm-require-implements" | "phpstan-require-implements" => {
329 if let Some(body_str) = body_text(&tag.body) {
330 let cls = body_str
331 .split_whitespace()
332 .next()
333 .unwrap_or("")
334 .trim()
335 .to_string();
336 if !cls.is_empty() {
337 result.require_implements.push(cls);
338 }
339 }
340 }
341 _ => {}
342 }
343 }
344
345 if text.to_ascii_lowercase().contains("{@inheritdoc}") {
346 result.is_inherit_doc = true;
347 }
348
349 result
350 }
351}
352
353#[derive(Debug, Default, Clone)]
358pub struct DocProperty {
359 pub type_hint: String,
360 pub name: String, pub read_only: bool, pub write_only: bool, }
364
365#[derive(Debug, Default, Clone)]
366pub struct DocMethod {
367 pub return_type: String,
368 pub name: String,
369 pub is_static: bool,
370 pub params: Vec<DocMethodParam>,
371}
372
373#[derive(Debug, Default, Clone)]
374pub struct DocMethodParam {
375 pub name: String,
376 pub type_hint: String,
377 pub is_variadic: bool,
378 pub is_byref: bool,
379 pub is_optional: bool,
380}
381
382#[derive(Debug, Default, Clone)]
383pub struct DocTypeAlias {
384 pub name: String,
385 pub type_expr: String,
386}
387
388#[derive(Debug, Default, Clone)]
389pub struct DocImportType {
390 pub original: String,
392 pub local: String,
394 pub from_class: String,
396}
397
398#[derive(Debug, Default, Clone)]
403pub struct ParsedDocblock {
404 pub params: Vec<(String, Union)>,
406 pub return_type: Option<Union>,
408 pub var_type: Option<Union>,
410 pub var_name: Option<String>,
412 pub templates: Vec<(String, Option<Union>, Variance)>,
414 pub extends: Option<Union>,
416 pub implements: Vec<Union>,
418 pub throws: Vec<String>,
420 pub assertions: Vec<(String, Union)>,
422 pub assertions_if_true: Vec<(String, Union)>,
424 pub assertions_if_false: Vec<(String, Union)>,
426 pub suppressed_issues: Vec<String>,
428 pub is_deprecated: bool,
429 pub is_internal: bool,
430 pub is_pure: bool,
431 pub is_immutable: bool,
432 pub is_readonly: bool,
433 pub is_api: bool,
434 pub is_inherit_doc: bool,
437 pub description: String,
439 pub deprecated: Option<String>,
441 pub see: Vec<String>,
443 pub mixins: Vec<String>,
445 pub properties: Vec<DocProperty>,
447 pub methods: Vec<DocMethod>,
449 pub type_aliases: Vec<DocTypeAlias>,
451 pub import_types: Vec<DocImportType>,
453 pub require_extends: Vec<String>,
455 pub require_implements: Vec<String>,
457 pub since: Option<String>,
459 pub removed: Option<String>,
461 pub invalid_annotations: Vec<String>,
463}
464
465impl ParsedDocblock {
466 pub fn get_param_type(&self, name: &str) -> Option<&Union> {
472 let name = name.trim_start_matches('$');
473 self.params
474 .iter()
475 .rfind(|(n, _)| n.trim_start_matches('$') == name)
476 .map(|(_, ty)| ty)
477 }
478}
479
480pub fn parse_type_string(s: &str) -> Union {
488 let s = s.trim();
489
490 if let Some(inner) = s.strip_prefix('?') {
492 let inner_ty = parse_type_string(inner);
493 let mut u = inner_ty;
494 u.add_type(Atomic::TNull);
495 return u;
496 }
497
498 if s.starts_with('(') && s.ends_with(')') {
500 let inner = s[1..s.len() - 1].trim();
501 if let Some(conditional) = parse_conditional_type(inner) {
502 return conditional;
503 }
504 }
505
506 if s.contains('|') && !is_inside_generics(s) {
508 let parts = split_union(s);
509 if parts.len() > 1 {
510 let mut u = Union::empty();
511 for part in parts {
512 for atomic in parse_type_string(&part).types {
513 u.add_type(atomic);
514 }
515 }
516 return u;
517 }
518 }
519
520 if s.contains('&') && !is_inside_generics(s) {
522 let parts: Vec<Union> = s.split('&').map(|p| parse_type_string(p.trim())).collect();
523 return Union::single(Atomic::TIntersection { parts });
524 }
525
526 if let Some(value_str) = s.strip_suffix("[]") {
528 let value = parse_type_string(value_str);
529 return Union::single(Atomic::TArray {
530 key: Box::new(Union::single(Atomic::TInt)),
531 value: Box::new(value),
532 });
533 }
534
535 if let Some(call_ty) = parse_callable_syntax(s) {
537 return call_ty;
538 }
539
540 if s.ends_with('}') {
542 if let Some(open) = s.find('{') {
543 let prefix = s[..open].to_lowercase();
544 let inner = &s[open + 1..s.len() - 1];
545 if prefix == "array" {
546 return parse_keyed_array(inner, false);
547 } else if prefix == "list" {
548 return parse_keyed_array(inner, true);
549 }
550 }
551 }
552
553 if let Some(open) = s.find('<') {
555 if s.ends_with('>') {
556 let name = &s[..open];
557 let inner = &s[open + 1..s.len() - 1];
558 return parse_generic(name, inner);
559 }
560 }
561
562 match s.to_lowercase().as_str() {
564 "string" => Union::single(Atomic::TString),
565 "non-empty-string" => Union::single(Atomic::TNonEmptyString),
566 "numeric-string" => Union::single(Atomic::TNumericString),
567 "class-string" => Union::single(Atomic::TClassString(None)),
568 "int" | "integer" => Union::single(Atomic::TInt),
569 "positive-int" => Union::single(Atomic::TPositiveInt),
570 "negative-int" => Union::single(Atomic::TNegativeInt),
571 "non-negative-int" => Union::single(Atomic::TNonNegativeInt),
572 "float" | "double" => Union::single(Atomic::TFloat),
573 "bool" | "boolean" => Union::single(Atomic::TBool),
574 "true" => Union::single(Atomic::TTrue),
575 "false" => Union::single(Atomic::TFalse),
576 "null" => Union::single(Atomic::TNull),
577 "void" => Union::single(Atomic::TVoid),
578 "never" | "never-return" | "no-return" | "never-returns" => Union::single(Atomic::TNever),
579 "mixed" => Union::single(Atomic::TMixed),
580 "object" => Union::single(Atomic::TObject),
581 "array" => Union::single(Atomic::TArray {
582 key: Box::new(Union::single(Atomic::TMixed)),
583 value: Box::new(Union::mixed()),
584 }),
585 "list" => Union::single(Atomic::TList {
586 value: Box::new(Union::mixed()),
587 }),
588 "callable" => Union::single(Atomic::TCallable {
589 params: None,
590 return_type: None,
591 }),
592 "iterable" => Union::single(Atomic::TArray {
593 key: Box::new(Union::single(Atomic::TMixed)),
594 value: Box::new(Union::mixed()),
595 }),
596 "scalar" => Union::single(Atomic::TScalar),
597 "numeric" => Union::single(Atomic::TNumeric),
598 "resource" => Union::mixed(), "static" => Union::single(Atomic::TStaticObject {
601 fqcn: Arc::from(""),
602 }),
603 "self" | "$this" => Union::single(Atomic::TSelf {
604 fqcn: Arc::from(""),
605 }),
606 "parent" => Union::single(Atomic::TParent {
607 fqcn: Arc::from(""),
608 }),
609
610 _ if !s.is_empty()
612 && s.chars()
613 .next()
614 .map(|c| c.is_alphanumeric() || c == '\\' || c == '_')
615 .unwrap_or(false) =>
616 {
617 Union::single(Atomic::TNamedObject {
618 fqcn: normalize_fqcn(s).into(),
619 type_params: vec![],
620 })
621 }
622
623 _ => Union::mixed(),
624 }
625}
626
627fn parse_generic(name: &str, inner: &str) -> Union {
628 match name.to_lowercase().as_str() {
629 "array" => {
630 let params = split_generics(inner);
631 let (key, value) = match params.len() {
632 n if n >= 2 => (
633 parse_type_string(params[0].trim()),
634 parse_type_string(params[1].trim()),
635 ),
636 1 => (
637 Union::single(Atomic::TInt),
638 parse_type_string(params[0].trim()),
639 ),
640 _ => (Union::single(Atomic::TInt), Union::mixed()),
641 };
642 Union::single(Atomic::TArray {
643 key: Box::new(key),
644 value: Box::new(value),
645 })
646 }
647 "list" | "non-empty-list" => {
648 let value = parse_type_string(inner.trim());
649 if name.to_lowercase().starts_with("non-empty") {
650 Union::single(Atomic::TNonEmptyList {
651 value: Box::new(value),
652 })
653 } else {
654 Union::single(Atomic::TList {
655 value: Box::new(value),
656 })
657 }
658 }
659 "non-empty-array" => {
660 let params = split_generics(inner);
661 let (key, value) = match params.len() {
662 n if n >= 2 => (
663 parse_type_string(params[0].trim()),
664 parse_type_string(params[1].trim()),
665 ),
666 1 => (
667 Union::single(Atomic::TInt),
668 parse_type_string(params[0].trim()),
669 ),
670 _ => (Union::single(Atomic::TInt), Union::mixed()),
671 };
672 Union::single(Atomic::TNonEmptyArray {
673 key: Box::new(key),
674 value: Box::new(value),
675 })
676 }
677 "iterable" => {
678 let params = split_generics(inner);
679 let value = match params.len() {
680 n if n >= 2 => parse_type_string(params[1].trim()),
681 1 => parse_type_string(params[0].trim()),
682 _ => Union::mixed(),
683 };
684 Union::single(Atomic::TArray {
685 key: Box::new(Union::single(Atomic::TMixed)),
686 value: Box::new(value),
687 })
688 }
689 "class-string" => Union::single(Atomic::TClassString(Some(
690 normalize_fqcn(inner.trim()).into(),
691 ))),
692 "int" => {
693 Union::single(Atomic::TIntRange {
695 min: None,
696 max: None,
697 })
698 }
699 _ => {
701 let params: Vec<Union> = split_generics(inner)
702 .iter()
703 .map(|p| parse_type_string(p.trim()))
704 .collect();
705 Union::single(Atomic::TNamedObject {
706 fqcn: normalize_fqcn(name).into(),
707 type_params: params,
708 })
709 }
710 }
711}
712
713fn parse_keyed_array(inner: &str, is_list: bool) -> Union {
714 use mir_types::atomic::KeyedProperty;
715 let mut properties: IndexMap<ArrayKey, KeyedProperty> = IndexMap::new();
716 let mut is_open = false;
717 let mut auto_index = 0i64;
718
719 for item in split_generics(inner) {
720 let item = item.trim();
721 if item.is_empty() {
722 continue;
723 }
724 if item == "..." {
725 is_open = true;
726 continue;
727 }
728 let colon_pos = {
730 let mut depth = 0i32;
731 let mut found = None;
732 for (i, ch) in item.char_indices() {
733 match ch {
734 '<' | '(' | '{' => depth += 1,
735 '>' | ')' | '}' => depth -= 1,
736 ':' if depth == 0 => {
737 found = Some(i);
738 break;
739 }
740 _ => {}
741 }
742 }
743 found
744 };
745 if let Some(colon) = colon_pos {
746 let key_part = item[..colon].trim();
747 let ty_part = item[colon + 1..].trim();
748 let optional = key_part.ends_with('?');
749 let key_str = key_part.trim_end_matches('?').trim();
750 let key = if let Ok(n) = key_str.parse::<i64>() {
751 ArrayKey::Int(n)
752 } else {
753 ArrayKey::String(Arc::from(key_str))
754 };
755 properties.insert(
756 key,
757 KeyedProperty {
758 ty: parse_type_string(ty_part),
759 optional,
760 },
761 );
762 } else {
763 properties.insert(
764 ArrayKey::Int(auto_index),
765 KeyedProperty {
766 ty: parse_type_string(item),
767 optional: false,
768 },
769 );
770 auto_index += 1;
771 }
772 }
773
774 Union::single(Atomic::TKeyedArray {
775 properties,
776 is_open,
777 is_list,
778 })
779}
780
781fn parse_callable_syntax(s: &str) -> Option<Union> {
782 let s = s.trim_start_matches('\\');
783 let lower = s.to_lowercase();
784 let is_closure = lower.starts_with("closure");
785 let is_callable = lower.starts_with("callable");
786 if !is_closure && !is_callable {
787 return None;
788 }
789 let prefix_len = if is_closure {
790 "closure".len()
791 } else {
792 "callable".len()
793 };
794 let rest = s[prefix_len..].trim_start();
795 if !rest.starts_with('(') {
796 return None;
797 }
798 let close = find_matching_paren(rest)?;
799 let params_str = &rest[1..close];
800 let after = rest[close + 1..].trim();
801 let return_type = after
802 .strip_prefix(':')
803 .map(|ret_str| Box::new(parse_type_string(ret_str.trim())));
804 let params: Vec<mir_types::atomic::FnParam> = split_generics(params_str)
805 .into_iter()
806 .enumerate()
807 .filter(|(_, p)| !p.trim().is_empty())
808 .map(|(i, p)| {
809 let p = p.trim();
810 let (ty_str, name) = if let Some(dollar) = p.rfind('$') {
811 (p[..dollar].trim(), p[dollar + 1..].to_string())
812 } else {
813 (p, format!("arg{i}"))
814 };
815 mir_types::atomic::FnParam {
816 name: name.into(),
817 ty: Some(mir_types::SimpleType::from_union(parse_type_string(ty_str))),
818 default: None,
819 is_variadic: false,
820 is_byref: false,
821 is_optional: false,
822 }
823 })
824 .collect();
825 if is_closure {
826 Some(Union::single(Atomic::TClosure {
827 params,
828 return_type: return_type.unwrap_or_else(|| Box::new(Union::single(Atomic::TVoid))),
829 this_type: None,
830 }))
831 } else {
832 Some(Union::single(Atomic::TCallable {
833 params: Some(params),
834 return_type,
835 }))
836 }
837}
838
839fn find_matching_paren(s: &str) -> Option<usize> {
840 if !s.starts_with('(') {
841 return None;
842 }
843 let mut depth = 0i32;
844 for (i, ch) in s.char_indices() {
845 match ch {
846 '(' | '<' | '{' => depth += 1,
847 ')' | '>' | '}' => {
848 depth -= 1;
849 if depth == 0 {
850 return Some(i);
851 }
852 }
853 _ => {}
854 }
855 }
856 None
857}
858
859fn parse_template_line(_tag_name: &str, body: Option<String>) -> Option<(String, Option<String>)> {
865 let body = body?;
866 if let Some((name, bound)) = body.split_once(" of ").or_else(|| body.split_once(" as ")) {
867 Some((name.trim().to_string(), Some(bound.trim().to_string())))
868 } else {
869 Some((body.trim().to_string(), None))
870 }
871}
872
873fn extract_description(text: &str) -> String {
875 let mut desc_lines: Vec<&str> = Vec::new();
876 for line in text.lines() {
877 let l = line.trim();
878 let l = l.trim_start_matches("/**").trim();
879 let l = l.trim_end_matches("*/").trim();
880 let l = l.trim_start_matches("*/").trim();
881 let l = l.strip_prefix("* ").unwrap_or(l.trim_start_matches('*'));
882 let l = l.trim();
883 if l.starts_with('@') {
884 break;
885 }
886 if !l.is_empty() {
887 desc_lines.push(l);
888 }
889 }
890 desc_lines.join(" ")
891}
892
893fn parse_import_type(body: &str) -> Option<DocImportType> {
899 let (before_from, from_class_raw) = body.split_once(" from ")?;
901 let from_class = from_class_raw.trim().trim_start_matches('\\').to_string();
902 if from_class.is_empty() {
903 return None;
904 }
905 let (original, local) = if let Some((orig, loc)) = before_from.split_once(" as ") {
907 (orig.trim().to_string(), loc.trim().to_string())
908 } else {
909 let name = before_from.trim().to_string();
910 (name.clone(), name)
911 };
912 if original.is_empty() || local.is_empty() {
913 return None;
914 }
915 Some(DocImportType {
916 original,
917 local,
918 from_class,
919 })
920}
921
922fn parse_param_line(s: &str) -> Option<(String, String)> {
923 let mut best_split: Option<(String, String)> = None;
930
931 for (i, ch) in s.char_indices() {
932 if ch.is_whitespace() {
933 let after = &s[i..].trim_start();
935 if after.starts_with('$') {
936 let mut var_parts = after.split(char::is_whitespace);
938 if let Some(name_with_dollar) = var_parts.next() {
939 let name = name_with_dollar.trim_start_matches('$').to_string();
940 if !name.is_empty() {
941 let type_part = s[..i].trim().to_string();
942 if !type_part.is_empty() {
943 best_split = Some((type_part, name));
945 }
946 }
947 }
948 }
949 }
950 }
951
952 best_split
953}
954
955fn extract_return_type(s: &str) -> String {
956 let mut depth: i32 = 0;
965 let mut current_token = String::new();
966
967 for ch in s.chars() {
968 match ch {
969 '<' | '(' | '{' => {
970 depth += 1;
971 current_token.push(ch);
972 }
973 '>' | ')' | '}' => {
974 depth = (depth - 1).max(0);
975 current_token.push(ch);
976 }
977 _ if ch.is_whitespace() && depth == 0 => {
978 break;
979 }
980 _ => {
981 current_token.push(ch);
982 }
983 }
984 }
985
986 if current_token.ends_with(':') {
990 let offset = current_token.len();
991 let rest = s[offset..].trim_start();
992 if !rest.is_empty() {
993 let ret_type = extract_return_type(rest);
994 current_token.push_str(&ret_type);
995 }
996 }
997
998 current_token.trim().to_string()
999}
1000
1001fn split_union(s: &str) -> Vec<String> {
1002 let mut parts = Vec::new();
1003 let mut depth = 0;
1004 let mut current = String::new();
1005 for ch in s.chars() {
1006 match ch {
1007 '<' | '(' | '{' => {
1008 depth += 1;
1009 current.push(ch);
1010 }
1011 '>' | ')' | '}' => {
1012 depth -= 1;
1013 current.push(ch);
1014 }
1015 '|' if depth == 0 => {
1016 parts.push(current.trim().to_string());
1017 current = String::new();
1018 }
1019 _ => current.push(ch),
1020 }
1021 }
1022 if !current.trim().is_empty() {
1023 parts.push(current.trim().to_string());
1024 }
1025 parts
1026}
1027
1028fn split_generics(s: &str) -> Vec<String> {
1029 let mut parts = Vec::new();
1030 let mut depth = 0;
1031 let mut current = String::new();
1032 for ch in s.chars() {
1033 match ch {
1034 '<' | '(' | '{' => {
1035 depth += 1;
1036 current.push(ch);
1037 }
1038 '>' | ')' | '}' => {
1039 depth -= 1;
1040 current.push(ch);
1041 }
1042 ',' if depth == 0 => {
1043 parts.push(current.trim().to_string());
1044 current = String::new();
1045 }
1046 _ => current.push(ch),
1047 }
1048 }
1049 if !current.trim().is_empty() {
1050 parts.push(current.trim().to_string());
1051 }
1052 parts
1053}
1054
1055fn is_inside_generics(s: &str) -> bool {
1056 let mut depth = 0i32;
1057 for ch in s.chars() {
1058 match ch {
1059 '<' | '(' | '{' => depth += 1,
1060 '>' | ')' | '}' => depth -= 1,
1061 _ => {}
1062 }
1063 }
1064 depth != 0
1065}
1066
1067fn parse_conditional_type(s: &str) -> Option<Union> {
1069 if !s.starts_with('$') {
1070 return None;
1071 }
1072 let is_pos = s.find(" is ")?;
1073 let after_is = s[is_pos + 4..].trim();
1074 let q_pos = find_char_at_depth(after_is, '?')?;
1075 let subject_str = after_is[..q_pos].trim();
1076 let rest = after_is[q_pos + 1..].trim();
1077 let colon_pos = find_char_at_depth(rest, ':')?;
1078 let true_str = rest[..colon_pos].trim();
1079 let false_str = rest[colon_pos + 1..].trim();
1080 Some(Union::single(Atomic::TConditional {
1081 subject: Box::new(parse_type_string(subject_str)),
1082 if_true: Box::new(parse_type_string(true_str)),
1083 if_false: Box::new(parse_type_string(false_str)),
1084 }))
1085}
1086
1087fn find_char_at_depth(s: &str, target: char) -> Option<usize> {
1089 let mut depth = 0i32;
1090 for (i, ch) in s.char_indices() {
1091 match ch {
1092 '<' | '(' | '{' => depth += 1,
1093 '>' | ')' | '}' => depth -= 1,
1094 _ if ch == target && depth == 0 => return Some(i),
1095 _ => {}
1096 }
1097 }
1098 None
1099}
1100
1101fn normalize_fqcn(s: &str) -> String {
1102 s.trim_start_matches('\\').to_string()
1104}
1105
1106fn validate_type_str(s: &str, tag: &str) -> Option<String> {
1112 let s = s.trim();
1113 if s.is_empty() {
1114 return None;
1115 }
1116 if is_inside_generics(s) {
1117 return Some(format!("@{tag} has unclosed generic type `{s}`"));
1118 }
1119 let is_callable_type = s.to_lowercase().contains("callable") || s.contains("Closure");
1121 if !is_callable_type && has_empty_generics(s) {
1122 return Some(format!("@{tag} has empty generic type parameter in `{s}`"));
1123 }
1124 for part in split_union(s) {
1125 let p = part.trim();
1126 if p.starts_with('$') && p != "$this" {
1127 return Some(format!("@{tag} contains variable `{p}` in type position"));
1128 }
1129 }
1130 None
1131}
1132
1133fn has_empty_generics(s: &str) -> bool {
1134 let mut depth = 0;
1135 let mut prev_open = false;
1136 for ch in s.chars() {
1137 match ch {
1138 '<' | '(' | '{' => {
1139 if prev_open && depth == 0 {
1140 return true;
1141 }
1142 prev_open = true;
1143 depth += 1;
1144 }
1145 '>' | ')' | '}' => {
1146 depth -= 1;
1147 if depth == 0 {
1148 if prev_open {
1149 return true;
1150 }
1151 prev_open = false;
1152 }
1153 }
1154 c if !c.is_whitespace() => {
1155 prev_open = false;
1156 }
1157 _ => {}
1158 }
1159 }
1160 false
1161}
1162
1163fn parse_method_line(s: &str) -> Option<DocMethod> {
1165 let mut rest = s.trim();
1166 if rest.is_empty() {
1167 return None;
1168 }
1169 let is_static = rest
1170 .split_whitespace()
1171 .next()
1172 .map(|w| w.eq_ignore_ascii_case("static"))
1173 .unwrap_or(false);
1174 if is_static {
1175 rest = rest["static".len()..].trim_start();
1176 }
1177
1178 let open = rest.find('(').unwrap_or(rest.len());
1179 let prefix = rest[..open].trim();
1180 let mut parts: Vec<&str> = prefix.split_whitespace().collect();
1181 let name = parts.pop()?.to_string();
1182 if name.is_empty() {
1183 return None;
1184 }
1185 let return_type = parts.join(" ");
1186 Some(DocMethod {
1187 return_type,
1188 name,
1189 is_static,
1190 params: parse_method_params(rest),
1191 })
1192}
1193
1194fn parse_method_params(name_part: &str) -> Vec<DocMethodParam> {
1195 let Some(open) = name_part.find('(') else {
1196 return vec![];
1197 };
1198 let Some(close) = name_part.rfind(')') else {
1199 return vec![];
1200 };
1201 let inner = name_part[open + 1..close].trim();
1202 if inner.is_empty() {
1203 return vec![];
1204 }
1205
1206 split_generics(inner)
1207 .into_iter()
1208 .filter_map(|param| parse_method_param(¶m))
1209 .collect()
1210}
1211
1212fn parse_method_param(param: &str) -> Option<DocMethodParam> {
1213 let before_default = param.split('=').next()?.trim();
1214 let is_optional = param.contains('=');
1215 let mut tokens: Vec<&str> = before_default.split_whitespace().collect();
1216 let raw_name = tokens.pop()?;
1217 let is_variadic = raw_name.contains("...");
1218 let is_byref = raw_name.contains('&');
1219 let name = raw_name
1220 .trim_start_matches('&')
1221 .trim_start_matches("...")
1222 .trim_start_matches('&')
1223 .trim_start_matches('$')
1224 .to_string();
1225 if name.is_empty() {
1226 return None;
1227 }
1228 Some(DocMethodParam {
1229 name,
1230 type_hint: tokens.join(" "),
1231 is_variadic,
1232 is_byref,
1233 is_optional: is_optional || is_variadic,
1234 })
1235}
1236
1237#[cfg(test)]
1242mod tests {
1243 use super::*;
1244 use mir_types::Atomic;
1245
1246 #[test]
1247 fn parse_string() {
1248 let u = parse_type_string("string");
1249 assert_eq!(u.types.len(), 1);
1250 assert!(matches!(u.types[0], Atomic::TString));
1251 }
1252
1253 #[test]
1254 fn parse_nullable_string() {
1255 let u = parse_type_string("?string");
1256 assert!(u.is_nullable());
1257 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1258 }
1259
1260 #[test]
1261 fn parse_union() {
1262 let u = parse_type_string("string|int|null");
1263 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1264 assert!(u.contains(|t| matches!(t, Atomic::TInt)));
1265 assert!(u.is_nullable());
1266 }
1267
1268 #[test]
1269 fn parse_array_of_string() {
1270 let u = parse_type_string("array<string>");
1271 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1272 }
1273
1274 #[test]
1275 fn parse_list_of_int() {
1276 let u = parse_type_string("list<int>");
1277 assert!(u.contains(|t| matches!(t, Atomic::TList { .. })));
1278 }
1279
1280 #[test]
1281 fn parse_named_class() {
1282 let u = parse_type_string("Foo\\Bar");
1283 assert!(u.contains(
1284 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Foo\\Bar")
1285 ));
1286 }
1287
1288 #[test]
1289 fn parse_docblock_param_return() {
1290 let doc = r#"/**
1291 * @param string $name
1292 * @param int $age
1293 * @return bool
1294 */"#;
1295 let parsed = DocblockParser::parse(doc);
1296 assert_eq!(parsed.params.len(), 2);
1297 assert!(parsed.return_type.is_some());
1298 let ret = parsed.return_type.unwrap();
1299 assert!(ret.contains(|t| matches!(t, Atomic::TBool)));
1300 }
1301
1302 #[test]
1303 fn parse_template() {
1304 let doc = "/** @template T of object */";
1305 let parsed = DocblockParser::parse(doc);
1306 assert_eq!(parsed.templates.len(), 1);
1307 assert_eq!(parsed.templates[0].0, "T");
1308 assert!(parsed.templates[0].1.is_some());
1309 assert_eq!(parsed.templates[0].2, Variance::Invariant);
1310 }
1311
1312 #[test]
1313 fn parse_template_covariant() {
1314 let doc = "/** @template-covariant T */";
1315 let parsed = DocblockParser::parse(doc);
1316 assert_eq!(parsed.templates.len(), 1);
1317 assert_eq!(parsed.templates[0].0, "T");
1318 assert_eq!(parsed.templates[0].2, Variance::Covariant);
1319 }
1320
1321 #[test]
1322 fn parse_template_contravariant() {
1323 let doc = "/** @template-contravariant T */";
1324 let parsed = DocblockParser::parse(doc);
1325 assert_eq!(parsed.templates.len(), 1);
1326 assert_eq!(parsed.templates[0].0, "T");
1327 assert_eq!(parsed.templates[0].2, Variance::Contravariant);
1328 }
1329
1330 #[test]
1331 fn parse_deprecated() {
1332 let doc = "/** @deprecated use newMethod() instead */";
1333 let parsed = DocblockParser::parse(doc);
1334 assert!(parsed.is_deprecated);
1335 assert_eq!(
1336 parsed.deprecated.as_deref(),
1337 Some("use newMethod() instead")
1338 );
1339 }
1340
1341 #[test]
1342 fn parse_since_plain() {
1343 let parsed = DocblockParser::parse("/** @since 8.0 */");
1344 assert_eq!(parsed.since.as_deref(), Some("8.0"));
1345 assert_eq!(parsed.removed, None);
1346 }
1347
1348 #[test]
1349 fn parse_since_strips_trailing_description() {
1350 let parsed = DocblockParser::parse("/** @since 1.4.0 added \\$options argument */");
1353 assert_eq!(parsed.since.as_deref(), Some("1.4.0"));
1354 }
1355
1356 #[test]
1357 fn parse_removed_tag() {
1358 let parsed = DocblockParser::parse("/** @removed 8.0 use mb_convert_encoding */");
1359 assert_eq!(parsed.removed.as_deref(), Some("8.0"));
1360 }
1361
1362 #[test]
1363 fn parse_since_empty_body_is_none() {
1364 let parsed = DocblockParser::parse("/** @since */");
1365 assert_eq!(parsed.since, None);
1366 }
1367
1368 #[test]
1369 fn parse_description() {
1370 let doc = r#"/**
1371 * This is a description.
1372 * Spans two lines.
1373 * @param string $x
1374 */"#;
1375 let parsed = DocblockParser::parse(doc);
1376 assert!(parsed.description.contains("This is a description"));
1377 assert!(parsed.description.contains("Spans two lines"));
1378 }
1379
1380 #[test]
1381 fn parse_see_and_link() {
1382 let doc = "/** @see SomeClass\n * @link https://example.com */";
1383 let parsed = DocblockParser::parse(doc);
1384 assert_eq!(parsed.see.len(), 2);
1385 assert!(parsed.see.contains(&"SomeClass".to_string()));
1386 assert!(parsed.see.contains(&"https://example.com".to_string()));
1387 }
1388
1389 #[test]
1390 fn parse_mixin() {
1391 let doc = "/** @mixin SomeTrait */";
1392 let parsed = DocblockParser::parse(doc);
1393 assert_eq!(parsed.mixins, vec!["SomeTrait".to_string()]);
1394 }
1395
1396 #[test]
1397 fn parse_property_tags() {
1398 let doc = r#"/**
1399 * @property string $name
1400 * @property-read int $id
1401 * @property-write bool $active
1402 */"#;
1403 let parsed = DocblockParser::parse(doc);
1404 assert_eq!(parsed.properties.len(), 3);
1405 let name_prop = parsed.properties.iter().find(|p| p.name == "name").unwrap();
1406 assert_eq!(name_prop.type_hint, "string");
1407 assert!(!name_prop.read_only);
1408 assert!(!name_prop.write_only);
1409 let id_prop = parsed.properties.iter().find(|p| p.name == "id").unwrap();
1410 assert!(id_prop.read_only);
1411 let active_prop = parsed
1412 .properties
1413 .iter()
1414 .find(|p| p.name == "active")
1415 .unwrap();
1416 assert!(active_prop.write_only);
1417 }
1418
1419 #[test]
1420 fn parse_method_tag() {
1421 let doc = r#"/**
1422 * @method string getName()
1423 * @method static int create()
1424 */"#;
1425 let parsed = DocblockParser::parse(doc);
1426 assert_eq!(parsed.methods.len(), 2);
1427 let get_name = parsed.methods.iter().find(|m| m.name == "getName").unwrap();
1428 assert_eq!(get_name.return_type, "string");
1429 assert!(!get_name.is_static);
1430 let create = parsed.methods.iter().find(|m| m.name == "create").unwrap();
1431 assert!(create.is_static);
1432 }
1433
1434 #[test]
1435 fn parse_type_alias_tag() {
1436 let doc = "/** @psalm-type MyAlias = string|int */";
1437 let parsed = DocblockParser::parse(doc);
1438 assert_eq!(parsed.type_aliases.len(), 1);
1439 assert_eq!(parsed.type_aliases[0].name, "MyAlias");
1440 assert_eq!(parsed.type_aliases[0].type_expr, "string|int");
1441 }
1442
1443 #[test]
1444 fn parse_import_type_no_as() {
1445 let doc = "/** @psalm-import-type UserId from UserRepository */";
1446 let parsed = DocblockParser::parse(doc);
1447 assert_eq!(parsed.import_types.len(), 1);
1448 assert_eq!(parsed.import_types[0].original, "UserId");
1449 assert_eq!(parsed.import_types[0].local, "UserId");
1450 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1451 }
1452
1453 #[test]
1454 fn parse_import_type_with_as() {
1455 let doc = "/** @psalm-import-type UserId as LocalId from UserRepository */";
1456 let parsed = DocblockParser::parse(doc);
1457 assert_eq!(parsed.import_types.len(), 1);
1458 assert_eq!(parsed.import_types[0].original, "UserId");
1459 assert_eq!(parsed.import_types[0].local, "LocalId");
1460 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1461 }
1462
1463 #[test]
1464 fn parse_require_extends() {
1465 let doc = "/** @psalm-require-extends Model */";
1466 let parsed = DocblockParser::parse(doc);
1467 assert_eq!(parsed.require_extends, vec!["Model".to_string()]);
1468 }
1469
1470 #[test]
1471 fn parse_require_implements() {
1472 let doc = "/** @psalm-require-implements Countable */";
1473 let parsed = DocblockParser::parse(doc);
1474 assert_eq!(parsed.require_implements, vec!["Countable".to_string()]);
1475 }
1476
1477 #[test]
1478 fn parse_intersection_two_parts() {
1479 let u = parse_type_string("Iterator&Countable");
1480 assert_eq!(u.types.len(), 1);
1481 assert!(matches!(u.types[0], Atomic::TIntersection { ref parts } if parts.len() == 2));
1482 if let Atomic::TIntersection { parts } = &u.types[0] {
1483 assert!(parts[0].contains(
1484 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1485 ));
1486 assert!(parts[1].contains(
1487 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1488 ));
1489 }
1490 }
1491
1492 #[test]
1493 fn parse_intersection_three_parts() {
1494 let u = parse_type_string("Iterator&Countable&Stringable");
1495 assert_eq!(u.types.len(), 1);
1496 let Atomic::TIntersection { parts } = &u.types[0] else {
1497 panic!("expected TIntersection");
1498 };
1499 assert_eq!(parts.len(), 3);
1500 assert!(parts[0].contains(
1501 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1502 ));
1503 assert!(parts[1].contains(
1504 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1505 ));
1506 assert!(parts[2].contains(
1507 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Stringable")
1508 ));
1509 }
1510
1511 #[test]
1512 fn parse_intersection_in_union_with_null() {
1513 let u = parse_type_string("Iterator&Countable|null");
1514 assert!(u.is_nullable());
1515 let intersection = u
1516 .types
1517 .iter()
1518 .find_map(|t| {
1519 if let Atomic::TIntersection { parts } = t {
1520 Some(parts)
1521 } else {
1522 None
1523 }
1524 })
1525 .expect("expected TIntersection");
1526 assert_eq!(intersection.len(), 2);
1527 assert!(intersection[0].contains(
1528 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1529 ));
1530 assert!(intersection[1].contains(
1531 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1532 ));
1533 }
1534
1535 #[test]
1536 fn parse_intersection_in_union_with_scalar() {
1537 let u = parse_type_string("Iterator&Countable|string");
1538 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1539 let intersection = u
1540 .types
1541 .iter()
1542 .find_map(|t| {
1543 if let Atomic::TIntersection { parts } = t {
1544 Some(parts)
1545 } else {
1546 None
1547 }
1548 })
1549 .expect("expected TIntersection");
1550 assert!(intersection[0].contains(
1551 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1552 ));
1553 assert!(intersection[1].contains(
1554 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1555 ));
1556 }
1557
1558 #[test]
1559 fn validate_unclosed_generic_return() {
1560 let parsed = DocblockParser::parse("/** @return array< */");
1561 assert_eq!(parsed.invalid_annotations.len(), 1);
1562 assert!(
1563 parsed.invalid_annotations[0].contains("unclosed generic"),
1564 "got: {}",
1565 parsed.invalid_annotations[0]
1566 );
1567 }
1568
1569 #[test]
1570 fn parse_empty_generic_array_graceful() {
1571 let u = parse_type_string("array<>");
1572 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1573 }
1574
1575 #[test]
1576 fn parse_empty_generic_iterable_graceful() {
1577 let u = parse_type_string("iterable<>");
1578 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1579 }
1580
1581 #[test]
1582 fn parse_empty_generic_non_empty_array_graceful() {
1583 let u = parse_type_string("non-empty-array<>");
1584 assert!(u.contains(|t| matches!(t, Atomic::TNonEmptyArray { .. })));
1585 }
1586
1587 #[test]
1588 fn validate_variable_in_type_position_param() {
1589 let parsed = DocblockParser::parse("/** @param Foo|$invalid $x */");
1590 assert_eq!(parsed.invalid_annotations.len(), 1);
1591 assert!(
1592 parsed.invalid_annotations[0].contains("$invalid"),
1593 "got: {}",
1594 parsed.invalid_annotations[0]
1595 );
1596 }
1597
1598 #[test]
1599 fn validate_this_is_valid_in_type_position() {
1600 let parsed = DocblockParser::parse("/** @return $this */");
1601 assert!(
1602 parsed.invalid_annotations.is_empty(),
1603 "unexpected error: {:?}",
1604 parsed.invalid_annotations
1605 );
1606 }
1607
1608 #[test]
1609 fn validate_unclosed_generic_var() {
1610 let parsed = DocblockParser::parse("/** @var array<string */");
1611 assert_eq!(parsed.invalid_annotations.len(), 1);
1612 assert!(parsed.invalid_annotations[0].contains("@var"));
1613 }
1614
1615 #[test]
1616 fn validate_variable_in_template_bound() {
1617 let parsed = DocblockParser::parse("/** @template T of $invalid */");
1618 assert_eq!(parsed.invalid_annotations.len(), 1);
1619 assert!(parsed.invalid_annotations[0].contains("$invalid"));
1620 }
1621}