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