tftio-org-gdocs 0.1.3

Sync org-mode documents to Google Docs and pull reviewer comments back into org-mode
Documentation
//! Minimal s-expression reader/writer for the surfaces org-gdocs owns (A5).
//!
//! JSON is confined to the boundaries that force it (the Google API wire format,
//! `credentials.json`, `token.json`). Everywhere this crate controls *both*
//! producer and consumer — the `** Sync State` block ([`crate::syncstate`]) and
//! the CLI↔Emacs result envelope — the wire format is a single s-expression,
//! which Emacs reads natively with `(read …)`.
//!
//! This is a small hand-rolled value type plus reader/writer in the style of
//! `kb::sexp`; it intentionally pulls in no serde-sexp dependency. Only four value
//! shapes are needed: symbols, string literals, signed integers, and lists.

/// A parsed s-expression value.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Sexp {
    /// A bare symbol, e.g. `heading` or `gdoc-sync-state`.
    Symbol(String),
    /// A double-quoted string literal.
    Str(String),
    /// A signed integer.
    Int(i64),
    /// A parenthesized list of values.
    List(Vec<Sexp>),
}

/// Why parsing or decoding an s-expression failed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SexpError {
    /// The text was not a well-formed s-expression (unbalanced parens, bad
    /// escape, trailing input).
    Parse(String),
    /// The text parsed but did not match the shape the caller expected.
    Malformed(String),
}

impl std::fmt::Display for SexpError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Parse(message) => write!(f, "parse error: {message}"),
            Self::Malformed(message) => write!(f, "malformed: {message}"),
        }
    }
}

impl std::error::Error for SexpError {}

impl Sexp {
    /// Construct a [`Sexp::Symbol`].
    #[must_use]
    pub fn symbol(name: impl Into<String>) -> Self {
        Self::Symbol(name.into())
    }

    /// Construct a [`Sexp::Str`].
    #[must_use]
    pub fn string(value: impl Into<String>) -> Self {
        Self::Str(value.into())
    }

    /// Construct a [`Sexp::Int`].
    #[must_use]
    pub const fn int(value: i64) -> Self {
        Self::Int(value)
    }

    /// Construct a [`Sexp::List`].
    #[must_use]
    pub const fn list(items: Vec<Self>) -> Self {
        Self::List(items)
    }

    /// The symbol name, if this is a [`Sexp::Symbol`].
    #[must_use]
    pub fn as_symbol(&self) -> Option<&str> {
        match self {
            Self::Symbol(name) => Some(name),
            _ => None,
        }
    }

    /// The string value, if this is a [`Sexp::Str`].
    #[must_use]
    pub fn as_str(&self) -> Option<&str> {
        match self {
            Self::Str(value) => Some(value),
            _ => None,
        }
    }

    /// The integer value, if this is a [`Sexp::Int`].
    #[must_use]
    pub const fn as_int(&self) -> Option<i64> {
        match self {
            Self::Int(value) => Some(*value),
            _ => None,
        }
    }

    /// The list elements, if this is a [`Sexp::List`].
    #[must_use]
    pub fn as_list(&self) -> Option<&[Self]> {
        match self {
            Self::List(items) => Some(items),
            _ => None,
        }
    }

    /// Render to canonical s-expression text.
    #[must_use]
    pub fn render(&self) -> String {
        let mut out = String::new();
        self.write(&mut out);
        out
    }

    fn write(&self, out: &mut String) {
        match self {
            Self::Symbol(name) => out.push_str(name),
            Self::Str(value) => write_string(out, value),
            Self::Int(value) => out.push_str(&value.to_string()),
            Self::List(items) => {
                out.push('(');
                for (position, item) in items.iter().enumerate() {
                    if position > 0 {
                        out.push(' ');
                    }
                    item.write(out);
                }
                out.push(')');
            }
        }
    }
}

/// Write a quoted, escaped string literal.
fn write_string(out: &mut String, value: &str) {
    out.push('"');
    for ch in value.chars() {
        match ch {
            '\\' => out.push_str("\\\\"),
            '"' => out.push_str("\\\""),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            other => out.push(other),
        }
    }
    out.push('"');
}

/// Parse exactly one s-expression from `input`.
///
/// # Errors
///
/// Returns [`SexpError::Parse`] on malformed input or trailing content after the
/// first complete value.
pub fn parse(input: &str) -> Result<Sexp, SexpError> {
    let chars: Vec<char> = input.chars().collect();
    let mut pos = 0;
    let value = parse_one(&chars, &mut pos)?;
    skip_whitespace(&chars, &mut pos);
    if pos < chars.len() {
        return Err(SexpError::Parse(format!(
            "trailing input at position {pos}"
        )));
    }
    Ok(value)
}

