mir-analyzer 0.88.0

Analysis engine for the mir PHP static analyzer
Documentation
//! Bounded-memo guard for the inference queries.
//!
//! `infer_function` is keyed on resolved FQNs, so every rename mints a
//! brand-new memo key; without an eviction policy a long editing session grows
//! the memo table monotonically. It carries `lru = 4096` — these tests prove
//! salsa actually drops the evicted values (observed through `Weak` handles on
//! the memoized `Arc` results) once a write opens a new revision.
//! `infer_scope` is a fixpoint query and must stay LRU-free.

use std::sync::{Arc, Weak};

use crate::db::{
    analyze_file, file_scopes, infer_function, infer_scope, AnalyzeOutput, FunctionInferenceResult,
    MirDatabase,
};
use crate::{AnalysisSession, PhpVersion};

const LRU_CAP: usize = 4096;
/// Enough distinct keys past the cap that eviction is unambiguous.
const STORM: usize = LRU_CAP + 300;

fn storm_source(n: usize) -> String {
    let mut s = String::from("<?php\n");
    for i in 0..n {
        s.push_str(&format!(
            "function f{i}(int $x): int {{ return $x + 1; }}\n"
        ));
    }
    s
}

fn storm_session() -> (AnalysisSession, Arc<str>) {
    let mut session = AnalysisSession::new(PhpVersion::LATEST);
    let path: Arc<str> = Arc::from("/memo_bounds/storm.php");
    session.ingest_file(path.clone(), Arc::from(storm_source(STORM)));
    (session, path)
}

fn alive_count<T>(weaks: &[Weak<T>]) -> usize {
    weaks.iter().filter(|w| w.upgrade().is_some()).count()
}

#[test]
fn infer_function_memos_bounded_under_rename_storm() {
    let (mut session, path) = storm_session();

    let weaks: Vec<Weak<FunctionInferenceResult>> = {
        let db = session.snapshot_db();
        let file = db.lookup_source_file(path.as_ref()).unwrap();
        (0..STORM)
            .map(|i| {
                let result = infer_function(&db, file, Arc::from(format!("f{i}")))
                    .clone()
                    .unwrap_or_else(|| panic!("f{i} not found"));
                Arc::downgrade(&result)
            })
            .collect()
    };
    assert_eq!(alive_count(&weaks), STORM, "memos live before eviction");

    // LRU eviction runs when the next revision opens; any write triggers it.
    session.set_file_text(Arc::from("/memo_bounds/other.php"), Arc::from("<?php\n"));

    let alive = alive_count(&weaks);
    assert!(
        alive <= LRU_CAP,
        "infer_function memo table not bounded: {alive} values alive after \
         {STORM} distinct keys (lru = {LRU_CAP})"
    );
    assert!(alive > 0, "eviction dropped everything — lru misconfigured");
}

#[test]
fn analyze_file_memos_bounded_under_file_storm() {
    // `analyze_file` is keyed per file; hosts that background-warm the whole
    // workspace hold one memo per indexed file, so the table must be
    // lru-bounded. Overflow the cap with tiny files and prove eviction via
    // Weak handles, same as the per-scope tests above.
    const FILE_CAP: usize = 16384;
    const FILE_STORM: usize = FILE_CAP + 300;

    let mut session = AnalysisSession::new(PhpVersion::LATEST);
    let paths: Vec<Arc<str>> = (0..FILE_STORM)
        .map(|i| Arc::<str>::from(format!("/memo_bounds/files/f{i}.php")))
        .collect();
    for (i, path) in paths.iter().enumerate() {
        session.set_file_text(
            path.clone(),
            Arc::from(format!(
                "<?php\nfunction ff{i}(int $x): int {{ return $x + {i}; }}\n"
            )),
        );
    }

    let weaks: Vec<Weak<AnalyzeOutput>> = {
        let db = session.snapshot_db();
        paths
            .iter()
            .map(|path| {
                let file = db.lookup_source_file(path.as_ref()).unwrap();
                Arc::downgrade(analyze_file(&db, file))
            })
            .collect()
    };
    assert_eq!(
        alive_count(&weaks),
        FILE_STORM,
        "memos live before eviction"
    );

    session.set_file_text(Arc::from("/memo_bounds/other.php"), Arc::from("<?php\n"));

    let alive = alive_count(&weaks);
    assert!(
        alive <= FILE_CAP,
        "analyze_file memo table not bounded: {alive} values alive after \
         {FILE_STORM} distinct files (lru = {FILE_CAP})"
    );
    assert!(alive > 0, "eviction dropped everything — lru misconfigured");
}

/// An LRU on `infer_scope` (a fixpoint query) made salsa throw
/// `PropagatedPanic` when a file's memo was revalidated after other files
/// pushed its scopes out of the cache, failing every pass until the next write.
#[test]
fn analyze_file_revalidates_after_its_scopes_are_evicted() {
    let mut session = AnalysisSession::new(PhpVersion::LATEST);
    let mut small = String::from("<?php\n");
    for i in 0..20 {
        small.push_str(&format!(
            "function s{i}($x) {{ return s{}($x); }}\n",
            (i + 1) % 20
        ));
    }
    let small_path: Arc<str> = Arc::from("/memo_bounds/small.php");
    let big_path: Arc<str> = Arc::from("/memo_bounds/big.php");
    session.ingest_file(small_path.clone(), Arc::from(small));
    session.ingest_file(big_path.clone(), Arc::from(storm_source(STORM)));

    let analyze = |session: &AnalysisSession, path: &Arc<str>| {
        session.snapshot().read(|db| {
            let file = db.lookup_source_file(path.as_ref()).unwrap();
            let scopes = file_scopes(db, file);
            for key in scopes.iter() {
                infer_scope(db, file, key.clone());
            }
            analyze_file(db, file).clone()
        })
    };
    analyze(&session, &small_path).expect("small pass");
    analyze(&session, &big_path).expect("big pass");
    session.set_file_text(Arc::from("/memo_bounds/other.php"), Arc::from("<?php\n"));
    analyze(&session, &small_path).expect("small pass after eviction");
}