scryer-engine 0.3.0

Tree-sitter AST indexing and reference resolution engine for Scryer code intelligence
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};

use scryer_db::{ProjectRegistry, ScryerDb, SourceFile};
use scryer_engine::EngineService;

mod support;
use support::fingerprint::fingerprint;

/// Indexed paths for a project, sorted.
async fn indexed(db: &ScryerDb, project_id: u64) -> Vec<String> {
    let mut guard = db.lock().await;
    let mut paths: Vec<String> = SourceFile::filter_by_project_id(project_id)
        .exec(&mut *guard)
        .await
        .unwrap()
        .into_iter()
        .map(|f| f.path)
        .collect();
    paths.sort();
    paths
}

/// Poll until `project_id`'s indexed paths equal `expected`, failing after a few seconds.
async fn wait_for_index(db: &ScryerDb, project_id: u64, expected: &[&str]) {
    let deadline = Instant::now() + Duration::from_secs(5);
    loop {
        let paths = indexed(db, project_id).await;
        if paths == expected {
            return;
        }
        assert!(
            Instant::now() < deadline,
            "project {project_id}: indexed {paths:?}, expected {expected:?}"
        );
        tokio::time::sleep(Duration::from_millis(50)).await;
    }
}

fn make_repo(dir: &Path, name: &str) -> PathBuf {
    let root = dir.join(name);
    fs::create_dir_all(root.join("src")).unwrap();
    fs::write(root.join(".gitignore"), "target/\n").unwrap();
    fs::write(root.join("src/lib.rs"), "pub fn a() {}\n").unwrap();
    fs::create_dir_all(root.join("target/debug")).unwrap();
    dunce::canonicalize(root).unwrap()
}

#[tokio::test(flavor = "multi_thread")]
async fn one_watcher_tracks_several_projects() -> anyhow::Result<()> {
    let temp = tempfile::tempdir()?;
    let alpha = make_repo(temp.path(), "alpha");
    let beta = make_repo(temp.path(), "beta");

    let db = ScryerDb::new_in_memory().await?;
    let registry = Arc::new(ProjectRegistry::new(db.inner()).await?);
    let engine = EngineService::new(db.clone());
    let a = registry.register_or_update(&alpha, None).await?;
    let b = registry.register_or_update(&beta, None).await?;
    engine.index_project(a.id, &alpha).await?;
    engine.index_project(b.id, &beta).await?;

    let watcher = engine.watcher()?;
    watcher.add_project(a.id, &alpha)?;
    watcher.add_project(b.id, &beta)?;

    // Ignored directories are not watched: root and src only.
    let watched = watcher.projects();
    assert_eq!(watched.len(), 2);
    assert!(watched.iter().all(|w| w.directories == 2), "{watched:?}");

    // Edits in both projects are picked up; ignored output is not.
    fs::write(alpha.join("src/new.rs"), "pub fn n() {}\n")?;
    fs::write(beta.join("src/other.rs"), "pub fn o() {}\n")?;
    fs::write(alpha.join("target/debug/gen.rs"), "pub fn g() {}\n")?;
    wait_for_index(&db, a.id, &["src/lib.rs", "src/new.rs"]).await;
    wait_for_index(&db, b.id, &["src/lib.rs", "src/other.rs"]).await;

    // A file written right after creating its directory is indexed, and the directory watched.
    fs::create_dir_all(alpha.join("src/nested"))?;
    fs::write(alpha.join("src/nested/deep.rs"), "pub fn d() {}\n")?;
    wait_for_index(
        &db,
        a.id,
        &["src/lib.rs", "src/nested/deep.rs", "src/new.rs"],
    )
    .await;

    // Renaming a directory re-indexes the files it moved.
    fs::rename(alpha.join("src/nested"), alpha.join("src/moved"))?;
    wait_for_index(
        &db,
        a.id,
        &["src/lib.rs", "src/moved/deep.rs", "src/new.rs"],
    )
    .await;

    // Deletion, and nothing more once a project is removed from the watcher.
    fs::remove_file(beta.join("src/other.rs"))?;
    wait_for_index(&db, b.id, &["src/lib.rs"]).await;
    watcher.remove_project(b.id);
    fs::write(beta.join("src/late.rs"), "pub fn l() {}\n")?;
    tokio::time::sleep(Duration::from_millis(600)).await;
    assert_eq!(indexed(&db, b.id).await, ["src/lib.rs"]);

    watcher.stop().await;
    Ok(())
}

