aether-lspd 0.1.10

LSP daemon for sharing language servers across multiple agents
Documentation
use crate::uri::uri_to_path;
use lsp_types::{FileEvent, Uri};
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::sync::Arc;
use tokio::fs::read_to_string;
use tokio::sync::RwLock;

#[derive(Clone, Default)]
pub(crate) struct DocumentLifecycle {
    state: Arc<RwLock<DocumentState>>,
}

#[derive(Default)]
struct DocumentState {
    documents: HashMap<Uri, DocumentEntry>,
}

#[derive(Clone, Copy, Debug, Default)]
struct DocumentEntry {
    open_holders: usize,
    content_hash: Option<u64>,
    needs_refresh: bool,
}

pub(crate) enum AcquireAction {
    Open { file_path: String, content: String },
    Reopen { file_path: String, content: String },
    Unchanged,
    MissingOnDisk,
}

pub(crate) enum ReleaseAction {
    Close,
    CloseAndRefresh,
    Unchanged,
}

impl DocumentLifecycle {
    pub(crate) fn new() -> Self {
        Self::default()
    }

    pub(crate) async fn forget_uri(&self, uri: &Uri) {
        self.state.write().await.documents.remove(uri);
    }

    pub(crate) async fn acquire(&self, uri: &Uri) -> AcquireAction {
        let file_path = uri_to_path(uri);
        let Ok(content) = read_to_string(&file_path).await else {
            return AcquireAction::MissingOnDisk;
        };

        let content_hash = hash_content(&content);
        let mut state = self.state.write().await;
        let entry = state.documents.entry(uri.clone()).or_default();
        entry.open_holders += 1;

        if entry.open_holders == 1 {
            entry.content_hash = Some(content_hash);
            return AcquireAction::Open { file_path, content };
        }

        if entry.content_hash == Some(content_hash) {
            AcquireAction::Unchanged
        } else {
            entry.content_hash = Some(content_hash);
            AcquireAction::Reopen { file_path, content }
        }
    }

    pub(crate) async fn release(&self, uri: &Uri) -> ReleaseAction {
        let mut state = self.state.write().await;
        let Some(entry) = state.documents.get_mut(uri) else {
            return ReleaseAction::Unchanged;
        };

        if entry.open_holders == 0 {
            return ReleaseAction::Unchanged;
        }

        entry.open_holders -= 1;
        if entry.open_holders > 0 {
            return ReleaseAction::Unchanged;
        }

        if entry.needs_refresh {
            entry.needs_refresh = false;
            ReleaseAction::CloseAndRefresh
        } else {
            ReleaseAction::Close
        }
    }

    pub(crate) async fn filter_watcher_changes(&self, changes: Vec<FileEvent>) -> Vec<FileEvent> {
        let mut state = self.state.write().await;
        changes
            .into_iter()
            .filter_map(|change| {
                if let Some(entry) = state.documents.get_mut(&change.uri)
                    && entry.open_holders > 0
                {
                    entry.needs_refresh = true;
                    None
                } else {
                    Some(change)
                }
            })
            .collect()
    }
}

