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