use std::fmt::Write as FmtWrite;
use std::fs;
use std::path::{Path, PathBuf};
use rayon::prelude::*;
struct FixtureCategory {
outer_mod: Option<String>,
mod_name: String,
fixtures: Vec<(PathBuf, String)>,
}
fn sorted_subdirs(dir: &Path) -> Vec<fs::DirEntry> {
let mut entries: Vec<_> = fs::read_dir(dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.collect();
entries.sort_by_key(|e| e.file_name());
entries
}
fn collect_categories(fixtures_dir: &Path) -> Vec<FixtureCategory> {
let mut out = Vec::new();
for top in sorted_subdirs(fixtures_dir) {
let top_name = top.file_name().to_string_lossy().into_owned();
println!("cargo:rerun-if-changed={}", top.path().display());
if top_name.starts_with("by-") {
let outer_mod = top_name.replace('-', "_");
for inner in sorted_subdirs(&top.path()) {
println!("cargo:rerun-if-changed={}", inner.path().display());
let inner_name = inner.file_name().to_string_lossy().into_owned();
let rel_prefix = format!("{top_name}/{inner_name}");
if let Some(cat) = scan_dir(
&inner.path(),
Some(outer_mod.clone()),
&inner_name,
&rel_prefix,
) {
out.push(cat);
}
}
} else if let Some(cat) = scan_dir(&top.path(), None, &top_name, &top_name) {
out.push(cat);
}
}
out
}
fn scan_dir(
dir: &Path,
outer_mod: Option<String>,
mod_name: &str,
rel_prefix: &str,
) -> Option<FixtureCategory> {
let mut fixtures: Vec<_> = fs::read_dir(dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "phpt"))
.collect();
fixtures.sort_by_key(|e| e.file_name());
if fixtures.is_empty() {
return None;
}
Some(FixtureCategory {
outer_mod,
mod_name: mod_name.replace('-', "_"),
fixtures: fixtures
.into_iter()
.map(|f| {
let path = f.path();
let file_name = path.file_name().unwrap().to_string_lossy().into_owned();
(path, format!("tests/fixtures/{rel_prefix}/{file_name}"))
})
.collect(),
})
}
fn main() {
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap();
let out_dir = std::env::var("OUT_DIR").unwrap();
generate_builtin_fn_names(Path::new(&manifest_dir), Path::new(&out_dir));
generate_stub_files(Path::new(&manifest_dir), Path::new(&out_dir));
let fixtures_dir = Path::new(&manifest_dir).join("tests").join("fixtures");
let out_path = Path::new(&out_dir).join("fixture_tests.rs");
let mut code = String::from("// Auto-generated by build.rs — do not edit manually\n");
if !fixtures_dir.exists() {
fs::write(&out_path, &code).unwrap();
return;
}
println!("cargo:rerun-if-changed={}", fixtures_dir.display());
let categories = collect_categories(&fixtures_dir);
let all_paths: Vec<PathBuf> = categories
.iter()
.flat_map(|c| c.fixtures.iter().map(|(p, _)| p.clone()))
.collect();
let file_contents: std::collections::HashMap<PathBuf, String> = all_paths
.par_iter()
.map(|p| (p.clone(), fs::read_to_string(p).unwrap_or_default()))
.collect();
let mut current_outer: Option<&str> = None;
for cat in &categories {
let next_outer = cat.outer_mod.as_deref();
if next_outer != current_outer {
if current_outer.is_some() {
code.push_str("}\n");
}
if let Some(outer) = next_outer {
code.push_str(&format!("\nmod {outer} {{\n"));
}
current_outer = next_outer;
}
let indent = if current_outer.is_some() { " " } else { "" };
code.push_str(&format!("\n{indent}mod {} {{\n", cat.mod_name));
for (path, rel) in &cat.fixtures {
println!("cargo:rerun-if-changed={manifest_dir}/{rel}");
let content = &file_contents[path];
let stem = path
.file_stem()
.unwrap()
.to_string_lossy()
.replace('-', "_");
let ignore = if content.contains("===ignore===") {
format!("{indent} #[ignore]\n")
} else {
String::new()
};
let doc = extract_description(content)
.map(|d| {
d.lines()
.map(|l| format!("{indent} /// {}\n", l.trim()))
.collect::<String>()
})
.unwrap_or_default();
code.push_str(&format!(
"{doc}{indent} #[test]\n{ignore}{indent} fn {stem}() {{\n\
{indent} mir_analyzer::test_utils::run_fixture(\
concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/{rel}\"));\n\
{indent} }}\n"
));
}
code.push_str(&format!("{indent}}}\n"));
}
if current_outer.is_some() {
code.push_str("}\n");
}
fs::write(&out_path, code).unwrap();
}
fn generate_builtin_fn_names(manifest_dir: &Path, out_dir: &Path) {
let map_path = manifest_dir.join("stubs").join("PhpStormStubsMap.php");
let out_path = out_dir.join("phpstorm_builtin_fns.rs");
if !map_path.exists() {
fs::write(
&out_path,
"pub(crate) static BUILTIN_FN_NAMES: &[&str] = &[];\n\
pub(crate) static STUB_FN_INDEX: &[(&str, &str)] = &[];\n\
pub(crate) static STUB_CLASS_INDEX: &[(&str, &str)] = &[];\n\
pub(crate) static STUB_CONST_INDEX: &[(&str, &str)] = &[];\n",
)
.unwrap();
return;
}
println!("cargo:rerun-if-changed={}", map_path.display());
let content = fs::read_to_string(&map_path).unwrap();
let stubs_dir = manifest_dir.join("stubs");
assert!(
stubs_dir.is_dir(),
"mir-analyzer build.rs: stubs/ directory is missing at {} — \
the stub index would be empty and all built-ins would be reported as undefined. \
If this fired in cargo package, ensure stubs/ lives inside the crate.",
stubs_dir.display()
);
let stub_dirs_lower: std::collections::HashSet<String> = fs::read_dir(&stubs_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.map(|e| e.file_name().to_string_lossy().to_lowercase())
.collect();
enum Section {
None,
Classes,
Functions,
Constants,
}
let mut section = Section::None;
let mut fn_names: Vec<String> = Vec::new();
let mut fn_index: Vec<(String, String)> = Vec::new();
let mut class_index: Vec<(String, String)> = Vec::new();
let mut const_index: Vec<(String, String)> = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
match trimmed {
"const CLASSES = array (" => {
section = Section::Classes;
continue;
}
"const FUNCTIONS = array (" => {
section = Section::Functions;
continue;
}
"const CONSTANTS = array (" => {
section = Section::Constants;
continue;
}
");" => {
section = Section::None;
continue;
}
_ => {}
}
let Some(rest) = trimmed.strip_prefix('\'') else {
continue;
};
let Some((raw_name, rest)) = rest.split_once('\'') else {
continue;
};
let Some(rest) = rest.trim().strip_prefix("=> '") else {
continue;
};
let Some((path, _)) = rest.split_once('\'') else {
continue;
};
let Some((dir, _)) = path.split_once('/') else {
continue;
};
if !stub_dirs_lower.contains(&dir.to_lowercase()) {
continue;
}
let name = raw_name.replace("\\\\", "\\");
let virtual_path = format!("stubs/{path}");
match section {
Section::Functions => {
fn_names.push(name.clone());
fn_index.push((name.to_lowercase(), virtual_path));
}
Section::Classes => {
class_index.push((name.to_lowercase(), virtual_path));
}
Section::Constants => {
const_index.push((name, virtual_path));
}
Section::None => {}
}
}
fn_names.sort();
fn_names.dedup();
fn_index.sort_by(|a, b| a.0.cmp(&b.0));
fn_index.dedup_by(|a, b| a.0 == b.0);
class_index.sort_by(|a, b| a.0.cmp(&b.0));
class_index.dedup_by(|a, b| a.0 == b.0);
const_index.sort_by(|a, b| a.0.cmp(&b.0));
const_index.dedup_by(|a, b| a.0 == b.0);
let mut code = String::from(
"// Auto-generated by build.rs from PhpStormStubsMap.php — do not edit directly.\n\n\
/// Sorted list of PHP built-in function names. Used for fast existence checks.\n\
pub(crate) static BUILTIN_FN_NAMES: &[&str] = &[\n",
);
for name in &fn_names {
writeln!(code, " {name:?},").unwrap();
}
code.push_str("];\n\n");
fn write_index(code: &mut String, doc: &str, name: &str, entries: &[(String, String)]) {
writeln!(code, "/// {doc}").unwrap();
writeln!(code, "pub(crate) static {name}: &[(&str, &str)] = &[").unwrap();
for (key, path) in entries {
writeln!(code, " ({key:?}, {path:?}),").unwrap();
}
code.push_str("];\n\n");
}
write_index(
&mut code,
"Sorted lowercased function name → stub virtual path.",
"STUB_FN_INDEX",
&fn_index,
);
write_index(
&mut code,
"Sorted lowercased class FQCN → stub virtual path.",
"STUB_CLASS_INDEX",
&class_index,
);
write_index(
&mut code,
"Sorted constant name (case-sensitive) → stub virtual path.",
"STUB_CONST_INDEX",
&const_index,
);
fs::write(&out_path, code).unwrap();
}
fn collect_php_files(dir: &Path, stubs_root: &Path, code: &mut String) {
let mut entries: Vec<PathBuf> = match fs::read_dir(dir) {
Ok(rd) => rd.filter_map(|e| e.ok()).map(|e| e.path()).collect(),
Err(_) => return,
};
entries.sort();
for path in entries {
if path.is_dir() {
collect_php_files(&path, stubs_root, code);
} else if path.extension().is_some_and(|e| e == "php") {
let relative = path
.strip_prefix(stubs_root)
.unwrap_or(&path)
.to_string_lossy()
.replace('\\', "/");
let abs = {
let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
let s = canonical.to_string_lossy();
let s = s.strip_prefix(r"\\?\").unwrap_or(&s);
s.replace('\\', "/")
};
writeln!(
code,
" ({}, include_str!({})),",
format_args!("{relative:?}"),
format_args!("{abs:?}"),
)
.unwrap();
}
}
}
fn generate_stub_files(manifest_dir: &Path, out_dir: &Path) {
let stubs_dir = manifest_dir.join("stubs");
let out_path = out_dir.join("stub_files.rs");
assert!(
stubs_dir.is_dir(),
"mir-analyzer build.rs: stubs/ directory is missing at {} — \
the published crate would have no built-in symbols. \
If this fired in cargo package, ensure stubs/ lives inside the crate.",
stubs_dir.display()
);
println!("cargo:rerun-if-changed={}", stubs_dir.display());
let mut code = String::from(
"/// PHP stubs embedded from stubs/ — the single source of built-in definitions.\n\
/// Auto-generated by build.rs — do not edit directly.\n\
pub(crate) static STUB_FILES: &[(&str, &str)] = &[\n",
);
let mut ext_dirs: Vec<PathBuf> = fs::read_dir(&stubs_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.map(|e| e.path())
.collect();
ext_dirs.sort();
for ext_dir in &ext_dirs {
println!("cargo:rerun-if-changed={}", ext_dir.display());
collect_php_files(ext_dir, manifest_dir, &mut code);
}
code.push_str("];\n");
fs::write(&out_path, code).unwrap();
}
fn extract_description(content: &str) -> Option<String> {
const MARKER: &str = "===description===";
let start = content.find(MARKER)? + MARKER.len();
let end = content[start..]
.find("===")
.map(|r| start + r)
.unwrap_or(content.len());
let text = content[start..end].trim();
if text.is_empty() {
None
} else {
Some(text.to_string())
}
}