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 if let Ok(n) = s.parse::<i64>() {
619 return Union::single(Atomic::TLiteralInt(n));
620 }
621 Union::single(Atomic::TNamedObject {
622 fqcn: normalize_fqcn(s).into(),
623 type_params: vec![],
624 })
625 }
626
627 _ if s.starts_with('-') && s.len() > 1 && s[1..].chars().all(|c| c.is_ascii_digit()) => {
629 if let Ok(n) = s.parse::<i64>() {
630 Union::single(Atomic::TLiteralInt(n))
631 } else {
632 Union::mixed()
633 }
634 }
635
636 _ if (s.starts_with('\'') && s.ends_with('\''))
638 || (s.starts_with('"') && s.ends_with('"')) =>
639 {
640 let inner = &s[1..s.len() - 1];
641 Union::single(Atomic::TLiteralString(Arc::from(inner)))
642 }
643
644 _ => Union::mixed(),
645 }
646}
647
648fn parse_generic(name: &str, inner: &str) -> Union {
649 match name.to_lowercase().as_str() {
650 "array" => {
651 let params = split_generics(inner);
652 let (key, value) = match params.len() {
653 n if n >= 2 => (
654 parse_type_string(params[0].trim()),
655 parse_type_string(params[1].trim()),
656 ),
657 1 => (
658 Union::single(Atomic::TInt),
659 parse_type_string(params[0].trim()),
660 ),
661 _ => (Union::single(Atomic::TInt), Union::mixed()),
662 };
663 Union::single(Atomic::TArray {
664 key: Box::new(key),
665 value: Box::new(value),
666 })
667 }
668 "list" | "non-empty-list" => {
669 let value = parse_type_string(inner.trim());
670 if name.to_lowercase().starts_with("non-empty") {
671 Union::single(Atomic::TNonEmptyList {
672 value: Box::new(value),
673 })
674 } else {
675 Union::single(Atomic::TList {
676 value: Box::new(value),
677 })
678 }
679 }
680 "non-empty-array" => {
681 let params = split_generics(inner);
682 let (key, value) = match params.len() {
683 n if n >= 2 => (
684 parse_type_string(params[0].trim()),
685 parse_type_string(params[1].trim()),
686 ),
687 1 => (
688 Union::single(Atomic::TInt),
689 parse_type_string(params[0].trim()),
690 ),
691 _ => (Union::single(Atomic::TInt), Union::mixed()),
692 };
693 Union::single(Atomic::TNonEmptyArray {
694 key: Box::new(key),
695 value: Box::new(value),
696 })
697 }
698 "iterable" => {
699 let params = split_generics(inner);
700 let value = match params.len() {
701 n if n >= 2 => parse_type_string(params[1].trim()),
702 1 => parse_type_string(params[0].trim()),
703 _ => Union::mixed(),
704 };
705 Union::single(Atomic::TArray {
706 key: Box::new(Union::single(Atomic::TMixed)),
707 value: Box::new(value),
708 })
709 }
710 "class-string" => Union::single(Atomic::TClassString(Some(
711 normalize_fqcn(inner.trim()).into(),
712 ))),
713 "int" => {
714 Union::single(Atomic::TIntRange {
716 min: None,
717 max: None,
718 })
719 }
720 _ => {
722 let params: Vec<Union> = split_generics(inner)
723 .iter()
724 .map(|p| parse_type_string(p.trim()))
725 .collect();
726 Union::single(Atomic::TNamedObject {
727 fqcn: normalize_fqcn(name).into(),
728 type_params: params,
729 })
730 }
731 }
732}
733
734fn parse_keyed_array(inner: &str, is_list: bool) -> Union {
735 use mir_types::atomic::KeyedProperty;
736 let mut properties: IndexMap<ArrayKey, KeyedProperty> = IndexMap::new();
737 let mut is_open = false;
738 let mut auto_index = 0i64;
739
740 for item in split_generics(inner) {
741 let item = item.trim();
742 if item.is_empty() {
743 continue;
744 }
745 if item == "..." {
746 is_open = true;
747 continue;
748 }
749 let colon_pos = {
751 let mut depth = 0i32;
752 let mut found = None;
753 for (i, ch) in item.char_indices() {
754 match ch {
755 '<' | '(' | '{' => depth += 1,
756 '>' | ')' | '}' => depth -= 1,
757 ':' if depth == 0 => {
758 found = Some(i);
759 break;
760 }
761 _ => {}
762 }
763 }
764 found
765 };
766 if let Some(colon) = colon_pos {
767 let key_part = item[..colon].trim();
768 let ty_part = item[colon + 1..].trim();
769 let optional = key_part.ends_with('?');
770 let key_str = key_part.trim_end_matches('?').trim();
771 let key = if let Ok(n) = key_str.parse::<i64>() {
772 ArrayKey::Int(n)
773 } else {
774 ArrayKey::String(Arc::from(key_str))
775 };
776 properties.insert(
777 key,
778 KeyedProperty {
779 ty: parse_type_string(ty_part),
780 optional,
781 },
782 );
783 } else {
784 properties.insert(
785 ArrayKey::Int(auto_index),
786 KeyedProperty {
787 ty: parse_type_string(item),
788 optional: false,
789 },
790 );
791 auto_index += 1;
792 }
793 }
794
795 Union::single(Atomic::TKeyedArray {
796 properties,
797 is_open,
798 is_list,
799 })
800}
801
802fn parse_callable_syntax(s: &str) -> Option<Union> {
803 let s = s.trim_start_matches('\\');
804 let lower = s.to_lowercase();
805 let is_closure = lower.starts_with("closure");
806 let is_callable = lower.starts_with("callable");
807 if !is_closure && !is_callable {
808 return None;
809 }
810 let prefix_len = if is_closure {
811 "closure".len()
812 } else {
813 "callable".len()
814 };
815 let rest = s[prefix_len..].trim_start();
816 if !rest.starts_with('(') {
817 return None;
818 }
819 let close = find_matching_paren(rest)?;
820 let params_str = &rest[1..close];
821 let after = rest[close + 1..].trim();
822 let return_type = after
823 .strip_prefix(':')
824 .map(|ret_str| Box::new(parse_type_string(ret_str.trim())));
825 let params: Vec<mir_types::atomic::FnParam> = split_generics(params_str)
826 .into_iter()
827 .enumerate()
828 .filter(|(_, p)| !p.trim().is_empty())
829 .map(|(i, p)| {
830 let p = p.trim();
831 let (ty_str, name) = if let Some(dollar) = p.rfind('$') {
832 (p[..dollar].trim(), p[dollar + 1..].to_string())
833 } else {
834 (p, format!("arg{i}"))
835 };
836 mir_types::atomic::FnParam {
837 name: name.into(),
838 ty: Some(mir_types::SimpleType::from_union(parse_type_string(ty_str))),
839 default: None,
840 is_variadic: false,
841 is_byref: false,
842 is_optional: false,
843 }
844 })
845 .collect();
846 if is_closure {
847 Some(Union::single(Atomic::TClosure {
848 params,
849 return_type: return_type.unwrap_or_else(|| Box::new(Union::single(Atomic::TVoid))),
850 this_type: None,
851 }))
852 } else {
853 Some(Union::single(Atomic::TCallable {
854 params: Some(params),
855 return_type,
856 }))
857 }
858}
859
860fn find_matching_paren(s: &str) -> Option<usize> {
861 if !s.starts_with('(') {
862 return None;
863 }
864 let mut depth = 0i32;
865 for (i, ch) in s.char_indices() {
866 match ch {
867 '(' | '<' | '{' => depth += 1,
868 ')' | '>' | '}' => {
869 depth -= 1;
870 if depth == 0 {
871 return Some(i);
872 }
873 }
874 _ => {}
875 }
876 }
877 None
878}
879
880fn parse_template_line(_tag_name: &str, body: Option<String>) -> Option<(String, Option<String>)> {
886 let body = body?;
887 if let Some((name, bound)) = body.split_once(" of ").or_else(|| body.split_once(" as ")) {
888 Some((name.trim().to_string(), Some(bound.trim().to_string())))
889 } else {
890 Some((body.trim().to_string(), None))
891 }
892}
893
894fn extract_description(text: &str) -> String {
896 let mut desc_lines: Vec<&str> = Vec::new();
897 for line in text.lines() {
898 let l = line.trim();
899 let l = l.trim_start_matches("/**").trim();
900 let l = l.trim_end_matches("*/").trim();
901 let l = l.trim_start_matches("*/").trim();
902 let l = l.strip_prefix("* ").unwrap_or(l.trim_start_matches('*'));
903 let l = l.trim();
904 if l.starts_with('@') {
905 break;
906 }
907 if !l.is_empty() {
908 desc_lines.push(l);
909 }
910 }
911 desc_lines.join(" ")
912}
913
914fn parse_import_type(body: &str) -> Option<DocImportType> {
920 let (before_from, from_class_raw) = body.split_once(" from ")?;
922 let from_class = from_class_raw.trim().trim_start_matches('\\').to_string();
923 if from_class.is_empty() {
924 return None;
925 }
926 let (original, local) = if let Some((orig, loc)) = before_from.split_once(" as ") {
928 (orig.trim().to_string(), loc.trim().to_string())
929 } else {
930 let name = before_from.trim().to_string();
931 (name.clone(), name)
932 };
933 if original.is_empty() || local.is_empty() {
934 return None;
935 }
936 Some(DocImportType {
937 original,
938 local,
939 from_class,
940 })
941}
942
943fn parse_param_line(s: &str) -> Option<(String, String)> {
944 let mut best_split: Option<(String, String)> = None;
951
952 for (i, ch) in s.char_indices() {
953 if ch.is_whitespace() {
954 let after = &s[i..].trim_start();
956 if after.starts_with('$') {
957 let mut var_parts = after.split(char::is_whitespace);
959 if let Some(name_with_dollar) = var_parts.next() {
960 let name = name_with_dollar.trim_start_matches('$').to_string();
961 if !name.is_empty() {
962 let type_part = s[..i].trim().to_string();
963 if !type_part.is_empty() {
964 best_split = Some((type_part, name));
966 }
967 }
968 }
969 }
970 }
971 }
972
973 best_split
974}
975
976fn extract_return_type(s: &str) -> String {
977 let mut depth: i32 = 0;
986 let mut current_token = String::new();
987
988 for ch in s.chars() {
989 match ch {
990 '<' | '(' | '{' => {
991 depth += 1;
992 current_token.push(ch);
993 }
994 '>' | ')' | '}' => {
995 depth = (depth - 1).max(0);
996 current_token.push(ch);
997 }
998 _ if ch.is_whitespace() && depth == 0 => {
999 break;
1000 }
1001 _ => {
1002 current_token.push(ch);
1003 }
1004 }
1005 }
1006
1007 if current_token.ends_with(':') {
1011 let offset = current_token.len();
1012 let rest = s[offset..].trim_start();
1013 if !rest.is_empty() {
1014 let ret_type = extract_return_type(rest);
1015 current_token.push_str(&ret_type);
1016 }
1017 }
1018
1019 current_token.trim().to_string()
1020}
1021
1022fn split_union(s: &str) -> Vec<String> {
1023 let mut parts = Vec::new();
1024 let mut depth = 0;
1025 let mut current = String::new();
1026 for ch in s.chars() {
1027 match ch {
1028 '<' | '(' | '{' => {
1029 depth += 1;
1030 current.push(ch);
1031 }
1032 '>' | ')' | '}' => {
1033 depth -= 1;
1034 current.push(ch);
1035 }
1036 '|' if depth == 0 => {
1037 parts.push(current.trim().to_string());
1038 current = String::new();
1039 }
1040 _ => current.push(ch),
1041 }
1042 }
1043 if !current.trim().is_empty() {
1044 parts.push(current.trim().to_string());
1045 }
1046 parts
1047}
1048
1049fn split_generics(s: &str) -> Vec<String> {
1050 let mut parts = Vec::new();
1051 let mut depth = 0;
1052 let mut current = String::new();
1053 for ch in s.chars() {
1054 match ch {
1055 '<' | '(' | '{' => {
1056 depth += 1;
1057 current.push(ch);
1058 }
1059 '>' | ')' | '}' => {
1060 depth -= 1;
1061 current.push(ch);
1062 }
1063 ',' if depth == 0 => {
1064 parts.push(current.trim().to_string());
1065 current = String::new();
1066 }
1067 _ => current.push(ch),
1068 }
1069 }
1070 if !current.trim().is_empty() {
1071 parts.push(current.trim().to_string());
1072 }
1073 parts
1074}
1075
1076fn is_inside_generics(s: &str) -> bool {
1077 let mut depth = 0i32;
1078 for ch in s.chars() {
1079 match ch {
1080 '<' | '(' | '{' => depth += 1,
1081 '>' | ')' | '}' => depth -= 1,
1082 _ => {}
1083 }
1084 }
1085 depth != 0
1086}
1087
1088fn parse_conditional_type(s: &str) -> Option<Union> {
1090 if !s.starts_with('$') {
1091 return None;
1092 }
1093 let is_pos = s.find(" is ")?;
1094 let after_is = s[is_pos + 4..].trim();
1095 let q_pos = find_char_at_depth(after_is, '?')?;
1096 let subject_str = after_is[..q_pos].trim();
1097 let rest = after_is[q_pos + 1..].trim();
1098 let colon_pos = find_char_at_depth(rest, ':')?;
1099 let true_str = rest[..colon_pos].trim();
1100 let false_str = rest[colon_pos + 1..].trim();
1101 Some(Union::single(Atomic::TConditional {
1102 subject: Box::new(parse_type_string(subject_str)),
1103 if_true: Box::new(parse_type_string(true_str)),
1104 if_false: Box::new(parse_type_string(false_str)),
1105 }))
1106}
1107
1108fn find_char_at_depth(s: &str, target: char) -> Option<usize> {
1110 let mut depth = 0i32;
1111 for (i, ch) in s.char_indices() {
1112 match ch {
1113 '<' | '(' | '{' => depth += 1,
1114 '>' | ')' | '}' => depth -= 1,
1115 _ if ch == target && depth == 0 => return Some(i),
1116 _ => {}
1117 }
1118 }
1119 None
1120}
1121
1122fn normalize_fqcn(s: &str) -> String {
1123 s.trim_start_matches('\\').to_string()
1125}
1126
1127fn validate_type_str(s: &str, tag: &str) -> Option<String> {
1133 let s = s.trim();
1134 if s.is_empty() {
1135 return None;
1136 }
1137 if is_inside_generics(s) {
1138 return Some(format!("@{tag} has unclosed generic type `{s}`"));
1139 }
1140 let is_callable_type = s.to_lowercase().contains("callable") || s.contains("Closure");
1142 if !is_callable_type && has_empty_generics(s) {
1143 return Some(format!("@{tag} has empty generic type parameter in `{s}`"));
1144 }
1145 for part in split_union(s) {
1146 let p = part.trim();
1147 if p.starts_with('$') && p != "$this" {
1148 return Some(format!("@{tag} contains variable `{p}` in type position"));
1149 }
1150 }
1151 None
1152}
1153
1154fn has_empty_generics(s: &str) -> bool {
1155 let mut depth = 0;
1156 let mut prev_open = false;
1157 for ch in s.chars() {
1158 match ch {
1159 '<' | '(' | '{' => {
1160 if prev_open && depth == 0 {
1161 return true;
1162 }
1163 prev_open = true;
1164 depth += 1;
1165 }
1166 '>' | ')' | '}' => {
1167 depth -= 1;
1168 if depth == 0 {
1169 if prev_open {
1170 return true;
1171 }
1172 prev_open = false;
1173 }
1174 }
1175 c if !c.is_whitespace() => {
1176 prev_open = false;
1177 }
1178 _ => {}
1179 }
1180 }
1181 false
1182}
1183
1184fn parse_method_line(s: &str) -> Option<DocMethod> {
1186 let mut rest = s.trim();
1187 if rest.is_empty() {
1188 return None;
1189 }
1190 let is_static = rest
1191 .split_whitespace()
1192 .next()
1193 .map(|w| w.eq_ignore_ascii_case("static"))
1194 .unwrap_or(false);
1195 if is_static {
1196 rest = rest["static".len()..].trim_start();
1197 }
1198
1199 let open = rest.find('(').unwrap_or(rest.len());
1200 let prefix = rest[..open].trim();
1201 let mut parts: Vec<&str> = prefix.split_whitespace().collect();
1202 let name = parts.pop()?.to_string();
1203 if name.is_empty() {
1204 return None;
1205 }
1206 let return_type = parts.join(" ");
1207 Some(DocMethod {
1208 return_type,
1209 name,
1210 is_static,
1211 params: parse_method_params(rest),
1212 })
1213}
1214
1215fn parse_method_params(name_part: &str) -> Vec<DocMethodParam> {
1216 let Some(open) = name_part.find('(') else {
1217 return vec![];
1218 };
1219 let Some(close) = name_part.rfind(')') else {
1220 return vec![];
1221 };
1222 let inner = name_part[open + 1..close].trim();
1223 if inner.is_empty() {
1224 return vec![];
1225 }
1226
1227 split_generics(inner)
1228 .into_iter()
1229 .filter_map(|param| parse_method_param(¶m))
1230 .collect()
1231}
1232
1233fn parse_method_param(param: &str) -> Option<DocMethodParam> {
1234 let before_default = param.split('=').next()?.trim();
1235 let is_optional = param.contains('=');
1236 let mut tokens: Vec<&str> = before_default.split_whitespace().collect();
1237 let raw_name = tokens.pop()?;
1238 let is_variadic = raw_name.contains("...");
1239 let is_byref = raw_name.contains('&');
1240 let name = raw_name
1241 .trim_start_matches('&')
1242 .trim_start_matches("...")
1243 .trim_start_matches('&')
1244 .trim_start_matches('$')
1245 .to_string();
1246 if name.is_empty() {
1247 return None;
1248 }
1249 Some(DocMethodParam {
1250 name,
1251 type_hint: tokens.join(" "),
1252 is_variadic,
1253 is_byref,
1254 is_optional: is_optional || is_variadic,
1255 })
1256}
1257
1258#[cfg(test)]
1263mod tests {
1264 use super::*;
1265 use mir_types::Atomic;
1266
1267 #[test]
1268 fn parse_string() {
1269 let u = parse_type_string("string");
1270 assert_eq!(u.types.len(), 1);
1271 assert!(matches!(u.types[0], Atomic::TString));
1272 }
1273
1274 #[test]
1275 fn parse_nullable_string() {
1276 let u = parse_type_string("?string");
1277 assert!(u.is_nullable());
1278 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1279 }
1280
1281 #[test]
1282 fn parse_union() {
1283 let u = parse_type_string("string|int|null");
1284 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1285 assert!(u.contains(|t| matches!(t, Atomic::TInt)));
1286 assert!(u.is_nullable());
1287 }
1288
1289 #[test]
1290 fn parse_array_of_string() {
1291 let u = parse_type_string("array<string>");
1292 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1293 }
1294
1295 #[test]
1296 fn parse_list_of_int() {
1297 let u = parse_type_string("list<int>");
1298 assert!(u.contains(|t| matches!(t, Atomic::TList { .. })));
1299 }
1300
1301 #[test]
1302 fn parse_named_class() {
1303 let u = parse_type_string("Foo\\Bar");
1304 assert!(u.contains(
1305 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Foo\\Bar")
1306 ));
1307 }
1308
1309 #[test]
1310 fn parse_docblock_param_return() {
1311 let doc = r#"/**
1312 * @param string $name
1313 * @param int $age
1314 * @return bool
1315 */"#;
1316 let parsed = DocblockParser::parse(doc);
1317 assert_eq!(parsed.params.len(), 2);
1318 assert!(parsed.return_type.is_some());
1319 let ret = parsed.return_type.unwrap();
1320 assert!(ret.contains(|t| matches!(t, Atomic::TBool)));
1321 }
1322
1323 #[test]
1324 fn parse_template() {
1325 let doc = "/** @template T of object */";
1326 let parsed = DocblockParser::parse(doc);
1327 assert_eq!(parsed.templates.len(), 1);
1328 assert_eq!(parsed.templates[0].0, "T");
1329 assert!(parsed.templates[0].1.is_some());
1330 assert_eq!(parsed.templates[0].2, Variance::Invariant);
1331 }
1332
1333 #[test]
1334 fn parse_template_covariant() {
1335 let doc = "/** @template-covariant T */";
1336 let parsed = DocblockParser::parse(doc);
1337 assert_eq!(parsed.templates.len(), 1);
1338 assert_eq!(parsed.templates[0].0, "T");
1339 assert_eq!(parsed.templates[0].2, Variance::Covariant);
1340 }
1341
1342 #[test]
1343 fn parse_template_contravariant() {
1344 let doc = "/** @template-contravariant T */";
1345 let parsed = DocblockParser::parse(doc);
1346 assert_eq!(parsed.templates.len(), 1);
1347 assert_eq!(parsed.templates[0].0, "T");
1348 assert_eq!(parsed.templates[0].2, Variance::Contravariant);
1349 }
1350
1351 #[test]
1352 fn parse_deprecated() {
1353 let doc = "/** @deprecated use newMethod() instead */";
1354 let parsed = DocblockParser::parse(doc);
1355 assert!(parsed.is_deprecated);
1356 assert_eq!(
1357 parsed.deprecated.as_deref(),
1358 Some("use newMethod() instead")
1359 );
1360 }
1361
1362 #[test]
1363 fn parse_since_plain() {
1364 let parsed = DocblockParser::parse("/** @since 8.0 */");
1365 assert_eq!(parsed.since.as_deref(), Some("8.0"));
1366 assert_eq!(parsed.removed, None);
1367 }
1368
1369 #[test]
1370 fn parse_since_strips_trailing_description() {
1371 let parsed = DocblockParser::parse("/** @since 1.4.0 added \\$options argument */");
1374 assert_eq!(parsed.since.as_deref(), Some("1.4.0"));
1375 }
1376
1377 #[test]
1378 fn parse_removed_tag() {
1379 let parsed = DocblockParser::parse("/** @removed 8.0 use mb_convert_encoding */");
1380 assert_eq!(parsed.removed.as_deref(), Some("8.0"));
1381 }
1382
1383 #[test]
1384 fn parse_since_empty_body_is_none() {
1385 let parsed = DocblockParser::parse("/** @since */");
1386 assert_eq!(parsed.since, None);
1387 }
1388
1389 #[test]
1390 fn parse_description() {
1391 let doc = r#"/**
1392 * This is a description.
1393 * Spans two lines.
1394 * @param string $x
1395 */"#;
1396 let parsed = DocblockParser::parse(doc);
1397 assert!(parsed.description.contains("This is a description"));
1398 assert!(parsed.description.contains("Spans two lines"));
1399 }
1400
1401 #[test]
1402 fn parse_see_and_link() {
1403 let doc = "/** @see SomeClass\n * @link https://example.com */";
1404 let parsed = DocblockParser::parse(doc);
1405 assert_eq!(parsed.see.len(), 2);
1406 assert!(parsed.see.contains(&"SomeClass".to_string()));
1407 assert!(parsed.see.contains(&"https://example.com".to_string()));
1408 }
1409
1410 #[test]
1411 fn parse_mixin() {
1412 let doc = "/** @mixin SomeTrait */";
1413 let parsed = DocblockParser::parse(doc);
1414 assert_eq!(parsed.mixins, vec!["SomeTrait".to_string()]);
1415 }
1416
1417 #[test]
1418 fn parse_property_tags() {
1419 let doc = r#"/**
1420 * @property string $name
1421 * @property-read int $id
1422 * @property-write bool $active
1423 */"#;
1424 let parsed = DocblockParser::parse(doc);
1425 assert_eq!(parsed.properties.len(), 3);
1426 let name_prop = parsed.properties.iter().find(|p| p.name == "name").unwrap();
1427 assert_eq!(name_prop.type_hint, "string");
1428 assert!(!name_prop.read_only);
1429 assert!(!name_prop.write_only);
1430 let id_prop = parsed.properties.iter().find(|p| p.name == "id").unwrap();
1431 assert!(id_prop.read_only);
1432 let active_prop = parsed
1433 .properties
1434 .iter()
1435 .find(|p| p.name == "active")
1436 .unwrap();
1437 assert!(active_prop.write_only);
1438 }
1439
1440 #[test]
1441 fn parse_method_tag() {
1442 let doc = r#"/**
1443 * @method string getName()
1444 * @method static int create()
1445 */"#;
1446 let parsed = DocblockParser::parse(doc);
1447 assert_eq!(parsed.methods.len(), 2);
1448 let get_name = parsed.methods.iter().find(|m| m.name == "getName").unwrap();
1449 assert_eq!(get_name.return_type, "string");
1450 assert!(!get_name.is_static);
1451 let create = parsed.methods.iter().find(|m| m.name == "create").unwrap();
1452 assert!(create.is_static);
1453 }
1454
1455 #[test]
1456 fn parse_type_alias_tag() {
1457 let doc = "/** @psalm-type MyAlias = string|int */";
1458 let parsed = DocblockParser::parse(doc);
1459 assert_eq!(parsed.type_aliases.len(), 1);
1460 assert_eq!(parsed.type_aliases[0].name, "MyAlias");
1461 assert_eq!(parsed.type_aliases[0].type_expr, "string|int");
1462 }
1463
1464 #[test]
1465 fn parse_import_type_no_as() {
1466 let doc = "/** @psalm-import-type UserId from UserRepository */";
1467 let parsed = DocblockParser::parse(doc);
1468 assert_eq!(parsed.import_types.len(), 1);
1469 assert_eq!(parsed.import_types[0].original, "UserId");
1470 assert_eq!(parsed.import_types[0].local, "UserId");
1471 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1472 }
1473
1474 #[test]
1475 fn parse_import_type_with_as() {
1476 let doc = "/** @psalm-import-type UserId as LocalId from UserRepository */";
1477 let parsed = DocblockParser::parse(doc);
1478 assert_eq!(parsed.import_types.len(), 1);
1479 assert_eq!(parsed.import_types[0].original, "UserId");
1480 assert_eq!(parsed.import_types[0].local, "LocalId");
1481 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1482 }
1483
1484 #[test]
1485 fn parse_require_extends() {
1486 let doc = "/** @psalm-require-extends Model */";
1487 let parsed = DocblockParser::parse(doc);
1488 assert_eq!(parsed.require_extends, vec!["Model".to_string()]);
1489 }
1490
1491 #[test]
1492 fn parse_require_implements() {
1493 let doc = "/** @psalm-require-implements Countable */";
1494 let parsed = DocblockParser::parse(doc);
1495 assert_eq!(parsed.require_implements, vec!["Countable".to_string()]);
1496 }
1497
1498 #[test]
1499 fn parse_intersection_two_parts() {
1500 let u = parse_type_string("Iterator&Countable");
1501 assert_eq!(u.types.len(), 1);
1502 assert!(matches!(u.types[0], Atomic::TIntersection { ref parts } if parts.len() == 2));
1503 if let Atomic::TIntersection { parts } = &u.types[0] {
1504 assert!(parts[0].contains(
1505 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1506 ));
1507 assert!(parts[1].contains(
1508 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1509 ));
1510 }
1511 }
1512
1513 #[test]
1514 fn parse_intersection_three_parts() {
1515 let u = parse_type_string("Iterator&Countable&Stringable");
1516 assert_eq!(u.types.len(), 1);
1517 let Atomic::TIntersection { parts } = &u.types[0] else {
1518 panic!("expected TIntersection");
1519 };
1520 assert_eq!(parts.len(), 3);
1521 assert!(parts[0].contains(
1522 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1523 ));
1524 assert!(parts[1].contains(
1525 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1526 ));
1527 assert!(parts[2].contains(
1528 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Stringable")
1529 ));
1530 }
1531
1532 #[test]
1533 fn parse_intersection_in_union_with_null() {
1534 let u = parse_type_string("Iterator&Countable|null");
1535 assert!(u.is_nullable());
1536 let intersection = u
1537 .types
1538 .iter()
1539 .find_map(|t| {
1540 if let Atomic::TIntersection { parts } = t {
1541 Some(parts)
1542 } else {
1543 None
1544 }
1545 })
1546 .expect("expected TIntersection");
1547 assert_eq!(intersection.len(), 2);
1548 assert!(intersection[0].contains(
1549 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1550 ));
1551 assert!(intersection[1].contains(
1552 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1553 ));
1554 }
1555
1556 #[test]
1557 fn parse_intersection_in_union_with_scalar() {
1558 let u = parse_type_string("Iterator&Countable|string");
1559 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1560 let intersection = u
1561 .types
1562 .iter()
1563 .find_map(|t| {
1564 if let Atomic::TIntersection { parts } = t {
1565 Some(parts)
1566 } else {
1567 None
1568 }
1569 })
1570 .expect("expected TIntersection");
1571 assert!(intersection[0].contains(
1572 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1573 ));
1574 assert!(intersection[1].contains(
1575 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1576 ));
1577 }
1578
1579 #[test]
1580 fn validate_unclosed_generic_return() {
1581 let parsed = DocblockParser::parse("/** @return array< */");
1582 assert_eq!(parsed.invalid_annotations.len(), 1);
1583 assert!(
1584 parsed.invalid_annotations[0].contains("unclosed generic"),
1585 "got: {}",
1586 parsed.invalid_annotations[0]
1587 );
1588 }
1589
1590 #[test]
1591 fn parse_empty_generic_array_graceful() {
1592 let u = parse_type_string("array<>");
1593 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1594 }
1595
1596 #[test]
1597 fn parse_empty_generic_iterable_graceful() {
1598 let u = parse_type_string("iterable<>");
1599 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1600 }
1601
1602 #[test]
1603 fn parse_empty_generic_non_empty_array_graceful() {
1604 let u = parse_type_string("non-empty-array<>");
1605 assert!(u.contains(|t| matches!(t, Atomic::TNonEmptyArray { .. })));
1606 }
1607
1608 #[test]
1609 fn validate_variable_in_type_position_param() {
1610 let parsed = DocblockParser::parse("/** @param Foo|$invalid $x */");
1611 assert_eq!(parsed.invalid_annotations.len(), 1);
1612 assert!(
1613 parsed.invalid_annotations[0].contains("$invalid"),
1614 "got: {}",
1615 parsed.invalid_annotations[0]
1616 );
1617 }
1618
1619 #[test]
1620 fn validate_this_is_valid_in_type_position() {
1621 let parsed = DocblockParser::parse("/** @return $this */");
1622 assert!(
1623 parsed.invalid_annotations.is_empty(),
1624 "unexpected error: {:?}",
1625 parsed.invalid_annotations
1626 );
1627 }
1628
1629 #[test]
1630 fn validate_unclosed_generic_var() {
1631 let parsed = DocblockParser::parse("/** @var array<string */");
1632 assert_eq!(parsed.invalid_annotations.len(), 1);
1633 assert!(parsed.invalid_annotations[0].contains("@var"));
1634 }
1635
1636 #[test]
1637 fn validate_variable_in_template_bound() {
1638 let parsed = DocblockParser::parse("/** @template T of $invalid */");
1639 assert_eq!(parsed.invalid_annotations.len(), 1);
1640 assert!(parsed.invalid_annotations[0].contains("$invalid"));
1641 }
1642}