fn parse_one(chars: &[char], pos: &mut usize) -> Result<Sexp, SexpError> {
    skip_whitespace(chars, pos);
    let Some(&first) = chars.get(*pos) else {
        return Err(SexpError::Parse("unexpected end of input".to_owned()));
    };
    match first {
        '(' => parse_list(chars, pos),
        ')' => Err(SexpError::Parse("unexpected closing paren".to_owned())),
        '"' => parse_string(chars, pos),
        '-' | '0'..='9' => parse_int(chars, pos),
        _ => Ok(parse_symbol(chars, pos)),
    }
}

fn parse_list(chars: &[char], pos: &mut usize) -> Result<Sexp, SexpError> {
    *pos += 1; // consume '('
    let mut items = Vec::new();
    loop {
        skip_whitespace(chars, pos);
        match chars.get(*pos) {
            None => return Err(SexpError::Parse("unterminated list".to_owned())),
            Some(')') => {
                *pos += 1;
                return Ok(Sexp::List(items));
            }
            Some(_) => items.push(parse_one(chars, pos)?),
        }
    }
}

fn parse_string(chars: &[char], pos: &mut usize) -> Result<Sexp, SexpError> {
    *pos += 1; // consume opening quote
    let mut value = String::new();
    loop {
        let Some(&ch) = chars.get(*pos) else {
            return Err(SexpError::Parse("unterminated string".to_owned()));
        };
        match ch {
            '"' => {
                *pos += 1;
                return Ok(Sexp::Str(value));
            }
            '\\' => {
                *pos += 1;
                let Some(&escaped) = chars.get(*pos) else {
                    return Err(SexpError::Parse("unterminated escape".to_owned()));
                };
                value.push(unescape(escaped)?);
                *pos += 1;
            }
            other => {
                value.push(other);
                *pos += 1;
            }
        }
    }
}

fn unescape(escaped: char) -> Result<char, SexpError> {
    match escaped {
        '"' => Ok('"'),
        '\\' => Ok('\\'),
        'n' => Ok('\n'),
        'r' => Ok('\r'),
        't' => Ok('\t'),
        other => Err(SexpError::Parse(format!("unknown escape: \\{other}"))),
    }
}

fn parse_int(chars: &[char], pos: &mut usize) -> Result<Sexp, SexpError> {
    let start = *pos;
    if chars.get(*pos) == Some(&'-') {
        *pos += 1;
    }
    while chars.get(*pos).is_some_and(char::is_ascii_digit) {
        *pos += 1;
    }
    let text: String = chars.get(start..*pos).unwrap_or_default().iter().collect();
    text.parse()
        .map(Sexp::Int)
        .map_err(|error: std::num::ParseIntError| SexpError::Parse(error.to_string()))
}

fn parse_symbol(chars: &[char], pos: &mut usize) -> Sexp {
    let start = *pos;
    while chars
        .get(*pos)
        .is_some_and(|ch| !ch.is_whitespace() && *ch != '(' && *ch != ')')
    {
        *pos += 1;
    }
    let name: String = chars.get(start..*pos).unwrap_or_default().iter().collect();
    Sexp::Symbol(name)
}

fn skip_whitespace(chars: &[char], pos: &mut usize) {
    while chars.get(*pos).is_some_and(|ch| ch.is_whitespace()) {
        *pos += 1;
    }
}

#[cfg(test)]
mod tests {
    use super::{Sexp, SexpError, parse};

    #[test]
    fn renders_each_value_kind() {
        let value = Sexp::list(vec![
            Sexp::symbol("tag"),
            Sexp::string("a \"quoted\"\nline"),
            Sexp::int(-7),
        ]);
        assert_eq!(value.render(), "(tag \"a \\\"quoted\\\"\\nline\" -7)");
    }

    #[test]
    fn round_trips_through_parse() {
        let text = "(gdoc-sync-state 1 (pos \"sec-intro/p-1\" 5 paragraph))";
        let parsed = parse(text).expect("parses");
        assert_eq!(parsed.render(), text);
    }

    #[test]
    fn parses_nested_and_escaped() {
        let parsed = parse("(a (b \"x\\ty\") -1)").expect("parses");
        let items = parsed.as_list().expect("list");
        assert_eq!(items.first().and_then(Sexp::as_symbol), Some("a"));
        let inner = items.get(1).and_then(Sexp::as_list).expect("inner list");
        assert_eq!(inner.get(1).and_then(Sexp::as_str), Some("x\ty"));
        assert_eq!(items.get(2).and_then(Sexp::as_int), Some(-1));
    }

    #[test]
    fn rejects_unterminated_list() {
        assert!(matches!(parse("(a b"), Err(SexpError::Parse(_))));
    }

    #[test]
    fn rejects_trailing_input() {
        assert!(matches!(parse("(a) (b)"), Err(SexpError::Parse(_))));
    }

    #[test]
    fn rejects_unterminated_string() {
        assert!(matches!(parse("\"abc"), Err(SexpError::Parse(_))));
    }
}