1use mir_types::{Atomic, Union, Variance};
2use std::sync::Arc;
5
6use php_rs_parser::phpdoc::PhpDocTag;
7
8pub struct DocblockParser;
13
14impl DocblockParser {
15 pub fn parse(text: &str) -> ParsedDocblock {
16 let doc = php_rs_parser::phpdoc::parse(text);
17 let mut result = ParsedDocblock {
18 description: extract_description(text),
19 ..Default::default()
20 };
21
22 for tag in &doc.tags {
23 match tag {
24 PhpDocTag::Param {
25 type_str: Some(ty_s),
26 name: Some(n),
27 ..
28 } => {
29 result.params.push((
30 n.trim_start_matches('$').to_string(),
31 parse_type_string(ty_s),
32 ));
33 }
34 PhpDocTag::Return {
35 type_str: Some(ty_s),
36 ..
37 } => {
38 result.return_type = Some(parse_type_string(ty_s));
39 }
40 PhpDocTag::Var { type_str, name, .. } => {
41 if let Some(ty_s) = type_str {
42 result.var_type = Some(parse_type_string(ty_s));
43 }
44 if let Some(n) = name {
45 result.var_name = Some(n.trim_start_matches('$').to_string());
46 }
47 }
48 PhpDocTag::Throws {
49 type_str: Some(ty_s),
50 ..
51 } => {
52 let class = ty_s.split_whitespace().next().unwrap_or("").to_string();
53 if !class.is_empty() {
54 result.throws.push(class);
55 }
56 }
57 PhpDocTag::Deprecated { description } => {
58 result.is_deprecated = true;
59 result.deprecated = Some(
60 description
61 .as_ref()
62 .map(|d| d.to_string())
63 .unwrap_or_default(),
64 );
65 }
66 PhpDocTag::Template { name, bound } => {
67 result.templates.push((
68 name.to_string(),
69 bound.map(parse_type_string),
70 Variance::Invariant,
71 ));
72 }
73 PhpDocTag::TemplateCovariant { name, bound } => {
74 result.templates.push((
75 name.to_string(),
76 bound.map(parse_type_string),
77 Variance::Covariant,
78 ));
79 }
80 PhpDocTag::TemplateContravariant { name, bound } => {
81 result.templates.push((
82 name.to_string(),
83 bound.map(parse_type_string),
84 Variance::Contravariant,
85 ));
86 }
87 PhpDocTag::Extends { type_str } => {
88 result.extends = Some(parse_type_string(type_str));
89 }
90 PhpDocTag::Implements { type_str } => {
91 result.implements.push(parse_type_string(type_str));
92 }
93 PhpDocTag::Assert {
94 type_str: Some(ty_s),
95 name: Some(n),
96 } => {
97 result.assertions.push((
98 n.trim_start_matches('$').to_string(),
99 parse_type_string(ty_s),
100 ));
101 }
102 PhpDocTag::Suppress { rules } => {
103 for rule in rules.split([',', ' ']) {
104 let rule = rule.trim().to_string();
105 if !rule.is_empty() {
106 result.suppressed_issues.push(rule);
107 }
108 }
109 }
110 PhpDocTag::See { reference } => result.see.push(reference.to_string()),
111 PhpDocTag::Link { url } => result.see.push(url.to_string()),
112 PhpDocTag::Mixin { class } => result.mixins.push(class.to_string()),
113 PhpDocTag::Property {
114 type_str,
115 name: Some(n),
116 ..
117 } => result.properties.push(DocProperty {
118 type_hint: type_str.unwrap_or("").to_string(),
119 name: n.trim_start_matches('$').to_string(),
120 read_only: false,
121 write_only: false,
122 }),
123 PhpDocTag::PropertyRead {
124 type_str,
125 name: Some(n),
126 ..
127 } => result.properties.push(DocProperty {
128 type_hint: type_str.unwrap_or("").to_string(),
129 name: n.trim_start_matches('$').to_string(),
130 read_only: true,
131 write_only: false,
132 }),
133 PhpDocTag::PropertyWrite {
134 type_str,
135 name: Some(n),
136 ..
137 } => result.properties.push(DocProperty {
138 type_hint: type_str.unwrap_or("").to_string(),
139 name: n.trim_start_matches('$').to_string(),
140 read_only: false,
141 write_only: true,
142 }),
143 PhpDocTag::Method { signature } => {
144 if let Some(m) = parse_method_line(signature) {
145 result.methods.push(m);
146 }
147 }
148 PhpDocTag::TypeAlias {
149 name: Some(n),
150 type_str,
151 } => result.type_aliases.push(DocTypeAlias {
152 name: n.to_string(),
153 type_expr: type_str.unwrap_or("").to_string(),
154 }),
155 PhpDocTag::Internal => result.is_internal = true,
156 PhpDocTag::Pure => result.is_pure = true,
157 PhpDocTag::Immutable => result.is_immutable = true,
158 PhpDocTag::Readonly => result.is_readonly = true,
159 PhpDocTag::Generic { tag, body } => match *tag {
160 "api" | "psalm-api" => result.is_api = true,
161 "psalm-assert" | "phpstan-assert" => {
162 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
163 result.assertions.push((name, parse_type_string(&ty_str)));
164 }
165 }
166 "psalm-assert-if-true" | "phpstan-assert-if-true" => {
167 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
168 result
169 .assertions_if_true
170 .push((name, parse_type_string(&ty_str)));
171 }
172 }
173 "psalm-assert-if-false" | "phpstan-assert-if-false" => {
174 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
175 result
176 .assertions_if_false
177 .push((name, parse_type_string(&ty_str)));
178 }
179 }
180 "psalm-property" => {
181 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
182 result.properties.push(DocProperty {
183 type_hint: ty_str,
184 name,
185 read_only: false,
186 write_only: false,
187 });
188 }
189 }
190 "psalm-property-read" => {
191 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
192 result.properties.push(DocProperty {
193 type_hint: ty_str,
194 name,
195 read_only: true,
196 write_only: false,
197 });
198 }
199 }
200 "psalm-property-write" => {
201 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
202 result.properties.push(DocProperty {
203 type_hint: ty_str,
204 name,
205 read_only: false,
206 write_only: true,
207 });
208 }
209 }
210 "psalm-method" => {
211 if let Some(method) = body.as_deref().and_then(parse_method_line) {
212 result.methods.push(method);
213 }
214 }
215 _ => {}
216 },
217 _ => {}
218 }
219 }
220
221 result
222 }
223}
224
225#[derive(Debug, Default, Clone)]
230pub struct DocProperty {
231 pub type_hint: String,
232 pub name: String, pub read_only: bool, pub write_only: bool, }
236
237#[derive(Debug, Default, Clone)]
238pub struct DocMethod {
239 pub return_type: String,
240 pub name: String,
241 pub is_static: bool,
242 pub params: Vec<DocMethodParam>,
243}
244
245#[derive(Debug, Default, Clone)]
246pub struct DocMethodParam {
247 pub name: String,
248 pub type_hint: String,
249 pub is_variadic: bool,
250 pub is_byref: bool,
251 pub is_optional: bool,
252}
253
254#[derive(Debug, Default, Clone)]
255pub struct DocTypeAlias {
256 pub name: String,
257 pub type_expr: String,
258}
259
260#[derive(Debug, Default, Clone)]
265pub struct ParsedDocblock {
266 pub params: Vec<(String, Union)>,
268 pub return_type: Option<Union>,
270 pub var_type: Option<Union>,
272 pub var_name: Option<String>,
274 pub templates: Vec<(String, Option<Union>, Variance)>,
276 pub extends: Option<Union>,
278 pub implements: Vec<Union>,
280 pub throws: Vec<String>,
282 pub assertions: Vec<(String, Union)>,
284 pub assertions_if_true: Vec<(String, Union)>,
286 pub assertions_if_false: Vec<(String, Union)>,
288 pub suppressed_issues: Vec<String>,
290 pub is_deprecated: bool,
291 pub is_internal: bool,
292 pub is_pure: bool,
293 pub is_immutable: bool,
294 pub is_readonly: bool,
295 pub is_api: bool,
296 pub description: String,
298 pub deprecated: Option<String>,
300 pub see: Vec<String>,
302 pub mixins: Vec<String>,
304 pub properties: Vec<DocProperty>,
306 pub methods: Vec<DocMethod>,
308 pub type_aliases: Vec<DocTypeAlias>,
310}
311
312impl ParsedDocblock {
313 pub fn get_param_type(&self, name: &str) -> Option<&Union> {
315 let name = name.trim_start_matches('$');
316 self.params
317 .iter()
318 .find(|(n, _)| n.trim_start_matches('$') == name)
319 .map(|(_, ty)| ty)
320 }
321}
322
323pub fn parse_type_string(s: &str) -> Union {
331 let s = s.trim();
332
333 if let Some(inner) = s.strip_prefix('?') {
335 let inner_ty = parse_type_string(inner);
336 let mut u = inner_ty;
337 u.add_type(Atomic::TNull);
338 return u;
339 }
340
341 if s.contains('|') && !is_inside_generics(s) {
343 let parts = split_union(s);
344 if parts.len() > 1 {
345 let mut u = Union::empty();
346 for part in parts {
347 for atomic in parse_type_string(&part).types {
348 u.add_type(atomic);
349 }
350 }
351 return u;
352 }
353 }
354
355 if s.contains('&') && !is_inside_generics(s) {
357 let parts: Vec<Union> = s.split('&').map(|p| parse_type_string(p.trim())).collect();
358 return Union::single(Atomic::TIntersection { parts });
359 }
360
361 if let Some(value_str) = s.strip_suffix("[]") {
363 let value = parse_type_string(value_str);
364 return Union::single(Atomic::TArray {
365 key: Box::new(Union::single(Atomic::TInt)),
366 value: Box::new(value),
367 });
368 }
369
370 if let Some(open) = s.find('<') {
372 if s.ends_with('>') {
373 let name = &s[..open];
374 let inner = &s[open + 1..s.len() - 1];
375 return parse_generic(name, inner);
376 }
377 }
378
379 match s.to_lowercase().as_str() {
381 "string" => Union::single(Atomic::TString),
382 "non-empty-string" => Union::single(Atomic::TNonEmptyString),
383 "numeric-string" => Union::single(Atomic::TNumericString),
384 "class-string" => Union::single(Atomic::TClassString(None)),
385 "int" | "integer" => Union::single(Atomic::TInt),
386 "positive-int" => Union::single(Atomic::TPositiveInt),
387 "negative-int" => Union::single(Atomic::TNegativeInt),
388 "non-negative-int" => Union::single(Atomic::TNonNegativeInt),
389 "float" | "double" => Union::single(Atomic::TFloat),
390 "bool" | "boolean" => Union::single(Atomic::TBool),
391 "true" => Union::single(Atomic::TTrue),
392 "false" => Union::single(Atomic::TFalse),
393 "null" => Union::single(Atomic::TNull),
394 "void" => Union::single(Atomic::TVoid),
395 "never" | "never-return" | "no-return" | "never-returns" => Union::single(Atomic::TNever),
396 "mixed" => Union::single(Atomic::TMixed),
397 "object" => Union::single(Atomic::TObject),
398 "array" => Union::single(Atomic::TArray {
399 key: Box::new(Union::single(Atomic::TMixed)),
400 value: Box::new(Union::mixed()),
401 }),
402 "list" => Union::single(Atomic::TList {
403 value: Box::new(Union::mixed()),
404 }),
405 "callable" => Union::single(Atomic::TCallable {
406 params: None,
407 return_type: None,
408 }),
409 "iterable" => Union::single(Atomic::TArray {
410 key: Box::new(Union::single(Atomic::TMixed)),
411 value: Box::new(Union::mixed()),
412 }),
413 "scalar" => Union::single(Atomic::TScalar),
414 "numeric" => Union::single(Atomic::TNumeric),
415 "resource" => Union::mixed(), "static" => Union::single(Atomic::TStaticObject {
418 fqcn: Arc::from(""),
419 }),
420 "self" | "$this" => Union::single(Atomic::TSelf {
421 fqcn: Arc::from(""),
422 }),
423 "parent" => Union::single(Atomic::TParent {
424 fqcn: Arc::from(""),
425 }),
426
427 _ if !s.is_empty()
429 && s.chars()
430 .next()
431 .map(|c| c.is_alphanumeric() || c == '\\' || c == '_')
432 .unwrap_or(false) =>
433 {
434 Union::single(Atomic::TNamedObject {
435 fqcn: normalize_fqcn(s).into(),
436 type_params: vec![],
437 })
438 }
439
440 _ => Union::mixed(),
441 }
442}
443
444fn parse_generic(name: &str, inner: &str) -> Union {
445 match name.to_lowercase().as_str() {
446 "array" => {
447 let params = split_generics(inner);
448 let (key, value) = if params.len() >= 2 {
449 (
450 parse_type_string(params[0].trim()),
451 parse_type_string(params[1].trim()),
452 )
453 } else {
454 (
455 Union::single(Atomic::TInt),
456 parse_type_string(params[0].trim()),
457 )
458 };
459 Union::single(Atomic::TArray {
460 key: Box::new(key),
461 value: Box::new(value),
462 })
463 }
464 "list" | "non-empty-list" => {
465 let value = parse_type_string(inner.trim());
466 if name.to_lowercase().starts_with("non-empty") {
467 Union::single(Atomic::TNonEmptyList {
468 value: Box::new(value),
469 })
470 } else {
471 Union::single(Atomic::TList {
472 value: Box::new(value),
473 })
474 }
475 }
476 "non-empty-array" => {
477 let params = split_generics(inner);
478 let (key, value) = if params.len() >= 2 {
479 (
480 parse_type_string(params[0].trim()),
481 parse_type_string(params[1].trim()),
482 )
483 } else {
484 (
485 Union::single(Atomic::TInt),
486 parse_type_string(params[0].trim()),
487 )
488 };
489 Union::single(Atomic::TNonEmptyArray {
490 key: Box::new(key),
491 value: Box::new(value),
492 })
493 }
494 "iterable" => {
495 let params = split_generics(inner);
496 let value = if params.len() >= 2 {
497 parse_type_string(params[1].trim())
498 } else {
499 parse_type_string(params[0].trim())
500 };
501 Union::single(Atomic::TArray {
502 key: Box::new(Union::single(Atomic::TMixed)),
503 value: Box::new(value),
504 })
505 }
506 "class-string" => Union::single(Atomic::TClassString(Some(
507 normalize_fqcn(inner.trim()).into(),
508 ))),
509 "int" => {
510 Union::single(Atomic::TIntRange {
512 min: None,
513 max: None,
514 })
515 }
516 _ => {
518 let params: Vec<Union> = split_generics(inner)
519 .iter()
520 .map(|p| parse_type_string(p.trim()))
521 .collect();
522 Union::single(Atomic::TNamedObject {
523 fqcn: normalize_fqcn(name).into(),
524 type_params: params,
525 })
526 }
527 }
528}
529
530fn extract_description(text: &str) -> String {
536 let mut desc_lines: Vec<&str> = Vec::new();
537 for line in text.lines() {
538 let l = line.trim();
539 let l = l.trim_start_matches("/**").trim();
540 let l = l.trim_end_matches("*/").trim();
541 let l = l.trim_start_matches("*/").trim();
542 let l = l.strip_prefix("* ").unwrap_or(l.trim_start_matches('*'));
543 let l = l.trim();
544 if l.starts_with('@') {
545 break;
546 }
547 if !l.is_empty() {
548 desc_lines.push(l);
549 }
550 }
551 desc_lines.join(" ")
552}
553
554fn parse_param_line(s: &str) -> Option<(String, String)> {
555 let mut parts = s.splitn(3, char::is_whitespace);
557 let ty = parts.next()?.trim().to_string();
558 let name = parts.next()?.trim().trim_start_matches('$').to_string();
559 if ty.is_empty() || name.is_empty() {
560 return None;
561 }
562 Some((ty, name))
563}
564
565fn split_union(s: &str) -> Vec<String> {
566 let mut parts = Vec::new();
567 let mut depth = 0;
568 let mut current = String::new();
569 for ch in s.chars() {
570 match ch {
571 '<' | '(' | '{' => {
572 depth += 1;
573 current.push(ch);
574 }
575 '>' | ')' | '}' => {
576 depth -= 1;
577 current.push(ch);
578 }
579 '|' if depth == 0 => {
580 parts.push(current.trim().to_string());
581 current = String::new();
582 }
583 _ => current.push(ch),
584 }
585 }
586 if !current.trim().is_empty() {
587 parts.push(current.trim().to_string());
588 }
589 parts
590}
591
592fn split_generics(s: &str) -> Vec<String> {
593 let mut parts = Vec::new();
594 let mut depth = 0;
595 let mut current = String::new();
596 for ch in s.chars() {
597 match ch {
598 '<' | '(' | '{' => {
599 depth += 1;
600 current.push(ch);
601 }
602 '>' | ')' | '}' => {
603 depth -= 1;
604 current.push(ch);
605 }
606 ',' if depth == 0 => {
607 parts.push(current.trim().to_string());
608 current = String::new();
609 }
610 _ => current.push(ch),
611 }
612 }
613 if !current.trim().is_empty() {
614 parts.push(current.trim().to_string());
615 }
616 parts
617}
618
619fn is_inside_generics(s: &str) -> bool {
620 let mut depth = 0i32;
621 for ch in s.chars() {
622 match ch {
623 '<' | '(' | '{' => depth += 1,
624 '>' | ')' | '}' => depth -= 1,
625 _ => {}
626 }
627 }
628 depth != 0
629}
630
631fn normalize_fqcn(s: &str) -> String {
632 s.trim_start_matches('\\').to_string()
634}
635
636fn parse_method_line(s: &str) -> Option<DocMethod> {
638 let mut rest = s.trim();
639 if rest.is_empty() {
640 return None;
641 }
642 let is_static = rest
643 .split_whitespace()
644 .next()
645 .map(|w| w.eq_ignore_ascii_case("static"))
646 .unwrap_or(false);
647 if is_static {
648 rest = rest["static".len()..].trim_start();
649 }
650
651 let open = rest.find('(').unwrap_or(rest.len());
652 let prefix = rest[..open].trim();
653 let mut parts: Vec<&str> = prefix.split_whitespace().collect();
654 let name = parts.pop()?.to_string();
655 if name.is_empty() {
656 return None;
657 }
658 let return_type = parts.join(" ");
659 Some(DocMethod {
660 return_type,
661 name,
662 is_static,
663 params: parse_method_params(rest),
664 })
665}
666
667fn parse_method_params(name_part: &str) -> Vec<DocMethodParam> {
668 let Some(open) = name_part.find('(') else {
669 return vec![];
670 };
671 let Some(close) = name_part.rfind(')') else {
672 return vec![];
673 };
674 let inner = name_part[open + 1..close].trim();
675 if inner.is_empty() {
676 return vec![];
677 }
678
679 split_generics(inner)
680 .into_iter()
681 .filter_map(|param| parse_method_param(¶m))
682 .collect()
683}
684
685fn parse_method_param(param: &str) -> Option<DocMethodParam> {
686 let before_default = param.split('=').next()?.trim();
687 let is_optional = param.contains('=');
688 let mut tokens: Vec<&str> = before_default.split_whitespace().collect();
689 let raw_name = tokens.pop()?;
690 let is_variadic = raw_name.contains("...");
691 let is_byref = raw_name.contains('&');
692 let name = raw_name
693 .trim_start_matches('&')
694 .trim_start_matches("...")
695 .trim_start_matches('&')
696 .trim_start_matches('$')
697 .to_string();
698 if name.is_empty() {
699 return None;
700 }
701 Some(DocMethodParam {
702 name,
703 type_hint: tokens.join(" "),
704 is_variadic,
705 is_byref,
706 is_optional: is_optional || is_variadic,
707 })
708}
709
710#[cfg(test)]
715mod tests {
716 use super::*;
717 use mir_types::Atomic;
718
719 #[test]
720 fn parse_string() {
721 let u = parse_type_string("string");
722 assert_eq!(u.types.len(), 1);
723 assert!(matches!(u.types[0], Atomic::TString));
724 }
725
726 #[test]
727 fn parse_nullable_string() {
728 let u = parse_type_string("?string");
729 assert!(u.is_nullable());
730 assert!(u.contains(|t| matches!(t, Atomic::TString)));
731 }
732
733 #[test]
734 fn parse_union() {
735 let u = parse_type_string("string|int|null");
736 assert!(u.contains(|t| matches!(t, Atomic::TString)));
737 assert!(u.contains(|t| matches!(t, Atomic::TInt)));
738 assert!(u.is_nullable());
739 }
740
741 #[test]
742 fn parse_array_of_string() {
743 let u = parse_type_string("array<string>");
744 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
745 }
746
747 #[test]
748 fn parse_list_of_int() {
749 let u = parse_type_string("list<int>");
750 assert!(u.contains(|t| matches!(t, Atomic::TList { .. })));
751 }
752
753 #[test]
754 fn parse_named_class() {
755 let u = parse_type_string("Foo\\Bar");
756 assert!(u.contains(
757 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Foo\\Bar")
758 ));
759 }
760
761 #[test]
762 fn parse_docblock_param_return() {
763 let doc = r#"/**
764 * @param string $name
765 * @param int $age
766 * @return bool
767 */"#;
768 let parsed = DocblockParser::parse(doc);
769 assert_eq!(parsed.params.len(), 2);
770 assert!(parsed.return_type.is_some());
771 let ret = parsed.return_type.unwrap();
772 assert!(ret.contains(|t| matches!(t, Atomic::TBool)));
773 }
774
775 #[test]
776 fn parse_template() {
777 let doc = "/** @template T of object */";
778 let parsed = DocblockParser::parse(doc);
779 assert_eq!(parsed.templates.len(), 1);
780 assert_eq!(parsed.templates[0].0, "T");
781 assert!(parsed.templates[0].1.is_some());
782 assert_eq!(parsed.templates[0].2, Variance::Invariant);
783 }
784
785 #[test]
786 fn parse_template_covariant() {
787 let doc = "/** @template-covariant T */";
788 let parsed = DocblockParser::parse(doc);
789 assert_eq!(parsed.templates.len(), 1);
790 assert_eq!(parsed.templates[0].0, "T");
791 assert_eq!(parsed.templates[0].2, Variance::Covariant);
792 }
793
794 #[test]
795 fn parse_template_contravariant() {
796 let doc = "/** @template-contravariant T */";
797 let parsed = DocblockParser::parse(doc);
798 assert_eq!(parsed.templates.len(), 1);
799 assert_eq!(parsed.templates[0].0, "T");
800 assert_eq!(parsed.templates[0].2, Variance::Contravariant);
801 }
802
803 #[test]
804 fn parse_deprecated() {
805 let doc = "/** @deprecated use newMethod() instead */";
806 let parsed = DocblockParser::parse(doc);
807 assert!(parsed.is_deprecated);
808 assert_eq!(
809 parsed.deprecated.as_deref(),
810 Some("use newMethod() instead")
811 );
812 }
813
814 #[test]
815 fn parse_description() {
816 let doc = r#"/**
817 * This is a description.
818 * Spans two lines.
819 * @param string $x
820 */"#;
821 let parsed = DocblockParser::parse(doc);
822 assert!(parsed.description.contains("This is a description"));
823 assert!(parsed.description.contains("Spans two lines"));
824 }
825
826 #[test]
827 fn parse_see_and_link() {
828 let doc = "/** @see SomeClass\n * @link https://example.com */";
829 let parsed = DocblockParser::parse(doc);
830 assert_eq!(parsed.see.len(), 2);
831 assert!(parsed.see.contains(&"SomeClass".to_string()));
832 assert!(parsed.see.contains(&"https://example.com".to_string()));
833 }
834
835 #[test]
836 fn parse_mixin() {
837 let doc = "/** @mixin SomeTrait */";
838 let parsed = DocblockParser::parse(doc);
839 assert_eq!(parsed.mixins, vec!["SomeTrait".to_string()]);
840 }
841
842 #[test]
843 fn parse_property_tags() {
844 let doc = r#"/**
845 * @property string $name
846 * @property-read int $id
847 * @property-write bool $active
848 */"#;
849 let parsed = DocblockParser::parse(doc);
850 assert_eq!(parsed.properties.len(), 3);
851 let name_prop = parsed.properties.iter().find(|p| p.name == "name").unwrap();
852 assert_eq!(name_prop.type_hint, "string");
853 assert!(!name_prop.read_only);
854 assert!(!name_prop.write_only);
855 let id_prop = parsed.properties.iter().find(|p| p.name == "id").unwrap();
856 assert!(id_prop.read_only);
857 let active_prop = parsed
858 .properties
859 .iter()
860 .find(|p| p.name == "active")
861 .unwrap();
862 assert!(active_prop.write_only);
863 }
864
865 #[test]
866 fn parse_method_tag() {
867 let doc = r#"/**
868 * @method string getName()
869 * @method static int create()
870 */"#;
871 let parsed = DocblockParser::parse(doc);
872 assert_eq!(parsed.methods.len(), 2);
873 let get_name = parsed.methods.iter().find(|m| m.name == "getName").unwrap();
874 assert_eq!(get_name.return_type, "string");
875 assert!(!get_name.is_static);
876 let create = parsed.methods.iter().find(|m| m.name == "create").unwrap();
877 assert!(create.is_static);
878 }
879
880 #[test]
881 fn parse_type_alias_tag() {
882 let doc = "/** @psalm-type MyAlias = string|int */";
883 let parsed = DocblockParser::parse(doc);
884 assert_eq!(parsed.type_aliases.len(), 1);
885 assert_eq!(parsed.type_aliases[0].name, "MyAlias");
886 assert_eq!(parsed.type_aliases[0].type_expr, "string|int");
887 }
888
889 #[test]
890 fn parse_intersection_two_parts() {
891 let u = parse_type_string("Iterator&Countable");
892 assert_eq!(u.types.len(), 1);
893 assert!(matches!(u.types[0], Atomic::TIntersection { ref parts } if parts.len() == 2));
894 if let Atomic::TIntersection { parts } = &u.types[0] {
895 assert!(parts[0].contains(
896 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
897 ));
898 assert!(parts[1].contains(
899 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
900 ));
901 }
902 }
903
904 #[test]
905 fn parse_intersection_three_parts() {
906 let u = parse_type_string("Iterator&Countable&Stringable");
907 assert_eq!(u.types.len(), 1);
908 let Atomic::TIntersection { parts } = &u.types[0] else {
909 panic!("expected TIntersection");
910 };
911 assert_eq!(parts.len(), 3);
912 assert!(parts[0].contains(
913 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
914 ));
915 assert!(parts[1].contains(
916 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
917 ));
918 assert!(parts[2].contains(
919 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Stringable")
920 ));
921 }
922
923 #[test]
924 fn parse_intersection_in_union_with_null() {
925 let u = parse_type_string("Iterator&Countable|null");
926 assert!(u.is_nullable());
927 let intersection = u
928 .types
929 .iter()
930 .find_map(|t| {
931 if let Atomic::TIntersection { parts } = t {
932 Some(parts)
933 } else {
934 None
935 }
936 })
937 .expect("expected TIntersection");
938 assert_eq!(intersection.len(), 2);
939 assert!(intersection[0].contains(
940 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
941 ));
942 assert!(intersection[1].contains(
943 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
944 ));
945 }
946
947 #[test]
948 fn parse_intersection_in_union_with_scalar() {
949 let u = parse_type_string("Iterator&Countable|string");
950 assert!(u.contains(|t| matches!(t, Atomic::TString)));
951 let intersection = u
952 .types
953 .iter()
954 .find_map(|t| {
955 if let Atomic::TIntersection { parts } = t {
956 Some(parts)
957 } else {
958 None
959 }
960 })
961 .expect("expected TIntersection");
962 assert!(intersection[0].contains(
963 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
964 ));
965 assert!(intersection[1].contains(
966 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
967 ));
968 }
969}