sexpr.rs raw

//! The `.gdc` s-expression reader: hand-written, span-carrying, and
//! comment-recording, with no dependencies.
//!
//! Grammar: lists `( ... )`, symbols (`container`, `flex-start`, `+`, `i32`),
//! keywords (`:class`), double-quoted strings with `\\ \" \n \t \{`
//! escapes, integers, and floats. Line comments start with `;`.
//!
//! Every value carries its byte span in the source. Comments are collected
//! into a side table with spans, which together with the value spans is
//! sufficient for a future formatter to reproduce a file losslessly.

use std::fmt;

/// A byte range in the source text.
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
pub struct Span {
    pub start: usize,
    pub end: usize,
}

impl Span {
    pub fn new(start: usize, end: usize) -> Self {
        Self { start, end }
    }

    /// A span covering both.
    pub fn to(self, other: Span) -> Span {
        Span::new(self.start.min(other.start), self.end.max(other.end))
    }
}

/// One s-expression value.
#[derive(Clone, Debug, PartialEq)]
pub enum Sexpr {
    List(Vec<Sexpr>, Span),
    /// A bare symbol: `container`, `flex-start`, `i32`, `+`.
    Symbol(String, Span),
    /// A keyword: `:class` (stored without the colon).
    Keyword(String, Span),
    /// A string literal, with escapes already processed.
    Str(String, Span),
    Int(i64, Span),
    Float(f64, Span),
}

impl Sexpr {
    pub fn span(&self) -> Span {
        match self {
            Sexpr::List(_, s)
            | Sexpr::Symbol(_, s)
            | Sexpr::Keyword(_, s)
            | Sexpr::Str(_, s)
            | Sexpr::Int(_, s)
            | Sexpr::Float(_, s) => *s,
        }
    }

    pub fn as_symbol(&self) -> Option<&str> {
        match self {
            Sexpr::Symbol(s, _) => Some(s),
            _ => None,
        }
    }

    pub fn as_list(&self) -> Option<&[Sexpr]> {
        match self {
            Sexpr::List(items, _) => Some(items),
            _ => None,
        }
    }

    /// A short human name for the value's kind, for diagnostics.
    pub fn kind_name(&self) -> &'static str {
        match self {
            Sexpr::List(..) => "list",
            Sexpr::Symbol(..) => "symbol",
            Sexpr::Keyword(..) => "keyword",
            Sexpr::Str(..) => "string",
            Sexpr::Int(..) => "integer",
            Sexpr::Float(..) => "float",
        }
    }
}

/// A comment, with its span (including the `;`).
#[derive(Clone, Debug, PartialEq)]
pub struct Comment {
    pub text: String,
    pub span: Span,
}

/// The result of reading a source file: top-level values plus the comment
/// side table.
#[derive(Debug, Default)]
pub struct Document {
    pub values: Vec<Sexpr>,
    pub comments: Vec<Comment>,
}

/// A read error with location.
#[derive(Debug, Clone, PartialEq)]
pub struct ReadError {
    pub message: String,
    pub span: Span,
}

impl fmt::Display for ReadError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.message)
    }
}

/// Read a whole source text into a [`Document`].
pub fn read(source: &str) -> Result<Document, ReadError> {
    let mut reader = Reader {
        source,
        bytes: source.as_bytes(),
        pos: 0,
        comments: Vec::new(),
    };
    let mut values = Vec::new();
    loop {
        reader.skip_trivia();
        if reader.at_end() {
            break;
        }
        values.push(reader.value()?);
    }
    Ok(Document {
        values,
        comments: reader.comments,
    })
}

struct Reader<'a> {
    source: &'a str,
    bytes: &'a [u8],
    pos: usize,
    comments: Vec<Comment>,
}

