1use mir_types::{ArrayKey, Atomic, Type, Variance};
2use std::sync::Arc;
5
6use indexmap::IndexMap;
7use phpdoc_parser::{body_text, parse as parse_phpdoc};
8
9pub struct DocblockParser;
14
15impl DocblockParser {
16 pub fn parse(text: &str) -> ParsedDocblock {
17 let doc = parse_phpdoc(text);
18 let mut result = ParsedDocblock {
19 description: extract_description(text),
20 ..Default::default()
21 };
22
23 for tag in &doc.tags {
24 match tag.name.as_str() {
25 "param" | "psalm-param" | "phpstan-param" => {
26 if let Some(body_str) = body_text(&tag.body) {
27 if let Some((ty_s, name)) = parse_param_line(&body_str) {
28 if is_inside_generics(&ty_s) {
30 if let Some(msg) = validate_type_str(&body_str, "param") {
32 result.invalid_annotations.push(msg);
33 }
34 } else if let Some(msg) = validate_type_str(&ty_s, "param") {
35 result.invalid_annotations.push(msg);
37 } else {
38 result.params.push((
39 name.trim_start_matches('$').to_string(),
40 parse_type_string(&ty_s),
41 ));
42 }
43 } else if let Some(msg) = validate_type_str(&body_str, "param") {
44 result.invalid_annotations.push(msg);
46 }
47 }
48 }
49 "return" | "psalm-return" | "phpstan-return" => {
50 if let Some(body_str) = body_text(&tag.body) {
51 let ty_s = extract_return_type(&body_str);
52 if let Some(msg) = validate_type_str(&ty_s, "return") {
53 result.invalid_annotations.push(msg);
54 }
55 result.return_type = Some(parse_type_string(&ty_s));
56 }
57 }
58 "var" => {
59 if let Some(body_str) = body_text(&tag.body) {
60 if let Some((ty_s, name)) = parse_param_line(&body_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 result.var_name = Some(name.trim_start_matches('$').to_string());
66 } else {
67 let ty_s = extract_type_prefix(body_str.trim());
71 if let Some(msg) = validate_type_str(ty_s, "var") {
72 result.invalid_annotations.push(msg);
73 }
74 result.var_type = Some(parse_type_string(ty_s));
75 }
76 }
77 }
78 "throws" => {
79 if let Some(body_str) = body_text(&tag.body) {
80 let class = body_str.split_whitespace().next().unwrap_or("").to_string();
81 if !class.is_empty() {
82 result.throws.push(class);
83 }
84 }
85 }
86 "deprecated" => {
87 result.is_deprecated = true;
88 result.deprecated = Some(body_text(&tag.body).unwrap_or_default().to_string());
89 }
90 "template" => {
91 if let Some((name, bound)) =
92 parse_template_line(tag.name.as_str(), body_text(&tag.body))
93 {
94 if let Some(b) = &bound {
95 if let Some(msg) = validate_type_str(b, "template") {
96 result.invalid_annotations.push(msg);
97 }
98 }
99 result.templates.push((
100 name,
101 bound.map(|b| parse_type_string(&b)),
102 Variance::Invariant,
103 ));
104 }
105 }
106 "template-covariant" => {
107 if let Some((name, bound)) =
108 parse_template_line(tag.name.as_str(), body_text(&tag.body))
109 {
110 if let Some(b) = &bound {
111 if let Some(msg) = validate_type_str(b, "template-covariant") {
112 result.invalid_annotations.push(msg);
113 }
114 }
115 result.templates.push((
116 name,
117 bound.map(|b| parse_type_string(&b)),
118 Variance::Covariant,
119 ));
120 }
121 }
122 "template-contravariant" => {
123 if let Some((name, bound)) =
124 parse_template_line(tag.name.as_str(), body_text(&tag.body))
125 {
126 if let Some(b) = &bound {
127 if let Some(msg) = validate_type_str(b, "template-contravariant") {
128 result.invalid_annotations.push(msg);
129 }
130 }
131 result.templates.push((
132 name,
133 bound.map(|b| parse_type_string(&b)),
134 Variance::Contravariant,
135 ));
136 }
137 }
138 "extends" | "template-extends" | "phpstan-extends" => {
139 if let Some(body_str) = body_text(&tag.body) {
140 result.extends = Some(parse_type_string(body_str.trim()));
141 }
142 }
143 "implements" | "template-implements" | "phpstan-implements" => {
144 if let Some(body_str) = body_text(&tag.body) {
145 result.implements.push(parse_type_string(body_str.trim()));
146 }
147 }
148 "assert" | "psalm-assert" | "phpstan-assert" => {
149 if let Some(body_str) = body_text(&tag.body) {
150 if let Some((ty_str, name)) = parse_param_line(&body_str) {
151 result.assertions.push((name, parse_type_string(&ty_str)));
152 }
153 }
154 }
155 "suppress" | "psalm-suppress" => {
156 if let Some(body_str) = body_text(&tag.body) {
157 for rule in body_str.split([',', ' ']) {
158 let rule = rule.trim().to_string();
159 if !rule.is_empty() {
160 result.suppressed_issues.push(rule);
161 }
162 }
163 }
164 }
165 "see" => {
166 if let Some(body_str) = body_text(&tag.body) {
167 result.see.push(body_str.to_string());
168 }
169 }
170 "link" => {
171 if let Some(body_str) = body_text(&tag.body) {
172 result.see.push(body_str.to_string());
173 }
174 }
175 "mixin" => {
176 if let Some(body_str) = body_text(&tag.body) {
177 let base_class =
178 body_str.split('<').next().unwrap_or(&body_str).to_string();
179 result.mixins.push(base_class);
180 }
181 }
182 "property" => {
183 if let Some(body_str) = body_text(&tag.body) {
184 if let Some((ty_str, name)) = parse_param_line(&body_str) {
185 result.properties.push(DocProperty {
186 type_hint: ty_str,
187 name: name.trim_start_matches('$').to_string(),
188 read_only: false,
189 write_only: false,
190 });
191 }
192 }
193 }
194 "property-read" => {
195 if let Some(body_str) = body_text(&tag.body) {
196 if let Some((ty_str, name)) = parse_param_line(&body_str) {
197 result.properties.push(DocProperty {
198 type_hint: ty_str,
199 name: name.trim_start_matches('$').to_string(),
200 read_only: true,
201 write_only: false,
202 });
203 }
204 }
205 }
206 "property-write" => {
207 if let Some(body_str) = body_text(&tag.body) {
208 if let Some((ty_str, name)) = parse_param_line(&body_str) {
209 result.properties.push(DocProperty {
210 type_hint: ty_str,
211 name: name.trim_start_matches('$').to_string(),
212 read_only: false,
213 write_only: true,
214 });
215 }
216 }
217 }
218 "method" | "psalm-method" => {
219 let body_str = body_text(&tag.body).unwrap_or_default().trim().to_string();
220 if let Some(err) = validate_method_body(&body_str) {
221 result.invalid_annotations.push(err);
222 } else if let Some(m) = parse_method_line(&body_str) {
223 result.methods.push(m);
224 }
225 }
226 "psalm-type" | "phpstan-type" => {
227 if let Some(body_str) = body_text(&tag.body) {
228 if let Some((name, type_expr)) = body_str.split_once('=') {
229 result.type_aliases.push(DocTypeAlias {
230 name: name.trim().to_string(),
231 type_expr: type_expr.trim().to_string(),
232 });
233 }
234 }
235 }
236 "psalm-import-type" | "phpstan-import-type" => {
237 if let Some(body_str) = body_text(&tag.body) {
238 if let Some(import) = parse_import_type(&body_str) {
239 result.import_types.push(import);
240 }
241 }
242 }
243 "since" if result.since.is_none() => {
244 if let Some(body_str) = body_text(&tag.body) {
245 let v = body_str.split_whitespace().next().unwrap_or("");
246 if !v.is_empty() {
247 result.since = Some(v.to_string());
248 }
249 }
250 }
251 "removed" if result.removed.is_none() => {
252 if let Some(body_str) = body_text(&tag.body) {
253 let v = body_str.split_whitespace().next().unwrap_or("");
254 if !v.is_empty() {
255 result.removed = Some(v.to_string());
256 }
257 }
258 }
259 "internal" => result.is_internal = true,
260 "pure" => result.is_pure = true,
261 "immutable" => result.is_immutable = true,
262 "readonly" => result.is_readonly = true,
263 "final" => result.is_final = true,
264 "inheritDoc" | "inheritdoc" => result.is_inherit_doc = true,
265 "api" | "psalm-api" => result.is_api = true,
266 "psalm-assert-if-true" | "phpstan-assert-if-true" => {
267 if let Some(body_str) = body_text(&tag.body) {
268 if let Some((ty_str, name)) = parse_param_line(&body_str) {
269 result
270 .assertions_if_true
271 .push((name, parse_type_string(&ty_str)));
272 }
273 }
274 }
275 "psalm-assert-if-false" | "phpstan-assert-if-false" => {
276 if let Some(body_str) = body_text(&tag.body) {
277 if let Some((ty_str, name)) = parse_param_line(&body_str) {
278 result
279 .assertions_if_false
280 .push((name, parse_type_string(&ty_str)));
281 }
282 }
283 }
284 "psalm-property" => {
285 if let Some(body_str) = body_text(&tag.body) {
286 if let Some((ty_str, name)) = parse_param_line(&body_str) {
287 result.properties.push(DocProperty {
288 type_hint: ty_str,
289 name,
290 read_only: false,
291 write_only: false,
292 });
293 }
294 }
295 }
296 "psalm-property-read" => {
297 if let Some(body_str) = body_text(&tag.body) {
298 if let Some((ty_str, name)) = parse_param_line(&body_str) {
299 result.properties.push(DocProperty {
300 type_hint: ty_str,
301 name,
302 read_only: true,
303 write_only: false,
304 });
305 }
306 }
307 }
308 "psalm-property-write" => {
309 if let Some(body_str) = body_text(&tag.body) {
310 if let Some((ty_str, name)) = parse_param_line(&body_str) {
311 result.properties.push(DocProperty {
312 type_hint: ty_str,
313 name,
314 read_only: false,
315 write_only: true,
316 });
317 }
318 }
319 }
320 "psalm-require-extends" | "phpstan-require-extends" => {
321 if let Some(body_str) = body_text(&tag.body) {
322 let cls = body_str
323 .split_whitespace()
324 .next()
325 .unwrap_or("")
326 .trim()
327 .to_string();
328 if !cls.is_empty() {
329 result.require_extends.push(cls);
330 }
331 }
332 }
333 "psalm-require-implements" | "phpstan-require-implements" => {
334 if let Some(body_str) = body_text(&tag.body) {
335 let cls = body_str
336 .split_whitespace()
337 .next()
338 .unwrap_or("")
339 .trim()
340 .to_string();
341 if !cls.is_empty() {
342 result.require_implements.push(cls);
343 }
344 }
345 }
346 "mir-check" => {
347 if let Some(body_str) = body_text(&tag.body) {
348 if let Some((var_part, type_part)) = body_str.split_once(" is ") {
349 let var_name = var_part.trim().trim_start_matches('$').to_string();
350 let type_string = type_part.trim().to_string();
351 if !var_name.is_empty() && !type_string.is_empty() {
352 result.mir_checks.push((var_name, type_string));
353 }
354 }
355 }
356 }
357 _ => {}
358 }
359 }
360
361 if text.to_ascii_lowercase().contains("{@inheritdoc}") {
362 result.is_inherit_doc = true;
363 }
364
365 result
366 }
367}
368
369#[derive(Debug, Default, Clone)]
374pub struct DocProperty {
375 pub type_hint: String,
376 pub name: String, pub read_only: bool, pub write_only: bool, }
380
381#[derive(Debug, Default, Clone)]
382pub struct DocMethod {
383 pub return_type: String,
384 pub name: String,
385 pub is_static: bool,
386 pub params: Vec<DocMethodParam>,
387}
388
389#[derive(Debug, Default, Clone)]
390pub struct DocMethodParam {
391 pub name: String,
392 pub type_hint: String,
393 pub is_variadic: bool,
394 pub is_byref: bool,
395 pub is_optional: bool,
396}
397
398#[derive(Debug, Default, Clone)]
399pub struct DocTypeAlias {
400 pub name: String,
401 pub type_expr: String,
402}
403
404#[derive(Debug, Default, Clone)]
405pub struct DocImportType {
406 pub original: String,
408 pub local: String,
410 pub from_class: String,
412}
413
414#[derive(Debug, Default, Clone)]
419pub struct ParsedDocblock {
420 pub params: Vec<(String, Type)>,
422 pub return_type: Option<Type>,
424 pub var_type: Option<Type>,
426 pub var_name: Option<String>,
428 pub templates: Vec<(String, Option<Type>, Variance)>,
430 pub extends: Option<Type>,
432 pub implements: Vec<Type>,
434 pub throws: Vec<String>,
436 pub assertions: Vec<(String, Type)>,
438 pub assertions_if_true: Vec<(String, Type)>,
440 pub assertions_if_false: Vec<(String, Type)>,
442 pub suppressed_issues: Vec<String>,
444 pub is_deprecated: bool,
445 pub is_internal: bool,
446 pub is_pure: bool,
447 pub is_immutable: bool,
448 pub is_readonly: bool,
449 pub is_api: bool,
450 pub is_final: bool,
452 pub is_inherit_doc: bool,
455 pub description: String,
457 pub deprecated: Option<String>,
459 pub see: Vec<String>,
461 pub mixins: Vec<String>,
463 pub properties: Vec<DocProperty>,
465 pub methods: Vec<DocMethod>,
467 pub type_aliases: Vec<DocTypeAlias>,
469 pub import_types: Vec<DocImportType>,
471 pub require_extends: Vec<String>,
473 pub require_implements: Vec<String>,
475 pub since: Option<String>,
477 pub removed: Option<String>,
479 pub invalid_annotations: Vec<String>,
481 pub mir_checks: Vec<(String, String)>,
483}
484
485impl ParsedDocblock {
486 pub fn get_param_type(&self, name: &str) -> Option<&Type> {
492 let name = name.trim_start_matches('$');
493 self.params
494 .iter()
495 .rfind(|(n, _)| n.trim_start_matches('$') == name)
496 .map(|(_, ty)| ty)
497 }
498}
499
500pub fn parse_type_string(s: &str) -> Type {
508 let s = s.trim();
509
510 if let Some(inner) = s.strip_prefix('?') {
512 let inner_ty = parse_type_string(inner);
513 let mut u = inner_ty;
514 u.add_type(Atomic::TNull);
515 return u;
516 }
517
518 if s.starts_with('(') && s.ends_with(')') {
521 let inner = s[1..s.len() - 1].trim();
522 if let Some(conditional) = parse_conditional_type(inner) {
523 return conditional;
524 }
525 if is_balanced_parens(s) {
527 return parse_type_string(inner);
528 }
529 }
530
531 if s.contains('|') && !is_inside_generics(s) {
533 let parts = split_union(s);
534 if parts.len() > 1 {
535 let mut u = Type::empty();
536 for part in parts {
537 for atomic in parse_type_string(&part).types {
538 u.add_type(atomic);
539 }
540 }
541 return u;
542 }
543 }
544
545 if s.contains('&') && !is_inside_generics(s) {
548 let parts = split_intersection(s);
549 if parts.len() > 1 {
550 let parts: Vec<Type> = parts.iter().map(|p| parse_type_string(p.trim())).collect();
551 return Type::single(Atomic::TIntersection {
552 parts: mir_types::union::vec_to_type_params(parts),
553 });
554 }
555 }
556
557 if let Some(value_str) = s.strip_suffix("[]") {
559 let value = parse_type_string(value_str);
560 return Type::single(Atomic::TArray {
561 key: Box::new(Type::single(Atomic::TInt)),
562 value: Box::new(value),
563 });
564 }
565
566 if let Some(call_ty) = parse_callable_syntax(s) {
568 return call_ty;
569 }
570
571 if s.ends_with('}') {
573 if let Some(open) = s.find('{') {
574 let prefix = s[..open].to_lowercase();
575 let inner = &s[open + 1..s.len() - 1];
576 if prefix == "array" {
577 return parse_keyed_array(inner, false);
578 } else if prefix == "list" {
579 return parse_keyed_array(inner, true);
580 }
581 }
582 }
583
584 if let Some(open) = s.find('<') {
586 if s.ends_with('>') {
587 let name = &s[..open];
588 let inner = &s[open + 1..s.len() - 1];
589 return parse_generic(name, inner);
590 }
591 }
592
593 match s.to_lowercase().as_str() {
595 "string" => Type::single(Atomic::TString),
596 "non-empty-string" => Type::single(Atomic::TNonEmptyString),
597 "numeric-string" => Type::single(Atomic::TNumericString),
598 "class-string" => Type::single(Atomic::TClassString(None)),
599 "int" | "integer" => Type::single(Atomic::TInt),
600 "positive-int" => Type::single(Atomic::TPositiveInt),
601 "negative-int" => Type::single(Atomic::TNegativeInt),
602 "non-negative-int" => Type::single(Atomic::TNonNegativeInt),
603 "float" | "double" => Type::single(Atomic::TFloat),
604 "bool" | "boolean" => Type::single(Atomic::TBool),
605 "true" => Type::single(Atomic::TTrue),
606 "false" => Type::single(Atomic::TFalse),
607 "null" => Type::single(Atomic::TNull),
608 "void" => Type::single(Atomic::TVoid),
609 "never" | "never-return" | "no-return" | "never-returns" => Type::single(Atomic::TNever),
610 "mixed" => Type::single(Atomic::TMixed),
611 "object" => Type::single(Atomic::TObject),
612 "array" => Type::single(Atomic::TArray {
613 key: Box::new(Type::single(Atomic::TMixed)),
614 value: Box::new(Type::mixed()),
615 }),
616 "list" => Type::single(Atomic::TList {
617 value: Box::new(Type::mixed()),
618 }),
619 "callable" => Type::single(Atomic::TCallable {
620 params: None,
621 return_type: None,
622 }),
623 "callable-string" => Type::single(Atomic::TCallableString),
624 "iterable" => Type::single(Atomic::TArray {
625 key: Box::new(Type::single(Atomic::TMixed)),
626 value: Box::new(Type::mixed()),
627 }),
628 "scalar" => Type::single(Atomic::TScalar),
629 "numeric" => Type::single(Atomic::TNumeric),
630 "array-key" => {
631 let mut u = Type::single(Atomic::TInt);
632 u.add_type(Atomic::TString);
633 u
634 }
635 "resource" => Type::mixed(), "static" => Type::single(Atomic::TStaticObject {
638 fqcn: mir_types::Name::from(""),
639 }),
640 "self" | "$this" => Type::single(Atomic::TSelf {
641 fqcn: mir_types::Name::from(""),
642 }),
643 "parent" => Type::single(Atomic::TParent {
644 fqcn: mir_types::Name::from(""),
645 }),
646
647 _ if !s.is_empty()
649 && s.chars()
650 .next()
651 .map(|c| c.is_alphanumeric() || c == '\\' || c == '_')
652 .unwrap_or(false) =>
653 {
654 if let Ok(n) = s.parse::<i64>() {
656 return Type::single(Atomic::TLiteralInt(n));
657 }
658 Type::single(Atomic::TNamedObject {
659 fqcn: normalize_fqcn(s).into(),
660 type_params: mir_types::union::empty_type_params(),
661 })
662 }
663
664 _ if s.starts_with('-') && s.len() > 1 && s[1..].chars().all(|c| c.is_ascii_digit()) => {
666 if let Ok(n) = s.parse::<i64>() {
667 Type::single(Atomic::TLiteralInt(n))
668 } else {
669 Type::mixed()
670 }
671 }
672
673 _ if (s.starts_with('\'') && s.ends_with('\''))
675 || (s.starts_with('"') && s.ends_with('"')) =>
676 {
677 let inner = &s[1..s.len() - 1];
678 Type::single(Atomic::TLiteralString(Arc::from(inner)))
679 }
680
681 _ => Type::mixed(),
682 }
683}
684
685fn parse_generic(name: &str, inner: &str) -> Type {
686 match name.to_lowercase().as_str() {
687 "array" => {
688 let params = split_generics(inner);
689 let array_key = || {
690 let mut k = Type::single(Atomic::TInt);
691 k.add_type(Atomic::TString);
692 k
693 };
694 let (key, value) = match params.len() {
695 n if n >= 2 => (
696 parse_type_string(params[0].trim()),
697 parse_type_string(params[1].trim()),
698 ),
699 1 => (array_key(), parse_type_string(params[0].trim())),
700 _ => (array_key(), Type::mixed()),
701 };
702 Type::single(Atomic::TArray {
703 key: Box::new(key),
704 value: Box::new(value),
705 })
706 }
707 "list" | "non-empty-list" => {
708 let value = parse_type_string(inner.trim());
709 if name.to_lowercase().starts_with("non-empty") {
710 Type::single(Atomic::TNonEmptyList {
711 value: Box::new(value),
712 })
713 } else {
714 Type::single(Atomic::TList {
715 value: Box::new(value),
716 })
717 }
718 }
719 "non-empty-array" => {
720 let params = split_generics(inner);
721 let array_key = || {
722 let mut k = Type::single(Atomic::TInt);
723 k.add_type(Atomic::TString);
724 k
725 };
726 let (key, value) = match params.len() {
727 n if n >= 2 => (
728 parse_type_string(params[0].trim()),
729 parse_type_string(params[1].trim()),
730 ),
731 1 => (array_key(), parse_type_string(params[0].trim())),
732 _ => (array_key(), Type::mixed()),
733 };
734 Type::single(Atomic::TNonEmptyArray {
735 key: Box::new(key),
736 value: Box::new(value),
737 })
738 }
739 "iterable" => {
740 let params = split_generics(inner);
741 let value = match params.len() {
742 n if n >= 2 => parse_type_string(params[1].trim()),
743 1 => parse_type_string(params[0].trim()),
744 _ => Type::mixed(),
745 };
746 Type::single(Atomic::TArray {
747 key: Box::new(Type::single(Atomic::TMixed)),
748 value: Box::new(value),
749 })
750 }
751 "class-string" => Type::single(Atomic::TClassString(Some(
752 normalize_fqcn(inner.trim()).into(),
753 ))),
754 "int" => {
755 Type::single(Atomic::TIntRange {
757 min: None,
758 max: None,
759 })
760 }
761 _ => {
763 let params: Vec<Type> = split_generics(inner)
764 .iter()
765 .map(|p| parse_type_string(p.trim()))
766 .collect();
767 Type::single(Atomic::TNamedObject {
768 fqcn: normalize_fqcn(name).into(),
769 type_params: mir_types::union::vec_to_type_params(params),
770 })
771 }
772 }
773}
774
775fn strip_quotes(s: &str) -> &str {
776 if (s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')) {
777 &s[1..s.len() - 1]
778 } else {
779 s
780 }
781}
782
783fn parse_keyed_array(inner: &str, is_list: bool) -> Type {
784 use mir_types::atomic::KeyedProperty;
785 let mut properties: IndexMap<ArrayKey, KeyedProperty> = IndexMap::new();
786 let mut is_open = false;
787 let mut auto_index = 0i64;
788
789 for item in split_generics(inner) {
790 let item = item.trim();
791 if item.is_empty() {
792 continue;
793 }
794 if item == "..." {
795 is_open = true;
796 continue;
797 }
798 let colon_pos = {
800 let mut depth = 0i32;
801 let mut found = None;
802 for (i, ch) in item.char_indices() {
803 match ch {
804 '<' | '(' | '{' => depth += 1,
805 '>' | ')' | '}' => depth -= 1,
806 ':' if depth == 0 => {
807 found = Some(i);
808 break;
809 }
810 _ => {}
811 }
812 }
813 found
814 };
815 if let Some(colon) = colon_pos {
816 let key_part = item[..colon].trim();
817 let ty_part = item[colon + 1..].trim();
818 let optional = key_part.ends_with('?');
819 let key_str = key_part.trim_end_matches('?').trim();
820 let key_str = strip_quotes(key_str);
821 let key = if let Ok(n) = key_str.parse::<i64>() {
822 ArrayKey::Int(n)
823 } else {
824 ArrayKey::String(Arc::from(key_str))
825 };
826 properties.insert(
827 key,
828 KeyedProperty {
829 ty: parse_type_string(ty_part),
830 optional,
831 },
832 );
833 } else {
834 properties.insert(
835 ArrayKey::Int(auto_index),
836 KeyedProperty {
837 ty: parse_type_string(item),
838 optional: false,
839 },
840 );
841 auto_index += 1;
842 }
843 }
844
845 Type::single(Atomic::TKeyedArray {
846 properties,
847 is_open,
848 is_list,
849 })
850}
851
852fn parse_callable_syntax(s: &str) -> Option<Type> {
853 let s = s.trim_start_matches('\\');
854 let lower = s.to_lowercase();
855 let is_closure = lower.starts_with("closure");
856 let is_callable = lower.starts_with("callable");
857 if !is_closure && !is_callable {
858 return None;
859 }
860 let prefix_len = if is_closure {
861 "closure".len()
862 } else {
863 "callable".len()
864 };
865 let rest = s[prefix_len..].trim_start();
866 if !rest.starts_with('(') {
867 return None;
868 }
869 let close = find_matching_paren(rest)?;
870 let params_str = &rest[1..close];
871 let after = rest[close + 1..].trim();
872 let return_type = after
873 .strip_prefix(':')
874 .map(|ret_str| Box::new(parse_type_string(ret_str.trim())));
875 let params: Vec<mir_types::atomic::FnParam> = split_generics(params_str)
876 .into_iter()
877 .enumerate()
878 .filter(|(_, p)| !p.trim().is_empty())
879 .map(|(i, p)| {
880 let p = p.trim();
881 let (ty_str, name) = if let Some(dollar) = p.rfind('$') {
882 (p[..dollar].trim(), p[dollar + 1..].to_string())
883 } else {
884 (p, format!("arg{i}"))
885 };
886 mir_types::atomic::FnParam {
887 name: name.into(),
888 ty: Some(mir_types::SimpleType::from_union(parse_type_string(ty_str))),
889 default: None,
890 is_variadic: false,
891 is_byref: false,
892 is_optional: false,
893 }
894 })
895 .collect();
896 if is_closure {
897 Some(Type::single(Atomic::TClosure {
898 params,
899 return_type: return_type.unwrap_or_else(|| Box::new(Type::single(Atomic::TVoid))),
900 this_type: None,
901 }))
902 } else {
903 Some(Type::single(Atomic::TCallable {
904 params: Some(params),
905 return_type,
906 }))
907 }
908}
909
910fn find_matching_paren(s: &str) -> Option<usize> {
911 if !s.starts_with('(') {
912 return None;
913 }
914 let mut depth = 0i32;
915 for (i, ch) in s.char_indices() {
916 match ch {
917 '(' | '<' | '{' => depth += 1,
918 ')' | '>' | '}' => {
919 depth -= 1;
920 if depth == 0 {
921 return Some(i);
922 }
923 }
924 _ => {}
925 }
926 }
927 None
928}
929
930fn parse_template_line(_tag_name: &str, body: Option<String>) -> Option<(String, Option<String>)> {
942 let body = body?;
943 let body = body.trim();
944 let body = match body.find(" @") {
947 Some(idx) => body[..idx].trim_end(),
948 None => body,
949 };
950 if body.is_empty() {
951 return None;
952 }
953 if let Some((name, bound)) = body.split_once(" of ").or_else(|| body.split_once(" as ")) {
954 let bound = bound.trim();
955 Some((
956 name.trim().to_string(),
957 (!bound.is_empty()).then(|| bound.to_string()),
958 ))
959 } else {
960 let name = body.split_whitespace().next().unwrap_or(body);
962 Some((name.to_string(), None))
963 }
964}
965
966fn extract_description(text: &str) -> String {
968 let mut desc_lines: Vec<&str> = Vec::new();
969 for line in text.lines() {
970 let l = line.trim();
971 let l = l.trim_start_matches("/**").trim();
972 let l = l.trim_end_matches("*/").trim();
973 let l = l.trim_start_matches("*/").trim();
974 let l = l.strip_prefix("* ").unwrap_or(l.trim_start_matches('*'));
975 let l = l.trim();
976 if l.starts_with('@') {
977 break;
978 }
979 if !l.is_empty() {
980 desc_lines.push(l);
981 }
982 }
983 desc_lines.join(" ")
984}
985
986fn parse_import_type(body: &str) -> Option<DocImportType> {
992 let (before_from, from_class_raw) = body.split_once(" from ")?;
994 let from_class = from_class_raw.trim().trim_start_matches('\\').to_string();
995 if from_class.is_empty() {
996 return None;
997 }
998 let (original, local) = if let Some((orig, loc)) = before_from.split_once(" as ") {
1000 (orig.trim().to_string(), loc.trim().to_string())
1001 } else {
1002 let name = before_from.trim().to_string();
1003 (name.clone(), name)
1004 };
1005 if original.is_empty() || local.is_empty() {
1006 return None;
1007 }
1008 Some(DocImportType {
1009 original,
1010 local,
1011 from_class,
1012 })
1013}
1014
1015fn parse_param_line(s: &str) -> Option<(String, String)> {
1016 let first_line = s.lines().next().unwrap_or(s);
1022
1023 let mut best_split: Option<(String, String)> = None;
1026
1027 for (i, ch) in first_line.char_indices() {
1028 if ch.is_whitespace() {
1029 let after = first_line[i..].trim_start();
1030 let after_stripped = after.strip_prefix('&').unwrap_or(after);
1032 if after_stripped.starts_with('$') {
1033 let mut var_parts = after_stripped.split(char::is_whitespace);
1034 if let Some(name_with_dollar) = var_parts.next() {
1035 let name = name_with_dollar.trim_start_matches('$').to_string();
1036 if !name.is_empty() {
1037 let type_part = first_line[..i].trim().to_string();
1038 if !type_part.is_empty() {
1039 best_split = Some((type_part, name));
1040 }
1041 }
1042 }
1043 }
1044 }
1045 }
1046
1047 best_split
1048}
1049
1050fn extract_return_type(s: &str) -> String {
1051 let mut depth: i32 = 0;
1060 let mut current_token = String::new();
1061
1062 for ch in s.chars() {
1063 match ch {
1064 '<' | '(' | '{' => {
1065 depth += 1;
1066 current_token.push(ch);
1067 }
1068 '>' | ')' | '}' => {
1069 depth = (depth - 1).max(0);
1070 current_token.push(ch);
1071 }
1072 _ if ch.is_whitespace() && depth == 0 => {
1073 break;
1074 }
1075 _ => {
1076 current_token.push(ch);
1077 }
1078 }
1079 }
1080
1081 if current_token.ends_with(':') {
1085 let offset = current_token.len();
1086 let rest = s[offset..].trim_start();
1087 if !rest.is_empty() {
1088 let ret_type = extract_return_type(rest);
1089 current_token.push_str(&ret_type);
1090 }
1091 }
1092
1093 current_token.trim().to_string()
1094}
1095
1096fn split_union(s: &str) -> Vec<String> {
1097 let mut parts = Vec::new();
1098 let mut depth = 0;
1099 let mut current = String::new();
1100 for ch in s.chars() {
1101 match ch {
1102 '<' | '(' | '{' => {
1103 depth += 1;
1104 current.push(ch);
1105 }
1106 '>' | ')' | '}' => {
1107 depth -= 1;
1108 current.push(ch);
1109 }
1110 '|' if depth == 0 => {
1111 parts.push(current.trim().to_string());
1112 current = String::new();
1113 }
1114 _ => current.push(ch),
1115 }
1116 }
1117 if !current.trim().is_empty() {
1118 parts.push(current.trim().to_string());
1119 }
1120 parts
1121}
1122
1123fn split_intersection(s: &str) -> Vec<String> {
1125 let mut parts = Vec::new();
1126 let mut depth = 0i32;
1127 let mut current = String::new();
1128 for ch in s.chars() {
1129 match ch {
1130 '<' | '(' | '{' => {
1131 depth += 1;
1132 current.push(ch);
1133 }
1134 '>' | ')' | '}' => {
1135 depth -= 1;
1136 current.push(ch);
1137 }
1138 '&' if depth == 0 => {
1139 parts.push(current.trim().to_string());
1140 current = String::new();
1141 }
1142 _ => current.push(ch),
1143 }
1144 }
1145 if !current.trim().is_empty() {
1146 parts.push(current.trim().to_string());
1147 }
1148 parts
1149}
1150
1151fn is_balanced_parens(s: &str) -> bool {
1155 if !s.starts_with('(') || !s.ends_with(')') {
1156 return false;
1157 }
1158 let mut depth = 0i32;
1159 let chars: Vec<char> = s.chars().collect();
1160 let last = chars.len() - 1;
1161 for (i, ch) in chars.iter().enumerate() {
1162 match ch {
1163 '(' => depth += 1,
1164 ')' => {
1165 depth -= 1;
1166 if depth == 0 && i < last {
1169 return false;
1170 }
1171 }
1172 _ => {}
1173 }
1174 }
1175 depth == 0
1176}
1177
1178fn split_generics(s: &str) -> Vec<String> {
1179 let mut parts = Vec::new();
1180 let mut depth = 0;
1181 let mut current = String::new();
1182 for ch in s.chars() {
1183 match ch {
1184 '<' | '(' | '{' => {
1185 depth += 1;
1186 current.push(ch);
1187 }
1188 '>' | ')' | '}' => {
1189 depth -= 1;
1190 current.push(ch);
1191 }
1192 ',' if depth == 0 => {
1193 parts.push(current.trim().to_string());
1194 current = String::new();
1195 }
1196 _ => current.push(ch),
1197 }
1198 }
1199 if !current.trim().is_empty() {
1200 parts.push(current.trim().to_string());
1201 }
1202 parts
1203}
1204
1205fn extract_type_prefix(s: &str) -> &str {
1208 let mut depth = 0i32;
1209 let mut end = s.len();
1210 for (i, ch) in s.char_indices() {
1211 match ch {
1212 '<' | '(' | '{' => depth += 1,
1213 '>' | ')' | '}' => depth -= 1,
1214 _ if ch.is_whitespace() && depth == 0 => {
1215 end = i;
1216 break;
1217 }
1218 _ => {}
1219 }
1220 }
1221 &s[..end]
1222}
1223
1224fn is_inside_generics(s: &str) -> bool {
1225 let mut depth = 0i32;
1226 for ch in s.chars() {
1227 match ch {
1228 '<' | '(' | '{' => depth += 1,
1229 '>' | ')' | '}' => depth -= 1,
1230 _ => {}
1231 }
1232 }
1233 depth != 0
1234}
1235
1236fn parse_conditional_type(s: &str) -> Option<Type> {
1239 let is_pos = s.find(" is ")?;
1240 let param_raw = s[..is_pos].trim();
1241
1242 let param_name_str: &str = if let Some(name) = param_raw.strip_prefix('$') {
1244 if name.is_empty() {
1245 return None;
1246 }
1247 name
1248 } else {
1249 if param_raw.is_empty()
1252 || !param_raw.starts_with(|c: char| c.is_alphabetic() || c == '_')
1253 || !param_raw.chars().all(|c| c.is_alphanumeric() || c == '_')
1254 || !s.contains('?')
1255 {
1256 return None;
1257 }
1258 param_raw
1259 };
1260 let param_name = Some(mir_types::Name::new(param_name_str));
1261 let after_is = s[is_pos + 4..].trim();
1262 let q_pos = find_char_at_depth(after_is, '?')?;
1263 let subject_str = after_is[..q_pos].trim();
1264 let rest = after_is[q_pos + 1..].trim();
1265 let colon_pos = find_char_at_depth(rest, ':')?;
1266 let true_str = rest[..colon_pos].trim();
1267 let false_str = rest[colon_pos + 1..].trim();
1268 Some(Type::single(Atomic::TConditional {
1269 param_name,
1270 subject: Box::new(parse_type_string(subject_str)),
1271 if_true: Box::new(parse_type_string(true_str)),
1272 if_false: Box::new(parse_type_string(false_str)),
1273 }))
1274}
1275
1276fn find_char_at_depth(s: &str, target: char) -> Option<usize> {
1278 let mut depth = 0i32;
1279 for (i, ch) in s.char_indices() {
1280 match ch {
1281 '<' | '(' | '{' => depth += 1,
1282 '>' | ')' | '}' => depth -= 1,
1283 _ if ch == target && depth == 0 => return Some(i),
1284 _ => {}
1285 }
1286 }
1287 None
1288}
1289
1290fn normalize_fqcn(s: &str) -> String {
1291 s.trim_start_matches('\\').to_string()
1293}
1294
1295fn validate_type_str(s: &str, tag: &str) -> Option<String> {
1301 let s = s.trim();
1302 if s.is_empty() {
1303 return None;
1304 }
1305 if is_inside_generics(s) {
1306 return Some(format!("@{tag} has unclosed generic type `{s}`"));
1307 }
1308 let is_callable_type = s.to_lowercase().contains("callable") || s.contains("Closure");
1310 if !is_callable_type && has_empty_generics(s) {
1311 return Some(format!("@{tag} has empty generic type parameter in `{s}`"));
1312 }
1313 for part in split_union(s) {
1314 let p = part.trim();
1315 if p.starts_with('$') && p != "$this" {
1316 return Some(format!("@{tag} contains variable `{p}` in type position"));
1317 }
1318 if let Some(err) = validate_generic_semantics(p, tag) {
1319 return Some(err);
1320 }
1321 }
1322 None
1323}
1324
1325fn validate_generic_semantics(s: &str, tag: &str) -> Option<String> {
1327 let lower = s.to_lowercase();
1328 let (name, inner) = extract_generic_content(s)?;
1329 match lower[..name.len()].as_ref() {
1330 "int" => validate_int_range_inner(inner, tag),
1331 "array" | "non-empty-array" => validate_array_key_inner(inner, tag),
1332 _ => None,
1333 }
1334}
1335
1336fn extract_generic_content(s: &str) -> Option<(&str, &str)> {
1338 let lt = s.find('<')?;
1339 let name = s[..lt].trim();
1340 if name.is_empty() {
1341 return None;
1342 }
1343 let after_lt = &s[lt + 1..];
1344 let mut depth = 1i32;
1345 for (i, ch) in after_lt.char_indices() {
1346 match ch {
1347 '<' | '(' | '{' => depth += 1,
1348 '>' | ')' | '}' => {
1349 depth -= 1;
1350 if depth == 0 {
1351 return Some((name, &after_lt[..i]));
1352 }
1353 }
1354 _ => {}
1355 }
1356 }
1357 None
1358}
1359
1360fn validate_int_range_inner(inner: &str, tag: &str) -> Option<String> {
1361 let mut parts = inner.splitn(2, ',');
1362 let min_str = parts.next()?.trim();
1363 let max_str = parts.next()?.trim();
1364
1365 if min_str == "max" {
1366 return Some(format!(
1367 "@{tag} has invalid int range: `max` must be the second argument, not the first"
1368 ));
1369 }
1370 if max_str == "min" {
1371 return Some(format!(
1372 "@{tag} has invalid int range: `min` must be the first argument, not the second"
1373 ));
1374 }
1375
1376 let is_valid_bound = |s: &str| s == "min" || s == "max" || s.parse::<i64>().is_ok();
1377
1378 if !is_valid_bound(min_str) {
1379 return Some(format!(
1380 "@{tag} has invalid int range boundary `{min_str}`: must be an integer literal, `min`, or `max`"
1381 ));
1382 }
1383 if !is_valid_bound(max_str) {
1384 return Some(format!(
1385 "@{tag} has invalid int range boundary `{max_str}`: must be an integer literal, `min`, or `max`"
1386 ));
1387 }
1388
1389 if let (Ok(lo), Ok(hi)) = (min_str.parse::<i64>(), max_str.parse::<i64>()) {
1390 if lo > hi {
1391 return Some(format!(
1392 "@{tag} has invalid int range: min ({lo}) must not be greater than max ({hi})"
1393 ));
1394 }
1395 }
1396 None
1397}
1398
1399fn validate_array_key_inner(inner: &str, tag: &str) -> Option<String> {
1400 let params = split_generics(inner);
1401 if params.len() < 2 {
1402 return None;
1403 }
1404 let key_str = params[0].trim();
1405 let invalid_key_types = ["float", "bool", "true", "false"];
1409 if invalid_key_types.contains(&key_str.to_lowercase().as_str()) {
1410 return Some(format!(
1411 "@{tag} has invalid array key type `{key_str}`: must be a subtype of int|string"
1412 ));
1413 }
1414 None
1415}
1416
1417fn has_empty_generics(s: &str) -> bool {
1418 let mut depth = 0;
1419 let mut prev_open = false;
1420 for ch in s.chars() {
1421 match ch {
1422 '<' | '(' | '{' => {
1423 if prev_open && depth == 0 {
1424 return true;
1425 }
1426 prev_open = true;
1427 depth += 1;
1428 }
1429 '>' | ')' | '}' => {
1430 depth -= 1;
1431 if depth == 0 {
1432 if prev_open {
1433 return true;
1434 }
1435 prev_open = false;
1436 }
1437 }
1438 c if !c.is_whitespace() => {
1439 prev_open = false;
1440 }
1441 _ => {}
1442 }
1443 }
1444 false
1445}
1446
1447fn validate_method_body(s: &str) -> Option<String> {
1450 let s = s.trim();
1451 if s.is_empty() {
1452 return Some("@method annotation is missing a method definition".to_string());
1453 }
1454 let rest = if s.to_lowercase().starts_with("static ") {
1456 s["static ".len()..].trim_start()
1457 } else {
1458 s
1459 };
1460 let open = rest.find('(').unwrap_or(rest.len());
1462 let prefix = rest[..open].trim();
1463 let parts: Vec<&str> = prefix.split_whitespace().collect();
1464 let name = parts.last().unwrap_or(&"");
1465 if !name.is_empty() && !is_valid_php_identifier(name) {
1467 return Some(format!(
1468 "@method has invalid method name `{name}`: must be a valid PHP identifier"
1469 ));
1470 }
1471 if rest.contains('(') {
1473 let params_str = rest;
1474 let open_pos = params_str.find('(').unwrap();
1475 let after_open = ¶ms_str[open_pos + 1..];
1476 if let Some(rel_close) = find_matching_paren(¶ms_str[open_pos..]) {
1477 let close_pos = open_pos + rel_close;
1478 let inner = params_str[open_pos + 1..close_pos].trim();
1479 if !inner.is_empty() {
1480 for param in split_generics(inner) {
1481 let param = param.trim();
1482 if param.starts_with('&') {
1483 return Some(format!(
1484 "@method parameter `{param}` uses by-reference (`&`) which is not supported in @method annotations"
1485 ));
1486 }
1487 if let Some(amp_pos) = param.find('&') {
1489 let before_amp = ¶m[..amp_pos];
1490 let after_amp = param[amp_pos + 1..].trim_start();
1491 if !before_amp.trim().is_empty() && after_amp.starts_with('$') {
1492 return Some(format!(
1493 "@method parameter `{param}` uses by-reference (`&`) which is not supported in @method annotations"
1494 ));
1495 }
1496 }
1497 }
1498 }
1499 } else {
1500 let _ = after_open;
1501 }
1502 }
1503 None
1504}
1505
1506fn is_valid_php_identifier(s: &str) -> bool {
1507 let mut chars = s.chars();
1508 match chars.next() {
1509 Some(c) if c.is_alphabetic() || c == '_' => {}
1510 _ => return false,
1511 }
1512 chars.all(|c| c.is_alphanumeric() || c == '_')
1513}
1514
1515fn parse_method_line(s: &str) -> Option<DocMethod> {
1517 let mut rest = s.trim();
1518 if rest.is_empty() {
1519 return None;
1520 }
1521 let is_static = rest
1522 .split_whitespace()
1523 .next()
1524 .map(|w| w.eq_ignore_ascii_case("static"))
1525 .unwrap_or(false);
1526 if is_static {
1527 rest = rest["static".len()..].trim_start();
1528 }
1529
1530 let open = rest.find('(').unwrap_or(rest.len());
1531 let prefix = rest[..open].trim();
1532 let mut parts: Vec<&str> = prefix.split_whitespace().collect();
1533 let name = parts.pop()?.to_string();
1534 if name.is_empty() {
1535 return None;
1536 }
1537 let return_type = parts.join(" ");
1538 Some(DocMethod {
1539 return_type,
1540 name,
1541 is_static,
1542 params: parse_method_params(rest),
1543 })
1544}
1545
1546fn parse_method_params(name_part: &str) -> Vec<DocMethodParam> {
1547 let Some(open) = name_part.find('(') else {
1548 return vec![];
1549 };
1550 let Some(rel_close) = find_matching_paren(&name_part[open..]) else {
1554 return vec![];
1555 };
1556 let close = open + rel_close;
1557 let inner = name_part[open + 1..close].trim();
1558 if inner.is_empty() {
1559 return vec![];
1560 }
1561
1562 split_generics(inner)
1563 .into_iter()
1564 .filter_map(|param| parse_method_param(¶m))
1565 .collect()
1566}
1567
1568fn parse_method_param(param: &str) -> Option<DocMethodParam> {
1569 let before_default = param.split('=').next()?.trim();
1570 let is_optional = param.contains('=');
1571 let mut tokens: Vec<&str> = before_default.split_whitespace().collect();
1572 let raw_name = tokens.pop()?;
1573 let is_variadic = raw_name.contains("...");
1574 let is_byref = raw_name.contains('&');
1575 let name = raw_name
1576 .trim_start_matches('&')
1577 .trim_start_matches("...")
1578 .trim_start_matches('&')
1579 .trim_start_matches('$')
1580 .to_string();
1581 if name.is_empty() {
1582 return None;
1583 }
1584 Some(DocMethodParam {
1585 name,
1586 type_hint: tokens.join(" "),
1587 is_variadic,
1588 is_byref,
1589 is_optional: is_optional || is_variadic,
1590 })
1591}
1592
1593#[cfg(test)]
1598mod tests {
1599 use super::*;
1600 use mir_types::Atomic;
1601
1602 #[test]
1603 fn parse_string() {
1604 let u = parse_type_string("string");
1605 assert_eq!(u.types.len(), 1);
1606 assert!(matches!(u.types[0], Atomic::TString));
1607 }
1608
1609 #[test]
1610 fn parse_nullable_string() {
1611 let u = parse_type_string("?string");
1612 assert!(u.is_nullable());
1613 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1614 }
1615
1616 #[test]
1617 fn parse_union() {
1618 let u = parse_type_string("string|int|null");
1619 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1620 assert!(u.contains(|t| matches!(t, Atomic::TInt)));
1621 assert!(u.is_nullable());
1622 }
1623
1624 #[test]
1625 fn parse_array_of_string() {
1626 let u = parse_type_string("array<string>");
1627 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1628 }
1629
1630 #[test]
1631 fn parse_list_of_int() {
1632 let u = parse_type_string("list<int>");
1633 assert!(u.contains(|t| matches!(t, Atomic::TList { .. })));
1634 }
1635
1636 #[test]
1637 fn parse_named_class() {
1638 let u = parse_type_string("Foo\\Bar");
1639 assert!(u.contains(
1640 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Foo\\Bar")
1641 ));
1642 }
1643
1644 #[test]
1645 fn parse_docblock_param_return() {
1646 let doc = r#"/**
1647 * @param string $name
1648 * @param int $age
1649 * @return bool
1650 */"#;
1651 let parsed = DocblockParser::parse(doc);
1652 assert_eq!(parsed.params.len(), 2);
1653 assert!(parsed.return_type.is_some());
1654 let ret = parsed.return_type.unwrap();
1655 assert!(ret.contains(|t| matches!(t, Atomic::TBool)));
1656 }
1657
1658 #[test]
1659 fn parse_template() {
1660 let doc = "/** @template T of object */";
1661 let parsed = DocblockParser::parse(doc);
1662 assert_eq!(parsed.templates.len(), 1);
1663 assert_eq!(parsed.templates[0].0, "T");
1664 assert!(parsed.templates[0].1.is_some());
1665 assert_eq!(parsed.templates[0].2, Variance::Invariant);
1666 }
1667
1668 #[test]
1669 fn parse_template_covariant() {
1670 let doc = "/** @template-covariant T */";
1671 let parsed = DocblockParser::parse(doc);
1672 assert_eq!(parsed.templates.len(), 1);
1673 assert_eq!(parsed.templates[0].0, "T");
1674 assert_eq!(parsed.templates[0].2, Variance::Covariant);
1675 }
1676
1677 #[test]
1678 fn parse_template_contravariant() {
1679 let doc = "/** @template-contravariant T */";
1680 let parsed = DocblockParser::parse(doc);
1681 assert_eq!(parsed.templates.len(), 1);
1682 assert_eq!(parsed.templates[0].0, "T");
1683 assert_eq!(parsed.templates[0].2, Variance::Contravariant);
1684 }
1685
1686 #[test]
1687 fn parse_template_single_line_does_not_over_read() {
1688 let doc = "/** @template T @param T $x @return T */";
1691 let parsed = DocblockParser::parse(doc);
1692 assert_eq!(parsed.templates.len(), 1);
1693 assert_eq!(parsed.templates[0].0, "T");
1694 assert!(parsed.templates[0].1.is_none(), "expected no bound");
1695 }
1696
1697 #[test]
1698 fn parse_template_multiline_with_bound_still_works() {
1699 let doc = r#"/**
1701 * @template T of Base
1702 */"#;
1703 let parsed = DocblockParser::parse(doc);
1704 assert_eq!(parsed.templates.len(), 1);
1705 assert_eq!(parsed.templates[0].0, "T");
1706 let bound = parsed.templates[0].1.as_ref().expect("expected a bound");
1707 assert!(bound.contains(
1708 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Base")
1709 ));
1710 }
1711
1712 #[test]
1713 fn parse_template_extends_alias() {
1714 for tag in ["template-extends", "phpstan-extends"] {
1716 let doc = format!("/** @{tag} Base<User> */");
1717 let parsed = DocblockParser::parse(&doc);
1718 let extends = parsed
1719 .extends
1720 .unwrap_or_else(|| panic!("@{tag} should populate extends"));
1721 assert!(
1722 extends.contains(|t| matches!(
1723 t,
1724 Atomic::TNamedObject { fqcn, type_params }
1725 if fqcn.as_ref() == "Base" && !type_params.is_empty()
1726 )),
1727 "@{tag} should produce a generic Base<User>"
1728 );
1729 }
1730 }
1731
1732 #[test]
1733 fn parse_template_implements_alias() {
1734 for tag in ["template-implements", "phpstan-implements"] {
1736 let doc = format!("/** @{tag} Iter<User> */");
1737 let parsed = DocblockParser::parse(&doc);
1738 assert_eq!(
1739 parsed.implements.len(),
1740 1,
1741 "@{tag} should populate implements"
1742 );
1743 assert!(
1744 parsed.implements[0].contains(|t| matches!(
1745 t,
1746 Atomic::TNamedObject { fqcn, type_params }
1747 if fqcn.as_ref() == "Iter" && !type_params.is_empty()
1748 )),
1749 "@{tag} should produce a generic Iter<User>"
1750 );
1751 }
1752 }
1753
1754 #[test]
1755 fn parse_deprecated() {
1756 let doc = "/** @deprecated use newMethod() instead */";
1757 let parsed = DocblockParser::parse(doc);
1758 assert!(parsed.is_deprecated);
1759 assert_eq!(
1760 parsed.deprecated.as_deref(),
1761 Some("use newMethod() instead")
1762 );
1763 }
1764
1765 #[test]
1766 fn parse_since_plain() {
1767 let parsed = DocblockParser::parse("/** @since 8.0 */");
1768 assert_eq!(parsed.since.as_deref(), Some("8.0"));
1769 assert_eq!(parsed.removed, None);
1770 }
1771
1772 #[test]
1773 fn parse_since_strips_trailing_description() {
1774 let parsed = DocblockParser::parse("/** @since 1.4.0 added \\$options argument */");
1777 assert_eq!(parsed.since.as_deref(), Some("1.4.0"));
1778 }
1779
1780 #[test]
1781 fn parse_removed_tag() {
1782 let parsed = DocblockParser::parse("/** @removed 8.0 use mb_convert_encoding */");
1783 assert_eq!(parsed.removed.as_deref(), Some("8.0"));
1784 }
1785
1786 #[test]
1787 fn parse_since_empty_body_is_none() {
1788 let parsed = DocblockParser::parse("/** @since */");
1789 assert_eq!(parsed.since, None);
1790 }
1791
1792 #[test]
1793 fn parse_description() {
1794 let doc = r#"/**
1795 * This is a description.
1796 * Spans two lines.
1797 * @param string $x
1798 */"#;
1799 let parsed = DocblockParser::parse(doc);
1800 assert!(parsed.description.contains("This is a description"));
1801 assert!(parsed.description.contains("Spans two lines"));
1802 }
1803
1804 #[test]
1805 fn parse_see_and_link() {
1806 let doc = "/** @see SomeClass\n * @link https://example.com */";
1807 let parsed = DocblockParser::parse(doc);
1808 assert_eq!(parsed.see.len(), 2);
1809 assert!(parsed.see.contains(&"SomeClass".to_string()));
1810 assert!(parsed.see.contains(&"https://example.com".to_string()));
1811 }
1812
1813 #[test]
1814 fn parse_mixin() {
1815 let doc = "/** @mixin SomeTrait */";
1816 let parsed = DocblockParser::parse(doc);
1817 assert_eq!(parsed.mixins, vec!["SomeTrait".to_string()]);
1818 }
1819
1820 #[test]
1821 fn parse_property_tags() {
1822 let doc = r#"/**
1823 * @property string $name
1824 * @property-read int $id
1825 * @property-write bool $active
1826 */"#;
1827 let parsed = DocblockParser::parse(doc);
1828 assert_eq!(parsed.properties.len(), 3);
1829 let name_prop = parsed.properties.iter().find(|p| p.name == "name").unwrap();
1830 assert_eq!(name_prop.type_hint, "string");
1831 assert!(!name_prop.read_only);
1832 assert!(!name_prop.write_only);
1833 let id_prop = parsed.properties.iter().find(|p| p.name == "id").unwrap();
1834 assert!(id_prop.read_only);
1835 let active_prop = parsed
1836 .properties
1837 .iter()
1838 .find(|p| p.name == "active")
1839 .unwrap();
1840 assert!(active_prop.write_only);
1841 }
1842
1843 #[test]
1844 fn parse_method_tag() {
1845 let doc = r#"/**
1846 * @method string getName()
1847 * @method static int create()
1848 */"#;
1849 let parsed = DocblockParser::parse(doc);
1850 assert_eq!(parsed.methods.len(), 2);
1851 let get_name = parsed.methods.iter().find(|m| m.name == "getName").unwrap();
1852 assert_eq!(get_name.return_type, "string");
1853 assert!(!get_name.is_static);
1854 let create = parsed.methods.iter().find(|m| m.name == "create").unwrap();
1855 assert!(create.is_static);
1856 }
1857
1858 #[test]
1859 fn parse_method_tag_description_with_parens() {
1860 let doc = r#"/**
1864 * @method $this addDay() Add one day to the instance (using date interval).
1865 * @method $this subDays(int|float $value = 1) Sub days (the $value count passed in).
1866 */"#;
1867 let parsed = DocblockParser::parse(doc);
1868 let add_day = parsed.methods.iter().find(|m| m.name == "addDay").unwrap();
1869 assert_eq!(add_day.params.len(), 0, "addDay() must have zero params");
1870 let sub_days = parsed.methods.iter().find(|m| m.name == "subDays").unwrap();
1871 assert_eq!(sub_days.params.len(), 1);
1872 assert!(sub_days.params[0].is_optional);
1873 }
1874
1875 #[test]
1876 fn parse_type_alias_tag() {
1877 let doc = "/** @psalm-type MyAlias = string|int */";
1878 let parsed = DocblockParser::parse(doc);
1879 assert_eq!(parsed.type_aliases.len(), 1);
1880 assert_eq!(parsed.type_aliases[0].name, "MyAlias");
1881 assert_eq!(parsed.type_aliases[0].type_expr, "string|int");
1882 }
1883
1884 #[test]
1885 fn parse_import_type_no_as() {
1886 let doc = "/** @psalm-import-type UserId from UserRepository */";
1887 let parsed = DocblockParser::parse(doc);
1888 assert_eq!(parsed.import_types.len(), 1);
1889 assert_eq!(parsed.import_types[0].original, "UserId");
1890 assert_eq!(parsed.import_types[0].local, "UserId");
1891 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1892 }
1893
1894 #[test]
1895 fn parse_import_type_with_as() {
1896 let doc = "/** @psalm-import-type UserId as LocalId from UserRepository */";
1897 let parsed = DocblockParser::parse(doc);
1898 assert_eq!(parsed.import_types.len(), 1);
1899 assert_eq!(parsed.import_types[0].original, "UserId");
1900 assert_eq!(parsed.import_types[0].local, "LocalId");
1901 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1902 }
1903
1904 #[test]
1905 fn parse_require_extends() {
1906 let doc = "/** @psalm-require-extends Model */";
1907 let parsed = DocblockParser::parse(doc);
1908 assert_eq!(parsed.require_extends, vec!["Model".to_string()]);
1909 }
1910
1911 #[test]
1912 fn parse_require_implements() {
1913 let doc = "/** @psalm-require-implements Countable */";
1914 let parsed = DocblockParser::parse(doc);
1915 assert_eq!(parsed.require_implements, vec!["Countable".to_string()]);
1916 }
1917
1918 #[test]
1919 fn parse_intersection_two_parts() {
1920 let u = parse_type_string("Iterator&Countable");
1921 assert_eq!(u.types.len(), 1);
1922 assert!(matches!(u.types[0], Atomic::TIntersection { ref parts } if parts.len() == 2));
1923 if let Atomic::TIntersection { parts } = &u.types[0] {
1924 assert!(parts[0].contains(
1925 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1926 ));
1927 assert!(parts[1].contains(
1928 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1929 ));
1930 }
1931 }
1932
1933 #[test]
1934 fn parse_intersection_three_parts() {
1935 let u = parse_type_string("Iterator&Countable&Stringable");
1936 assert_eq!(u.types.len(), 1);
1937 let Atomic::TIntersection { parts } = &u.types[0] else {
1938 panic!("expected TIntersection");
1939 };
1940 assert_eq!(parts.len(), 3);
1941 assert!(parts[0].contains(
1942 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1943 ));
1944 assert!(parts[1].contains(
1945 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1946 ));
1947 assert!(parts[2].contains(
1948 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Stringable")
1949 ));
1950 }
1951
1952 #[test]
1953 fn parse_intersection_in_union_with_null() {
1954 let u = parse_type_string("Iterator&Countable|null");
1955 assert!(u.is_nullable());
1956 let intersection = u
1957 .types
1958 .iter()
1959 .find_map(|t| {
1960 if let Atomic::TIntersection { parts } = t {
1961 Some(parts)
1962 } else {
1963 None
1964 }
1965 })
1966 .expect("expected TIntersection");
1967 assert_eq!(intersection.len(), 2);
1968 assert!(intersection[0].contains(
1969 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1970 ));
1971 assert!(intersection[1].contains(
1972 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1973 ));
1974 }
1975
1976 #[test]
1977 fn parse_intersection_in_union_with_scalar() {
1978 let u = parse_type_string("Iterator&Countable|string");
1979 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1980 let intersection = u
1981 .types
1982 .iter()
1983 .find_map(|t| {
1984 if let Atomic::TIntersection { parts } = t {
1985 Some(parts)
1986 } else {
1987 None
1988 }
1989 })
1990 .expect("expected TIntersection");
1991 assert!(intersection[0].contains(
1992 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1993 ));
1994 assert!(intersection[1].contains(
1995 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1996 ));
1997 }
1998
1999 #[test]
2000 fn validate_unclosed_generic_return() {
2001 let parsed = DocblockParser::parse("/** @return array< */");
2002 assert_eq!(parsed.invalid_annotations.len(), 1);
2003 assert!(
2004 parsed.invalid_annotations[0].contains("unclosed generic"),
2005 "got: {}",
2006 parsed.invalid_annotations[0]
2007 );
2008 }
2009
2010 #[test]
2011 fn parse_empty_generic_array_graceful() {
2012 let u = parse_type_string("array<>");
2013 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
2014 }
2015
2016 #[test]
2017 fn parse_empty_generic_iterable_graceful() {
2018 let u = parse_type_string("iterable<>");
2019 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
2020 }
2021
2022 #[test]
2023 fn parse_empty_generic_non_empty_array_graceful() {
2024 let u = parse_type_string("non-empty-array<>");
2025 assert!(u.contains(|t| matches!(t, Atomic::TNonEmptyArray { .. })));
2026 }
2027
2028 #[test]
2029 fn validate_variable_in_type_position_param() {
2030 let parsed = DocblockParser::parse("/** @param Foo|$invalid $x */");
2031 assert_eq!(parsed.invalid_annotations.len(), 1);
2032 assert!(
2033 parsed.invalid_annotations[0].contains("$invalid"),
2034 "got: {}",
2035 parsed.invalid_annotations[0]
2036 );
2037 }
2038
2039 #[test]
2040 fn validate_this_is_valid_in_type_position() {
2041 let parsed = DocblockParser::parse("/** @return $this */");
2042 assert!(
2043 parsed.invalid_annotations.is_empty(),
2044 "unexpected error: {:?}",
2045 parsed.invalid_annotations
2046 );
2047 }
2048
2049 #[test]
2050 fn validate_unclosed_generic_var() {
2051 let parsed = DocblockParser::parse("/** @var array<string */");
2052 assert_eq!(parsed.invalid_annotations.len(), 1);
2053 assert!(parsed.invalid_annotations[0].contains("@var"));
2054 }
2055
2056 #[test]
2057 fn validate_variable_in_template_bound() {
2058 let parsed = DocblockParser::parse("/** @template T of $invalid */");
2059 assert_eq!(parsed.invalid_annotations.len(), 1);
2060 assert!(parsed.invalid_annotations[0].contains("$invalid"));
2061 }
2062}