wsb-daemon 0.275.7622

wsb's daemon: the workbench, the GitHub mirror's schedule and peer continuity, for every workspace on the machine
//! A document's text as an editor changes it: positions in UTF-16 code units, lines ended by
//! `\n`, `\r\n` or a lone `\r`, as the language server protocol counts them.
//!
//! The daemon keeps every page's copy of an open document and the copy it gave the server, and
//! they must be the server's character for character: a position the server answers is only
//! right in the text it has. So a change is applied exactly or not at all. A range outside the
//! text, or one that splits a character, is an error — never clamped into something the page
//! did not mean.

use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Position {
    pub line: u32,
    pub character: u32,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Range {
    pub start: Position,
    pub end: Position,
}

/// A change as the protocol carries it: a range replaced, or (without one) the whole text.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Change {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub range: Option<Range>,
    pub text: String,
}

/// The byte offset of the start of each line.
fn line_starts(text: &str) -> Vec<usize> {
    let bytes = text.as_bytes();
    let mut starts = vec![0];
    let mut i = 0;
    while i < bytes.len() {
        match bytes[i] {
            b'\r' if bytes.get(i + 1) == Some(&b'\n') => {
                starts.push(i + 2);
                i += 2;
            }
            b'\r' | b'\n' => {
                starts.push(i + 1);
                i += 1;
            }
            _ => i += 1,
        }
    }
    starts
}

/// The byte offset of `pos` in `text`.
pub fn offset(text: &str, pos: Position) -> Result<usize, String> {
    let starts = line_starts(text);
    let line = pos.line as usize;
    let Some(&start) = starts.get(line) else {
        return Err(format!("line {} is past the end of a {}-line text", pos.line, starts.len()));
    };
    // The line without its terminator.
    let end = starts.get(line + 1).map_or(text.len(), |&next| {
        let mut e = next;
        let b = text.as_bytes();
        if e > start && b[e - 1] == b'\n' {
            e -= 1;
        }
        if e > start && b[e - 1] == b'\r' {
            e -= 1;
        }
        e
    });
    let mut units = 0u32;
    for (i, c) in text[start..end].char_indices() {
        if units == pos.character {
            return Ok(start + i);
        }
        units += c.len_utf16() as u32;
        if units > pos.character {
            return Err(format!("character {} of line {} falls inside a character", pos.character, pos.line));
        }
    }
    if units == pos.character {
        Ok(end)
    } else {
        Err(format!("character {} is past the end of line {} ({units} long)", pos.character, pos.line))
    }
}

/// `text` with `changes` applied in order, each against the text the previous one left.
pub fn apply(text: &str, changes: &[Change]) -> Result<String, String> {
    let mut out = text.to_owned();
    for change in changes {
        out = match &change.range {
            None => change.text.clone(),
            Some(r) => {
                let (a, b) = (offset(&out, r.start)?, offset(&out, r.end)?);
                if a > b {
                    return Err(format!("a range ends before it starts: {r:?}"));
                }
                format!("{}{}{}", &out[..a], change.text, &out[b..])
            }
        };
    }
    Ok(out)
}

/// The position of a byte offset (a character boundary) in `text`.
pub fn position(text: &str, offset: usize) -> Result<Position, String> {
    if offset > text.len() || !text.is_char_boundary(offset) {
        return Err(format!("offset {offset} is not a character boundary of a {}-byte text", text.len()));
    }
    let starts = line_starts(text);
    let line = starts.partition_point(|&s| s <= offset) - 1;
    let character = text[starts[line]..offset].encode_utf16().count() as u32;
    Ok(Position { line: line as u32, character })
}

/// SHA-256 (hex) of a text: what a page compares to know diagnostics are about its text.
pub fn digest(text: &str) -> String {
    use sha2::{Digest, Sha256};
    format!("{:x}", Sha256::digest(text.as_bytes()))
}

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