use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use memchr::{memchr, memmem};
#[derive(Debug, Clone, Default)]
pub struct ScanResult {
pub classes: Vec<String>,
pub functions: Vec<String>,
pub constants: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct WorkspaceScanResult {
pub classmap: HashMap<String, PathBuf>,
pub function_index: HashMap<String, PathBuf>,
pub constant_index: HashMap<String, PathBuf>,
}
pub fn scan_file(path: &Path) -> Vec<String> {
let Ok(content) = std::fs::read(path) else {
return Vec::new();
};
if content.is_empty() {
return Vec::new();
}
find_classes(&content)
}
pub fn scan_content(content: &[u8]) -> Vec<String> {
if content.is_empty() {
return Vec::new();
}
find_classes(content)
}
pub fn scan_file_full(path: &Path) -> ScanResult {
let Ok(content) = std::fs::read(path) else {
return ScanResult::default();
};
if content.is_empty() {
return ScanResult::default();
}
find_symbols(&content)
}
fn thread_count() -> usize {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(4)
}
pub fn scan_directories(
dirs: &[PathBuf],
vendor_dir_paths: &[PathBuf],
) -> HashMap<String, PathBuf> {
let mut php_files: Vec<PathBuf> = Vec::new();
let skip_paths = HashSet::new();
for dir in dirs {
if !dir.is_dir() {
continue;
}
collect_php_files(dir, vendor_dir_paths, &skip_paths, &mut php_files);
}
scan_files_parallel_classes(&php_files)
}
pub fn scan_psr4_directories(
psr4: &[(String, PathBuf)],
classmap_dirs: &[PathBuf],
vendor_dir_paths: &[PathBuf],
) -> HashMap<String, PathBuf> {
scan_psr4_directories_with_skip(psr4, classmap_dirs, vendor_dir_paths, &HashSet::new())
}
pub fn scan_psr4_directories_with_skip(
psr4: &[(String, PathBuf)],
classmap_dirs: &[PathBuf],
vendor_dir_paths: &[PathBuf],
skip_paths: &HashSet<PathBuf>,
) -> HashMap<String, PathBuf> {
let mut psr4_files: Vec<(PathBuf, String)> = Vec::new();
for (prefix, base_path) in psr4 {
if !base_path.is_dir() {
continue;
}
collect_psr4_php_files(
base_path,
prefix,
vendor_dir_paths,
skip_paths,
&mut psr4_files,
);
}
let mut plain_files: Vec<PathBuf> = Vec::new();
for dir in classmap_dirs {
if !dir.is_dir() {
continue;
}
collect_php_files(dir, vendor_dir_paths, skip_paths, &mut plain_files);
}
let mut classmap = scan_files_parallel_psr4(&psr4_files);
let plain_classmap = scan_files_parallel_classes(&plain_files);
for (fqcn, path) in plain_classmap {
classmap.entry(fqcn).or_insert(path);
}
classmap
}
pub fn scan_vendor_packages(workspace_root: &Path, vendor_dir: &str) -> HashMap<String, PathBuf> {
scan_vendor_packages_with_skip(workspace_root, vendor_dir, &HashSet::new())
}
pub fn scan_vendor_packages_with_skip(
workspace_root: &Path,
vendor_dir: &str,
skip_paths: &HashSet<PathBuf>,
) -> HashMap<String, PathBuf> {
let vendor_path = workspace_root.join(vendor_dir);
let installed_path = vendor_path.join("composer").join("installed.json");
let Ok(content) = std::fs::read_to_string(&installed_path) else {
return HashMap::new();
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) else {
return HashMap::new();
};
let packages = if let Some(arr) = json.as_array() {
arr.as_slice()
} else if let Some(pkgs) = json.get("packages").and_then(|p| p.as_array()) {
pkgs.as_slice()
} else {
return HashMap::new();
};
let vendor_dir_paths: Vec<PathBuf> = vec![vendor_path.clone()];
let composer_dir = vendor_path.join("composer");
let mut psr4_files: Vec<(PathBuf, String)> = Vec::new();
let mut plain_files: Vec<PathBuf> = Vec::new();
for package in packages {
let pkg_path =
if let Some(install_path) = package.get("install-path").and_then(|p| p.as_str()) {
composer_dir.join(install_path)
} else if let Some(pkg_name) = package.get("name").and_then(|n| n.as_str()) {
vendor_path.join(pkg_name)
} else {
continue;
};
let pkg_path = match pkg_path.canonicalize() {
Ok(p) => p,
Err(_) => {
if !pkg_path.is_dir() {
continue;
}
pkg_path
}
};
if !pkg_path.is_dir() {
continue;
}
let Some(autoload) = package.get("autoload") else {
continue;
};
if let Some(psr4) = autoload.get("psr-4").and_then(|p| p.as_object()) {
for (prefix, paths) in psr4 {
let prefix = normalise_prefix(prefix);
for dir_str in value_to_strings(paths) {
let dir = pkg_path.join(&dir_str);
if dir.is_dir() {
collect_psr4_php_files(
&dir,
&prefix,
&vendor_dir_paths,
skip_paths,
&mut psr4_files,
);
}
}
}
}
if let Some(cm) = autoload.get("classmap").and_then(|c| c.as_array()) {
for entry in cm {
if let Some(dir_str) = entry.as_str() {
let dir = pkg_path.join(dir_str);
if dir.is_dir() {
collect_php_files(&dir, &vendor_dir_paths, skip_paths, &mut plain_files);
} else if dir.is_file()
&& dir.extension().is_some_and(|ext| ext == "php")
&& !skip_paths.contains(&dir)
{
plain_files.push(dir);
}
}
}
}
}
let mut classmap = scan_files_parallel_psr4(&psr4_files);
let plain_classmap = scan_files_parallel_classes(&plain_files);
for (fqcn, path) in plain_classmap {
classmap.entry(fqcn).or_insert(path);
}
classmap
}
pub fn scan_workspace_fallback(
workspace_root: &Path,
vendor_dir_paths: &[PathBuf],
) -> HashMap<String, PathBuf> {
scan_directories(&[workspace_root.to_path_buf()], vendor_dir_paths)
}
fn scan_files_parallel_classes(files: &[PathBuf]) -> HashMap<String, PathBuf> {
if files.is_empty() {
return HashMap::new();
}
if files.len() <= 4 {
let mut classmap = HashMap::new();
for path in files {
if let Ok(content) = std::fs::read(path) {
for fqcn in scan_content(&content) {
classmap.entry(fqcn).or_insert_with(|| path.clone());
}
}
}
return classmap;
}
let n_threads = thread_count().min(files.len());
let chunk_size = files.len().div_ceil(n_threads);
let results: Vec<Vec<(String, PathBuf)>> = std::thread::scope(|s| {
let handles: Vec<_> = files
.chunks(chunk_size)
.map(|chunk| {
s.spawn(move || {
let mut local: Vec<(String, PathBuf)> = Vec::new();
for path in chunk {
if let Ok(content) = std::fs::read(path) {
for fqcn in scan_content(&content) {
local.push((fqcn, path.clone()));
}
}
}
local
})
})
.collect();
handles
.into_iter()
.map(|h| {
h.join().unwrap_or_else(|_| {
tracing::error!("PHPantom: thread panic in scan_files_parallel_classes");
Vec::new()
})
})
.collect()
});
let total: usize = results.iter().map(|v| v.len()).sum();
let mut classmap = HashMap::with_capacity(total);
for batch in results {
for (fqcn, path) in batch {
classmap.entry(fqcn).or_insert(path);
}
}
classmap
}
fn scan_files_parallel_psr4(files: &[(PathBuf, String)]) -> HashMap<String, PathBuf> {
if files.is_empty() {
return HashMap::new();
}
if files.len() <= 4 {
let mut classmap = HashMap::new();
for (path, expected_fqn) in files {
if let Ok(content) = std::fs::read(path) {
for fqcn in scan_content(&content) {
if &fqcn == expected_fqn {
classmap.entry(fqcn).or_insert_with(|| path.clone());
}
}
}
}
return classmap;
}
let n_threads = thread_count().min(files.len());
let chunk_size = files.len().div_ceil(n_threads);
let results: Vec<Vec<(String, PathBuf)>> = std::thread::scope(|s| {
let handles: Vec<_> = files
.chunks(chunk_size)
.map(|chunk| {
s.spawn(move || {
let mut local: Vec<(String, PathBuf)> = Vec::new();
for (path, expected_fqn) in chunk {
if let Ok(content) = std::fs::read(path) {
for fqcn in scan_content(&content) {
if &fqcn == expected_fqn {
local.push((fqcn, path.clone()));
}
}
}
}
local
})
})
.collect();
handles
.into_iter()
.map(|h| {
h.join().unwrap_or_else(|_| {
tracing::error!("PHPantom: thread panic in scan_files_parallel_psr4");
Vec::new()
})
})
.collect()
});
let total: usize = results.iter().map(|v| v.len()).sum();
let mut classmap = HashMap::with_capacity(total);
for batch in results {
for (fqcn, path) in batch {
classmap.entry(fqcn).or_insert(path);
}
}
classmap
}
fn scan_files_parallel_full(files: &[PathBuf]) -> WorkspaceScanResult {
if files.is_empty() {
return WorkspaceScanResult::default();
}
if files.len() <= 4 {
let mut result = WorkspaceScanResult::default();
for path in files {
if let Ok(content) = std::fs::read(path) {
let scan = find_symbols(&content);
for fqcn in scan.classes {
result.classmap.entry(fqcn).or_insert_with(|| path.clone());
}
for fqn in scan.functions {
result
.function_index
.entry(fqn)
.or_insert_with(|| path.clone());
}
for name in scan.constants {
result
.constant_index
.entry(name)
.or_insert_with(|| path.clone());
}
}
}
return result;
}
let n_threads = thread_count().min(files.len());
let chunk_size = files.len().div_ceil(n_threads);
let results: Vec<Vec<(ScanResult, PathBuf)>> = std::thread::scope(|s| {
let handles: Vec<_> = files
.chunks(chunk_size)
.map(|chunk| {
s.spawn(move || {
let mut local: Vec<(ScanResult, PathBuf)> = Vec::new();
for path in chunk {
if let Ok(content) = std::fs::read(path) {
let scan = find_symbols(&content);
if !scan.classes.is_empty()
|| !scan.functions.is_empty()
|| !scan.constants.is_empty()
{
local.push((scan, path.clone()));
}
}
}
local
})
})
.collect();
handles
.into_iter()
.map(|h| {
h.join().unwrap_or_else(|_| {
tracing::error!("PHPantom: thread panic in scan_files_parallel_full");
Vec::new()
})
})
.collect()
});
let mut result = WorkspaceScanResult::default();
for batch in results {
for (scan, path) in batch {
for fqcn in scan.classes {
result.classmap.entry(fqcn).or_insert_with(|| path.clone());
}
for fqn in scan.functions {
result
.function_index
.entry(fqn)
.or_insert_with(|| path.clone());
}
for name in scan.constants {
result
.constant_index
.entry(name)
.or_insert_with(|| path.clone());
}
}
}
result
}
pub fn scan_workspace_fallback_full(
workspace_root: &Path,
skip_dirs: &HashSet<PathBuf>,
) -> WorkspaceScanResult {
use ignore::WalkBuilder;
let skip_dirs_owned = skip_dirs.clone();
let walker = WalkBuilder::new(workspace_root)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.hidden(true)
.parents(true)
.ignore(true)
.filter_entry(move |entry| {
if entry.file_type().is_some_and(|ft| ft.is_dir()) {
let path = entry.path();
if skip_dirs_owned.contains(path) {
return false;
}
}
true
})
.build();
let mut php_files: Vec<PathBuf> = Vec::new();
for entry in walker.flatten() {
let path = entry.path();
if path.is_file() && path.extension().is_some_and(|ext| ext == "php") {
php_files.push(path.to_path_buf());
}
}
scan_files_parallel_full(&php_files)
}
pub fn scan_drupal_directories(web_root: &Path) -> WorkspaceScanResult {
use ignore::WalkBuilder;
let drupal_dirs = [
"core",
"modules/contrib",
"modules/custom",
"themes/contrib",
"themes/custom",
"profiles",
"sites",
];
let mut php_files: Vec<PathBuf> = Vec::new();
for rel in &drupal_dirs {
let dir = web_root.join(rel);
if !dir.exists() {
continue;
}
let walker = WalkBuilder::new(&dir)
.git_ignore(false)
.git_global(false)
.git_exclude(false)
.hidden(true) .parents(false)
.ignore(false)
.filter_entry(|entry| {
if entry.file_type().is_some_and(|ft| ft.is_dir()) {
let name = entry.file_name().to_str().unwrap_or("");
if name == "tests" || name == "Tests" {
return false;
}
}
true
})
.build();
for entry in walker.flatten() {
let path = entry.path();
if path.is_file() && is_drupal_php_file(path) {
php_files.push(path.to_path_buf());
}
}
}
scan_files_parallel_full(&php_files)
}
fn is_drupal_php_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|e| e.to_str()),
Some("php" | "module" | "install" | "theme" | "profile" | "inc" | "engine")
)
}
pub fn find_symbols(content: &[u8]) -> ScanResult {
if !has_any_keyword(content) {
return ScanResult::default();
}
let mut result = ScanResult::default();
let mut namespace = String::new();
let len = content.len();
let mut i = 0;
let mut brace_depth: u32 = 0;
let mut in_braced_namespace = false;
let mut namespace_brace_depth: u32 = 0;
let mut in_line_comment = false;
let mut in_block_comment = false;
let mut in_single_string = false;
let mut in_double_string = false;
let mut in_heredoc = false;
let mut heredoc_id: &[u8] = &[];
while i < len {
if in_line_comment {
if let Some(pos) = memchr(b'\n', &content[i..]) {
i += pos + 1;
} else {
break; }
in_line_comment = false;
continue;
}
if in_block_comment {
if let Some(pos) = memchr(b'*', &content[i..]) {
i += pos;
if i + 1 < len && content[i + 1] == b'/' {
in_block_comment = false;
i += 2;
} else {
i += 1;
}
} else {
break; }
continue;
}
if in_single_string {
match memchr2_single_string(&content[i..]) {
Some((offset, b'\\')) => {
i += offset + 2; }
Some((offset, _)) => {
i += offset + 1;
in_single_string = false;
}
None => break, }
continue;
}
if in_double_string {
match memchr2_double_string(&content[i..]) {
Some((offset, b'\\')) => {
i += offset + 2; }
Some((offset, _)) => {
i += offset + 1;
in_double_string = false;
}
None => break, }
continue;
}
if in_heredoc {
let line_start = i;
while i < len && (content[i] == b' ' || content[i] == b'\t') {
i += 1;
}
if i + heredoc_id.len() <= len && &content[i..i + heredoc_id.len()] == heredoc_id {
let after = i + heredoc_id.len();
if after >= len
|| content[after] == b';'
|| content[after] == b'\n'
|| content[after] == b'\r'
|| content[after] == b','
|| content[after] == b')'
{
in_heredoc = false;
i = after;
continue;
}
}
i = line_start;
if let Some(pos) = memchr(b'\n', &content[i..]) {
i += pos + 1;
} else {
break; }
continue;
}
let b = content[i];
if b == b'{' {
brace_depth += 1;
i += 1;
continue;
}
if b == b'}' {
brace_depth = brace_depth.saturating_sub(1);
if in_braced_namespace && brace_depth == namespace_brace_depth {
in_braced_namespace = false;
namespace.clear();
}
i += 1;
continue;
}
if b == b'/' && i + 1 < len {
if content[i + 1] == b'/' {
in_line_comment = true;
i += 2;
continue;
}
if content[i + 1] == b'*' {
in_block_comment = true;
i += 2;
continue;
}
}
if b == b'#' {
if i + 1 < len && content[i + 1] == b'[' {
i += 1;
continue;
}
in_line_comment = true;
i += 1;
continue;
}
if b == b'\'' {
in_single_string = true;
i += 1;
continue;
}
if b == b'"' {
in_double_string = true;
i += 1;
continue;
}
if b == b'<' && i + 2 < len && content[i + 1] == b'<' && content[i + 2] == b'<' {
i += 3;
while i < len && content[i] == b' ' {
i += 1;
}
if i < len && (content[i] == b'\'' || content[i] == b'"') {
i += 1;
}
let id_start = i;
while i < len && (content[i].is_ascii_alphanumeric() || content[i] == b'_') {
i += 1;
}
if i > id_start {
heredoc_id = &content[id_start..i];
in_heredoc = true;
if i < len && (content[i] == b'\'' || content[i] == b'"') {
i += 1;
}
while i < len && content[i] != b'\n' {
i += 1;
}
if i < len {
i += 1;
}
}
continue;
}
if is_keyword_boundary(content, i) {
if b == b'n'
&& i + 9 <= len
&& &content[i..i + 9] == b"namespace"
&& (i + 9 >= len
|| content[i + 9].is_ascii_whitespace()
|| content[i + 9] == b';'
|| content[i + 9] == b'{')
{
i += 9;
while i < len && content[i].is_ascii_whitespace() {
i += 1;
}
let ns_start = i;
while i < len {
let c = content[i];
if c.is_ascii_alphanumeric()
|| c == b'_'
|| c == b'\\'
|| c.is_ascii_whitespace()
{
i += 1;
} else {
break;
}
}
namespace = content[ns_start..i]
.iter()
.filter(|&&c| !c.is_ascii_whitespace())
.map(|&c| c as char)
.collect();
if !namespace.is_empty() && !namespace.ends_with('\\') {
namespace.push('\\');
}
while i < len && content[i].is_ascii_whitespace() {
i += 1;
}
if i < len && content[i] == b'{' {
in_braced_namespace = true;
namespace_brace_depth = brace_depth;
brace_depth += 1;
i += 1;
}
continue;
}
if b == b'c'
&& i + 5 <= len
&& &content[i..i + 5] == b"class"
&& (i + 5 >= len || content[i + 5].is_ascii_whitespace())
{
i += 5;
if let Some(name) = read_name(content, &mut i) {
result.classes.push(format!("{namespace}{name}"));
}
continue;
}
if b == b'i'
&& i + 9 <= len
&& &content[i..i + 9] == b"interface"
&& (i + 9 >= len || content[i + 9].is_ascii_whitespace())
{
i += 9;
if let Some(name) = read_name(content, &mut i) {
result.classes.push(format!("{namespace}{name}"));
}
continue;
}
if b == b't'
&& i + 5 <= len
&& &content[i..i + 5] == b"trait"
&& (i + 5 >= len || content[i + 5].is_ascii_whitespace())
{
i += 5;
if let Some(name) = read_name(content, &mut i) {
result.classes.push(format!("{namespace}{name}"));
}
continue;
}
if b == b'e'
&& i + 4 <= len
&& &content[i..i + 4] == b"enum"
&& (i + 4 >= len || content[i + 4].is_ascii_whitespace())
{
i += 4;
if let Some(name) = read_name(content, &mut i) {
result.classes.push(format!("{namespace}{name}"));
}
continue;
}
if b == b'f'
&& i + 8 <= len
&& &content[i..i + 8] == b"function"
&& (i + 8 >= len || content[i + 8].is_ascii_whitespace() || content[i + 8] == b'(')
{
let is_top_level = if in_braced_namespace {
brace_depth == namespace_brace_depth + 1
} else {
brace_depth == 0
};
if is_top_level {
i += 8;
let mut j = i;
while j < len && content[j].is_ascii_whitespace() {
j += 1;
}
if j < len && content[j] == b'(' {
i = j;
} else if let Some(name) = read_name(content, &mut i) {
result.functions.push(format!("{namespace}{name}"));
}
} else {
i += 8;
}
continue;
}
if b == b'd'
&& i + 6 <= len
&& &content[i..i + 6] == b"define"
&& (i + 6 < len && content[i + 6] == b'(')
{
i += 7; while i < len && content[i].is_ascii_whitespace() {
i += 1;
}
if let Some(name) = read_define_name(content, &mut i) {
result.constants.push(name.to_string());
}
continue;
}
if b == b'c'
&& i + 5 <= len
&& &content[i..i + 5] == b"const"
&& (i + 5 >= len || content[i + 5].is_ascii_whitespace())
{
let is_top_level = if in_braced_namespace {
brace_depth == namespace_brace_depth + 1
} else {
brace_depth == 0
};
if is_top_level {
i += 5;
if let Some(name) = read_name(content, &mut i) {
result.constants.push(format!("{namespace}{name}"));
}
} else {
i += 5;
}
continue;
}
}
i += 1;
}
result
}
pub fn find_classes(content: &[u8]) -> Vec<String> {
if !has_class_keyword(content) {
return Vec::new();
}
let mut classes = Vec::with_capacity(4);
let mut namespace = String::new();
let len = content.len();
let mut i = 0;
let mut in_line_comment = false;
let mut in_block_comment = false;
let mut in_single_string = false;
let mut in_double_string = false;
let mut in_heredoc = false;
let mut heredoc_id: &[u8] = &[];
while i < len {
if in_line_comment {
if let Some(pos) = memchr(b'\n', &content[i..]) {
i += pos + 1;
} else {
break;
}
in_line_comment = false;
continue;
}
if in_block_comment {
if let Some(pos) = memchr(b'*', &content[i..]) {
i += pos;
if i + 1 < len && content[i + 1] == b'/' {
in_block_comment = false;
i += 2;
} else {
i += 1;
}
} else {
break;
}
continue;
}
if in_single_string {
match memchr2_single_string(&content[i..]) {
Some((offset, b'\\')) => {
i += offset + 2;
}
Some((offset, _)) => {
i += offset + 1;
in_single_string = false;
}
None => break,
}
continue;
}
if in_double_string {
match memchr2_double_string(&content[i..]) {
Some((offset, b'\\')) => {
i += offset + 2;
}
Some((offset, _)) => {
i += offset + 1;
in_double_string = false;
}
None => break,
}
continue;
}
if in_heredoc {
let line_start = i;
while i < len && (content[i] == b' ' || content[i] == b'\t') {
i += 1;
}
if i + heredoc_id.len() <= len && &content[i..i + heredoc_id.len()] == heredoc_id {
let after = i + heredoc_id.len();
if after >= len
|| content[after] == b';'
|| content[after] == b'\n'
|| content[after] == b'\r'
|| content[after] == b','
|| content[after] == b')'
{
in_heredoc = false;
i = after;
continue;
}
}
i = line_start;
if let Some(pos) = memchr(b'\n', &content[i..]) {
i += pos + 1;
} else {
break;
}
continue;
}
let b = content[i];
if b == b'/' && i + 1 < len {
if content[i + 1] == b'/' {
in_line_comment = true;
i += 2;
continue;
}
if content[i + 1] == b'*' {
in_block_comment = true;
i += 2;
continue;
}
}
if b == b'#' {
if i + 1 < len && content[i + 1] == b'[' {
i += 1;
continue;
}
in_line_comment = true;
i += 1;
continue;
}
if b == b'\'' {
in_single_string = true;
i += 1;
continue;
}
if b == b'"' {
in_double_string = true;
i += 1;
continue;
}
if b == b'<' && i + 2 < len && content[i + 1] == b'<' && content[i + 2] == b'<' {
i += 3;
while i < len && content[i] == b' ' {
i += 1;
}
if i < len && (content[i] == b'\'' || content[i] == b'"') {
i += 1;
}
let id_start = i;
while i < len && (content[i].is_ascii_alphanumeric() || content[i] == b'_') {
i += 1;
}
if i > id_start {
heredoc_id = &content[id_start..i];
in_heredoc = true;
if i < len && (content[i] == b'\'' || content[i] == b'"') {
i += 1;
}
while i < len && content[i] != b'\n' {
i += 1;
}
if i < len {
i += 1;
}
}
continue;
}
if is_keyword_boundary(content, i) {
if b == b'n'
&& i + 9 <= len
&& &content[i..i + 9] == b"namespace"
&& (i + 9 >= len
|| content[i + 9].is_ascii_whitespace()
|| content[i + 9] == b';'
|| content[i + 9] == b'{')
{
i += 9;
while i < len && content[i].is_ascii_whitespace() {
i += 1;
}
let ns_start = i;
while i < len {
let c = content[i];
if c.is_ascii_alphanumeric()
|| c == b'_'
|| c == b'\\'
|| c.is_ascii_whitespace()
{
i += 1;
} else {
break;
}
}
namespace = content[ns_start..i]
.iter()
.filter(|&&c| !c.is_ascii_whitespace())
.map(|&c| c as char)
.collect();
if !namespace.is_empty() && !namespace.ends_with('\\') {
namespace.push('\\');
}
continue;
}
if b == b'c'
&& i + 5 <= len
&& &content[i..i + 5] == b"class"
&& (i + 5 >= len || content[i + 5].is_ascii_whitespace())
{
i += 5;
if let Some(name) = read_name(content, &mut i) {
classes.push(format!("{namespace}{name}"));
}
continue;
}
if b == b'i'
&& i + 9 <= len
&& &content[i..i + 9] == b"interface"
&& (i + 9 >= len || content[i + 9].is_ascii_whitespace())
{
i += 9;
if let Some(name) = read_name(content, &mut i) {
classes.push(format!("{namespace}{name}"));
}
continue;
}
if b == b't'
&& i + 5 <= len
&& &content[i..i + 5] == b"trait"
&& (i + 5 >= len || content[i + 5].is_ascii_whitespace())
{
i += 5;
if let Some(name) = read_name(content, &mut i) {
classes.push(format!("{namespace}{name}"));
}
continue;
}
if b == b'e'
&& i + 4 <= len
&& &content[i..i + 4] == b"enum"
&& (i + 4 >= len || content[i + 4].is_ascii_whitespace())
{
i += 4;
if let Some(name) = read_name(content, &mut i) {
classes.push(format!("{namespace}{name}"));
}
continue;
}
}
i += 1;
}
classes
}
#[inline]
fn has_class_keyword(content: &[u8]) -> bool {
memmem::find(content, b"class").is_some()
|| memmem::find(content, b"interface").is_some()
|| memmem::find(content, b"trait").is_some()
|| memmem::find(content, b"enum").is_some()
}
#[inline]
fn has_any_keyword(content: &[u8]) -> bool {
memmem::find(content, b"class").is_some()
|| memmem::find(content, b"interface").is_some()
|| memmem::find(content, b"trait").is_some()
|| memmem::find(content, b"enum").is_some()
|| memmem::find(content, b"function").is_some()
|| memmem::find(content, b"define").is_some()
|| memmem::find(content, b"const").is_some()
}
#[inline]
fn is_boundary_char(c: u8) -> bool {
!c.is_ascii_alphanumeric() && c != b'_' && c != b':' && c != b'$'
}
#[inline]
fn memchr2_single_string(haystack: &[u8]) -> Option<(usize, u8)> {
memchr::memchr2(b'\'', b'\\', haystack).map(|pos| (pos, haystack[pos]))
}
#[inline]
fn memchr2_double_string(haystack: &[u8]) -> Option<(usize, u8)> {
memchr::memchr2(b'"', b'\\', haystack).map(|pos| (pos, haystack[pos]))
}
#[inline]
fn is_keyword_boundary(content: &[u8], i: usize) -> bool {
if i == 0 {
return true;
}
let prev = content[i - 1];
if !is_boundary_char(prev) {
return false;
}
if prev == b'>' && i >= 2 {
let prev2 = content[i - 2];
if prev2 == b'-' || prev2 == b'?' {
return false;
}
}
true
}
#[inline]
fn read_define_name<'a>(content: &'a [u8], i: &mut usize) -> Option<&'a str> {
let len = content.len();
if *i >= len {
return None;
}
let quote = content[*i];
if quote != b'\'' && quote != b'"' {
return None;
}
*i += 1; let start = *i;
while *i < len && content[*i] != quote {
if content[*i] == b'\\' && *i + 1 < len {
let next = content[*i + 1];
if next == quote || next == b'\\' {
return None;
}
}
*i += 1;
}
if *i >= len {
return None;
}
let name = &content[start..*i];
*i += 1; std::str::from_utf8(name).ok()
}
#[inline]
fn read_name<'a>(content: &'a [u8], i: &mut usize) -> Option<&'a str> {
let len = content.len();
while *i < len && content[*i].is_ascii_whitespace() {
*i += 1;
}
let start = *i;
while *i < len {
let c = content[*i];
if c.is_ascii_alphanumeric() || c == b'_' {
*i += 1;
} else {
break;
}
}
if *i == start {
return None;
}
let name = &content[start..*i];
if name == b"extends" || name == b"implements" {
return None;
}
std::str::from_utf8(name).ok()
}
fn normalise_prefix(prefix: &str) -> String {
if prefix.is_empty() {
String::new()
} else if prefix.ends_with('\\') {
prefix.to_string()
} else {
format!("{prefix}\\")
}
}
fn value_to_strings(value: &serde_json::Value) -> Vec<String> {
match value {
serde_json::Value::String(s) => vec![s.clone()],
serde_json::Value::Array(arr) => arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect(),
_ => Vec::new(),
}
}
fn collect_php_files(
dir: &Path,
vendor_dir_paths: &[PathBuf],
skip_paths: &HashSet<PathBuf>,
out: &mut Vec<PathBuf>,
) {
use ignore::WalkBuilder;
let vendor_paths: Vec<PathBuf> = vendor_dir_paths.to_vec();
let walker = WalkBuilder::new(dir)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.hidden(true)
.parents(true)
.ignore(true)
.filter_entry(move |entry| {
if entry.file_type().is_some_and(|ft| ft.is_dir()) {
let path = entry.path();
if vendor_paths.iter().any(|vp| vp == path) {
return false;
}
}
true
})
.build();
for entry in walker.flatten() {
let path = entry.path();
if path.is_file() && path.extension().is_some_and(|ext| ext == "php") {
let owned = path.to_path_buf();
if !skip_paths.contains(&owned) {
out.push(owned);
}
}
}
}
fn collect_psr4_php_files(
base_path: &Path,
namespace_prefix: &str,
vendor_dir_paths: &[PathBuf],
skip_paths: &HashSet<PathBuf>,
out: &mut Vec<(PathBuf, String)>,
) {
use ignore::WalkBuilder;
let vendor_paths: Vec<PathBuf> = vendor_dir_paths.to_vec();
let walker = WalkBuilder::new(base_path)
.git_ignore(true)
.git_global(true)
.git_exclude(true)
.hidden(true)
.parents(true)
.ignore(true)
.filter_entry(move |entry| {
if entry.file_type().is_some_and(|ft| ft.is_dir()) {
let path = entry.path();
if vendor_paths.iter().any(|vp| vp == path) {
return false;
}
}
true
})
.build();
for entry in walker.flatten() {
let path = entry.path();
if path.is_file() && path.extension().is_some_and(|ext| ext == "php") {
let owned = path.to_path_buf();
if skip_paths.contains(&owned) {
continue;
}
let relative = match path.strip_prefix(base_path) {
Ok(rel) => rel,
Err(_) => continue,
};
let relative_str = relative.to_string_lossy();
let stem = match relative_str.strip_suffix(".php") {
Some(s) => s,
None => continue,
};
let expected_fqn = format!("{}{}", namespace_prefix, stem.replace('/', "\\"));
out.push((owned, expected_fqn));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn simple_class() {
let content = b"<?php\nclass Foo {}";
assert_eq!(find_classes(content), vec!["Foo"]);
}
#[test]
fn namespaced_class() {
let content = b"<?php\nnamespace App\\Models;\nclass User {}";
assert_eq!(find_classes(content), vec!["App\\Models\\User"]);
}
#[test]
fn multiple_declarations() {
let content = br"<?php
namespace App;
class Foo {}
interface Bar {}
trait Baz {}
enum Status {}
";
assert_eq!(
find_classes(content),
vec!["App\\Foo", "App\\Bar", "App\\Baz", "App\\Status"]
);
}
#[test]
fn class_in_comment_ignored() {
let content = br"<?php
// class Fake {}
/* class AlsoFake {} */
class Real {}
";
assert_eq!(find_classes(content), vec!["Real"]);
}
#[test]
fn class_in_string_ignored() {
let content = br#"<?php
$x = "class Fake {}";
$y = 'class AlsoFake {}';
class Real {}
"#;
assert_eq!(find_classes(content), vec!["Real"]);
}
#[test]
fn no_classes() {
let content = b"<?php\necho 'hello';";
assert!(find_classes(content).is_empty());
}
#[test]
fn enum_with_type() {
let content = b"<?php\nenum Status: int { case Active = 1; }";
assert_eq!(find_classes(content), vec!["Status"]);
}
#[test]
fn class_constant_not_treated_as_declaration() {
let content = b"<?php\n$x = SomeClass::class;\nclass Real {}";
assert_eq!(find_classes(content), vec!["Real"]);
}
#[test]
fn php_attribute() {
let content = br"<?php
#[Attribute]
class MyAttribute {}
";
assert_eq!(find_classes(content), vec!["MyAttribute"]);
}
#[test]
fn heredoc() {
let content = br"<?php
$x = <<<EOT
class Fake {}
EOT;
class Real {}
";
assert_eq!(find_classes(content), vec!["Real"]);
}
#[test]
fn nowdoc() {
let content = br"<?php
$x = <<<'EOT'
class Fake {}
EOT;
class Real {}
";
assert_eq!(find_classes(content), vec!["Real"]);
}
#[test]
fn property_access_class_ignored() {
let content = br"<?php
namespace Foo;
if ($node->class instanceof Name) {
}
";
assert!(find_classes(content).is_empty());
}
#[test]
fn nullsafe_property_access_class_ignored() {
let content = br"<?php
namespace Foo;
if ($node?->class instanceof Name) {
}
";
assert!(find_classes(content).is_empty());
}
#[test]
fn real_class_not_affected_by_property_access() {
let content = br"<?php
namespace Foo;
class Real {}
if ($node->class instanceof Name) {
}
";
assert_eq!(find_classes(content), vec!["Foo\\Real"]);
}
#[test]
fn anonymous_class_ignored() {
let content = br"<?php
$x = new class extends Foo {};
class Real {}
";
assert_eq!(find_classes(content), vec!["Real"]);
}
#[test]
fn anonymous_class_implements_ignored() {
let content = br"<?php
$x = new class implements Bar {};
class Real {}
";
assert_eq!(find_classes(content), vec!["Real"]);
}
#[test]
fn hash_comment_not_confused_with_attribute() {
let content = br"<?php
# This is a comment with class keyword
class Real {}
";
assert_eq!(find_classes(content), vec!["Real"]);
}
#[test]
fn multiple_namespaces() {
let content = br"<?php
namespace First;
class A {}
namespace Second;
class B {}
";
assert_eq!(find_classes(content), vec!["First\\A", "Second\\B"]);
}
#[test]
fn global_namespace_after_named() {
let content = br"<?php
namespace Foo;
class A {}
namespace;
class B {}
";
assert_eq!(find_classes(content), vec!["Foo\\A", "B"]);
}
#[test]
fn escaped_string_does_not_leak() {
let content = br#"<?php
$x = "escaped \" class Fake {}";
class Real {}
"#;
assert_eq!(find_classes(content), vec!["Real"]);
}
#[test]
fn escaped_single_quote_string_does_not_leak() {
let content = br"<?php
$x = 'escaped \' class Fake {}';
class Real {}
";
assert_eq!(find_classes(content), vec!["Real"]);
}
#[test]
fn block_comment_with_star() {
let content = br"<?php
/**
* class Fake {}
*/
class Real {}
";
assert_eq!(find_classes(content), vec!["Real"]);
}
#[test]
fn empty_content() {
assert!(find_classes(b"").is_empty());
}
#[test]
fn no_keyword_quick_rejection() {
let content = b"<?php\necho 'hello world';";
assert!(find_classes(content).is_empty());
}
#[test]
fn flexible_heredoc_php73() {
let content = br"<?php
$x = <<<EOT
class Fake {}
EOT;
class Real {}
";
assert_eq!(find_classes(content), vec!["Real"]);
}
#[test]
fn scan_directories_finds_classes() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
std::fs::create_dir_all(&src).unwrap();
std::fs::write(
src.join("User.php"),
"<?php\nnamespace App\\Models;\nclass User {}",
)
.unwrap();
std::fs::write(
src.join("Order.php"),
"<?php\nnamespace App\\Models;\nclass Order {}",
)
.unwrap();
let vendor_dir_paths = vec![dir.path().join("vendor")];
let classmap = scan_directories(&[src], &vendor_dir_paths);
assert_eq!(classmap.len(), 2);
assert!(classmap.contains_key("App\\Models\\User"));
assert!(classmap.contains_key("App\\Models\\Order"));
}
#[test]
fn scan_directories_skips_hidden() {
let dir = tempfile::tempdir().unwrap();
let hidden = dir.path().join(".hidden");
std::fs::create_dir_all(&hidden).unwrap();
std::fs::write(hidden.join("Secret.php"), "<?php\nclass Secret {}").unwrap();
let classmap = scan_directories(&[dir.path().to_path_buf()], &[]);
assert!(!classmap.contains_key("Secret"));
}
#[test]
fn scan_directories_skips_vendor() {
let dir = tempfile::tempdir().unwrap();
let vendor = dir.path().join("vendor");
std::fs::create_dir_all(&vendor).unwrap();
std::fs::write(vendor.join("Lib.php"), "<?php\nclass Lib {}").unwrap();
let vendor_dir_paths = vec![vendor];
let classmap = scan_directories(&[dir.path().to_path_buf()], &vendor_dir_paths);
assert!(!classmap.contains_key("Lib"));
}
#[test]
fn psr4_filtering() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src");
let models = src.join("Models");
std::fs::create_dir_all(&models).unwrap();
std::fs::write(
models.join("User.php"),
"<?php\nnamespace App\\Models;\nclass User {}",
)
.unwrap();
std::fs::write(
models.join("Misplaced.php"),
"<?php\nnamespace App\\Wrong;\nclass Misplaced {}",
)
.unwrap();
let classmap = scan_psr4_directories(&[("App\\".to_string(), src)], &[], &[]);
assert!(classmap.contains_key("App\\Models\\User"));
assert!(!classmap.contains_key("App\\Wrong\\Misplaced"));
}
#[test]
fn scan_vendor_packages_installed_json_v2() {
let dir = tempfile::tempdir().unwrap();
let vendor = dir.path().join("vendor");
let composer_dir = vendor.join("composer");
std::fs::create_dir_all(&composer_dir).unwrap();
let pkg_src = vendor.join("acme").join("logger").join("src");
std::fs::create_dir_all(&pkg_src).unwrap();
std::fs::write(
pkg_src.join("Logger.php"),
"<?php\nnamespace Acme\\Logger;\nclass Logger {}",
)
.unwrap();
let installed = serde_json::json!({
"packages": [
{
"name": "acme/logger",
"install-path": "../acme/logger",
"autoload": {
"psr-4": {
"Acme\\Logger\\": "src/"
}
}
}
]
});
std::fs::write(
composer_dir.join("installed.json"),
serde_json::to_string(&installed).unwrap(),
)
.unwrap();
let classmap = scan_vendor_packages(dir.path(), "vendor");
assert!(
classmap.contains_key("Acme\\Logger\\Logger"),
"classmap keys: {:?}",
classmap.keys().collect::<Vec<_>>()
);
}
#[test]
fn scan_vendor_packages_install_path_non_standard_location() {
let dir = tempfile::tempdir().unwrap();
let vendor = dir.path().join("vendor");
let composer_dir = vendor.join("composer");
std::fs::create_dir_all(&composer_dir).unwrap();
let custom_location = dir.path().join("packages").join("my-lib").join("src");
std::fs::create_dir_all(&custom_location).unwrap();
std::fs::write(
custom_location.join("Widget.php"),
"<?php\nnamespace My\\Lib;\nclass Widget {}",
)
.unwrap();
let installed = serde_json::json!({
"packages": [
{
"name": "my/lib",
"install-path": "../../packages/my-lib",
"autoload": {
"psr-4": {
"My\\Lib\\": "src/"
}
}
}
]
});
std::fs::write(
composer_dir.join("installed.json"),
serde_json::to_string(&installed).unwrap(),
)
.unwrap();
let classmap = scan_vendor_packages(dir.path(), "vendor");
assert!(
classmap.contains_key("My\\Lib\\Widget"),
"install-path should resolve non-standard locations; keys: {:?}",
classmap.keys().collect::<Vec<_>>()
);
}
#[test]
fn scan_vendor_packages_falls_back_to_name_without_install_path() {
let dir = tempfile::tempdir().unwrap();
let vendor = dir.path().join("vendor");
let composer_dir = vendor.join("composer");
std::fs::create_dir_all(&composer_dir).unwrap();
let pkg_src = vendor.join("old").join("pkg").join("src");
std::fs::create_dir_all(&pkg_src).unwrap();
std::fs::write(
pkg_src.join("Legacy.php"),
"<?php\nnamespace Old\\Pkg;\nclass Legacy {}",
)
.unwrap();
let installed = serde_json::json!([
{
"name": "old/pkg",
"autoload": {
"psr-4": {
"Old\\Pkg\\": "src/"
}
}
}
]);
std::fs::write(
composer_dir.join("installed.json"),
serde_json::to_string(&installed).unwrap(),
)
.unwrap();
let classmap = scan_vendor_packages(dir.path(), "vendor");
assert!(
classmap.contains_key("Old\\Pkg\\Legacy"),
"should fall back to vendor/<name> when install-path is absent; keys: {:?}",
classmap.keys().collect::<Vec<_>>()
);
}
#[test]
fn scan_vendor_packages_classmap_entry() {
let dir = tempfile::tempdir().unwrap();
let vendor = dir.path().join("vendor");
let composer_dir = vendor.join("composer");
std::fs::create_dir_all(&composer_dir).unwrap();
let pkg_lib = vendor.join("acme").join("utils").join("lib");
std::fs::create_dir_all(&pkg_lib).unwrap();
std::fs::write(pkg_lib.join("Helper.php"), "<?php\nclass Helper {}").unwrap();
let installed = serde_json::json!({
"packages": [
{
"name": "acme/utils",
"install-path": "../acme/utils",
"autoload": {
"classmap": ["lib/"]
}
}
]
});
std::fs::write(
composer_dir.join("installed.json"),
serde_json::to_string(&installed).unwrap(),
)
.unwrap();
let classmap = scan_vendor_packages(dir.path(), "vendor");
assert!(classmap.contains_key("Helper"));
}
#[test]
fn scan_workspace_fallback_finds_all() {
let dir = tempfile::tempdir().unwrap();
let sub = dir.path().join("lib");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("Foo.php"), "<?php\nclass Foo {}").unwrap();
std::fs::write(dir.path().join("Bar.php"), "<?php\nclass Bar {}").unwrap();
let vendor_dir_paths = vec![dir.path().join("vendor")];
let classmap = scan_workspace_fallback(dir.path(), &vendor_dir_paths);
assert!(classmap.contains_key("Foo"));
assert!(classmap.contains_key("Bar"));
}
#[test]
fn symbols_simple_function() {
let content = b"<?php\nfunction helper(): void {}";
let result = find_symbols(content);
assert_eq!(result.functions, vec!["helper"]);
assert!(result.classes.is_empty());
assert!(result.constants.is_empty());
}
#[test]
fn symbols_namespaced_function() {
let content = b"<?php\nnamespace App\\Helpers;\nfunction helper(): void {}";
let result = find_symbols(content);
assert_eq!(result.functions, vec!["App\\Helpers\\helper"]);
}
#[test]
fn symbols_closure_not_captured() {
let content = b"<?php\n$fn = function () { return 1; };";
let result = find_symbols(content);
assert!(result.functions.is_empty());
}
#[test]
fn symbols_method_not_captured() {
let content = br"<?php
class Foo {
public function bar(): void {}
}
";
let result = find_symbols(content);
assert_eq!(result.classes, vec!["Foo"]);
assert!(
result.functions.is_empty(),
"methods should not appear as functions: {:?}",
result.functions
);
}
#[test]
fn symbols_define_single_quote() {
let content = b"<?php\ndefine('MY_CONST', 42);";
let result = find_symbols(content);
assert_eq!(result.constants, vec!["MY_CONST"]);
}
#[test]
fn symbols_define_double_quote() {
let content = b"<?php\ndefine(\"APP_VERSION\", '1.0');";
let result = find_symbols(content);
assert_eq!(result.constants, vec!["APP_VERSION"]);
}
#[test]
fn symbols_top_level_const() {
let content = b"<?php\nconst FOO = 'bar';";
let result = find_symbols(content);
assert_eq!(result.constants, vec!["FOO"]);
}
#[test]
fn symbols_namespaced_const() {
let content = b"<?php\nnamespace App;\nconst VERSION = '1.0';";
let result = find_symbols(content);
assert_eq!(result.constants, vec!["App\\VERSION"]);
}
#[test]
fn symbols_class_const_not_captured() {
let content = br"<?php
class Config {
const MAX = 100;
public function foo(): void {}
}
";
let result = find_symbols(content);
assert_eq!(result.classes, vec!["Config"]);
assert!(
result.constants.is_empty(),
"class constants should not be captured: {:?}",
result.constants
);
assert!(
result.functions.is_empty(),
"methods should not be captured: {:?}",
result.functions
);
}
#[test]
fn symbols_mixed_file() {
let content = br#"<?php
namespace App\Utils;
class Helper {}
interface Renderable {}
function formatDate(): string { return ''; }
function parseJson(): array { return []; }
define('APP_NAME', 'MyApp');
const DEBUG = true;
"#;
let result = find_symbols(content);
assert_eq!(
result.classes,
vec!["App\\Utils\\Helper", "App\\Utils\\Renderable"]
);
assert_eq!(
result.functions,
vec!["App\\Utils\\formatDate", "App\\Utils\\parseJson"]
);
assert!(
result.constants.contains(&"APP_NAME".to_string()),
"should find define(): {:?}",
result.constants
);
assert!(
result.constants.contains(&"App\\Utils\\DEBUG".to_string()),
"should find namespaced const: {:?}",
result.constants
);
}
#[test]
fn symbols_function_in_comment_ignored() {
let content = b"<?php\n// function notReal(): void {}\nfunction real(): void {}";
let result = find_symbols(content);
assert_eq!(result.functions, vec!["real"]);
}
#[test]
fn symbols_define_in_string_ignored() {
let content = b"<?php\n$s = \"define('NOT_REAL', 1);\";";
let result = find_symbols(content);
assert!(result.constants.is_empty());
}
#[test]
fn symbols_braced_namespace() {
let content = br"<?php
namespace Foo {
class A {}
function helper(): void {}
const BAR = 1;
}
namespace Baz {
class B {}
function other(): void {}
}
";
let result = find_symbols(content);
assert_eq!(result.classes, vec!["Foo\\A", "Baz\\B"]);
assert_eq!(result.functions, vec!["Foo\\helper", "Baz\\other"]);
assert_eq!(result.constants, vec!["Foo\\BAR"]);
}
#[test]
fn symbols_function_with_parenthesized_return() {
let content = b"<?php\n$f = function(int $x): int { return $x; };";
let result = find_symbols(content);
assert!(result.functions.is_empty());
}
#[test]
fn symbols_define_in_block_comment_ignored() {
let content = b"<?php\n/* define('NOPE', 1); */\ndefine('YES', 2);";
let result = find_symbols(content);
assert_eq!(result.constants, vec!["YES"]);
}
#[test]
fn symbols_empty_content() {
let result = find_symbols(b"");
assert!(result.classes.is_empty());
assert!(result.functions.is_empty());
assert!(result.constants.is_empty());
}
#[test]
fn symbols_no_php_symbols() {
let result = find_symbols(b"<?php\n$x = 1 + 2;\necho $x;");
assert!(result.classes.is_empty());
assert!(result.functions.is_empty());
assert!(result.constants.is_empty());
}
#[test]
fn symbols_heredoc_skipped() {
let content = br#"<?php
$s = <<<EOT
function fakeFunc(): void {}
define('FAKE', 1);
class FakeClass {}
EOT;
function realFunc(): void {}
"#;
let result = find_symbols(content);
assert_eq!(result.functions, vec!["realFunc"]);
assert!(result.classes.is_empty());
assert!(result.constants.is_empty());
}
#[test]
fn scan_workspace_fallback_full_finds_all_symbol_types() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("helpers.php"),
"<?php\nfunction myHelper(): void {}\ndefine('MY_CONST', 1);\nconst DEBUG = true;",
)
.unwrap();
std::fs::write(dir.path().join("Model.php"), "<?php\nclass User {}").unwrap();
let skip = std::collections::HashSet::new();
let result = scan_workspace_fallback_full(dir.path(), &skip);
assert!(result.classmap.contains_key("User"));
assert!(
result.function_index.contains_key("myHelper"),
"should find function: {:?}",
result.function_index
);
assert!(
result.constant_index.contains_key("MY_CONST"),
"should find define constant: {:?}",
result.constant_index
);
assert!(
result.constant_index.contains_key("DEBUG"),
"should find top-level const: {:?}",
result.constant_index
);
}
#[test]
fn scan_workspace_fallback_full_skips_vendor() {
let dir = tempfile::tempdir().unwrap();
let vendor = dir.path().join("vendor");
std::fs::create_dir_all(&vendor).unwrap();
std::fs::write(
vendor.join("lib.php"),
"<?php\nfunction vendorFunc(): void {}",
)
.unwrap();
std::fs::write(
dir.path().join("app.php"),
"<?php\nfunction appFunc(): void {}",
)
.unwrap();
let mut skip = std::collections::HashSet::new();
skip.insert(vendor.clone());
let result = scan_workspace_fallback_full(dir.path(), &skip);
assert!(result.function_index.contains_key("appFunc"));
assert!(
!result.function_index.contains_key("vendorFunc"),
"vendor functions should be excluded"
);
}
#[test]
fn scan_workspace_fallback_full_skips_hidden_dirs() {
let dir = tempfile::tempdir().unwrap();
let hidden = dir.path().join(".hidden");
std::fs::create_dir_all(&hidden).unwrap();
std::fs::write(
hidden.join("secret.php"),
"<?php\nfunction secretFunc(): void {}",
)
.unwrap();
std::fs::write(
dir.path().join("public.php"),
"<?php\nfunction publicFunc(): void {}",
)
.unwrap();
let skip = std::collections::HashSet::new();
let result = scan_workspace_fallback_full(dir.path(), &skip);
assert!(result.function_index.contains_key("publicFunc"));
assert!(
!result.function_index.contains_key("secretFunc"),
"hidden dir functions should be excluded"
);
}
#[test]
fn drupal_php_file_accepts_php() {
assert!(is_drupal_php_file(Path::new("module.php")));
}
#[test]
fn drupal_php_file_accepts_module() {
assert!(is_drupal_php_file(Path::new("mymodule.module")));
}
#[test]
fn drupal_php_file_accepts_install() {
assert!(is_drupal_php_file(Path::new("mymodule.install")));
}
#[test]
fn drupal_php_file_accepts_theme() {
assert!(is_drupal_php_file(Path::new("mytheme.theme")));
}
#[test]
fn drupal_php_file_accepts_profile() {
assert!(is_drupal_php_file(Path::new("myprofile.profile")));
}
#[test]
fn drupal_php_file_accepts_inc() {
assert!(is_drupal_php_file(Path::new("helpers.inc")));
}
#[test]
fn drupal_php_file_accepts_engine() {
assert!(is_drupal_php_file(Path::new("phptemplate.engine")));
}
#[test]
fn drupal_php_file_rejects_txt() {
assert!(!is_drupal_php_file(Path::new("README.txt")));
}
#[test]
fn drupal_php_file_rejects_yml() {
assert!(!is_drupal_php_file(Path::new("mymodule.info.yml")));
}
#[test]
fn drupal_php_file_rejects_no_extension() {
assert!(!is_drupal_php_file(Path::new("Makefile")));
}
#[test]
fn scan_drupal_directories_finds_php_and_module_files() {
let dir = tempfile::tempdir().unwrap();
let web_root = dir.path();
let entity_dir = web_root.join("core/lib/Drupal/Core/Entity");
std::fs::create_dir_all(&entity_dir).unwrap();
std::fs::write(
entity_dir.join("EntityInterface.php"),
"<?php\nnamespace Drupal\\Core\\Entity;\ninterface EntityInterface {}",
)
.unwrap();
let token_dir = web_root.join("modules/contrib/token/src");
std::fs::create_dir_all(&token_dir).unwrap();
std::fs::write(
token_dir.join("TokenService.php"),
"<?php\nnamespace Drupal\\token;\nclass TokenService {}",
)
.unwrap();
let custom_dir = web_root.join("modules/custom/mymod");
std::fs::create_dir_all(&custom_dir).unwrap();
std::fs::write(
custom_dir.join("mymod.module"),
"<?php\nfunction mymod_help() {}",
)
.unwrap();
let result = scan_drupal_directories(web_root);
assert!(
result
.classmap
.contains_key("Drupal\\Core\\Entity\\EntityInterface"),
"should index core PHP files; keys: {:?}",
result.classmap.keys().collect::<Vec<_>>()
);
assert!(
result.classmap.contains_key("Drupal\\token\\TokenService"),
"should index contrib module PHP files; keys: {:?}",
result.classmap.keys().collect::<Vec<_>>()
);
assert!(
result.function_index.contains_key("mymod_help"),
"should index .module files; functions: {:?}",
result.function_index.keys().collect::<Vec<_>>()
);
}
#[test]
fn scan_drupal_directories_skips_test_dirs() {
let dir = tempfile::tempdir().unwrap();
let web_root = dir.path();
let test_dir = web_root.join("modules/contrib/token/tests/src");
std::fs::create_dir_all(&test_dir).unwrap();
std::fs::write(
test_dir.join("TokenTest.php"),
"<?php\nnamespace Drupal\\Tests\\token;\nclass TokenTest {}",
)
.unwrap();
let test_dir2 = web_root.join("core/Tests");
std::fs::create_dir_all(&test_dir2).unwrap();
std::fs::write(
test_dir2.join("CoreTest.php"),
"<?php\nnamespace Drupal\\Tests;\nclass CoreTest {}",
)
.unwrap();
let result = scan_drupal_directories(web_root);
assert!(
!result
.classmap
.contains_key("Drupal\\Tests\\token\\TokenTest"),
"should skip tests/ directories"
);
assert!(
!result.classmap.contains_key("Drupal\\Tests\\CoreTest"),
"should skip Tests/ directories"
);
}
#[test]
fn scan_drupal_directories_skips_nonexistent_dirs() {
let dir = tempfile::tempdir().unwrap();
let result = scan_drupal_directories(dir.path());
assert!(result.classmap.is_empty());
assert!(result.function_index.is_empty());
assert!(result.constant_index.is_empty());
}
#[test]
fn scan_drupal_directories_ignores_non_php_files() {
let dir = tempfile::tempdir().unwrap();
let web_root = dir.path();
let core_dir = web_root.join("core");
std::fs::create_dir_all(&core_dir).unwrap();
std::fs::write(core_dir.join("core.services.yml"), "services: {}").unwrap();
std::fs::write(core_dir.join("README.txt"), "Drupal core").unwrap();
std::fs::write(
core_dir.join("install.php"),
"<?php\nfunction install_begin() {}",
)
.unwrap();
let result = scan_drupal_directories(web_root);
assert!(
result.function_index.contains_key("install_begin"),
"should index .php files"
);
assert_eq!(
result.classmap.len() + result.function_index.len() + result.constant_index.len(),
1,
"should not index .yml or .txt files"
);
}
}