Skip to main content

mir_analyzer/
include_graph.rs

1//! Include closure: the files reachable from a set of seeds through statically
2//! resolvable `require`/`include` targets.
3
4use php_ast::ast::{BinaryOp, MagicConstKind};
5use php_ast::owned::visitor::{walk_owned_expr, 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/// Whether include targets under a `vendor` directory join the closure.
13///
14/// Project walks [`Skip`](Self::Skip) them: composer autoloading already
15/// reaches vendor code, and re-adding it would index it as project code.
16/// A closure seeded from vendor files themselves (a files-autoload bootstrap's
17/// sibling requires) must [`Follow`](Self::Follow), since every target lives
18/// inside `vendor`.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub enum VendorTargets {
21    Skip,
22    Follow,
23}
24
25/// Returns `seeds` plus every file reachable from them through `require`/`include`
26/// targets that resolve to a real file with a PHP extension in `extensions`,
27/// recursing since a newly-added file may itself reach further files. Only
28/// statically-resolvable target shapes are followed: a literal string, and
29/// `__DIR__` / `dirname(__FILE__)` concatenated with a literal string, the common
30/// manual-bootstrap idiom (e.g. `require_once __DIR__ . '/../legacy/bootstrap.php'`).
31/// A bare literal with no `__DIR__` resolves relative to the including file's
32/// own directory.
33pub 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) = std::fs::read_to_string(&file) else {
46            continue;
47        };
48        // Cheap bailout: skip the full parse for the (common) majority of
49        // files that don't even mention require/include.
50        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
86/// Collapses `.`/`..` components lexically instead of relying on the OS to
87/// resolve them, since a `__DIR__`-derived base can be a Windows verbatim
88/// (`\\?\`-prefixed) path — those disable `..` resolution by the OS, so a
89/// naively-concatenated `\\?\C:\...\src/../legacy/helpers.php` would never
90/// resolve to a real file even though the target genuinely exists.
91fn 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        walk_owned_expr(self, expr)
127    }
128}
129
130/// Statically evaluates the include-target expression shapes real code
131/// actually uses: a literal string, `__DIR__`, `__FILE__`, `dirname()` of those
132/// (with an optional literal level), and `.` concatenations. Anything else (a
133/// variable, another call) can't be resolved without running the program, so
134/// is left alone.
135fn 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}