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 "if-this-is" | "psalm-if-this-is" | "phpstan-if-this-is" => {
156 if let Some(body_str) = body_text(&tag.body) {
157 let trimmed = body_str.trim();
158 if !trimmed.is_empty() {
159 result.if_this_is = Some(parse_type_string(trimmed));
160 }
161 }
162 }
163 "suppress" | "psalm-suppress" => {
164 if let Some(body_str) = body_text(&tag.body) {
165 for rule in body_str.split([',', ' ']) {
166 let rule = rule.trim().to_string();
167 if !rule.is_empty() {
168 result.suppressed_issues.push(rule);
169 }
170 }
171 }
172 }
173 "see" => {
174 if let Some(body_str) = body_text(&tag.body) {
175 result.see.push(body_str.to_string());
176 }
177 }
178 "link" => {
179 if let Some(body_str) = body_text(&tag.body) {
180 result.see.push(body_str.to_string());
181 }
182 }
183 "mixin" => {
184 if let Some(body_str) = body_text(&tag.body) {
185 let base_class =
186 body_str.split('<').next().unwrap_or(&body_str).to_string();
187 result.mixins.push(base_class);
188 }
189 }
190 "property" => {
191 if let Some(body_str) = body_text(&tag.body) {
192 if let Some((ty_str, name)) = parse_param_line(&body_str) {
193 result.properties.push(DocProperty {
194 type_hint: ty_str,
195 name: name.trim_start_matches('$').to_string(),
196 read_only: false,
197 write_only: false,
198 });
199 }
200 }
201 }
202 "property-read" => {
203 if let Some(body_str) = body_text(&tag.body) {
204 if let Some((ty_str, name)) = parse_param_line(&body_str) {
205 result.properties.push(DocProperty {
206 type_hint: ty_str,
207 name: name.trim_start_matches('$').to_string(),
208 read_only: true,
209 write_only: false,
210 });
211 }
212 }
213 }
214 "property-write" => {
215 if let Some(body_str) = body_text(&tag.body) {
216 if let Some((ty_str, name)) = parse_param_line(&body_str) {
217 result.properties.push(DocProperty {
218 type_hint: ty_str,
219 name: name.trim_start_matches('$').to_string(),
220 read_only: false,
221 write_only: true,
222 });
223 }
224 }
225 }
226 "method" | "psalm-method" => {
227 let body_str = body_text(&tag.body).unwrap_or_default().trim().to_string();
228 if let Some(err) = validate_method_body(&body_str) {
229 result.invalid_annotations.push(err);
230 } else if let Some(m) = parse_method_line(&body_str) {
231 result.methods.push(m);
232 }
233 }
234 "psalm-type" | "phpstan-type" => {
235 if let Some(body_str) = body_text(&tag.body) {
236 if let Some((name, type_expr)) = body_str.split_once('=') {
237 result.type_aliases.push(DocTypeAlias {
238 name: name.trim().to_string(),
239 type_expr: type_expr.trim().to_string(),
240 });
241 }
242 }
243 }
244 "psalm-import-type" | "phpstan-import-type" => {
245 if let Some(body_str) = body_text(&tag.body) {
246 if let Some(import) = parse_import_type(&body_str) {
247 result.import_types.push(import);
248 }
249 }
250 }
251 "since" if result.since.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.since = Some(v.to_string());
256 }
257 }
258 }
259 "removed" if result.removed.is_none() => {
260 if let Some(body_str) = body_text(&tag.body) {
261 let v = body_str.split_whitespace().next().unwrap_or("");
262 if !v.is_empty() {
263 result.removed = Some(v.to_string());
264 }
265 }
266 }
267 "internal" => result.is_internal = true,
268 "pure" => result.is_pure = true,
269 "seal-properties" | "psalm-seal-properties" => result.seal_properties = true,
270 "no-named-arguments" => result.no_named_arguments = true,
271 "immutable" => result.is_immutable = true,
272 "readonly" => result.is_readonly = true,
273 "final" => result.is_final = true,
274 "inheritDoc" | "inheritdoc" => result.is_inherit_doc = true,
275 "api" | "psalm-api" => result.is_api = true,
276 "psalm-assert-if-true" | "phpstan-assert-if-true" => {
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_true
281 .push((name, parse_type_string(&ty_str)));
282 }
283 }
284 }
285 "psalm-assert-if-false" | "phpstan-assert-if-false" => {
286 if let Some(body_str) = body_text(&tag.body) {
287 if let Some((ty_str, name)) = parse_param_line(&body_str) {
288 result
289 .assertions_if_false
290 .push((name, parse_type_string(&ty_str)));
291 }
292 }
293 }
294 "psalm-property" => {
295 if let Some(body_str) = body_text(&tag.body) {
296 if let Some((ty_str, name)) = parse_param_line(&body_str) {
297 result.properties.push(DocProperty {
298 type_hint: ty_str,
299 name,
300 read_only: false,
301 write_only: false,
302 });
303 }
304 }
305 }
306 "psalm-property-read" => {
307 if let Some(body_str) = body_text(&tag.body) {
308 if let Some((ty_str, name)) = parse_param_line(&body_str) {
309 result.properties.push(DocProperty {
310 type_hint: ty_str,
311 name,
312 read_only: true,
313 write_only: false,
314 });
315 }
316 }
317 }
318 "psalm-property-write" => {
319 if let Some(body_str) = body_text(&tag.body) {
320 if let Some((ty_str, name)) = parse_param_line(&body_str) {
321 result.properties.push(DocProperty {
322 type_hint: ty_str,
323 name,
324 read_only: false,
325 write_only: true,
326 });
327 }
328 }
329 }
330 "psalm-require-extends" | "phpstan-require-extends" => {
331 if let Some(body_str) = body_text(&tag.body) {
332 let cls = body_str
333 .split_whitespace()
334 .next()
335 .unwrap_or("")
336 .trim()
337 .to_string();
338 if !cls.is_empty() {
339 result.require_extends.push(cls);
340 }
341 }
342 }
343 "psalm-require-implements" | "phpstan-require-implements" => {
344 if let Some(body_str) = body_text(&tag.body) {
345 let cls = body_str
346 .split_whitespace()
347 .next()
348 .unwrap_or("")
349 .trim()
350 .to_string();
351 if !cls.is_empty() {
352 result.require_implements.push(cls);
353 }
354 }
355 }
356 "mir-check" => {
357 if let Some(body_str) = body_text(&tag.body) {
358 if let Some((var_part, type_part)) = body_str.split_once(" is ") {
359 let var_name = var_part.trim().trim_start_matches('$').to_string();
360 let type_string = type_part.trim().to_string();
361 if !var_name.is_empty() && !type_string.is_empty() {
362 result.mir_checks.push((var_name, type_string));
363 }
364 }
365 }
366 }
367 "trace" => {
368 if let Some(body_str) = body_text(&tag.body) {
369 for part in body_str.split([',', ' ']) {
371 let var_name = part.trim().trim_start_matches('$').to_string();
372 if !var_name.is_empty() {
373 result.trace_vars.push(var_name);
374 }
375 }
376 }
377 }
378 "taint-sink" => {
379 if let Some(body_str) = body_text(&tag.body) {
380 let mut tokens = body_str.split_whitespace();
382 if let Some(kind) = tokens.next() {
383 let kind = kind.to_string();
384 for param_token in tokens {
385 let param = param_token.trim_start_matches('$').to_string();
386 if !param.is_empty() {
387 result.taint_sinks.push((param, kind.clone()));
388 }
389 }
390 }
391 }
392 }
393 _ => {}
394 }
395 }
396
397 if text.to_ascii_lowercase().contains("{@inheritdoc}") {
398 result.is_inherit_doc = true;
399 }
400
401 result
402 }
403}
404
405#[derive(Debug, Default, Clone)]
410pub struct DocProperty {
411 pub type_hint: String,
412 pub name: String, pub read_only: bool, pub write_only: bool, }
416
417#[derive(Debug, Default, Clone)]
418pub struct DocMethod {
419 pub return_type: String,
420 pub name: String,
421 pub is_static: bool,
422 pub params: Vec<DocMethodParam>,
423}
424
425#[derive(Debug, Default, Clone)]
426pub struct DocMethodParam {
427 pub name: String,
428 pub type_hint: String,
429 pub is_variadic: bool,
430 pub is_byref: bool,
431 pub is_optional: bool,
432}
433
434#[derive(Debug, Default, Clone)]
435pub struct DocTypeAlias {
436 pub name: String,
437 pub type_expr: String,
438}
439
440#[derive(Debug, Default, Clone)]
441pub struct DocImportType {
442 pub original: String,
444 pub local: String,
446 pub from_class: String,
448}
449
450#[derive(Debug, Default, Clone)]
455pub struct ParsedDocblock {
456 pub params: Vec<(String, Type)>,
458 pub return_type: Option<Type>,
460 pub var_type: Option<Type>,
462 pub var_name: Option<String>,
464 pub templates: Vec<(String, Option<Type>, Variance)>,
466 pub extends: Option<Type>,
468 pub implements: Vec<Type>,
470 pub throws: Vec<String>,
472 pub assertions: Vec<(String, Type)>,
474 pub assertions_if_true: Vec<(String, Type)>,
476 pub assertions_if_false: Vec<(String, Type)>,
478 pub suppressed_issues: Vec<String>,
480 pub is_deprecated: bool,
481 pub is_internal: bool,
482 pub is_pure: bool,
483 pub no_named_arguments: bool,
484 pub is_immutable: bool,
485 pub is_readonly: bool,
486 pub is_api: bool,
487 pub is_final: bool,
489 pub is_inherit_doc: bool,
492 pub description: String,
494 pub deprecated: Option<String>,
496 pub see: Vec<String>,
498 pub mixins: Vec<String>,
500 pub properties: Vec<DocProperty>,
502 pub methods: Vec<DocMethod>,
504 pub type_aliases: Vec<DocTypeAlias>,
506 pub import_types: Vec<DocImportType>,
508 pub require_extends: Vec<String>,
510 pub require_implements: Vec<String>,
512 pub since: Option<String>,
514 pub removed: Option<String>,
516 pub invalid_annotations: Vec<String>,
518 pub mir_checks: Vec<(String, String)>,
520 pub trace_vars: Vec<String>,
522 pub taint_sinks: Vec<(String, String)>,
524 pub seal_properties: bool,
526 pub if_this_is: Option<Type>,
530}
531
532impl ParsedDocblock {
533 pub fn get_param_type(&self, name: &str) -> Option<&Type> {
539 let name = name.trim_start_matches('$');
540 self.params
541 .iter()
542 .rfind(|(n, _)| n.trim_start_matches('$') == name)
543 .map(|(_, ty)| ty)
544 }
545}
546
547#[cfg(test)]
552mod tests;
553mod types;
557mod validate;
558
559use types::*;
560use validate::*;
561
562pub(crate) use types::parse_type_string;