mir-analyzer 0.4.1

Analysis engine for the mir PHP static analyzer
Documentation
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_phpstorm_stubs(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;
    }

    // Rerun when the top-level fixtures directory changes (category added/removed).
    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().map(|t| t.is_dir()).unwrap_or(false))
        .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('-', "_");

        // Rerun when a fixture is added to or removed from this category.
        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()
                    .map(|ext| ext == "phpt")
                    .unwrap_or(false)
            })
            .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}");

            // Rerun when this specific fixture file changes.
            println!("cargo:rerun-if-changed={manifest_dir}/{rel}");

            code.push_str(&format!(
                "    #[test]\n    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();
}

// ---------------------------------------------------------------------------
// PhpStorm stubs embedding
// ---------------------------------------------------------------------------

/// Core PHP extensions to pull from phpstorm-stubs.
/// Third-party / rarely-installed extensions are intentionally excluded.
const STUB_DIRS: &[&str] = &[
    "Core",
    "standard",
    "SPL",
    "bcmath",
    "ctype",
    "curl",
    "date",
    "dom",
    "fileinfo",
    "filter",
    "gmp",
    "hash",
    "iconv",
    "intl",
    "json",
    "libxml",
    "mbstring",
    "mysqli",
    "openssl",
    "pcntl",
    "pcre",
    "PDO",
    "posix",
    "random",
    "Reflection",
    "session",
    "SimpleXML",
    "sodium",
    "sockets",
    "tokenizer",
    "xml",
    "zip",
    "zlib",
];

fn generate_phpstorm_stubs(manifest_dir: &Path, out_dir: &Path) {
    let stubs_root = manifest_dir.join("phpstorm-stubs");
    let out_path = out_dir.join("phpstorm_stubs.rs");

    if !stubs_root.exists() {
        // Submodule not initialized — emit an empty list so the build succeeds.
        fs::write(
            &out_path,
            "/// phpstorm-stubs submodule not found; run `git submodule update --init`.\n\
             pub(crate) static PHPSTORM_STUB_FILES: &[(&str, &str)] = &[];\n",
        )
        .unwrap();
        return;
    }

    // Watch the submodule pointer; individual file changes are tracked by
    // rustc via the include_str!() calls in the generated file.
    println!("cargo:rerun-if-changed=phpstorm-stubs");

    let mut code = String::from(
        "/// PHP standard-library stubs embedded from phpstorm-stubs.\n\
         /// Auto-generated by build.rs — do not edit directly.\n\
         pub(crate) static PHPSTORM_STUB_FILES: &[(&str, &str)] = &[\n",
    );

    for dir_name in STUB_DIRS {
        let dir = stubs_root.join(dir_name);
        if dir.is_dir() {
            // Per-directory watch so adding a new .php file triggers a rebuild.
            println!("cargo:rerun-if-changed={}", dir.display());
            collect_php_files(&dir, &stubs_root, &mut code);
        }
    }

    code.push_str("];\n");
    fs::write(&out_path, code).unwrap();
}

/// Recursively collect `.php` files under `dir`, appending `include_str!` entries to `code`.
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('\\', "/");

            // Canonicalize gives us a stable absolute path for include_str!.
            let abs = path
                .canonicalize()
                .unwrap_or_else(|_| path.clone())
                .to_string_lossy()
                .replace('\\', "/");

            writeln!(
                code,
                "    ({}, include_str!({})),",
                format_args!("{relative:?}"),
                format_args!("{abs:?}"),
            )
            .unwrap();
        }
    }
}