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) = match params.len() {
587 n if n >= 2 => (
588 parse_type_string(params[0].trim()),
589 parse_type_string(params[1].trim()),
590 ),
591 1 => (
592 Union::single(Atomic::TInt),
593 parse_type_string(params[0].trim()),
594 ),
595 _ => (Union::single(Atomic::TInt), Union::mixed()),
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) = match params.len() {
617 n if n >= 2 => (
618 parse_type_string(params[0].trim()),
619 parse_type_string(params[1].trim()),
620 ),
621 1 => (
622 Union::single(Atomic::TInt),
623 parse_type_string(params[0].trim()),
624 ),
625 _ => (Union::single(Atomic::TInt), Union::mixed()),
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 = match params.len() {
635 n if n >= 2 => parse_type_string(params[1].trim()),
636 1 => parse_type_string(params[0].trim()),
637 _ => Union::mixed(),
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 if has_empty_generics(s) {
997 return Some(format!("@{tag} has empty generic type parameter in `{s}`"));
998 }
999 for part in split_union(s) {
1000 let p = part.trim();
1001 if p.starts_with('$') && p != "$this" {
1002 return Some(format!("@{tag} contains variable `{p}` in type position"));
1003 }
1004 }
1005 None
1006}
1007
1008fn has_empty_generics(s: &str) -> bool {
1009 let mut depth = 0;
1010 let mut prev_open = false;
1011 for ch in s.chars() {
1012 match ch {
1013 '<' | '(' | '{' => {
1014 if prev_open && depth == 0 {
1015 return true;
1016 }
1017 prev_open = true;
1018 depth += 1;
1019 }
1020 '>' | ')' | '}' => {
1021 depth -= 1;
1022 if depth == 0 {
1023 if prev_open {
1024 return true;
1025 }
1026 prev_open = false;
1027 }
1028 }
1029 c if !c.is_whitespace() => {
1030 prev_open = false;
1031 }
1032 _ => {}
1033 }
1034 }
1035 false
1036}
1037
1038fn parse_method_line(s: &str) -> Option<DocMethod> {
1040 let mut rest = s.trim();
1041 if rest.is_empty() {
1042 return None;
1043 }
1044 let is_static = rest
1045 .split_whitespace()
1046 .next()
1047 .map(|w| w.eq_ignore_ascii_case("static"))
1048 .unwrap_or(false);
1049 if is_static {
1050 rest = rest["static".len()..].trim_start();
1051 }
1052
1053 let open = rest.find('(').unwrap_or(rest.len());
1054 let prefix = rest[..open].trim();
1055 let mut parts: Vec<&str> = prefix.split_whitespace().collect();
1056 let name = parts.pop()?.to_string();
1057 if name.is_empty() {
1058 return None;
1059 }
1060 let return_type = parts.join(" ");
1061 Some(DocMethod {
1062 return_type,
1063 name,
1064 is_static,
1065 params: parse_method_params(rest),
1066 })
1067}
1068
1069fn parse_method_params(name_part: &str) -> Vec<DocMethodParam> {
1070 let Some(open) = name_part.find('(') else {
1071 return vec![];
1072 };
1073 let Some(close) = name_part.rfind(')') else {
1074 return vec![];
1075 };
1076 let inner = name_part[open + 1..close].trim();
1077 if inner.is_empty() {
1078 return vec![];
1079 }
1080
1081 split_generics(inner)
1082 .into_iter()
1083 .filter_map(|param| parse_method_param(¶m))
1084 .collect()
1085}
1086
1087fn parse_method_param(param: &str) -> Option<DocMethodParam> {
1088 let before_default = param.split('=').next()?.trim();
1089 let is_optional = param.contains('=');
1090 let mut tokens: Vec<&str> = before_default.split_whitespace().collect();
1091 let raw_name = tokens.pop()?;
1092 let is_variadic = raw_name.contains("...");
1093 let is_byref = raw_name.contains('&');
1094 let name = raw_name
1095 .trim_start_matches('&')
1096 .trim_start_matches("...")
1097 .trim_start_matches('&')
1098 .trim_start_matches('$')
1099 .to_string();
1100 if name.is_empty() {
1101 return None;
1102 }
1103 Some(DocMethodParam {
1104 name,
1105 type_hint: tokens.join(" "),
1106 is_variadic,
1107 is_byref,
1108 is_optional: is_optional || is_variadic,
1109 })
1110}
1111
1112#[cfg(test)]
1117mod tests {
1118 use super::*;
1119 use mir_types::Atomic;
1120
1121 #[test]
1122 fn parse_string() {
1123 let u = parse_type_string("string");
1124 assert_eq!(u.types.len(), 1);
1125 assert!(matches!(u.types[0], Atomic::TString));
1126 }
1127
1128 #[test]
1129 fn parse_nullable_string() {
1130 let u = parse_type_string("?string");
1131 assert!(u.is_nullable());
1132 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1133 }
1134
1135 #[test]
1136 fn parse_union() {
1137 let u = parse_type_string("string|int|null");
1138 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1139 assert!(u.contains(|t| matches!(t, Atomic::TInt)));
1140 assert!(u.is_nullable());
1141 }
1142
1143 #[test]
1144 fn parse_array_of_string() {
1145 let u = parse_type_string("array<string>");
1146 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1147 }
1148
1149 #[test]
1150 fn parse_list_of_int() {
1151 let u = parse_type_string("list<int>");
1152 assert!(u.contains(|t| matches!(t, Atomic::TList { .. })));
1153 }
1154
1155 #[test]
1156 fn parse_named_class() {
1157 let u = parse_type_string("Foo\\Bar");
1158 assert!(u.contains(
1159 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Foo\\Bar")
1160 ));
1161 }
1162
1163 #[test]
1164 fn parse_docblock_param_return() {
1165 let doc = r#"/**
1166 * @param string $name
1167 * @param int $age
1168 * @return bool
1169 */"#;
1170 let parsed = DocblockParser::parse(doc);
1171 assert_eq!(parsed.params.len(), 2);
1172 assert!(parsed.return_type.is_some());
1173 let ret = parsed.return_type.unwrap();
1174 assert!(ret.contains(|t| matches!(t, Atomic::TBool)));
1175 }
1176
1177 #[test]
1178 fn parse_template() {
1179 let doc = "/** @template T of object */";
1180 let parsed = DocblockParser::parse(doc);
1181 assert_eq!(parsed.templates.len(), 1);
1182 assert_eq!(parsed.templates[0].0, "T");
1183 assert!(parsed.templates[0].1.is_some());
1184 assert_eq!(parsed.templates[0].2, Variance::Invariant);
1185 }
1186
1187 #[test]
1188 fn parse_template_covariant() {
1189 let doc = "/** @template-covariant T */";
1190 let parsed = DocblockParser::parse(doc);
1191 assert_eq!(parsed.templates.len(), 1);
1192 assert_eq!(parsed.templates[0].0, "T");
1193 assert_eq!(parsed.templates[0].2, Variance::Covariant);
1194 }
1195
1196 #[test]
1197 fn parse_template_contravariant() {
1198 let doc = "/** @template-contravariant T */";
1199 let parsed = DocblockParser::parse(doc);
1200 assert_eq!(parsed.templates.len(), 1);
1201 assert_eq!(parsed.templates[0].0, "T");
1202 assert_eq!(parsed.templates[0].2, Variance::Contravariant);
1203 }
1204
1205 #[test]
1206 fn parse_deprecated() {
1207 let doc = "/** @deprecated use newMethod() instead */";
1208 let parsed = DocblockParser::parse(doc);
1209 assert!(parsed.is_deprecated);
1210 assert_eq!(
1211 parsed.deprecated.as_deref(),
1212 Some("use newMethod() instead")
1213 );
1214 }
1215
1216 #[test]
1217 fn parse_since_plain() {
1218 let parsed = DocblockParser::parse("/** @since 8.0 */");
1219 assert_eq!(parsed.since.as_deref(), Some("8.0"));
1220 assert_eq!(parsed.removed, None);
1221 }
1222
1223 #[test]
1224 fn parse_since_strips_trailing_description() {
1225 let parsed = DocblockParser::parse("/** @since 1.4.0 added \\$options argument */");
1228 assert_eq!(parsed.since.as_deref(), Some("1.4.0"));
1229 }
1230
1231 #[test]
1232 fn parse_removed_tag() {
1233 let parsed = DocblockParser::parse("/** @removed 8.0 use mb_convert_encoding */");
1234 assert_eq!(parsed.removed.as_deref(), Some("8.0"));
1235 }
1236
1237 #[test]
1238 fn parse_since_empty_body_is_none() {
1239 let parsed = DocblockParser::parse("/** @since */");
1240 assert_eq!(parsed.since, None);
1241 }
1242
1243 #[test]
1244 fn parse_description() {
1245 let doc = r#"/**
1246 * This is a description.
1247 * Spans two lines.
1248 * @param string $x
1249 */"#;
1250 let parsed = DocblockParser::parse(doc);
1251 assert!(parsed.description.contains("This is a description"));
1252 assert!(parsed.description.contains("Spans two lines"));
1253 }
1254
1255 #[test]
1256 fn parse_see_and_link() {
1257 let doc = "/** @see SomeClass\n * @link https://example.com */";
1258 let parsed = DocblockParser::parse(doc);
1259 assert_eq!(parsed.see.len(), 2);
1260 assert!(parsed.see.contains(&"SomeClass".to_string()));
1261 assert!(parsed.see.contains(&"https://example.com".to_string()));
1262 }
1263
1264 #[test]
1265 fn parse_mixin() {
1266 let doc = "/** @mixin SomeTrait */";
1267 let parsed = DocblockParser::parse(doc);
1268 assert_eq!(parsed.mixins, vec!["SomeTrait".to_string()]);
1269 }
1270
1271 #[test]
1272 fn parse_property_tags() {
1273 let doc = r#"/**
1274 * @property string $name
1275 * @property-read int $id
1276 * @property-write bool $active
1277 */"#;
1278 let parsed = DocblockParser::parse(doc);
1279 assert_eq!(parsed.properties.len(), 3);
1280 let name_prop = parsed.properties.iter().find(|p| p.name == "name").unwrap();
1281 assert_eq!(name_prop.type_hint, "string");
1282 assert!(!name_prop.read_only);
1283 assert!(!name_prop.write_only);
1284 let id_prop = parsed.properties.iter().find(|p| p.name == "id").unwrap();
1285 assert!(id_prop.read_only);
1286 let active_prop = parsed
1287 .properties
1288 .iter()
1289 .find(|p| p.name == "active")
1290 .unwrap();
1291 assert!(active_prop.write_only);
1292 }
1293
1294 #[test]
1295 fn parse_method_tag() {
1296 let doc = r#"/**
1297 * @method string getName()
1298 * @method static int create()
1299 */"#;
1300 let parsed = DocblockParser::parse(doc);
1301 assert_eq!(parsed.methods.len(), 2);
1302 let get_name = parsed.methods.iter().find(|m| m.name == "getName").unwrap();
1303 assert_eq!(get_name.return_type, "string");
1304 assert!(!get_name.is_static);
1305 let create = parsed.methods.iter().find(|m| m.name == "create").unwrap();
1306 assert!(create.is_static);
1307 }
1308
1309 #[test]
1310 fn parse_type_alias_tag() {
1311 let doc = "/** @psalm-type MyAlias = string|int */";
1312 let parsed = DocblockParser::parse(doc);
1313 assert_eq!(parsed.type_aliases.len(), 1);
1314 assert_eq!(parsed.type_aliases[0].name, "MyAlias");
1315 assert_eq!(parsed.type_aliases[0].type_expr, "string|int");
1316 }
1317
1318 #[test]
1319 fn parse_import_type_no_as() {
1320 let doc = "/** @psalm-import-type UserId from UserRepository */";
1321 let parsed = DocblockParser::parse(doc);
1322 assert_eq!(parsed.import_types.len(), 1);
1323 assert_eq!(parsed.import_types[0].original, "UserId");
1324 assert_eq!(parsed.import_types[0].local, "UserId");
1325 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1326 }
1327
1328 #[test]
1329 fn parse_import_type_with_as() {
1330 let doc = "/** @psalm-import-type UserId as LocalId from UserRepository */";
1331 let parsed = DocblockParser::parse(doc);
1332 assert_eq!(parsed.import_types.len(), 1);
1333 assert_eq!(parsed.import_types[0].original, "UserId");
1334 assert_eq!(parsed.import_types[0].local, "LocalId");
1335 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1336 }
1337
1338 #[test]
1339 fn parse_require_extends() {
1340 let doc = "/** @psalm-require-extends Model */";
1341 let parsed = DocblockParser::parse(doc);
1342 assert_eq!(parsed.require_extends, vec!["Model".to_string()]);
1343 }
1344
1345 #[test]
1346 fn parse_require_implements() {
1347 let doc = "/** @psalm-require-implements Countable */";
1348 let parsed = DocblockParser::parse(doc);
1349 assert_eq!(parsed.require_implements, vec!["Countable".to_string()]);
1350 }
1351
1352 #[test]
1353 fn parse_intersection_two_parts() {
1354 let u = parse_type_string("Iterator&Countable");
1355 assert_eq!(u.types.len(), 1);
1356 assert!(matches!(u.types[0], Atomic::TIntersection { ref parts } if parts.len() == 2));
1357 if let Atomic::TIntersection { parts } = &u.types[0] {
1358 assert!(parts[0].contains(
1359 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1360 ));
1361 assert!(parts[1].contains(
1362 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1363 ));
1364 }
1365 }
1366
1367 #[test]
1368 fn parse_intersection_three_parts() {
1369 let u = parse_type_string("Iterator&Countable&Stringable");
1370 assert_eq!(u.types.len(), 1);
1371 let Atomic::TIntersection { parts } = &u.types[0] else {
1372 panic!("expected TIntersection");
1373 };
1374 assert_eq!(parts.len(), 3);
1375 assert!(parts[0].contains(
1376 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1377 ));
1378 assert!(parts[1].contains(
1379 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1380 ));
1381 assert!(parts[2].contains(
1382 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Stringable")
1383 ));
1384 }
1385
1386 #[test]
1387 fn parse_intersection_in_union_with_null() {
1388 let u = parse_type_string("Iterator&Countable|null");
1389 assert!(u.is_nullable());
1390 let intersection = u
1391 .types
1392 .iter()
1393 .find_map(|t| {
1394 if let Atomic::TIntersection { parts } = t {
1395 Some(parts)
1396 } else {
1397 None
1398 }
1399 })
1400 .expect("expected TIntersection");
1401 assert_eq!(intersection.len(), 2);
1402 assert!(intersection[0].contains(
1403 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1404 ));
1405 assert!(intersection[1].contains(
1406 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1407 ));
1408 }
1409
1410 #[test]
1411 fn parse_intersection_in_union_with_scalar() {
1412 let u = parse_type_string("Iterator&Countable|string");
1413 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1414 let intersection = u
1415 .types
1416 .iter()
1417 .find_map(|t| {
1418 if let Atomic::TIntersection { parts } = t {
1419 Some(parts)
1420 } else {
1421 None
1422 }
1423 })
1424 .expect("expected TIntersection");
1425 assert!(intersection[0].contains(
1426 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1427 ));
1428 assert!(intersection[1].contains(
1429 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1430 ));
1431 }
1432
1433 #[test]
1434 fn validate_unclosed_generic_return() {
1435 let parsed = DocblockParser::parse("/** @return array< */");
1436 assert_eq!(parsed.invalid_annotations.len(), 1);
1437 assert!(
1438 parsed.invalid_annotations[0].contains("unclosed generic"),
1439 "got: {}",
1440 parsed.invalid_annotations[0]
1441 );
1442 }
1443
1444 #[test]
1445 fn parse_empty_generic_array_graceful() {
1446 let u = parse_type_string("array<>");
1447 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1448 }
1449
1450 #[test]
1451 fn parse_empty_generic_iterable_graceful() {
1452 let u = parse_type_string("iterable<>");
1453 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1454 }
1455
1456 #[test]
1457 fn parse_empty_generic_non_empty_array_graceful() {
1458 let u = parse_type_string("non-empty-array<>");
1459 assert!(u.contains(|t| matches!(t, Atomic::TNonEmptyArray { .. })));
1460 }
1461
1462 #[test]
1463 fn validate_variable_in_type_position_param() {
1464 let parsed = DocblockParser::parse("/** @param Foo|$invalid $x */");
1465 assert_eq!(parsed.invalid_annotations.len(), 1);
1466 assert!(
1467 parsed.invalid_annotations[0].contains("$invalid"),
1468 "got: {}",
1469 parsed.invalid_annotations[0]
1470 );
1471 }
1472
1473 #[test]
1474 fn validate_this_is_valid_in_type_position() {
1475 let parsed = DocblockParser::parse("/** @return $this */");
1476 assert!(
1477 parsed.invalid_annotations.is_empty(),
1478 "unexpected error: {:?}",
1479 parsed.invalid_annotations
1480 );
1481 }
1482
1483 #[test]
1484 fn validate_unclosed_generic_var() {
1485 let parsed = DocblockParser::parse("/** @var array<string */");
1486 assert_eq!(parsed.invalid_annotations.len(), 1);
1487 assert!(parsed.invalid_annotations[0].contains("@var"));
1488 }
1489
1490 #[test]
1491 fn validate_variable_in_template_bound() {
1492 let parsed = DocblockParser::parse("/** @template T of $invalid */");
1493 assert_eq!(parsed.invalid_annotations.len(), 1);
1494 assert!(parsed.invalid_annotations[0].contains("$invalid"));
1495 }
1496}