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 first_word = body_str.split_whitespace().next().unwrap_or("");
95 for class in first_word.split('|') {
96 if !class.is_empty() {
97 result.throws.push(class.to_string());
98 }
99 }
100 }
101 }
102 "deprecated" => {
103 result.is_deprecated = true;
104 result.deprecated = Some(body_text(&tag.body).unwrap_or_default().to_string());
105 }
106 "template" | "psalm-template" | "phpstan-template" => {
107 if let Some((name, bound, default)) =
108 parse_template_line(tag.name.as_str(), body_text(&tag.body))
109 {
110 if let Some(msg) = validate_type_str(&name, "template") {
111 result.invalid_annotations.push(msg);
112 }
113 if let Some(b) = &bound {
114 if let Some(msg) = validate_type_str(b, "template") {
115 result.invalid_annotations.push(msg);
116 }
117 }
118 result.templates.push((
119 name,
120 bound.map(|b| parse_type_string(&b)),
121 Variance::Invariant,
122 default.map(|d| parse_type_string(&d)),
123 ));
124 }
125 }
126 "template-covariant"
127 | "psalm-template-covariant"
128 | "phpstan-template-covariant" => {
129 if let Some((name, bound, default)) =
130 parse_template_line(tag.name.as_str(), body_text(&tag.body))
131 {
132 if let Some(msg) = validate_type_str(&name, "template-covariant") {
133 result.invalid_annotations.push(msg);
134 }
135 if let Some(b) = &bound {
136 if let Some(msg) = validate_type_str(b, "template-covariant") {
137 result.invalid_annotations.push(msg);
138 }
139 }
140 result.templates.push((
141 name,
142 bound.map(|b| parse_type_string(&b)),
143 Variance::Covariant,
144 default.map(|d| parse_type_string(&d)),
145 ));
146 }
147 }
148 "template-contravariant"
149 | "psalm-template-contravariant"
150 | "phpstan-template-contravariant" => {
151 if let Some((name, bound, default)) =
152 parse_template_line(tag.name.as_str(), body_text(&tag.body))
153 {
154 if let Some(msg) = validate_type_str(&name, "template-contravariant") {
155 result.invalid_annotations.push(msg);
156 }
157 if let Some(b) = &bound {
158 if let Some(msg) = validate_type_str(b, "template-contravariant") {
159 result.invalid_annotations.push(msg);
160 }
161 }
162 result.templates.push((
163 name,
164 bound.map(|b| parse_type_string(&b)),
165 Variance::Contravariant,
166 default.map(|d| parse_type_string(&d)),
167 ));
168 }
169 }
170 "extends" | "template-extends" | "phpstan-extends" => {
171 if let Some(body_str) = body_text(&tag.body) {
172 let trimmed = body_str.trim();
173 if let Some(msg) = validate_type_str(trimmed, "extends") {
174 result.invalid_annotations.push(msg);
175 }
176 result.extends.push(parse_type_string(trimmed));
177 }
178 }
179 "implements" | "template-implements" | "phpstan-implements" => {
180 if let Some(body_str) = body_text(&tag.body) {
181 let trimmed = body_str.trim();
182 if let Some(msg) = validate_type_str(trimmed, "implements") {
183 result.invalid_annotations.push(msg);
184 }
185 result.implements.push(parse_type_string(trimmed));
186 }
187 }
188 "assert" | "psalm-assert" | "phpstan-assert" => {
189 if let Some(body_str) = body_text(&tag.body) {
190 if let Some((ty_str, name)) = parse_param_line(&body_str) {
191 let (ty, negated) = parse_assertion_type(&ty_str);
192 result.assertions.push((name, ty, negated));
193 }
194 }
195 }
196 "if-this-is" | "psalm-if-this-is" | "phpstan-if-this-is" => {
197 if let Some(body_str) = body_text(&tag.body) {
198 let trimmed = body_str.trim();
199 if !trimmed.is_empty() {
200 result.if_this_is = Some(parse_type_string(trimmed));
201 }
202 }
203 }
204 "self-out" | "psalm-self-out" | "phpstan-self-out" => {
205 if let Some(body_str) = body_text(&tag.body) {
206 let trimmed = body_str.trim();
207 if !trimmed.is_empty() {
208 result.self_out = Some(parse_type_string(trimmed));
209 }
210 }
211 }
212 "suppress" | "psalm-suppress" => {
213 if let Some(body_str) = body_text(&tag.body) {
214 for rule in body_str.split([',', ' ']) {
215 let rule = rule.trim().to_string();
216 if !rule.is_empty() {
217 result.suppressed_issues.push(rule);
218 }
219 }
220 }
221 }
222 "see" => {
223 if let Some(body_str) = body_text(&tag.body) {
224 result.see.push(body_str.to_string());
225 }
226 }
227 "link" => {
228 if let Some(body_str) = body_text(&tag.body) {
229 result.see.push(body_str.to_string());
230 }
231 }
232 "mixin" => {
233 if let Some(body_str) = body_text(&tag.body) {
234 let base_class =
235 body_str.split('<').next().unwrap_or(&body_str).to_string();
236 result.mixins.push(base_class);
237 }
238 }
239 "property" => {
240 if let Some(body_str) = body_text(&tag.body) {
241 if let Some((ty_str, name)) = parse_param_line(&body_str) {
242 result.properties.push(DocProperty {
243 type_hint: ty_str,
244 name: name.trim_start_matches('$').to_string(),
245 read_only: false,
246 write_only: false,
247 });
248 }
249 }
250 }
251 "property-read" => {
252 if let Some(body_str) = body_text(&tag.body) {
253 if let Some((ty_str, name)) = parse_param_line(&body_str) {
254 result.properties.push(DocProperty {
255 type_hint: ty_str,
256 name: name.trim_start_matches('$').to_string(),
257 read_only: true,
258 write_only: false,
259 });
260 }
261 }
262 }
263 "property-write" => {
264 if let Some(body_str) = body_text(&tag.body) {
265 if let Some((ty_str, name)) = parse_param_line(&body_str) {
266 result.properties.push(DocProperty {
267 type_hint: ty_str,
268 name: name.trim_start_matches('$').to_string(),
269 read_only: false,
270 write_only: true,
271 });
272 }
273 }
274 }
275 "method" | "psalm-method" => {
276 let body_str = body_text(&tag.body).unwrap_or_default().trim().to_string();
277 if let Some(err) = validate_method_body(&body_str) {
278 result.invalid_annotations.push(err);
279 } else if let Some(m) = parse_method_line(&body_str) {
280 result.methods.push(m);
281 }
282 }
283 "psalm-type" | "phpstan-type" => {
284 if let Some(body_str) = body_text(&tag.body) {
285 if let Some((name, type_expr)) = body_str.split_once('=') {
286 result.type_aliases.push(DocTypeAlias {
287 name: name.trim().to_string(),
288 type_expr: type_expr.trim().to_string(),
289 });
290 }
291 }
292 }
293 "psalm-import-type" | "phpstan-import-type" => {
294 if let Some(body_str) = body_text(&tag.body) {
295 if let Some(import) = parse_import_type(&body_str) {
296 result.import_types.push(import);
297 }
298 }
299 }
300 "since" if result.since.is_none() => {
301 if let Some(body_str) = body_text(&tag.body) {
302 let v = body_str.split_whitespace().next().unwrap_or("");
303 if !v.is_empty() {
304 result.since = Some(v.to_string());
305 }
306 }
307 }
308 "removed" if result.removed.is_none() => {
309 if let Some(body_str) = body_text(&tag.body) {
310 let v = body_str.split_whitespace().next().unwrap_or("");
311 if !v.is_empty() {
312 result.removed = Some(v.to_string());
313 }
314 }
315 }
316 "internal" => result.is_internal = true,
317 "pure" | "psalm-pure" | "phpstan-pure" => result.is_pure = true,
318 "seal-properties" | "psalm-seal-properties" => result.seal_properties = true,
319 "no-named-arguments" => result.no_named_arguments = true,
320 "mutation-free" | "psalm-mutation-free" | "phpstan-mutation-free" => {
321 result.is_mutation_free = true
322 }
323 "psalm-external-mutation-free" => result.is_external_mutation_free = true,
324 "immutable" | "psalm-immutable" => result.is_immutable = true,
325 "readonly" | "psalm-readonly" | "phpstan-readonly" => result.is_readonly = true,
326 "final" => result.is_final = true,
327 "inheritDoc" | "inheritdoc" => result.is_inherit_doc = true,
328 "api" | "psalm-api" => result.is_api = true,
329 "psalm-assert-if-true" | "phpstan-assert-if-true" => {
330 if let Some(body_str) = body_text(&tag.body) {
331 if let Some((ty_str, name)) = parse_param_line(&body_str) {
332 let (ty, negated) = parse_assertion_type(&ty_str);
333 result.assertions_if_true.push((name, ty, negated));
334 }
335 }
336 }
337 "psalm-assert-if-false" | "phpstan-assert-if-false" => {
338 if let Some(body_str) = body_text(&tag.body) {
339 if let Some((ty_str, name)) = parse_param_line(&body_str) {
340 let (ty, negated) = parse_assertion_type(&ty_str);
341 result.assertions_if_false.push((name, ty, negated));
342 }
343 }
344 }
345 "psalm-property" => {
346 if let Some(body_str) = body_text(&tag.body) {
347 if let Some((ty_str, name)) = parse_param_line(&body_str) {
348 result.properties.push(DocProperty {
349 type_hint: ty_str,
350 name,
351 read_only: false,
352 write_only: false,
353 });
354 }
355 }
356 }
357 "psalm-property-read" => {
358 if let Some(body_str) = body_text(&tag.body) {
359 if let Some((ty_str, name)) = parse_param_line(&body_str) {
360 result.properties.push(DocProperty {
361 type_hint: ty_str,
362 name,
363 read_only: true,
364 write_only: false,
365 });
366 }
367 }
368 }
369 "psalm-property-write" => {
370 if let Some(body_str) = body_text(&tag.body) {
371 if let Some((ty_str, name)) = parse_param_line(&body_str) {
372 result.properties.push(DocProperty {
373 type_hint: ty_str,
374 name,
375 read_only: false,
376 write_only: true,
377 });
378 }
379 }
380 }
381 "psalm-require-extends" | "phpstan-require-extends" => {
382 if let Some(body_str) = body_text(&tag.body) {
383 let cls = body_str
384 .split_whitespace()
385 .next()
386 .unwrap_or("")
387 .trim()
388 .to_string();
389 if !cls.is_empty() {
390 result.require_extends.push(cls);
391 }
392 }
393 }
394 "psalm-require-implements" | "phpstan-require-implements" => {
395 if let Some(body_str) = body_text(&tag.body) {
396 let cls = body_str
397 .split_whitespace()
398 .next()
399 .unwrap_or("")
400 .trim()
401 .to_string();
402 if !cls.is_empty() {
403 result.require_implements.push(cls);
404 }
405 }
406 }
407 "mir-check" => {
408 if let Some(body_str) = body_text(&tag.body) {
409 if let Some((var_part, type_part)) = body_str.split_once(" is ") {
410 let var_name = var_part.trim().trim_start_matches('$').to_string();
411 let type_string = type_part.trim().to_string();
412 if !var_name.is_empty() && !type_string.is_empty() {
413 result.mir_checks.push((var_name, type_string));
414 }
415 }
416 }
417 }
418 "dataProvider" => {
419 if let Some(body_str) = body_text(&tag.body) {
420 let name = body_str
421 .trim()
422 .trim_end_matches("()")
423 .rsplit("::")
424 .next()
425 .unwrap_or("")
426 .trim();
427 if !name.is_empty() {
428 result.data_providers.push(name.to_string());
429 }
430 }
431 }
432 "trace" => {
433 if let Some(body_str) = body_text(&tag.body) {
434 for part in body_str.split([',', ' ']) {
436 let var_name = part.trim().trim_start_matches('$').to_string();
437 if !var_name.is_empty() {
438 result.trace_vars.push(var_name);
439 }
440 }
441 }
442 }
443 "taint-sink" => {
444 if let Some(body_str) = body_text(&tag.body) {
445 let mut tokens = body_str.split_whitespace();
447 if let Some(kind) = tokens.next() {
448 let kind = kind.to_string();
449 for param_token in tokens {
450 let param = param_token.trim_start_matches('$').to_string();
451 if !param.is_empty() {
452 result.taint_sinks.push((param, kind.clone()));
453 }
454 }
455 }
456 }
457 }
458 _ => {}
459 }
460 }
461
462 if text.to_ascii_lowercase().contains("{@inheritdoc}") {
463 result.is_inherit_doc = true;
464 }
465
466 result
467 }
468}
469
470#[derive(Debug, Default, Clone)]
475pub struct DocProperty {
476 pub type_hint: String,
477 pub name: String, pub read_only: bool, pub write_only: bool, }
481
482#[derive(Debug, Default, Clone)]
483pub struct DocMethod {
484 pub return_type: String,
485 pub name: String,
486 pub is_static: bool,
487 pub params: Vec<DocMethodParam>,
488}
489
490#[derive(Debug, Default, Clone)]
491pub struct DocMethodParam {
492 pub name: String,
493 pub type_hint: String,
494 pub is_variadic: bool,
495 pub is_byref: bool,
496 pub is_optional: bool,
497}
498
499#[derive(Debug, Default, Clone)]
500pub struct DocTypeAlias {
501 pub name: String,
502 pub type_expr: String,
503}
504
505#[derive(Debug, Default, Clone)]
506pub struct DocImportType {
507 pub original: String,
509 pub local: String,
511 pub from_class: String,
513}
514
515#[derive(Debug, Default, Clone)]
520pub struct ParsedDocblock {
521 pub params: Vec<(String, Type)>,
523 pub out_params: Vec<(String, Type)>,
526 pub return_type: Option<Type>,
528 pub var_type: Option<Type>,
530 pub var_name: Option<String>,
532 pub templates: Vec<(String, Option<Type>, Variance, Option<Type>)>,
535 pub extends: Vec<Type>,
539 pub implements: Vec<Type>,
541 pub throws: Vec<String>,
543 pub assertions: Vec<(String, Type, bool)>,
545 pub assertions_if_true: Vec<(String, Type, bool)>,
547 pub assertions_if_false: Vec<(String, Type, bool)>,
549 pub suppressed_issues: Vec<String>,
551 pub is_deprecated: bool,
552 pub is_internal: bool,
553 pub is_pure: bool,
554 pub is_mutation_free: bool,
555 pub is_external_mutation_free: bool,
556 pub no_named_arguments: bool,
557 pub is_immutable: bool,
558 pub is_readonly: bool,
559 pub is_api: bool,
560 pub is_final: bool,
562 pub is_inherit_doc: bool,
565 pub description: String,
567 pub deprecated: Option<String>,
569 pub see: Vec<String>,
571 pub mixins: Vec<String>,
573 pub properties: Vec<DocProperty>,
575 pub methods: Vec<DocMethod>,
577 pub type_aliases: Vec<DocTypeAlias>,
579 pub import_types: Vec<DocImportType>,
581 pub require_extends: Vec<String>,
583 pub require_implements: Vec<String>,
585 pub since: Option<String>,
587 pub removed: Option<String>,
589 pub invalid_annotations: Vec<String>,
591 pub mir_checks: Vec<(String, String)>,
593 pub trace_vars: Vec<String>,
595 pub taint_sinks: Vec<(String, String)>,
597 pub seal_properties: bool,
599 pub if_this_is: Option<Type>,
603 pub self_out: Option<Type>,
607 pub data_providers: Vec<String>,
610}
611
612impl ParsedDocblock {
613 pub fn get_param_type(&self, name: &str) -> Option<&Type> {
619 let name = name.trim_start_matches('$');
620 self.params
621 .iter()
622 .rfind(|(n, _)| n.trim_start_matches('$') == name)
623 .map(|(_, ty)| ty)
624 }
625
626 pub fn get_out_param_type(&self, name: &str) -> Option<&Type> {
629 let name = name.trim_start_matches('$');
630 self.out_params
631 .iter()
632 .rfind(|(n, _)| n.trim_start_matches('$') == name)
633 .map(|(_, ty)| ty)
634 }
635}
636
637#[cfg(test)]
642mod tests;
643mod types;
647mod validate;
648
649pub(crate) use types::SelfIntConstantsGuard;
650use types::*;
651use validate::*;
652
653pub(crate) use types::parse_type_string;