use std::fmt::Write as FmtWrite;
use std::fs;
use std::path::{Path, PathBuf};
use rayon::prelude::*;
struct FixtureCategory {
mod_name: String,
fixtures: Vec<(PathBuf, String, String)>,
}
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());
let mut all_fixture_data: Vec<FixtureCategory> = Vec::new();
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;
}
let fixture_data: Vec<_> = fixtures
.into_iter()
.map(|f| {
let path = f.path();
let file_name = path.file_name().unwrap().to_string_lossy().into_owned();
let rel = format!("tests/fixtures/{cat_dir_name}/{file_name}");
(path, file_name, rel)
})
.collect();
all_fixture_data.push(FixtureCategory {
mod_name: cat_mod_name,
fixtures: fixture_data,
});
}
let all_paths: Vec<_> = all_fixture_data
.iter()
.flat_map(|cat| cat.fixtures.iter().map(|(path, _, _)| path.clone()))
.collect();
let file_contents: std::collections::HashMap<PathBuf, String> = all_paths
.par_iter()
.map(|path| (path.clone(), fs::read_to_string(path).unwrap_or_default()))
.collect();
for category in all_fixture_data {
code.push_str(&format!("\nmod {} {{\n", category.mod_name));
for (path, _file_name, rel) in category.fixtures {
let stem = path
.file_stem()
.unwrap()
.to_string_lossy()
.replace('-', "_");
println!("cargo:rerun-if-changed={manifest_dir}/{rel}");
let content = &file_contents[&path];
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\
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())
}
}