1use mir_types::{ArrayKey, Atomic, Type, 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 = extract_type_prefix(body_str.trim());
71 if let Some(msg) = validate_type_str(ty_s, "var") {
72 result.invalid_annotations.push(msg);
73 }
74 result.var_type = Some(parse_type_string(ty_s));
75 }
76 }
77 }
78 "throws" => {
79 if let Some(body_str) = body_text(&tag.body) {
80 let class = body_str.split_whitespace().next().unwrap_or("").to_string();
81 if !class.is_empty() {
82 result.throws.push(class);
83 }
84 }
85 }
86 "deprecated" => {
87 result.is_deprecated = true;
88 result.deprecated = Some(body_text(&tag.body).unwrap_or_default().to_string());
89 }
90 "template" => {
91 if let Some((name, bound)) =
92 parse_template_line(tag.name.as_str(), body_text(&tag.body))
93 {
94 if let Some(b) = &bound {
95 if let Some(msg) = validate_type_str(b, "template") {
96 result.invalid_annotations.push(msg);
97 }
98 }
99 result.templates.push((
100 name,
101 bound.map(|b| parse_type_string(&b)),
102 Variance::Invariant,
103 ));
104 }
105 }
106 "template-covariant" => {
107 if let Some((name, bound)) =
108 parse_template_line(tag.name.as_str(), body_text(&tag.body))
109 {
110 if let Some(b) = &bound {
111 if let Some(msg) = validate_type_str(b, "template-covariant") {
112 result.invalid_annotations.push(msg);
113 }
114 }
115 result.templates.push((
116 name,
117 bound.map(|b| parse_type_string(&b)),
118 Variance::Covariant,
119 ));
120 }
121 }
122 "template-contravariant" => {
123 if let Some((name, bound)) =
124 parse_template_line(tag.name.as_str(), body_text(&tag.body))
125 {
126 if let Some(b) = &bound {
127 if let Some(msg) = validate_type_str(b, "template-contravariant") {
128 result.invalid_annotations.push(msg);
129 }
130 }
131 result.templates.push((
132 name,
133 bound.map(|b| parse_type_string(&b)),
134 Variance::Contravariant,
135 ));
136 }
137 }
138 "extends" | "template-extends" | "phpstan-extends" => {
139 if let Some(body_str) = body_text(&tag.body) {
140 result.extends = Some(parse_type_string(body_str.trim()));
141 }
142 }
143 "implements" | "template-implements" | "phpstan-implements" => {
144 if let Some(body_str) = body_text(&tag.body) {
145 result.implements.push(parse_type_string(body_str.trim()));
146 }
147 }
148 "assert" | "psalm-assert" | "phpstan-assert" => {
149 if let Some(body_str) = body_text(&tag.body) {
150 if let Some((ty_str, name)) = parse_param_line(&body_str) {
151 result.assertions.push((name, parse_type_string(&ty_str)));
152 }
153 }
154 }
155 "suppress" | "psalm-suppress" => {
156 if let Some(body_str) = body_text(&tag.body) {
157 for rule in body_str.split([',', ' ']) {
158 let rule = rule.trim().to_string();
159 if !rule.is_empty() {
160 result.suppressed_issues.push(rule);
161 }
162 }
163 }
164 }
165 "see" => {
166 if let Some(body_str) = body_text(&tag.body) {
167 result.see.push(body_str.to_string());
168 }
169 }
170 "link" => {
171 if let Some(body_str) = body_text(&tag.body) {
172 result.see.push(body_str.to_string());
173 }
174 }
175 "mixin" => {
176 if let Some(body_str) = body_text(&tag.body) {
177 let base_class =
178 body_str.split('<').next().unwrap_or(&body_str).to_string();
179 result.mixins.push(base_class);
180 }
181 }
182 "property" => {
183 if let Some(body_str) = body_text(&tag.body) {
184 if let Some((ty_str, name)) = parse_param_line(&body_str) {
185 result.properties.push(DocProperty {
186 type_hint: ty_str,
187 name: name.trim_start_matches('$').to_string(),
188 read_only: false,
189 write_only: false,
190 });
191 }
192 }
193 }
194 "property-read" => {
195 if let Some(body_str) = body_text(&tag.body) {
196 if let Some((ty_str, name)) = parse_param_line(&body_str) {
197 result.properties.push(DocProperty {
198 type_hint: ty_str,
199 name: name.trim_start_matches('$').to_string(),
200 read_only: true,
201 write_only: false,
202 });
203 }
204 }
205 }
206 "property-write" => {
207 if let Some(body_str) = body_text(&tag.body) {
208 if let Some((ty_str, name)) = parse_param_line(&body_str) {
209 result.properties.push(DocProperty {
210 type_hint: ty_str,
211 name: name.trim_start_matches('$').to_string(),
212 read_only: false,
213 write_only: true,
214 });
215 }
216 }
217 }
218 "method" | "psalm-method" => {
219 if let Some(body_str) = body_text(&tag.body) {
220 if let Some(m) = parse_method_line(&body_str) {
221 result.methods.push(m);
222 }
223 }
224 }
225 "psalm-type" | "phpstan-type" => {
226 if let Some(body_str) = body_text(&tag.body) {
227 if let Some((name, type_expr)) = body_str.split_once('=') {
228 result.type_aliases.push(DocTypeAlias {
229 name: name.trim().to_string(),
230 type_expr: type_expr.trim().to_string(),
231 });
232 }
233 }
234 }
235 "psalm-import-type" | "phpstan-import-type" => {
236 if let Some(body_str) = body_text(&tag.body) {
237 if let Some(import) = parse_import_type(&body_str) {
238 result.import_types.push(import);
239 }
240 }
241 }
242 "since" if result.since.is_none() => {
243 if let Some(body_str) = body_text(&tag.body) {
244 let v = body_str.split_whitespace().next().unwrap_or("");
245 if !v.is_empty() {
246 result.since = Some(v.to_string());
247 }
248 }
249 }
250 "removed" if result.removed.is_none() => {
251 if let Some(body_str) = body_text(&tag.body) {
252 let v = body_str.split_whitespace().next().unwrap_or("");
253 if !v.is_empty() {
254 result.removed = Some(v.to_string());
255 }
256 }
257 }
258 "internal" => result.is_internal = true,
259 "pure" => result.is_pure = true,
260 "immutable" => result.is_immutable = true,
261 "readonly" => result.is_readonly = true,
262 "inheritDoc" | "inheritdoc" => result.is_inherit_doc = true,
263 "api" | "psalm-api" => result.is_api = true,
264 "psalm-assert-if-true" | "phpstan-assert-if-true" => {
265 if let Some(body_str) = body_text(&tag.body) {
266 if let Some((ty_str, name)) = parse_param_line(&body_str) {
267 result
268 .assertions_if_true
269 .push((name, parse_type_string(&ty_str)));
270 }
271 }
272 }
273 "psalm-assert-if-false" | "phpstan-assert-if-false" => {
274 if let Some(body_str) = body_text(&tag.body) {
275 if let Some((ty_str, name)) = parse_param_line(&body_str) {
276 result
277 .assertions_if_false
278 .push((name, parse_type_string(&ty_str)));
279 }
280 }
281 }
282 "psalm-property" => {
283 if let Some(body_str) = body_text(&tag.body) {
284 if let Some((ty_str, name)) = parse_param_line(&body_str) {
285 result.properties.push(DocProperty {
286 type_hint: ty_str,
287 name,
288 read_only: false,
289 write_only: false,
290 });
291 }
292 }
293 }
294 "psalm-property-read" => {
295 if let Some(body_str) = body_text(&tag.body) {
296 if let Some((ty_str, name)) = parse_param_line(&body_str) {
297 result.properties.push(DocProperty {
298 type_hint: ty_str,
299 name,
300 read_only: true,
301 write_only: false,
302 });
303 }
304 }
305 }
306 "psalm-property-write" => {
307 if let Some(body_str) = body_text(&tag.body) {
308 if let Some((ty_str, name)) = parse_param_line(&body_str) {
309 result.properties.push(DocProperty {
310 type_hint: ty_str,
311 name,
312 read_only: false,
313 write_only: true,
314 });
315 }
316 }
317 }
318 "psalm-require-extends" | "phpstan-require-extends" => {
319 if let Some(body_str) = body_text(&tag.body) {
320 let cls = body_str
321 .split_whitespace()
322 .next()
323 .unwrap_or("")
324 .trim()
325 .to_string();
326 if !cls.is_empty() {
327 result.require_extends.push(cls);
328 }
329 }
330 }
331 "psalm-require-implements" | "phpstan-require-implements" => {
332 if let Some(body_str) = body_text(&tag.body) {
333 let cls = body_str
334 .split_whitespace()
335 .next()
336 .unwrap_or("")
337 .trim()
338 .to_string();
339 if !cls.is_empty() {
340 result.require_implements.push(cls);
341 }
342 }
343 }
344 "mir-check" => {
345 if let Some(body_str) = body_text(&tag.body) {
346 if let Some((var_part, type_part)) = body_str.split_once(" is ") {
347 let var_name = var_part.trim().trim_start_matches('$').to_string();
348 let type_string = type_part.trim().to_string();
349 if !var_name.is_empty() && !type_string.is_empty() {
350 result.mir_checks.push((var_name, type_string));
351 }
352 }
353 }
354 }
355 _ => {}
356 }
357 }
358
359 if text.to_ascii_lowercase().contains("{@inheritdoc}") {
360 result.is_inherit_doc = true;
361 }
362
363 result
364 }
365}
366
367#[derive(Debug, Default, Clone)]
372pub struct DocProperty {
373 pub type_hint: String,
374 pub name: String, pub read_only: bool, pub write_only: bool, }
378
379#[derive(Debug, Default, Clone)]
380pub struct DocMethod {
381 pub return_type: String,
382 pub name: String,
383 pub is_static: bool,
384 pub params: Vec<DocMethodParam>,
385}
386
387#[derive(Debug, Default, Clone)]
388pub struct DocMethodParam {
389 pub name: String,
390 pub type_hint: String,
391 pub is_variadic: bool,
392 pub is_byref: bool,
393 pub is_optional: bool,
394}
395
396#[derive(Debug, Default, Clone)]
397pub struct DocTypeAlias {
398 pub name: String,
399 pub type_expr: String,
400}
401
402#[derive(Debug, Default, Clone)]
403pub struct DocImportType {
404 pub original: String,
406 pub local: String,
408 pub from_class: String,
410}
411
412#[derive(Debug, Default, Clone)]
417pub struct ParsedDocblock {
418 pub params: Vec<(String, Type)>,
420 pub return_type: Option<Type>,
422 pub var_type: Option<Type>,
424 pub var_name: Option<String>,
426 pub templates: Vec<(String, Option<Type>, Variance)>,
428 pub extends: Option<Type>,
430 pub implements: Vec<Type>,
432 pub throws: Vec<String>,
434 pub assertions: Vec<(String, Type)>,
436 pub assertions_if_true: Vec<(String, Type)>,
438 pub assertions_if_false: Vec<(String, Type)>,
440 pub suppressed_issues: Vec<String>,
442 pub is_deprecated: bool,
443 pub is_internal: bool,
444 pub is_pure: bool,
445 pub is_immutable: bool,
446 pub is_readonly: bool,
447 pub is_api: bool,
448 pub is_inherit_doc: bool,
451 pub description: String,
453 pub deprecated: Option<String>,
455 pub see: Vec<String>,
457 pub mixins: Vec<String>,
459 pub properties: Vec<DocProperty>,
461 pub methods: Vec<DocMethod>,
463 pub type_aliases: Vec<DocTypeAlias>,
465 pub import_types: Vec<DocImportType>,
467 pub require_extends: Vec<String>,
469 pub require_implements: Vec<String>,
471 pub since: Option<String>,
473 pub removed: Option<String>,
475 pub invalid_annotations: Vec<String>,
477 pub mir_checks: Vec<(String, String)>,
479}
480
481impl ParsedDocblock {
482 pub fn get_param_type(&self, name: &str) -> Option<&Type> {
488 let name = name.trim_start_matches('$');
489 self.params
490 .iter()
491 .rfind(|(n, _)| n.trim_start_matches('$') == name)
492 .map(|(_, ty)| ty)
493 }
494}
495
496pub fn parse_type_string(s: &str) -> Type {
504 let s = s.trim();
505
506 if let Some(inner) = s.strip_prefix('?') {
508 let inner_ty = parse_type_string(inner);
509 let mut u = inner_ty;
510 u.add_type(Atomic::TNull);
511 return u;
512 }
513
514 if s.starts_with('(') && s.ends_with(')') {
517 let inner = s[1..s.len() - 1].trim();
518 if let Some(conditional) = parse_conditional_type(inner) {
519 return conditional;
520 }
521 if is_balanced_parens(s) {
523 return parse_type_string(inner);
524 }
525 }
526
527 if s.contains('|') && !is_inside_generics(s) {
529 let parts = split_union(s);
530 if parts.len() > 1 {
531 let mut u = Type::empty();
532 for part in parts {
533 for atomic in parse_type_string(&part).types {
534 u.add_type(atomic);
535 }
536 }
537 return u;
538 }
539 }
540
541 if s.contains('&') && !is_inside_generics(s) {
544 let parts = split_intersection(s);
545 if parts.len() > 1 {
546 let parts: Vec<Type> = parts.iter().map(|p| parse_type_string(p.trim())).collect();
547 return Type::single(Atomic::TIntersection {
548 parts: mir_types::union::vec_to_type_params(parts),
549 });
550 }
551 }
552
553 if let Some(value_str) = s.strip_suffix("[]") {
555 let value = parse_type_string(value_str);
556 return Type::single(Atomic::TArray {
557 key: Box::new(Type::single(Atomic::TInt)),
558 value: Box::new(value),
559 });
560 }
561
562 if let Some(call_ty) = parse_callable_syntax(s) {
564 return call_ty;
565 }
566
567 if s.ends_with('}') {
569 if let Some(open) = s.find('{') {
570 let prefix = s[..open].to_lowercase();
571 let inner = &s[open + 1..s.len() - 1];
572 if prefix == "array" {
573 return parse_keyed_array(inner, false);
574 } else if prefix == "list" {
575 return parse_keyed_array(inner, true);
576 }
577 }
578 }
579
580 if let Some(open) = s.find('<') {
582 if s.ends_with('>') {
583 let name = &s[..open];
584 let inner = &s[open + 1..s.len() - 1];
585 return parse_generic(name, inner);
586 }
587 }
588
589 match s.to_lowercase().as_str() {
591 "string" => Type::single(Atomic::TString),
592 "non-empty-string" => Type::single(Atomic::TNonEmptyString),
593 "numeric-string" => Type::single(Atomic::TNumericString),
594 "class-string" => Type::single(Atomic::TClassString(None)),
595 "int" | "integer" => Type::single(Atomic::TInt),
596 "positive-int" => Type::single(Atomic::TPositiveInt),
597 "negative-int" => Type::single(Atomic::TNegativeInt),
598 "non-negative-int" => Type::single(Atomic::TNonNegativeInt),
599 "float" | "double" => Type::single(Atomic::TFloat),
600 "bool" | "boolean" => Type::single(Atomic::TBool),
601 "true" => Type::single(Atomic::TTrue),
602 "false" => Type::single(Atomic::TFalse),
603 "null" => Type::single(Atomic::TNull),
604 "void" => Type::single(Atomic::TVoid),
605 "never" | "never-return" | "no-return" | "never-returns" => Type::single(Atomic::TNever),
606 "mixed" => Type::single(Atomic::TMixed),
607 "object" => Type::single(Atomic::TObject),
608 "array" => Type::single(Atomic::TArray {
609 key: Box::new(Type::single(Atomic::TMixed)),
610 value: Box::new(Type::mixed()),
611 }),
612 "list" => Type::single(Atomic::TList {
613 value: Box::new(Type::mixed()),
614 }),
615 "callable" => Type::single(Atomic::TCallable {
616 params: None,
617 return_type: None,
618 }),
619 "callable-string" => Type::single(Atomic::TCallableString),
620 "iterable" => Type::single(Atomic::TArray {
621 key: Box::new(Type::single(Atomic::TMixed)),
622 value: Box::new(Type::mixed()),
623 }),
624 "scalar" => Type::single(Atomic::TScalar),
625 "numeric" => Type::single(Atomic::TNumeric),
626 "array-key" => {
627 let mut u = Type::single(Atomic::TInt);
628 u.add_type(Atomic::TString);
629 u
630 }
631 "resource" => Type::mixed(), "static" => Type::single(Atomic::TStaticObject {
634 fqcn: mir_types::Name::from(""),
635 }),
636 "self" | "$this" => Type::single(Atomic::TSelf {
637 fqcn: mir_types::Name::from(""),
638 }),
639 "parent" => Type::single(Atomic::TParent {
640 fqcn: mir_types::Name::from(""),
641 }),
642
643 _ if !s.is_empty()
645 && s.chars()
646 .next()
647 .map(|c| c.is_alphanumeric() || c == '\\' || c == '_')
648 .unwrap_or(false) =>
649 {
650 if let Ok(n) = s.parse::<i64>() {
652 return Type::single(Atomic::TLiteralInt(n));
653 }
654 Type::single(Atomic::TNamedObject {
655 fqcn: normalize_fqcn(s).into(),
656 type_params: mir_types::union::empty_type_params(),
657 })
658 }
659
660 _ if s.starts_with('-') && s.len() > 1 && s[1..].chars().all(|c| c.is_ascii_digit()) => {
662 if let Ok(n) = s.parse::<i64>() {
663 Type::single(Atomic::TLiteralInt(n))
664 } else {
665 Type::mixed()
666 }
667 }
668
669 _ if (s.starts_with('\'') && s.ends_with('\''))
671 || (s.starts_with('"') && s.ends_with('"')) =>
672 {
673 let inner = &s[1..s.len() - 1];
674 Type::single(Atomic::TLiteralString(Arc::from(inner)))
675 }
676
677 _ => Type::mixed(),
678 }
679}
680
681fn parse_generic(name: &str, inner: &str) -> Type {
682 match name.to_lowercase().as_str() {
683 "array" => {
684 let params = split_generics(inner);
685 let array_key = || {
686 let mut k = Type::single(Atomic::TInt);
687 k.add_type(Atomic::TString);
688 k
689 };
690 let (key, value) = match params.len() {
691 n if n >= 2 => (
692 parse_type_string(params[0].trim()),
693 parse_type_string(params[1].trim()),
694 ),
695 1 => (array_key(), parse_type_string(params[0].trim())),
696 _ => (array_key(), Type::mixed()),
697 };
698 Type::single(Atomic::TArray {
699 key: Box::new(key),
700 value: Box::new(value),
701 })
702 }
703 "list" | "non-empty-list" => {
704 let value = parse_type_string(inner.trim());
705 if name.to_lowercase().starts_with("non-empty") {
706 Type::single(Atomic::TNonEmptyList {
707 value: Box::new(value),
708 })
709 } else {
710 Type::single(Atomic::TList {
711 value: Box::new(value),
712 })
713 }
714 }
715 "non-empty-array" => {
716 let params = split_generics(inner);
717 let array_key = || {
718 let mut k = Type::single(Atomic::TInt);
719 k.add_type(Atomic::TString);
720 k
721 };
722 let (key, value) = match params.len() {
723 n if n >= 2 => (
724 parse_type_string(params[0].trim()),
725 parse_type_string(params[1].trim()),
726 ),
727 1 => (array_key(), parse_type_string(params[0].trim())),
728 _ => (array_key(), Type::mixed()),
729 };
730 Type::single(Atomic::TNonEmptyArray {
731 key: Box::new(key),
732 value: Box::new(value),
733 })
734 }
735 "iterable" => {
736 let params = split_generics(inner);
737 let value = match params.len() {
738 n if n >= 2 => parse_type_string(params[1].trim()),
739 1 => parse_type_string(params[0].trim()),
740 _ => Type::mixed(),
741 };
742 Type::single(Atomic::TArray {
743 key: Box::new(Type::single(Atomic::TMixed)),
744 value: Box::new(value),
745 })
746 }
747 "class-string" => Type::single(Atomic::TClassString(Some(
748 normalize_fqcn(inner.trim()).into(),
749 ))),
750 "int" => {
751 Type::single(Atomic::TIntRange {
753 min: None,
754 max: None,
755 })
756 }
757 _ => {
759 let params: Vec<Type> = split_generics(inner)
760 .iter()
761 .map(|p| parse_type_string(p.trim()))
762 .collect();
763 Type::single(Atomic::TNamedObject {
764 fqcn: normalize_fqcn(name).into(),
765 type_params: mir_types::union::vec_to_type_params(params),
766 })
767 }
768 }
769}
770
771fn strip_quotes(s: &str) -> &str {
772 if (s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')) {
773 &s[1..s.len() - 1]
774 } else {
775 s
776 }
777}
778
779fn parse_keyed_array(inner: &str, is_list: bool) -> Type {
780 use mir_types::atomic::KeyedProperty;
781 let mut properties: IndexMap<ArrayKey, KeyedProperty> = IndexMap::new();
782 let mut is_open = false;
783 let mut auto_index = 0i64;
784
785 for item in split_generics(inner) {
786 let item = item.trim();
787 if item.is_empty() {
788 continue;
789 }
790 if item == "..." {
791 is_open = true;
792 continue;
793 }
794 let colon_pos = {
796 let mut depth = 0i32;
797 let mut found = None;
798 for (i, ch) in item.char_indices() {
799 match ch {
800 '<' | '(' | '{' => depth += 1,
801 '>' | ')' | '}' => depth -= 1,
802 ':' if depth == 0 => {
803 found = Some(i);
804 break;
805 }
806 _ => {}
807 }
808 }
809 found
810 };
811 if let Some(colon) = colon_pos {
812 let key_part = item[..colon].trim();
813 let ty_part = item[colon + 1..].trim();
814 let optional = key_part.ends_with('?');
815 let key_str = key_part.trim_end_matches('?').trim();
816 let key_str = strip_quotes(key_str);
817 let key = if let Ok(n) = key_str.parse::<i64>() {
818 ArrayKey::Int(n)
819 } else {
820 ArrayKey::String(Arc::from(key_str))
821 };
822 properties.insert(
823 key,
824 KeyedProperty {
825 ty: parse_type_string(ty_part),
826 optional,
827 },
828 );
829 } else {
830 properties.insert(
831 ArrayKey::Int(auto_index),
832 KeyedProperty {
833 ty: parse_type_string(item),
834 optional: false,
835 },
836 );
837 auto_index += 1;
838 }
839 }
840
841 Type::single(Atomic::TKeyedArray {
842 properties,
843 is_open,
844 is_list,
845 })
846}
847
848fn parse_callable_syntax(s: &str) -> Option<Type> {
849 let s = s.trim_start_matches('\\');
850 let lower = s.to_lowercase();
851 let is_closure = lower.starts_with("closure");
852 let is_callable = lower.starts_with("callable");
853 if !is_closure && !is_callable {
854 return None;
855 }
856 let prefix_len = if is_closure {
857 "closure".len()
858 } else {
859 "callable".len()
860 };
861 let rest = s[prefix_len..].trim_start();
862 if !rest.starts_with('(') {
863 return None;
864 }
865 let close = find_matching_paren(rest)?;
866 let params_str = &rest[1..close];
867 let after = rest[close + 1..].trim();
868 let return_type = after
869 .strip_prefix(':')
870 .map(|ret_str| Box::new(parse_type_string(ret_str.trim())));
871 let params: Vec<mir_types::atomic::FnParam> = split_generics(params_str)
872 .into_iter()
873 .enumerate()
874 .filter(|(_, p)| !p.trim().is_empty())
875 .map(|(i, p)| {
876 let p = p.trim();
877 let (ty_str, name) = if let Some(dollar) = p.rfind('$') {
878 (p[..dollar].trim(), p[dollar + 1..].to_string())
879 } else {
880 (p, format!("arg{i}"))
881 };
882 mir_types::atomic::FnParam {
883 name: name.into(),
884 ty: Some(mir_types::SimpleType::from_union(parse_type_string(ty_str))),
885 default: None,
886 is_variadic: false,
887 is_byref: false,
888 is_optional: false,
889 }
890 })
891 .collect();
892 if is_closure {
893 Some(Type::single(Atomic::TClosure {
894 params,
895 return_type: return_type.unwrap_or_else(|| Box::new(Type::single(Atomic::TVoid))),
896 this_type: None,
897 }))
898 } else {
899 Some(Type::single(Atomic::TCallable {
900 params: Some(params),
901 return_type,
902 }))
903 }
904}
905
906fn find_matching_paren(s: &str) -> Option<usize> {
907 if !s.starts_with('(') {
908 return None;
909 }
910 let mut depth = 0i32;
911 for (i, ch) in s.char_indices() {
912 match ch {
913 '(' | '<' | '{' => depth += 1,
914 ')' | '>' | '}' => {
915 depth -= 1;
916 if depth == 0 {
917 return Some(i);
918 }
919 }
920 _ => {}
921 }
922 }
923 None
924}
925
926fn parse_template_line(_tag_name: &str, body: Option<String>) -> Option<(String, Option<String>)> {
938 let body = body?;
939 let body = body.trim();
940 let body = match body.find(" @") {
943 Some(idx) => body[..idx].trim_end(),
944 None => body,
945 };
946 if body.is_empty() {
947 return None;
948 }
949 if let Some((name, bound)) = body.split_once(" of ").or_else(|| body.split_once(" as ")) {
950 let bound = bound.trim();
951 Some((
952 name.trim().to_string(),
953 (!bound.is_empty()).then(|| bound.to_string()),
954 ))
955 } else {
956 let name = body.split_whitespace().next().unwrap_or(body);
958 Some((name.to_string(), None))
959 }
960}
961
962fn extract_description(text: &str) -> String {
964 let mut desc_lines: Vec<&str> = Vec::new();
965 for line in text.lines() {
966 let l = line.trim();
967 let l = l.trim_start_matches("/**").trim();
968 let l = l.trim_end_matches("*/").trim();
969 let l = l.trim_start_matches("*/").trim();
970 let l = l.strip_prefix("* ").unwrap_or(l.trim_start_matches('*'));
971 let l = l.trim();
972 if l.starts_with('@') {
973 break;
974 }
975 if !l.is_empty() {
976 desc_lines.push(l);
977 }
978 }
979 desc_lines.join(" ")
980}
981
982fn parse_import_type(body: &str) -> Option<DocImportType> {
988 let (before_from, from_class_raw) = body.split_once(" from ")?;
990 let from_class = from_class_raw.trim().trim_start_matches('\\').to_string();
991 if from_class.is_empty() {
992 return None;
993 }
994 let (original, local) = if let Some((orig, loc)) = before_from.split_once(" as ") {
996 (orig.trim().to_string(), loc.trim().to_string())
997 } else {
998 let name = before_from.trim().to_string();
999 (name.clone(), name)
1000 };
1001 if original.is_empty() || local.is_empty() {
1002 return None;
1003 }
1004 Some(DocImportType {
1005 original,
1006 local,
1007 from_class,
1008 })
1009}
1010
1011fn parse_param_line(s: &str) -> Option<(String, String)> {
1012 let first_line = s.lines().next().unwrap_or(s);
1018
1019 let mut best_split: Option<(String, String)> = None;
1022
1023 for (i, ch) in first_line.char_indices() {
1024 if ch.is_whitespace() {
1025 let after = first_line[i..].trim_start();
1026 let after_stripped = after.strip_prefix('&').unwrap_or(after);
1028 if after_stripped.starts_with('$') {
1029 let mut var_parts = after_stripped.split(char::is_whitespace);
1030 if let Some(name_with_dollar) = var_parts.next() {
1031 let name = name_with_dollar.trim_start_matches('$').to_string();
1032 if !name.is_empty() {
1033 let type_part = first_line[..i].trim().to_string();
1034 if !type_part.is_empty() {
1035 best_split = Some((type_part, name));
1036 }
1037 }
1038 }
1039 }
1040 }
1041 }
1042
1043 best_split
1044}
1045
1046fn extract_return_type(s: &str) -> String {
1047 let mut depth: i32 = 0;
1056 let mut current_token = String::new();
1057
1058 for ch in s.chars() {
1059 match ch {
1060 '<' | '(' | '{' => {
1061 depth += 1;
1062 current_token.push(ch);
1063 }
1064 '>' | ')' | '}' => {
1065 depth = (depth - 1).max(0);
1066 current_token.push(ch);
1067 }
1068 _ if ch.is_whitespace() && depth == 0 => {
1069 break;
1070 }
1071 _ => {
1072 current_token.push(ch);
1073 }
1074 }
1075 }
1076
1077 if current_token.ends_with(':') {
1081 let offset = current_token.len();
1082 let rest = s[offset..].trim_start();
1083 if !rest.is_empty() {
1084 let ret_type = extract_return_type(rest);
1085 current_token.push_str(&ret_type);
1086 }
1087 }
1088
1089 current_token.trim().to_string()
1090}
1091
1092fn split_union(s: &str) -> Vec<String> {
1093 let mut parts = Vec::new();
1094 let mut depth = 0;
1095 let mut current = String::new();
1096 for ch in s.chars() {
1097 match ch {
1098 '<' | '(' | '{' => {
1099 depth += 1;
1100 current.push(ch);
1101 }
1102 '>' | ')' | '}' => {
1103 depth -= 1;
1104 current.push(ch);
1105 }
1106 '|' if depth == 0 => {
1107 parts.push(current.trim().to_string());
1108 current = String::new();
1109 }
1110 _ => current.push(ch),
1111 }
1112 }
1113 if !current.trim().is_empty() {
1114 parts.push(current.trim().to_string());
1115 }
1116 parts
1117}
1118
1119fn split_intersection(s: &str) -> Vec<String> {
1121 let mut parts = Vec::new();
1122 let mut depth = 0i32;
1123 let mut current = String::new();
1124 for ch in s.chars() {
1125 match ch {
1126 '<' | '(' | '{' => {
1127 depth += 1;
1128 current.push(ch);
1129 }
1130 '>' | ')' | '}' => {
1131 depth -= 1;
1132 current.push(ch);
1133 }
1134 '&' if depth == 0 => {
1135 parts.push(current.trim().to_string());
1136 current = String::new();
1137 }
1138 _ => current.push(ch),
1139 }
1140 }
1141 if !current.trim().is_empty() {
1142 parts.push(current.trim().to_string());
1143 }
1144 parts
1145}
1146
1147fn is_balanced_parens(s: &str) -> bool {
1151 if !s.starts_with('(') || !s.ends_with(')') {
1152 return false;
1153 }
1154 let mut depth = 0i32;
1155 let chars: Vec<char> = s.chars().collect();
1156 let last = chars.len() - 1;
1157 for (i, ch) in chars.iter().enumerate() {
1158 match ch {
1159 '(' => depth += 1,
1160 ')' => {
1161 depth -= 1;
1162 if depth == 0 && i < last {
1165 return false;
1166 }
1167 }
1168 _ => {}
1169 }
1170 }
1171 depth == 0
1172}
1173
1174fn split_generics(s: &str) -> Vec<String> {
1175 let mut parts = Vec::new();
1176 let mut depth = 0;
1177 let mut current = String::new();
1178 for ch in s.chars() {
1179 match ch {
1180 '<' | '(' | '{' => {
1181 depth += 1;
1182 current.push(ch);
1183 }
1184 '>' | ')' | '}' => {
1185 depth -= 1;
1186 current.push(ch);
1187 }
1188 ',' if depth == 0 => {
1189 parts.push(current.trim().to_string());
1190 current = String::new();
1191 }
1192 _ => current.push(ch),
1193 }
1194 }
1195 if !current.trim().is_empty() {
1196 parts.push(current.trim().to_string());
1197 }
1198 parts
1199}
1200
1201fn extract_type_prefix(s: &str) -> &str {
1204 let mut depth = 0i32;
1205 let mut end = s.len();
1206 for (i, ch) in s.char_indices() {
1207 match ch {
1208 '<' | '(' | '{' => depth += 1,
1209 '>' | ')' | '}' => depth -= 1,
1210 _ if ch.is_whitespace() && depth == 0 => {
1211 end = i;
1212 break;
1213 }
1214 _ => {}
1215 }
1216 }
1217 &s[..end]
1218}
1219
1220fn is_inside_generics(s: &str) -> bool {
1221 let mut depth = 0i32;
1222 for ch in s.chars() {
1223 match ch {
1224 '<' | '(' | '{' => depth += 1,
1225 '>' | ')' | '}' => depth -= 1,
1226 _ => {}
1227 }
1228 }
1229 depth != 0
1230}
1231
1232fn parse_conditional_type(s: &str) -> Option<Type> {
1235 let is_pos = s.find(" is ")?;
1236 let param_raw = s[..is_pos].trim();
1237
1238 let param_name_str: &str = if let Some(name) = param_raw.strip_prefix('$') {
1240 if name.is_empty() {
1241 return None;
1242 }
1243 name
1244 } else {
1245 if param_raw.is_empty()
1248 || !param_raw.starts_with(|c: char| c.is_alphabetic() || c == '_')
1249 || !param_raw.chars().all(|c| c.is_alphanumeric() || c == '_')
1250 || !s.contains('?')
1251 {
1252 return None;
1253 }
1254 param_raw
1255 };
1256 let param_name = Some(mir_types::Name::new(param_name_str));
1257 let after_is = s[is_pos + 4..].trim();
1258 let q_pos = find_char_at_depth(after_is, '?')?;
1259 let subject_str = after_is[..q_pos].trim();
1260 let rest = after_is[q_pos + 1..].trim();
1261 let colon_pos = find_char_at_depth(rest, ':')?;
1262 let true_str = rest[..colon_pos].trim();
1263 let false_str = rest[colon_pos + 1..].trim();
1264 Some(Type::single(Atomic::TConditional {
1265 param_name,
1266 subject: Box::new(parse_type_string(subject_str)),
1267 if_true: Box::new(parse_type_string(true_str)),
1268 if_false: Box::new(parse_type_string(false_str)),
1269 }))
1270}
1271
1272fn find_char_at_depth(s: &str, target: char) -> Option<usize> {
1274 let mut depth = 0i32;
1275 for (i, ch) in s.char_indices() {
1276 match ch {
1277 '<' | '(' | '{' => depth += 1,
1278 '>' | ')' | '}' => depth -= 1,
1279 _ if ch == target && depth == 0 => return Some(i),
1280 _ => {}
1281 }
1282 }
1283 None
1284}
1285
1286fn normalize_fqcn(s: &str) -> String {
1287 s.trim_start_matches('\\').to_string()
1289}
1290
1291fn validate_type_str(s: &str, tag: &str) -> Option<String> {
1297 let s = s.trim();
1298 if s.is_empty() {
1299 return None;
1300 }
1301 if is_inside_generics(s) {
1302 return Some(format!("@{tag} has unclosed generic type `{s}`"));
1303 }
1304 let is_callable_type = s.to_lowercase().contains("callable") || s.contains("Closure");
1306 if !is_callable_type && has_empty_generics(s) {
1307 return Some(format!("@{tag} has empty generic type parameter in `{s}`"));
1308 }
1309 for part in split_union(s) {
1310 let p = part.trim();
1311 if p.starts_with('$') && p != "$this" {
1312 return Some(format!("@{tag} contains variable `{p}` in type position"));
1313 }
1314 }
1315 None
1316}
1317
1318fn has_empty_generics(s: &str) -> bool {
1319 let mut depth = 0;
1320 let mut prev_open = false;
1321 for ch in s.chars() {
1322 match ch {
1323 '<' | '(' | '{' => {
1324 if prev_open && depth == 0 {
1325 return true;
1326 }
1327 prev_open = true;
1328 depth += 1;
1329 }
1330 '>' | ')' | '}' => {
1331 depth -= 1;
1332 if depth == 0 {
1333 if prev_open {
1334 return true;
1335 }
1336 prev_open = false;
1337 }
1338 }
1339 c if !c.is_whitespace() => {
1340 prev_open = false;
1341 }
1342 _ => {}
1343 }
1344 }
1345 false
1346}
1347
1348fn parse_method_line(s: &str) -> Option<DocMethod> {
1350 let mut rest = s.trim();
1351 if rest.is_empty() {
1352 return None;
1353 }
1354 let is_static = rest
1355 .split_whitespace()
1356 .next()
1357 .map(|w| w.eq_ignore_ascii_case("static"))
1358 .unwrap_or(false);
1359 if is_static {
1360 rest = rest["static".len()..].trim_start();
1361 }
1362
1363 let open = rest.find('(').unwrap_or(rest.len());
1364 let prefix = rest[..open].trim();
1365 let mut parts: Vec<&str> = prefix.split_whitespace().collect();
1366 let name = parts.pop()?.to_string();
1367 if name.is_empty() {
1368 return None;
1369 }
1370 let return_type = parts.join(" ");
1371 Some(DocMethod {
1372 return_type,
1373 name,
1374 is_static,
1375 params: parse_method_params(rest),
1376 })
1377}
1378
1379fn parse_method_params(name_part: &str) -> Vec<DocMethodParam> {
1380 let Some(open) = name_part.find('(') else {
1381 return vec![];
1382 };
1383 let Some(rel_close) = find_matching_paren(&name_part[open..]) else {
1387 return vec![];
1388 };
1389 let close = open + rel_close;
1390 let inner = name_part[open + 1..close].trim();
1391 if inner.is_empty() {
1392 return vec![];
1393 }
1394
1395 split_generics(inner)
1396 .into_iter()
1397 .filter_map(|param| parse_method_param(¶m))
1398 .collect()
1399}
1400
1401fn parse_method_param(param: &str) -> Option<DocMethodParam> {
1402 let before_default = param.split('=').next()?.trim();
1403 let is_optional = param.contains('=');
1404 let mut tokens: Vec<&str> = before_default.split_whitespace().collect();
1405 let raw_name = tokens.pop()?;
1406 let is_variadic = raw_name.contains("...");
1407 let is_byref = raw_name.contains('&');
1408 let name = raw_name
1409 .trim_start_matches('&')
1410 .trim_start_matches("...")
1411 .trim_start_matches('&')
1412 .trim_start_matches('$')
1413 .to_string();
1414 if name.is_empty() {
1415 return None;
1416 }
1417 Some(DocMethodParam {
1418 name,
1419 type_hint: tokens.join(" "),
1420 is_variadic,
1421 is_byref,
1422 is_optional: is_optional || is_variadic,
1423 })
1424}
1425
1426#[cfg(test)]
1431mod tests {
1432 use super::*;
1433 use mir_types::Atomic;
1434
1435 #[test]
1436 fn parse_string() {
1437 let u = parse_type_string("string");
1438 assert_eq!(u.types.len(), 1);
1439 assert!(matches!(u.types[0], Atomic::TString));
1440 }
1441
1442 #[test]
1443 fn parse_nullable_string() {
1444 let u = parse_type_string("?string");
1445 assert!(u.is_nullable());
1446 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1447 }
1448
1449 #[test]
1450 fn parse_union() {
1451 let u = parse_type_string("string|int|null");
1452 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1453 assert!(u.contains(|t| matches!(t, Atomic::TInt)));
1454 assert!(u.is_nullable());
1455 }
1456
1457 #[test]
1458 fn parse_array_of_string() {
1459 let u = parse_type_string("array<string>");
1460 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1461 }
1462
1463 #[test]
1464 fn parse_list_of_int() {
1465 let u = parse_type_string("list<int>");
1466 assert!(u.contains(|t| matches!(t, Atomic::TList { .. })));
1467 }
1468
1469 #[test]
1470 fn parse_named_class() {
1471 let u = parse_type_string("Foo\\Bar");
1472 assert!(u.contains(
1473 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Foo\\Bar")
1474 ));
1475 }
1476
1477 #[test]
1478 fn parse_docblock_param_return() {
1479 let doc = r#"/**
1480 * @param string $name
1481 * @param int $age
1482 * @return bool
1483 */"#;
1484 let parsed = DocblockParser::parse(doc);
1485 assert_eq!(parsed.params.len(), 2);
1486 assert!(parsed.return_type.is_some());
1487 let ret = parsed.return_type.unwrap();
1488 assert!(ret.contains(|t| matches!(t, Atomic::TBool)));
1489 }
1490
1491 #[test]
1492 fn parse_template() {
1493 let doc = "/** @template T of object */";
1494 let parsed = DocblockParser::parse(doc);
1495 assert_eq!(parsed.templates.len(), 1);
1496 assert_eq!(parsed.templates[0].0, "T");
1497 assert!(parsed.templates[0].1.is_some());
1498 assert_eq!(parsed.templates[0].2, Variance::Invariant);
1499 }
1500
1501 #[test]
1502 fn parse_template_covariant() {
1503 let doc = "/** @template-covariant T */";
1504 let parsed = DocblockParser::parse(doc);
1505 assert_eq!(parsed.templates.len(), 1);
1506 assert_eq!(parsed.templates[0].0, "T");
1507 assert_eq!(parsed.templates[0].2, Variance::Covariant);
1508 }
1509
1510 #[test]
1511 fn parse_template_contravariant() {
1512 let doc = "/** @template-contravariant T */";
1513 let parsed = DocblockParser::parse(doc);
1514 assert_eq!(parsed.templates.len(), 1);
1515 assert_eq!(parsed.templates[0].0, "T");
1516 assert_eq!(parsed.templates[0].2, Variance::Contravariant);
1517 }
1518
1519 #[test]
1520 fn parse_template_single_line_does_not_over_read() {
1521 let doc = "/** @template T @param T $x @return T */";
1524 let parsed = DocblockParser::parse(doc);
1525 assert_eq!(parsed.templates.len(), 1);
1526 assert_eq!(parsed.templates[0].0, "T");
1527 assert!(parsed.templates[0].1.is_none(), "expected no bound");
1528 }
1529
1530 #[test]
1531 fn parse_template_multiline_with_bound_still_works() {
1532 let doc = r#"/**
1534 * @template T of Base
1535 */"#;
1536 let parsed = DocblockParser::parse(doc);
1537 assert_eq!(parsed.templates.len(), 1);
1538 assert_eq!(parsed.templates[0].0, "T");
1539 let bound = parsed.templates[0].1.as_ref().expect("expected a bound");
1540 assert!(bound.contains(
1541 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Base")
1542 ));
1543 }
1544
1545 #[test]
1546 fn parse_template_extends_alias() {
1547 for tag in ["template-extends", "phpstan-extends"] {
1549 let doc = format!("/** @{tag} Base<User> */");
1550 let parsed = DocblockParser::parse(&doc);
1551 let extends = parsed
1552 .extends
1553 .unwrap_or_else(|| panic!("@{tag} should populate extends"));
1554 assert!(
1555 extends.contains(|t| matches!(
1556 t,
1557 Atomic::TNamedObject { fqcn, type_params }
1558 if fqcn.as_ref() == "Base" && !type_params.is_empty()
1559 )),
1560 "@{tag} should produce a generic Base<User>"
1561 );
1562 }
1563 }
1564
1565 #[test]
1566 fn parse_template_implements_alias() {
1567 for tag in ["template-implements", "phpstan-implements"] {
1569 let doc = format!("/** @{tag} Iter<User> */");
1570 let parsed = DocblockParser::parse(&doc);
1571 assert_eq!(
1572 parsed.implements.len(),
1573 1,
1574 "@{tag} should populate implements"
1575 );
1576 assert!(
1577 parsed.implements[0].contains(|t| matches!(
1578 t,
1579 Atomic::TNamedObject { fqcn, type_params }
1580 if fqcn.as_ref() == "Iter" && !type_params.is_empty()
1581 )),
1582 "@{tag} should produce a generic Iter<User>"
1583 );
1584 }
1585 }
1586
1587 #[test]
1588 fn parse_deprecated() {
1589 let doc = "/** @deprecated use newMethod() instead */";
1590 let parsed = DocblockParser::parse(doc);
1591 assert!(parsed.is_deprecated);
1592 assert_eq!(
1593 parsed.deprecated.as_deref(),
1594 Some("use newMethod() instead")
1595 );
1596 }
1597
1598 #[test]
1599 fn parse_since_plain() {
1600 let parsed = DocblockParser::parse("/** @since 8.0 */");
1601 assert_eq!(parsed.since.as_deref(), Some("8.0"));
1602 assert_eq!(parsed.removed, None);
1603 }
1604
1605 #[test]
1606 fn parse_since_strips_trailing_description() {
1607 let parsed = DocblockParser::parse("/** @since 1.4.0 added \\$options argument */");
1610 assert_eq!(parsed.since.as_deref(), Some("1.4.0"));
1611 }
1612
1613 #[test]
1614 fn parse_removed_tag() {
1615 let parsed = DocblockParser::parse("/** @removed 8.0 use mb_convert_encoding */");
1616 assert_eq!(parsed.removed.as_deref(), Some("8.0"));
1617 }
1618
1619 #[test]
1620 fn parse_since_empty_body_is_none() {
1621 let parsed = DocblockParser::parse("/** @since */");
1622 assert_eq!(parsed.since, None);
1623 }
1624
1625 #[test]
1626 fn parse_description() {
1627 let doc = r#"/**
1628 * This is a description.
1629 * Spans two lines.
1630 * @param string $x
1631 */"#;
1632 let parsed = DocblockParser::parse(doc);
1633 assert!(parsed.description.contains("This is a description"));
1634 assert!(parsed.description.contains("Spans two lines"));
1635 }
1636
1637 #[test]
1638 fn parse_see_and_link() {
1639 let doc = "/** @see SomeClass\n * @link https://example.com */";
1640 let parsed = DocblockParser::parse(doc);
1641 assert_eq!(parsed.see.len(), 2);
1642 assert!(parsed.see.contains(&"SomeClass".to_string()));
1643 assert!(parsed.see.contains(&"https://example.com".to_string()));
1644 }
1645
1646 #[test]
1647 fn parse_mixin() {
1648 let doc = "/** @mixin SomeTrait */";
1649 let parsed = DocblockParser::parse(doc);
1650 assert_eq!(parsed.mixins, vec!["SomeTrait".to_string()]);
1651 }
1652
1653 #[test]
1654 fn parse_property_tags() {
1655 let doc = r#"/**
1656 * @property string $name
1657 * @property-read int $id
1658 * @property-write bool $active
1659 */"#;
1660 let parsed = DocblockParser::parse(doc);
1661 assert_eq!(parsed.properties.len(), 3);
1662 let name_prop = parsed.properties.iter().find(|p| p.name == "name").unwrap();
1663 assert_eq!(name_prop.type_hint, "string");
1664 assert!(!name_prop.read_only);
1665 assert!(!name_prop.write_only);
1666 let id_prop = parsed.properties.iter().find(|p| p.name == "id").unwrap();
1667 assert!(id_prop.read_only);
1668 let active_prop = parsed
1669 .properties
1670 .iter()
1671 .find(|p| p.name == "active")
1672 .unwrap();
1673 assert!(active_prop.write_only);
1674 }
1675
1676 #[test]
1677 fn parse_method_tag() {
1678 let doc = r#"/**
1679 * @method string getName()
1680 * @method static int create()
1681 */"#;
1682 let parsed = DocblockParser::parse(doc);
1683 assert_eq!(parsed.methods.len(), 2);
1684 let get_name = parsed.methods.iter().find(|m| m.name == "getName").unwrap();
1685 assert_eq!(get_name.return_type, "string");
1686 assert!(!get_name.is_static);
1687 let create = parsed.methods.iter().find(|m| m.name == "create").unwrap();
1688 assert!(create.is_static);
1689 }
1690
1691 #[test]
1692 fn parse_method_tag_description_with_parens() {
1693 let doc = r#"/**
1697 * @method $this addDay() Add one day to the instance (using date interval).
1698 * @method $this subDays(int|float $value = 1) Sub days (the $value count passed in).
1699 */"#;
1700 let parsed = DocblockParser::parse(doc);
1701 let add_day = parsed.methods.iter().find(|m| m.name == "addDay").unwrap();
1702 assert_eq!(add_day.params.len(), 0, "addDay() must have zero params");
1703 let sub_days = parsed.methods.iter().find(|m| m.name == "subDays").unwrap();
1704 assert_eq!(sub_days.params.len(), 1);
1705 assert!(sub_days.params[0].is_optional);
1706 }
1707
1708 #[test]
1709 fn parse_type_alias_tag() {
1710 let doc = "/** @psalm-type MyAlias = string|int */";
1711 let parsed = DocblockParser::parse(doc);
1712 assert_eq!(parsed.type_aliases.len(), 1);
1713 assert_eq!(parsed.type_aliases[0].name, "MyAlias");
1714 assert_eq!(parsed.type_aliases[0].type_expr, "string|int");
1715 }
1716
1717 #[test]
1718 fn parse_import_type_no_as() {
1719 let doc = "/** @psalm-import-type UserId from UserRepository */";
1720 let parsed = DocblockParser::parse(doc);
1721 assert_eq!(parsed.import_types.len(), 1);
1722 assert_eq!(parsed.import_types[0].original, "UserId");
1723 assert_eq!(parsed.import_types[0].local, "UserId");
1724 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1725 }
1726
1727 #[test]
1728 fn parse_import_type_with_as() {
1729 let doc = "/** @psalm-import-type UserId as LocalId from UserRepository */";
1730 let parsed = DocblockParser::parse(doc);
1731 assert_eq!(parsed.import_types.len(), 1);
1732 assert_eq!(parsed.import_types[0].original, "UserId");
1733 assert_eq!(parsed.import_types[0].local, "LocalId");
1734 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1735 }
1736
1737 #[test]
1738 fn parse_require_extends() {
1739 let doc = "/** @psalm-require-extends Model */";
1740 let parsed = DocblockParser::parse(doc);
1741 assert_eq!(parsed.require_extends, vec!["Model".to_string()]);
1742 }
1743
1744 #[test]
1745 fn parse_require_implements() {
1746 let doc = "/** @psalm-require-implements Countable */";
1747 let parsed = DocblockParser::parse(doc);
1748 assert_eq!(parsed.require_implements, vec!["Countable".to_string()]);
1749 }
1750
1751 #[test]
1752 fn parse_intersection_two_parts() {
1753 let u = parse_type_string("Iterator&Countable");
1754 assert_eq!(u.types.len(), 1);
1755 assert!(matches!(u.types[0], Atomic::TIntersection { ref parts } if parts.len() == 2));
1756 if let Atomic::TIntersection { parts } = &u.types[0] {
1757 assert!(parts[0].contains(
1758 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1759 ));
1760 assert!(parts[1].contains(
1761 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1762 ));
1763 }
1764 }
1765
1766 #[test]
1767 fn parse_intersection_three_parts() {
1768 let u = parse_type_string("Iterator&Countable&Stringable");
1769 assert_eq!(u.types.len(), 1);
1770 let Atomic::TIntersection { parts } = &u.types[0] else {
1771 panic!("expected TIntersection");
1772 };
1773 assert_eq!(parts.len(), 3);
1774 assert!(parts[0].contains(
1775 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1776 ));
1777 assert!(parts[1].contains(
1778 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1779 ));
1780 assert!(parts[2].contains(
1781 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Stringable")
1782 ));
1783 }
1784
1785 #[test]
1786 fn parse_intersection_in_union_with_null() {
1787 let u = parse_type_string("Iterator&Countable|null");
1788 assert!(u.is_nullable());
1789 let intersection = u
1790 .types
1791 .iter()
1792 .find_map(|t| {
1793 if let Atomic::TIntersection { parts } = t {
1794 Some(parts)
1795 } else {
1796 None
1797 }
1798 })
1799 .expect("expected TIntersection");
1800 assert_eq!(intersection.len(), 2);
1801 assert!(intersection[0].contains(
1802 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1803 ));
1804 assert!(intersection[1].contains(
1805 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1806 ));
1807 }
1808
1809 #[test]
1810 fn parse_intersection_in_union_with_scalar() {
1811 let u = parse_type_string("Iterator&Countable|string");
1812 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1813 let intersection = u
1814 .types
1815 .iter()
1816 .find_map(|t| {
1817 if let Atomic::TIntersection { parts } = t {
1818 Some(parts)
1819 } else {
1820 None
1821 }
1822 })
1823 .expect("expected TIntersection");
1824 assert!(intersection[0].contains(
1825 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1826 ));
1827 assert!(intersection[1].contains(
1828 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1829 ));
1830 }
1831
1832 #[test]
1833 fn validate_unclosed_generic_return() {
1834 let parsed = DocblockParser::parse("/** @return array< */");
1835 assert_eq!(parsed.invalid_annotations.len(), 1);
1836 assert!(
1837 parsed.invalid_annotations[0].contains("unclosed generic"),
1838 "got: {}",
1839 parsed.invalid_annotations[0]
1840 );
1841 }
1842
1843 #[test]
1844 fn parse_empty_generic_array_graceful() {
1845 let u = parse_type_string("array<>");
1846 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1847 }
1848
1849 #[test]
1850 fn parse_empty_generic_iterable_graceful() {
1851 let u = parse_type_string("iterable<>");
1852 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1853 }
1854
1855 #[test]
1856 fn parse_empty_generic_non_empty_array_graceful() {
1857 let u = parse_type_string("non-empty-array<>");
1858 assert!(u.contains(|t| matches!(t, Atomic::TNonEmptyArray { .. })));
1859 }
1860
1861 #[test]
1862 fn validate_variable_in_type_position_param() {
1863 let parsed = DocblockParser::parse("/** @param Foo|$invalid $x */");
1864 assert_eq!(parsed.invalid_annotations.len(), 1);
1865 assert!(
1866 parsed.invalid_annotations[0].contains("$invalid"),
1867 "got: {}",
1868 parsed.invalid_annotations[0]
1869 );
1870 }
1871
1872 #[test]
1873 fn validate_this_is_valid_in_type_position() {
1874 let parsed = DocblockParser::parse("/** @return $this */");
1875 assert!(
1876 parsed.invalid_annotations.is_empty(),
1877 "unexpected error: {:?}",
1878 parsed.invalid_annotations
1879 );
1880 }
1881
1882 #[test]
1883 fn validate_unclosed_generic_var() {
1884 let parsed = DocblockParser::parse("/** @var array<string */");
1885 assert_eq!(parsed.invalid_annotations.len(), 1);
1886 assert!(parsed.invalid_annotations[0].contains("@var"));
1887 }
1888
1889 #[test]
1890 fn validate_variable_in_template_bound() {
1891 let parsed = DocblockParser::parse("/** @template T of $invalid */");
1892 assert_eq!(parsed.invalid_annotations.len(), 1);
1893 assert!(parsed.invalid_annotations[0].contains("$invalid"));
1894 }
1895}