#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Sexp {
Symbol(String),
Str(String),
Int(i64),
List(Vec<Sexp>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SexpError {
Parse(String),
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 {
#[must_use]
pub fn symbol(name: impl Into<String>) -> Self {
Self::Symbol(name.into())
}
#[must_use]
pub fn string(value: impl Into<String>) -> Self {
Self::Str(value.into())
}
#[must_use]
pub const fn int(value: i64) -> Self {
Self::Int(value)
}
#[must_use]
pub const fn list(items: Vec<Self>) -> Self {
Self::List(items)
}
#[must_use]
pub fn as_symbol(&self) -> Option<&str> {
match self {
Self::Symbol(name) => Some(name),
_ => None,
}
}
#[must_use]
pub fn as_str(&self) -> Option<&str> {
match self {
Self::Str(value) => Some(value),
_ => None,
}
}
#[must_use]
pub const fn as_int(&self) -> Option<i64> {
match self {
Self::Int(value) => Some(*value),
_ => None,
}
}
#[must_use]
pub fn as_list(&self) -> Option<&[Self]> {
match self {
Self::List(items) => Some(items),
_ => None,
}
}
#[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(')');
}
}
}
}
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('"');
}
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; 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; 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(_))));
}
}