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