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