use std::fmt::Write as FmtWrite;
use std::fs;
use std::path::{Path, PathBuf};
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 mut categories: Vec<_> = fs::read_dir(&fixtures_dir)
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.collect();
categories.sort_by_key(|e| e.file_name());
for cat_entry in categories {
let cat_dir_name = cat_entry.file_name().to_string_lossy().into_owned();
let cat_mod_name = cat_dir_name.replace('-', "_");
println!("cargo:rerun-if-changed={}", cat_entry.path().display());
let mut fixtures: Vec<_> = fs::read_dir(cat_entry.path())
.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() {
continue;
}
code.push_str(&format!("\nmod {cat_mod_name} {{\n"));
for fixture in fixtures {
let path = fixture.path();
let stem = path
.file_stem()
.unwrap()
.to_string_lossy()
.replace('-', "_");
let file_name = path.file_name().unwrap().to_string_lossy().into_owned();
let rel = format!("tests/fixtures/{cat_dir_name}/{file_name}");
println!("cargo:rerun-if-changed={manifest_dir}/{rel}");
let content = fs::read_to_string(&path).unwrap_or_default();
let ignore_attr = if content.contains("===ignore===") {
" #[ignore]\n"
} else {
""
};
let doc_comment = extract_description(&content)
.map(|d| {
d.lines()
.map(|l| format!(" /// {}\n", l.trim()))
.collect::<String>()
})
.unwrap_or_default();
code.push_str(&format!(
"{doc_comment} #[test]\n{ignore_attr} fn {stem}() {{\n \
mir_analyzer::test_utils::run_fixture(concat!(env!(\"CARGO_MANIFEST_DIR\"), \"/{rel}\"));\n \
}}\n"
));
}
code.push_str("}\n");
}
fs::write(&out_path, code).unwrap();
}
fn generate_builtin_fn_names(manifest_dir: &Path, out_dir: &Path) {
let stubs_root = manifest_dir.join("phpstorm-stubs");
let map_path = stubs_root.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",
)
.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");
let stub_dirs_lower: std::collections::HashSet<String> = if stubs_dir.is_dir() {
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()
} else {
std::collections::HashSet::new()
};
let mut fn_names: Vec<String> = Vec::new();
let mut in_functions = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed == "const FUNCTIONS = array (" {
in_functions = true;
continue;
}
if in_functions {
if trimmed == ");" {
break;
}
if let Some(rest) = trimmed.strip_prefix('\'') {
if let Some((name, rest)) = rest.split_once('\'') {
if let Some(rest) = rest.trim().strip_prefix("=> '") {
if let Some((path, _)) = rest.split_once('\'') {
if let Some((dir, _)) = path.split_once('/') {
if stub_dirs_lower.contains(&dir.to_lowercase()) {
fn_names.push(name.to_owned());
}
}
}
}
}
}
}
}
fn_names.sort();
fn_names.dedup();
let mut code = String::from(
"/// Sorted list of PHP built-in function names from PhpStormStubsMap.php.\n\
/// Auto-generated by build.rs — do not edit directly.\n\
pub(crate) static BUILTIN_FN_NAMES: &[&str] = &[\n",
);
for name in &fn_names {
writeln!(code, " {name:?},").unwrap();
}
code.push_str("];\n");
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())
}
}