#[cfg(not(feature = "std"))]
use alloc::{
string::{String, ToString},
vec::Vec,
};
#[derive(Debug)]
pub(crate) enum SExpr {
Atom(String),
List(Vec<SExpr>),
}
const MAX_SEXPR_DEPTH: usize = 64;
#[derive(Debug, PartialEq)]
enum Token<'a> {
LParen,
RParen,
Atom(&'a str),
Quoted(String),
}
pub(crate) fn parse_sexprs(input: &str) -> Vec<SExpr> {
let tokens = tokenize(input);
let mut result = Vec::new();
let mut pos = 0usize;
while pos < tokens.len() {
if let Some(expr) = parse_one(&tokens, &mut pos, 0) {
result.push(expr);
}
}
result
}
fn tokenize(input: &str) -> Vec<Token<'_>> {
let mut tokens = Vec::new();
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes.get(i) {
Some(b'(') => {
tokens.push(Token::LParen);
i += 1;
}
Some(b')') => {
tokens.push(Token::RParen);
i += 1;
}
Some(b'"') => {
i += 1;
let mut s = String::new();
while i < bytes.len() {
match bytes.get(i) {
Some(b'\\') if i + 1 < bytes.len() => {
i += 1;
if let Some(&c) = bytes.get(i) {
s.push(c as char);
}
i += 1;
}
Some(b'"') => {
i += 1;
break;
}
Some(&c) => {
s.push(c as char);
i += 1;
}
None => break,
}
}
tokens.push(Token::Quoted(s));
}
Some(b' ') | Some(b'\t') | Some(b'\n') | Some(b'\r') => {
i += 1;
}
Some(b';') => {
while i < bytes.len() && bytes.get(i) != Some(&b'\n') {
i += 1;
}
}
_ => {
let start = i;
while i < bytes.len() {
match bytes.get(i) {
Some(b'(') | Some(b')') | Some(b'"') | Some(b' ') | Some(b'\t')
| Some(b'\n') | Some(b'\r') => break,
_ => i += 1,
}
}
if let Some(slice) = input.get(start..i)
&& !slice.is_empty()
{
tokens.push(Token::Atom(slice));
}
}
}
}
tokens
}
fn parse_one(tokens: &[Token<'_>], pos: &mut usize, depth: usize) -> Option<SExpr> {
if depth > MAX_SEXPR_DEPTH {
return None;
}
match tokens.get(*pos) {
Some(Token::LParen) => {
*pos += 1;
let mut items = Vec::new();
loop {
match tokens.get(*pos) {
Some(Token::RParen) => {
*pos += 1;
break;
}
None => break,
_ => {
if let Some(child) = parse_one(tokens, pos, depth + 1) {
items.push(child);
} else {
break;
}
}
}
}
Some(SExpr::List(items))
}
Some(Token::RParen) => {
*pos += 1;
None
}
Some(Token::Atom(s)) => {
let s = s.to_string();
*pos += 1;
Some(SExpr::Atom(s))
}
Some(Token::Quoted(s)) => {
let s = s.clone();
*pos += 1;
Some(SExpr::Atom(s))
}
None => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn atom(e: &SExpr) -> Option<&str> {
match e {
SExpr::Atom(s) => Some(s.as_str()),
SExpr::List(_) => None,
}
}
#[test]
fn parses_flat_list() {
let exprs = parse_sexprs("(zoom 100)");
assert_eq!(exprs.len(), 1);
let SExpr::List(items) = &exprs[0] else {
panic!("expected list")
};
assert_eq!(atom(&items[0]), Some("zoom"));
assert_eq!(atom(&items[1]), Some("100"));
}
#[test]
fn quoted_and_atom_both_reduce_to_atom() {
let exprs = parse_sexprs(r#"(title "My Book")"#);
let SExpr::List(items) = &exprs[0] else {
panic!("expected list")
};
assert_eq!(atom(&items[1]), Some("My Book"));
}
#[test]
fn line_comment_is_skipped() {
let exprs = parse_sexprs("; a comment\n(a b)");
assert_eq!(exprs.len(), 1);
}
#[test]
fn escaped_quote_inside_string() {
let exprs = parse_sexprs(r#"(k "a\"b")"#);
let SExpr::List(items) = &exprs[0] else {
panic!("expected list")
};
assert_eq!(atom(&items[1]), Some("a\"b"));
}
#[test]
fn depth_guard_bounds_recursion() {
let deep = format!("{}{}", "(".repeat(10_000), ")".repeat(10_000));
let exprs = parse_sexprs(&deep);
assert_eq!(exprs.len(), 1);
}
#[test]
fn unterminated_quoted_string_does_not_panic() {
let exprs = parse_sexprs(r#"(k "unterminated"#);
assert_eq!(exprs.len(), 1);
}
#[test]
fn unclosed_parenthesis_does_not_panic() {
let exprs = parse_sexprs("(unclosed");
assert_eq!(exprs.len(), 1);
}
#[test]
fn unclosed_quoted_string_does_not_panic() {
let exprs = parse_sexprs("\"unclosed");
assert_eq!(exprs.len(), 1);
assert_eq!(atom(&exprs[0]), Some("unclosed"));
}
}