1use mir_types::{Atomic, Union, Variance};
2use std::sync::Arc;
5
6use php_ast::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(description.unwrap_or("").to_string());
60 }
61 PhpDocTag::Template { name, bound } => {
62 result.templates.push((
63 name.to_string(),
64 bound.map(parse_type_string),
65 Variance::Invariant,
66 ));
67 }
68 PhpDocTag::TemplateCovariant { name, bound } => {
69 result.templates.push((
70 name.to_string(),
71 bound.map(parse_type_string),
72 Variance::Covariant,
73 ));
74 }
75 PhpDocTag::TemplateContravariant { name, bound } => {
76 result.templates.push((
77 name.to_string(),
78 bound.map(parse_type_string),
79 Variance::Contravariant,
80 ));
81 }
82 PhpDocTag::Extends { type_str } => {
83 result.extends = Some(type_str.to_string());
84 }
85 PhpDocTag::Implements { type_str } => {
86 result.implements.push(type_str.to_string());
87 }
88 PhpDocTag::Assert {
89 type_str: Some(ty_s),
90 name: Some(n),
91 } => {
92 result.assertions.push((
93 n.trim_start_matches('$').to_string(),
94 parse_type_string(ty_s),
95 ));
96 }
97 PhpDocTag::Suppress { rules } => {
98 for rule in rules.split([',', ' ']) {
99 let rule = rule.trim().to_string();
100 if !rule.is_empty() {
101 result.suppressed_issues.push(rule);
102 }
103 }
104 }
105 PhpDocTag::See { reference } => result.see.push(reference.to_string()),
106 PhpDocTag::Link { url } => result.see.push(url.to_string()),
107 PhpDocTag::Mixin { class } => result.mixins.push(class.to_string()),
108 PhpDocTag::Property {
109 type_str,
110 name: Some(n),
111 ..
112 } => result.properties.push(DocProperty {
113 type_hint: type_str.unwrap_or("").to_string(),
114 name: n.trim_start_matches('$').to_string(),
115 read_only: false,
116 write_only: false,
117 }),
118 PhpDocTag::PropertyRead {
119 type_str,
120 name: Some(n),
121 ..
122 } => result.properties.push(DocProperty {
123 type_hint: type_str.unwrap_or("").to_string(),
124 name: n.trim_start_matches('$').to_string(),
125 read_only: true,
126 write_only: false,
127 }),
128 PhpDocTag::PropertyWrite {
129 type_str,
130 name: Some(n),
131 ..
132 } => result.properties.push(DocProperty {
133 type_hint: type_str.unwrap_or("").to_string(),
134 name: n.trim_start_matches('$').to_string(),
135 read_only: false,
136 write_only: true,
137 }),
138 PhpDocTag::Method { signature } => {
139 if let Some(m) = parse_method_line(signature) {
140 result.methods.push(m);
141 }
142 }
143 PhpDocTag::TypeAlias {
144 name: Some(n),
145 type_str,
146 } => result.type_aliases.push(DocTypeAlias {
147 name: n.to_string(),
148 type_expr: type_str.unwrap_or("").to_string(),
149 }),
150 PhpDocTag::Internal => result.is_internal = true,
151 PhpDocTag::Pure => result.is_pure = true,
152 PhpDocTag::Immutable => result.is_immutable = true,
153 PhpDocTag::Readonly => result.is_readonly = true,
154 PhpDocTag::Generic { tag, body } => match *tag {
155 "api" | "psalm-api" => result.is_api = true,
156 "psalm-assert-if-true" | "phpstan-assert-if-true" => {
157 if let Some((ty_str, name)) = body.and_then(parse_param_line) {
158 result
159 .assertions_if_true
160 .push((name, parse_type_string(&ty_str)));
161 }
162 }
163 "psalm-assert-if-false" | "phpstan-assert-if-false" => {
164 if let Some((ty_str, name)) = body.and_then(parse_param_line) {
165 result
166 .assertions_if_false
167 .push((name, parse_type_string(&ty_str)));
168 }
169 }
170 _ => {}
171 },
172 _ => {}
173 }
174 }
175
176 result
177 }
178}
179
180#[derive(Debug, Default, Clone)]
185pub struct DocProperty {
186 pub type_hint: String,
187 pub name: String, pub read_only: bool, pub write_only: bool, }
191
192#[derive(Debug, Default, Clone)]
193pub struct DocMethod {
194 pub return_type: String,
195 pub name: String,
196 pub is_static: bool,
197}
198
199#[derive(Debug, Default, Clone)]
200pub struct DocTypeAlias {
201 pub name: String,
202 pub type_expr: String,
203}
204
205#[derive(Debug, Default, Clone)]
210pub struct ParsedDocblock {
211 pub params: Vec<(String, Union)>,
213 pub return_type: Option<Union>,
215 pub var_type: Option<Union>,
217 pub var_name: Option<String>,
219 pub templates: Vec<(String, Option<Union>, Variance)>,
221 pub extends: Option<String>,
223 pub implements: Vec<String>,
225 pub throws: Vec<String>,
227 pub assertions: Vec<(String, Union)>,
229 pub assertions_if_true: Vec<(String, Union)>,
231 pub assertions_if_false: Vec<(String, Union)>,
233 pub suppressed_issues: Vec<String>,
235 pub is_deprecated: bool,
236 pub is_internal: bool,
237 pub is_pure: bool,
238 pub is_immutable: bool,
239 pub is_readonly: bool,
240 pub is_api: bool,
241 pub description: String,
243 pub deprecated: Option<String>,
245 pub see: Vec<String>,
247 pub mixins: Vec<String>,
249 pub properties: Vec<DocProperty>,
251 pub methods: Vec<DocMethod>,
253 pub type_aliases: Vec<DocTypeAlias>,
255}
256
257impl ParsedDocblock {
258 pub fn get_param_type(&self, name: &str) -> Option<&Union> {
260 let name = name.trim_start_matches('$');
261 self.params
262 .iter()
263 .find(|(n, _)| n.trim_start_matches('$') == name)
264 .map(|(_, ty)| ty)
265 }
266}
267
268pub fn parse_type_string(s: &str) -> Union {
276 let s = s.trim();
277
278 if let Some(inner) = s.strip_prefix('?') {
280 let inner_ty = parse_type_string(inner);
281 let mut u = inner_ty;
282 u.add_type(Atomic::TNull);
283 return u;
284 }
285
286 if s.contains('|') && !is_inside_generics(s) {
288 let parts = split_union(s);
289 if parts.len() > 1 {
290 let mut u = Union::empty();
291 for part in parts {
292 for atomic in parse_type_string(&part).types {
293 u.add_type(atomic);
294 }
295 }
296 return u;
297 }
298 }
299
300 if s.contains('&') && !is_inside_generics(s) {
302 let first = s.split('&').next().unwrap_or(s);
303 return parse_type_string(first.trim());
304 }
305
306 if let Some(value_str) = s.strip_suffix("[]") {
308 let value = parse_type_string(value_str);
309 return Union::single(Atomic::TArray {
310 key: Box::new(Union::single(Atomic::TInt)),
311 value: Box::new(value),
312 });
313 }
314
315 if let Some(open) = s.find('<') {
317 if s.ends_with('>') {
318 let name = &s[..open];
319 let inner = &s[open + 1..s.len() - 1];
320 return parse_generic(name, inner);
321 }
322 }
323
324 match s.to_lowercase().as_str() {
326 "string" => Union::single(Atomic::TString),
327 "non-empty-string" => Union::single(Atomic::TNonEmptyString),
328 "numeric-string" => Union::single(Atomic::TNumericString),
329 "class-string" => Union::single(Atomic::TClassString(None)),
330 "int" | "integer" => Union::single(Atomic::TInt),
331 "positive-int" => Union::single(Atomic::TPositiveInt),
332 "negative-int" => Union::single(Atomic::TNegativeInt),
333 "non-negative-int" => Union::single(Atomic::TNonNegativeInt),
334 "float" | "double" => Union::single(Atomic::TFloat),
335 "bool" | "boolean" => Union::single(Atomic::TBool),
336 "true" => Union::single(Atomic::TTrue),
337 "false" => Union::single(Atomic::TFalse),
338 "null" => Union::single(Atomic::TNull),
339 "void" => Union::single(Atomic::TVoid),
340 "never" | "never-return" | "no-return" | "never-returns" => Union::single(Atomic::TNever),
341 "mixed" => Union::single(Atomic::TMixed),
342 "object" => Union::single(Atomic::TObject),
343 "array" => Union::single(Atomic::TArray {
344 key: Box::new(Union::single(Atomic::TMixed)),
345 value: Box::new(Union::mixed()),
346 }),
347 "list" => Union::single(Atomic::TList {
348 value: Box::new(Union::mixed()),
349 }),
350 "callable" => Union::single(Atomic::TCallable {
351 params: None,
352 return_type: None,
353 }),
354 "iterable" => Union::single(Atomic::TArray {
355 key: Box::new(Union::single(Atomic::TMixed)),
356 value: Box::new(Union::mixed()),
357 }),
358 "scalar" => Union::single(Atomic::TScalar),
359 "numeric" => Union::single(Atomic::TNumeric),
360 "resource" => Union::mixed(), "static" => Union::single(Atomic::TStaticObject {
363 fqcn: Arc::from(""),
364 }),
365 "self" | "$this" => Union::single(Atomic::TSelf {
366 fqcn: Arc::from(""),
367 }),
368 "parent" => Union::single(Atomic::TParent {
369 fqcn: Arc::from(""),
370 }),
371
372 _ if !s.is_empty()
374 && s.chars()
375 .next()
376 .map(|c| c.is_alphanumeric() || c == '\\' || c == '_')
377 .unwrap_or(false) =>
378 {
379 Union::single(Atomic::TNamedObject {
380 fqcn: normalize_fqcn(s).into(),
381 type_params: vec![],
382 })
383 }
384
385 _ => Union::mixed(),
386 }
387}
388
389fn parse_generic(name: &str, inner: &str) -> Union {
390 match name.to_lowercase().as_str() {
391 "array" => {
392 let params = split_generics(inner);
393 let (key, value) = if params.len() >= 2 {
394 (
395 parse_type_string(params[0].trim()),
396 parse_type_string(params[1].trim()),
397 )
398 } else {
399 (
400 Union::single(Atomic::TInt),
401 parse_type_string(params[0].trim()),
402 )
403 };
404 Union::single(Atomic::TArray {
405 key: Box::new(key),
406 value: Box::new(value),
407 })
408 }
409 "list" | "non-empty-list" => {
410 let value = parse_type_string(inner.trim());
411 if name.to_lowercase().starts_with("non-empty") {
412 Union::single(Atomic::TNonEmptyList {
413 value: Box::new(value),
414 })
415 } else {
416 Union::single(Atomic::TList {
417 value: Box::new(value),
418 })
419 }
420 }
421 "non-empty-array" => {
422 let params = split_generics(inner);
423 let (key, value) = if params.len() >= 2 {
424 (
425 parse_type_string(params[0].trim()),
426 parse_type_string(params[1].trim()),
427 )
428 } else {
429 (
430 Union::single(Atomic::TInt),
431 parse_type_string(params[0].trim()),
432 )
433 };
434 Union::single(Atomic::TNonEmptyArray {
435 key: Box::new(key),
436 value: Box::new(value),
437 })
438 }
439 "iterable" => {
440 let params = split_generics(inner);
441 let value = if params.len() >= 2 {
442 parse_type_string(params[1].trim())
443 } else {
444 parse_type_string(params[0].trim())
445 };
446 Union::single(Atomic::TArray {
447 key: Box::new(Union::single(Atomic::TMixed)),
448 value: Box::new(value),
449 })
450 }
451 "class-string" => Union::single(Atomic::TClassString(Some(
452 normalize_fqcn(inner.trim()).into(),
453 ))),
454 "int" => {
455 Union::single(Atomic::TIntRange {
457 min: None,
458 max: None,
459 })
460 }
461 _ => {
463 let params: Vec<Union> = split_generics(inner)
464 .iter()
465 .map(|p| parse_type_string(p.trim()))
466 .collect();
467 Union::single(Atomic::TNamedObject {
468 fqcn: normalize_fqcn(name).into(),
469 type_params: params,
470 })
471 }
472 }
473}
474
475fn extract_description(text: &str) -> String {
481 let mut desc_lines: Vec<&str> = Vec::new();
482 for line in text.lines() {
483 let l = line.trim();
484 let l = l.trim_start_matches("/**").trim();
485 let l = l.trim_end_matches("*/").trim();
486 let l = l.trim_start_matches("*/").trim();
487 let l = l.strip_prefix("* ").unwrap_or(l.trim_start_matches('*'));
488 let l = l.trim();
489 if l.starts_with('@') {
490 break;
491 }
492 if !l.is_empty() {
493 desc_lines.push(l);
494 }
495 }
496 desc_lines.join(" ")
497}
498
499fn parse_param_line(s: &str) -> Option<(String, String)> {
500 let mut parts = s.splitn(3, char::is_whitespace);
502 let ty = parts.next()?.trim().to_string();
503 let name = parts.next()?.trim().trim_start_matches('$').to_string();
504 if ty.is_empty() || name.is_empty() {
505 return None;
506 }
507 Some((ty, name))
508}
509
510fn split_union(s: &str) -> Vec<String> {
511 let mut parts = Vec::new();
512 let mut depth = 0;
513 let mut current = String::new();
514 for ch in s.chars() {
515 match ch {
516 '<' | '(' | '{' => {
517 depth += 1;
518 current.push(ch);
519 }
520 '>' | ')' | '}' => {
521 depth -= 1;
522 current.push(ch);
523 }
524 '|' if depth == 0 => {
525 parts.push(current.trim().to_string());
526 current = String::new();
527 }
528 _ => current.push(ch),
529 }
530 }
531 if !current.trim().is_empty() {
532 parts.push(current.trim().to_string());
533 }
534 parts
535}
536
537fn split_generics(s: &str) -> Vec<String> {
538 let mut parts = Vec::new();
539 let mut depth = 0;
540 let mut current = String::new();
541 for ch in s.chars() {
542 match ch {
543 '<' | '(' | '{' => {
544 depth += 1;
545 current.push(ch);
546 }
547 '>' | ')' | '}' => {
548 depth -= 1;
549 current.push(ch);
550 }
551 ',' if depth == 0 => {
552 parts.push(current.trim().to_string());
553 current = String::new();
554 }
555 _ => current.push(ch),
556 }
557 }
558 if !current.trim().is_empty() {
559 parts.push(current.trim().to_string());
560 }
561 parts
562}
563
564fn is_inside_generics(s: &str) -> bool {
565 let mut depth = 0i32;
566 for ch in s.chars() {
567 match ch {
568 '<' | '(' | '{' => depth += 1,
569 '>' | ')' | '}' => depth -= 1,
570 _ => {}
571 }
572 }
573 depth != 0
574}
575
576fn normalize_fqcn(s: &str) -> String {
577 s.trim_start_matches('\\').to_string()
579}
580
581fn parse_method_line(s: &str) -> Option<DocMethod> {
583 let mut words = s.splitn(4, char::is_whitespace);
584 let first = words.next()?.trim();
585 if first.is_empty() {
586 return None;
587 }
588 let is_static = first.eq_ignore_ascii_case("static");
589 let (return_type, name_part) = if is_static {
590 let ret = words.next()?.trim().to_string();
591 let nm = words.next()?.trim().to_string();
592 (ret, nm)
593 } else {
594 let second = words
596 .next()
597 .map(|s| s.trim().to_string())
598 .unwrap_or_default();
599 if second.is_empty() {
600 let name = first.split('(').next().unwrap_or(first).to_string();
602 return Some(DocMethod {
603 return_type: String::new(),
604 name,
605 is_static: false,
606 });
607 }
608 if first.contains('(') {
609 let name = first.split('(').next().unwrap_or(first).to_string();
611 return Some(DocMethod {
612 return_type: String::new(),
613 name,
614 is_static: false,
615 });
616 }
617 (first.to_string(), second)
618 };
619 let name = name_part
620 .split('(')
621 .next()
622 .unwrap_or(&name_part)
623 .to_string();
624 Some(DocMethod {
625 return_type,
626 name,
627 is_static,
628 })
629}
630
631#[cfg(test)]
636mod tests {
637 use super::*;
638 use mir_types::Atomic;
639
640 #[test]
641 fn parse_string() {
642 let u = parse_type_string("string");
643 assert_eq!(u.types.len(), 1);
644 assert!(matches!(u.types[0], Atomic::TString));
645 }
646
647 #[test]
648 fn parse_nullable_string() {
649 let u = parse_type_string("?string");
650 assert!(u.is_nullable());
651 assert!(u.contains(|t| matches!(t, Atomic::TString)));
652 }
653
654 #[test]
655 fn parse_union() {
656 let u = parse_type_string("string|int|null");
657 assert!(u.contains(|t| matches!(t, Atomic::TString)));
658 assert!(u.contains(|t| matches!(t, Atomic::TInt)));
659 assert!(u.is_nullable());
660 }
661
662 #[test]
663 fn parse_array_of_string() {
664 let u = parse_type_string("array<string>");
665 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
666 }
667
668 #[test]
669 fn parse_list_of_int() {
670 let u = parse_type_string("list<int>");
671 assert!(u.contains(|t| matches!(t, Atomic::TList { .. })));
672 }
673
674 #[test]
675 fn parse_named_class() {
676 let u = parse_type_string("Foo\\Bar");
677 assert!(u.contains(
678 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Foo\\Bar")
679 ));
680 }
681
682 #[test]
683 fn parse_docblock_param_return() {
684 let doc = r#"/**
685 * @param string $name
686 * @param int $age
687 * @return bool
688 */"#;
689 let parsed = DocblockParser::parse(doc);
690 assert_eq!(parsed.params.len(), 2);
691 assert!(parsed.return_type.is_some());
692 let ret = parsed.return_type.unwrap();
693 assert!(ret.contains(|t| matches!(t, Atomic::TBool)));
694 }
695
696 #[test]
697 fn parse_template() {
698 let doc = "/** @template T of object */";
699 let parsed = DocblockParser::parse(doc);
700 assert_eq!(parsed.templates.len(), 1);
701 assert_eq!(parsed.templates[0].0, "T");
702 assert!(parsed.templates[0].1.is_some());
703 assert_eq!(parsed.templates[0].2, Variance::Invariant);
704 }
705
706 #[test]
707 fn parse_template_covariant() {
708 let doc = "/** @template-covariant T */";
709 let parsed = DocblockParser::parse(doc);
710 assert_eq!(parsed.templates.len(), 1);
711 assert_eq!(parsed.templates[0].0, "T");
712 assert_eq!(parsed.templates[0].2, Variance::Covariant);
713 }
714
715 #[test]
716 fn parse_template_contravariant() {
717 let doc = "/** @template-contravariant T */";
718 let parsed = DocblockParser::parse(doc);
719 assert_eq!(parsed.templates.len(), 1);
720 assert_eq!(parsed.templates[0].0, "T");
721 assert_eq!(parsed.templates[0].2, Variance::Contravariant);
722 }
723
724 #[test]
725 fn parse_deprecated() {
726 let doc = "/** @deprecated use newMethod() instead */";
727 let parsed = DocblockParser::parse(doc);
728 assert!(parsed.is_deprecated);
729 assert_eq!(
730 parsed.deprecated.as_deref(),
731 Some("use newMethod() instead")
732 );
733 }
734
735 #[test]
736 fn parse_description() {
737 let doc = r#"/**
738 * This is a description.
739 * Spans two lines.
740 * @param string $x
741 */"#;
742 let parsed = DocblockParser::parse(doc);
743 assert!(parsed.description.contains("This is a description"));
744 assert!(parsed.description.contains("Spans two lines"));
745 }
746
747 #[test]
748 fn parse_see_and_link() {
749 let doc = "/** @see SomeClass\n * @link https://example.com */";
750 let parsed = DocblockParser::parse(doc);
751 assert_eq!(parsed.see.len(), 2);
752 assert!(parsed.see.contains(&"SomeClass".to_string()));
753 assert!(parsed.see.contains(&"https://example.com".to_string()));
754 }
755
756 #[test]
757 fn parse_mixin() {
758 let doc = "/** @mixin SomeTrait */";
759 let parsed = DocblockParser::parse(doc);
760 assert_eq!(parsed.mixins, vec!["SomeTrait".to_string()]);
761 }
762
763 #[test]
764 fn parse_property_tags() {
765 let doc = r#"/**
766 * @property string $name
767 * @property-read int $id
768 * @property-write bool $active
769 */"#;
770 let parsed = DocblockParser::parse(doc);
771 assert_eq!(parsed.properties.len(), 3);
772 let name_prop = parsed.properties.iter().find(|p| p.name == "name").unwrap();
773 assert_eq!(name_prop.type_hint, "string");
774 assert!(!name_prop.read_only);
775 assert!(!name_prop.write_only);
776 let id_prop = parsed.properties.iter().find(|p| p.name == "id").unwrap();
777 assert!(id_prop.read_only);
778 let active_prop = parsed
779 .properties
780 .iter()
781 .find(|p| p.name == "active")
782 .unwrap();
783 assert!(active_prop.write_only);
784 }
785
786 #[test]
787 fn parse_method_tag() {
788 let doc = r#"/**
789 * @method string getName()
790 * @method static int create()
791 */"#;
792 let parsed = DocblockParser::parse(doc);
793 assert_eq!(parsed.methods.len(), 2);
794 let get_name = parsed.methods.iter().find(|m| m.name == "getName").unwrap();
795 assert_eq!(get_name.return_type, "string");
796 assert!(!get_name.is_static);
797 let create = parsed.methods.iter().find(|m| m.name == "create").unwrap();
798 assert!(create.is_static);
799 }
800
801 #[test]
802 fn parse_type_alias_tag() {
803 let doc = "/** @psalm-type MyAlias = string|int */";
804 let parsed = DocblockParser::parse(doc);
805 assert_eq!(parsed.type_aliases.len(), 1);
806 assert_eq!(parsed.type_aliases[0].name, "MyAlias");
807 assert_eq!(parsed.type_aliases[0].type_expr, "string|int");
808 }
809}