1use mir_types::{ArrayKey, Atomic, Union, Variance};
2use std::sync::Arc;
5
6use indexmap::IndexMap;
7
8use php_rs_parser::phpdoc::PhpDocTag;
9
10pub struct DocblockParser;
15
16impl DocblockParser {
17 pub fn parse(text: &str) -> ParsedDocblock {
18 let doc = php_rs_parser::phpdoc::parse(text);
19 let mut result = ParsedDocblock {
20 description: extract_description(text),
21 ..Default::default()
22 };
23
24 for tag in &doc.tags {
25 match tag {
26 PhpDocTag::Param {
27 type_str: Some(ty_s),
28 name: Some(n),
29 ..
30 } => {
31 if let Some(msg) = validate_type_str(ty_s, "param") {
32 result.invalid_annotations.push(msg);
33 }
34 result.params.push((
35 n.trim_start_matches('$').to_string(),
36 parse_type_string(ty_s),
37 ));
38 }
39 PhpDocTag::Param {
42 type_str: Some(ty_s),
43 name: None,
44 ..
45 } => {
46 if let Some(msg) = validate_type_str(ty_s, "param") {
47 result.invalid_annotations.push(msg);
48 }
49 }
50 PhpDocTag::Return {
51 type_str: Some(ty_s),
52 ..
53 } => {
54 if let Some(msg) = validate_type_str(ty_s, "return") {
55 result.invalid_annotations.push(msg);
56 }
57 result.return_type = Some(parse_type_string(ty_s));
58 }
59 PhpDocTag::Var { type_str, name, .. } => {
60 if let Some(ty_s) = type_str {
61 if let Some(msg) = validate_type_str(ty_s, "var") {
62 result.invalid_annotations.push(msg);
63 }
64 result.var_type = Some(parse_type_string(ty_s));
65 }
66 if let Some(n) = name {
67 result.var_name = Some(n.trim_start_matches('$').to_string());
68 }
69 }
70 PhpDocTag::Throws {
71 type_str: Some(ty_s),
72 ..
73 } => {
74 let class = ty_s.split_whitespace().next().unwrap_or("").to_string();
75 if !class.is_empty() {
76 result.throws.push(class);
77 }
78 }
79 PhpDocTag::Deprecated { description } => {
80 result.is_deprecated = true;
81 result.deprecated = Some(
82 description
83 .as_ref()
84 .map(|d| d.to_string())
85 .unwrap_or_default(),
86 );
87 }
88 PhpDocTag::Template { name, bound } => {
89 if let Some(b) = bound {
90 if let Some(msg) = validate_type_str(b, "template") {
91 result.invalid_annotations.push(msg);
92 }
93 }
94 result.templates.push((
95 name.to_string(),
96 bound.map(parse_type_string),
97 Variance::Invariant,
98 ));
99 }
100 PhpDocTag::TemplateCovariant { name, bound } => {
101 if let Some(b) = bound {
102 if let Some(msg) = validate_type_str(b, "template-covariant") {
103 result.invalid_annotations.push(msg);
104 }
105 }
106 result.templates.push((
107 name.to_string(),
108 bound.map(parse_type_string),
109 Variance::Covariant,
110 ));
111 }
112 PhpDocTag::TemplateContravariant { name, bound } => {
113 if let Some(b) = bound {
114 if let Some(msg) = validate_type_str(b, "template-contravariant") {
115 result.invalid_annotations.push(msg);
116 }
117 }
118 result.templates.push((
119 name.to_string(),
120 bound.map(parse_type_string),
121 Variance::Contravariant,
122 ));
123 }
124 PhpDocTag::Extends { type_str } => {
125 result.extends = Some(parse_type_string(type_str));
126 }
127 PhpDocTag::Implements { type_str } => {
128 result.implements.push(parse_type_string(type_str));
129 }
130 PhpDocTag::Assert {
131 type_str: Some(ty_s),
132 name: Some(n),
133 } => {
134 result.assertions.push((
135 n.trim_start_matches('$').to_string(),
136 parse_type_string(ty_s),
137 ));
138 }
139 PhpDocTag::Suppress { rules } => {
140 for rule in rules.split([',', ' ']) {
141 let rule = rule.trim().to_string();
142 if !rule.is_empty() {
143 result.suppressed_issues.push(rule);
144 }
145 }
146 }
147 PhpDocTag::See { reference } => result.see.push(reference.to_string()),
148 PhpDocTag::Link { url } => result.see.push(url.to_string()),
149 PhpDocTag::Mixin { class } => result.mixins.push(class.to_string()),
150 PhpDocTag::Property {
151 type_str,
152 name: Some(n),
153 ..
154 } => result.properties.push(DocProperty {
155 type_hint: type_str.unwrap_or("").to_string(),
156 name: n.trim_start_matches('$').to_string(),
157 read_only: false,
158 write_only: false,
159 }),
160 PhpDocTag::PropertyRead {
161 type_str,
162 name: Some(n),
163 ..
164 } => result.properties.push(DocProperty {
165 type_hint: type_str.unwrap_or("").to_string(),
166 name: n.trim_start_matches('$').to_string(),
167 read_only: true,
168 write_only: false,
169 }),
170 PhpDocTag::PropertyWrite {
171 type_str,
172 name: Some(n),
173 ..
174 } => result.properties.push(DocProperty {
175 type_hint: type_str.unwrap_or("").to_string(),
176 name: n.trim_start_matches('$').to_string(),
177 read_only: false,
178 write_only: true,
179 }),
180 PhpDocTag::Method { signature } => {
181 if let Some(m) = parse_method_line(signature) {
182 result.methods.push(m);
183 }
184 }
185 PhpDocTag::TypeAlias {
186 name: Some(n),
187 type_str,
188 } => result.type_aliases.push(DocTypeAlias {
189 name: n.to_string(),
190 type_expr: type_str.unwrap_or("").to_string(),
191 }),
192 PhpDocTag::ImportType { body } => {
193 if let Some(import) = parse_import_type(body) {
194 result.import_types.push(import);
195 }
196 }
197 PhpDocTag::Since { version } if result.since.is_none() => {
198 let v = version.split_whitespace().next().unwrap_or("");
201 if !v.is_empty() {
202 result.since = Some(v.to_string());
203 }
204 }
205 PhpDocTag::Internal => result.is_internal = true,
206 PhpDocTag::Pure => result.is_pure = true,
207 PhpDocTag::Immutable => result.is_immutable = true,
208 PhpDocTag::Readonly => result.is_readonly = true,
209 PhpDocTag::Generic { tag, body } => match *tag {
210 "inheritDoc" | "inheritdoc" => result.is_inherit_doc = true,
211 "api" | "psalm-api" => result.is_api = true,
212 "removed" if result.removed.is_none() => {
213 if let Some(b) = body {
214 let v = b.split_whitespace().next().unwrap_or("");
215 if !v.is_empty() {
216 result.removed = Some(v.to_string());
217 }
218 }
219 }
220 "psalm-assert" | "phpstan-assert" => {
221 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
222 result.assertions.push((name, parse_type_string(&ty_str)));
223 }
224 }
225 "psalm-assert-if-true" | "phpstan-assert-if-true" => {
226 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
227 result
228 .assertions_if_true
229 .push((name, parse_type_string(&ty_str)));
230 }
231 }
232 "psalm-assert-if-false" | "phpstan-assert-if-false" => {
233 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
234 result
235 .assertions_if_false
236 .push((name, parse_type_string(&ty_str)));
237 }
238 }
239 "psalm-property" => {
240 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
241 result.properties.push(DocProperty {
242 type_hint: ty_str,
243 name,
244 read_only: false,
245 write_only: false,
246 });
247 }
248 }
249 "psalm-property-read" => {
250 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
251 result.properties.push(DocProperty {
252 type_hint: ty_str,
253 name,
254 read_only: true,
255 write_only: false,
256 });
257 }
258 }
259 "psalm-property-write" => {
260 if let Some((ty_str, name)) = body.as_deref().and_then(parse_param_line) {
261 result.properties.push(DocProperty {
262 type_hint: ty_str,
263 name,
264 read_only: false,
265 write_only: true,
266 });
267 }
268 }
269 "psalm-method" => {
270 if let Some(method) = body.as_deref().and_then(parse_method_line) {
271 result.methods.push(method);
272 }
273 }
274 "psalm-require-extends" | "phpstan-require-extends" => {
275 if let Some(b) = body {
276 let cls = b.split_whitespace().next().unwrap_or("").trim().to_string();
277 if !cls.is_empty() {
278 result.require_extends.push(cls);
279 }
280 }
281 }
282 "psalm-require-implements" | "phpstan-require-implements" => {
283 if let Some(b) = body {
284 let cls = b.split_whitespace().next().unwrap_or("").trim().to_string();
285 if !cls.is_empty() {
286 result.require_implements.push(cls);
287 }
288 }
289 }
290 _ => {}
291 },
292 _ => {}
293 }
294 }
295
296 if text.to_ascii_lowercase().contains("{@inheritdoc}") {
297 result.is_inherit_doc = true;
298 }
299
300 result
301 }
302}
303
304#[derive(Debug, Default, Clone)]
309pub struct DocProperty {
310 pub type_hint: String,
311 pub name: String, pub read_only: bool, pub write_only: bool, }
315
316#[derive(Debug, Default, Clone)]
317pub struct DocMethod {
318 pub return_type: String,
319 pub name: String,
320 pub is_static: bool,
321 pub params: Vec<DocMethodParam>,
322}
323
324#[derive(Debug, Default, Clone)]
325pub struct DocMethodParam {
326 pub name: String,
327 pub type_hint: String,
328 pub is_variadic: bool,
329 pub is_byref: bool,
330 pub is_optional: bool,
331}
332
333#[derive(Debug, Default, Clone)]
334pub struct DocTypeAlias {
335 pub name: String,
336 pub type_expr: String,
337}
338
339#[derive(Debug, Default, Clone)]
340pub struct DocImportType {
341 pub original: String,
343 pub local: String,
345 pub from_class: String,
347}
348
349#[derive(Debug, Default, Clone)]
354pub struct ParsedDocblock {
355 pub params: Vec<(String, Union)>,
357 pub return_type: Option<Union>,
359 pub var_type: Option<Union>,
361 pub var_name: Option<String>,
363 pub templates: Vec<(String, Option<Union>, Variance)>,
365 pub extends: Option<Union>,
367 pub implements: Vec<Union>,
369 pub throws: Vec<String>,
371 pub assertions: Vec<(String, Union)>,
373 pub assertions_if_true: Vec<(String, Union)>,
375 pub assertions_if_false: Vec<(String, Union)>,
377 pub suppressed_issues: Vec<String>,
379 pub is_deprecated: bool,
380 pub is_internal: bool,
381 pub is_pure: bool,
382 pub is_immutable: bool,
383 pub is_readonly: bool,
384 pub is_api: bool,
385 pub is_inherit_doc: bool,
388 pub description: String,
390 pub deprecated: Option<String>,
392 pub see: Vec<String>,
394 pub mixins: Vec<String>,
396 pub properties: Vec<DocProperty>,
398 pub methods: Vec<DocMethod>,
400 pub type_aliases: Vec<DocTypeAlias>,
402 pub import_types: Vec<DocImportType>,
404 pub require_extends: Vec<String>,
406 pub require_implements: Vec<String>,
408 pub since: Option<String>,
410 pub removed: Option<String>,
412 pub invalid_annotations: Vec<String>,
414}
415
416impl ParsedDocblock {
417 pub fn get_param_type(&self, name: &str) -> Option<&Union> {
423 let name = name.trim_start_matches('$');
424 self.params
425 .iter()
426 .rfind(|(n, _)| n.trim_start_matches('$') == name)
427 .map(|(_, ty)| ty)
428 }
429}
430
431pub fn parse_type_string(s: &str) -> Union {
439 let s = s.trim();
440
441 if let Some(inner) = s.strip_prefix('?') {
443 let inner_ty = parse_type_string(inner);
444 let mut u = inner_ty;
445 u.add_type(Atomic::TNull);
446 return u;
447 }
448
449 if s.contains('|') && !is_inside_generics(s) {
451 let parts = split_union(s);
452 if parts.len() > 1 {
453 let mut u = Union::empty();
454 for part in parts {
455 for atomic in parse_type_string(&part).types {
456 u.add_type(atomic);
457 }
458 }
459 return u;
460 }
461 }
462
463 if s.contains('&') && !is_inside_generics(s) {
465 let parts: Vec<Union> = s.split('&').map(|p| parse_type_string(p.trim())).collect();
466 return Union::single(Atomic::TIntersection { parts });
467 }
468
469 if let Some(value_str) = s.strip_suffix("[]") {
471 let value = parse_type_string(value_str);
472 return Union::single(Atomic::TArray {
473 key: Box::new(Union::single(Atomic::TInt)),
474 value: Box::new(value),
475 });
476 }
477
478 if let Some(call_ty) = parse_callable_syntax(s) {
480 return call_ty;
481 }
482
483 if s.ends_with('}') {
485 if let Some(open) = s.find('{') {
486 let prefix = s[..open].to_lowercase();
487 let inner = &s[open + 1..s.len() - 1];
488 if prefix == "array" {
489 return parse_keyed_array(inner, false);
490 } else if prefix == "list" {
491 return parse_keyed_array(inner, true);
492 }
493 }
494 }
495
496 if let Some(open) = s.find('<') {
498 if s.ends_with('>') {
499 let name = &s[..open];
500 let inner = &s[open + 1..s.len() - 1];
501 return parse_generic(name, inner);
502 }
503 }
504
505 match s.to_lowercase().as_str() {
507 "string" => Union::single(Atomic::TString),
508 "non-empty-string" => Union::single(Atomic::TNonEmptyString),
509 "numeric-string" => Union::single(Atomic::TNumericString),
510 "class-string" => Union::single(Atomic::TClassString(None)),
511 "int" | "integer" => Union::single(Atomic::TInt),
512 "positive-int" => Union::single(Atomic::TPositiveInt),
513 "negative-int" => Union::single(Atomic::TNegativeInt),
514 "non-negative-int" => Union::single(Atomic::TNonNegativeInt),
515 "float" | "double" => Union::single(Atomic::TFloat),
516 "bool" | "boolean" => Union::single(Atomic::TBool),
517 "true" => Union::single(Atomic::TTrue),
518 "false" => Union::single(Atomic::TFalse),
519 "null" => Union::single(Atomic::TNull),
520 "void" => Union::single(Atomic::TVoid),
521 "never" | "never-return" | "no-return" | "never-returns" => Union::single(Atomic::TNever),
522 "mixed" => Union::single(Atomic::TMixed),
523 "object" => Union::single(Atomic::TObject),
524 "array" => Union::single(Atomic::TArray {
525 key: Box::new(Union::single(Atomic::TMixed)),
526 value: Box::new(Union::mixed()),
527 }),
528 "list" => Union::single(Atomic::TList {
529 value: Box::new(Union::mixed()),
530 }),
531 "callable" => Union::single(Atomic::TCallable {
532 params: None,
533 return_type: None,
534 }),
535 "iterable" => Union::single(Atomic::TArray {
536 key: Box::new(Union::single(Atomic::TMixed)),
537 value: Box::new(Union::mixed()),
538 }),
539 "scalar" => Union::single(Atomic::TScalar),
540 "numeric" => Union::single(Atomic::TNumeric),
541 "resource" => Union::mixed(), "static" => Union::single(Atomic::TStaticObject {
544 fqcn: Arc::from(""),
545 }),
546 "self" | "$this" => Union::single(Atomic::TSelf {
547 fqcn: Arc::from(""),
548 }),
549 "parent" => Union::single(Atomic::TParent {
550 fqcn: Arc::from(""),
551 }),
552
553 _ if !s.is_empty()
555 && s.chars()
556 .next()
557 .map(|c| c.is_alphanumeric() || c == '\\' || c == '_')
558 .unwrap_or(false) =>
559 {
560 Union::single(Atomic::TNamedObject {
561 fqcn: normalize_fqcn(s).into(),
562 type_params: vec![],
563 })
564 }
565
566 _ => Union::mixed(),
567 }
568}
569
570fn parse_generic(name: &str, inner: &str) -> Union {
571 match name.to_lowercase().as_str() {
572 "array" => {
573 let params = split_generics(inner);
574 let (key, value) = if params.len() >= 2 {
575 (
576 parse_type_string(params[0].trim()),
577 parse_type_string(params[1].trim()),
578 )
579 } else {
580 (
581 Union::single(Atomic::TInt),
582 parse_type_string(params[0].trim()),
583 )
584 };
585 Union::single(Atomic::TArray {
586 key: Box::new(key),
587 value: Box::new(value),
588 })
589 }
590 "list" | "non-empty-list" => {
591 let value = parse_type_string(inner.trim());
592 if name.to_lowercase().starts_with("non-empty") {
593 Union::single(Atomic::TNonEmptyList {
594 value: Box::new(value),
595 })
596 } else {
597 Union::single(Atomic::TList {
598 value: Box::new(value),
599 })
600 }
601 }
602 "non-empty-array" => {
603 let params = split_generics(inner);
604 let (key, value) = if params.len() >= 2 {
605 (
606 parse_type_string(params[0].trim()),
607 parse_type_string(params[1].trim()),
608 )
609 } else {
610 (
611 Union::single(Atomic::TInt),
612 parse_type_string(params[0].trim()),
613 )
614 };
615 Union::single(Atomic::TNonEmptyArray {
616 key: Box::new(key),
617 value: Box::new(value),
618 })
619 }
620 "iterable" => {
621 let params = split_generics(inner);
622 let value = if params.len() >= 2 {
623 parse_type_string(params[1].trim())
624 } else {
625 parse_type_string(params[0].trim())
626 };
627 Union::single(Atomic::TArray {
628 key: Box::new(Union::single(Atomic::TMixed)),
629 value: Box::new(value),
630 })
631 }
632 "class-string" => Union::single(Atomic::TClassString(Some(
633 normalize_fqcn(inner.trim()).into(),
634 ))),
635 "int" => {
636 Union::single(Atomic::TIntRange {
638 min: None,
639 max: None,
640 })
641 }
642 _ => {
644 let params: Vec<Union> = split_generics(inner)
645 .iter()
646 .map(|p| parse_type_string(p.trim()))
647 .collect();
648 Union::single(Atomic::TNamedObject {
649 fqcn: normalize_fqcn(name).into(),
650 type_params: params,
651 })
652 }
653 }
654}
655
656fn parse_keyed_array(inner: &str, is_list: bool) -> Union {
657 use mir_types::atomic::KeyedProperty;
658 let mut properties: IndexMap<ArrayKey, KeyedProperty> = IndexMap::new();
659 let mut is_open = false;
660 let mut auto_index = 0i64;
661
662 for item in split_generics(inner) {
663 let item = item.trim();
664 if item.is_empty() {
665 continue;
666 }
667 if item == "..." {
668 is_open = true;
669 continue;
670 }
671 let colon_pos = {
673 let mut depth = 0i32;
674 let mut found = None;
675 for (i, ch) in item.char_indices() {
676 match ch {
677 '<' | '(' | '{' => depth += 1,
678 '>' | ')' | '}' => depth -= 1,
679 ':' if depth == 0 => {
680 found = Some(i);
681 break;
682 }
683 _ => {}
684 }
685 }
686 found
687 };
688 if let Some(colon) = colon_pos {
689 let key_part = item[..colon].trim();
690 let ty_part = item[colon + 1..].trim();
691 let optional = key_part.ends_with('?');
692 let key_str = key_part.trim_end_matches('?').trim();
693 let key = if let Ok(n) = key_str.parse::<i64>() {
694 ArrayKey::Int(n)
695 } else {
696 ArrayKey::String(Arc::from(key_str))
697 };
698 properties.insert(
699 key,
700 KeyedProperty {
701 ty: parse_type_string(ty_part),
702 optional,
703 },
704 );
705 } else {
706 properties.insert(
707 ArrayKey::Int(auto_index),
708 KeyedProperty {
709 ty: parse_type_string(item),
710 optional: false,
711 },
712 );
713 auto_index += 1;
714 }
715 }
716
717 Union::single(Atomic::TKeyedArray {
718 properties,
719 is_open,
720 is_list,
721 })
722}
723
724fn parse_callable_syntax(s: &str) -> Option<Union> {
725 let s = s.trim_start_matches('\\');
726 let lower = s.to_lowercase();
727 let is_closure = lower.starts_with("closure");
728 let is_callable = lower.starts_with("callable");
729 if !is_closure && !is_callable {
730 return None;
731 }
732 let prefix_len = if is_closure {
733 "closure".len()
734 } else {
735 "callable".len()
736 };
737 let rest = s[prefix_len..].trim_start();
738 if !rest.starts_with('(') {
739 return None;
740 }
741 let close = find_matching_paren(rest)?;
742 let params_str = &rest[1..close];
743 let after = rest[close + 1..].trim();
744 let return_type = after
745 .strip_prefix(':')
746 .map(|ret_str| Box::new(parse_type_string(ret_str.trim())));
747 let params: Vec<mir_types::atomic::FnParam> = split_generics(params_str)
748 .into_iter()
749 .enumerate()
750 .filter(|(_, p)| !p.trim().is_empty())
751 .map(|(i, p)| {
752 let p = p.trim();
753 let (ty_str, name) = if let Some(dollar) = p.rfind('$') {
754 (p[..dollar].trim(), p[dollar + 1..].to_string())
755 } else {
756 (p, format!("arg{i}"))
757 };
758 mir_types::atomic::FnParam {
759 name: name.into(),
760 ty: Some(parse_type_string(ty_str)),
761 default: None,
762 is_variadic: false,
763 is_byref: false,
764 is_optional: false,
765 }
766 })
767 .collect();
768 if is_closure {
769 Some(Union::single(Atomic::TClosure {
770 params,
771 return_type: return_type.unwrap_or_else(|| Box::new(Union::single(Atomic::TVoid))),
772 this_type: None,
773 }))
774 } else {
775 Some(Union::single(Atomic::TCallable {
776 params: Some(params),
777 return_type,
778 }))
779 }
780}
781
782fn find_matching_paren(s: &str) -> Option<usize> {
783 if !s.starts_with('(') {
784 return None;
785 }
786 let mut depth = 0i32;
787 for (i, ch) in s.char_indices() {
788 match ch {
789 '(' | '<' | '{' => depth += 1,
790 ')' | '>' | '}' => {
791 depth -= 1;
792 if depth == 0 {
793 return Some(i);
794 }
795 }
796 _ => {}
797 }
798 }
799 None
800}
801
802fn extract_description(text: &str) -> String {
808 let mut desc_lines: Vec<&str> = Vec::new();
809 for line in text.lines() {
810 let l = line.trim();
811 let l = l.trim_start_matches("/**").trim();
812 let l = l.trim_end_matches("*/").trim();
813 let l = l.trim_start_matches("*/").trim();
814 let l = l.strip_prefix("* ").unwrap_or(l.trim_start_matches('*'));
815 let l = l.trim();
816 if l.starts_with('@') {
817 break;
818 }
819 if !l.is_empty() {
820 desc_lines.push(l);
821 }
822 }
823 desc_lines.join(" ")
824}
825
826fn parse_import_type(body: &str) -> Option<DocImportType> {
832 let (before_from, from_class_raw) = body.split_once(" from ")?;
834 let from_class = from_class_raw.trim().trim_start_matches('\\').to_string();
835 if from_class.is_empty() {
836 return None;
837 }
838 let (original, local) = if let Some((orig, loc)) = before_from.split_once(" as ") {
840 (orig.trim().to_string(), loc.trim().to_string())
841 } else {
842 let name = before_from.trim().to_string();
843 (name.clone(), name)
844 };
845 if original.is_empty() || local.is_empty() {
846 return None;
847 }
848 Some(DocImportType {
849 original,
850 local,
851 from_class,
852 })
853}
854
855fn parse_param_line(s: &str) -> Option<(String, String)> {
856 let mut parts = s.splitn(3, char::is_whitespace);
858 let ty = parts.next()?.trim().to_string();
859 let name = parts.next()?.trim().trim_start_matches('$').to_string();
860 if ty.is_empty() || name.is_empty() {
861 return None;
862 }
863 Some((ty, name))
864}
865
866fn split_union(s: &str) -> Vec<String> {
867 let mut parts = Vec::new();
868 let mut depth = 0;
869 let mut current = String::new();
870 for ch in s.chars() {
871 match ch {
872 '<' | '(' | '{' => {
873 depth += 1;
874 current.push(ch);
875 }
876 '>' | ')' | '}' => {
877 depth -= 1;
878 current.push(ch);
879 }
880 '|' if depth == 0 => {
881 parts.push(current.trim().to_string());
882 current = String::new();
883 }
884 _ => current.push(ch),
885 }
886 }
887 if !current.trim().is_empty() {
888 parts.push(current.trim().to_string());
889 }
890 parts
891}
892
893fn split_generics(s: &str) -> Vec<String> {
894 let mut parts = Vec::new();
895 let mut depth = 0;
896 let mut current = String::new();
897 for ch in s.chars() {
898 match ch {
899 '<' | '(' | '{' => {
900 depth += 1;
901 current.push(ch);
902 }
903 '>' | ')' | '}' => {
904 depth -= 1;
905 current.push(ch);
906 }
907 ',' if depth == 0 => {
908 parts.push(current.trim().to_string());
909 current = String::new();
910 }
911 _ => current.push(ch),
912 }
913 }
914 if !current.trim().is_empty() {
915 parts.push(current.trim().to_string());
916 }
917 parts
918}
919
920fn is_inside_generics(s: &str) -> bool {
921 let mut depth = 0i32;
922 for ch in s.chars() {
923 match ch {
924 '<' | '(' | '{' => depth += 1,
925 '>' | ')' | '}' => depth -= 1,
926 _ => {}
927 }
928 }
929 depth != 0
930}
931
932fn normalize_fqcn(s: &str) -> String {
933 s.trim_start_matches('\\').to_string()
935}
936
937fn validate_type_str(s: &str, tag: &str) -> Option<String> {
943 let s = s.trim();
944 if s.is_empty() {
945 return None;
946 }
947 if is_inside_generics(s) {
948 return Some(format!("@{tag} has unclosed generic type `{s}`"));
949 }
950 for part in split_union(s) {
951 let p = part.trim();
952 if p.starts_with('$') && p != "$this" {
953 return Some(format!("@{tag} contains variable `{p}` in type position"));
954 }
955 }
956 None
957}
958
959fn parse_method_line(s: &str) -> Option<DocMethod> {
961 let mut rest = s.trim();
962 if rest.is_empty() {
963 return None;
964 }
965 let is_static = rest
966 .split_whitespace()
967 .next()
968 .map(|w| w.eq_ignore_ascii_case("static"))
969 .unwrap_or(false);
970 if is_static {
971 rest = rest["static".len()..].trim_start();
972 }
973
974 let open = rest.find('(').unwrap_or(rest.len());
975 let prefix = rest[..open].trim();
976 let mut parts: Vec<&str> = prefix.split_whitespace().collect();
977 let name = parts.pop()?.to_string();
978 if name.is_empty() {
979 return None;
980 }
981 let return_type = parts.join(" ");
982 Some(DocMethod {
983 return_type,
984 name,
985 is_static,
986 params: parse_method_params(rest),
987 })
988}
989
990fn parse_method_params(name_part: &str) -> Vec<DocMethodParam> {
991 let Some(open) = name_part.find('(') else {
992 return vec![];
993 };
994 let Some(close) = name_part.rfind(')') else {
995 return vec![];
996 };
997 let inner = name_part[open + 1..close].trim();
998 if inner.is_empty() {
999 return vec![];
1000 }
1001
1002 split_generics(inner)
1003 .into_iter()
1004 .filter_map(|param| parse_method_param(¶m))
1005 .collect()
1006}
1007
1008fn parse_method_param(param: &str) -> Option<DocMethodParam> {
1009 let before_default = param.split('=').next()?.trim();
1010 let is_optional = param.contains('=');
1011 let mut tokens: Vec<&str> = before_default.split_whitespace().collect();
1012 let raw_name = tokens.pop()?;
1013 let is_variadic = raw_name.contains("...");
1014 let is_byref = raw_name.contains('&');
1015 let name = raw_name
1016 .trim_start_matches('&')
1017 .trim_start_matches("...")
1018 .trim_start_matches('&')
1019 .trim_start_matches('$')
1020 .to_string();
1021 if name.is_empty() {
1022 return None;
1023 }
1024 Some(DocMethodParam {
1025 name,
1026 type_hint: tokens.join(" "),
1027 is_variadic,
1028 is_byref,
1029 is_optional: is_optional || is_variadic,
1030 })
1031}
1032
1033#[cfg(test)]
1038mod tests {
1039 use super::*;
1040 use mir_types::Atomic;
1041
1042 #[test]
1043 fn parse_string() {
1044 let u = parse_type_string("string");
1045 assert_eq!(u.types.len(), 1);
1046 assert!(matches!(u.types[0], Atomic::TString));
1047 }
1048
1049 #[test]
1050 fn parse_nullable_string() {
1051 let u = parse_type_string("?string");
1052 assert!(u.is_nullable());
1053 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1054 }
1055
1056 #[test]
1057 fn parse_union() {
1058 let u = parse_type_string("string|int|null");
1059 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1060 assert!(u.contains(|t| matches!(t, Atomic::TInt)));
1061 assert!(u.is_nullable());
1062 }
1063
1064 #[test]
1065 fn parse_array_of_string() {
1066 let u = parse_type_string("array<string>");
1067 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1068 }
1069
1070 #[test]
1071 fn parse_list_of_int() {
1072 let u = parse_type_string("list<int>");
1073 assert!(u.contains(|t| matches!(t, Atomic::TList { .. })));
1074 }
1075
1076 #[test]
1077 fn parse_named_class() {
1078 let u = parse_type_string("Foo\\Bar");
1079 assert!(u.contains(
1080 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Foo\\Bar")
1081 ));
1082 }
1083
1084 #[test]
1085 fn parse_docblock_param_return() {
1086 let doc = r#"/**
1087 * @param string $name
1088 * @param int $age
1089 * @return bool
1090 */"#;
1091 let parsed = DocblockParser::parse(doc);
1092 assert_eq!(parsed.params.len(), 2);
1093 assert!(parsed.return_type.is_some());
1094 let ret = parsed.return_type.unwrap();
1095 assert!(ret.contains(|t| matches!(t, Atomic::TBool)));
1096 }
1097
1098 #[test]
1099 fn parse_template() {
1100 let doc = "/** @template T of object */";
1101 let parsed = DocblockParser::parse(doc);
1102 assert_eq!(parsed.templates.len(), 1);
1103 assert_eq!(parsed.templates[0].0, "T");
1104 assert!(parsed.templates[0].1.is_some());
1105 assert_eq!(parsed.templates[0].2, Variance::Invariant);
1106 }
1107
1108 #[test]
1109 fn parse_template_covariant() {
1110 let doc = "/** @template-covariant T */";
1111 let parsed = DocblockParser::parse(doc);
1112 assert_eq!(parsed.templates.len(), 1);
1113 assert_eq!(parsed.templates[0].0, "T");
1114 assert_eq!(parsed.templates[0].2, Variance::Covariant);
1115 }
1116
1117 #[test]
1118 fn parse_template_contravariant() {
1119 let doc = "/** @template-contravariant T */";
1120 let parsed = DocblockParser::parse(doc);
1121 assert_eq!(parsed.templates.len(), 1);
1122 assert_eq!(parsed.templates[0].0, "T");
1123 assert_eq!(parsed.templates[0].2, Variance::Contravariant);
1124 }
1125
1126 #[test]
1127 fn parse_deprecated() {
1128 let doc = "/** @deprecated use newMethod() instead */";
1129 let parsed = DocblockParser::parse(doc);
1130 assert!(parsed.is_deprecated);
1131 assert_eq!(
1132 parsed.deprecated.as_deref(),
1133 Some("use newMethod() instead")
1134 );
1135 }
1136
1137 #[test]
1138 fn parse_since_plain() {
1139 let parsed = DocblockParser::parse("/** @since 8.0 */");
1140 assert_eq!(parsed.since.as_deref(), Some("8.0"));
1141 assert_eq!(parsed.removed, None);
1142 }
1143
1144 #[test]
1145 fn parse_since_strips_trailing_description() {
1146 let parsed = DocblockParser::parse("/** @since 1.4.0 added \\$options argument */");
1149 assert_eq!(parsed.since.as_deref(), Some("1.4.0"));
1150 }
1151
1152 #[test]
1153 fn parse_removed_tag() {
1154 let parsed = DocblockParser::parse("/** @removed 8.0 use mb_convert_encoding */");
1155 assert_eq!(parsed.removed.as_deref(), Some("8.0"));
1156 }
1157
1158 #[test]
1159 fn parse_since_empty_body_is_none() {
1160 let parsed = DocblockParser::parse("/** @since */");
1161 assert_eq!(parsed.since, None);
1162 }
1163
1164 #[test]
1165 fn parse_description() {
1166 let doc = r#"/**
1167 * This is a description.
1168 * Spans two lines.
1169 * @param string $x
1170 */"#;
1171 let parsed = DocblockParser::parse(doc);
1172 assert!(parsed.description.contains("This is a description"));
1173 assert!(parsed.description.contains("Spans two lines"));
1174 }
1175
1176 #[test]
1177 fn parse_see_and_link() {
1178 let doc = "/** @see SomeClass\n * @link https://example.com */";
1179 let parsed = DocblockParser::parse(doc);
1180 assert_eq!(parsed.see.len(), 2);
1181 assert!(parsed.see.contains(&"SomeClass".to_string()));
1182 assert!(parsed.see.contains(&"https://example.com".to_string()));
1183 }
1184
1185 #[test]
1186 fn parse_mixin() {
1187 let doc = "/** @mixin SomeTrait */";
1188 let parsed = DocblockParser::parse(doc);
1189 assert_eq!(parsed.mixins, vec!["SomeTrait".to_string()]);
1190 }
1191
1192 #[test]
1193 fn parse_property_tags() {
1194 let doc = r#"/**
1195 * @property string $name
1196 * @property-read int $id
1197 * @property-write bool $active
1198 */"#;
1199 let parsed = DocblockParser::parse(doc);
1200 assert_eq!(parsed.properties.len(), 3);
1201 let name_prop = parsed.properties.iter().find(|p| p.name == "name").unwrap();
1202 assert_eq!(name_prop.type_hint, "string");
1203 assert!(!name_prop.read_only);
1204 assert!(!name_prop.write_only);
1205 let id_prop = parsed.properties.iter().find(|p| p.name == "id").unwrap();
1206 assert!(id_prop.read_only);
1207 let active_prop = parsed
1208 .properties
1209 .iter()
1210 .find(|p| p.name == "active")
1211 .unwrap();
1212 assert!(active_prop.write_only);
1213 }
1214
1215 #[test]
1216 fn parse_method_tag() {
1217 let doc = r#"/**
1218 * @method string getName()
1219 * @method static int create()
1220 */"#;
1221 let parsed = DocblockParser::parse(doc);
1222 assert_eq!(parsed.methods.len(), 2);
1223 let get_name = parsed.methods.iter().find(|m| m.name == "getName").unwrap();
1224 assert_eq!(get_name.return_type, "string");
1225 assert!(!get_name.is_static);
1226 let create = parsed.methods.iter().find(|m| m.name == "create").unwrap();
1227 assert!(create.is_static);
1228 }
1229
1230 #[test]
1231 fn parse_type_alias_tag() {
1232 let doc = "/** @psalm-type MyAlias = string|int */";
1233 let parsed = DocblockParser::parse(doc);
1234 assert_eq!(parsed.type_aliases.len(), 1);
1235 assert_eq!(parsed.type_aliases[0].name, "MyAlias");
1236 assert_eq!(parsed.type_aliases[0].type_expr, "string|int");
1237 }
1238
1239 #[test]
1240 fn parse_import_type_no_as() {
1241 let doc = "/** @psalm-import-type UserId from UserRepository */";
1242 let parsed = DocblockParser::parse(doc);
1243 assert_eq!(parsed.import_types.len(), 1);
1244 assert_eq!(parsed.import_types[0].original, "UserId");
1245 assert_eq!(parsed.import_types[0].local, "UserId");
1246 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1247 }
1248
1249 #[test]
1250 fn parse_import_type_with_as() {
1251 let doc = "/** @psalm-import-type UserId as LocalId from UserRepository */";
1252 let parsed = DocblockParser::parse(doc);
1253 assert_eq!(parsed.import_types.len(), 1);
1254 assert_eq!(parsed.import_types[0].original, "UserId");
1255 assert_eq!(parsed.import_types[0].local, "LocalId");
1256 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1257 }
1258
1259 #[test]
1260 fn parse_require_extends() {
1261 let doc = "/** @psalm-require-extends Model */";
1262 let parsed = DocblockParser::parse(doc);
1263 assert_eq!(parsed.require_extends, vec!["Model".to_string()]);
1264 }
1265
1266 #[test]
1267 fn parse_require_implements() {
1268 let doc = "/** @psalm-require-implements Countable */";
1269 let parsed = DocblockParser::parse(doc);
1270 assert_eq!(parsed.require_implements, vec!["Countable".to_string()]);
1271 }
1272
1273 #[test]
1274 fn parse_intersection_two_parts() {
1275 let u = parse_type_string("Iterator&Countable");
1276 assert_eq!(u.types.len(), 1);
1277 assert!(matches!(u.types[0], Atomic::TIntersection { ref parts } if parts.len() == 2));
1278 if let Atomic::TIntersection { parts } = &u.types[0] {
1279 assert!(parts[0].contains(
1280 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1281 ));
1282 assert!(parts[1].contains(
1283 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1284 ));
1285 }
1286 }
1287
1288 #[test]
1289 fn parse_intersection_three_parts() {
1290 let u = parse_type_string("Iterator&Countable&Stringable");
1291 assert_eq!(u.types.len(), 1);
1292 let Atomic::TIntersection { parts } = &u.types[0] else {
1293 panic!("expected TIntersection");
1294 };
1295 assert_eq!(parts.len(), 3);
1296 assert!(parts[0].contains(
1297 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1298 ));
1299 assert!(parts[1].contains(
1300 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1301 ));
1302 assert!(parts[2].contains(
1303 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Stringable")
1304 ));
1305 }
1306
1307 #[test]
1308 fn parse_intersection_in_union_with_null() {
1309 let u = parse_type_string("Iterator&Countable|null");
1310 assert!(u.is_nullable());
1311 let intersection = u
1312 .types
1313 .iter()
1314 .find_map(|t| {
1315 if let Atomic::TIntersection { parts } = t {
1316 Some(parts)
1317 } else {
1318 None
1319 }
1320 })
1321 .expect("expected TIntersection");
1322 assert_eq!(intersection.len(), 2);
1323 assert!(intersection[0].contains(
1324 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1325 ));
1326 assert!(intersection[1].contains(
1327 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1328 ));
1329 }
1330
1331 #[test]
1332 fn parse_intersection_in_union_with_scalar() {
1333 let u = parse_type_string("Iterator&Countable|string");
1334 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1335 let intersection = u
1336 .types
1337 .iter()
1338 .find_map(|t| {
1339 if let Atomic::TIntersection { parts } = t {
1340 Some(parts)
1341 } else {
1342 None
1343 }
1344 })
1345 .expect("expected TIntersection");
1346 assert!(intersection[0].contains(
1347 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1348 ));
1349 assert!(intersection[1].contains(
1350 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1351 ));
1352 }
1353
1354 #[test]
1355 fn validate_unclosed_generic_return() {
1356 let parsed = DocblockParser::parse("/** @return array< */");
1357 assert_eq!(parsed.invalid_annotations.len(), 1);
1358 assert!(
1359 parsed.invalid_annotations[0].contains("unclosed generic"),
1360 "got: {}",
1361 parsed.invalid_annotations[0]
1362 );
1363 }
1364
1365 #[test]
1366 fn validate_variable_in_type_position_param() {
1367 let parsed = DocblockParser::parse("/** @param Foo|$invalid $x */");
1368 assert_eq!(parsed.invalid_annotations.len(), 1);
1369 assert!(
1370 parsed.invalid_annotations[0].contains("$invalid"),
1371 "got: {}",
1372 parsed.invalid_annotations[0]
1373 );
1374 }
1375
1376 #[test]
1377 fn validate_this_is_valid_in_type_position() {
1378 let parsed = DocblockParser::parse("/** @return $this */");
1379 assert!(
1380 parsed.invalid_annotations.is_empty(),
1381 "unexpected error: {:?}",
1382 parsed.invalid_annotations
1383 );
1384 }
1385
1386 #[test]
1387 fn validate_unclosed_generic_var() {
1388 let parsed = DocblockParser::parse("/** @var array<string */");
1389 assert_eq!(parsed.invalid_annotations.len(), 1);
1390 assert!(parsed.invalid_annotations[0].contains("@var"));
1391 }
1392
1393 #[test]
1394 fn validate_variable_in_template_bound() {
1395 let parsed = DocblockParser::parse("/** @template T of $invalid */");
1396 assert_eq!(parsed.invalid_annotations.len(), 1);
1397 assert!(parsed.invalid_annotations[0].contains("$invalid"));
1398 }
1399}