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 if let Some(msg) = validate_type_str(ty_s, "param") {
30 result.invalid_annotations.push(msg);
31 }
32 result.params.push((
33 n.trim_start_matches('$').to_string(),
34 parse_type_string(ty_s),
35 ));
36 }
37 PhpDocTag::Param {
40 type_str: Some(ty_s),
41 name: None,
42 ..
43 } => {
44 if let Some(msg) = validate_type_str(ty_s, "param") {
45 result.invalid_annotations.push(msg);
46 }
47 }
48 PhpDocTag::Return {
49 type_str: Some(ty_s),
50 ..
51 } => {
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 PhpDocTag::Var { type_str, name, .. } => {
58 if let Some(ty_s) = type_str {
59 if let Some(msg) = validate_type_str(ty_s, "var") {
60 result.invalid_annotations.push(msg);
61 }
62 result.var_type = Some(parse_type_string(ty_s));
63 }
64 if let Some(n) = name {
65 result.var_name = Some(n.trim_start_matches('$').to_string());
66 }
67 }
68 PhpDocTag::Throws {
69 type_str: Some(ty_s),
70 ..
71 } => {
72 let class = ty_s.split_whitespace().next().unwrap_or("").to_string();
73 if !class.is_empty() {
74 result.throws.push(class);
75 }
76 }
77 PhpDocTag::Deprecated { description } => {
78 result.is_deprecated = true;
79 result.deprecated = Some(
80 description
81 .as_ref()
82 .map(|d| d.to_string())
83 .unwrap_or_default(),
84 );
85 }
86 PhpDocTag::Template { name, bound } => {
87 if let Some(b) = bound {
88 if let Some(msg) = validate_type_str(b, "template") {
89 result.invalid_annotations.push(msg);
90 }
91 }
92 result.templates.push((
93 name.to_string(),
94 bound.map(parse_type_string),
95 Variance::Invariant,
96 ));
97 }
98 PhpDocTag::TemplateCovariant { name, bound } => {
99 if let Some(b) = bound {
100 if let Some(msg) = validate_type_str(b, "template-covariant") {
101 result.invalid_annotations.push(msg);
102 }
103 }
104 result.templates.push((
105 name.to_string(),
106 bound.map(parse_type_string),
107 Variance::Covariant,
108 ));
109 }
110 PhpDocTag::TemplateContravariant { name, bound } => {
111 if let Some(b) = bound {
112 if let Some(msg) = validate_type_str(b, "template-contravariant") {
113 result.invalid_annotations.push(msg);
114 }
115 }
116 result.templates.push((
117 name.to_string(),
118 bound.map(parse_type_string),
119 Variance::Contravariant,
120 ));
121 }
122 PhpDocTag::Extends { type_str } => {
123 result.extends = Some(parse_type_string(type_str));
124 }
125 PhpDocTag::Implements { type_str } => {
126 result.implements.push(parse_type_string(type_str));
127 }
128 PhpDocTag::Assert {
129 type_str: Some(ty_s),
130 name: Some(n),
131 } => {
132 result.assertions.push((
133 n.trim_start_matches('$').to_string(),
134 parse_type_string(ty_s),
135 ));
136 }
137 PhpDocTag::Suppress { rules } => {
138 for rule in rules.split([',', ' ']) {
139 let rule = rule.trim().to_string();
140 if !rule.is_empty() {
141 result.suppressed_issues.push(rule);
142 }
143 }
144 }
145 PhpDocTag::See { reference } => result.see.push(reference.to_string()),
146 PhpDocTag::Link { url } => result.see.push(url.to_string()),
147 PhpDocTag::Mixin { class } => result.mixins.push(class.to_string()),
148 PhpDocTag::Property {
149 type_str,
150 name: Some(n),
151 ..
152 } => result.properties.push(DocProperty {
153 type_hint: type_str.unwrap_or("").to_string(),
154 name: n.trim_start_matches('$').to_string(),
155 read_only: false,
156 write_only: false,
157 }),
158 PhpDocTag::PropertyRead {
159 type_str,
160 name: Some(n),
161 ..
162 } => result.properties.push(DocProperty {
163 type_hint: type_str.unwrap_or("").to_string(),
164 name: n.trim_start_matches('$').to_string(),
165 read_only: true,
166 write_only: false,
167 }),
168 PhpDocTag::PropertyWrite {
169 type_str,
170 name: Some(n),
171 ..
172 } => result.properties.push(DocProperty {
173 type_hint: type_str.unwrap_or("").to_string(),
174 name: n.trim_start_matches('$').to_string(),
175 read_only: false,
176 write_only: true,
177 }),
178 PhpDocTag::Method { signature } => {
179 if let Some(m) = parse_method_line(signature) {
180 result.methods.push(m);
181 }
182 }
183 PhpDocTag::TypeAlias {
184 name: Some(n),
185 type_str,
186 } => result.type_aliases.push(DocTypeAlias {
187 name: n.to_string(),
188 type_expr: type_str.unwrap_or("").to_string(),
189 }),
190 PhpDocTag::ImportType { body } => {
191 if let Some(import) = parse_import_type(body) {
192 result.import_types.push(import);
193 }
194 }
195 PhpDocTag::Since { version } if result.since.is_none() => {
196 let v = version.split_whitespace().next().unwrap_or("");
199 if !v.is_empty() {
200 result.since = Some(v.to_string());
201 }
202 }
203 PhpDocTag::Internal => result.is_internal = true,
204 PhpDocTag::Pure => result.is_pure = true,
205 PhpDocTag::Immutable => result.is_immutable = true,
206 PhpDocTag::Readonly => result.is_readonly = true,
207 PhpDocTag::Generic { tag, body } => match *tag {
208 "api" | "psalm-api" => result.is_api = true,
209 "removed" if result.removed.is_none() => {
210 if let Some(b) = body {
211 let v = b.split_whitespace().next().unwrap_or("");
212 if !v.is_empty() {
213 result.removed = Some(v.to_string());
214 }
215 }
216 }
217 "psalm-assert" | "phpstan-assert" => {
218 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
219 result.assertions.push((name, parse_type_string(&ty_str)));
220 }
221 }
222 "psalm-assert-if-true" | "phpstan-assert-if-true" => {
223 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
224 result
225 .assertions_if_true
226 .push((name, parse_type_string(&ty_str)));
227 }
228 }
229 "psalm-assert-if-false" | "phpstan-assert-if-false" => {
230 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
231 result
232 .assertions_if_false
233 .push((name, parse_type_string(&ty_str)));
234 }
235 }
236 "psalm-property" => {
237 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
238 result.properties.push(DocProperty {
239 type_hint: ty_str,
240 name,
241 read_only: false,
242 write_only: false,
243 });
244 }
245 }
246 "psalm-property-read" => {
247 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
248 result.properties.push(DocProperty {
249 type_hint: ty_str,
250 name,
251 read_only: true,
252 write_only: false,
253 });
254 }
255 }
256 "psalm-property-write" => {
257 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
258 result.properties.push(DocProperty {
259 type_hint: ty_str,
260 name,
261 read_only: false,
262 write_only: true,
263 });
264 }
265 }
266 "psalm-method" => {
267 if let Some(method) = body.as_deref().and_then(parse_method_line) {
268 result.methods.push(method);
269 }
270 }
271 "psalm-require-extends" | "phpstan-require-extends" => {
272 if let Some(b) = body {
273 let cls = b.split_whitespace().next().unwrap_or("").trim().to_string();
274 if !cls.is_empty() {
275 result.require_extends.push(cls);
276 }
277 }
278 }
279 "psalm-require-implements" | "phpstan-require-implements" => {
280 if let Some(b) = body {
281 let cls = b.split_whitespace().next().unwrap_or("").trim().to_string();
282 if !cls.is_empty() {
283 result.require_implements.push(cls);
284 }
285 }
286 }
287 _ => {}
288 },
289 _ => {}
290 }
291 }
292
293 result
294 }
295}
296
297#[derive(Debug, Default, Clone)]
302pub struct DocProperty {
303 pub type_hint: String,
304 pub name: String, pub read_only: bool, pub write_only: bool, }
308
309#[derive(Debug, Default, Clone)]
310pub struct DocMethod {
311 pub return_type: String,
312 pub name: String,
313 pub is_static: bool,
314 pub params: Vec<DocMethodParam>,
315}
316
317#[derive(Debug, Default, Clone)]
318pub struct DocMethodParam {
319 pub name: String,
320 pub type_hint: String,
321 pub is_variadic: bool,
322 pub is_byref: bool,
323 pub is_optional: bool,
324}
325
326#[derive(Debug, Default, Clone)]
327pub struct DocTypeAlias {
328 pub name: String,
329 pub type_expr: String,
330}
331
332#[derive(Debug, Default, Clone)]
333pub struct DocImportType {
334 pub original: String,
336 pub local: String,
338 pub from_class: String,
340}
341
342#[derive(Debug, Default, Clone)]
347pub struct ParsedDocblock {
348 pub params: Vec<(String, Union)>,
350 pub return_type: Option<Union>,
352 pub var_type: Option<Union>,
354 pub var_name: Option<String>,
356 pub templates: Vec<(String, Option<Union>, Variance)>,
358 pub extends: Option<Union>,
360 pub implements: Vec<Union>,
362 pub throws: Vec<String>,
364 pub assertions: Vec<(String, Union)>,
366 pub assertions_if_true: Vec<(String, Union)>,
368 pub assertions_if_false: Vec<(String, Union)>,
370 pub suppressed_issues: Vec<String>,
372 pub is_deprecated: bool,
373 pub is_internal: bool,
374 pub is_pure: bool,
375 pub is_immutable: bool,
376 pub is_readonly: bool,
377 pub is_api: bool,
378 pub description: String,
380 pub deprecated: Option<String>,
382 pub see: Vec<String>,
384 pub mixins: Vec<String>,
386 pub properties: Vec<DocProperty>,
388 pub methods: Vec<DocMethod>,
390 pub type_aliases: Vec<DocTypeAlias>,
392 pub import_types: Vec<DocImportType>,
394 pub require_extends: Vec<String>,
396 pub require_implements: Vec<String>,
398 pub since: Option<String>,
400 pub removed: Option<String>,
402 pub invalid_annotations: Vec<String>,
404}
405
406impl ParsedDocblock {
407 pub fn get_param_type(&self, name: &str) -> Option<&Union> {
413 let name = name.trim_start_matches('$');
414 self.params
415 .iter()
416 .rfind(|(n, _)| n.trim_start_matches('$') == name)
417 .map(|(_, ty)| ty)
418 }
419}
420
421pub fn parse_type_string(s: &str) -> Union {
429 let s = s.trim();
430
431 if let Some(inner) = s.strip_prefix('?') {
433 let inner_ty = parse_type_string(inner);
434 let mut u = inner_ty;
435 u.add_type(Atomic::TNull);
436 return u;
437 }
438
439 if s.contains('|') && !is_inside_generics(s) {
441 let parts = split_union(s);
442 if parts.len() > 1 {
443 let mut u = Union::empty();
444 for part in parts {
445 for atomic in parse_type_string(&part).types {
446 u.add_type(atomic);
447 }
448 }
449 return u;
450 }
451 }
452
453 if s.contains('&') && !is_inside_generics(s) {
455 let parts: Vec<Union> = s.split('&').map(|p| parse_type_string(p.trim())).collect();
456 return Union::single(Atomic::TIntersection { parts });
457 }
458
459 if let Some(value_str) = s.strip_suffix("[]") {
461 let value = parse_type_string(value_str);
462 return Union::single(Atomic::TArray {
463 key: Box::new(Union::single(Atomic::TInt)),
464 value: Box::new(value),
465 });
466 }
467
468 if let Some(open) = s.find('<') {
470 if s.ends_with('>') {
471 let name = &s[..open];
472 let inner = &s[open + 1..s.len() - 1];
473 return parse_generic(name, inner);
474 }
475 }
476
477 match s.to_lowercase().as_str() {
479 "string" => Union::single(Atomic::TString),
480 "non-empty-string" => Union::single(Atomic::TNonEmptyString),
481 "numeric-string" => Union::single(Atomic::TNumericString),
482 "class-string" => Union::single(Atomic::TClassString(None)),
483 "int" | "integer" => Union::single(Atomic::TInt),
484 "positive-int" => Union::single(Atomic::TPositiveInt),
485 "negative-int" => Union::single(Atomic::TNegativeInt),
486 "non-negative-int" => Union::single(Atomic::TNonNegativeInt),
487 "float" | "double" => Union::single(Atomic::TFloat),
488 "bool" | "boolean" => Union::single(Atomic::TBool),
489 "true" => Union::single(Atomic::TTrue),
490 "false" => Union::single(Atomic::TFalse),
491 "null" => Union::single(Atomic::TNull),
492 "void" => Union::single(Atomic::TVoid),
493 "never" | "never-return" | "no-return" | "never-returns" => Union::single(Atomic::TNever),
494 "mixed" => Union::single(Atomic::TMixed),
495 "object" => Union::single(Atomic::TObject),
496 "array" => Union::single(Atomic::TArray {
497 key: Box::new(Union::single(Atomic::TMixed)),
498 value: Box::new(Union::mixed()),
499 }),
500 "list" => Union::single(Atomic::TList {
501 value: Box::new(Union::mixed()),
502 }),
503 "callable" => Union::single(Atomic::TCallable {
504 params: None,
505 return_type: None,
506 }),
507 "iterable" => Union::single(Atomic::TArray {
508 key: Box::new(Union::single(Atomic::TMixed)),
509 value: Box::new(Union::mixed()),
510 }),
511 "scalar" => Union::single(Atomic::TScalar),
512 "numeric" => Union::single(Atomic::TNumeric),
513 "resource" => Union::mixed(), "static" => Union::single(Atomic::TStaticObject {
516 fqcn: Arc::from(""),
517 }),
518 "self" | "$this" => Union::single(Atomic::TSelf {
519 fqcn: Arc::from(""),
520 }),
521 "parent" => Union::single(Atomic::TParent {
522 fqcn: Arc::from(""),
523 }),
524
525 _ if !s.is_empty()
527 && s.chars()
528 .next()
529 .map(|c| c.is_alphanumeric() || c == '\\' || c == '_')
530 .unwrap_or(false) =>
531 {
532 Union::single(Atomic::TNamedObject {
533 fqcn: normalize_fqcn(s).into(),
534 type_params: vec![],
535 })
536 }
537
538 _ => Union::mixed(),
539 }
540}
541
542fn parse_generic(name: &str, inner: &str) -> Union {
543 match name.to_lowercase().as_str() {
544 "array" => {
545 let params = split_generics(inner);
546 let (key, value) = if params.len() >= 2 {
547 (
548 parse_type_string(params[0].trim()),
549 parse_type_string(params[1].trim()),
550 )
551 } else {
552 (
553 Union::single(Atomic::TInt),
554 parse_type_string(params[0].trim()),
555 )
556 };
557 Union::single(Atomic::TArray {
558 key: Box::new(key),
559 value: Box::new(value),
560 })
561 }
562 "list" | "non-empty-list" => {
563 let value = parse_type_string(inner.trim());
564 if name.to_lowercase().starts_with("non-empty") {
565 Union::single(Atomic::TNonEmptyList {
566 value: Box::new(value),
567 })
568 } else {
569 Union::single(Atomic::TList {
570 value: Box::new(value),
571 })
572 }
573 }
574 "non-empty-array" => {
575 let params = split_generics(inner);
576 let (key, value) = if params.len() >= 2 {
577 (
578 parse_type_string(params[0].trim()),
579 parse_type_string(params[1].trim()),
580 )
581 } else {
582 (
583 Union::single(Atomic::TInt),
584 parse_type_string(params[0].trim()),
585 )
586 };
587 Union::single(Atomic::TNonEmptyArray {
588 key: Box::new(key),
589 value: Box::new(value),
590 })
591 }
592 "iterable" => {
593 let params = split_generics(inner);
594 let value = if params.len() >= 2 {
595 parse_type_string(params[1].trim())
596 } else {
597 parse_type_string(params[0].trim())
598 };
599 Union::single(Atomic::TArray {
600 key: Box::new(Union::single(Atomic::TMixed)),
601 value: Box::new(value),
602 })
603 }
604 "class-string" => Union::single(Atomic::TClassString(Some(
605 normalize_fqcn(inner.trim()).into(),
606 ))),
607 "int" => {
608 Union::single(Atomic::TIntRange {
610 min: None,
611 max: None,
612 })
613 }
614 _ => {
616 let params: Vec<Union> = split_generics(inner)
617 .iter()
618 .map(|p| parse_type_string(p.trim()))
619 .collect();
620 Union::single(Atomic::TNamedObject {
621 fqcn: normalize_fqcn(name).into(),
622 type_params: params,
623 })
624 }
625 }
626}
627
628fn extract_description(text: &str) -> String {
634 let mut desc_lines: Vec<&str> = Vec::new();
635 for line in text.lines() {
636 let l = line.trim();
637 let l = l.trim_start_matches("/**").trim();
638 let l = l.trim_end_matches("*/").trim();
639 let l = l.trim_start_matches("*/").trim();
640 let l = l.strip_prefix("* ").unwrap_or(l.trim_start_matches('*'));
641 let l = l.trim();
642 if l.starts_with('@') {
643 break;
644 }
645 if !l.is_empty() {
646 desc_lines.push(l);
647 }
648 }
649 desc_lines.join(" ")
650}
651
652fn parse_import_type(body: &str) -> Option<DocImportType> {
658 let (before_from, from_class_raw) = body.split_once(" from ")?;
660 let from_class = from_class_raw.trim().trim_start_matches('\\').to_string();
661 if from_class.is_empty() {
662 return None;
663 }
664 let (original, local) = if let Some((orig, loc)) = before_from.split_once(" as ") {
666 (orig.trim().to_string(), loc.trim().to_string())
667 } else {
668 let name = before_from.trim().to_string();
669 (name.clone(), name)
670 };
671 if original.is_empty() || local.is_empty() {
672 return None;
673 }
674 Some(DocImportType {
675 original,
676 local,
677 from_class,
678 })
679}
680
681fn parse_param_line(s: &str) -> Option<(String, String)> {
682 let mut parts = s.splitn(3, char::is_whitespace);
684 let ty = parts.next()?.trim().to_string();
685 let name = parts.next()?.trim().trim_start_matches('$').to_string();
686 if ty.is_empty() || name.is_empty() {
687 return None;
688 }
689 Some((ty, name))
690}
691
692fn split_union(s: &str) -> Vec<String> {
693 let mut parts = Vec::new();
694 let mut depth = 0;
695 let mut current = String::new();
696 for ch in s.chars() {
697 match ch {
698 '<' | '(' | '{' => {
699 depth += 1;
700 current.push(ch);
701 }
702 '>' | ')' | '}' => {
703 depth -= 1;
704 current.push(ch);
705 }
706 '|' if depth == 0 => {
707 parts.push(current.trim().to_string());
708 current = String::new();
709 }
710 _ => current.push(ch),
711 }
712 }
713 if !current.trim().is_empty() {
714 parts.push(current.trim().to_string());
715 }
716 parts
717}
718
719fn split_generics(s: &str) -> Vec<String> {
720 let mut parts = Vec::new();
721 let mut depth = 0;
722 let mut current = String::new();
723 for ch in s.chars() {
724 match ch {
725 '<' | '(' | '{' => {
726 depth += 1;
727 current.push(ch);
728 }
729 '>' | ')' | '}' => {
730 depth -= 1;
731 current.push(ch);
732 }
733 ',' if depth == 0 => {
734 parts.push(current.trim().to_string());
735 current = String::new();
736 }
737 _ => current.push(ch),
738 }
739 }
740 if !current.trim().is_empty() {
741 parts.push(current.trim().to_string());
742 }
743 parts
744}
745
746fn is_inside_generics(s: &str) -> bool {
747 let mut depth = 0i32;
748 for ch in s.chars() {
749 match ch {
750 '<' | '(' | '{' => depth += 1,
751 '>' | ')' | '}' => depth -= 1,
752 _ => {}
753 }
754 }
755 depth != 0
756}
757
758fn normalize_fqcn(s: &str) -> String {
759 s.trim_start_matches('\\').to_string()
761}
762
763fn validate_type_str(s: &str, tag: &str) -> Option<String> {
769 let s = s.trim();
770 if s.is_empty() {
771 return None;
772 }
773 if is_inside_generics(s) {
774 return Some(format!("@{tag} has unclosed generic type `{s}`"));
775 }
776 for part in split_union(s) {
777 let p = part.trim();
778 if p.starts_with('$') && p != "$this" {
779 return Some(format!("@{tag} contains variable `{p}` in type position"));
780 }
781 }
782 None
783}
784
785fn parse_method_line(s: &str) -> Option<DocMethod> {
787 let mut rest = s.trim();
788 if rest.is_empty() {
789 return None;
790 }
791 let is_static = rest
792 .split_whitespace()
793 .next()
794 .map(|w| w.eq_ignore_ascii_case("static"))
795 .unwrap_or(false);
796 if is_static {
797 rest = rest["static".len()..].trim_start();
798 }
799
800 let open = rest.find('(').unwrap_or(rest.len());
801 let prefix = rest[..open].trim();
802 let mut parts: Vec<&str> = prefix.split_whitespace().collect();
803 let name = parts.pop()?.to_string();
804 if name.is_empty() {
805 return None;
806 }
807 let return_type = parts.join(" ");
808 Some(DocMethod {
809 return_type,
810 name,
811 is_static,
812 params: parse_method_params(rest),
813 })
814}
815
816fn parse_method_params(name_part: &str) -> Vec<DocMethodParam> {
817 let Some(open) = name_part.find('(') else {
818 return vec![];
819 };
820 let Some(close) = name_part.rfind(')') else {
821 return vec![];
822 };
823 let inner = name_part[open + 1..close].trim();
824 if inner.is_empty() {
825 return vec![];
826 }
827
828 split_generics(inner)
829 .into_iter()
830 .filter_map(|param| parse_method_param(¶m))
831 .collect()
832}
833
834fn parse_method_param(param: &str) -> Option<DocMethodParam> {
835 let before_default = param.split('=').next()?.trim();
836 let is_optional = param.contains('=');
837 let mut tokens: Vec<&str> = before_default.split_whitespace().collect();
838 let raw_name = tokens.pop()?;
839 let is_variadic = raw_name.contains("...");
840 let is_byref = raw_name.contains('&');
841 let name = raw_name
842 .trim_start_matches('&')
843 .trim_start_matches("...")
844 .trim_start_matches('&')
845 .trim_start_matches('$')
846 .to_string();
847 if name.is_empty() {
848 return None;
849 }
850 Some(DocMethodParam {
851 name,
852 type_hint: tokens.join(" "),
853 is_variadic,
854 is_byref,
855 is_optional: is_optional || is_variadic,
856 })
857}
858
859#[cfg(test)]
864mod tests {
865 use super::*;
866 use mir_types::Atomic;
867
868 #[test]
869 fn parse_string() {
870 let u = parse_type_string("string");
871 assert_eq!(u.types.len(), 1);
872 assert!(matches!(u.types[0], Atomic::TString));
873 }
874
875 #[test]
876 fn parse_nullable_string() {
877 let u = parse_type_string("?string");
878 assert!(u.is_nullable());
879 assert!(u.contains(|t| matches!(t, Atomic::TString)));
880 }
881
882 #[test]
883 fn parse_union() {
884 let u = parse_type_string("string|int|null");
885 assert!(u.contains(|t| matches!(t, Atomic::TString)));
886 assert!(u.contains(|t| matches!(t, Atomic::TInt)));
887 assert!(u.is_nullable());
888 }
889
890 #[test]
891 fn parse_array_of_string() {
892 let u = parse_type_string("array<string>");
893 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
894 }
895
896 #[test]
897 fn parse_list_of_int() {
898 let u = parse_type_string("list<int>");
899 assert!(u.contains(|t| matches!(t, Atomic::TList { .. })));
900 }
901
902 #[test]
903 fn parse_named_class() {
904 let u = parse_type_string("Foo\\Bar");
905 assert!(u.contains(
906 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Foo\\Bar")
907 ));
908 }
909
910 #[test]
911 fn parse_docblock_param_return() {
912 let doc = r#"/**
913 * @param string $name
914 * @param int $age
915 * @return bool
916 */"#;
917 let parsed = DocblockParser::parse(doc);
918 assert_eq!(parsed.params.len(), 2);
919 assert!(parsed.return_type.is_some());
920 let ret = parsed.return_type.unwrap();
921 assert!(ret.contains(|t| matches!(t, Atomic::TBool)));
922 }
923
924 #[test]
925 fn parse_template() {
926 let doc = "/** @template T of object */";
927 let parsed = DocblockParser::parse(doc);
928 assert_eq!(parsed.templates.len(), 1);
929 assert_eq!(parsed.templates[0].0, "T");
930 assert!(parsed.templates[0].1.is_some());
931 assert_eq!(parsed.templates[0].2, Variance::Invariant);
932 }
933
934 #[test]
935 fn parse_template_covariant() {
936 let doc = "/** @template-covariant T */";
937 let parsed = DocblockParser::parse(doc);
938 assert_eq!(parsed.templates.len(), 1);
939 assert_eq!(parsed.templates[0].0, "T");
940 assert_eq!(parsed.templates[0].2, Variance::Covariant);
941 }
942
943 #[test]
944 fn parse_template_contravariant() {
945 let doc = "/** @template-contravariant T */";
946 let parsed = DocblockParser::parse(doc);
947 assert_eq!(parsed.templates.len(), 1);
948 assert_eq!(parsed.templates[0].0, "T");
949 assert_eq!(parsed.templates[0].2, Variance::Contravariant);
950 }
951
952 #[test]
953 fn parse_deprecated() {
954 let doc = "/** @deprecated use newMethod() instead */";
955 let parsed = DocblockParser::parse(doc);
956 assert!(parsed.is_deprecated);
957 assert_eq!(
958 parsed.deprecated.as_deref(),
959 Some("use newMethod() instead")
960 );
961 }
962
963 #[test]
964 fn parse_since_plain() {
965 let parsed = DocblockParser::parse("/** @since 8.0 */");
966 assert_eq!(parsed.since.as_deref(), Some("8.0"));
967 assert_eq!(parsed.removed, None);
968 }
969
970 #[test]
971 fn parse_since_strips_trailing_description() {
972 let parsed = DocblockParser::parse("/** @since 1.4.0 added \\$options argument */");
975 assert_eq!(parsed.since.as_deref(), Some("1.4.0"));
976 }
977
978 #[test]
979 fn parse_removed_tag() {
980 let parsed = DocblockParser::parse("/** @removed 8.0 use mb_convert_encoding */");
981 assert_eq!(parsed.removed.as_deref(), Some("8.0"));
982 }
983
984 #[test]
985 fn parse_since_empty_body_is_none() {
986 let parsed = DocblockParser::parse("/** @since */");
987 assert_eq!(parsed.since, None);
988 }
989
990 #[test]
991 fn parse_description() {
992 let doc = r#"/**
993 * This is a description.
994 * Spans two lines.
995 * @param string $x
996 */"#;
997 let parsed = DocblockParser::parse(doc);
998 assert!(parsed.description.contains("This is a description"));
999 assert!(parsed.description.contains("Spans two lines"));
1000 }
1001
1002 #[test]
1003 fn parse_see_and_link() {
1004 let doc = "/** @see SomeClass\n * @link https://example.com */";
1005 let parsed = DocblockParser::parse(doc);
1006 assert_eq!(parsed.see.len(), 2);
1007 assert!(parsed.see.contains(&"SomeClass".to_string()));
1008 assert!(parsed.see.contains(&"https://example.com".to_string()));
1009 }
1010
1011 #[test]
1012 fn parse_mixin() {
1013 let doc = "/** @mixin SomeTrait */";
1014 let parsed = DocblockParser::parse(doc);
1015 assert_eq!(parsed.mixins, vec!["SomeTrait".to_string()]);
1016 }
1017
1018 #[test]
1019 fn parse_property_tags() {
1020 let doc = r#"/**
1021 * @property string $name
1022 * @property-read int $id
1023 * @property-write bool $active
1024 */"#;
1025 let parsed = DocblockParser::parse(doc);
1026 assert_eq!(parsed.properties.len(), 3);
1027 let name_prop = parsed.properties.iter().find(|p| p.name == "name").unwrap();
1028 assert_eq!(name_prop.type_hint, "string");
1029 assert!(!name_prop.read_only);
1030 assert!(!name_prop.write_only);
1031 let id_prop = parsed.properties.iter().find(|p| p.name == "id").unwrap();
1032 assert!(id_prop.read_only);
1033 let active_prop = parsed
1034 .properties
1035 .iter()
1036 .find(|p| p.name == "active")
1037 .unwrap();
1038 assert!(active_prop.write_only);
1039 }
1040
1041 #[test]
1042 fn parse_method_tag() {
1043 let doc = r#"/**
1044 * @method string getName()
1045 * @method static int create()
1046 */"#;
1047 let parsed = DocblockParser::parse(doc);
1048 assert_eq!(parsed.methods.len(), 2);
1049 let get_name = parsed.methods.iter().find(|m| m.name == "getName").unwrap();
1050 assert_eq!(get_name.return_type, "string");
1051 assert!(!get_name.is_static);
1052 let create = parsed.methods.iter().find(|m| m.name == "create").unwrap();
1053 assert!(create.is_static);
1054 }
1055
1056 #[test]
1057 fn parse_type_alias_tag() {
1058 let doc = "/** @psalm-type MyAlias = string|int */";
1059 let parsed = DocblockParser::parse(doc);
1060 assert_eq!(parsed.type_aliases.len(), 1);
1061 assert_eq!(parsed.type_aliases[0].name, "MyAlias");
1062 assert_eq!(parsed.type_aliases[0].type_expr, "string|int");
1063 }
1064
1065 #[test]
1066 fn parse_import_type_no_as() {
1067 let doc = "/** @psalm-import-type UserId from UserRepository */";
1068 let parsed = DocblockParser::parse(doc);
1069 assert_eq!(parsed.import_types.len(), 1);
1070 assert_eq!(parsed.import_types[0].original, "UserId");
1071 assert_eq!(parsed.import_types[0].local, "UserId");
1072 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1073 }
1074
1075 #[test]
1076 fn parse_import_type_with_as() {
1077 let doc = "/** @psalm-import-type UserId as LocalId from UserRepository */";
1078 let parsed = DocblockParser::parse(doc);
1079 assert_eq!(parsed.import_types.len(), 1);
1080 assert_eq!(parsed.import_types[0].original, "UserId");
1081 assert_eq!(parsed.import_types[0].local, "LocalId");
1082 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1083 }
1084
1085 #[test]
1086 fn parse_require_extends() {
1087 let doc = "/** @psalm-require-extends Model */";
1088 let parsed = DocblockParser::parse(doc);
1089 assert_eq!(parsed.require_extends, vec!["Model".to_string()]);
1090 }
1091
1092 #[test]
1093 fn parse_require_implements() {
1094 let doc = "/** @psalm-require-implements Countable */";
1095 let parsed = DocblockParser::parse(doc);
1096 assert_eq!(parsed.require_implements, vec!["Countable".to_string()]);
1097 }
1098
1099 #[test]
1100 fn parse_intersection_two_parts() {
1101 let u = parse_type_string("Iterator&Countable");
1102 assert_eq!(u.types.len(), 1);
1103 assert!(matches!(u.types[0], Atomic::TIntersection { ref parts } if parts.len() == 2));
1104 if let Atomic::TIntersection { parts } = &u.types[0] {
1105 assert!(parts[0].contains(
1106 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1107 ));
1108 assert!(parts[1].contains(
1109 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1110 ));
1111 }
1112 }
1113
1114 #[test]
1115 fn parse_intersection_three_parts() {
1116 let u = parse_type_string("Iterator&Countable&Stringable");
1117 assert_eq!(u.types.len(), 1);
1118 let Atomic::TIntersection { parts } = &u.types[0] else {
1119 panic!("expected TIntersection");
1120 };
1121 assert_eq!(parts.len(), 3);
1122 assert!(parts[0].contains(
1123 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1124 ));
1125 assert!(parts[1].contains(
1126 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1127 ));
1128 assert!(parts[2].contains(
1129 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Stringable")
1130 ));
1131 }
1132
1133 #[test]
1134 fn parse_intersection_in_union_with_null() {
1135 let u = parse_type_string("Iterator&Countable|null");
1136 assert!(u.is_nullable());
1137 let intersection = u
1138 .types
1139 .iter()
1140 .find_map(|t| {
1141 if let Atomic::TIntersection { parts } = t {
1142 Some(parts)
1143 } else {
1144 None
1145 }
1146 })
1147 .expect("expected TIntersection");
1148 assert_eq!(intersection.len(), 2);
1149 assert!(intersection[0].contains(
1150 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1151 ));
1152 assert!(intersection[1].contains(
1153 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1154 ));
1155 }
1156
1157 #[test]
1158 fn parse_intersection_in_union_with_scalar() {
1159 let u = parse_type_string("Iterator&Countable|string");
1160 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1161 let intersection = u
1162 .types
1163 .iter()
1164 .find_map(|t| {
1165 if let Atomic::TIntersection { parts } = t {
1166 Some(parts)
1167 } else {
1168 None
1169 }
1170 })
1171 .expect("expected TIntersection");
1172 assert!(intersection[0].contains(
1173 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1174 ));
1175 assert!(intersection[1].contains(
1176 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1177 ));
1178 }
1179
1180 #[test]
1181 fn validate_unclosed_generic_return() {
1182 let parsed = DocblockParser::parse("/** @return array< */");
1183 assert_eq!(parsed.invalid_annotations.len(), 1);
1184 assert!(
1185 parsed.invalid_annotations[0].contains("unclosed generic"),
1186 "got: {}",
1187 parsed.invalid_annotations[0]
1188 );
1189 }
1190
1191 #[test]
1192 fn validate_variable_in_type_position_param() {
1193 let parsed = DocblockParser::parse("/** @param Foo|$invalid $x */");
1194 assert_eq!(parsed.invalid_annotations.len(), 1);
1195 assert!(
1196 parsed.invalid_annotations[0].contains("$invalid"),
1197 "got: {}",
1198 parsed.invalid_annotations[0]
1199 );
1200 }
1201
1202 #[test]
1203 fn validate_this_is_valid_in_type_position() {
1204 let parsed = DocblockParser::parse("/** @return $this */");
1205 assert!(
1206 parsed.invalid_annotations.is_empty(),
1207 "unexpected error: {:?}",
1208 parsed.invalid_annotations
1209 );
1210 }
1211
1212 #[test]
1213 fn validate_unclosed_generic_var() {
1214 let parsed = DocblockParser::parse("/** @var array<string */");
1215 assert_eq!(parsed.invalid_annotations.len(), 1);
1216 assert!(parsed.invalid_annotations[0].contains("@var"));
1217 }
1218
1219 #[test]
1220 fn validate_variable_in_template_bound() {
1221 let parsed = DocblockParser::parse("/** @template T of $invalid */");
1222 assert_eq!(parsed.invalid_annotations.len(), 1);
1223 assert!(parsed.invalid_annotations[0].contains("$invalid"));
1224 }
1225}