mir-analyzer 0.14.0

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_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;
    }

    // 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().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('-', "_");

        // 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().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}");

            // 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();
}

/// Parse `PhpStormStubsMap.php` FUNCTIONS section and generate a sorted static slice of
/// built-in function names for the extensions present in `stubs/`.
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();

    // Build lowercase set of stub directory names from the stubs/ directory for O(1) lookup.
    let workspace_root = find_workspace_root(manifest_dir);
    let stubs_dir = workspace_root.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 {
            // End of the FUNCTIONS array.
            if trimmed == ");" {
                break;
            }
            // Each entry: '\\some\\func_name' => 'ext_dir/file.php',
            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();
}

/// 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!.
            // On Windows, canonicalize() returns \\?\-prefixed UNC paths; strip
            // that prefix so include_str! receives a plain absolute path.
            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();
        }
    }
}

// ---------------------------------------------------------------------------
// Stub embedding — stubs/{ext}/*.php
// ---------------------------------------------------------------------------

/// Walk every `stubs/{ext}/` directory and embed each `.php` file as a
/// `(workspace-relative-path, content)` pair in `STUB_FILES`.
///
/// Paths use the workspace root as the prefix so they look like
/// `"stubs/standard/standard_9.php"` — stable virtual identifiers for go-to-definition.
fn generate_stub_files(manifest_dir: &Path, out_dir: &Path) {
    let workspace_root = find_workspace_root(manifest_dir);
    let stubs_dir = workspace_root.join("stubs");
    let out_path = out_dir.join("stub_files.rs");

    if !stubs_dir.exists() {
        fs::write(
            &out_path,
            "/// No stubs/ directory found.\n\
             pub(crate) static STUB_FILES: &[(&str, &str)] = &[];\n",
        )
        .unwrap();
        return;
    }

    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());
        // Use workspace_root as the prefix so paths are workspace-relative.
        collect_php_files(ext_dir, &workspace_root, &mut code);
    }

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

/// Walk up from `start` until finding a directory that contains a `Cargo.toml`
/// with `[workspace]`.  Falls back to `start` if nothing is found.
fn find_workspace_root(start: &Path) -> PathBuf {
    let mut dir = start.to_path_buf();
    loop {
        let toml = dir.join("Cargo.toml");
        if toml.exists() {
            if let Ok(content) = fs::read_to_string(&toml) {
                if content.contains("[workspace]") {
                    return dir;
                }
            }
        }
        match dir.parent() {
            Some(p) => dir = p.to_path_buf(),
            None => return start.to_path_buf(),
        }
    }
}