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