Skip to main content

org_gdocs/
sexp.rs

1//! Minimal s-expression reader/writer for the surfaces org-gdocs owns (A5).
2//!
3//! JSON is confined to the boundaries that force it (the Google API wire format,
4//! `credentials.json`, `token.json`). Everywhere this crate controls *both*
5//! producer and consumer — the `** Sync State` block ([`crate::syncstate`]) and
6//! the CLI↔Emacs result envelope — the wire format is a single s-expression,
7//! which Emacs reads natively with `(read …)`.
8//!
9//! This is a small hand-rolled value type plus reader/writer in the style of
10//! `kb::sexp`; it intentionally pulls in no serde-sexp dependency. Only four value
11//! shapes are needed: symbols, string literals, signed integers, and lists.
12
13/// A parsed s-expression value.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub enum Sexp {
16    /// A bare symbol, e.g. `heading` or `gdoc-sync-state`.
17    Symbol(String),
18    /// A double-quoted string literal.
19    Str(String),
20    /// A signed integer.
21    Int(i64),
22    /// A parenthesized list of values.
23    List(Vec<Sexp>),
24}
25
26/// Why parsing or decoding an s-expression failed.
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub enum SexpError {
29    /// The text was not a well-formed s-expression (unbalanced parens, bad
30    /// escape, trailing input).
31    Parse(String),
32    /// The text parsed but did not match the shape the caller expected.
33    Malformed(String),
34}
35
36impl std::fmt::Display for SexpError {
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        match self {
39            Self::Parse(message) => write!(f, "parse error: {message}"),
40            Self::Malformed(message) => write!(f, "malformed: {message}"),
41        }
42    }
43}
44
45impl std::error::Error for SexpError {}
46
47impl Sexp {
48    /// Construct a [`Sexp::Symbol`].
49    #[must_use]
50    pub fn symbol(name: impl Into<String>) -> Self {
51        Self::Symbol(name.into())
52    }
53
54    /// Construct a [`Sexp::Str`].
55    #[must_use]
56    pub fn string(value: impl Into<String>) -> Self {
57        Self::Str(value.into())
58    }
59
60    /// Construct a [`Sexp::Int`].
61    #[must_use]
62    pub const fn int(value: i64) -> Self {
63        Self::Int(value)
64    }
65
66    /// Construct a [`Sexp::List`].
67    #[must_use]
68    pub const fn list(items: Vec<Self>) -> Self {
69        Self::List(items)
70    }
71
72    /// The symbol name, if this is a [`Sexp::Symbol`].
73    #[must_use]
74    pub fn as_symbol(&self) -> Option<&str> {
75        match self {
76            Self::Symbol(name) => Some(name),
77            _ => None,
78        }
79    }
80
81    /// The string value, if this is a [`Sexp::Str`].
82    #[must_use]
83    pub fn as_str(&self) -> Option<&str> {
84        match self {
85            Self::Str(value) => Some(value),
86            _ => None,
87        }
88    }
89
90    /// The integer value, if this is a [`Sexp::Int`].
91    #[must_use]
92    pub const fn as_int(&self) -> Option<i64> {
93        match self {
94            Self::Int(value) => Some(*value),
95            _ => None,
96        }
97    }
98
99    /// The list elements, if this is a [`Sexp::List`].
100    #[must_use]
101    pub fn as_list(&self) -> Option<&[Self]> {
102        match self {
103            Self::List(items) => Some(items),
104            _ => None,
105        }
106    }
107
108    /// Render to canonical s-expression text.
109    #[must_use]
110    pub fn render(&self) -> String {
111        let mut out = String::new();
112        self.write(&mut out);
113        out
114    }
115
116    fn write(&self, out: &mut String) {
117        match self {
118            Self::Symbol(name) => out.push_str(name),
119            Self::Str(value) => write_string(out, value),
120            Self::Int(value) => out.push_str(&value.to_string()),
121            Self::List(items) => {
122                out.push('(');
123                for (position, item) in items.iter().enumerate() {
124                    if position > 0 {
125                        out.push(' ');
126                    }
127                    item.write(out);
128                }
129                out.push(')');
130            }
131        }
132    }
133}
134
135/// Write a quoted, escaped string literal.
136fn write_string(out: &mut String, value: &str) {
137    out.push('"');
138    for ch in value.chars() {
139        match ch {
140            '\\' => out.push_str("\\\\"),
141            '"' => out.push_str("\\\""),
142            '\n' => out.push_str("\\n"),
143            '\r' => out.push_str("\\r"),
144            '\t' => out.push_str("\\t"),
145            other => out.push(other),
146        }
147    }
148    out.push('"');
149}
150
151/// Parse exactly one s-expression from `input`.
152///
153/// # Errors
154///
155/// Returns [`SexpError::Parse`] on malformed input or trailing content after the
156/// first complete value.
157pub fn parse(input: &str) -> Result<Sexp, SexpError> {
158    let chars: Vec<char> = input.chars().collect();
159    let mut pos = 0;
160    let value = parse_one(&chars, &mut pos)?;
161    skip_whitespace(&chars, &mut pos);
162    if pos < chars.len() {
163        return Err(SexpError::Parse(format!(
164            "trailing input at position {pos}"
165        )));
166    }
167    Ok(value)
168}
169
170fn parse_one(chars: &[char], pos: &mut usize) -> Result<Sexp, SexpError> {
171    skip_whitespace(chars, pos);
172    let Some(&first) = chars.get(*pos) else {
173        return Err(SexpError::Parse("unexpected end of input".to_owned()));
174    };
175    match first {
176        '(' => parse_list(chars, pos),
177        ')' => Err(SexpError::Parse("unexpected closing paren".to_owned())),
178        '"' => parse_string(chars, pos),
179        '-' | '0'..='9' => parse_int(chars, pos),
180        _ => Ok(parse_symbol(chars, pos)),
181    }
182}
183
184fn parse_list(chars: &[char], pos: &mut usize) -> Result<Sexp, SexpError> {
185    *pos += 1; // consume '('
186    let mut items = Vec::new();
187    loop {
188        skip_whitespace(chars, pos);
189        match chars.get(*pos) {
190            None => return Err(SexpError::Parse("unterminated list".to_owned())),
191            Some(')') => {
192                *pos += 1;
193                return Ok(Sexp::List(items));
194            }
195            Some(_) => items.push(parse_one(chars, pos)?),
196        }
197    }
198}
199
200fn parse_string(chars: &[char], pos: &mut usize) -> Result<Sexp, SexpError> {
201    *pos += 1; // consume opening quote
202    let mut value = String::new();
203    loop {
204        let Some(&ch) = chars.get(*pos) else {
205            return Err(SexpError::Parse("unterminated string".to_owned()));
206        };
207        match ch {
208            '"' => {
209                *pos += 1;
210                return Ok(Sexp::Str(value));
211            }
212            '\\' => {
213                *pos += 1;
214                let Some(&escaped) = chars.get(*pos) else {
215                    return Err(SexpError::Parse("unterminated escape".to_owned()));
216                };
217                value.push(unescape(escaped)?);
218                *pos += 1;
219            }
220            other => {
221                value.push(other);
222                *pos += 1;
223            }
224        }
225    }
226}
227
228fn unescape(escaped: char) -> Result<char, SexpError> {
229    match escaped {
230        '"' => Ok('"'),
231        '\\' => Ok('\\'),
232        'n' => Ok('\n'),
233        'r' => Ok('\r'),
234        't' => Ok('\t'),
235        other => Err(SexpError::Parse(format!("unknown escape: \\{other}"))),
236    }
237}
238
239fn parse_int(chars: &[char], pos: &mut usize) -> Result<Sexp, SexpError> {
240    let start = *pos;
241    if chars.get(*pos) == Some(&'-') {
242        *pos += 1;
243    }
244    while chars.get(*pos).is_some_and(char::is_ascii_digit) {
245        *pos += 1;
246    }
247    let text: String = chars.get(start..*pos).unwrap_or_default().iter().collect();
248    text.parse()
249        .map(Sexp::Int)
250        .map_err(|error: std::num::ParseIntError| SexpError::Parse(error.to_string()))
251}
252
253fn parse_symbol(chars: &[char], pos: &mut usize) -> Sexp {
254    let start = *pos;
255    while chars
256        .get(*pos)
257        .is_some_and(|ch| !ch.is_whitespace() && *ch != '(' && *ch != ')')
258    {
259        *pos += 1;
260    }
261    let name: String = chars.get(start..*pos).unwrap_or_default().iter().collect();
262    Sexp::Symbol(name)
263}
264
265fn skip_whitespace(chars: &[char], pos: &mut usize) {
266    while chars.get(*pos).is_some_and(|ch| ch.is_whitespace()) {
267        *pos += 1;
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::{Sexp, SexpError, parse};
274
275    #[test]
276    fn renders_each_value_kind() {
277        let value = Sexp::list(vec![
278            Sexp::symbol("tag"),
279            Sexp::string("a \"quoted\"\nline"),
280            Sexp::int(-7),
281        ]);
282        assert_eq!(value.render(), "(tag \"a \\\"quoted\\\"\\nline\" -7)");
283    }
284
285    #[test]
286    fn round_trips_through_parse() {
287        let text = "(gdoc-sync-state 1 (pos \"sec-intro/p-1\" 5 paragraph))";
288        let parsed = parse(text).expect("parses");
289        assert_eq!(parsed.render(), text);
290    }
291
292    #[test]
293    fn parses_nested_and_escaped() {
294        let parsed = parse("(a (b \"x\\ty\") -1)").expect("parses");
295        let items = parsed.as_list().expect("list");
296        assert_eq!(items.first().and_then(Sexp::as_symbol), Some("a"));
297        let inner = items.get(1).and_then(Sexp::as_list).expect("inner list");
298        assert_eq!(inner.get(1).and_then(Sexp::as_str), Some("x\ty"));
299        assert_eq!(items.get(2).and_then(Sexp::as_int), Some(-1));
300    }
301
302    #[test]
303    fn rejects_unterminated_list() {
304        assert!(matches!(parse("(a b"), Err(SexpError::Parse(_))));
305    }
306
307    #[test]
308    fn rejects_trailing_input() {
309        assert!(matches!(parse("(a) (b)"), Err(SexpError::Parse(_))));
310    }
311
312    #[test]
313    fn rejects_unterminated_string() {
314        assert!(matches!(parse("\"abc"), Err(SexpError::Parse(_))));
315    }
316}