use std::collections::BTreeSet;
use std::path::{Component, Path, PathBuf};
use anyhow::Result;
use tree_sitter::{Language, Node, Query};
use super::{Adapter, Import, Target, captured_nodes, parse, relative};
const QUERY: &str = "
[
(namespace_use_declaration)
(require_expression)
(require_once_expression)
(include_expression)
(include_once_expression)
] @node";
pub struct Php {
root: PathBuf,
language: Language,
query: Query,
psr4: Vec<(String, PathBuf)>,
paths: BTreeSet<PathBuf>,
}
impl Php {
pub fn new(root: &Path) -> Result<Self> {
let language: Language = tree_sitter_php::LANGUAGE_PHP.into();
let query = Query::new(&language, QUERY)?;
let mut paths = BTreeSet::new();
let mut manifests = Vec::new();
for entry in crate::scan::walker(root).build().flatten() {
let Some(rel) = relative(root, entry.path()) else {
continue;
};
if entry.file_name() == "composer.json" {
manifests.push((rel.clone(), entry.path().to_path_buf()));
}
for ancestor in rel.ancestors().filter(|a| !a.as_os_str().is_empty()) {
paths.insert(ancestor.to_path_buf());
}
}
let mut psr4 = Vec::new();
for (rel, path) in manifests {
let Ok(text) = std::fs::read_to_string(path) else {
continue;
};
let dir = rel.parent().unwrap_or(Path::new(""));
psr4.extend(psr4_maps(&text, dir));
}
psr4.sort_by_key(|(prefix, _)| std::cmp::Reverse(prefix.len()));
Ok(Self {
root: root.to_path_buf(),
language,
query,
psr4,
paths,
})
}
fn namespaced(&self, name: &str, namespace_only: bool) -> Option<PathBuf> {
let name = name.trim_start_matches('\\');
let full = format!("{name}\\");
for (prefix, dir) in &self.psr4 {
let Some(rest) = full.strip_prefix(prefix.as_str()) else {
continue;
};
let rest = rest.trim_end_matches('\\');
let mut path = dir.clone();
path.extend(rest.split('\\').filter(|p| !p.is_empty()));
let file = path.with_extension("php");
if !namespace_only && self.paths.contains(&file) {
return Some(file);
}
if self.paths.contains(&path) {
return Some(path);
}
let mut shorter = path;
while shorter.pop() && shorter.starts_with(dir) {
let file = shorter.with_extension("php");
if !namespace_only && self.paths.contains(&file) {
return Some(file);
}
}
}
None
}
}
fn psr4_maps(manifest: &str, dir: &Path) -> Vec<(String, PathBuf)> {
let Ok(json) = serde_json::from_str::<serde_json::Value>(manifest) else {
return Vec::new();
};
let mut maps = Vec::new();
for section in ["autoload", "autoload-dev"] {
let Some(map) = json[section]["psr-4"].as_object() else {
continue;
};
for (prefix, target) in map {
let dirs = match target {
serde_json::Value::String(one) => vec![one.as_str()],
serde_json::Value::Array(many) => many.iter().filter_map(|v| v.as_str()).collect(),
_ => Vec::new(),
};
for target in dirs {
let prefix = if prefix.is_empty() || prefix.ends_with('\\') {
prefix.clone()
} else {
format!("{prefix}\\")
};
maps.push((prefix, normalize(&dir.join(target.trim_end_matches('/')))));
}
}
}
maps
}
fn normalize(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other),
}
}
out
}
fn text<'a>(node: Node, source: &'a str) -> &'a str {
node.utf8_text(source.as_bytes()).unwrap_or_default()
}
fn use_specifiers(node: Node, source: &str) -> Vec<String> {
let mut cursor = node.walk();
let children: Vec<Node> = node.children(&mut cursor).collect();
let kind_of = |node: Node| -> &'static str {
let mut cursor = node.walk();
let found = node.children(&mut cursor).find_map(|c| match c.kind() {
"function" => Some("function "),
"const" => Some("const "),
_ => None,
});
found.unwrap_or("")
};
let declared = kind_of(node);
let clause_name = |clause: Node| -> String {
let mut cursor = clause.walk();
let name = clause
.named_children(&mut cursor)
.find(|n| matches!(n.kind(), "qualified_name" | "name" | "namespace_name"));
name.map_or_else(String::new, |n| {
text(n, source).split_whitespace().collect::<String>()
})
};
let mut specifiers = Vec::new();
let prefix = children
.iter()
.find(|c| c.kind() == "namespace_name")
.map(|c| text(*c, source).trim_matches('\\').to_string());
for child in &children {
match child.kind() {
"namespace_use_clause" => {
let kind = if declared.is_empty() {
kind_of(*child)
} else {
declared
};
specifiers.push(format!(
"{kind}{}",
clause_name(*child).trim_start_matches('\\')
));
}
"namespace_use_group" => {
let mut cursor = child.walk();
for clause in child.named_children(&mut cursor) {
if clause.kind() == "namespace_use_clause" {
let kind = if declared.is_empty() {
kind_of(clause)
} else {
declared
};
let name = clause_name(clause);
let full = match &prefix {
Some(p) => format!("{p}\\{}", name.trim_start_matches('\\')),
None => name,
};
specifiers.push(format!("{kind}{full}"));
}
}
}
_ => {}
}
}
specifiers
}
fn include_specifier(node: Node, source: &str) -> Option<String> {
let mut cursor = node.walk();
let argument = node.named_children(&mut cursor).next()?;
let literal = |n: Node| -> Option<String> {
let mut cursor = n.walk();
let content = n
.named_children(&mut cursor)
.find(|c| c.kind() == "string_content")?;
Some(text(content, source).to_string())
};
match argument.kind() {
"string" | "encapsed_string" => literal(argument).map(|p| format!("include:{p}")),
"binary_expression" => {
let left = argument.child_by_field_name("left")?;
let right = argument.child_by_field_name("right")?;
(text(left, source) == "__DIR__" && right.kind() == "string")
.then(|| literal(right))
.flatten()
.map(|p| format!("include:{}", p.trim_start_matches('/')))
}
_ => None,
}
}
impl Adapter for Php {
fn name(&self) -> &'static str {
"php"
}
fn handles(&self, path: &Path) -> bool {
path.extension().is_some_and(|e| e == "php")
}
fn imports(&self, path: &Path, source: &str) -> Result<Vec<Import>> {
let tree = parse(&self.language, path, source)?;
let mut imports = Vec::new();
for node in captured_nodes(&self.query, &tree, source) {
let line = node.start_position().row + 1;
let specifiers = if node.kind() == "namespace_use_declaration" {
use_specifiers(node, source)
} else {
include_specifier(node, source).into_iter().collect()
};
imports.extend(
specifiers
.into_iter()
.map(|specifier| Import { specifier, line }),
);
}
Ok(imports)
}
fn resolve(&self, file: &Path, specifier: &str) -> Target {
if let Some(path) = specifier.strip_prefix("include:") {
let found = relative(&self.root, file)
.map(|rel| normalize(&rel.parent().unwrap_or(Path::new("")).join(path)))
.filter(|candidate| self.paths.contains(candidate));
return found.map_or_else(|| Target::External(path.to_string()), Target::Internal);
}
let (name, namespace_only) = match specifier.split_once(' ') {
Some((_, name)) => (name, true),
None => (specifier, false),
};
let lookup = if namespace_only {
name.rsplit_once('\\')
.map_or(name, |(namespace, _)| namespace)
} else {
name
};
match self.namespaced(lookup, namespace_only) {
Some(path) => Target::Internal(path),
None => Target::External(specifier.to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extracts_uses_groups_and_includes() {
let php = Php::new(Path::new("/nonexistent")).unwrap();
let src = "<?php\nnamespace App\\Shop;\nuse App\\Models\\User;\nuse App\\Models\\{Order, Cart as C};\nuse function App\\Util\\helper;\nuse const App\\X\\Y;\nclass A {}\nrequire_once 'lib/x.php';\ninclude __DIR__ . '/y.php';\ninclude $dynamic;\n";
let got: Vec<_> = php
.imports(Path::new("A.php"), src)
.unwrap()
.into_iter()
.map(|i| (i.specifier, i.line))
.collect();
assert_eq!(
got,
[
("App\\Models\\User".to_string(), 3),
("App\\Models\\Order".to_string(), 4),
("App\\Models\\Cart".to_string(), 4),
("function App\\Util\\helper".to_string(), 5),
("const App\\X\\Y".to_string(), 6),
("include:lib/x.php".to_string(), 8),
("include:y.php".to_string(), 9),
]
);
}
#[test]
fn reads_psr4_maps() {
let maps = psr4_maps(
r#"{"autoload":{"psr-4":{"App\\":"src/","Lib":["lib/","vendor-lib/"]}},"autoload-dev":{"psr-4":{"Tests\\":"tests/"}}}"#,
Path::new("pkg"),
);
assert!(maps.contains(&("App\\".to_string(), PathBuf::from("pkg/src"))));
assert!(maps.contains(&("Lib\\".to_string(), PathBuf::from("pkg/vendor-lib"))));
assert!(maps.contains(&("Tests\\".to_string(), PathBuf::from("pkg/tests"))));
}
#[test]
fn resolves_psr4_names_namespaces_and_includes() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
for (path, text) in [
(
"composer.json",
r#"{"autoload":{"psr-4":{"App\\":"src/"}}}"#,
),
(
"src/Models/User.php",
"<?php\nnamespace App\\Models;\nclass User {}\n",
),
("src/Util/helpers.php", "<?php\n"),
("lib/x.php", "<?php\n"),
] {
let path = root.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, text).unwrap();
}
let php = Php::new(root).unwrap();
let go = |file: &str, spec: &str| php.resolve(&root.join(file), spec);
let internal = |p: &str| Target::Internal(PathBuf::from(p));
assert_eq!(
go("src/A.php", "App\\Models\\User"),
internal("src/Models/User.php")
);
assert_eq!(go("src/A.php", "App\\Models"), internal("src/Models"));
assert_eq!(
go("src/A.php", "App\\Models\\User\\Role"),
internal("src/Models/User.php")
);
assert_eq!(
go("src/A.php", "function App\\Util\\helper"),
internal("src/Util")
);
assert_eq!(
go("src/A.php", "include:../lib/x.php"),
internal("lib/x.php")
);
assert_eq!(
go("src/A.php", "Illuminate\\Support\\Str"),
Target::External("Illuminate\\Support\\Str".into())
);
}
}