/// Edits made while the watcher runs must reach the references, not just the symbols: a new
/// file and an edited caller link without a restart or a full re-index.
#[tokio::test(flavor = "multi_thread")]
async fn watcher_edits_are_visible_to_reference_resolution() -> anyhow::Result<()> {
    let temp = tempfile::tempdir()?;
    let root = make_repo(temp.path(), "resolve");
    fs::write(root.join("src/main.rs"), "fn main() {}\n")?;

    let db = ScryerDb::new_in_memory().await?;
    let registry = Arc::new(ProjectRegistry::new(db.inner()).await?);
    let engine = EngineService::new(db.clone());
    let project = registry.register_or_update(&root, None).await?;
    engine.index_project(project.id, &root).await?;

    let watcher = engine.watcher()?;
    watcher.add_project(project.id, &root)?;

    fs::write(root.join("src/util.rs"), "pub fn helper() {}\n")?;
    fs::write(
        root.join("src/main.rs"),
        "mod util;\nfn main() {\n    helper();\n}\n",
    )?;
    wait_for_index(
        &db,
        project.id,
        &["src/lib.rs", "src/main.rs", "src/util.rs"],
    )
    .await;

    // Indexing finishes per file, so allow the links a moment to see the last write.
    let deadline = Instant::now() + Duration::from_secs(5);
    loop {
        let fp = fingerprint(&db, project.id).await?;
        if fp.references.iter().any(|r| {
            r.starts_with("src/main.rs:") && r.ends_with("->src/util.rs::crate::util::helper")
        }) {
            break;
        }
        assert!(
            Instant::now() < deadline,
            "the call to helper never linked: {:?}",
            fp.references
        );
        tokio::time::sleep(Duration::from_millis(50)).await;
    }

    watcher.stop().await;
    Ok(())
}

/// A burst of changes (a branch switch, a pull) is applied as one re-index, and the project
/// ends `Ready` with nothing pending.
#[tokio::test(flavor = "multi_thread")]
async fn a_burst_of_changes_is_re_indexed_as_a_whole() -> anyhow::Result<()> {
    let temp = tempfile::tempdir()?;
    let repo = make_repo(temp.path(), "burst");

    let db = ScryerDb::new_in_memory().await?;
    let registry = Arc::new(ProjectRegistry::new(db.inner()).await?);
    let engine = EngineService::new(db.clone());
    let project = registry.register_or_update(&repo, None).await?;
    engine.index_project(project.id, &repo).await?;
    assert_eq!(
        engine.index_state(project.id),
        scryer_engine::IndexState::Ready
    );

    let watcher = engine.watcher()?;
    watcher.add_project(project.id, &repo)?;

    let mut expected = vec!["src/lib.rs".to_string()];
    for i in 0..48 {
        fs::write(
            repo.join(format!("src/gen_{i:02}.rs")),
            format!("pub fn g{i}() {{}}\n"),
        )?;
        expected.push(format!("src/gen_{i:02}.rs"));
    }
    expected.sort();
    let expected: Vec<&str> = expected.iter().map(String::as_str).collect();
    wait_for_index(&db, project.id, &expected).await;

    // Once the watcher has drained, the state is settled.
    engine.wait_watcher_idle(Duration::from_secs(5)).await;
    assert_eq!(
        engine.index_state(project.id),
        scryer_engine::IndexState::Ready
    );
    watcher.stop().await;
    Ok(())
}