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(type_str.to_string());
89 }
90 PhpDocTag::Implements { type_str } => {
91 result.implements.push(type_str.to_string());
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-if-true" | "phpstan-assert-if-true" => {
162 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
163 result
164 .assertions_if_true
165 .push((name, parse_type_string(&ty_str)));
166 }
167 }
168 "psalm-assert-if-false" | "phpstan-assert-if-false" => {
169 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
170 result
171 .assertions_if_false
172 .push((name, parse_type_string(&ty_str)));
173 }
174 }
175 _ => {}
176 },
177 _ => {}
178 }
179 }
180
181 result
182 }
183}
184
185#[derive(Debug, Default, Clone)]
190pub struct DocProperty {
191 pub type_hint: String,
192 pub name: String, pub read_only: bool, pub write_only: bool, }
196
197#[derive(Debug, Default, Clone)]
198pub struct DocMethod {
199 pub return_type: String,
200 pub name: String,
201 pub is_static: bool,
202}
203
204#[derive(Debug, Default, Clone)]
205pub struct DocTypeAlias {
206 pub name: String,
207 pub type_expr: String,
208}
209
210#[derive(Debug, Default, Clone)]
215pub struct ParsedDocblock {
216 pub params: Vec<(String, Union)>,
218 pub return_type: Option<Union>,
220 pub var_type: Option<Union>,
222 pub var_name: Option<String>,
224 pub templates: Vec<(String, Option<Union>, Variance)>,
226 pub extends: Option<String>,
228 pub implements: Vec<String>,
230 pub throws: Vec<String>,
232 pub assertions: Vec<(String, Union)>,
234 pub assertions_if_true: Vec<(String, Union)>,
236 pub assertions_if_false: Vec<(String, Union)>,
238 pub suppressed_issues: Vec<String>,
240 pub is_deprecated: bool,
241 pub is_internal: bool,
242 pub is_pure: bool,
243 pub is_immutable: bool,
244 pub is_readonly: bool,
245 pub is_api: bool,
246 pub description: String,
248 pub deprecated: Option<String>,
250 pub see: Vec<String>,
252 pub mixins: Vec<String>,
254 pub properties: Vec<DocProperty>,
256 pub methods: Vec<DocMethod>,
258 pub type_aliases: Vec<DocTypeAlias>,
260}
261
262impl ParsedDocblock {
263 pub fn get_param_type(&self, name: &str) -> Option<&Union> {
265 let name = name.trim_start_matches('$');
266 self.params
267 .iter()
268 .find(|(n, _)| n.trim_start_matches('$') == name)
269 .map(|(_, ty)| ty)
270 }
271}
272
273pub fn parse_type_string(s: &str) -> Union {
281 let s = s.trim();
282
283 if let Some(inner) = s.strip_prefix('?') {
285 let inner_ty = parse_type_string(inner);
286 let mut u = inner_ty;
287 u.add_type(Atomic::TNull);
288 return u;
289 }
290
291 if s.contains('|') && !is_inside_generics(s) {
293 let parts = split_union(s);
294 if parts.len() > 1 {
295 let mut u = Union::empty();
296 for part in parts {
297 for atomic in parse_type_string(&part).types {
298 u.add_type(atomic);
299 }
300 }
301 return u;
302 }
303 }
304
305 if s.contains('&') && !is_inside_generics(s) {
307 let first = s.split('&').next().unwrap_or(s);
308 return parse_type_string(first.trim());
309 }
310
311 if let Some(value_str) = s.strip_suffix("[]") {
313 let value = parse_type_string(value_str);
314 return Union::single(Atomic::TArray {
315 key: Box::new(Union::single(Atomic::TInt)),
316 value: Box::new(value),
317 });
318 }
319
320 if let Some(open) = s.find('<') {
322 if s.ends_with('>') {
323 let name = &s[..open];
324 let inner = &s[open + 1..s.len() - 1];
325 return parse_generic(name, inner);
326 }
327 }
328
329 match s.to_lowercase().as_str() {
331 "string" => Union::single(Atomic::TString),
332 "non-empty-string" => Union::single(Atomic::TNonEmptyString),
333 "numeric-string" => Union::single(Atomic::TNumericString),
334 "class-string" => Union::single(Atomic::TClassString(None)),
335 "int" | "integer" => Union::single(Atomic::TInt),
336 "positive-int" => Union::single(Atomic::TPositiveInt),
337 "negative-int" => Union::single(Atomic::TNegativeInt),
338 "non-negative-int" => Union::single(Atomic::TNonNegativeInt),
339 "float" | "double" => Union::single(Atomic::TFloat),
340 "bool" | "boolean" => Union::single(Atomic::TBool),
341 "true" => Union::single(Atomic::TTrue),
342 "false" => Union::single(Atomic::TFalse),
343 "null" => Union::single(Atomic::TNull),
344 "void" => Union::single(Atomic::TVoid),
345 "never" | "never-return" | "no-return" | "never-returns" => Union::single(Atomic::TNever),
346 "mixed" => Union::single(Atomic::TMixed),
347 "object" => Union::single(Atomic::TObject),
348 "array" => Union::single(Atomic::TArray {
349 key: Box::new(Union::single(Atomic::TMixed)),
350 value: Box::new(Union::mixed()),
351 }),
352 "list" => Union::single(Atomic::TList {
353 value: Box::new(Union::mixed()),
354 }),
355 "callable" => Union::single(Atomic::TCallable {
356 params: None,
357 return_type: None,
358 }),
359 "iterable" => Union::single(Atomic::TArray {
360 key: Box::new(Union::single(Atomic::TMixed)),
361 value: Box::new(Union::mixed()),
362 }),
363 "scalar" => Union::single(Atomic::TScalar),
364 "numeric" => Union::single(Atomic::TNumeric),
365 "resource" => Union::mixed(), "static" => Union::single(Atomic::TStaticObject {
368 fqcn: Arc::from(""),
369 }),
370 "self" | "$this" => Union::single(Atomic::TSelf {
371 fqcn: Arc::from(""),
372 }),
373 "parent" => Union::single(Atomic::TParent {
374 fqcn: Arc::from(""),
375 }),
376
377 _ if !s.is_empty()
379 && s.chars()
380 .next()
381 .map(|c| c.is_alphanumeric() || c == '\\' || c == '_')
382 .unwrap_or(false) =>
383 {
384 Union::single(Atomic::TNamedObject {
385 fqcn: normalize_fqcn(s).into(),
386 type_params: vec![],
387 })
388 }
389
390 _ => Union::mixed(),
391 }
392}
393
394fn parse_generic(name: &str, inner: &str) -> Union {
395 match name.to_lowercase().as_str() {
396 "array" => {
397 let params = split_generics(inner);
398 let (key, value) = if params.len() >= 2 {
399 (
400 parse_type_string(params[0].trim()),
401 parse_type_string(params[1].trim()),
402 )
403 } else {
404 (
405 Union::single(Atomic::TInt),
406 parse_type_string(params[0].trim()),
407 )
408 };
409 Union::single(Atomic::TArray {
410 key: Box::new(key),
411 value: Box::new(value),
412 })
413 }
414 "list" | "non-empty-list" => {
415 let value = parse_type_string(inner.trim());
416 if name.to_lowercase().starts_with("non-empty") {
417 Union::single(Atomic::TNonEmptyList {
418 value: Box::new(value),
419 })
420 } else {
421 Union::single(Atomic::TList {
422 value: Box::new(value),
423 })
424 }
425 }
426 "non-empty-array" => {
427 let params = split_generics(inner);
428 let (key, value) = if params.len() >= 2 {
429 (
430 parse_type_string(params[0].trim()),
431 parse_type_string(params[1].trim()),
432 )
433 } else {
434 (
435 Union::single(Atomic::TInt),
436 parse_type_string(params[0].trim()),
437 )
438 };
439 Union::single(Atomic::TNonEmptyArray {
440 key: Box::new(key),
441 value: Box::new(value),
442 })
443 }
444 "iterable" => {
445 let params = split_generics(inner);
446 let value = if params.len() >= 2 {
447 parse_type_string(params[1].trim())
448 } else {
449 parse_type_string(params[0].trim())
450 };
451 Union::single(Atomic::TArray {
452 key: Box::new(Union::single(Atomic::TMixed)),
453 value: Box::new(value),
454 })
455 }
456 "class-string" => Union::single(Atomic::TClassString(Some(
457 normalize_fqcn(inner.trim()).into(),
458 ))),
459 "int" => {
460 Union::single(Atomic::TIntRange {
462 min: None,
463 max: None,
464 })
465 }
466 _ => {
468 let params: Vec<Union> = split_generics(inner)
469 .iter()
470 .map(|p| parse_type_string(p.trim()))
471 .collect();
472 Union::single(Atomic::TNamedObject {
473 fqcn: normalize_fqcn(name).into(),
474 type_params: params,
475 })
476 }
477 }
478}
479
480fn extract_description(text: &str) -> String {
486 let mut desc_lines: Vec<&str> = Vec::new();
487 for line in text.lines() {
488 let l = line.trim();
489 let l = l.trim_start_matches("/**").trim();
490 let l = l.trim_end_matches("*/").trim();
491 let l = l.trim_start_matches("*/").trim();
492 let l = l.strip_prefix("* ").unwrap_or(l.trim_start_matches('*'));
493 let l = l.trim();
494 if l.starts_with('@') {
495 break;
496 }
497 if !l.is_empty() {
498 desc_lines.push(l);
499 }
500 }
501 desc_lines.join(" ")
502}
503
504fn parse_param_line(s: &str) -> Option<(String, String)> {
505 let mut parts = s.splitn(3, char::is_whitespace);
507 let ty = parts.next()?.trim().to_string();
508 let name = parts.next()?.trim().trim_start_matches('$').to_string();
509 if ty.is_empty() || name.is_empty() {
510 return None;
511 }
512 Some((ty, name))
513}
514
515fn split_union(s: &str) -> Vec<String> {
516 let mut parts = Vec::new();
517 let mut depth = 0;
518 let mut current = String::new();
519 for ch in s.chars() {
520 match ch {
521 '<' | '(' | '{' => {
522 depth += 1;
523 current.push(ch);
524 }
525 '>' | ')' | '}' => {
526 depth -= 1;
527 current.push(ch);
528 }
529 '|' if depth == 0 => {
530 parts.push(current.trim().to_string());
531 current = String::new();
532 }
533 _ => current.push(ch),
534 }
535 }
536 if !current.trim().is_empty() {
537 parts.push(current.trim().to_string());
538 }
539 parts
540}
541
542fn split_generics(s: &str) -> Vec<String> {
543 let mut parts = Vec::new();
544 let mut depth = 0;
545 let mut current = String::new();
546 for ch in s.chars() {
547 match ch {
548 '<' | '(' | '{' => {
549 depth += 1;
550 current.push(ch);
551 }
552 '>' | ')' | '}' => {
553 depth -= 1;
554 current.push(ch);
555 }
556 ',' if depth == 0 => {
557 parts.push(current.trim().to_string());
558 current = String::new();
559 }
560 _ => current.push(ch),
561 }
562 }
563 if !current.trim().is_empty() {
564 parts.push(current.trim().to_string());
565 }
566 parts
567}
568
569fn is_inside_generics(s: &str) -> bool {
570 let mut depth = 0i32;
571 for ch in s.chars() {
572 match ch {
573 '<' | '(' | '{' => depth += 1,
574 '>' | ')' | '}' => depth -= 1,
575 _ => {}
576 }
577 }
578 depth != 0
579}
580
581fn normalize_fqcn(s: &str) -> String {
582 s.trim_start_matches('\\').to_string()
584}
585
586fn parse_method_line(s: &str) -> Option<DocMethod> {
588 let mut words = s.splitn(4, char::is_whitespace);
589 let first = words.next()?.trim();
590 if first.is_empty() {
591 return None;
592 }
593 let is_static = first.eq_ignore_ascii_case("static");
594 let (return_type, name_part) = if is_static {
595 let ret = words.next()?.trim().to_string();
596 let nm = words.next()?.trim().to_string();
597 (ret, nm)
598 } else {
599 let second = words
601 .next()
602 .map(|s| s.trim().to_string())
603 .unwrap_or_default();
604 if second.is_empty() {
605 let name = first.split('(').next().unwrap_or(first).to_string();
607 return Some(DocMethod {
608 return_type: String::new(),
609 name,
610 is_static: false,
611 });
612 }
613 if first.contains('(') {
614 let name = first.split('(').next().unwrap_or(first).to_string();
616 return Some(DocMethod {
617 return_type: String::new(),
618 name,
619 is_static: false,
620 });
621 }
622 (first.to_string(), second)
623 };
624 let name = name_part
625 .split('(')
626 .next()
627 .unwrap_or(&name_part)
628 .to_string();
629 Some(DocMethod {
630 return_type,
631 name,
632 is_static,
633 })
634}
635
636#[cfg(test)]
641mod tests {
642 use super::*;
643 use mir_types::Atomic;
644
645 #[test]
646 fn parse_string() {
647 let u = parse_type_string("string");
648 assert_eq!(u.types.len(), 1);
649 assert!(matches!(u.types[0], Atomic::TString));
650 }
651
652 #[test]
653 fn parse_nullable_string() {
654 let u = parse_type_string("?string");
655 assert!(u.is_nullable());
656 assert!(u.contains(|t| matches!(t, Atomic::TString)));
657 }
658
659 #[test]
660 fn parse_union() {
661 let u = parse_type_string("string|int|null");
662 assert!(u.contains(|t| matches!(t, Atomic::TString)));
663 assert!(u.contains(|t| matches!(t, Atomic::TInt)));
664 assert!(u.is_nullable());
665 }
666
667 #[test]
668 fn parse_array_of_string() {
669 let u = parse_type_string("array<string>");
670 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
671 }
672
673 #[test]
674 fn parse_list_of_int() {
675 let u = parse_type_string("list<int>");
676 assert!(u.contains(|t| matches!(t, Atomic::TList { .. })));
677 }
678
679 #[test]
680 fn parse_named_class() {
681 let u = parse_type_string("Foo\\Bar");
682 assert!(u.contains(
683 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Foo\\Bar")
684 ));
685 }
686
687 #[test]
688 fn parse_docblock_param_return() {
689 let doc = r#"/**
690 * @param string $name
691 * @param int $age
692 * @return bool
693 */"#;
694 let parsed = DocblockParser::parse(doc);
695 assert_eq!(parsed.params.len(), 2);
696 assert!(parsed.return_type.is_some());
697 let ret = parsed.return_type.unwrap();
698 assert!(ret.contains(|t| matches!(t, Atomic::TBool)));
699 }
700
701 #[test]
702 fn parse_template() {
703 let doc = "/** @template T of object */";
704 let parsed = DocblockParser::parse(doc);
705 assert_eq!(parsed.templates.len(), 1);
706 assert_eq!(parsed.templates[0].0, "T");
707 assert!(parsed.templates[0].1.is_some());
708 assert_eq!(parsed.templates[0].2, Variance::Invariant);
709 }
710
711 #[test]
712 fn parse_template_covariant() {
713 let doc = "/** @template-covariant T */";
714 let parsed = DocblockParser::parse(doc);
715 assert_eq!(parsed.templates.len(), 1);
716 assert_eq!(parsed.templates[0].0, "T");
717 assert_eq!(parsed.templates[0].2, Variance::Covariant);
718 }
719
720 #[test]
721 fn parse_template_contravariant() {
722 let doc = "/** @template-contravariant T */";
723 let parsed = DocblockParser::parse(doc);
724 assert_eq!(parsed.templates.len(), 1);
725 assert_eq!(parsed.templates[0].0, "T");
726 assert_eq!(parsed.templates[0].2, Variance::Contravariant);
727 }
728
729 #[test]
730 fn parse_deprecated() {
731 let doc = "/** @deprecated use newMethod() instead */";
732 let parsed = DocblockParser::parse(doc);
733 assert!(parsed.is_deprecated);
734 assert_eq!(
735 parsed.deprecated.as_deref(),
736 Some("use newMethod() instead")
737 );
738 }
739
740 #[test]
741 fn parse_description() {
742 let doc = r#"/**
743 * This is a description.
744 * Spans two lines.
745 * @param string $x
746 */"#;
747 let parsed = DocblockParser::parse(doc);
748 assert!(parsed.description.contains("This is a description"));
749 assert!(parsed.description.contains("Spans two lines"));
750 }
751
752 #[test]
753 fn parse_see_and_link() {
754 let doc = "/** @see SomeClass\n * @link https://example.com */";
755 let parsed = DocblockParser::parse(doc);
756 assert_eq!(parsed.see.len(), 2);
757 assert!(parsed.see.contains(&"SomeClass".to_string()));
758 assert!(parsed.see.contains(&"https://example.com".to_string()));
759 }
760
761 #[test]
762 fn parse_mixin() {
763 let doc = "/** @mixin SomeTrait */";
764 let parsed = DocblockParser::parse(doc);
765 assert_eq!(parsed.mixins, vec!["SomeTrait".to_string()]);
766 }
767
768 #[test]
769 fn parse_property_tags() {
770 let doc = r#"/**
771 * @property string $name
772 * @property-read int $id
773 * @property-write bool $active
774 */"#;
775 let parsed = DocblockParser::parse(doc);
776 assert_eq!(parsed.properties.len(), 3);
777 let name_prop = parsed.properties.iter().find(|p| p.name == "name").unwrap();
778 assert_eq!(name_prop.type_hint, "string");
779 assert!(!name_prop.read_only);
780 assert!(!name_prop.write_only);
781 let id_prop = parsed.properties.iter().find(|p| p.name == "id").unwrap();
782 assert!(id_prop.read_only);
783 let active_prop = parsed
784 .properties
785 .iter()
786 .find(|p| p.name == "active")
787 .unwrap();
788 assert!(active_prop.write_only);
789 }
790
791 #[test]
792 fn parse_method_tag() {
793 let doc = r#"/**
794 * @method string getName()
795 * @method static int create()
796 */"#;
797 let parsed = DocblockParser::parse(doc);
798 assert_eq!(parsed.methods.len(), 2);
799 let get_name = parsed.methods.iter().find(|m| m.name == "getName").unwrap();
800 assert_eq!(get_name.return_type, "string");
801 assert!(!get_name.is_static);
802 let create = parsed.methods.iter().find(|m| m.name == "create").unwrap();
803 assert!(create.is_static);
804 }
805
806 #[test]
807 fn parse_type_alias_tag() {
808 let doc = "/** @psalm-type MyAlias = string|int */";
809 let parsed = DocblockParser::parse(doc);
810 assert_eq!(parsed.type_aliases.len(), 1);
811 assert_eq!(parsed.type_aliases[0].name, "MyAlias");
812 assert_eq!(parsed.type_aliases[0].type_expr, "string|int");
813 }
814}