wsb-daemon 0.275.7622

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
//! The editor's code intelligence against a real `omni` (needs WSB_OMNI): what a page sees,
//! from opening a file to its server ending and starting again.

use super::*;
use crate::code::text::{Change, Position, Range};

fn omni_on_path() {
    let omni = std::env::var_os("WSB_OMNI").expect("WSB_OMNI must name an omni binary for this test");
    let dir = Path::new(&omni).parent().expect("a directory").to_owned();
    let path = std::env::var_os("PATH").unwrap_or_default();
    let mut dirs = vec![dir];
    dirs.extend(std::env::split_paths(&path));
    // The `omni` this test runs, first on PATH, as the daemon finds one; its state its own.
    std::env::set_var("PATH", std::env::join_paths(dirs).expect("a PATH"));
}

async fn next(events: &mut broadcast::Receiver<CodeEvent>, what: &str, pred: impl Fn(&CodeEvent) -> bool) -> CodeEvent {
    let wait = async {
        loop {
            match events.recv().await {
                Ok(e) if pred(&e) => return e,
                Ok(_) => {}
                Err(e) => panic!("the events ended waiting for {what}: {e}"),
            }
        }
    };
    tokio::time::timeout(Duration::from_secs(180), wait).await.unwrap_or_else(|_| panic!("no {what}"))
}

fn git(dir: &Path, args: &[&str]) {
    let o = wsb::git_backend::git_command().arg("-C").arg(dir).args(args).output().expect("git runs");
    assert!(o.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&o.stderr));
}

/// The pids of the `omni lsp` processes serving `dir`.
fn server_pids(dir: &Path) -> Vec<u32> {
    let mut pids = Vec::new();
    for entry in std::fs::read_dir("/proc").expect("/proc") {
        let Ok(entry) = entry else { continue };
        let Ok(pid) = entry.file_name().to_string_lossy().parse::<u32>() else { continue };
        let cmd = std::fs::read(entry.path().join("cmdline")).unwrap_or_default();
        let cwd = std::fs::read_link(entry.path().join("cwd")).ok();
        if String::from_utf8_lossy(&cmd).contains("lsp\0--daemon") && cwd.as_deref() == Some(dir) {
            pids.push(pid);
        }
    }
    pids
}

/// The pid of the `omni lsp` serving `dir`.
fn server_pid(dir: &Path) -> u32 {
    match server_pids(dir)[..] {
        [pid] => pid,
        ref pids => panic!("{} omni lsp processes serve {}: {pids:?}", pids.len(), dir.display()),
    }
}

/// Ends the language servers of the worktree `.1` and the omni daemon they started (state
/// `.0`), when the test ends however it ends: the daemon would otherwise outlive the test's
/// state directory by its idle timeout, and a server left running would start another.
struct StopsOmni(std::path::PathBuf, std::path::PathBuf);

impl Drop for StopsOmni {
    fn drop(&mut self) {
        let servers = server_pids(&self.1);
        wsb::ops::process::signal(&servers, "KILL");
        let deadline = std::time::Instant::now() + Duration::from_secs(10);
        while servers.iter().any(|p| wsb::ops::process::is_alive(*p)) && std::time::Instant::now() < deadline {
            std::thread::sleep(Duration::from_millis(20));
        }
        let omni = std::env::var_os("WSB_OMNI").expect("WSB_OMNI");
        let _ = std::process::Command::new(omni).env("OMNI_STATE_DIR", &self.0).args(["daemon", "stop"]).output();
    }
}

const UTIL: &str = "def helper(x):\n    \"\"\"Double it.\"\"\"\n    return x * 2\n";
const APP: &str = "from util import helper\n\nhelper(1)\n";

