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 = body.lines().next().unwrap_or(body).trim_end();
964 let body = match body.find(" @") {
967 Some(idx) => body[..idx].trim_end(),
968 None => body,
969 };
970 if body.is_empty() {
971 return None;
972 }
973 if let Some((name, bound)) = body.split_once(" of ").or_else(|| body.split_once(" as ")) {
974 let bound = bound.trim();
975 Some((
976 name.trim().to_string(),
977 (!bound.is_empty()).then(|| bound.to_string()),
978 ))
979 } else {
980 let name = body.split_whitespace().next().unwrap_or(body);
982 Some((name.to_string(), None))
983 }
984}
985
986fn extract_description(text: &str) -> String {
988 let mut desc_lines: Vec<&str> = Vec::new();
989 for line in text.lines() {
990 let l = line.trim();
991 let l = l.trim_start_matches("/**").trim();
992 let l = l.trim_end_matches("*/").trim();
993 let l = l.trim_start_matches("*/").trim();
994 let l = l.strip_prefix("* ").unwrap_or(l.trim_start_matches('*'));
995 let l = l.trim();
996 if l.starts_with('@') {
997 break;
998 }
999 if !l.is_empty() {
1000 desc_lines.push(l);
1001 }
1002 }
1003 desc_lines.join(" ")
1004}
1005
1006fn parse_import_type(body: &str) -> Option<DocImportType> {
1012 let (before_from, from_class_raw) = body.split_once(" from ")?;
1014 let from_class = from_class_raw.trim().trim_start_matches('\\').to_string();
1015 if from_class.is_empty() {
1016 return None;
1017 }
1018 let (original, local) = if let Some((orig, loc)) = before_from.split_once(" as ") {
1020 (orig.trim().to_string(), loc.trim().to_string())
1021 } else {
1022 let name = before_from.trim().to_string();
1023 (name.clone(), name)
1024 };
1025 if original.is_empty() || local.is_empty() {
1026 return None;
1027 }
1028 Some(DocImportType {
1029 original,
1030 local,
1031 from_class,
1032 })
1033}
1034
1035fn parse_param_line(s: &str) -> Option<(String, String)> {
1036 let first_line = s.lines().next().unwrap_or(s);
1042
1043 let mut best_split: Option<(String, String)> = None;
1046
1047 for (i, ch) in first_line.char_indices() {
1048 if ch.is_whitespace() {
1049 let after = first_line[i..].trim_start();
1050 let after_stripped = after.strip_prefix('&').unwrap_or(after);
1052 if after_stripped.starts_with('$') {
1053 let mut var_parts = after_stripped.split(char::is_whitespace);
1054 if let Some(name_with_dollar) = var_parts.next() {
1055 let name = name_with_dollar.trim_start_matches('$').to_string();
1056 if !name.is_empty() {
1057 let type_part = first_line[..i].trim().to_string();
1058 if !type_part.is_empty() {
1059 best_split = Some((type_part, name));
1060 }
1061 }
1062 }
1063 }
1064 }
1065 }
1066
1067 best_split
1068}
1069
1070fn extract_return_type(s: &str) -> String {
1071 let mut depth: i32 = 0;
1080 let mut current_token = String::new();
1081
1082 for ch in s.chars() {
1083 match ch {
1084 '<' | '(' | '{' => {
1085 depth += 1;
1086 current_token.push(ch);
1087 }
1088 '>' | ')' | '}' => {
1089 depth = (depth - 1).max(0);
1090 current_token.push(ch);
1091 }
1092 _ if ch.is_whitespace() && depth == 0 => {
1093 break;
1094 }
1095 _ => {
1096 current_token.push(ch);
1097 }
1098 }
1099 }
1100
1101 if current_token.ends_with(':') {
1105 let offset = current_token.len();
1106 let rest = s[offset..].trim_start();
1107 if !rest.is_empty() {
1108 let ret_type = extract_return_type(rest);
1109 current_token.push_str(&ret_type);
1110 }
1111 }
1112
1113 current_token.trim().to_string()
1114}
1115
1116fn split_union(s: &str) -> Vec<String> {
1117 let mut parts = Vec::new();
1118 let mut depth = 0;
1119 let mut current = String::new();
1120 for ch in s.chars() {
1121 match ch {
1122 '<' | '(' | '{' => {
1123 depth += 1;
1124 current.push(ch);
1125 }
1126 '>' | ')' | '}' => {
1127 depth -= 1;
1128 current.push(ch);
1129 }
1130 '|' if depth == 0 => {
1131 parts.push(current.trim().to_string());
1132 current = String::new();
1133 }
1134 _ => current.push(ch),
1135 }
1136 }
1137 if !current.trim().is_empty() {
1138 parts.push(current.trim().to_string());
1139 }
1140 parts
1141}
1142
1143fn split_intersection(s: &str) -> Vec<String> {
1145 let mut parts = Vec::new();
1146 let mut depth = 0i32;
1147 let mut current = String::new();
1148 for ch in s.chars() {
1149 match ch {
1150 '<' | '(' | '{' => {
1151 depth += 1;
1152 current.push(ch);
1153 }
1154 '>' | ')' | '}' => {
1155 depth -= 1;
1156 current.push(ch);
1157 }
1158 '&' if depth == 0 => {
1159 parts.push(current.trim().to_string());
1160 current = String::new();
1161 }
1162 _ => current.push(ch),
1163 }
1164 }
1165 if !current.trim().is_empty() {
1166 parts.push(current.trim().to_string());
1167 }
1168 parts
1169}
1170
1171fn is_balanced_parens(s: &str) -> bool {
1175 if !s.starts_with('(') || !s.ends_with(')') {
1176 return false;
1177 }
1178 let mut depth = 0i32;
1179 let chars: Vec<char> = s.chars().collect();
1180 let last = chars.len() - 1;
1181 for (i, ch) in chars.iter().enumerate() {
1182 match ch {
1183 '(' => depth += 1,
1184 ')' => {
1185 depth -= 1;
1186 if depth == 0 && i < last {
1189 return false;
1190 }
1191 }
1192 _ => {}
1193 }
1194 }
1195 depth == 0
1196}
1197
1198fn split_generics(s: &str) -> Vec<String> {
1199 let mut parts = Vec::new();
1200 let mut depth = 0;
1201 let mut current = String::new();
1202 for ch in s.chars() {
1203 match ch {
1204 '<' | '(' | '{' => {
1205 depth += 1;
1206 current.push(ch);
1207 }
1208 '>' | ')' | '}' => {
1209 depth -= 1;
1210 current.push(ch);
1211 }
1212 ',' if depth == 0 => {
1213 parts.push(current.trim().to_string());
1214 current = String::new();
1215 }
1216 _ => current.push(ch),
1217 }
1218 }
1219 if !current.trim().is_empty() {
1220 parts.push(current.trim().to_string());
1221 }
1222 parts
1223}
1224
1225fn extract_type_prefix(s: &str) -> &str {
1228 let mut depth = 0i32;
1229 let mut end = s.len();
1230 for (i, ch) in s.char_indices() {
1231 match ch {
1232 '<' | '(' | '{' => depth += 1,
1233 '>' | ')' | '}' => depth -= 1,
1234 _ if ch.is_whitespace() && depth == 0 => {
1235 end = i;
1236 break;
1237 }
1238 _ => {}
1239 }
1240 }
1241 &s[..end]
1242}
1243
1244fn is_inside_generics(s: &str) -> bool {
1245 let mut depth = 0i32;
1246 for ch in s.chars() {
1247 match ch {
1248 '<' | '(' | '{' => depth += 1,
1249 '>' | ')' | '}' => depth -= 1,
1250 _ => {}
1251 }
1252 }
1253 depth != 0
1254}
1255
1256fn parse_conditional_type(s: &str) -> Option<Type> {
1259 let is_pos = s.find(" is ")?;
1260 let param_raw = s[..is_pos].trim();
1261
1262 let param_name_str: &str = if let Some(name) = param_raw.strip_prefix('$') {
1264 if name.is_empty() {
1265 return None;
1266 }
1267 name
1268 } else {
1269 if param_raw.is_empty()
1272 || !param_raw.starts_with(|c: char| c.is_alphabetic() || c == '_')
1273 || !param_raw.chars().all(|c| c.is_alphanumeric() || c == '_')
1274 || !s.contains('?')
1275 {
1276 return None;
1277 }
1278 param_raw
1279 };
1280 let param_name = Some(mir_types::Name::new(param_name_str));
1281 let after_is = s[is_pos + 4..].trim();
1282 let q_pos = find_char_at_depth(after_is, '?')?;
1283 let subject_str = after_is[..q_pos].trim();
1284 let rest = after_is[q_pos + 1..].trim();
1285 let colon_pos = find_char_at_depth(rest, ':')?;
1286 let true_str = rest[..colon_pos].trim();
1287 let false_str = rest[colon_pos + 1..].trim();
1288 Some(Type::single(Atomic::TConditional {
1289 param_name,
1290 subject: Box::new(parse_type_string(subject_str)),
1291 if_true: Box::new(parse_type_string(true_str)),
1292 if_false: Box::new(parse_type_string(false_str)),
1293 }))
1294}
1295
1296fn find_char_at_depth(s: &str, target: char) -> Option<usize> {
1298 let mut depth = 0i32;
1299 for (i, ch) in s.char_indices() {
1300 match ch {
1301 '<' | '(' | '{' => depth += 1,
1302 '>' | ')' | '}' => depth -= 1,
1303 _ if ch == target && depth == 0 => return Some(i),
1304 _ => {}
1305 }
1306 }
1307 None
1308}
1309
1310fn normalize_fqcn(s: &str) -> String {
1311 s.trim_start_matches('\\').to_string()
1313}
1314
1315fn validate_type_str(s: &str, tag: &str) -> Option<String> {
1321 let s = s.trim();
1322 if s.is_empty() {
1323 return None;
1324 }
1325 if is_inside_generics(s) {
1326 return Some(format!("@{tag} has unclosed generic type `{s}`"));
1327 }
1328 let is_callable_type = s.to_lowercase().contains("callable") || s.contains("Closure");
1330 if !is_callable_type && has_empty_generics(s) {
1331 return Some(format!("@{tag} has empty generic type parameter in `{s}`"));
1332 }
1333 for part in split_union(s) {
1334 let p = part.trim();
1335 if p.starts_with('$') && p != "$this" {
1336 return Some(format!("@{tag} contains variable `{p}` in type position"));
1337 }
1338 if let Some(err) = validate_generic_semantics(p, tag) {
1339 return Some(err);
1340 }
1341 }
1342 None
1343}
1344
1345fn validate_generic_semantics(s: &str, tag: &str) -> Option<String> {
1347 let lower = s.to_lowercase();
1348 let (name, inner) = extract_generic_content(s)?;
1349 match lower[..name.len()].as_ref() {
1350 "int" => validate_int_range_inner(inner, tag),
1351 "array" | "non-empty-array" => validate_array_key_inner(inner, tag),
1352 _ => None,
1353 }
1354}
1355
1356fn extract_generic_content(s: &str) -> Option<(&str, &str)> {
1358 let lt = s.find('<')?;
1359 let name = s[..lt].trim();
1360 if name.is_empty() {
1361 return None;
1362 }
1363 let after_lt = &s[lt + 1..];
1364 let mut depth = 1i32;
1365 for (i, ch) in after_lt.char_indices() {
1366 match ch {
1367 '<' | '(' | '{' => depth += 1,
1368 '>' | ')' | '}' => {
1369 depth -= 1;
1370 if depth == 0 {
1371 return Some((name, &after_lt[..i]));
1372 }
1373 }
1374 _ => {}
1375 }
1376 }
1377 None
1378}
1379
1380fn validate_int_range_inner(inner: &str, tag: &str) -> Option<String> {
1381 let mut parts = inner.splitn(2, ',');
1382 let min_str = parts.next()?.trim();
1383 let max_str = parts.next()?.trim();
1384
1385 if min_str == "max" {
1386 return Some(format!(
1387 "@{tag} has invalid int range: `max` must be the second argument, not the first"
1388 ));
1389 }
1390 if max_str == "min" {
1391 return Some(format!(
1392 "@{tag} has invalid int range: `min` must be the first argument, not the second"
1393 ));
1394 }
1395
1396 let is_valid_bound = |s: &str| s == "min" || s == "max" || s.parse::<i64>().is_ok();
1397
1398 if !is_valid_bound(min_str) {
1399 return Some(format!(
1400 "@{tag} has invalid int range boundary `{min_str}`: must be an integer literal, `min`, or `max`"
1401 ));
1402 }
1403 if !is_valid_bound(max_str) {
1404 return Some(format!(
1405 "@{tag} has invalid int range boundary `{max_str}`: must be an integer literal, `min`, or `max`"
1406 ));
1407 }
1408
1409 if let (Ok(lo), Ok(hi)) = (min_str.parse::<i64>(), max_str.parse::<i64>()) {
1410 if lo > hi {
1411 return Some(format!(
1412 "@{tag} has invalid int range: min ({lo}) must not be greater than max ({hi})"
1413 ));
1414 }
1415 }
1416 None
1417}
1418
1419fn validate_array_key_inner(inner: &str, tag: &str) -> Option<String> {
1420 let params = split_generics(inner);
1421 if params.len() < 2 {
1422 return None;
1423 }
1424 let key_str = params[0].trim();
1425 let invalid_key_types = ["float", "bool", "true", "false"];
1429 if invalid_key_types.contains(&key_str.to_lowercase().as_str()) {
1430 return Some(format!(
1431 "@{tag} has invalid array key type `{key_str}`: must be a subtype of int|string"
1432 ));
1433 }
1434 None
1435}
1436
1437fn has_empty_generics(s: &str) -> bool {
1438 let mut depth = 0;
1439 let mut prev_open = false;
1440 for ch in s.chars() {
1441 match ch {
1442 '<' | '(' | '{' => {
1443 if prev_open && depth == 0 {
1444 return true;
1445 }
1446 prev_open = true;
1447 depth += 1;
1448 }
1449 '>' | ')' | '}' => {
1450 depth -= 1;
1451 if depth == 0 {
1452 if prev_open {
1453 return true;
1454 }
1455 prev_open = false;
1456 }
1457 }
1458 c if !c.is_whitespace() => {
1459 prev_open = false;
1460 }
1461 _ => {}
1462 }
1463 }
1464 false
1465}
1466
1467fn validate_method_body(s: &str) -> Option<String> {
1470 let s = s.trim();
1471 if s.is_empty() {
1472 return Some("@method annotation is missing a method definition".to_string());
1473 }
1474 let rest = if s.to_lowercase().starts_with("static ") {
1476 s["static ".len()..].trim_start()
1477 } else {
1478 s
1479 };
1480 let open = rest.find('(').unwrap_or(rest.len());
1482 let prefix = rest[..open].trim();
1483 let parts: Vec<&str> = prefix.split_whitespace().collect();
1484 let name = parts.last().unwrap_or(&"");
1485 if !name.is_empty() && !is_valid_php_identifier(name) {
1487 return Some(format!(
1488 "@method has invalid method name `{name}`: must be a valid PHP identifier"
1489 ));
1490 }
1491 if rest.contains('(') {
1493 let params_str = rest;
1494 let open_pos = params_str.find('(').unwrap();
1495 let after_open = ¶ms_str[open_pos + 1..];
1496 if let Some(rel_close) = find_matching_paren(¶ms_str[open_pos..]) {
1497 let close_pos = open_pos + rel_close;
1498 let inner = params_str[open_pos + 1..close_pos].trim();
1499 if !inner.is_empty() {
1500 for param in split_generics(inner) {
1501 let param = param.trim();
1502 if param.starts_with('&') {
1503 return Some(format!(
1504 "@method parameter `{param}` uses by-reference (`&`) which is not supported in @method annotations"
1505 ));
1506 }
1507 if let Some(amp_pos) = param.find('&') {
1509 let before_amp = ¶m[..amp_pos];
1510 let after_amp = param[amp_pos + 1..].trim_start();
1511 if !before_amp.trim().is_empty() && after_amp.starts_with('$') {
1512 return Some(format!(
1513 "@method parameter `{param}` uses by-reference (`&`) which is not supported in @method annotations"
1514 ));
1515 }
1516 }
1517 }
1518 }
1519 } else {
1520 let _ = after_open;
1521 }
1522 }
1523 None
1524}
1525
1526fn is_valid_php_identifier(s: &str) -> bool {
1527 let mut chars = s.chars();
1528 match chars.next() {
1529 Some(c) if c.is_alphabetic() || c == '_' => {}
1530 _ => return false,
1531 }
1532 chars.all(|c| c.is_alphanumeric() || c == '_')
1533}
1534
1535fn parse_method_line(s: &str) -> Option<DocMethod> {
1537 let mut rest = s.trim();
1538 if rest.is_empty() {
1539 return None;
1540 }
1541 let is_static = rest
1542 .split_whitespace()
1543 .next()
1544 .map(|w| w.eq_ignore_ascii_case("static"))
1545 .unwrap_or(false);
1546 if is_static {
1547 rest = rest["static".len()..].trim_start();
1548 }
1549
1550 let open = rest.find('(').unwrap_or(rest.len());
1551 let prefix = rest[..open].trim();
1552 let mut parts: Vec<&str> = prefix.split_whitespace().collect();
1553 let name = parts.pop()?.to_string();
1554 if name.is_empty() {
1555 return None;
1556 }
1557 let return_type = parts.join(" ");
1558 Some(DocMethod {
1559 return_type,
1560 name,
1561 is_static,
1562 params: parse_method_params(rest),
1563 })
1564}
1565
1566fn parse_method_params(name_part: &str) -> Vec<DocMethodParam> {
1567 let Some(open) = name_part.find('(') else {
1568 return vec![];
1569 };
1570 let Some(rel_close) = find_matching_paren(&name_part[open..]) else {
1574 return vec![];
1575 };
1576 let close = open + rel_close;
1577 let inner = name_part[open + 1..close].trim();
1578 if inner.is_empty() {
1579 return vec![];
1580 }
1581
1582 split_generics(inner)
1583 .into_iter()
1584 .filter_map(|param| parse_method_param(¶m))
1585 .collect()
1586}
1587
1588fn parse_method_param(param: &str) -> Option<DocMethodParam> {
1589 let before_default = param.split('=').next()?.trim();
1590 let is_optional = param.contains('=');
1591 let mut tokens: Vec<&str> = before_default.split_whitespace().collect();
1592 let raw_name = tokens.pop()?;
1593 let is_variadic = raw_name.contains("...");
1594 let is_byref = raw_name.contains('&');
1595 let name = raw_name
1596 .trim_start_matches('&')
1597 .trim_start_matches("...")
1598 .trim_start_matches('&')
1599 .trim_start_matches('$')
1600 .to_string();
1601 if name.is_empty() {
1602 return None;
1603 }
1604 Some(DocMethodParam {
1605 name,
1606 type_hint: tokens.join(" "),
1607 is_variadic,
1608 is_byref,
1609 is_optional: is_optional || is_variadic,
1610 })
1611}
1612
1613#[cfg(test)]
1618mod tests {
1619 use super::*;
1620 use mir_types::Atomic;
1621
1622 #[test]
1623 fn parse_string() {
1624 let u = parse_type_string("string");
1625 assert_eq!(u.types.len(), 1);
1626 assert!(matches!(u.types[0], Atomic::TString));
1627 }
1628
1629 #[test]
1630 fn parse_nullable_string() {
1631 let u = parse_type_string("?string");
1632 assert!(u.is_nullable());
1633 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1634 }
1635
1636 #[test]
1637 fn parse_union() {
1638 let u = parse_type_string("string|int|null");
1639 assert!(u.contains(|t| matches!(t, Atomic::TString)));
1640 assert!(u.contains(|t| matches!(t, Atomic::TInt)));
1641 assert!(u.is_nullable());
1642 }
1643
1644 #[test]
1645 fn parse_array_of_string() {
1646 let u = parse_type_string("array<string>");
1647 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
1648 }
1649
1650 #[test]
1651 fn parse_list_of_int() {
1652 let u = parse_type_string("list<int>");
1653 assert!(u.contains(|t| matches!(t, Atomic::TList { .. })));
1654 }
1655
1656 #[test]
1657 fn parse_named_class() {
1658 let u = parse_type_string("Foo\\Bar");
1659 assert!(u.contains(
1660 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Foo\\Bar")
1661 ));
1662 }
1663
1664 #[test]
1665 fn parse_docblock_param_return() {
1666 let doc = r#"/**
1667 * @param string $name
1668 * @param int $age
1669 * @return bool
1670 */"#;
1671 let parsed = DocblockParser::parse(doc);
1672 assert_eq!(parsed.params.len(), 2);
1673 assert!(parsed.return_type.is_some());
1674 let ret = parsed.return_type.unwrap();
1675 assert!(ret.contains(|t| matches!(t, Atomic::TBool)));
1676 }
1677
1678 #[test]
1679 fn parse_template() {
1680 let doc = "/** @template T of object */";
1681 let parsed = DocblockParser::parse(doc);
1682 assert_eq!(parsed.templates.len(), 1);
1683 assert_eq!(parsed.templates[0].0, "T");
1684 assert!(parsed.templates[0].1.is_some());
1685 assert_eq!(parsed.templates[0].2, Variance::Invariant);
1686 }
1687
1688 #[test]
1689 fn parse_template_covariant() {
1690 let doc = "/** @template-covariant T */";
1691 let parsed = DocblockParser::parse(doc);
1692 assert_eq!(parsed.templates.len(), 1);
1693 assert_eq!(parsed.templates[0].0, "T");
1694 assert_eq!(parsed.templates[0].2, Variance::Covariant);
1695 }
1696
1697 #[test]
1698 fn parse_template_contravariant() {
1699 let doc = "/** @template-contravariant T */";
1700 let parsed = DocblockParser::parse(doc);
1701 assert_eq!(parsed.templates.len(), 1);
1702 assert_eq!(parsed.templates[0].0, "T");
1703 assert_eq!(parsed.templates[0].2, Variance::Contravariant);
1704 }
1705
1706 #[test]
1707 fn parse_template_single_line_does_not_over_read() {
1708 let doc = "/** @template T @param T $x @return T */";
1711 let parsed = DocblockParser::parse(doc);
1712 assert_eq!(parsed.templates.len(), 1);
1713 assert_eq!(parsed.templates[0].0, "T");
1714 assert!(parsed.templates[0].1.is_none(), "expected no bound");
1715 }
1716
1717 #[test]
1718 fn parse_template_multiline_with_bound_still_works() {
1719 let doc = r#"/**
1721 * @template T of Base
1722 */"#;
1723 let parsed = DocblockParser::parse(doc);
1724 assert_eq!(parsed.templates.len(), 1);
1725 assert_eq!(parsed.templates[0].0, "T");
1726 let bound = parsed.templates[0].1.as_ref().expect("expected a bound");
1727 assert!(bound.contains(
1728 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Base")
1729 ));
1730 }
1731
1732 #[test]
1733 fn parse_template_extends_alias() {
1734 for tag in ["template-extends", "phpstan-extends"] {
1736 let doc = format!("/** @{tag} Base<User> */");
1737 let parsed = DocblockParser::parse(&doc);
1738 let extends = parsed
1739 .extends
1740 .unwrap_or_else(|| panic!("@{tag} should populate extends"));
1741 assert!(
1742 extends.contains(|t| matches!(
1743 t,
1744 Atomic::TNamedObject { fqcn, type_params }
1745 if fqcn.as_ref() == "Base" && !type_params.is_empty()
1746 )),
1747 "@{tag} should produce a generic Base<User>"
1748 );
1749 }
1750 }
1751
1752 #[test]
1753 fn parse_template_implements_alias() {
1754 for tag in ["template-implements", "phpstan-implements"] {
1756 let doc = format!("/** @{tag} Iter<User> */");
1757 let parsed = DocblockParser::parse(&doc);
1758 assert_eq!(
1759 parsed.implements.len(),
1760 1,
1761 "@{tag} should populate implements"
1762 );
1763 assert!(
1764 parsed.implements[0].contains(|t| matches!(
1765 t,
1766 Atomic::TNamedObject { fqcn, type_params }
1767 if fqcn.as_ref() == "Iter" && !type_params.is_empty()
1768 )),
1769 "@{tag} should produce a generic Iter<User>"
1770 );
1771 }
1772 }
1773
1774 #[test]
1775 fn parse_deprecated() {
1776 let doc = "/** @deprecated use newMethod() instead */";
1777 let parsed = DocblockParser::parse(doc);
1778 assert!(parsed.is_deprecated);
1779 assert_eq!(
1780 parsed.deprecated.as_deref(),
1781 Some("use newMethod() instead")
1782 );
1783 }
1784
1785 #[test]
1786 fn parse_since_plain() {
1787 let parsed = DocblockParser::parse("/** @since 8.0 */");
1788 assert_eq!(parsed.since.as_deref(), Some("8.0"));
1789 assert_eq!(parsed.removed, None);
1790 }
1791
1792 #[test]
1793 fn parse_since_strips_trailing_description() {
1794 let parsed = DocblockParser::parse("/** @since 1.4.0 added \\$options argument */");
1797 assert_eq!(parsed.since.as_deref(), Some("1.4.0"));
1798 }
1799
1800 #[test]
1801 fn parse_removed_tag() {
1802 let parsed = DocblockParser::parse("/** @removed 8.0 use mb_convert_encoding */");
1803 assert_eq!(parsed.removed.as_deref(), Some("8.0"));
1804 }
1805
1806 #[test]
1807 fn parse_since_empty_body_is_none() {
1808 let parsed = DocblockParser::parse("/** @since */");
1809 assert_eq!(parsed.since, None);
1810 }
1811
1812 #[test]
1813 fn parse_description() {
1814 let doc = r#"/**
1815 * This is a description.
1816 * Spans two lines.
1817 * @param string $x
1818 */"#;
1819 let parsed = DocblockParser::parse(doc);
1820 assert!(parsed.description.contains("This is a description"));
1821 assert!(parsed.description.contains("Spans two lines"));
1822 }
1823
1824 #[test]
1825 fn parse_see_and_link() {
1826 let doc = "/** @see SomeClass\n * @link https://example.com */";
1827 let parsed = DocblockParser::parse(doc);
1828 assert_eq!(parsed.see.len(), 2);
1829 assert!(parsed.see.contains(&"SomeClass".to_string()));
1830 assert!(parsed.see.contains(&"https://example.com".to_string()));
1831 }
1832
1833 #[test]
1834 fn parse_mixin() {
1835 let doc = "/** @mixin SomeTrait */";
1836 let parsed = DocblockParser::parse(doc);
1837 assert_eq!(parsed.mixins, vec!["SomeTrait".to_string()]);
1838 }
1839
1840 #[test]
1841 fn parse_property_tags() {
1842 let doc = r#"/**
1843 * @property string $name
1844 * @property-read int $id
1845 * @property-write bool $active
1846 */"#;
1847 let parsed = DocblockParser::parse(doc);
1848 assert_eq!(parsed.properties.len(), 3);
1849 let name_prop = parsed.properties.iter().find(|p| p.name == "name").unwrap();
1850 assert_eq!(name_prop.type_hint, "string");
1851 assert!(!name_prop.read_only);
1852 assert!(!name_prop.write_only);
1853 let id_prop = parsed.properties.iter().find(|p| p.name == "id").unwrap();
1854 assert!(id_prop.read_only);
1855 let active_prop = parsed
1856 .properties
1857 .iter()
1858 .find(|p| p.name == "active")
1859 .unwrap();
1860 assert!(active_prop.write_only);
1861 }
1862
1863 #[test]
1864 fn parse_method_tag() {
1865 let doc = r#"/**
1866 * @method string getName()
1867 * @method static int create()
1868 */"#;
1869 let parsed = DocblockParser::parse(doc);
1870 assert_eq!(parsed.methods.len(), 2);
1871 let get_name = parsed.methods.iter().find(|m| m.name == "getName").unwrap();
1872 assert_eq!(get_name.return_type, "string");
1873 assert!(!get_name.is_static);
1874 let create = parsed.methods.iter().find(|m| m.name == "create").unwrap();
1875 assert!(create.is_static);
1876 }
1877
1878 #[test]
1879 fn parse_method_tag_description_with_parens() {
1880 let doc = r#"/**
1884 * @method $this addDay() Add one day to the instance (using date interval).
1885 * @method $this subDays(int|float $value = 1) Sub days (the $value count passed in).
1886 */"#;
1887 let parsed = DocblockParser::parse(doc);
1888 let add_day = parsed.methods.iter().find(|m| m.name == "addDay").unwrap();
1889 assert_eq!(add_day.params.len(), 0, "addDay() must have zero params");
1890 let sub_days = parsed.methods.iter().find(|m| m.name == "subDays").unwrap();
1891 assert_eq!(sub_days.params.len(), 1);
1892 assert!(sub_days.params[0].is_optional);
1893 }
1894
1895 #[test]
1896 fn parse_type_alias_tag() {
1897 let doc = "/** @psalm-type MyAlias = string|int */";
1898 let parsed = DocblockParser::parse(doc);
1899 assert_eq!(parsed.type_aliases.len(), 1);
1900 assert_eq!(parsed.type_aliases[0].name, "MyAlias");
1901 assert_eq!(parsed.type_aliases[0].type_expr, "string|int");
1902 }
1903
1904 #[test]
1905 fn parse_import_type_no_as() {
1906 let doc = "/** @psalm-import-type UserId from UserRepository */";
1907 let parsed = DocblockParser::parse(doc);
1908 assert_eq!(parsed.import_types.len(), 1);
1909 assert_eq!(parsed.import_types[0].original, "UserId");
1910 assert_eq!(parsed.import_types[0].local, "UserId");
1911 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1912 }
1913
1914 #[test]
1915 fn parse_import_type_with_as() {
1916 let doc = "/** @psalm-import-type UserId as LocalId from UserRepository */";
1917 let parsed = DocblockParser::parse(doc);
1918 assert_eq!(parsed.import_types.len(), 1);
1919 assert_eq!(parsed.import_types[0].original, "UserId");
1920 assert_eq!(parsed.import_types[0].local, "LocalId");
1921 assert_eq!(parsed.import_types[0].from_class, "UserRepository");
1922 }
1923
1924 #[test]
1925 fn parse_require_extends() {
1926 let doc = "/** @psalm-require-extends Model */";
1927 let parsed = DocblockParser::parse(doc);
1928 assert_eq!(parsed.require_extends, vec!["Model".to_string()]);
1929 }
1930
1931 #[test]
1932 fn parse_require_implements() {
1933 let doc = "/** @psalm-require-implements Countable */";
1934 let parsed = DocblockParser::parse(doc);
1935 assert_eq!(parsed.require_implements, vec!["Countable".to_string()]);
1936 }
1937
1938 #[test]
1939 fn parse_intersection_two_parts() {
1940 let u = parse_type_string("Iterator&Countable");
1941 assert_eq!(u.types.len(), 1);
1942 assert!(matches!(u.types[0], Atomic::TIntersection { ref parts } if parts.len() == 2));
1943 if let Atomic::TIntersection { parts } = &u.types[0] {
1944 assert!(parts[0].contains(
1945 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1946 ));
1947 assert!(parts[1].contains(
1948 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1949 ));
1950 }
1951 }
1952
1953 #[test]
1954 fn parse_intersection_three_parts() {
1955 let u = parse_type_string("Iterator&Countable&Stringable");
1956 assert_eq!(u.types.len(), 1);
1957 let Atomic::TIntersection { parts } = &u.types[0] else {
1958 panic!("expected TIntersection");
1959 };
1960 assert_eq!(parts.len(), 3);
1961 assert!(parts[0].contains(
1962 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1963 ));
1964 assert!(parts[1].contains(
1965 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1966 ));
1967 assert!(parts[2].contains(
1968 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Stringable")
1969 ));
1970 }
1971
1972 #[test]
1973 fn parse_intersection_in_union_with_null() {
1974 let u = parse_type_string("Iterator&Countable|null");
1975 assert!(u.is_nullable());
1976 let intersection = u
1977 .types
1978 .iter()
1979 .find_map(|t| {
1980 if let Atomic::TIntersection { parts } = t {
1981 Some(parts)
1982 } else {
1983 None
1984 }
1985 })
1986 .expect("expected TIntersection");
1987 assert_eq!(intersection.len(), 2);
1988 assert!(intersection[0].contains(
1989 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
1990 ));
1991 assert!(intersection[1].contains(
1992 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
1993 ));
1994 }
1995
1996 #[test]
1997 fn parse_intersection_in_union_with_scalar() {
1998 let u = parse_type_string("Iterator&Countable|string");
1999 assert!(u.contains(|t| matches!(t, Atomic::TString)));
2000 let intersection = u
2001 .types
2002 .iter()
2003 .find_map(|t| {
2004 if let Atomic::TIntersection { parts } = t {
2005 Some(parts)
2006 } else {
2007 None
2008 }
2009 })
2010 .expect("expected TIntersection");
2011 assert!(intersection[0].contains(
2012 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Iterator")
2013 ));
2014 assert!(intersection[1].contains(
2015 |t| matches!(t, Atomic::TNamedObject { fqcn, .. } if fqcn.as_ref() == "Countable")
2016 ));
2017 }
2018
2019 #[test]
2020 fn validate_unclosed_generic_return() {
2021 let parsed = DocblockParser::parse("/** @return array< */");
2022 assert_eq!(parsed.invalid_annotations.len(), 1);
2023 assert!(
2024 parsed.invalid_annotations[0].contains("unclosed generic"),
2025 "got: {}",
2026 parsed.invalid_annotations[0]
2027 );
2028 }
2029
2030 #[test]
2031 fn parse_empty_generic_array_graceful() {
2032 let u = parse_type_string("array<>");
2033 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
2034 }
2035
2036 #[test]
2037 fn parse_empty_generic_iterable_graceful() {
2038 let u = parse_type_string("iterable<>");
2039 assert!(u.contains(|t| matches!(t, Atomic::TArray { .. })));
2040 }
2041
2042 #[test]
2043 fn parse_empty_generic_non_empty_array_graceful() {
2044 let u = parse_type_string("non-empty-array<>");
2045 assert!(u.contains(|t| matches!(t, Atomic::TNonEmptyArray { .. })));
2046 }
2047
2048 #[test]
2049 fn validate_variable_in_type_position_param() {
2050 let parsed = DocblockParser::parse("/** @param Foo|$invalid $x */");
2051 assert_eq!(parsed.invalid_annotations.len(), 1);
2052 assert!(
2053 parsed.invalid_annotations[0].contains("$invalid"),
2054 "got: {}",
2055 parsed.invalid_annotations[0]
2056 );
2057 }
2058
2059 #[test]
2060 fn validate_this_is_valid_in_type_position() {
2061 let parsed = DocblockParser::parse("/** @return $this */");
2062 assert!(
2063 parsed.invalid_annotations.is_empty(),
2064 "unexpected error: {:?}",
2065 parsed.invalid_annotations
2066 );
2067 }
2068
2069 #[test]
2070 fn validate_unclosed_generic_var() {
2071 let parsed = DocblockParser::parse("/** @var array<string */");
2072 assert_eq!(parsed.invalid_annotations.len(), 1);
2073 assert!(parsed.invalid_annotations[0].contains("@var"));
2074 }
2075
2076 #[test]
2077 fn validate_variable_in_template_bound() {
2078 let parsed = DocblockParser::parse("/** @template T of $invalid */");
2079 assert_eq!(parsed.invalid_annotations.len(), 1);
2080 assert!(parsed.invalid_annotations[0].contains("$invalid"));
2081 }
2082}