fn hash_content(content: &str) -> u64 {
    let mut hasher = DefaultHasher::new();
    content.hash(&mut hasher);
    hasher.finish()
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;
    use tempfile::TempDir;

    fn uri_for(path: &Path) -> Uri {
        crate::path_to_uri(path).unwrap()
    }

    #[tokio::test]
    async fn acquire_returns_open_action() {
        let temp = TempDir::new().unwrap();
        let path = temp.path().join("main.rs");
        std::fs::write(&path, "fn main() {}\n").unwrap();
        let uri = uri_for(&path);
        let lifecycle = DocumentLifecycle::new();

        let action = lifecycle.acquire(&uri).await;
        let AcquireAction::Open { file_path, content } = action else {
            panic!("expected Open");
        };
        let expected_path = path.canonicalize().unwrap();
        assert_eq!(file_path, expected_path.to_string_lossy());
        assert_eq!(content, "fn main() {}\n");
    }

    #[tokio::test]
    async fn acquire_unchanged_content_returns_unchanged() {
        let temp = TempDir::new().unwrap();
        let path = temp.path().join("main.rs");
        std::fs::write(&path, "fn main() {}\n").unwrap();
        let uri = uri_for(&path);
        let lifecycle = DocumentLifecycle::new();

        assert!(matches!(lifecycle.acquire(&uri).await, AcquireAction::Open { .. }));
        assert!(matches!(lifecycle.acquire(&uri).await, AcquireAction::Unchanged));
    }

    #[tokio::test]
    async fn acquire_changed_content_returns_reopen() {
        let temp = TempDir::new().unwrap();
        let path = temp.path().join("main.rs");
        std::fs::write(&path, "fn main() {}\n").unwrap();
        let uri = uri_for(&path);
        let lifecycle = DocumentLifecycle::new();

        assert!(matches!(lifecycle.acquire(&uri).await, AcquireAction::Open { .. }));

        std::fs::write(&path, "fn main() { println!(\"hi\"); }\n").unwrap();
        let action = lifecycle.acquire(&uri).await;
        let AcquireAction::Reopen { file_path, content } = action else {
            panic!("expected Reopen after content change");
        };
        let expected_path = path.canonicalize().unwrap();
        assert_eq!(file_path, expected_path.to_string_lossy());
        assert_eq!(content, "fn main() { println!(\"hi\"); }\n");
    }

    #[tokio::test]
    async fn acquire_missing_file() {
        let uri: Uri = "file:///nonexistent/path.rs".parse().unwrap();
        let lifecycle = DocumentLifecycle::new();
        assert!(matches!(lifecycle.acquire(&uri).await, AcquireAction::MissingOnDisk));
    }

    #[tokio::test]
    async fn release_when_not_open_returns_unchanged() {
        let uri: Uri = "file:///test.rs".parse().unwrap();
        let lifecycle = DocumentLifecycle::new();
        assert!(matches!(lifecycle.release(&uri).await, ReleaseAction::Unchanged));
    }

    #[tokio::test]
    async fn release_after_acquire_closes() {
        let temp = TempDir::new().unwrap();
        let path = temp.path().join("main.rs");
        std::fs::write(&path, "fn main() {}\n").unwrap();
        let uri = uri_for(&path);
        let lifecycle = DocumentLifecycle::new();

        assert!(matches!(lifecycle.acquire(&uri).await, AcquireAction::Open { .. }));
        assert!(matches!(lifecycle.release(&uri).await, ReleaseAction::Close));
        assert!(matches!(lifecycle.release(&uri).await, ReleaseAction::Unchanged));
    }

    #[tokio::test]
    async fn filter_watcher_changes_suppresses_open_docs() {
        let temp = TempDir::new().unwrap();
        let path = temp.path().join("main.rs");
        std::fs::write(&path, "fn main() {}\n").unwrap();
        let uri = uri_for(&path);
        let other_uri: Uri = "file:///other.rs".parse().unwrap();
        let lifecycle = DocumentLifecycle::new();

        assert!(matches!(lifecycle.acquire(&uri).await, AcquireAction::Open { .. }));

        let changes = vec![
            FileEvent { uri: uri.clone(), typ: lsp_types::FileChangeType::CHANGED },
            FileEvent { uri: other_uri.clone(), typ: lsp_types::FileChangeType::CHANGED },
        ];
        let filtered = lifecycle.filter_watcher_changes(changes).await;
        assert_eq!(filtered.len(), 1);
        assert_eq!(filtered[0].uri, other_uri);
    }

    #[tokio::test]
    async fn suppressed_watcher_change_requests_refresh_after_close() {
        let temp = TempDir::new().unwrap();
        let path = temp.path().join("main.rs");
        std::fs::write(&path, "fn main() {}\n").unwrap();
        let uri = uri_for(&path);
        let lifecycle = DocumentLifecycle::new();

        assert!(matches!(lifecycle.acquire(&uri).await, AcquireAction::Open { .. }));

        let filtered = lifecycle
            .filter_watcher_changes(vec![FileEvent { uri: uri.clone(), typ: lsp_types::FileChangeType::CHANGED }])
            .await;
        assert!(filtered.is_empty());
        assert!(matches!(lifecycle.release(&uri).await, ReleaseAction::CloseAndRefresh));
    }

    #[tokio::test]
    async fn suppressed_watcher_change_waits_for_last_release() {
        let temp = TempDir::new().unwrap();
        let path = temp.path().join("main.rs");
        std::fs::write(&path, "fn main() {}\n").unwrap();
        let uri = uri_for(&path);
        let lifecycle = DocumentLifecycle::new();

        assert!(matches!(lifecycle.acquire(&uri).await, AcquireAction::Open { .. }));
        assert!(matches!(lifecycle.acquire(&uri).await, AcquireAction::Unchanged));

        let filtered = lifecycle
            .filter_watcher_changes(vec![FileEvent { uri: uri.clone(), typ: lsp_types::FileChangeType::CHANGED }])
            .await;
        assert!(filtered.is_empty());
        assert!(matches!(lifecycle.release(&uri).await, ReleaseAction::Unchanged));
        assert!(matches!(lifecycle.release(&uri).await, ReleaseAction::CloseAndRefresh));
    }

    #[tokio::test]
    async fn concurrent_acquire_single_release_keeps_open() {
        let temp = TempDir::new().unwrap();
        let path = temp.path().join("main.rs");
        std::fs::write(&path, "fn main() {}\n").unwrap();
        let uri = uri_for(&path);
        let lifecycle = DocumentLifecycle::new();

        assert!(matches!(lifecycle.acquire(&uri).await, AcquireAction::Open { .. }));
        assert!(matches!(lifecycle.acquire(&uri).await, AcquireAction::Unchanged));
        assert!(matches!(lifecycle.release(&uri).await, ReleaseAction::Unchanged));
    }

    #[tokio::test]
    async fn concurrent_acquire_both_release_closes() {
        let temp = TempDir::new().unwrap();
        let path = temp.path().join("main.rs");
        std::fs::write(&path, "fn main() {}\n").unwrap();
        let uri = uri_for(&path);
        let lifecycle = DocumentLifecycle::new();

        assert!(matches!(lifecycle.acquire(&uri).await, AcquireAction::Open { .. }));
        assert!(matches!(lifecycle.acquire(&uri).await, AcquireAction::Unchanged));
        assert!(matches!(lifecycle.release(&uri).await, ReleaseAction::Unchanged));
        assert!(matches!(lifecycle.release(&uri).await, ReleaseAction::Close));
    }
}