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;