mir-analyzer 0.26.0

Analysis engine for the mir PHP static analyzer
Documentation
//! Integration tests for the persistent Pass-1 [`StubSliceCache`].
//!
//! These verify the cache is not only fast (the benches cover that) but
//! also *correct*: a warm cache produces the same observable analyzer
//! state as a cold cache.
//!
//! [`StubSliceCache`]: mir_analyzer::stub_cache::StubSliceCache

mod common;

use std::path::PathBuf;

use mir_analyzer::{AnalysisSession, PhpVersion, ProjectAnalyzer, Symbol};

use self::common::{create_temp_dir, write_file};

/// Two-file project with cross-file references — exercises class lookup,
/// method resolution, and reference recording.
fn write_fixture(src_dir: &tempfile::TempDir) -> (PathBuf, PathBuf) {
    let a = write_file(
        src_dir,
        "A.php",
        "<?php\n\
         namespace App;\n\
         class A {\n\
             public function greet(string $name): string { return \"hi $name\"; }\n\
         }\n",
    );
    let b = write_file(
        src_dir,
        "B.php",
        "<?php\n\
         namespace App;\n\
         class B {\n\
             public function run(A $a): string { return $a->greet('mir'); }\n\
         }\n",
    );
    (a, b)
}

#[test]
fn project_analyzer_cold_and_warm_produce_identical_symbol_table() {
    let src_dir = create_temp_dir("stub_cache_correctness: src");
    let cache_dir = create_temp_dir("stub_cache_correctness: cache");
    let (a, b) = write_fixture(&src_dir);
    let paths = [a.clone(), b.clone()];

    // --- Cold: populate the cache. -------------------------------------
    let cold = ProjectAnalyzer::with_cache(cache_dir.path());
    let cold_result = cold.analyze(&paths);
    let cold_issues = cold_result.issues.len();
    assert!(
        cold.contains_class("App\\A"),
        "App\\A should be registered after cold run"
    );
    assert!(
        cold.contains_method("App\\A", "greet"),
        "App\\A::greet should be visible after cold run"
    );

    // collect_types_only fires for vendor — for analyze(), the stub cache
    // is consulted inside SharedDb::collect_and_ingest_file (used by
    // re_analyze_file). The cold run writes through the project Pass 1
    // path that bypasses the cache, so hits are typically 0 here. That's
    // expected: the next test asserts the warm-cache path.
    let (cold_hits, _cold_misses) = cold.stub_cache_stats();
    drop(cold);

    // --- Warm: re-run via re_analyze_file to exercise the SharedDb cache.
    // The cache was populated during cold's lazy-loading of dependents,
    // so this run should observe hits. Even if re_analyze_file produces
    // no hits the assertion below catches regressions: cold and warm
    // must agree on the observable analyzer state.
    let warm = ProjectAnalyzer::with_cache(cache_dir.path());
    let warm_result = warm.analyze(&paths);
    assert_eq!(
        warm_result.issues.len(),
        cold_issues,
        "warm run must produce the same issues as cold"
    );
    assert!(warm.contains_class("App\\A"));
    assert!(warm.contains_method("App\\A", "greet"));
    let (_warm_hits, _warm_misses) = warm.stub_cache_stats();
    // No strict assertion on warm hits here — the project Pass 1 path in
    // analyze() does not consult the cache. The session test below
    // exercises the path that does.
    let _ = cold_hits;
}

