wsb-daemon 0.275.7622

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
//! Who has which document open, and what the server was told about it.
//!
//! A language server holds one buffer per document; a workbench may have the same file open in
//! several pages, each with edits of its own. The daemon keeps every page's copy and the one it
//! gave the server, and the server's follows whichever page last edited the file or asked about
//! it — its *owner*. A page that asks about a file whose server copy is another page's text
//! first becomes the owner, with one full-text change, so every answer is about the text the
//! asking page has.
//!
//! Pure: every operation returns what to tell the server, and the session sends it. Versions
//! given to the server only grow.

use std::collections::BTreeMap;

use super::text::{self, Change};

/// A page, for as long as it is open.
pub type ClientId = String;

#[derive(Debug, Clone, PartialEq, Eq)]
struct ClientDoc {
    /// The page's own counter for its text.
    version: i64,
    text: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct Doc {
    language_id: String,
    server_version: i32,
    server_text: String,
    owner: ClientId,
    clients: BTreeMap<ClientId, ClientDoc>,
}

/// What to tell the server, in order.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outgoing {
    Open { path: String, language_id: String, version: i32, text: String },
    /// The owner's own edit, as it made it.
    Edit { path: String, version: i32, changes: Vec<Change> },
    /// Another page's text replacing the server's.
    Replace { path: String, version: i32, text: String },
    Close { path: String },
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SyncError {
    /// The page has not opened the file.
    NotOpen { path: String },
    /// The page's text is not at the version it names: it asked about text the daemon does
    /// not have (a request raced its own edit).
    Stale { path: String, have: i64, asked: i64 },
    /// An edit from a version the daemon does not have: edits were lost or reordered.
    Gap { path: String, have: i64, from: i64 },
    /// An edit that does not apply to the text.
    Invalid { path: String, detail: String },
}

impl std::fmt::Display for SyncError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::NotOpen { path } => write!(f, "{path} is not open in this page"),
            Self::Stale { path, have, asked } => {
                write!(f, "{path} is at version {have} here, not {asked}: the request is about text that has changed")
            }
            Self::Gap { path, have, from } => {
                write!(f, "{path} is at version {have} here; an edit from version {from} cannot be applied")
            }
            Self::Invalid { path, detail } => write!(f, "an edit of {path} does not apply: {detail}"),
        }
    }
}

#[derive(Debug, Default)]
pub struct Registry {
    docs: BTreeMap<String, Doc>,
}

impl Registry {
    /// The server's copy of `path`: its version and text.
    pub fn server_copy(&self, path: &str) -> Option<(i32, &str)> {
        self.docs.get(path).map(|d| (d.server_version, d.server_text.as_str()))
    }

    pub fn is_empty(&self) -> bool {
        self.docs.is_empty()
    }

    /// A page opened `path` with `text` at its `version`.
    pub fn open(&mut self, client: &str, path: &str, language_id: &str, version: i64, text: &str) -> Vec<Outgoing> {
        let page = ClientDoc { version, text: text.to_owned() };
        match self.docs.get_mut(path) {
            None => {
                let mut clients = BTreeMap::new();
                clients.insert(client.to_owned(), page);
                self.docs.insert(
                    path.to_owned(),
                    Doc {
                        language_id: language_id.to_owned(),
                        server_version: 1,
                        server_text: text.to_owned(),
                        owner: client.to_owned(),
                        clients,
                    },
                );
                vec![Outgoing::Open {
                    path: path.to_owned(),
                    language_id: language_id.to_owned(),
                    version: 1,
                    text: text.to_owned(),
                }]
            }
            Some(doc) => {
                doc.clients.insert(client.to_owned(), page);
                take_over(path, doc, client).into_iter().collect()
            }
        }
    }

    /// A page edited `path` from its version `from` to `to`.
    pub fn change(
        &mut self,
        client: &str,
        path: &str,
        from: i64,
        to: i64,
        changes: &[Change],
    ) -> Result<Vec<Outgoing>, SyncError> {
        let doc = self.docs.get_mut(path).ok_or_else(|| SyncError::NotOpen { path: path.to_owned() })?;
        let page = doc.clients.get_mut(client).ok_or_else(|| SyncError::NotOpen { path: path.to_owned() })?;
        if page.version != from {
            return Err(SyncError::Gap { path: path.to_owned(), have: page.version, from });
        }
        let edited =
            text::apply(&page.text, changes).map_err(|detail| SyncError::Invalid { path: path.to_owned(), detail })?;
        page.text = edited;
        page.version = to;
        if doc.owner == client {
            // The server's copy is this page's text as it was: the same edit applies.
            doc.server_text = doc.clients[client].text.clone();
            doc.server_version += 1;
            return Ok(vec![Outgoing::Edit { path: path.to_owned(), version: doc.server_version, changes: changes.to_vec() }]);
        }
        Ok(take_over(path, doc, client).into_iter().collect())
    }

    /// Make the server's copy of `path` the text `client` has at `version`, before asking the
    /// server about it.
    pub fn ensure(&mut self, client: &str, path: &str, version: i64) -> Result<Vec<Outgoing>, SyncError> {
        let doc = self.docs.get_mut(path).ok_or_else(|| SyncError::NotOpen { path: path.to_owned() })?;
        let page = doc.clients.get(client).ok_or_else(|| SyncError::NotOpen { path: path.to_owned() })?;
        if page.version != version {
            return Err(SyncError::Stale { path: path.to_owned(), have: page.version, asked: version });
        }
        Ok(take_over(path, doc, client).into_iter().collect())
    }

    /// A page closed `path`. The last one to close it closes it on the server; otherwise a
    /// remaining page's text becomes the server's, if it differs.
    pub fn close(&mut self, client: &str, path: &str) -> Vec<Outgoing> {
        let Some(doc) = self.docs.get_mut(path) else { return Vec::new() };
        if doc.clients.remove(client).is_none() {
            return Vec::new();
        }
        let Some(next) = doc.clients.keys().next().cloned() else {
            self.docs.remove(path);
            return vec![Outgoing::Close { path: path.to_owned() }];
        };
        if doc.owner != client {
            return Vec::new();
        }
        take_over(path, doc, &next).into_iter().collect()
    }

    /// A page is gone: every document it had open is closed for it.
    pub fn close_client(&mut self, client: &str) -> Vec<Outgoing> {
        let paths: Vec<String> =
            self.docs.iter().filter(|(_, d)| d.clients.contains_key(client)).map(|(p, _)| p.clone()).collect();
        paths.iter().flat_map(|p| self.close(client, p)).collect()
    }

    /// Everything the server must be told to hold what it held: for a server started again.
    pub fn replay(&self) -> Vec<Outgoing> {
        self.docs
            .iter()
            .map(|(p, d)| Outgoing::Open {
                path: p.clone(),
                language_id: d.language_id.clone(),
                version: d.server_version,
                text: d.server_text.clone(),
            })
            .collect()
    }
}

/// `client` becomes the owner; a full-text change when its text is not the server's.
fn take_over(path: &str, doc: &mut Doc, client: &str) -> Option<Outgoing> {
    doc.owner = client.to_owned();
    let text = &doc.clients[client].text;
    if *text == doc.server_text {
        return None;
    }
    doc.server_text = text.clone();
    doc.server_version += 1;
    Some(Outgoing::Replace { path: path.to_owned(), version: doc.server_version, text: text.clone() })
}

#[cfg(test)]
#[path = "documents_tests.rs"]
mod tests;