Lines
93.41 %
Functions
43.14 %
Branches
100 %
//! A small, self-contained s-expression value + reader/writer for the SLYNK
//! wire subset (see `doc/editor/slynk-protocol-transcript.org`). This is
//! deliberately NOT the nomiscript reader: SLYNK forms are Emacs-Lisp / CL
//! shaped (`'quote`, `pkg:sym`, `t`/`nil`, dotted-free lists, CL string
//! escapes) and the language reader neither parses nor preserves those. The
//! grammar here is exactly what SLY emits; anything outside it is a parse
//! error, surfaced to the caller (never a panic).
use std::fmt::Write as _;
/// Recursion ceiling for nested lists — SLYNK messages are shallow; a deeper
/// nesting is treated as malformed rather than risking a native stack overflow
/// on hostile input (mirrors the compiler's depth-guard discipline).
const MAX_DEPTH: usize = 64;
/// A parsed SLYNK s-expression. `Symbol` covers bare symbols, keywords
/// (`:foo`), `t`, and `nil` verbatim (callers compare against the literal text,
/// e.g. `"nil"`); `quote` is preserved as a `(quote X)` two-element list so
/// `'(a b)` and `(quote (a b))` are indistinguishable downstream (matching CL).
#[derive(Debug, Clone, PartialEq)]
pub enum Sexp {
Symbol(String),
Int(i64),
Str(String),
List(Vec<Sexp>),
}
impl Sexp {
/// The symbol/keyword text, if this is a `Symbol`.
pub fn as_symbol(&self) -> Option<&str> {
match self {
Sexp::Symbol(s) => Some(s),
_ => None,
/// The string contents, if this is a `Str`.
pub fn as_str(&self) -> Option<&str> {
Sexp::Str(s) => Some(s),
pub fn as_int(&self) -> Option<i64> {
Sexp::Int(n) => Some(*n),
/// The elements, if this is a `List`.
pub fn as_list(&self) -> Option<&[Sexp]> {
Sexp::List(items) => Some(items),
#[derive(Debug, PartialEq, Eq)]
pub struct ParseError(pub String);
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "slynk sexp parse error: {}", self.0)
impl std::error::Error for ParseError {}
/// Parses exactly one s-expression from `input`, ignoring trailing whitespace.
pub fn parse(input: &str) -> Result<Sexp, ParseError> {
let mut p = Parser {
chars: input.chars().peekable(),
depth: 0,
};
p.skip_ws();
let v = p.parse_value()?;
if p.chars.peek().is_some() {
return Err(ParseError("trailing data after expression".into()));
Ok(v)
struct Parser<'a> {
chars: std::iter::Peekable<std::str::Chars<'a>>,
depth: usize,
impl Parser<'_> {
fn skip_ws(&mut self) {
while matches!(self.chars.peek(), Some(c) if c.is_whitespace()) {
self.chars.next();
fn parse_value(&mut self) -> Result<Sexp, ParseError> {
match self.chars.peek().copied() {
None => Err(ParseError("unexpected end of input".into())),
Some('(') => self.parse_list(),
Some('"') => self.parse_string(),
Some('\'') => {
// 'X → (quote X), matching CL/Elisp reader.
let inner = self.parse_value()?;
Ok(Sexp::List(vec![Sexp::Symbol("quote".into()), inner]))
Some(')') => Err(ParseError("unexpected ')'".into())),
Some(_) => self.parse_atom(),
fn parse_list(&mut self) -> Result<Sexp, ParseError> {
if self.depth >= MAX_DEPTH {
return Err(ParseError("list nesting too deep".into()));
self.chars.next(); // consume '('
self.depth += 1;
let mut items = Vec::new();
loop {
self.skip_ws();
None => return Err(ParseError("unterminated list".into())),
Some(')') => {
break;
Some(_) => items.push(self.parse_value()?),
self.depth -= 1;
Ok(Sexp::List(items))
fn parse_string(&mut self) -> Result<Sexp, ParseError> {
self.chars.next(); // consume opening '"'
let mut s = String::new();
match self.chars.next() {
None => return Err(ParseError("unterminated string".into())),
Some('"') => break,
// CL/Elisp string escapes: only `\` and `"` are semantically
// meaningful on the wire; any other `\x` keeps `x` verbatim.
Some('\\') => match self.chars.next() {
None => return Err(ParseError("trailing backslash in string".into())),
Some('n') => s.push('\n'),
Some('t') => s.push('\t'),
Some(c) => s.push(c),
},
Ok(Sexp::Str(s))
/// A bare token (symbol/keyword/`t`/`nil`/integer), terminated by
/// whitespace, `(`, `)`, or `"`.
fn parse_atom(&mut self) -> Result<Sexp, ParseError> {
let mut tok = String::new();
while let Some(&c) = self.chars.peek() {
if c.is_whitespace() || c == '(' || c == ')' || c == '"' {
tok.push(c);
if tok.is_empty() {
return Err(ParseError("empty atom".into()));
if let Ok(n) = tok.parse::<i64>() {
return Ok(Sexp::Int(n));
Ok(Sexp::Symbol(tok))
// --- writer ---
/// Renders an `Sexp` to its wire form with correct CL string escaping.
pub fn write(sexp: &Sexp) -> String {
let mut out = String::new();
write_into(&mut out, sexp);
out
fn write_into(out: &mut String, sexp: &Sexp) {
match sexp {
Sexp::Symbol(s) => out.push_str(s),
Sexp::Int(n) => {
let _ = write!(out, "{n}");
Sexp::Str(s) => write_string(out, s),
Sexp::List(items) => {
out.push('(');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(' ');
write_into(out, item);
out.push(')');
/// Writes a CL string literal: wrap in `"` and escape `\` and `"`. Other bytes
/// (including newlines) pass through literally — the reader handles them.
fn write_string(out: &mut String, s: &str) {
out.push('"');
for c in s.chars() {
if c == '"' || c == '\\' {
out.push('\\');
out.push(c);
#[cfg(test)]
mod tests {
use super::*;
fn sym(s: &str) -> Sexp {
Sexp::Symbol(s.into())
#[test]
fn parses_the_emacs_rex_connection_info() {
let v = parse("(:emacs-rex (slynk:connection-info) nil t 1)").unwrap();
let items = v.as_list().unwrap();
assert_eq!(items[0].as_symbol(), Some(":emacs-rex"));
assert_eq!(
items[1].as_list().unwrap()[0].as_symbol(),
Some("slynk:connection-info")
);
assert_eq!(items[2].as_symbol(), Some("nil"));
assert_eq!(items[3].as_symbol(), Some("t"));
assert_eq!(items[4].as_int(), Some(1));
fn parses_channel_send_process() {
let v = parse("(:emacs-channel-send 1 (:process \"(+ 1 2)\"))").unwrap();
assert_eq!(items[0].as_symbol(), Some(":emacs-channel-send"));
assert_eq!(items[1].as_int(), Some(1));
let inner = items[2].as_list().unwrap();
assert_eq!(inner[0].as_symbol(), Some(":process"));
assert_eq!(inner[1].as_str(), Some("(+ 1 2)"));
fn parses_quote_as_quote_list() {
let v = parse("(slynk:slynk-require '(\"a\" \"b\"))").unwrap();
let q = items[1].as_list().unwrap();
assert_eq!(q[0].as_symbol(), Some("quote"));
assert_eq!(q[1].as_list().unwrap().len(), 2);
fn parses_string_with_escapes() {
let v = parse(r#"(:x "a\"b\\c")"#).unwrap();
assert_eq!(v.as_list().unwrap()[1].as_str(), Some("a\"b\\c"));
fn writes_string_with_escaping() {
let s = Sexp::Str("a\"b\\c\nd".into());
assert_eq!(write(&s), "\"a\\\"b\\\\c\nd\"");
fn write_round_trips_a_return() {
let v = Sexp::List(vec![
sym(":return"),
Sexp::List(vec![sym(":ok"), sym("nil")]),
Sexp::Int(2),
]);
assert_eq!(write(&v), "(:return (:ok nil) 2)");
assert_eq!(parse(&write(&v)).unwrap(), v);
fn malformed_inputs_error_not_panic() {
for bad in ["(", ")", "(:a", "\"unterminated", "(a (b (c", "'"] {
assert!(parse(bad).is_err(), "{bad:?} should be a parse error");
fn deep_nesting_is_rejected() {
let deep = "(".repeat(200);
assert!(parse(&deep).is_err());