#[test]
fn analysis_session_warm_cache_observes_hits_and_preserves_symbols() {
    // The LSP path (`AnalysisSession::ingest_file` -> SharedDb::collect_and_ingest_file)
    // is where the persistent Pass-1 cache actually fires. We populate the
    // cache in a first session, drop it, then open a second session over
    // the same dir and verify (a) cache hits happen and (b) symbols are
    // observable just like they were after the cold run.
    let src_dir = create_temp_dir("stub_cache_correctness: lsp src");
    let cache_dir = create_temp_dir("stub_cache_correctness: lsp cache");
    let (a, b) = write_fixture(&src_dir);

    let a_path: std::sync::Arc<str> = std::sync::Arc::from(a.to_string_lossy().as_ref());
    let b_path: std::sync::Arc<str> = std::sync::Arc::from(b.to_string_lossy().as_ref());
    let a_src: std::sync::Arc<str> =
        std::sync::Arc::from(std::fs::read_to_string(&a).unwrap().as_str());
    let b_src: std::sync::Arc<str> =
        std::sync::Arc::from(std::fs::read_to_string(&b).unwrap().as_str());

    // --- Cold session: ingest both files, populating the cache. --------
    {
        let session = AnalysisSession::new(PhpVersion::LATEST).with_cache_dir(cache_dir.path());
        session.ensure_essential_stubs_loaded();
        session.ingest_file(a_path.clone(), a_src.clone());
        session.ingest_file(b_path.clone(), b_src.clone());

        // Sanity-check that the cold session sees the symbols.
        let def = session
            .definition_of(&Symbol::class("App\\A"))
            .expect("App\\A defined in cold session");
        assert_eq!(def.file.as_ref(), a_path.as_ref());
    }

    // --- Warm session: same content -> every cache lookup must hit. ---
    let session2 = AnalysisSession::new(PhpVersion::LATEST).with_cache_dir(cache_dir.path());
    session2.ensure_essential_stubs_loaded();
    session2.ingest_file(a_path.clone(), a_src.clone());
    session2.ingest_file(b_path.clone(), b_src.clone());

    // We can't reach into AnalysisSession's SharedDb directly, but the
    // cache hits are the only thing that explains identical symbol state
    // arriving in this session without a re-parse — the warm sweep would
    // otherwise be observably indistinguishable. So verify symbol parity
    // instead: a hit returns the slice that the original collector
    // produced, and ingestion must place the same symbols.
    let def = session2
        .definition_of(&Symbol::class("App\\A"))
        .expect("App\\A must still be defined in warm session");
    assert_eq!(def.file.as_ref(), a_path.as_ref());
    let def_b = session2
        .definition_of(&Symbol::class("App\\B"))
        .expect("App\\B must be defined in warm session");
    assert_eq!(def_b.file.as_ref(), b_path.as_ref());
}

#[test]
fn cache_miss_after_content_change() {
    // A second session sees a *different* file content for the same path:
    // the cache must miss and the new symbols must be registered, not
    // the stale ones from the previous version.
    let src_dir = create_temp_dir("stub_cache_correctness: invalidation");
    let cache_dir = create_temp_dir("stub_cache_correctness: cache");
    let a_path = write_file(
        &src_dir,
        "A.php",
        "<?php\nnamespace App; class A { public function v1(): void {} }\n",
    );
    let a_arc: std::sync::Arc<str> = std::sync::Arc::from(a_path.to_string_lossy().as_ref());
    let v1: std::sync::Arc<str> =
        std::sync::Arc::from(std::fs::read_to_string(&a_path).unwrap().as_str());

    {
        let session = AnalysisSession::new(PhpVersion::LATEST).with_cache_dir(cache_dir.path());
        session.ensure_essential_stubs_loaded();
        session.ingest_file(a_arc.clone(), v1);
        // v1: v1() exists, v2() doesn't.
        assert!(session
            .definition_of(&Symbol::method("App\\A", "v1"))
            .is_ok());
    }

    // Edit the file to rename v1 -> v2 and re-ingest in a fresh session.
    write_file(
        &src_dir,
        "A.php",
        "<?php\nnamespace App; class A { public function v2(): void {} }\n",
    );
    let v2: std::sync::Arc<str> =
        std::sync::Arc::from(std::fs::read_to_string(&a_path).unwrap().as_str());

    let session2 = AnalysisSession::new(PhpVersion::LATEST).with_cache_dir(cache_dir.path());
    session2.ensure_essential_stubs_loaded();
    session2.ingest_file(a_arc.clone(), v2);

    // The new content must produce the new symbol; the stale cache entry
    // must not have been served.
    assert!(
        session2
            .definition_of(&Symbol::method("App\\A", "v2"))
            .is_ok(),
        "renamed method v2 must appear after content change"
    );
    // v1 should no longer be defined.
    assert!(
        session2
            .definition_of(&Symbol::method("App\\A", "v1"))
            .is_err(),
        "old method v1 must not survive a content change"
    );
}