/// TEST20093: a page's file is served as it is edited, from more than one page, through a
/// server that ends and is started again (needs WSB_OMNI).
#[tokio::test(flavor = "multi_thread")]
#[ignore = "needs WSB_OMNI: the path of an omni binary"]
async fn test20093_a_page_is_answered_about_its_own_text_through_a_server_that_ends() {
    omni_on_path();
    let state = tempfile::tempdir().expect("omni's state");
    std::env::set_var("OMNI_STATE_DIR", state.path());
    let ws = tempfile::tempdir().expect("a worktree");
    let root = wsb::platform::canonicalize(ws.path()).expect("resolves");
    let _stops = StopsOmni(state.path().to_owned(), root.clone());
    git(&root, &["init", "-q"]);
    std::fs::write(root.join("util.py"), UTIL).expect("a file");
    std::fs::write(root.join("app.py"), APP).expect("a file");
    std::fs::write(root.join("notes.txt"), "nothing to analyze\n").expect("a file");

    let intel = CodeIntel::new(&root).expect("code intelligence");
    let mut events = intel.subscribe();
    match intel.open("a", "app.py", 1, APP).await.expect("opened") {
        Opened::Yes { root: r, language, features } => {
            assert_eq!((r.as_str(), language.as_str()), (".", "python"));
            assert!(features.iter().any(|f| f == "definition"), "{features:?}");
        }
        Opened::No { reason } => panic!("not served: {reason}"),
    }
    next(&mut events, "the server ready", |e| matches!(e, CodeEvent::Status { status: RootStatus { state: State::Ready { .. }, .. }, .. })).await;

    // Across files, about the asking page's text.
    let call = json!({ "position": { "line": 2, "character": 1 } });
    let def = intel.ask("a", "app.py", 1, "definition", call.clone()).await.expect("an answer");
    assert_eq!(def[0]["uri"], json!({ "path": "util.py" }), "{def}");

    // An edit's diagnostics are about the edited text, and say so by its digest.
    let broken = format!("{APP}def broken(:\n");
    let edit = Change {
        range: Some(Range { start: Position { line: 3, character: 0 }, end: Position { line: 3, character: 0 } }),
        text: "def broken(:\n".into(),
    };
    intel.change("a", "app.py", 1, 2, &[edit]).await.expect("an edit");
    let want = text::digest(&broken);
    let event = next(&mut events, "diagnostics of the edit", |e| matches!(e, CodeEvent::Diagnostics { digest, .. } if *digest == want)).await;
    let CodeEvent::Diagnostics { diagnostics, .. } = event else { unreachable!() };
    assert!(!diagnostics.is_empty(), "a syntax error has no diagnostic");

    // A second page with the file as it was: its question is about its text.
    intel.open("b", "app.py", 1, APP).await.expect("opened by b");
    intel.ask("b", "app.py", 1, "definition", call.clone()).await.expect("b's answer");
    let original = text::digest(APP);
    next(&mut events, "diagnostics of b's text", |e| matches!(e, CodeEvent::Diagnostics { digest, .. } if *digest == original)).await;
    assert!(
        matches!(intel.ask("a", "app.py", 1, "definition", call.clone()).await, Err(AskError::Stale(_))),
        "a question about text the page no longer has was answered"
    );

    // b's text moves the call a line down, where the file on disk has nothing.
    let blank = Change {
        range: Some(Range { start: Position { line: 1, character: 0 }, end: Position { line: 1, character: 0 } }),
        text: "\n".into(),
    };
    intel.change("b", "app.py", 1, 2, &[blank]).await.expect("b's edit");
    let moved = json!({ "position": { "line": 3, "character": 1 } });
    intel.ask("b", "app.py", 2, "definition", moved.clone()).await.expect("b's answer about its edit");

    // The server ends: the page is told how, nothing answers, nothing starts it again
    // unasked, and it is started again when asked.
    wsb::ops::process::signal(&[server_pid(&root)], "KILL");
    next(&mut events, "the crash", |e| matches!(e, CodeEvent::Status { status: RootStatus { state: State::Crashed { .. }, .. }, .. })).await;
    assert!(matches!(intel.ask("b", "app.py", 2, "definition", moved.clone()).await, Err(AskError::Unavailable(_))));
    let refused = intel.open("c", "util.py", 1, UTIL).await.expect_err("a file opened while the server is down");
    assert!(refused.contains("start it again"), "{refused}");
    intel.restart(".").await.expect("started again");
    next(&mut events, "the server ready again", |e| matches!(e, CodeEvent::Status { status: RootStatus { state: State::Ready { .. }, .. }, .. })).await;
    let def = intel.ask("b", "app.py", 2, "definition", moved).await.expect("an answer after the restart");
    assert_eq!(def[0]["uri"], json!({ "path": "util.py" }), "the restarted server does not hold the page's text: {def}");

    // A file of no language omnicode answers for is not served, and says why.
    match intel.open("a", "notes.txt", 1, "x").await.expect("an answer") {
        Opened::No { reason } => assert!(reason.contains("does not analyze"), "{reason}"),
        other => panic!("served: {other:?}"),
    }
}