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