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