impl Reader<'_> {
    fn at_end(&self) -> bool {
        self.pos >= self.bytes.len()
    }

    fn peek(&self) -> Option<u8> {
        self.bytes.get(self.pos).copied()
    }

    fn error(&self, message: impl Into<String>, span: Span) -> ReadError {
        ReadError {
            message: message.into(),
            span,
        }
    }

    /// Skip whitespace and comments, recording the comments.
    fn skip_trivia(&mut self) {
        loop {
            match self.peek() {
                Some(b) if b.is_ascii_whitespace() => self.pos += 1,
                Some(b';') => {
                    let start = self.pos;
                    while let Some(b) = self.peek() {
                        if b == b'\n' {
                            break;
                        }
                        self.pos += 1;
                    }
                    self.comments.push(Comment {
                        text: self.source[start..self.pos].to_owned(),
                        span: Span::new(start, self.pos),
                    });
                }
                _ => return,
            }
        }
    }

    fn value(&mut self) -> Result<Sexpr, ReadError> {
        self.skip_trivia();
        let start = self.pos;
        match self.peek() {
            None => Err(self.error("unexpected end of input", Span::new(start, start))),
            Some(b'(') => self.list(),
            Some(b')') => {
                Err(self.error("unmatched closing parenthesis", Span::new(start, start + 1)))
            }
            Some(b'"') => self.string(),
            Some(b':') => self.keyword(),
            Some(b) if b.is_ascii_digit() => self.number(),
            Some(b'-')
                if self
                    .bytes
                    .get(self.pos + 1)
                    .is_some_and(|b| b.is_ascii_digit()) =>
            {
                self.number()
            }
            Some(_) => self.symbol(),
        }
    }

    fn list(&mut self) -> Result<Sexpr, ReadError> {
        let start = self.pos;
        self.pos += 1; // consume '('
        let mut items = Vec::new();
        loop {
            self.skip_trivia();
            match self.peek() {
                None => {
                    return Err(self.error(
                        "unclosed parenthesis; expected `)`",
                        Span::new(start, start + 1),
                    ));
                }
                Some(b')') => {
                    self.pos += 1;
                    return Ok(Sexpr::List(items, Span::new(start, self.pos)));
                }
                Some(_) => items.push(self.value()?),
            }
        }
    }

    fn string(&mut self) -> Result<Sexpr, ReadError> {
        let start = self.pos;
        self.pos += 1; // consume '"'
        let mut text = String::new();
        loop {
            match self.peek() {
                None => {
                    return Err(self.error("unterminated string", Span::new(start, self.pos)));
                }
                Some(b'"') => {
                    self.pos += 1;
                    return Ok(Sexpr::Str(text, Span::new(start, self.pos)));
                }
                Some(b'\\') => {
                    let escape_start = self.pos;
                    self.pos += 1;
                    let escaped = match self.peek() {
                        Some(b'n') => '\n',
                        Some(b't') => '\t',
                        Some(b'\\') => '\\',
                        Some(b'"') => '"',
                        // `\{` produces a literal brace, exempt from
                        // interpolation.
                        Some(b'{') => '{',
                        other => {
                            return Err(self.error(
                                match other {
                                    Some(c) => {
                                        format!("unknown escape `\\{}`", c as char)
                                    }
                                    None => "unterminated escape".to_owned(),
                                },
                                Span::new(escape_start, self.pos + 1),
                            ));
                        }
                    };
                    text.push(escaped);
                    self.pos += 1;
                }
                Some(_) => {
                    // Consume one full UTF-8 character.
                    let ch = self.source[self.pos..]
                        .chars()
                        .next()
                        .expect("peek saw a byte");
                    text.push(ch);
                    self.pos += ch.len_utf8();
                }
            }
        }
    }

    fn keyword(&mut self) -> Result<Sexpr, ReadError> {
        let start = self.pos;
        self.pos += 1; // consume ':'
        let name_start = self.pos;
        self.consume_symbol_bytes();
        if self.pos == name_start {
            return Err(self.error("`:` must be followed by a name", Span::new(start, self.pos)));
        }
        Ok(Sexpr::Keyword(
            self.source[name_start..self.pos].to_owned(),
            Span::new(start, self.pos),
        ))
    }

    fn number(&mut self) -> Result<Sexpr, ReadError> {
        let start = self.pos;
        self.consume_symbol_bytes();
        let text = &self.source[start..self.pos];
        let span = Span::new(start, self.pos);
        if text.contains('.') || text.contains('e') || text.contains('E') {
            text.parse::<f64>()
                .map(|v| Sexpr::Float(v, span))
                .map_err(|_| self.error(format!("invalid number `{text}`"), span))
        } else {
            text.parse::<i64>()
                .map(|v| Sexpr::Int(v, span))
                .map_err(|_| self.error(format!("invalid number `{text}`"), span))
        }
    }

    fn symbol(&mut self) -> Result<Sexpr, ReadError> {
        let start = self.pos;
        self.consume_symbol_bytes();
        if self.pos == start {
            let ch = self.source[self.pos..].chars().next().unwrap_or('\0');
            return Err(self.error(
                format!("unexpected character `{ch}`"),
                Span::new(start, start + ch.len_utf8().max(1)),
            ));
        }
        Ok(Sexpr::Symbol(
            self.source[start..self.pos].to_owned(),
            Span::new(start, self.pos),
        ))
    }

    fn consume_symbol_bytes(&mut self) {
        while let Some(b) = self.peek() {
            let terminator =
                b.is_ascii_whitespace() || matches!(b, b'(' | b')' | b'"' | b';' | b':');
            if terminator {
                break;
            }
            self.pos += 1;
        }
    }
}

#[cfg(test)]
mod tests;