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-out" | "psalm-param-out" | "phpstan-param-out" => {
26 if let Some(body_str) = body_text(&tag.body) {
27 if let Some((ty_s, name)) = parse_param_line(&body_str) {
28 if let Some(msg) = validate_type_str(&ty_s, "param-out") {
29 result.invalid_annotations.push(msg);
30 } else {
31 result.out_params.push((
32 name.trim_start_matches('$').to_string(),
33 parse_type_string(&ty_s),
34 ));
35 }
36 }
37 }
38 }
39 "param" | "psalm-param" | "phpstan-param" => {
40 if let Some(body_str) = body_text(&tag.body) {
41 if let Some((ty_s, name)) = parse_param_line(&body_str) {
42 if is_inside_generics(&ty_s) {
44 if let Some(msg) = validate_type_str(&body_str, "param") {
46 result.invalid_annotations.push(msg);
47 }
48 } else if let Some(msg) = validate_type_str(&ty_s, "param") {
49 result.invalid_annotations.push(msg);
51 } else {
52 result.params.push((
53 name.trim_start_matches('$').to_string(),
54 parse_type_string(&ty_s),
55 ));
56 }
57 } else if let Some(msg) = validate_type_str(&body_str, "param") {
58 result.invalid_annotations.push(msg);
60 }
61 }
62 }
63 "return" | "psalm-return" | "phpstan-return" => {
64 if let Some(body_str) = body_text(&tag.body) {
65 let ty_s = extract_return_type(&body_str);
66 if let Some(msg) = validate_type_str(&ty_s, "return") {
67 result.invalid_annotations.push(msg);
68 }
69 result.return_type = Some(parse_type_string(&ty_s));
70 }
71 }
72 "var" | "psalm-var" | "phpstan-var" => {
73 if let Some(body_str) = body_text(&tag.body) {
74 if let Some((ty_s, name)) = parse_param_line(&body_str) {
75 if let Some(msg) = validate_type_str(&ty_s, "var") {
76 result.invalid_annotations.push(msg);
77 }
78 result.var_type = Some(parse_type_string(&ty_s));
79 result.var_name = Some(name.trim_start_matches('$').to_string());
80 } else {
81 let ty_s = extract_type_prefix(body_str.trim());
85 if let Some(msg) = validate_type_str(ty_s, "var") {
86 result.invalid_annotations.push(msg);
87 }
88 result.var_type = Some(parse_type_string(ty_s));
89 }
90 }
91 }
92 "throws" => {
93 if let Some(body_str) = body_text(&tag.body) {
94 let class = body_str.split_whitespace().next().unwrap_or("").to_string();
95 if !class.is_empty() {
96 result.throws.push(class);
97 }
98 }
99 }
100 "deprecated" => {
101 result.is_deprecated = true;
102 result.deprecated = Some(body_text(&tag.body).unwrap_or_default().to_string());
103 }
104 "template" | "psalm-template" | "phpstan-template" => {
105 if let Some((name, bound)) =
106 parse_template_line(tag.name.as_str(), body_text(&tag.body))
107 {
108 if let Some(msg) = validate_type_str(&name, "template") {
109 result.invalid_annotations.push(msg);
110 }
111 if let Some(b) = &bound {
112 if let Some(msg) = validate_type_str(b, "template") {
113 result.invalid_annotations.push(msg);
114 }
115 }
116 result.templates.push((
117 name,
118 bound.map(|b| parse_type_string(&b)),
119 Variance::Invariant,
120 ));
121 }
122 }
123 "template-covariant"
124 | "psalm-template-covariant"
125 | "phpstan-template-covariant" => {
126 if let Some((name, bound)) =
127 parse_template_line(tag.name.as_str(), body_text(&tag.body))
128 {
129 if let Some(msg) = validate_type_str(&name, "template-covariant") {
130 result.invalid_annotations.push(msg);
131 }
132 if let Some(b) = &bound {
133 if let Some(msg) = validate_type_str(b, "template-covariant") {
134 result.invalid_annotations.push(msg);
135 }
136 }
137 result.templates.push((
138 name,
139 bound.map(|b| parse_type_string(&b)),
140 Variance::Covariant,
141 ));
142 }
143 }
144 "template-contravariant"
145 | "psalm-template-contravariant"
146 | "phpstan-template-contravariant" => {
147 if let Some((name, bound)) =
148 parse_template_line(tag.name.as_str(), body_text(&tag.body))
149 {
150 if let Some(msg) = validate_type_str(&name, "template-contravariant") {
151 result.invalid_annotations.push(msg);
152 }
153 if let Some(b) = &bound {
154 if let Some(msg) = validate_type_str(b, "template-contravariant") {
155 result.invalid_annotations.push(msg);
156 }
157 }
158 result.templates.push((
159 name,
160 bound.map(|b| parse_type_string(&b)),
161 Variance::Contravariant,
162 ));
163 }
164 }
165 "extends" | "template-extends" | "phpstan-extends" => {
166 if let Some(body_str) = body_text(&tag.body) {
167 let trimmed = body_str.trim();
168 if let Some(msg) = validate_type_str(trimmed, "extends") {
169 result.invalid_annotations.push(msg);
170 }
171 result.extends.push(parse_type_string(trimmed));
172 }
173 }
174 "implements" | "template-implements" | "phpstan-implements" => {
175 if let Some(body_str) = body_text(&tag.body) {
176 let trimmed = body_str.trim();
177 if let Some(msg) = validate_type_str(trimmed, "implements") {
178 result.invalid_annotations.push(msg);
179 }
180 result.implements.push(parse_type_string(trimmed));
181 }
182 }
183 "assert" | "psalm-assert" | "phpstan-assert" => {
184 if let Some(body_str) = body_text(&tag.body) {
185 if let Some((ty_str, name)) = parse_param_line(&body_str) {
186 result.assertions.push((name, parse_type_string(&ty_str)));
187 }
188 }
189 }
190 "if-this-is" | "psalm-if-this-is" | "phpstan-if-this-is" => {
191 if let Some(body_str) = body_text(&tag.body) {
192 let trimmed = body_str.trim();
193 if !trimmed.is_empty() {
194 result.if_this_is = Some(parse_type_string(trimmed));
195 }
196 }
197 }
198 "self-out" | "psalm-self-out" | "phpstan-self-out" => {
199 if let Some(body_str) = body_text(&tag.body) {
200 let trimmed = body_str.trim();
201 if !trimmed.is_empty() {
202 result.self_out = Some(parse_type_string(trimmed));
203 }
204 }
205 }
206 "suppress" | "psalm-suppress" => {
207 if let Some(body_str) = body_text(&tag.body) {
208 for rule in body_str.split([',', ' ']) {
209 let rule = rule.trim().to_string();
210 if !rule.is_empty() {
211 result.suppressed_issues.push(rule);
212 }
213 }
214 }
215 }
216 "see" => {
217 if let Some(body_str) = body_text(&tag.body) {
218 result.see.push(body_str.to_string());
219 }
220 }
221 "link" => {
222 if let Some(body_str) = body_text(&tag.body) {
223 result.see.push(body_str.to_string());
224 }
225 }
226 "mixin" => {
227 if let Some(body_str) = body_text(&tag.body) {
228 let base_class =
229 body_str.split('<').next().unwrap_or(&body_str).to_string();
230 result.mixins.push(base_class);
231 }
232 }
233 "property" => {
234 if let Some(body_str) = body_text(&tag.body) {
235 if let Some((ty_str, name)) = parse_param_line(&body_str) {
236 result.properties.push(DocProperty {
237 type_hint: ty_str,
238 name: name.trim_start_matches('$').to_string(),
239 read_only: false,
240 write_only: false,
241 });
242 }
243 }
244 }
245 "property-read" => {
246 if let Some(body_str) = body_text(&tag.body) {
247 if let Some((ty_str, name)) = parse_param_line(&body_str) {
248 result.properties.push(DocProperty {
249 type_hint: ty_str,
250 name: name.trim_start_matches('$').to_string(),
251 read_only: true,
252 write_only: false,
253 });
254 }
255 }
256 }
257 "property-write" => {
258 if let Some(body_str) = body_text(&tag.body) {
259 if let Some((ty_str, name)) = parse_param_line(&body_str) {
260 result.properties.push(DocProperty {
261 type_hint: ty_str,
262 name: name.trim_start_matches('$').to_string(),
263 read_only: false,
264 write_only: true,
265 });
266 }
267 }
268 }
269 "method" | "psalm-method" => {
270 let body_str = body_text(&tag.body).unwrap_or_default().trim().to_string();
271 if let Some(err) = validate_method_body(&body_str) {
272 result.invalid_annotations.push(err);
273 } else if let Some(m) = parse_method_line(&body_str) {
274 result.methods.push(m);
275 }
276 }
277 "psalm-type" | "phpstan-type" => {
278 if let Some(body_str) = body_text(&tag.body) {
279 if let Some((name, type_expr)) = body_str.split_once('=') {
280 result.type_aliases.push(DocTypeAlias {
281 name: name.trim().to_string(),
282 type_expr: type_expr.trim().to_string(),
283 });
284 }
285 }
286 }
287 "psalm-import-type" | "phpstan-import-type" => {
288 if let Some(body_str) = body_text(&tag.body) {
289 if let Some(import) = parse_import_type(&body_str) {
290 result.import_types.push(import);
291 }
292 }
293 }
294 "since" if result.since.is_none() => {
295 if let Some(body_str) = body_text(&tag.body) {
296 let v = body_str.split_whitespace().next().unwrap_or("");
297 if !v.is_empty() {
298 result.since = Some(v.to_string());
299 }
300 }
301 }
302 "removed" if result.removed.is_none() => {
303 if let Some(body_str) = body_text(&tag.body) {
304 let v = body_str.split_whitespace().next().unwrap_or("");
305 if !v.is_empty() {
306 result.removed = Some(v.to_string());
307 }
308 }
309 }
310 "internal" => result.is_internal = true,
311 "pure" | "psalm-pure" | "phpstan-pure" => result.is_pure = true,
312 "seal-properties" | "psalm-seal-properties" => result.seal_properties = true,
313 "no-named-arguments" => result.no_named_arguments = true,
314 "mutation-free" | "psalm-mutation-free" | "phpstan-mutation-free" => {
315 result.is_mutation_free = true
316 }
317 "psalm-external-mutation-free" => result.is_external_mutation_free = true,
318 "immutable" | "psalm-immutable" => result.is_immutable = true,
319 "readonly" | "psalm-readonly" | "phpstan-readonly" => result.is_readonly = true,
320 "final" => result.is_final = true,
321 "inheritDoc" | "inheritdoc" => result.is_inherit_doc = true,
322 "api" | "psalm-api" => result.is_api = true,
323 "psalm-assert-if-true" | "phpstan-assert-if-true" => {
324 if let Some(body_str) = body_text(&tag.body) {
325 if let Some((ty_str, name)) = parse_param_line(&body_str) {
326 result
327 .assertions_if_true
328 .push((name, parse_type_string(&ty_str)));
329 }
330 }
331 }
332 "psalm-assert-if-false" | "phpstan-assert-if-false" => {
333 if let Some(body_str) = body_text(&tag.body) {
334 if let Some((ty_str, name)) = parse_param_line(&body_str) {
335 result
336 .assertions_if_false
337 .push((name, parse_type_string(&ty_str)));
338 }
339 }
340 }
341 "psalm-property" => {
342 if let Some(body_str) = body_text(&tag.body) {
343 if let Some((ty_str, name)) = parse_param_line(&body_str) {
344 result.properties.push(DocProperty {
345 type_hint: ty_str,
346 name,
347 read_only: false,
348 write_only: false,
349 });
350 }
351 }
352 }
353 "psalm-property-read" => {
354 if let Some(body_str) = body_text(&tag.body) {
355 if let Some((ty_str, name)) = parse_param_line(&body_str) {
356 result.properties.push(DocProperty {
357 type_hint: ty_str,
358 name,
359 read_only: true,
360 write_only: false,
361 });
362 }
363 }
364 }
365 "psalm-property-write" => {
366 if let Some(body_str) = body_text(&tag.body) {
367 if let Some((ty_str, name)) = parse_param_line(&body_str) {
368 result.properties.push(DocProperty {
369 type_hint: ty_str,
370 name,
371 read_only: false,
372 write_only: true,
373 });
374 }
375 }
376 }
377 "psalm-require-extends" | "phpstan-require-extends" => {
378 if let Some(body_str) = body_text(&tag.body) {
379 let cls = body_str
380 .split_whitespace()
381 .next()
382 .unwrap_or("")
383 .trim()
384 .to_string();
385 if !cls.is_empty() {
386 result.require_extends.push(cls);
387 }
388 }
389 }
390 "psalm-require-implements" | "phpstan-require-implements" => {
391 if let Some(body_str) = body_text(&tag.body) {
392 let cls = body_str
393 .split_whitespace()
394 .next()
395 .unwrap_or("")
396 .trim()
397 .to_string();
398 if !cls.is_empty() {
399 result.require_implements.push(cls);
400 }
401 }
402 }
403 "mir-check" => {
404 if let Some(body_str) = body_text(&tag.body) {
405 if let Some((var_part, type_part)) = body_str.split_once(" is ") {
406 let var_name = var_part.trim().trim_start_matches('$').to_string();
407 let type_string = type_part.trim().to_string();
408 if !var_name.is_empty() && !type_string.is_empty() {
409 result.mir_checks.push((var_name, type_string));
410 }
411 }
412 }
413 }
414 "trace" => {
415 if let Some(body_str) = body_text(&tag.body) {
416 for part in body_str.split([',', ' ']) {
418 let var_name = part.trim().trim_start_matches('$').to_string();
419 if !var_name.is_empty() {
420 result.trace_vars.push(var_name);
421 }
422 }
423 }
424 }
425 "taint-sink" => {
426 if let Some(body_str) = body_text(&tag.body) {
427 let mut tokens = body_str.split_whitespace();
429 if let Some(kind) = tokens.next() {
430 let kind = kind.to_string();
431 for param_token in tokens {
432 let param = param_token.trim_start_matches('$').to_string();
433 if !param.is_empty() {
434 result.taint_sinks.push((param, kind.clone()));
435 }
436 }
437 }
438 }
439 }
440 _ => {}
441 }
442 }
443
444 if text.to_ascii_lowercase().contains("{@inheritdoc}") {
445 result.is_inherit_doc = true;
446 }
447
448 result
449 }
450}
451
452#[derive(Debug, Default, Clone)]
457pub struct DocProperty {
458 pub type_hint: String,
459 pub name: String, pub read_only: bool, pub write_only: bool, }
463
464#[derive(Debug, Default, Clone)]
465pub struct DocMethod {
466 pub return_type: String,
467 pub name: String,
468 pub is_static: bool,
469 pub params: Vec<DocMethodParam>,
470}
471
472#[derive(Debug, Default, Clone)]
473pub struct DocMethodParam {
474 pub name: String,
475 pub type_hint: String,
476 pub is_variadic: bool,
477 pub is_byref: bool,
478 pub is_optional: bool,
479}
480
481#[derive(Debug, Default, Clone)]
482pub struct DocTypeAlias {
483 pub name: String,
484 pub type_expr: String,
485}
486
487#[derive(Debug, Default, Clone)]
488pub struct DocImportType {
489 pub original: String,
491 pub local: String,
493 pub from_class: String,
495}
496
497#[derive(Debug, Default, Clone)]
502pub struct ParsedDocblock {
503 pub params: Vec<(String, Type)>,
505 pub out_params: Vec<(String, Type)>,
508 pub return_type: Option<Type>,
510 pub var_type: Option<Type>,
512 pub var_name: Option<String>,
514 pub templates: Vec<(String, Option<Type>, Variance)>,
516 pub extends: Vec<Type>,
520 pub implements: Vec<Type>,
522 pub throws: Vec<String>,
524 pub assertions: Vec<(String, Type)>,
526 pub assertions_if_true: Vec<(String, Type)>,
528 pub assertions_if_false: Vec<(String, Type)>,
530 pub suppressed_issues: Vec<String>,
532 pub is_deprecated: bool,
533 pub is_internal: bool,
534 pub is_pure: bool,
535 pub is_mutation_free: bool,
536 pub is_external_mutation_free: bool,
537 pub no_named_arguments: bool,
538 pub is_immutable: bool,
539 pub is_readonly: bool,
540 pub is_api: bool,
541 pub is_final: bool,
543 pub is_inherit_doc: bool,
546 pub description: String,
548 pub deprecated: Option<String>,
550 pub see: Vec<String>,
552 pub mixins: Vec<String>,
554 pub properties: Vec<DocProperty>,
556 pub methods: Vec<DocMethod>,
558 pub type_aliases: Vec<DocTypeAlias>,
560 pub import_types: Vec<DocImportType>,
562 pub require_extends: Vec<String>,
564 pub require_implements: Vec<String>,
566 pub since: Option<String>,
568 pub removed: Option<String>,
570 pub invalid_annotations: Vec<String>,
572 pub mir_checks: Vec<(String, String)>,
574 pub trace_vars: Vec<String>,
576 pub taint_sinks: Vec<(String, String)>,
578 pub seal_properties: bool,
580 pub if_this_is: Option<Type>,
584 pub self_out: Option<Type>,
588}
589
590impl ParsedDocblock {
591 pub fn get_param_type(&self, name: &str) -> Option<&Type> {
597 let name = name.trim_start_matches('$');
598 self.params
599 .iter()
600 .rfind(|(n, _)| n.trim_start_matches('$') == name)
601 .map(|(_, ty)| ty)
602 }
603
604 pub fn get_out_param_type(&self, name: &str) -> Option<&Type> {
607 let name = name.trim_start_matches('$');
608 self.out_params
609 .iter()
610 .rfind(|(n, _)| n.trim_start_matches('$') == name)
611 .map(|(_, ty)| ty)
612 }
613}
614
615#[cfg(test)]
620mod tests;
621mod types;
625mod validate;
626
627pub(crate) use types::SelfIntConstantsGuard;
628use types::*;
629use validate::*;
630
631pub(crate) use types::parse_type_string;