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 "seal-properties" | "psalm-seal-properties" => result.seal_properties = true,
262 "no-named-arguments" => result.no_named_arguments = true,
263 "immutable" => result.is_immutable = true,
264 "readonly" => result.is_readonly = true,
265 "final" => result.is_final = true,
266 "inheritDoc" | "inheritdoc" => result.is_inherit_doc = true,
267 "api" | "psalm-api" => result.is_api = true,
268 "psalm-assert-if-true" | "phpstan-assert-if-true" => {
269 if let Some(body_str) = body_text(&tag.body) {
270 if let Some((ty_str, name)) = parse_param_line(&body_str) {
271 result
272 .assertions_if_true
273 .push((name, parse_type_string(&ty_str)));
274 }
275 }
276 }
277 "psalm-assert-if-false" | "phpstan-assert-if-false" => {
278 if let Some(body_str) = body_text(&tag.body) {
279 if let Some((ty_str, name)) = parse_param_line(&body_str) {
280 result
281 .assertions_if_false
282 .push((name, parse_type_string(&ty_str)));
283 }
284 }
285 }
286 "psalm-property" => {
287 if let Some(body_str) = body_text(&tag.body) {
288 if let Some((ty_str, name)) = parse_param_line(&body_str) {
289 result.properties.push(DocProperty {
290 type_hint: ty_str,
291 name,
292 read_only: false,
293 write_only: false,
294 });
295 }
296 }
297 }
298 "psalm-property-read" => {
299 if let Some(body_str) = body_text(&tag.body) {
300 if let Some((ty_str, name)) = parse_param_line(&body_str) {
301 result.properties.push(DocProperty {
302 type_hint: ty_str,
303 name,
304 read_only: true,
305 write_only: false,
306 });
307 }
308 }
309 }
310 "psalm-property-write" => {
311 if let Some(body_str) = body_text(&tag.body) {
312 if let Some((ty_str, name)) = parse_param_line(&body_str) {
313 result.properties.push(DocProperty {
314 type_hint: ty_str,
315 name,
316 read_only: false,
317 write_only: true,
318 });
319 }
320 }
321 }
322 "psalm-require-extends" | "phpstan-require-extends" => {
323 if let Some(body_str) = body_text(&tag.body) {
324 let cls = body_str
325 .split_whitespace()
326 .next()
327 .unwrap_or("")
328 .trim()
329 .to_string();
330 if !cls.is_empty() {
331 result.require_extends.push(cls);
332 }
333 }
334 }
335 "psalm-require-implements" | "phpstan-require-implements" => {
336 if let Some(body_str) = body_text(&tag.body) {
337 let cls = body_str
338 .split_whitespace()
339 .next()
340 .unwrap_or("")
341 .trim()
342 .to_string();
343 if !cls.is_empty() {
344 result.require_implements.push(cls);
345 }
346 }
347 }
348 "mir-check" => {
349 if let Some(body_str) = body_text(&tag.body) {
350 if let Some((var_part, type_part)) = body_str.split_once(" is ") {
351 let var_name = var_part.trim().trim_start_matches('$').to_string();
352 let type_string = type_part.trim().to_string();
353 if !var_name.is_empty() && !type_string.is_empty() {
354 result.mir_checks.push((var_name, type_string));
355 }
356 }
357 }
358 }
359 "trace" => {
360 if let Some(body_str) = body_text(&tag.body) {
361 for part in body_str.split([',', ' ']) {
363 let var_name = part.trim().trim_start_matches('$').to_string();
364 if !var_name.is_empty() {
365 result.trace_vars.push(var_name);
366 }
367 }
368 }
369 }
370 "taint-sink" => {
371 if let Some(body_str) = body_text(&tag.body) {
372 let mut tokens = body_str.split_whitespace();
374 if let Some(kind) = tokens.next() {
375 let kind = kind.to_string();
376 for param_token in tokens {
377 let param = param_token.trim_start_matches('$').to_string();
378 if !param.is_empty() {
379 result.taint_sinks.push((param, kind.clone()));
380 }
381 }
382 }
383 }
384 }
385 _ => {}
386 }
387 }
388
389 if text.to_ascii_lowercase().contains("{@inheritdoc}") {
390 result.is_inherit_doc = true;
391 }
392
393 result
394 }
395}
396
397#[derive(Debug, Default, Clone)]
402pub struct DocProperty {
403 pub type_hint: String,
404 pub name: String, pub read_only: bool, pub write_only: bool, }
408
409#[derive(Debug, Default, Clone)]
410pub struct DocMethod {
411 pub return_type: String,
412 pub name: String,
413 pub is_static: bool,
414 pub params: Vec<DocMethodParam>,
415}
416
417#[derive(Debug, Default, Clone)]
418pub struct DocMethodParam {
419 pub name: String,
420 pub type_hint: String,
421 pub is_variadic: bool,
422 pub is_byref: bool,
423 pub is_optional: bool,
424}
425
426#[derive(Debug, Default, Clone)]
427pub struct DocTypeAlias {
428 pub name: String,
429 pub type_expr: String,
430}
431
432#[derive(Debug, Default, Clone)]
433pub struct DocImportType {
434 pub original: String,
436 pub local: String,
438 pub from_class: String,
440}
441
442#[derive(Debug, Default, Clone)]
447pub struct ParsedDocblock {
448 pub params: Vec<(String, Type)>,
450 pub return_type: Option<Type>,
452 pub var_type: Option<Type>,
454 pub var_name: Option<String>,
456 pub templates: Vec<(String, Option<Type>, Variance)>,
458 pub extends: Option<Type>,
460 pub implements: Vec<Type>,
462 pub throws: Vec<String>,
464 pub assertions: Vec<(String, Type)>,
466 pub assertions_if_true: Vec<(String, Type)>,
468 pub assertions_if_false: Vec<(String, Type)>,
470 pub suppressed_issues: Vec<String>,
472 pub is_deprecated: bool,
473 pub is_internal: bool,
474 pub is_pure: bool,
475 pub no_named_arguments: bool,
476 pub is_immutable: bool,
477 pub is_readonly: bool,
478 pub is_api: bool,
479 pub is_final: bool,
481 pub is_inherit_doc: bool,
484 pub description: String,
486 pub deprecated: Option<String>,
488 pub see: Vec<String>,
490 pub mixins: Vec<String>,
492 pub properties: Vec<DocProperty>,
494 pub methods: Vec<DocMethod>,
496 pub type_aliases: Vec<DocTypeAlias>,
498 pub import_types: Vec<DocImportType>,
500 pub require_extends: Vec<String>,
502 pub require_implements: Vec<String>,
504 pub since: Option<String>,
506 pub removed: Option<String>,
508 pub invalid_annotations: Vec<String>,
510 pub mir_checks: Vec<(String, String)>,
512 pub trace_vars: Vec<String>,
514 pub taint_sinks: Vec<(String, String)>,
516 pub seal_properties: bool,
518}
519
520impl ParsedDocblock {
521 pub fn get_param_type(&self, name: &str) -> Option<&Type> {
527 let name = name.trim_start_matches('$');
528 self.params
529 .iter()
530 .rfind(|(n, _)| n.trim_start_matches('$') == name)
531 .map(|(_, ty)| ty)
532 }
533}
534
535pub fn parse_type_string(s: &str) -> Type {
543 let s = s.trim();
544
545 if let Some(inner) = s.strip_prefix('?') {
547 let inner_ty = parse_type_string(inner);
548 let mut u = inner_ty;
549 u.add_type(Atomic::TNull);
550 return u;
551 }
552
553 if s.starts_with('(') && s.ends_with(')') {
556 let inner = s[1..s.len() - 1].trim();
557 if let Some(conditional) = parse_conditional_type(inner) {
558 return conditional;
559 }
560 if is_balanced_parens(s) {
562 return parse_type_string(inner);
563 }
564 }
565
566 if s.contains('|') && !is_inside_generics(s) {
568 let parts = split_union(s);
569 if parts.len() > 1 {
570 let mut u = Type::empty();
571 for part in parts {
572 for atomic in parse_type_string(&part).types {
573 u.add_type(atomic);
574 }
575 }
576 return u;
577 }
578 }
579
580 if s.contains('&') && !is_inside_generics(s) {
583 let parts = split_intersection(s);
584 if parts.len() > 1 {
585 let parts: Vec<Type> = parts.iter().map(|p| parse_type_string(p.trim())).collect();
586 return Type::single(Atomic::TIntersection {
587 parts: mir_types::union::vec_to_type_params(parts),
588 });
589 }
590 }
591
592 if let Some(value_str) = s.strip_suffix("[]") {
594 let value = parse_type_string(value_str);
595 return Type::single(Atomic::TArray {
596 key: Box::new(Type::single(Atomic::TInt)),
597 value: Box::new(value),
598 });
599 }
600
601 if let Some(call_ty) = parse_callable_syntax(s) {
603 return call_ty;
604 }
605
606 if s.ends_with('}') {
608 if let Some(open) = s.find('{') {
609 let prefix = s[..open].to_lowercase();
610 let inner = &s[open + 1..s.len() - 1];
611 if prefix == "array" {
612 return parse_keyed_array(inner, false);
613 } else if prefix == "list" {
614 return parse_keyed_array(inner, true);
615 }
616 }
617 }
618
619 if let Some(open) = s.find('<') {
621 if s.ends_with('>') {
622 let name = &s[..open];
623 let inner = &s[open + 1..s.len() - 1];
624 return parse_generic(name, inner);
625 }
626 }
627
628 match s.to_lowercase().as_str() {
630 "string" => Type::single(Atomic::TString),
631 "non-empty-string" => Type::single(Atomic::TNonEmptyString),
632 "numeric-string" => Type::single(Atomic::TNumericString),
633 "class-string" => Type::single(Atomic::TClassString(None)),
634 "int" | "integer" => Type::single(Atomic::TInt),
635 "positive-int" => Type::single(Atomic::TPositiveInt),
636 "negative-int" => Type::single(Atomic::TNegativeInt),
637 "non-negative-int" => Type::single(Atomic::TNonNegativeInt),
638 "float" | "double" => Type::single(Atomic::TFloat),
639 "bool" | "boolean" => Type::single(Atomic::TBool),
640 "true" => Type::single(Atomic::TTrue),
641 "false" => Type::single(Atomic::TFalse),
642 "null" => Type::single(Atomic::TNull),
643 "void" => Type::single(Atomic::TVoid),
644 "never" | "never-return" | "no-return" | "never-returns" => Type::single(Atomic::TNever),
645 "mixed" => Type::single(Atomic::TMixed),
646 "object" => Type::single(Atomic::TObject),
647 "array" => Type::single(Atomic::TArray {
648 key: Box::new(Type::single(Atomic::TMixed)),
649 value: Box::new(Type::mixed()),
650 }),
651 "list" => Type::single(Atomic::TList {
652 value: Box::new(Type::mixed()),
653 }),
654 "callable" => Type::single(Atomic::TCallable {
655 params: None,
656 return_type: None,
657 }),
658 "callable-string" => Type::single(Atomic::TCallableString),
659 "iterable" => Type::single(Atomic::TArray {
660 key: Box::new(Type::single(Atomic::TMixed)),
661 value: Box::new(Type::mixed()),
662 }),
663 "scalar" => Type::single(Atomic::TScalar),
664 "numeric" => Type::single(Atomic::TNumeric),
665 "array-key" => {
666 let mut u = Type::single(Atomic::TInt);
667 u.add_type(Atomic::TString);
668 u
669 }
670 "resource" => Type::mixed(), "static" => Type::single(Atomic::TStaticObject {
673 fqcn: mir_types::Name::from(""),
674 }),
675 "self" | "$this" => Type::single(Atomic::TSelf {
676 fqcn: mir_types::Name::from(""),
677 }),
678 "parent" => Type::single(Atomic::TParent {
679 fqcn: mir_types::Name::from(""),
680 }),
681
682 _ if !s.is_empty()
684 && s.chars()
685 .next()
686 .map(|c| c.is_alphanumeric() || c == '\\' || c == '_')
687 .unwrap_or(false) =>
688 {
689 if let Ok(n) = s.parse::<i64>() {
691 return Type::single(Atomic::TLiteralInt(n));
692 }
693 Type::single(Atomic::TNamedObject {
694 fqcn: normalize_fqcn(s).into(),
695 type_params: mir_types::union::empty_type_params(),
696 })
697 }
698
699 _ if s.starts_with('-') && s.len() > 1 && s[1..].chars().all(|c| c.is_ascii_digit()) => {
701 if let Ok(n) = s.parse::<i64>() {
702 Type::single(Atomic::TLiteralInt(n))
703 } else {
704 Type::mixed()
705 }
706 }
707
708 _ if (s.starts_with('\'') && s.ends_with('\''))
710 || (s.starts_with('"') && s.ends_with('"')) =>
711 {
712 let inner = &s[1..s.len() - 1];
713 Type::single(Atomic::TLiteralString(Arc::from(inner)))
714 }
715
716 _ => Type::mixed(),
717 }
718}
719
720fn parse_generic(name: &str, inner: &str) -> Type {
721 match name.to_lowercase().as_str() {
722 "array" => {
723 let params = split_generics(inner);
724 let array_key = || {
725 let mut k = Type::single(Atomic::TInt);
726 k.add_type(Atomic::TString);
727 k
728 };
729 let (key, value) = match params.len() {
730 n if n >= 2 => (
731 parse_type_string(params[0].trim()),
732 parse_type_string(params[1].trim()),
733 ),
734 1 => (array_key(), parse_type_string(params[0].trim())),
735 _ => (array_key(), Type::mixed()),
736 };
737 Type::single(Atomic::TArray {
738 key: Box::new(key),
739 value: Box::new(value),
740 })
741 }
742 "list" | "non-empty-list" => {
743 let value = parse_type_string(inner.trim());
744 if name.to_lowercase().starts_with("non-empty") {
745 Type::single(Atomic::TNonEmptyList {
746 value: Box::new(value),
747 })
748 } else {
749 Type::single(Atomic::TList {
750 value: Box::new(value),
751 })
752 }
753 }
754 "non-empty-array" => {
755 let params = split_generics(inner);
756 let array_key = || {
757 let mut k = Type::single(Atomic::TInt);
758 k.add_type(Atomic::TString);
759 k
760 };
761 let (key, value) = match params.len() {
762 n if n >= 2 => (
763 parse_type_string(params[0].trim()),
764 parse_type_string(params[1].trim()),
765 ),
766 1 => (array_key(), parse_type_string(params[0].trim())),
767 _ => (array_key(), Type::mixed()),
768 };
769 Type::single(Atomic::TNonEmptyArray {
770 key: Box::new(key),
771 value: Box::new(value),
772 })
773 }
774 "iterable" => {
775 let params = split_generics(inner);
776 let value = match params.len() {
777 n if n >= 2 => parse_type_string(params[1].trim()),
778 1 => parse_type_string(params[0].trim()),
779 _ => Type::mixed(),
780 };
781 Type::single(Atomic::TArray {
782 key: Box::new(Type::single(Atomic::TMixed)),
783 value: Box::new(value),
784 })
785 }
786 "class-string" => Type::single(Atomic::TClassString(Some(
787 normalize_fqcn(inner.trim()).into(),
788 ))),
789 "int" => {
790 Type::single(Atomic::TIntRange {
792 min: None,
793 max: None,
794 })
795 }
796 _ => {
798 let params: Vec<Type> = split_generics(inner)
799 .iter()
800 .map(|p| parse_type_string(p.trim()))
801 .collect();
802 Type::single(Atomic::TNamedObject {
803 fqcn: normalize_fqcn(name).into(),
804 type_params: mir_types::union::vec_to_type_params(params),
805 })
806 }
807 }
808}
809
810fn strip_quotes(s: &str) -> &str {
811 if (s.starts_with('\'') && s.ends_with('\'')) || (s.starts_with('"') && s.ends_with('"')) {
812 &s[1..s.len() - 1]
813 } else {
814 s
815 }
816}
817
818fn parse_keyed_array(inner: &str, is_list: bool) -> Type {
819 use mir_types::atomic::KeyedProperty;
820 let mut properties: IndexMap<ArrayKey, KeyedProperty> = IndexMap::new();
821 let mut is_open = false;
822 let mut auto_index = 0i64;
823
824 for item in split_generics(inner) {
825 let item = item.trim();
826 if item.is_empty() {
827 continue;
828 }
829 if item == "..." {
830 is_open = true;
831 continue;
832 }
833 let colon_pos = {
835 let mut depth = 0i32;
836 let mut found = None;
837 for (i, ch) in item.char_indices() {
838 match ch {
839 '<' | '(' | '{' => depth += 1,
840 '>' | ')' | '}' => depth -= 1,
841 ':' if depth == 0 => {
842 found = Some(i);
843 break;
844 }
845 _ => {}
846 }
847 }
848 found
849 };
850 if let Some(colon) = colon_pos {
851 let key_part = item[..colon].trim();
852 let ty_part = item[colon + 1..].trim();
853 let optional = key_part.ends_with('?');
854 let key_str = key_part.trim_end_matches('?').trim();
855 let key_str = strip_quotes(key_str);
856 let key = if let Ok(n) = key_str.parse::<i64>() {
857 ArrayKey::Int(n)
858 } else {
859 ArrayKey::String(Arc::from(key_str))
860 };
861 properties.insert(
862 key,
863 KeyedProperty {
864 ty: parse_type_string(ty_part),
865 optional,
866 },
867 );
868 } else {
869 properties.insert(
870 ArrayKey::Int(auto_index),
871 KeyedProperty {
872 ty: parse_type_string(item),
873 optional: false,
874 },
875 );
876 auto_index += 1;
877 }
878 }
879
880 Type::single(Atomic::TKeyedArray {
881 properties,
882 is_open,
883 is_list,
884 })
885}
886
887fn parse_callable_syntax(s: &str) -> Option<Type> {
888 let s = s.trim_start_matches('\\');
889 let lower = s.to_lowercase();
890 let is_closure = lower.starts_with("closure");
891 let is_callable = lower.starts_with("callable");
892 if !is_closure && !is_callable {
893 return None;
894 }
895 let prefix_len = if is_closure {
896 "closure".len()
897 } else {
898 "callable".len()
899 };
900 let rest = s[prefix_len..].trim_start();
901 if !rest.starts_with('(') {
902 return None;
903 }
904 let close = find_matching_paren(rest)?;
905 let params_str = &rest[1..close];
906 let after = rest[close + 1..].trim();
907 let return_type = after
908 .strip_prefix(':')
909 .map(|ret_str| Box::new(parse_type_string(ret_str.trim())));
910 let params: Vec<mir_types::atomic::FnParam> = split_generics(params_str)
911 .into_iter()
912 .enumerate()
913 .filter(|(_, p)| !p.trim().is_empty())
914 .map(|(i, p)| {
915 let p = p.trim();
916 let (ty_str, name) = if let Some(dollar) = p.rfind('$') {
917 (p[..dollar].trim(), p[dollar + 1..].to_string())
918 } else {
919 (p, format!("arg{i}"))
920 };
921 mir_types::atomic::FnParam {
922 name: name.into(),
923 ty: Some(mir_types::SimpleType::from_union(parse_type_string(ty_str))),
924 default: None,
925 is_variadic: false,
926 is_byref: false,
927 is_optional: false,
928 }
929 })
930 .collect();
931 if is_closure {
932 Some(Type::single(Atomic::TClosure {
933 params,
934 return_type: return_type.unwrap_or_else(|| Box::new(Type::single(Atomic::TVoid))),
935 this_type: None,
936 }))
937 } else {
938 Some(Type::single(Atomic::TCallable {
939 params: Some(params),
940 return_type,
941 }))
942 }
943}
944
945fn find_matching_paren(s: &str) -> Option<usize> {
946 if !s.starts_with('(') {
947 return None;
948 }
949 let mut depth = 0i32;
950 for (i, ch) in s.char_indices() {
951 match ch {
952 '(' | '<' | '{' => depth += 1,
953 ')' | '>' | '}' => {
954 depth -= 1;
955 if depth == 0 {
956 return Some(i);
957 }
958 }
959 _ => {}
960 }
961 }
962 None
963}
964
965fn parse_template_line(_tag_name: &str, body: Option<String>) -> Option<(String, Option<String>)> {
977 let body = body?;
978 let body = body.trim();
979 let body = body.lines().next().unwrap_or(body).trim_end();
984 let body = match body.find(" @") {
987 Some(idx) => body[..idx].trim_end(),
988 None => body,
989 };
990 if body.is_empty() {
991 return None;
992 }
993 if let Some((name, bound)) = body.split_once(" of ").or_else(|| body.split_once(" as ")) {
994 let bound = bound.trim();
995 Some((
996 name.trim().to_string(),
997 (!bound.is_empty()).then(|| bound.to_string()),
998 ))
999 } else {
1000 let name = body.split_whitespace().next().unwrap_or(body);
1002 Some((name.to_string(), None))
1003 }
1004}
1005
1006fn extract_description(text: &str) -> String {
1008 let mut desc_lines: Vec<&str> = Vec::new();
1009 for line in text.lines() {
1010 let l = line.trim();
1011 let l = l.trim_start_matches("/**").trim();
1012 let l = l.trim_end_matches("*/").trim();
1013 let l = l.trim_start_matches("*/").trim();
1014 let l = l.strip_prefix("* ").unwrap_or(l.trim_start_matches('*'));
1015 let l = l.trim();
1016 if l.starts_with('@') {
1017 break;
1018 }
1019 if !l.is_empty() {
1020 desc_lines.push(l);
1021 }
1022 }
1023 desc_lines.join(" ")
1024}
1025
1026fn parse_import_type(body: &str) -> Option<DocImportType> {
1032 let (before_from, from_class_raw) = body.split_once(" from ")?;
1034 let from_class = from_class_raw.trim().trim_start_matches('\\').to_string();
1035 if from_class.is_empty() {
1036 return None;
1037 }
1038 let (original, local) = if let Some((orig, loc)) = before_from.split_once(" as ") {
1040 (orig.trim().to_string(), loc.trim().to_string())
1041 } else {
1042 let name = before_from.trim().to_string();
1043 (name.clone(), name)
1044 };
1045 if original.is_empty() || local.is_empty() {
1046 return None;
1047 }
1048 Some(DocImportType {
1049 original,
1050 local,
1051 from_class,
1052 })
1053}
1054
1055fn parse_param_line(s: &str) -> Option<(String, String)> {
1056 let first_line = s.lines().next().unwrap_or(s);
1062
1063 let mut best_split: Option<(String, String)> = None;
1066
1067 for (i, ch) in first_line.char_indices() {
1068 if ch.is_whitespace() {
1069 let after = first_line[i..].trim_start();
1070 let after_stripped = after.strip_prefix('&').unwrap_or(after);
1072 if after_stripped.starts_with('$') {
1073 let mut var_parts = after_stripped.split(char::is_whitespace);
1074 if let Some(name_with_dollar) = var_parts.next() {
1075 let name = name_with_dollar.trim_start_matches('$').to_string();
1076 if !name.is_empty() {
1077 let type_part = first_line[..i].trim().to_string();
1078 if !type_part.is_empty() {
1079 best_split = Some((type_part, name));
1080 }
1081 }
1082 }
1083 }
1084 }
1085 }
1086
1087 best_split
1088}
1089
1090fn extract_return_type(s: &str) -> String {
1091 let mut depth: i32 = 0;
1100 let mut current_token = String::new();
1101
1102 for ch in s.chars() {
1103 match ch {
1104 '<' | '(' | '{' => {
1105 depth += 1;
1106 current_token.push(ch);
1107 }
1108 '>' | ')' | '}' => {
1109 depth = (depth - 1).max(0);
1110 current_token.push(ch);
1111 }
1112 _ if ch.is_whitespace() && depth == 0 => {
1113 break;
1114 }
1115 _ => {
1116 current_token.push(ch);
1117 }
1118 }
1119 }
1120
1121 if current_token.ends_with(':') {
1125 let offset = current_token.len();
1126 let rest = s[offset..].trim_start();
1127 if !rest.is_empty() {
1128 let ret_type = extract_return_type(rest);
1129 current_token.push_str(&ret_type);
1130 }
1131 }
1132
1133 current_token.trim().to_string()
1134}
1135
1136fn split_union(s: &str) -> Vec<String> {
1137 let mut parts = Vec::new();
1138 let mut depth = 0;
1139 let mut current = String::new();
1140 for ch in s.chars() {
1141 match ch {
1142 '<' | '(' | '{' => {
1143 depth += 1;
1144 current.push(ch);
1145 }
1146 '>' | ')' | '}' => {
1147 depth -= 1;
1148 current.push(ch);
1149 }
1150 '|' if depth == 0 => {
1151 parts.push(current.trim().to_string());
1152 current = String::new();
1153 }
1154 _ => current.push(ch),
1155 }
1156 }
1157 if !current.trim().is_empty() {
1158 parts.push(current.trim().to_string());
1159 }
1160 parts
1161}
1162
1163fn split_intersection(s: &str) -> Vec<String> {
1165 let mut parts = Vec::new();
1166 let mut depth = 0i32;
1167 let mut current = String::new();
1168 for ch in s.chars() {
1169 match ch {
1170 '<' | '(' | '{' => {
1171 depth += 1;
1172 current.push(ch);
1173 }
1174 '>' | ')' | '}' => {
1175 depth -= 1;
1176 current.push(ch);
1177 }
1178 '&' if depth == 0 => {
1179 parts.push(current.trim().to_string());
1180 current = String::new();
1181 }
1182 _ => current.push(ch),
1183 }
1184 }
1185 if !current.trim().is_empty() {
1186 parts.push(current.trim().to_string());
1187 }
1188 parts
1189}
1190
1191fn is_balanced_parens(s: &str) -> bool {
1195 if !s.starts_with('(') || !s.ends_with(')') {
1196 return false;
1197 }
1198 let mut depth = 0i32;
1199 let chars: Vec<char> = s.chars().collect();
1200 let last = chars.len() - 1;
1201 for (i, ch) in chars.iter().enumerate() {
1202 match ch {
1203 '(' => depth += 1,
1204 ')' => {
1205 depth -= 1;
1206 if depth == 0 && i < last {
1209 return false;
1210 }
1211 }
1212 _ => {}
1213 }
1214 }
1215 depth == 0
1216}
1217
1218fn split_generics(s: &str) -> Vec<String> {
1219 let mut parts = Vec::new();
1220 let mut depth = 0;
1221 let mut current = String::new();
1222 for ch in s.chars() {
1223 match ch {
1224 '<' | '(' | '{' => {
1225 depth += 1;
1226 current.push(ch);
1227 }
1228 '>' | ')' | '}' => {
1229 depth -= 1;
1230 current.push(ch);
1231 }
1232 ',' if depth == 0 => {
1233 parts.push(current.trim().to_string());
1234 current = String::new();
1235 }
1236 _ => current.push(ch),
1237 }
1238 }
1239 if !current.trim().is_empty() {
1240 parts.push(current.trim().to_string());
1241 }
1242 parts
1243}
1244
1245fn extract_type_prefix(s: &str) -> &str {
1248 let mut depth = 0i32;
1249 let mut end = s.len();
1250 for (i, ch) in s.char_indices() {
1251 match ch {
1252 '<' | '(' | '{' => depth += 1,
1253 '>' | ')' | '}' => depth -= 1,
1254 _ if ch.is_whitespace() && depth == 0 => {
1255 end = i;
1256 break;
1257 }
1258 _ => {}
1259 }
1260 }
1261 &s[..end]
1262}
1263
1264fn is_inside_generics(s: &str) -> bool {
1265 let mut depth = 0i32;
1266 for ch in s.chars() {
1267 match ch {
1268 '<' | '(' | '{' => depth += 1,
1269 '>' | ')' | '}' => depth -= 1,
1270 _ => {}
1271 }
1272 }
1273 depth != 0
1274}
1275
1276fn parse_conditional_type(s: &str) -> Option<Type> {
1279 let is_pos = s.find(" is ")?;
1280 let param_raw = s[..is_pos].trim();
1281
1282 let param_name_str: &str = if let Some(name) = param_raw.strip_prefix('$') {
1284 if name.is_empty() {
1285 return None;
1286 }
1287 name
1288 } else {
1289 if param_raw.is_empty()
1292 || !param_raw.starts_with(|c: char| c.is_alphabetic() || c == '_')
1293 || !param_raw.chars().all(|c| c.is_alphanumeric() || c == '_')
1294 || !s.contains('?')
1295 {
1296 return None;
1297 }
1298 param_raw
1299 };
1300 let param_name = Some(mir_types::Name::new(param_name_str));
1301 let after_is = s[is_pos + 4..].trim();
1302 let q_pos = find_char_at_depth(after_is, '?')?;
1303 let subject_str = after_is[..q_pos].trim();
1304 let rest = after_is[q_pos + 1..].trim();
1305 let colon_pos = find_char_at_depth(rest, ':')?;
1306 let true_str = rest[..colon_pos].trim();
1307 let false_str = rest[colon_pos + 1..].trim();
1308 Some(Type::single(Atomic::TConditional {
1309 param_name,
1310 subject: Box::new(parse_type_string(subject_str)),
1311 if_true: Box::new(parse_type_string(true_str)),
1312 if_false: Box::new(parse_type_string(false_str)),
1313 }))
1314}
1315
1316fn find_char_at_depth(s: &str, target: char) -> Option<usize> {
1318 let mut depth = 0i32;
1319 for (i, ch) in s.char_indices() {
1320 match ch {
1321 '<' | '(' | '{' => depth += 1,
1322 '>' | ')' | '}' => depth -= 1,
1323 _ if ch == target && depth == 0 => return Some(i),
1324 _ => {}
1325 }
1326 }
1327 None
1328}
1329
1330fn normalize_fqcn(s: &str) -> String {
1331 s.trim_start_matches('\\').to_string()
1333}
1334
1335fn validate_type_str(s: &str, tag: &str) -> Option<String> {
1341 let s = s.trim();
1342 if s.is_empty() {
1343 return None;
1344 }
1345 if is_inside_generics(s) {
1346 return Some(format!("@{tag} has unclosed generic type `{s}`"));
1347 }
1348 let is_callable_type = s.to_lowercase().contains("callable") || s.contains("Closure");
1350 if !is_callable_type && has_empty_generics(s) {
1351 return Some(format!("@{tag} has empty generic type parameter in `{s}`"));
1352 }
1353 for part in split_union(s) {
1354 let p = part.trim();
1355 if p.starts_with('$') && p != "$this" {
1356 return Some(format!("@{tag} contains variable `{p}` in type position"));
1357 }
1358 if let Some(err) = validate_generic_semantics(p, tag) {
1359 return Some(err);
1360 }
1361 }
1362 None
1363}
1364
1365fn validate_generic_semantics(s: &str, tag: &str) -> Option<String> {
1367 let lower = s.to_lowercase();
1368 let (name, inner) = extract_generic_content(s)?;
1369 match lower[..name.len()].as_ref() {
1370 "int" => validate_int_range_inner(inner, tag),
1371 "array" | "non-empty-array" => validate_array_key_inner(inner, tag),
1372 _ => None,
1373 }
1374}
1375
1376fn extract_generic_content(s: &str) -> Option<(&str, &str)> {
1378 let lt = s.find('<')?;
1379 let name = s[..lt].trim();
1380 if name.is_empty() {
1381 return None;
1382 }
1383 let after_lt = &s[lt + 1..];
1384 let mut depth = 1i32;
1385 for (i, ch) in after_lt.char_indices() {
1386 match ch {
1387 '<' | '(' | '{' => depth += 1,
1388 '>' | ')' | '}' => {
1389 depth -= 1;
1390 if depth == 0 {
1391 return Some((name, &after_lt[..i]));
1392 }
1393 }
1394 _ => {}
1395 }
1396 }
1397 None
1398}
1399
1400fn validate_int_range_inner(inner: &str, tag: &str) -> Option<String> {
1401 let mut parts = inner.splitn(2, ',');
1402 let min_str = parts.next()?.trim();
1403 let max_str = parts.next()?.trim();
1404
1405 if min_str == "max" {
1406 return Some(format!(
1407 "@{tag} has invalid int range: `max` must be the second argument, not the first"
1408 ));
1409 }
1410 if max_str == "min" {
1411 return Some(format!(
1412 "@{tag} has invalid int range: `min` must be the first argument, not the second"
1413 ));
1414 }
1415
1416 let is_valid_bound = |s: &str| s == "min" || s == "max" || s.parse::<i64>().is_ok();
1417
1418 if !is_valid_bound(min_str) {
1419 return Some(format!(
1420 "@{tag} has invalid int range boundary `{min_str}`: must be an integer literal, `min`, or `max`"
1421 ));
1422 }
1423 if !is_valid_bound(max_str) {
1424 return Some(format!(
1425 "@{tag} has invalid int range boundary `{max_str}`: must be an integer literal, `min`, or `max`"
1426 ));
1427 }
1428
1429 if let (Ok(lo), Ok(hi)) = (min_str.parse::<i64>(), max_str.parse::<i64>()) {
1430 if lo > hi {
1431 return Some(format!(
1432 "@{tag} has invalid int range: min ({lo}) must not be greater than max ({hi})"
1433 ));
1434 }
1435 }
1436 None
1437}
1438
1439fn validate_array_key_inner(inner: &str, tag: &str) -> Option<String> {
1440 let params = split_generics(inner);
1441 if params.len() < 2 {
1442 return None;
1443 }
1444 let key_str = params[0].trim();
1445 let invalid_key_types = ["float", "bool", "true", "false"];
1449 if invalid_key_types.contains(&key_str.to_lowercase().as_str()) {
1450 return Some(format!(
1451 "@{tag} has invalid array key type `{key_str}`: must be a subtype of int|string"
1452 ));
1453 }
1454 None
1455}
1456
1457fn has_empty_generics(s: &str) -> bool {
1458 let mut depth = 0;
1459 let mut prev_open = false;
1460 for ch in s.chars() {
1461 match ch {
1462 '<' | '(' | '{' => {
1463 if prev_open && depth == 0 {
1464 return true;
1465 }
1466 prev_open = true;
1467 depth += 1;
1468 }
1469 '>' | ')' | '}' => {
1470 depth -= 1;
1471 if depth == 0 {
1472 if prev_open {
1473 return true;
1474 }
1475 prev_open = false;
1476 }
1477 }
1478 c if !c.is_whitespace() => {
1479 prev_open = false;
1480 }
1481 _ => {}
1482 }
1483 }
1484 false
1485}
1486
1487fn validate_method_body(s: &str) -> Option<String> {
1490 let s = s.trim();
1491 if s.is_empty() {
1492 return Some("@method annotation is missing a method definition".to_string());
1493 }
1494 let rest = if s.to_lowercase().starts_with("static ") {
1496 s["static ".len()..].trim_start()
1497 } else {
1498 s
1499 };
1500 let open = rest.find('(').unwrap_or(rest.len());
1502 let prefix = rest[..open].trim();
1503 let parts: Vec<&str> = prefix.split_whitespace().collect();
1504 let name = parts.last().unwrap_or(&"");
1505 if !name.is_empty() && !is_valid_php_identifier(name) {
1507 return Some(format!(
1508 "@method has invalid method name `{name}`: must be a valid PHP identifier"
1509 ));
1510 }
1511 if rest.contains('(') {
1513 let params_str = rest;
1514 let open_pos = params_str.find('(').unwrap();
1515 let after_open = ¶ms_str[open_pos + 1..];
1516 if let Some(rel_close) = find_matching_paren(¶ms_str[open_pos..]) {
1517 let close_pos = open_pos + rel_close;
1518 let inner = params_str[open_pos + 1..close_pos].trim();
1519 if !inner.is_empty() {
1520 for param in split_generics(inner) {
1521 let param = param.trim();
1522 if param.starts_with('&') {
1523 return Some(format!(
1524 "@method parameter `{param}` uses by-reference (`&`) which is not supported in @method annotations"
1525 ));
1526 }
1527 if let Some(amp_pos) = param.find('&') {
1529 let before_amp = ¶m[..amp_pos];
1530 let after_amp = param[amp_pos + 1..].trim_start();
1531 if !before_amp.trim().is_empty() && after_amp.starts_with('$') {
1532 return Some(format!(
1533 "@method parameter `{param}` uses by-reference (`&`) which is not supported in @method annotations"
1534 ));
1535 }
1536 }
1537 }
1538 }
1539 } else {
1540 let _ = after_open;
1541 }
1542 }
1543 None
1544}
1545
1546fn is_valid_php_identifier(s: &str) -> bool {
1547 let mut chars = s.chars();
1548 match chars.next() {
1549 Some(c) if c.is_alphabetic() || c == '_' => {}
1550 _ => return false,
1551 }
1552 chars.all(|c| c.is_alphanumeric() || c == '_')
1553}
1554
1555fn parse_method_line(s: &str) -> Option<DocMethod> {
1557 let mut rest = s.trim();
1558 if rest.is_empty() {
1559 return None;
1560 }
1561 let is_static = rest
1562 .split_whitespace()
1563 .next()
1564 .map(|w| w.eq_ignore_ascii_case("static"))
1565 .unwrap_or(false);
1566 if is_static {
1567 rest = rest["static".len()..].trim_start();
1568 }
1569
1570 let open = rest.find('(').unwrap_or(rest.len());
1571 let prefix = rest[..open].trim();
1572 let mut parts: Vec<&str> = prefix.split_whitespace().collect();
1573 let name = parts.pop()?.to_string();
1574 if name.is_empty() {
1575 return None;
1576 }
1577 let return_type = parts.join(" ");
1578 Some(DocMethod {
1579 return_type,
1580 name,
1581 is_static,
1582 params: parse_method_params(rest),
1583 })
1584}
1585
1586fn parse_method_params(name_part: &str) -> Vec<DocMethodParam> {
1587 let Some(open) = name_part.find('(') else {
1588 return vec![];
1589 };
1590 let Some(rel_close) = find_matching_paren(&name_part[open..]) else {
1594 return vec![];
1595 };
1596 let close = open + rel_close;
1597 let inner = name_part[open + 1..close].trim();
1598 if inner.is_empty() {
1599 return vec![];
1600 }
1601
1602 split_generics(inner)
1603 .into_iter()
1604 .filter_map(|param| parse_method_param(¶m))
1605 .collect()
1606}
1607
1608fn parse_method_param(param: &str) -> Option<DocMethodParam> {
1609 let before_default = param.split('=').next()?.trim();
1610 let is_optional = param.contains('=');
1611 let mut tokens: Vec<&str> = before_default.split_whitespace().collect();
1612 let raw_name = tokens.pop()?;
1613 let is_variadic = raw_name.contains("...");
1614 let is_byref = raw_name.contains('&');
1615 let name = raw_name
1616 .trim_start_matches('&')
1617 .trim_start_matches("...")
1618 .trim_start_matches('&')
1619 .trim_start_matches('$')
1620 .to_string();
1621 if name.is_empty() {
1622 return None;
1623 }
1624 Some(DocMethodParam {
1625 name,
1626 type_hint: tokens.join(" "),
1627 is_variadic,
1628 is_byref,
1629 is_optional: is_optional || is_variadic,
1630 })
1631}
1632
1633#[cfg(test)]
1638mod tests {
1639 use super::*;
1640 use mir_types::Atomic;
1641
1642 #[test]
1643 fn parse_string() {
1644 let u = parse_type_string("string");
1645 assert_eq!(u.types.len(), 1);
1646 assert!(matches!(u.types[0], Atomic::TString));
1647 }
1648
1649 #[test]
1650 fn parse_nullable_string() {
1651 let u = parse_type_string("?string");
1652 assert!(u.is_nullable());
1653 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1654 }
1655
1656 #[test]
1657 fn parse_union() {
1658 let u = parse_type_string("string|int|null");
1659 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1660 assert!(u.contains(|t| matches!(t, Atomic::TInt)));
1661 assert!(u.is_nullable());
1662 }
1663
1664 #[test]
1665 fn parse_array_of_string() {
1666 let u = parse_type_string("array<string>");
1667 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1668 }
1669
1670 #[test]
1671 fn parse_list_of_int() {
1672 let u = parse_type_string("list<int>");
1673 assert!(u.contains(|t| matches!(t, Atomic::TList { .. })));
1674 }
1675
1676 #[test]
1677 fn parse_named_class() {
1678 let u = parse_type_string("Foo\\Bar");
1679 assert!(u.contains(
1680 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Foo\\Bar")
1681 ));
1682 }
1683
1684 #[test]
1685 fn parse_docblock_param_return() {
1686 let doc = r#"/**
1687 * @param string $name
1688 * @param int $age
1689 * @return bool
1690 */"#;
1691 let parsed = DocblockParser::parse(doc);
1692 assert_eq!(parsed.params.len(), 2);
1693 assert!(parsed.return_type.is_some());
1694 let ret = parsed.return_type.unwrap();
1695 assert!(ret.contains(|t| matches!(t, Atomic::TBool)));
1696 }
1697
1698 #[test]
1699 fn parse_template() {
1700 let doc = "/** @template T of object */";
1701 let parsed = DocblockParser::parse(doc);
1702 assert_eq!(parsed.templates.len(), 1);
1703 assert_eq!(parsed.templates[0].0, "T");
1704 assert!(parsed.templates[0].1.is_some());
1705 assert_eq!(parsed.templates[0].2, Variance::Invariant);
1706 }
1707
1708 #[test]
1709 fn parse_template_covariant() {
1710 let doc = "/** @template-covariant T */";
1711 let parsed = DocblockParser::parse(doc);
1712 assert_eq!(parsed.templates.len(), 1);
1713 assert_eq!(parsed.templates[0].0, "T");
1714 assert_eq!(parsed.templates[0].2, Variance::Covariant);
1715 }
1716
1717 #[test]
1718 fn parse_template_contravariant() {
1719 let doc = "/** @template-contravariant T */";
1720 let parsed = DocblockParser::parse(doc);
1721 assert_eq!(parsed.templates.len(), 1);
1722 assert_eq!(parsed.templates[0].0, "T");
1723 assert_eq!(parsed.templates[0].2, Variance::Contravariant);
1724 }
1725
1726 #[test]
1727 fn parse_template_single_line_does_not_over_read() {
1728 let doc = "/** @template T @param T $x @return T */";
1731 let parsed = DocblockParser::parse(doc);
1732 assert_eq!(parsed.templates.len(), 1);
1733 assert_eq!(parsed.templates[0].0, "T");
1734 assert!(parsed.templates[0].1.is_none(), "expected no bound");
1735 }
1736
1737 #[test]
1738 fn parse_template_multiline_with_bound_still_works() {
1739 let doc = r#"/**
1741 * @template T of Base
1742 */"#;
1743 let parsed = DocblockParser::parse(doc);
1744 assert_eq!(parsed.templates.len(), 1);
1745 assert_eq!(parsed.templates[0].0, "T");
1746 let bound = parsed.templates[0].1.as_ref().expect("expected a bound");
1747 assert!(bound.contains(
1748 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Base")
1749 ));
1750 }
1751
1752 #[test]
1753 fn parse_template_extends_alias() {
1754 for tag in ["template-extends", "phpstan-extends"] {
1756 let doc = format!("/** @{tag} Base<User> */");
1757 let parsed = DocblockParser::parse(&doc);
1758 let extends = parsed
1759 .extends
1760 .unwrap_or_else(|| panic!("@{tag} should populate extends"));
1761 assert!(
1762 extends.contains(|t| matches!(
1763 t,
1764 Atomic::TNamedObject { fqcn, type_params }
1765 if fqcn.as_ref() == "Base" && !type_params.is_empty()
1766 )),
1767 "@{tag} should produce a generic Base<User>"
1768 );
1769 }
1770 }
1771
1772 #[test]
1773 fn parse_template_implements_alias() {
1774 for tag in ["template-implements", "phpstan-implements"] {
1776 let doc = format!("/** @{tag} Iter<User> */");
1777 let parsed = DocblockParser::parse(&doc);
1778 assert_eq!(
1779 parsed.implements.len(),
1780 1,
1781 "@{tag} should populate implements"
1782 );
1783 assert!(
1784 parsed.implements[0].contains(|t| matches!(
1785 t,
1786 Atomic::TNamedObject { fqcn, type_params }
1787 if fqcn.as_ref() == "Iter" && !type_params.is_empty()
1788 )),
1789 "@{tag} should produce a generic Iter<User>"
1790 );
1791 }
1792 }
1793
1794 #[test]
1795 fn parse_deprecated() {
1796 let doc = "/** @deprecated use newMethod() instead */";
1797 let parsed = DocblockParser::parse(doc);
1798 assert!(parsed.is_deprecated);
1799 assert_eq!(
1800 parsed.deprecated.as_deref(),
1801 Some("use newMethod() instead")
1802 );
1803 }
1804
1805 #[test]
1806 fn parse_since_plain() {
1807 let parsed = DocblockParser::parse("/** @since 8.0 */");
1808 assert_eq!(parsed.since.as_deref(), Some("8.0"));
1809 assert_eq!(parsed.removed, None);
1810 }
1811
1812 #[test]
1813 fn parse_since_strips_trailing_description() {
1814 let parsed = DocblockParser::parse("/** @since 1.4.0 added \\$options argument */");
1817 assert_eq!(parsed.since.as_deref(), Some("1.4.0"));
1818 }
1819
1820 #[test]
1821 fn parse_removed_tag() {
1822 let parsed = DocblockParser::parse("/** @removed 8.0 use mb_convert_encoding */");
1823 assert_eq!(parsed.removed.as_deref(), Some("8.0"));
1824 }
1825
1826 #[test]
1827 fn parse_since_empty_body_is_none() {
1828 let parsed = DocblockParser::parse("/** @since */");
1829 assert_eq!(parsed.since, None);
1830 }
1831
1832 #[test]
1833 fn parse_description() {
1834 let doc = r#"/**
1835 * This is a description.
1836 * Spans two lines.
1837 * @param string $x
1838 */"#;
1839 let parsed = DocblockParser::parse(doc);
1840 assert!(parsed.description.contains("This is a description"));
1841 assert!(parsed.description.contains("Spans two lines"));
1842 }
1843
1844 #[test]
1845 fn parse_see_and_link() {
1846 let doc = "/** @see SomeClass\n * @link https://example.com */";
1847 let parsed = DocblockParser::parse(doc);
1848 assert_eq!(parsed.see.len(), 2);
1849 assert!(parsed.see.contains(&"SomeClass".to_string()));
1850 assert!(parsed.see.contains(&"https://example.com".to_string()));
1851 }
1852
1853 #[test]
1854 fn parse_mixin() {
1855 let doc = "/** @mixin SomeTrait */";
1856 let parsed = DocblockParser::parse(doc);
1857 assert_eq!(parsed.mixins, vec!["SomeTrait".to_string()]);
1858 }
1859
1860 #[test]
1861 fn parse_property_tags() {
1862 let doc = r#"/**
1863 * @property string $name
1864 * @property-read int $id
1865 * @property-write bool $active
1866 */"#;
1867 let parsed = DocblockParser::parse(doc);
1868 assert_eq!(parsed.properties.len(), 3);
1869 let name_prop = parsed.properties.iter().find(|p| p.name == "name").unwrap();
1870 assert_eq!(name_prop.type_hint, "string");
1871 assert!(!name_prop.read_only);
1872 assert!(!name_prop.write_only);
1873 let id_prop = parsed.properties.iter().find(|p| p.name == "id").unwrap();
1874 assert!(id_prop.read_only);
1875 let active_prop = parsed
1876 .properties
1877 .iter()
1878 .find(|p| p.name == "active")
1879 .unwrap();
1880 assert!(active_prop.write_only);
1881 }
1882
1883 #[test]
1884 fn parse_method_tag() {
1885 let doc = r#"/**
1886 * @method string getName()
1887 * @method static int create()
1888 */"#;
1889 let parsed = DocblockParser::parse(doc);
1890 assert_eq!(parsed.methods.len(), 2);
1891 let get_name = parsed.methods.iter().find(|m| m.name == "getName").unwrap();
1892 assert_eq!(get_name.return_type, "string");
1893 assert!(!get_name.is_static);
1894 let create = parsed.methods.iter().find(|m| m.name == "create").unwrap();
1895 assert!(create.is_static);
1896 }
1897
1898 #[test]
1899 fn parse_method_tag_description_with_parens() {
1900 let doc = r#"/**
1904 * @method $this addDay() Add one day to the instance (using date interval).
1905 * @method $this subDays(int|float $value = 1) Sub days (the $value count passed in).
1906 */"#;
1907 let parsed = DocblockParser::parse(doc);
1908 let add_day = parsed.methods.iter().find(|m| m.name == "addDay").unwrap();
1909 assert_eq!(add_day.params.len(), 0, "addDay() must have zero params");
1910 let sub_days = parsed.methods.iter().find(|m| m.name == "subDays").unwrap();
1911 assert_eq!(sub_days.params.len(), 1);
1912 assert!(sub_days.params[0].is_optional);
1913 }
1914
1915 #[test]
1916 fn parse_type_alias_tag() {
1917 let doc = "/** @psalm-type MyAlias = string|int */";
1918 let parsed = DocblockParser::parse(doc);
1919 assert_eq!(parsed.type_aliases.len(), 1);
1920 assert_eq!(parsed.type_aliases[0].name, "MyAlias");
1921 assert_eq!(parsed.type_aliases[0].type_expr, "string|int");
1922 }
1923
1924 #[test]
1925 fn parse_import_type_no_as() {
1926 let doc = "/** @psalm-import-type UserId from UserRepository */";
1927 let parsed = DocblockParser::parse(doc);
1928 assert_eq!(parsed.import_types.len(), 1);
1929 assert_eq!(parsed.import_types[0].original, "UserId");
1930 assert_eq!(parsed.import_types[0].local, "UserId");
1931 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1932 }
1933
1934 #[test]
1935 fn parse_import_type_with_as() {
1936 let doc = "/** @psalm-import-type UserId as LocalId from UserRepository */";
1937 let parsed = DocblockParser::parse(doc);
1938 assert_eq!(parsed.import_types.len(), 1);
1939 assert_eq!(parsed.import_types[0].original, "UserId");
1940 assert_eq!(parsed.import_types[0].local, "LocalId");
1941 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1942 }
1943
1944 #[test]
1945 fn parse_require_extends() {
1946 let doc = "/** @psalm-require-extends Model */";
1947 let parsed = DocblockParser::parse(doc);
1948 assert_eq!(parsed.require_extends, vec!["Model".to_string()]);
1949 }
1950
1951 #[test]
1952 fn parse_require_implements() {
1953 let doc = "/** @psalm-require-implements Countable */";
1954 let parsed = DocblockParser::parse(doc);
1955 assert_eq!(parsed.require_implements, vec!["Countable".to_string()]);
1956 }
1957
1958 #[test]
1959 fn parse_intersection_two_parts() {
1960 let u = parse_type_string("Iterator&Countable");
1961 assert_eq!(u.types.len(), 1);
1962 assert!(matches!(u.types[0], Atomic::TIntersection { ref parts } if parts.len() == 2));
1963 if let Atomic::TIntersection { parts } = &u.types[0] {
1964 assert!(parts[0].contains(
1965 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1966 ));
1967 assert!(parts[1].contains(
1968 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1969 ));
1970 }
1971 }
1972
1973 #[test]
1974 fn parse_intersection_three_parts() {
1975 let u = parse_type_string("Iterator&Countable&Stringable");
1976 assert_eq!(u.types.len(), 1);
1977 let Atomic::TIntersection { parts } = &u.types[0] else {
1978 panic!("expected TIntersection");
1979 };
1980 assert_eq!(parts.len(), 3);
1981 assert!(parts[0].contains(
1982 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1983 ));
1984 assert!(parts[1].contains(
1985 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1986 ));
1987 assert!(parts[2].contains(
1988 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Stringable")
1989 ));
1990 }
1991
1992 #[test]
1993 fn parse_intersection_in_union_with_null() {
1994 let u = parse_type_string("Iterator&Countable|null");
1995 assert!(u.is_nullable());
1996 let intersection = u
1997 .types
1998 .iter()
1999 .find_map(|t| {
2000 if let Atomic::TIntersection { parts } = t {
2001 Some(parts)
2002 } else {
2003 None
2004 }
2005 })
2006 .expect("expected TIntersection");
2007 assert_eq!(intersection.len(), 2);
2008 assert!(intersection[0].contains(
2009 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
2010 ));
2011 assert!(intersection[1].contains(
2012 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
2013 ));
2014 }
2015
2016 #[test]
2017 fn parse_intersection_in_union_with_scalar() {
2018 let u = parse_type_string("Iterator&Countable|string");
2019 assert!(u.contains(|t| matches!(t, Atomic::TString)));
2020 let intersection = u
2021 .types
2022 .iter()
2023 .find_map(|t| {
2024 if let Atomic::TIntersection { parts } = t {
2025 Some(parts)
2026 } else {
2027 None
2028 }
2029 })
2030 .expect("expected TIntersection");
2031 assert!(intersection[0].contains(
2032 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
2033 ));
2034 assert!(intersection[1].contains(
2035 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
2036 ));
2037 }
2038
2039 #[test]
2040 fn validate_unclosed_generic_return() {
2041 let parsed = DocblockParser::parse("/** @return array< */");
2042 assert_eq!(parsed.invalid_annotations.len(), 1);
2043 assert!(
2044 parsed.invalid_annotations[0].contains("unclosed generic"),
2045 "got: {}",
2046 parsed.invalid_annotations[0]
2047 );
2048 }
2049
2050 #[test]
2051 fn parse_empty_generic_array_graceful() {
2052 let u = parse_type_string("array<>");
2053 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
2054 }
2055
2056 #[test]
2057 fn parse_empty_generic_iterable_graceful() {
2058 let u = parse_type_string("iterable<>");
2059 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
2060 }
2061
2062 #[test]
2063 fn parse_empty_generic_non_empty_array_graceful() {
2064 let u = parse_type_string("non-empty-array<>");
2065 assert!(u.contains(|t| matches!(t, Atomic::TNonEmptyArray { .. })));
2066 }
2067
2068 #[test]
2069 fn validate_variable_in_type_position_param() {
2070 let parsed = DocblockParser::parse("/** @param Foo|$invalid $x */");
2071 assert_eq!(parsed.invalid_annotations.len(), 1);
2072 assert!(
2073 parsed.invalid_annotations[0].contains("$invalid"),
2074 "got: {}",
2075 parsed.invalid_annotations[0]
2076 );
2077 }
2078
2079 #[test]
2080 fn validate_this_is_valid_in_type_position() {
2081 let parsed = DocblockParser::parse("/** @return $this */");
2082 assert!(
2083 parsed.invalid_annotations.is_empty(),
2084 "unexpected error: {:?}",
2085 parsed.invalid_annotations
2086 );
2087 }
2088
2089 #[test]
2090 fn validate_unclosed_generic_var() {
2091 let parsed = DocblockParser::parse("/** @var array<string */");
2092 assert_eq!(parsed.invalid_annotations.len(), 1);
2093 assert!(parsed.invalid_annotations[0].contains("@var"));
2094 }
2095
2096 #[test]
2097 fn validate_variable_in_template_bound() {
2098 let parsed = DocblockParser::parse("/** @template T of $invalid */");
2099 assert_eq!(parsed.invalid_annotations.len(), 1);
2100 assert!(parsed.invalid_annotations[0].contains("$invalid"));
2101 }
2102}