scryer-engine 0.2.1

Tree-sitter and stack-graphs AST indexing 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;

/// 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 stack graph, not just the database:
/// a new file and an edited caller resolve without a restart or a full re-index.
#[tokio::test(flavor = "multi_thread")]
async fn watcher_edits_are_visible_to_definition_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 resolution a moment to see the last write.
    let deadline = Instant::now() + Duration::from_secs(5);
    let def = loop {
        if let Some(def) = engine
            .resolve_definition(project.id, &root, Path::new("src/main.rs"), 3, 6)
            .await?
        {
            break def;
        }
        assert!(Instant::now() < deadline, "helper never resolved");
        tokio::time::sleep(Duration::from_millis(50)).await;
    };
    assert_eq!(def.symbol_name, "helper");
    assert_eq!(def.file_path, Path::new("src/util.rs"));

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