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.starts_with('(') && s.ends_with(')') {
451 let inner = s[1..s.len() - 1].trim();
452 if let Some(conditional) = parse_conditional_type(inner) {
453 return conditional;
454 }
455 }
456
457 if s.contains('|') && !is_inside_generics(s) {
459 let parts = split_union(s);
460 if parts.len() > 1 {
461 let mut u = Union::empty();
462 for part in parts {
463 for atomic in parse_type_string(&part).types {
464 u.add_type(atomic);
465 }
466 }
467 return u;
468 }
469 }
470
471 if s.contains('&') && !is_inside_generics(s) {
473 let parts: Vec<Union> = s.split('&').map(|p| parse_type_string(p.trim())).collect();
474 return Union::single(Atomic::TIntersection { parts });
475 }
476
477 if let Some(value_str) = s.strip_suffix("[]") {
479 let value = parse_type_string(value_str);
480 return Union::single(Atomic::TArray {
481 key: Box::new(Union::single(Atomic::TInt)),
482 value: Box::new(value),
483 });
484 }
485
486 if let Some(call_ty) = parse_callable_syntax(s) {
488 return call_ty;
489 }
490
491 if s.ends_with('}') {
493 if let Some(open) = s.find('{') {
494 let prefix = s[..open].to_lowercase();
495 let inner = &s[open + 1..s.len() - 1];
496 if prefix == "array" {
497 return parse_keyed_array(inner, false);
498 } else if prefix == "list" {
499 return parse_keyed_array(inner, true);
500 }
501 }
502 }
503
504 if let Some(open) = s.find('<') {
506 if s.ends_with('>') {
507 let name = &s[..open];
508 let inner = &s[open + 1..s.len() - 1];
509 return parse_generic(name, inner);
510 }
511 }
512
513 match s.to_lowercase().as_str() {
515 "string" => Union::single(Atomic::TString),
516 "non-empty-string" => Union::single(Atomic::TNonEmptyString),
517 "numeric-string" => Union::single(Atomic::TNumericString),
518 "class-string" => Union::single(Atomic::TClassString(None)),
519 "int" | "integer" => Union::single(Atomic::TInt),
520 "positive-int" => Union::single(Atomic::TPositiveInt),
521 "negative-int" => Union::single(Atomic::TNegativeInt),
522 "non-negative-int" => Union::single(Atomic::TNonNegativeInt),
523 "float" | "double" => Union::single(Atomic::TFloat),
524 "bool" | "boolean" => Union::single(Atomic::TBool),
525 "true" => Union::single(Atomic::TTrue),
526 "false" => Union::single(Atomic::TFalse),
527 "null" => Union::single(Atomic::TNull),
528 "void" => Union::single(Atomic::TVoid),
529 "never" | "never-return" | "no-return" | "never-returns" => Union::single(Atomic::TNever),
530 "mixed" => Union::single(Atomic::TMixed),
531 "object" => Union::single(Atomic::TObject),
532 "array" => Union::single(Atomic::TArray {
533 key: Box::new(Union::single(Atomic::TMixed)),
534 value: Box::new(Union::mixed()),
535 }),
536 "list" => Union::single(Atomic::TList {
537 value: Box::new(Union::mixed()),
538 }),
539 "callable" => Union::single(Atomic::TCallable {
540 params: None,
541 return_type: None,
542 }),
543 "iterable" => Union::single(Atomic::TArray {
544 key: Box::new(Union::single(Atomic::TMixed)),
545 value: Box::new(Union::mixed()),
546 }),
547 "scalar" => Union::single(Atomic::TScalar),
548 "numeric" => Union::single(Atomic::TNumeric),
549 "resource" => Union::mixed(), "static" => Union::single(Atomic::TStaticObject {
552 fqcn: Arc::from(""),
553 }),
554 "self" | "$this" => Union::single(Atomic::TSelf {
555 fqcn: Arc::from(""),
556 }),
557 "parent" => Union::single(Atomic::TParent {
558 fqcn: Arc::from(""),
559 }),
560
561 _ if !s.is_empty()
563 && s.chars()
564 .next()
565 .map(|c| c.is_alphanumeric() || c == '\\' || c == '_')
566 .unwrap_or(false) =>
567 {
568 Union::single(Atomic::TNamedObject {
569 fqcn: normalize_fqcn(s).into(),
570 type_params: vec![],
571 })
572 }
573
574 _ => Union::mixed(),
575 }
576}
577
578fn parse_generic(name: &str, inner: &str) -> Union {
579 match name.to_lowercase().as_str() {
580 "array" => {
581 let params = split_generics(inner);
582 let (key, value) = if params.len() >= 2 {
583 (
584 parse_type_string(params[0].trim()),
585 parse_type_string(params[1].trim()),
586 )
587 } else {
588 (
589 Union::single(Atomic::TInt),
590 parse_type_string(params[0].trim()),
591 )
592 };
593 Union::single(Atomic::TArray {
594 key: Box::new(key),
595 value: Box::new(value),
596 })
597 }
598 "list" | "non-empty-list" => {
599 let value = parse_type_string(inner.trim());
600 if name.to_lowercase().starts_with("non-empty") {
601 Union::single(Atomic::TNonEmptyList {
602 value: Box::new(value),
603 })
604 } else {
605 Union::single(Atomic::TList {
606 value: Box::new(value),
607 })
608 }
609 }
610 "non-empty-array" => {
611 let params = split_generics(inner);
612 let (key, value) = if params.len() >= 2 {
613 (
614 parse_type_string(params[0].trim()),
615 parse_type_string(params[1].trim()),
616 )
617 } else {
618 (
619 Union::single(Atomic::TInt),
620 parse_type_string(params[0].trim()),
621 )
622 };
623 Union::single(Atomic::TNonEmptyArray {
624 key: Box::new(key),
625 value: Box::new(value),
626 })
627 }
628 "iterable" => {
629 let params = split_generics(inner);
630 let value = if params.len() >= 2 {
631 parse_type_string(params[1].trim())
632 } else {
633 parse_type_string(params[0].trim())
634 };
635 Union::single(Atomic::TArray {
636 key: Box::new(Union::single(Atomic::TMixed)),
637 value: Box::new(value),
638 })
639 }
640 "class-string" => Union::single(Atomic::TClassString(Some(
641 normalize_fqcn(inner.trim()).into(),
642 ))),
643 "int" => {
644 Union::single(Atomic::TIntRange {
646 min: None,
647 max: None,
648 })
649 }
650 _ => {
652 let params: Vec<Union> = split_generics(inner)
653 .iter()
654 .map(|p| parse_type_string(p.trim()))
655 .collect();
656 Union::single(Atomic::TNamedObject {
657 fqcn: normalize_fqcn(name).into(),
658 type_params: params,
659 })
660 }
661 }
662}
663
664fn parse_keyed_array(inner: &str, is_list: bool) -> Union {
665 use mir_types::atomic::KeyedProperty;
666 let mut properties: IndexMap<ArrayKey, KeyedProperty> = IndexMap::new();
667 let mut is_open = false;
668 let mut auto_index = 0i64;
669
670 for item in split_generics(inner) {
671 let item = item.trim();
672 if item.is_empty() {
673 continue;
674 }
675 if item == "..." {
676 is_open = true;
677 continue;
678 }
679 let colon_pos = {
681 let mut depth = 0i32;
682 let mut found = None;
683 for (i, ch) in item.char_indices() {
684 match ch {
685 '<' | '(' | '{' => depth += 1,
686 '>' | ')' | '}' => depth -= 1,
687 ':' if depth == 0 => {
688 found = Some(i);
689 break;
690 }
691 _ => {}
692 }
693 }
694 found
695 };
696 if let Some(colon) = colon_pos {
697 let key_part = item[..colon].trim();
698 let ty_part = item[colon + 1..].trim();
699 let optional = key_part.ends_with('?');
700 let key_str = key_part.trim_end_matches('?').trim();
701 let key = if let Ok(n) = key_str.parse::<i64>() {
702 ArrayKey::Int(n)
703 } else {
704 ArrayKey::String(Arc::from(key_str))
705 };
706 properties.insert(
707 key,
708 KeyedProperty {
709 ty: parse_type_string(ty_part),
710 optional,
711 },
712 );
713 } else {
714 properties.insert(
715 ArrayKey::Int(auto_index),
716 KeyedProperty {
717 ty: parse_type_string(item),
718 optional: false,
719 },
720 );
721 auto_index += 1;
722 }
723 }
724
725 Union::single(Atomic::TKeyedArray {
726 properties,
727 is_open,
728 is_list,
729 })
730}
731
732fn parse_callable_syntax(s: &str) -> Option<Union> {
733 let s = s.trim_start_matches('\\');
734 let lower = s.to_lowercase();
735 let is_closure = lower.starts_with("closure");
736 let is_callable = lower.starts_with("callable");
737 if !is_closure && !is_callable {
738 return None;
739 }
740 let prefix_len = if is_closure {
741 "closure".len()
742 } else {
743 "callable".len()
744 };
745 let rest = s[prefix_len..].trim_start();
746 if !rest.starts_with('(') {
747 return None;
748 }
749 let close = find_matching_paren(rest)?;
750 let params_str = &rest[1..close];
751 let after = rest[close + 1..].trim();
752 let return_type = after
753 .strip_prefix(':')
754 .map(|ret_str| Box::new(parse_type_string(ret_str.trim())));
755 let params: Vec<mir_types::atomic::FnParam> = split_generics(params_str)
756 .into_iter()
757 .enumerate()
758 .filter(|(_, p)| !p.trim().is_empty())
759 .map(|(i, p)| {
760 let p = p.trim();
761 let (ty_str, name) = if let Some(dollar) = p.rfind('$') {
762 (p[..dollar].trim(), p[dollar + 1..].to_string())
763 } else {
764 (p, format!("arg{i}"))
765 };
766 mir_types::atomic::FnParam {
767 name: name.into(),
768 ty: Some(parse_type_string(ty_str)),
769 default: None,
770 is_variadic: false,
771 is_byref: false,
772 is_optional: false,
773 }
774 })
775 .collect();
776 if is_closure {
777 Some(Union::single(Atomic::TClosure {
778 params,
779 return_type: return_type.unwrap_or_else(|| Box::new(Union::single(Atomic::TVoid))),
780 this_type: None,
781 }))
782 } else {
783 Some(Union::single(Atomic::TCallable {
784 params: Some(params),
785 return_type,
786 }))
787 }
788}
789
790fn find_matching_paren(s: &str) -> Option<usize> {
791 if !s.starts_with('(') {
792 return None;
793 }
794 let mut depth = 0i32;
795 for (i, ch) in s.char_indices() {
796 match ch {
797 '(' | '<' | '{' => depth += 1,
798 ')' | '>' | '}' => {
799 depth -= 1;
800 if depth == 0 {
801 return Some(i);
802 }
803 }
804 _ => {}
805 }
806 }
807 None
808}
809
810fn extract_description(text: &str) -> String {
816 let mut desc_lines: Vec<&str> = Vec::new();
817 for line in text.lines() {
818 let l = line.trim();
819 let l = l.trim_start_matches("/**").trim();
820 let l = l.trim_end_matches("*/").trim();
821 let l = l.trim_start_matches("*/").trim();
822 let l = l.strip_prefix("* ").unwrap_or(l.trim_start_matches('*'));
823 let l = l.trim();
824 if l.starts_with('@') {
825 break;
826 }
827 if !l.is_empty() {
828 desc_lines.push(l);
829 }
830 }
831 desc_lines.join(" ")
832}
833
834fn parse_import_type(body: &str) -> Option<DocImportType> {
840 let (before_from, from_class_raw) = body.split_once(" from ")?;
842 let from_class = from_class_raw.trim().trim_start_matches('\\').to_string();
843 if from_class.is_empty() {
844 return None;
845 }
846 let (original, local) = if let Some((orig, loc)) = before_from.split_once(" as ") {
848 (orig.trim().to_string(), loc.trim().to_string())
849 } else {
850 let name = before_from.trim().to_string();
851 (name.clone(), name)
852 };
853 if original.is_empty() || local.is_empty() {
854 return None;
855 }
856 Some(DocImportType {
857 original,
858 local,
859 from_class,
860 })
861}
862
863fn parse_param_line(s: &str) -> Option<(String, String)> {
864 let mut parts = s.splitn(3, char::is_whitespace);
866 let ty = parts.next()?.trim().to_string();
867 let name = parts.next()?.trim().trim_start_matches('$').to_string();
868 if ty.is_empty() || name.is_empty() {
869 return None;
870 }
871 Some((ty, name))
872}
873
874fn split_union(s: &str) -> Vec<String> {
875 let mut parts = Vec::new();
876 let mut depth = 0;
877 let mut current = String::new();
878 for ch in s.chars() {
879 match ch {
880 '<' | '(' | '{' => {
881 depth += 1;
882 current.push(ch);
883 }
884 '>' | ')' | '}' => {
885 depth -= 1;
886 current.push(ch);
887 }
888 '|' if depth == 0 => {
889 parts.push(current.trim().to_string());
890 current = String::new();
891 }
892 _ => current.push(ch),
893 }
894 }
895 if !current.trim().is_empty() {
896 parts.push(current.trim().to_string());
897 }
898 parts
899}
900
901fn split_generics(s: &str) -> Vec<String> {
902 let mut parts = Vec::new();
903 let mut depth = 0;
904 let mut current = String::new();
905 for ch in s.chars() {
906 match ch {
907 '<' | '(' | '{' => {
908 depth += 1;
909 current.push(ch);
910 }
911 '>' | ')' | '}' => {
912 depth -= 1;
913 current.push(ch);
914 }
915 ',' if depth == 0 => {
916 parts.push(current.trim().to_string());
917 current = String::new();
918 }
919 _ => current.push(ch),
920 }
921 }
922 if !current.trim().is_empty() {
923 parts.push(current.trim().to_string());
924 }
925 parts
926}
927
928fn is_inside_generics(s: &str) -> bool {
929 let mut depth = 0i32;
930 for ch in s.chars() {
931 match ch {
932 '<' | '(' | '{' => depth += 1,
933 '>' | ')' | '}' => depth -= 1,
934 _ => {}
935 }
936 }
937 depth != 0
938}
939
940fn parse_conditional_type(s: &str) -> Option<Union> {
942 if !s.starts_with('$') {
943 return None;
944 }
945 let is_pos = s.find(" is ")?;
946 let after_is = s[is_pos + 4..].trim();
947 let q_pos = find_char_at_depth(after_is, '?')?;
948 let subject_str = after_is[..q_pos].trim();
949 let rest = after_is[q_pos + 1..].trim();
950 let colon_pos = find_char_at_depth(rest, ':')?;
951 let true_str = rest[..colon_pos].trim();
952 let false_str = rest[colon_pos + 1..].trim();
953 Some(Union::single(Atomic::TConditional {
954 subject: Box::new(parse_type_string(subject_str)),
955 if_true: Box::new(parse_type_string(true_str)),
956 if_false: Box::new(parse_type_string(false_str)),
957 }))
958}
959
960fn find_char_at_depth(s: &str, target: char) -> Option<usize> {
962 let mut depth = 0i32;
963 for (i, ch) in s.char_indices() {
964 match ch {
965 '<' | '(' | '{' => depth += 1,
966 '>' | ')' | '}' => depth -= 1,
967 _ if ch == target && depth == 0 => return Some(i),
968 _ => {}
969 }
970 }
971 None
972}
973
974fn normalize_fqcn(s: &str) -> String {
975 s.trim_start_matches('\\').to_string()
977}
978
979fn validate_type_str(s: &str, tag: &str) -> Option<String> {
985 let s = s.trim();
986 if s.is_empty() {
987 return None;
988 }
989 if is_inside_generics(s) {
990 return Some(format!("@{tag} has unclosed generic type `{s}`"));
991 }
992 for part in split_union(s) {
993 let p = part.trim();
994 if p.starts_with('$') && p != "$this" {
995 return Some(format!("@{tag} contains variable `{p}` in type position"));
996 }
997 }
998 None
999}
1000
1001fn parse_method_line(s: &str) -> Option<DocMethod> {
1003 let mut rest = s.trim();
1004 if rest.is_empty() {
1005 return None;
1006 }
1007 let is_static = rest
1008 .split_whitespace()
1009 .next()
1010 .map(|w| w.eq_ignore_ascii_case("static"))
1011 .unwrap_or(false);
1012 if is_static {
1013 rest = rest["static".len()..].trim_start();
1014 }
1015
1016 let open = rest.find('(').unwrap_or(rest.len());
1017 let prefix = rest[..open].trim();
1018 let mut parts: Vec<&str> = prefix.split_whitespace().collect();
1019 let name = parts.pop()?.to_string();
1020 if name.is_empty() {
1021 return None;
1022 }
1023 let return_type = parts.join(" ");
1024 Some(DocMethod {
1025 return_type,
1026 name,
1027 is_static,
1028 params: parse_method_params(rest),
1029 })
1030}
1031
1032fn parse_method_params(name_part: &str) -> Vec<DocMethodParam> {
1033 let Some(open) = name_part.find('(') else {
1034 return vec![];
1035 };
1036 let Some(close) = name_part.rfind(')') else {
1037 return vec![];
1038 };
1039 let inner = name_part[open + 1..close].trim();
1040 if inner.is_empty() {
1041 return vec![];
1042 }
1043
1044 split_generics(inner)
1045 .into_iter()
1046 .filter_map(|param| parse_method_param(¶m))
1047 .collect()
1048}
1049
1050fn parse_method_param(param: &str) -> Option<DocMethodParam> {
1051 let before_default = param.split('=').next()?.trim();
1052 let is_optional = param.contains('=');
1053 let mut tokens: Vec<&str> = before_default.split_whitespace().collect();
1054 let raw_name = tokens.pop()?;
1055 let is_variadic = raw_name.contains("...");
1056 let is_byref = raw_name.contains('&');
1057 let name = raw_name
1058 .trim_start_matches('&')
1059 .trim_start_matches("...")
1060 .trim_start_matches('&')
1061 .trim_start_matches('$')
1062 .to_string();
1063 if name.is_empty() {
1064 return None;
1065 }
1066 Some(DocMethodParam {
1067 name,
1068 type_hint: tokens.join(" "),
1069 is_variadic,
1070 is_byref,
1071 is_optional: is_optional || is_variadic,
1072 })
1073}
1074
1075#[cfg(test)]
1080mod tests {
1081 use super::*;
1082 use mir_types::Atomic;
1083
1084 #[test]
1085 fn parse_string() {
1086 let u = parse_type_string("string");
1087 assert_eq!(u.types.len(), 1);
1088 assert!(matches!(u.types[0], Atomic::TString));
1089 }
1090
1091 #[test]
1092 fn parse_nullable_string() {
1093 let u = parse_type_string("?string");
1094 assert!(u.is_nullable());
1095 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1096 }
1097
1098 #[test]
1099 fn parse_union() {
1100 let u = parse_type_string("string|int|null");
1101 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1102 assert!(u.contains(|t| matches!(t, Atomic::TInt)));
1103 assert!(u.is_nullable());
1104 }
1105
1106 #[test]
1107 fn parse_array_of_string() {
1108 let u = parse_type_string("array<string>");
1109 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1110 }
1111
1112 #[test]
1113 fn parse_list_of_int() {
1114 let u = parse_type_string("list<int>");
1115 assert!(u.contains(|t| matches!(t, Atomic::TList { .. })));
1116 }
1117
1118 #[test]
1119 fn parse_named_class() {
1120 let u = parse_type_string("Foo\\Bar");
1121 assert!(u.contains(
1122 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Foo\\Bar")
1123 ));
1124 }
1125
1126 #[test]
1127 fn parse_docblock_param_return() {
1128 let doc = r#"/**
1129 * @param string $name
1130 * @param int $age
1131 * @return bool
1132 */"#;
1133 let parsed = DocblockParser::parse(doc);
1134 assert_eq!(parsed.params.len(), 2);
1135 assert!(parsed.return_type.is_some());
1136 let ret = parsed.return_type.unwrap();
1137 assert!(ret.contains(|t| matches!(t, Atomic::TBool)));
1138 }
1139
1140 #[test]
1141 fn parse_template() {
1142 let doc = "/** @template T of object */";
1143 let parsed = DocblockParser::parse(doc);
1144 assert_eq!(parsed.templates.len(), 1);
1145 assert_eq!(parsed.templates[0].0, "T");
1146 assert!(parsed.templates[0].1.is_some());
1147 assert_eq!(parsed.templates[0].2, Variance::Invariant);
1148 }
1149
1150 #[test]
1151 fn parse_template_covariant() {
1152 let doc = "/** @template-covariant T */";
1153 let parsed = DocblockParser::parse(doc);
1154 assert_eq!(parsed.templates.len(), 1);
1155 assert_eq!(parsed.templates[0].0, "T");
1156 assert_eq!(parsed.templates[0].2, Variance::Covariant);
1157 }
1158
1159 #[test]
1160 fn parse_template_contravariant() {
1161 let doc = "/** @template-contravariant T */";
1162 let parsed = DocblockParser::parse(doc);
1163 assert_eq!(parsed.templates.len(), 1);
1164 assert_eq!(parsed.templates[0].0, "T");
1165 assert_eq!(parsed.templates[0].2, Variance::Contravariant);
1166 }
1167
1168 #[test]
1169 fn parse_deprecated() {
1170 let doc = "/** @deprecated use newMethod() instead */";
1171 let parsed = DocblockParser::parse(doc);
1172 assert!(parsed.is_deprecated);
1173 assert_eq!(
1174 parsed.deprecated.as_deref(),
1175 Some("use newMethod() instead")
1176 );
1177 }
1178
1179 #[test]
1180 fn parse_since_plain() {
1181 let parsed = DocblockParser::parse("/** @since 8.0 */");
1182 assert_eq!(parsed.since.as_deref(), Some("8.0"));
1183 assert_eq!(parsed.removed, None);
1184 }
1185
1186 #[test]
1187 fn parse_since_strips_trailing_description() {
1188 let parsed = DocblockParser::parse("/** @since 1.4.0 added \\$options argument */");
1191 assert_eq!(parsed.since.as_deref(), Some("1.4.0"));
1192 }
1193
1194 #[test]
1195 fn parse_removed_tag() {
1196 let parsed = DocblockParser::parse("/** @removed 8.0 use mb_convert_encoding */");
1197 assert_eq!(parsed.removed.as_deref(), Some("8.0"));
1198 }
1199
1200 #[test]
1201 fn parse_since_empty_body_is_none() {
1202 let parsed = DocblockParser::parse("/** @since */");
1203 assert_eq!(parsed.since, None);
1204 }
1205
1206 #[test]
1207 fn parse_description() {
1208 let doc = r#"/**
1209 * This is a description.
1210 * Spans two lines.
1211 * @param string $x
1212 */"#;
1213 let parsed = DocblockParser::parse(doc);
1214 assert!(parsed.description.contains("This is a description"));
1215 assert!(parsed.description.contains("Spans two lines"));
1216 }
1217
1218 #[test]
1219 fn parse_see_and_link() {
1220 let doc = "/** @see SomeClass\n * @link https://example.com */";
1221 let parsed = DocblockParser::parse(doc);
1222 assert_eq!(parsed.see.len(), 2);
1223 assert!(parsed.see.contains(&"SomeClass".to_string()));
1224 assert!(parsed.see.contains(&"https://example.com".to_string()));
1225 }
1226
1227 #[test]
1228 fn parse_mixin() {
1229 let doc = "/** @mixin SomeTrait */";
1230 let parsed = DocblockParser::parse(doc);
1231 assert_eq!(parsed.mixins, vec!["SomeTrait".to_string()]);
1232 }
1233
1234 #[test]
1235 fn parse_property_tags() {
1236 let doc = r#"/**
1237 * @property string $name
1238 * @property-read int $id
1239 * @property-write bool $active
1240 */"#;
1241 let parsed = DocblockParser::parse(doc);
1242 assert_eq!(parsed.properties.len(), 3);
1243 let name_prop = parsed.properties.iter().find(|p| p.name == "name").unwrap();
1244 assert_eq!(name_prop.type_hint, "string");
1245 assert!(!name_prop.read_only);
1246 assert!(!name_prop.write_only);
1247 let id_prop = parsed.properties.iter().find(|p| p.name == "id").unwrap();
1248 assert!(id_prop.read_only);
1249 let active_prop = parsed
1250 .properties
1251 .iter()
1252 .find(|p| p.name == "active")
1253 .unwrap();
1254 assert!(active_prop.write_only);
1255 }
1256
1257 #[test]
1258 fn parse_method_tag() {
1259 let doc = r#"/**
1260 * @method string getName()
1261 * @method static int create()
1262 */"#;
1263 let parsed = DocblockParser::parse(doc);
1264 assert_eq!(parsed.methods.len(), 2);
1265 let get_name = parsed.methods.iter().find(|m| m.name == "getName").unwrap();
1266 assert_eq!(get_name.return_type, "string");
1267 assert!(!get_name.is_static);
1268 let create = parsed.methods.iter().find(|m| m.name == "create").unwrap();
1269 assert!(create.is_static);
1270 }
1271
1272 #[test]
1273 fn parse_type_alias_tag() {
1274 let doc = "/** @psalm-type MyAlias = string|int */";
1275 let parsed = DocblockParser::parse(doc);
1276 assert_eq!(parsed.type_aliases.len(), 1);
1277 assert_eq!(parsed.type_aliases[0].name, "MyAlias");
1278 assert_eq!(parsed.type_aliases[0].type_expr, "string|int");
1279 }
1280
1281 #[test]
1282 fn parse_import_type_no_as() {
1283 let doc = "/** @psalm-import-type UserId from UserRepository */";
1284 let parsed = DocblockParser::parse(doc);
1285 assert_eq!(parsed.import_types.len(), 1);
1286 assert_eq!(parsed.import_types[0].original, "UserId");
1287 assert_eq!(parsed.import_types[0].local, "UserId");
1288 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1289 }
1290
1291 #[test]
1292 fn parse_import_type_with_as() {
1293 let doc = "/** @psalm-import-type UserId as LocalId from UserRepository */";
1294 let parsed = DocblockParser::parse(doc);
1295 assert_eq!(parsed.import_types.len(), 1);
1296 assert_eq!(parsed.import_types[0].original, "UserId");
1297 assert_eq!(parsed.import_types[0].local, "LocalId");
1298 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1299 }
1300
1301 #[test]
1302 fn parse_require_extends() {
1303 let doc = "/** @psalm-require-extends Model */";
1304 let parsed = DocblockParser::parse(doc);
1305 assert_eq!(parsed.require_extends, vec!["Model".to_string()]);
1306 }
1307
1308 #[test]
1309 fn parse_require_implements() {
1310 let doc = "/** @psalm-require-implements Countable */";
1311 let parsed = DocblockParser::parse(doc);
1312 assert_eq!(parsed.require_implements, vec!["Countable".to_string()]);
1313 }
1314
1315 #[test]
1316 fn parse_intersection_two_parts() {
1317 let u = parse_type_string("Iterator&Countable");
1318 assert_eq!(u.types.len(), 1);
1319 assert!(matches!(u.types[0], Atomic::TIntersection { ref parts } if parts.len() == 2));
1320 if let Atomic::TIntersection { parts } = &u.types[0] {
1321 assert!(parts[0].contains(
1322 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1323 ));
1324 assert!(parts[1].contains(
1325 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1326 ));
1327 }
1328 }
1329
1330 #[test]
1331 fn parse_intersection_three_parts() {
1332 let u = parse_type_string("Iterator&Countable&Stringable");
1333 assert_eq!(u.types.len(), 1);
1334 let Atomic::TIntersection { parts } = &u.types[0] else {
1335 panic!("expected TIntersection");
1336 };
1337 assert_eq!(parts.len(), 3);
1338 assert!(parts[0].contains(
1339 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1340 ));
1341 assert!(parts[1].contains(
1342 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1343 ));
1344 assert!(parts[2].contains(
1345 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Stringable")
1346 ));
1347 }
1348
1349 #[test]
1350 fn parse_intersection_in_union_with_null() {
1351 let u = parse_type_string("Iterator&Countable|null");
1352 assert!(u.is_nullable());
1353 let intersection = u
1354 .types
1355 .iter()
1356 .find_map(|t| {
1357 if let Atomic::TIntersection { parts } = t {
1358 Some(parts)
1359 } else {
1360 None
1361 }
1362 })
1363 .expect("expected TIntersection");
1364 assert_eq!(intersection.len(), 2);
1365 assert!(intersection[0].contains(
1366 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1367 ));
1368 assert!(intersection[1].contains(
1369 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1370 ));
1371 }
1372
1373 #[test]
1374 fn parse_intersection_in_union_with_scalar() {
1375 let u = parse_type_string("Iterator&Countable|string");
1376 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1377 let intersection = u
1378 .types
1379 .iter()
1380 .find_map(|t| {
1381 if let Atomic::TIntersection { parts } = t {
1382 Some(parts)
1383 } else {
1384 None
1385 }
1386 })
1387 .expect("expected TIntersection");
1388 assert!(intersection[0].contains(
1389 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1390 ));
1391 assert!(intersection[1].contains(
1392 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1393 ));
1394 }
1395
1396 #[test]
1397 fn validate_unclosed_generic_return() {
1398 let parsed = DocblockParser::parse("/** @return array< */");
1399 assert_eq!(parsed.invalid_annotations.len(), 1);
1400 assert!(
1401 parsed.invalid_annotations[0].contains("unclosed generic"),
1402 "got: {}",
1403 parsed.invalid_annotations[0]
1404 );
1405 }
1406
1407 #[test]
1408 fn validate_variable_in_type_position_param() {
1409 let parsed = DocblockParser::parse("/** @param Foo|$invalid $x */");
1410 assert_eq!(parsed.invalid_annotations.len(), 1);
1411 assert!(
1412 parsed.invalid_annotations[0].contains("$invalid"),
1413 "got: {}",
1414 parsed.invalid_annotations[0]
1415 );
1416 }
1417
1418 #[test]
1419 fn validate_this_is_valid_in_type_position() {
1420 let parsed = DocblockParser::parse("/** @return $this */");
1421 assert!(
1422 parsed.invalid_annotations.is_empty(),
1423 "unexpected error: {:?}",
1424 parsed.invalid_annotations
1425 );
1426 }
1427
1428 #[test]
1429 fn validate_unclosed_generic_var() {
1430 let parsed = DocblockParser::parse("/** @var array<string */");
1431 assert_eq!(parsed.invalid_annotations.len(), 1);
1432 assert!(parsed.invalid_annotations[0].contains("@var"));
1433 }
1434
1435 #[test]
1436 fn validate_variable_in_template_bound() {
1437 let parsed = DocblockParser::parse("/** @template T of $invalid */");
1438 assert_eq!(parsed.invalid_annotations.len(), 1);
1439 assert!(parsed.invalid_annotations[0].contains("$invalid"));
1440 }
1441}