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::ImportType { body } => {
156 if let Some(import) = parse_import_type(body) {
157 result.import_types.push(import);
158 }
159 }
160 PhpDocTag::Internal => result.is_internal = true,
161 PhpDocTag::Pure => result.is_pure = true,
162 PhpDocTag::Immutable => result.is_immutable = true,
163 PhpDocTag::Readonly => result.is_readonly = true,
164 PhpDocTag::Generic { tag, body } => match *tag {
165 "api" | "psalm-api" => result.is_api = true,
166 "psalm-assert" | "phpstan-assert" => {
167 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
168 result.assertions.push((name, parse_type_string(&ty_str)));
169 }
170 }
171 "psalm-assert-if-true" | "phpstan-assert-if-true" => {
172 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
173 result
174 .assertions_if_true
175 .push((name, parse_type_string(&ty_str)));
176 }
177 }
178 "psalm-assert-if-false" | "phpstan-assert-if-false" => {
179 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
180 result
181 .assertions_if_false
182 .push((name, parse_type_string(&ty_str)));
183 }
184 }
185 "psalm-property" => {
186 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
187 result.properties.push(DocProperty {
188 type_hint: ty_str,
189 name,
190 read_only: false,
191 write_only: false,
192 });
193 }
194 }
195 "psalm-property-read" => {
196 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
197 result.properties.push(DocProperty {
198 type_hint: ty_str,
199 name,
200 read_only: true,
201 write_only: false,
202 });
203 }
204 }
205 "psalm-property-write" => {
206 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
207 result.properties.push(DocProperty {
208 type_hint: ty_str,
209 name,
210 read_only: false,
211 write_only: true,
212 });
213 }
214 }
215 "psalm-method" => {
216 if let Some(method) = body.as_deref().and_then(parse_method_line) {
217 result.methods.push(method);
218 }
219 }
220 "psalm-require-extends" | "phpstan-require-extends" => {
221 if let Some(b) = body {
222 let cls = b.split_whitespace().next().unwrap_or("").trim().to_string();
223 if !cls.is_empty() {
224 result.require_extends.push(cls);
225 }
226 }
227 }
228 "psalm-require-implements" | "phpstan-require-implements" => {
229 if let Some(b) = body {
230 let cls = b.split_whitespace().next().unwrap_or("").trim().to_string();
231 if !cls.is_empty() {
232 result.require_implements.push(cls);
233 }
234 }
235 }
236 _ => {}
237 },
238 _ => {}
239 }
240 }
241
242 result
243 }
244}
245
246#[derive(Debug, Default, Clone)]
251pub struct DocProperty {
252 pub type_hint: String,
253 pub name: String, pub read_only: bool, pub write_only: bool, }
257
258#[derive(Debug, Default, Clone)]
259pub struct DocMethod {
260 pub return_type: String,
261 pub name: String,
262 pub is_static: bool,
263 pub params: Vec<DocMethodParam>,
264}
265
266#[derive(Debug, Default, Clone)]
267pub struct DocMethodParam {
268 pub name: String,
269 pub type_hint: String,
270 pub is_variadic: bool,
271 pub is_byref: bool,
272 pub is_optional: bool,
273}
274
275#[derive(Debug, Default, Clone)]
276pub struct DocTypeAlias {
277 pub name: String,
278 pub type_expr: String,
279}
280
281#[derive(Debug, Default, Clone)]
282pub struct DocImportType {
283 pub original: String,
285 pub local: String,
287 pub from_class: String,
289}
290
291#[derive(Debug, Default, Clone)]
296pub struct ParsedDocblock {
297 pub params: Vec<(String, Union)>,
299 pub return_type: Option<Union>,
301 pub var_type: Option<Union>,
303 pub var_name: Option<String>,
305 pub templates: Vec<(String, Option<Union>, Variance)>,
307 pub extends: Option<Union>,
309 pub implements: Vec<Union>,
311 pub throws: Vec<String>,
313 pub assertions: Vec<(String, Union)>,
315 pub assertions_if_true: Vec<(String, Union)>,
317 pub assertions_if_false: Vec<(String, Union)>,
319 pub suppressed_issues: Vec<String>,
321 pub is_deprecated: bool,
322 pub is_internal: bool,
323 pub is_pure: bool,
324 pub is_immutable: bool,
325 pub is_readonly: bool,
326 pub is_api: bool,
327 pub description: String,
329 pub deprecated: Option<String>,
331 pub see: Vec<String>,
333 pub mixins: Vec<String>,
335 pub properties: Vec<DocProperty>,
337 pub methods: Vec<DocMethod>,
339 pub type_aliases: Vec<DocTypeAlias>,
341 pub import_types: Vec<DocImportType>,
343 pub require_extends: Vec<String>,
345 pub require_implements: Vec<String>,
347}
348
349impl ParsedDocblock {
350 pub fn get_param_type(&self, name: &str) -> Option<&Union> {
356 let name = name.trim_start_matches('$');
357 self.params
358 .iter()
359 .rfind(|(n, _)| n.trim_start_matches('$') == name)
360 .map(|(_, ty)| ty)
361 }
362}
363
364pub fn parse_type_string(s: &str) -> Union {
372 let s = s.trim();
373
374 if let Some(inner) = s.strip_prefix('?') {
376 let inner_ty = parse_type_string(inner);
377 let mut u = inner_ty;
378 u.add_type(Atomic::TNull);
379 return u;
380 }
381
382 if s.contains('|') && !is_inside_generics(s) {
384 let parts = split_union(s);
385 if parts.len() > 1 {
386 let mut u = Union::empty();
387 for part in parts {
388 for atomic in parse_type_string(&part).types {
389 u.add_type(atomic);
390 }
391 }
392 return u;
393 }
394 }
395
396 if s.contains('&') && !is_inside_generics(s) {
398 let parts: Vec<Union> = s.split('&').map(|p| parse_type_string(p.trim())).collect();
399 return Union::single(Atomic::TIntersection { parts });
400 }
401
402 if let Some(value_str) = s.strip_suffix("[]") {
404 let value = parse_type_string(value_str);
405 return Union::single(Atomic::TArray {
406 key: Box::new(Union::single(Atomic::TInt)),
407 value: Box::new(value),
408 });
409 }
410
411 if let Some(open) = s.find('<') {
413 if s.ends_with('>') {
414 let name = &s[..open];
415 let inner = &s[open + 1..s.len() - 1];
416 return parse_generic(name, inner);
417 }
418 }
419
420 match s.to_lowercase().as_str() {
422 "string" => Union::single(Atomic::TString),
423 "non-empty-string" => Union::single(Atomic::TNonEmptyString),
424 "numeric-string" => Union::single(Atomic::TNumericString),
425 "class-string" => Union::single(Atomic::TClassString(None)),
426 "int" | "integer" => Union::single(Atomic::TInt),
427 "positive-int" => Union::single(Atomic::TPositiveInt),
428 "negative-int" => Union::single(Atomic::TNegativeInt),
429 "non-negative-int" => Union::single(Atomic::TNonNegativeInt),
430 "float" | "double" => Union::single(Atomic::TFloat),
431 "bool" | "boolean" => Union::single(Atomic::TBool),
432 "true" => Union::single(Atomic::TTrue),
433 "false" => Union::single(Atomic::TFalse),
434 "null" => Union::single(Atomic::TNull),
435 "void" => Union::single(Atomic::TVoid),
436 "never" | "never-return" | "no-return" | "never-returns" => Union::single(Atomic::TNever),
437 "mixed" => Union::single(Atomic::TMixed),
438 "object" => Union::single(Atomic::TObject),
439 "array" => Union::single(Atomic::TArray {
440 key: Box::new(Union::single(Atomic::TMixed)),
441 value: Box::new(Union::mixed()),
442 }),
443 "list" => Union::single(Atomic::TList {
444 value: Box::new(Union::mixed()),
445 }),
446 "callable" => Union::single(Atomic::TCallable {
447 params: None,
448 return_type: None,
449 }),
450 "iterable" => Union::single(Atomic::TArray {
451 key: Box::new(Union::single(Atomic::TMixed)),
452 value: Box::new(Union::mixed()),
453 }),
454 "scalar" => Union::single(Atomic::TScalar),
455 "numeric" => Union::single(Atomic::TNumeric),
456 "resource" => Union::mixed(), "static" => Union::single(Atomic::TStaticObject {
459 fqcn: Arc::from(""),
460 }),
461 "self" | "$this" => Union::single(Atomic::TSelf {
462 fqcn: Arc::from(""),
463 }),
464 "parent" => Union::single(Atomic::TParent {
465 fqcn: Arc::from(""),
466 }),
467
468 _ if !s.is_empty()
470 && s.chars()
471 .next()
472 .map(|c| c.is_alphanumeric() || c == '\\' || c == '_')
473 .unwrap_or(false) =>
474 {
475 Union::single(Atomic::TNamedObject {
476 fqcn: normalize_fqcn(s).into(),
477 type_params: vec![],
478 })
479 }
480
481 _ => Union::mixed(),
482 }
483}
484
485fn parse_generic(name: &str, inner: &str) -> Union {
486 match name.to_lowercase().as_str() {
487 "array" => {
488 let params = split_generics(inner);
489 let (key, value) = if params.len() >= 2 {
490 (
491 parse_type_string(params[0].trim()),
492 parse_type_string(params[1].trim()),
493 )
494 } else {
495 (
496 Union::single(Atomic::TInt),
497 parse_type_string(params[0].trim()),
498 )
499 };
500 Union::single(Atomic::TArray {
501 key: Box::new(key),
502 value: Box::new(value),
503 })
504 }
505 "list" | "non-empty-list" => {
506 let value = parse_type_string(inner.trim());
507 if name.to_lowercase().starts_with("non-empty") {
508 Union::single(Atomic::TNonEmptyList {
509 value: Box::new(value),
510 })
511 } else {
512 Union::single(Atomic::TList {
513 value: Box::new(value),
514 })
515 }
516 }
517 "non-empty-array" => {
518 let params = split_generics(inner);
519 let (key, value) = if params.len() >= 2 {
520 (
521 parse_type_string(params[0].trim()),
522 parse_type_string(params[1].trim()),
523 )
524 } else {
525 (
526 Union::single(Atomic::TInt),
527 parse_type_string(params[0].trim()),
528 )
529 };
530 Union::single(Atomic::TNonEmptyArray {
531 key: Box::new(key),
532 value: Box::new(value),
533 })
534 }
535 "iterable" => {
536 let params = split_generics(inner);
537 let value = if params.len() >= 2 {
538 parse_type_string(params[1].trim())
539 } else {
540 parse_type_string(params[0].trim())
541 };
542 Union::single(Atomic::TArray {
543 key: Box::new(Union::single(Atomic::TMixed)),
544 value: Box::new(value),
545 })
546 }
547 "class-string" => Union::single(Atomic::TClassString(Some(
548 normalize_fqcn(inner.trim()).into(),
549 ))),
550 "int" => {
551 Union::single(Atomic::TIntRange {
553 min: None,
554 max: None,
555 })
556 }
557 _ => {
559 let params: Vec<Union> = split_generics(inner)
560 .iter()
561 .map(|p| parse_type_string(p.trim()))
562 .collect();
563 Union::single(Atomic::TNamedObject {
564 fqcn: normalize_fqcn(name).into(),
565 type_params: params,
566 })
567 }
568 }
569}
570
571fn extract_description(text: &str) -> String {
577 let mut desc_lines: Vec<&str> = Vec::new();
578 for line in text.lines() {
579 let l = line.trim();
580 let l = l.trim_start_matches("/**").trim();
581 let l = l.trim_end_matches("*/").trim();
582 let l = l.trim_start_matches("*/").trim();
583 let l = l.strip_prefix("* ").unwrap_or(l.trim_start_matches('*'));
584 let l = l.trim();
585 if l.starts_with('@') {
586 break;
587 }
588 if !l.is_empty() {
589 desc_lines.push(l);
590 }
591 }
592 desc_lines.join(" ")
593}
594
595fn parse_import_type(body: &str) -> Option<DocImportType> {
601 let (before_from, from_class_raw) = body.split_once(" from ")?;
603 let from_class = from_class_raw.trim().trim_start_matches('\\').to_string();
604 if from_class.is_empty() {
605 return None;
606 }
607 let (original, local) = if let Some((orig, loc)) = before_from.split_once(" as ") {
609 (orig.trim().to_string(), loc.trim().to_string())
610 } else {
611 let name = before_from.trim().to_string();
612 (name.clone(), name)
613 };
614 if original.is_empty() || local.is_empty() {
615 return None;
616 }
617 Some(DocImportType {
618 original,
619 local,
620 from_class,
621 })
622}
623
624fn parse_param_line(s: &str) -> Option<(String, String)> {
625 let mut parts = s.splitn(3, char::is_whitespace);
627 let ty = parts.next()?.trim().to_string();
628 let name = parts.next()?.trim().trim_start_matches('$').to_string();
629 if ty.is_empty() || name.is_empty() {
630 return None;
631 }
632 Some((ty, name))
633}
634
635fn split_union(s: &str) -> Vec<String> {
636 let mut parts = Vec::new();
637 let mut depth = 0;
638 let mut current = String::new();
639 for ch in s.chars() {
640 match ch {
641 '<' | '(' | '{' => {
642 depth += 1;
643 current.push(ch);
644 }
645 '>' | ')' | '}' => {
646 depth -= 1;
647 current.push(ch);
648 }
649 '|' if depth == 0 => {
650 parts.push(current.trim().to_string());
651 current = String::new();
652 }
653 _ => current.push(ch),
654 }
655 }
656 if !current.trim().is_empty() {
657 parts.push(current.trim().to_string());
658 }
659 parts
660}
661
662fn split_generics(s: &str) -> Vec<String> {
663 let mut parts = Vec::new();
664 let mut depth = 0;
665 let mut current = String::new();
666 for ch in s.chars() {
667 match ch {
668 '<' | '(' | '{' => {
669 depth += 1;
670 current.push(ch);
671 }
672 '>' | ')' | '}' => {
673 depth -= 1;
674 current.push(ch);
675 }
676 ',' if depth == 0 => {
677 parts.push(current.trim().to_string());
678 current = String::new();
679 }
680 _ => current.push(ch),
681 }
682 }
683 if !current.trim().is_empty() {
684 parts.push(current.trim().to_string());
685 }
686 parts
687}
688
689fn is_inside_generics(s: &str) -> bool {
690 let mut depth = 0i32;
691 for ch in s.chars() {
692 match ch {
693 '<' | '(' | '{' => depth += 1,
694 '>' | ')' | '}' => depth -= 1,
695 _ => {}
696 }
697 }
698 depth != 0
699}
700
701fn normalize_fqcn(s: &str) -> String {
702 s.trim_start_matches('\\').to_string()
704}
705
706fn parse_method_line(s: &str) -> Option<DocMethod> {
708 let mut rest = s.trim();
709 if rest.is_empty() {
710 return None;
711 }
712 let is_static = rest
713 .split_whitespace()
714 .next()
715 .map(|w| w.eq_ignore_ascii_case("static"))
716 .unwrap_or(false);
717 if is_static {
718 rest = rest["static".len()..].trim_start();
719 }
720
721 let open = rest.find('(').unwrap_or(rest.len());
722 let prefix = rest[..open].trim();
723 let mut parts: Vec<&str> = prefix.split_whitespace().collect();
724 let name = parts.pop()?.to_string();
725 if name.is_empty() {
726 return None;
727 }
728 let return_type = parts.join(" ");
729 Some(DocMethod {
730 return_type,
731 name,
732 is_static,
733 params: parse_method_params(rest),
734 })
735}
736
737fn parse_method_params(name_part: &str) -> Vec<DocMethodParam> {
738 let Some(open) = name_part.find('(') else {
739 return vec![];
740 };
741 let Some(close) = name_part.rfind(')') else {
742 return vec![];
743 };
744 let inner = name_part[open + 1..close].trim();
745 if inner.is_empty() {
746 return vec![];
747 }
748
749 split_generics(inner)
750 .into_iter()
751 .filter_map(|param| parse_method_param(¶m))
752 .collect()
753}
754
755fn parse_method_param(param: &str) -> Option<DocMethodParam> {
756 let before_default = param.split('=').next()?.trim();
757 let is_optional = param.contains('=');
758 let mut tokens: Vec<&str> = before_default.split_whitespace().collect();
759 let raw_name = tokens.pop()?;
760 let is_variadic = raw_name.contains("...");
761 let is_byref = raw_name.contains('&');
762 let name = raw_name
763 .trim_start_matches('&')
764 .trim_start_matches("...")
765 .trim_start_matches('&')
766 .trim_start_matches('$')
767 .to_string();
768 if name.is_empty() {
769 return None;
770 }
771 Some(DocMethodParam {
772 name,
773 type_hint: tokens.join(" "),
774 is_variadic,
775 is_byref,
776 is_optional: is_optional || is_variadic,
777 })
778}
779
780#[cfg(test)]
785mod tests {
786 use super::*;
787 use mir_types::Atomic;
788
789 #[test]
790 fn parse_string() {
791 let u = parse_type_string("string");
792 assert_eq!(u.types.len(), 1);
793 assert!(matches!(u.types[0], Atomic::TString));
794 }
795
796 #[test]
797 fn parse_nullable_string() {
798 let u = parse_type_string("?string");
799 assert!(u.is_nullable());
800 assert!(u.contains(|t| matches!(t, Atomic::TString)));
801 }
802
803 #[test]
804 fn parse_union() {
805 let u = parse_type_string("string|int|null");
806 assert!(u.contains(|t| matches!(t, Atomic::TString)));
807 assert!(u.contains(|t| matches!(t, Atomic::TInt)));
808 assert!(u.is_nullable());
809 }
810
811 #[test]
812 fn parse_array_of_string() {
813 let u = parse_type_string("array<string>");
814 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
815 }
816
817 #[test]
818 fn parse_list_of_int() {
819 let u = parse_type_string("list<int>");
820 assert!(u.contains(|t| matches!(t, Atomic::TList { .. })));
821 }
822
823 #[test]
824 fn parse_named_class() {
825 let u = parse_type_string("Foo\\Bar");
826 assert!(u.contains(
827 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Foo\\Bar")
828 ));
829 }
830
831 #[test]
832 fn parse_docblock_param_return() {
833 let doc = r#"/**
834 * @param string $name
835 * @param int $age
836 * @return bool
837 */"#;
838 let parsed = DocblockParser::parse(doc);
839 assert_eq!(parsed.params.len(), 2);
840 assert!(parsed.return_type.is_some());
841 let ret = parsed.return_type.unwrap();
842 assert!(ret.contains(|t| matches!(t, Atomic::TBool)));
843 }
844
845 #[test]
846 fn parse_template() {
847 let doc = "/** @template T of object */";
848 let parsed = DocblockParser::parse(doc);
849 assert_eq!(parsed.templates.len(), 1);
850 assert_eq!(parsed.templates[0].0, "T");
851 assert!(parsed.templates[0].1.is_some());
852 assert_eq!(parsed.templates[0].2, Variance::Invariant);
853 }
854
855 #[test]
856 fn parse_template_covariant() {
857 let doc = "/** @template-covariant T */";
858 let parsed = DocblockParser::parse(doc);
859 assert_eq!(parsed.templates.len(), 1);
860 assert_eq!(parsed.templates[0].0, "T");
861 assert_eq!(parsed.templates[0].2, Variance::Covariant);
862 }
863
864 #[test]
865 fn parse_template_contravariant() {
866 let doc = "/** @template-contravariant T */";
867 let parsed = DocblockParser::parse(doc);
868 assert_eq!(parsed.templates.len(), 1);
869 assert_eq!(parsed.templates[0].0, "T");
870 assert_eq!(parsed.templates[0].2, Variance::Contravariant);
871 }
872
873 #[test]
874 fn parse_deprecated() {
875 let doc = "/** @deprecated use newMethod() instead */";
876 let parsed = DocblockParser::parse(doc);
877 assert!(parsed.is_deprecated);
878 assert_eq!(
879 parsed.deprecated.as_deref(),
880 Some("use newMethod() instead")
881 );
882 }
883
884 #[test]
885 fn parse_description() {
886 let doc = r#"/**
887 * This is a description.
888 * Spans two lines.
889 * @param string $x
890 */"#;
891 let parsed = DocblockParser::parse(doc);
892 assert!(parsed.description.contains("This is a description"));
893 assert!(parsed.description.contains("Spans two lines"));
894 }
895
896 #[test]
897 fn parse_see_and_link() {
898 let doc = "/** @see SomeClass\n * @link https://example.com */";
899 let parsed = DocblockParser::parse(doc);
900 assert_eq!(parsed.see.len(), 2);
901 assert!(parsed.see.contains(&"SomeClass".to_string()));
902 assert!(parsed.see.contains(&"https://example.com".to_string()));
903 }
904
905 #[test]
906 fn parse_mixin() {
907 let doc = "/** @mixin SomeTrait */";
908 let parsed = DocblockParser::parse(doc);
909 assert_eq!(parsed.mixins, vec!["SomeTrait".to_string()]);
910 }
911
912 #[test]
913 fn parse_property_tags() {
914 let doc = r#"/**
915 * @property string $name
916 * @property-read int $id
917 * @property-write bool $active
918 */"#;
919 let parsed = DocblockParser::parse(doc);
920 assert_eq!(parsed.properties.len(), 3);
921 let name_prop = parsed.properties.iter().find(|p| p.name == "name").unwrap();
922 assert_eq!(name_prop.type_hint, "string");
923 assert!(!name_prop.read_only);
924 assert!(!name_prop.write_only);
925 let id_prop = parsed.properties.iter().find(|p| p.name == "id").unwrap();
926 assert!(id_prop.read_only);
927 let active_prop = parsed
928 .properties
929 .iter()
930 .find(|p| p.name == "active")
931 .unwrap();
932 assert!(active_prop.write_only);
933 }
934
935 #[test]
936 fn parse_method_tag() {
937 let doc = r#"/**
938 * @method string getName()
939 * @method static int create()
940 */"#;
941 let parsed = DocblockParser::parse(doc);
942 assert_eq!(parsed.methods.len(), 2);
943 let get_name = parsed.methods.iter().find(|m| m.name == "getName").unwrap();
944 assert_eq!(get_name.return_type, "string");
945 assert!(!get_name.is_static);
946 let create = parsed.methods.iter().find(|m| m.name == "create").unwrap();
947 assert!(create.is_static);
948 }
949
950 #[test]
951 fn parse_type_alias_tag() {
952 let doc = "/** @psalm-type MyAlias = string|int */";
953 let parsed = DocblockParser::parse(doc);
954 assert_eq!(parsed.type_aliases.len(), 1);
955 assert_eq!(parsed.type_aliases[0].name, "MyAlias");
956 assert_eq!(parsed.type_aliases[0].type_expr, "string|int");
957 }
958
959 #[test]
960 fn parse_import_type_no_as() {
961 let doc = "/** @psalm-import-type UserId from UserRepository */";
962 let parsed = DocblockParser::parse(doc);
963 assert_eq!(parsed.import_types.len(), 1);
964 assert_eq!(parsed.import_types[0].original, "UserId");
965 assert_eq!(parsed.import_types[0].local, "UserId");
966 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
967 }
968
969 #[test]
970 fn parse_import_type_with_as() {
971 let doc = "/** @psalm-import-type UserId as LocalId from UserRepository */";
972 let parsed = DocblockParser::parse(doc);
973 assert_eq!(parsed.import_types.len(), 1);
974 assert_eq!(parsed.import_types[0].original, "UserId");
975 assert_eq!(parsed.import_types[0].local, "LocalId");
976 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
977 }
978
979 #[test]
980 fn parse_require_extends() {
981 let doc = "/** @psalm-require-extends Model */";
982 let parsed = DocblockParser::parse(doc);
983 assert_eq!(parsed.require_extends, vec!["Model".to_string()]);
984 }
985
986 #[test]
987 fn parse_require_implements() {
988 let doc = "/** @psalm-require-implements Countable */";
989 let parsed = DocblockParser::parse(doc);
990 assert_eq!(parsed.require_implements, vec!["Countable".to_string()]);
991 }
992
993 #[test]
994 fn parse_intersection_two_parts() {
995 let u = parse_type_string("Iterator&Countable");
996 assert_eq!(u.types.len(), 1);
997 assert!(matches!(u.types[0], Atomic::TIntersection { ref parts } if parts.len() == 2));
998 if let Atomic::TIntersection { parts } = &u.types[0] {
999 assert!(parts[0].contains(
1000 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1001 ));
1002 assert!(parts[1].contains(
1003 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1004 ));
1005 }
1006 }
1007
1008 #[test]
1009 fn parse_intersection_three_parts() {
1010 let u = parse_type_string("Iterator&Countable&Stringable");
1011 assert_eq!(u.types.len(), 1);
1012 let Atomic::TIntersection { parts } = &u.types[0] else {
1013 panic!("expected TIntersection");
1014 };
1015 assert_eq!(parts.len(), 3);
1016 assert!(parts[0].contains(
1017 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1018 ));
1019 assert!(parts[1].contains(
1020 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1021 ));
1022 assert!(parts[2].contains(
1023 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Stringable")
1024 ));
1025 }
1026
1027 #[test]
1028 fn parse_intersection_in_union_with_null() {
1029 let u = parse_type_string("Iterator&Countable|null");
1030 assert!(u.is_nullable());
1031 let intersection = u
1032 .types
1033 .iter()
1034 .find_map(|t| {
1035 if let Atomic::TIntersection { parts } = t {
1036 Some(parts)
1037 } else {
1038 None
1039 }
1040 })
1041 .expect("expected TIntersection");
1042 assert_eq!(intersection.len(), 2);
1043 assert!(intersection[0].contains(
1044 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1045 ));
1046 assert!(intersection[1].contains(
1047 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1048 ));
1049 }
1050
1051 #[test]
1052 fn parse_intersection_in_union_with_scalar() {
1053 let u = parse_type_string("Iterator&Countable|string");
1054 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1055 let intersection = u
1056 .types
1057 .iter()
1058 .find_map(|t| {
1059 if let Atomic::TIntersection { parts } = t {
1060 Some(parts)
1061 } else {
1062 None
1063 }
1064 })
1065 .expect("expected TIntersection");
1066 assert!(intersection[0].contains(
1067 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1068 ));
1069 assert!(intersection[1].contains(
1070 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1071 ));
1072 }
1073}