mir_analyzer/
include_graph.rs1use php_ast::ast::{BinaryOp, MagicConstKind};
5use php_ast::owned::visitor::OwnedVisitor;
6use php_ast::owned::{Expr, ExprKind};
7use std::ops::ControlFlow;
8use std::path::{Component, Path, PathBuf};
9
10use crate::file_extensions::PhpFileExtensions;
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum VendorTargets {
21 Skip,
22 Follow,
23}
24
25pub fn include_closure(
34 seeds: Vec<PathBuf>,
35 vendor: VendorTargets,
36 extensions: &PhpFileExtensions,
37) -> Vec<PathBuf> {
38 let mut out = seeds;
39 let mut seen: rustc_hash::FxHashSet<PathBuf> = out.iter().cloned().collect();
40 let mut queue: Vec<PathBuf> = out.clone();
41 while let Some(file) = queue.pop() {
42 let Some(dir) = file.parent().map(Path::to_path_buf) else {
43 continue;
44 };
45 let Ok(text) = crate::read_php_source(&file) else {
46 continue;
47 };
48 if !text.contains("require") && !text.contains("include") {
51 continue;
52 }
53 let parsed = php_rs_parser::parse(&text);
54 let mut scanner = IncludeTargetScanner {
55 file: &file,
56 targets: Vec::new(),
57 };
58 let _ = scanner.visit_program(&parsed.program);
59 for target in scanner.targets {
60 let resolved = if Path::new(&target).is_absolute() {
61 PathBuf::from(target)
62 } else {
63 dir.join(target)
64 };
65 let resolved = lexically_normalize(&resolved);
66 if !extensions.is_php_source(&resolved) {
67 continue;
68 }
69 if vendor == VendorTargets::Skip
70 && resolved.components().any(|c| c.as_os_str() == "vendor")
71 {
72 continue;
73 }
74 if !resolved.is_file() {
75 continue;
76 }
77 if seen.insert(resolved.clone()) {
78 out.push(resolved.clone());
79 queue.push(resolved);
80 }
81 }
82 }
83 out
84}
85
86fn lexically_normalize(path: &Path) -> PathBuf {
92 let mut components = path.components().peekable();
93 let mut ret = if let Some(c @ Component::Prefix(_)) = components.peek().copied() {
94 components.next();
95 PathBuf::from(c.as_os_str())
96 } else {
97 PathBuf::new()
98 };
99
100 for component in components {
101 match component {
102 Component::Prefix(_) => unreachable!(),
103 Component::RootDir => ret.push(component.as_os_str()),
104 Component::CurDir => {}
105 Component::ParentDir => {
106 ret.pop();
107 }
108 Component::Normal(c) => ret.push(c),
109 }
110 }
111 ret
112}
113
114struct IncludeTargetScanner<'a> {
115 file: &'a Path,
116 targets: Vec<String>,
117}
118
119impl OwnedVisitor for IncludeTargetScanner<'_> {
120 fn visit_expr(&mut self, expr: &Expr) -> ControlFlow<()> {
121 if let ExprKind::Include(_, inner) = &expr.kind {
122 if let Some(target) = resolve_static_include_target(inner, self.file) {
123 self.targets.push(target);
124 }
125 }
126 crate::recursion::walk_expr(self, expr)
127 }
128}
129
130fn resolve_static_include_target(expr: &Expr, file: &Path) -> Option<String> {
136 match &expr.kind {
137 ExprKind::String(s) => Some(s.to_string()),
138 ExprKind::MagicConst(MagicConstKind::File) => Some(file.to_string_lossy().into_owned()),
139 ExprKind::MagicConst(MagicConstKind::Dir) => {
140 Some(file.parent()?.to_string_lossy().into_owned())
141 }
142 ExprKind::Parenthesized(inner) => resolve_static_include_target(inner, file),
143 ExprKind::Binary(b) if b.op == BinaryOp::Concat => {
144 let left = resolve_static_include_target(&b.left, file)?;
145 let right = resolve_static_include_target(&b.right, file)?;
146 Some(format!("{left}{right}"))
147 }
148 ExprKind::FunctionCall(call) => {
149 let ExprKind::Identifier(name) = &call.name.kind else {
150 return None;
151 };
152 if !name.eq_ignore_ascii_case("dirname") || call.args.len() > 2 {
153 return None;
154 }
155 let path = resolve_static_include_target(call.args.first()?.value.as_ref()?, file)?;
156 let levels = match call.args.get(1) {
157 None => 1,
158 Some(arg) => match arg.value.as_ref()?.kind {
159 ExprKind::Int(n) if n >= 1 => n,
160 _ => return None,
161 },
162 };
163 let mut path = Path::new(&path);
164 for _ in 0..levels {
165 path = path.parent()?;
166 }
167 Some(path.to_string_lossy().into_owned())
168 }
169 _ => None,
170 }
171}