narju/parse/
mod.rs

1//! S-expression reader and name-resolution lowering to [`Exp`].
2//!
3//! Source text passes through up to three stages here:
4//!
5//! 1. **Reading** ([`parse_one`] / [`parse_all`]): text to [`Sexp`]
6//!    trees. Comments (`;` to end of line) are dropped; the
7//!    quote-family reader macros (`'`, `` ` ``, `,`, `,@`) become
8//!    `quote` / `quasiquote` / `unquote` list heads.
9//! 2. **Surface desugaring**, applied only at source boundaries:
10//!    [`sug`] rewrites the `cadr` family into car/cdr chains
11//!    everywhere, including inside quoted data (Pink source is quoted
12//!    data); [`expand_qq`] turns quasiquote in *loaded files* into
13//!    plain cons/quote source.
14//! 3. **Lowering** ([`lower`]): [`Sexp`] to [`Exp`], resolving each
15//!    name to an environment index against a compile-time name stack.
16//!    Special forms are matched by head symbol and shape here; a list
17//!    that matches nothing becomes a (curried) application.
18//!
19//! Values entered at a REPL prompt follow 1–3; data read by the `read`
20//! primitive stops after 1 and is converted directly to a value with
21//! [`sexp_to_val`].
22
23use smol_str::SmolStr;
24
25use crate::core::{rc_exp, Exp};
26
27// ── S-expression type ─────────────────────────────────────────────────────────
28
29/// A read s-expression — the concrete syntax tree, before lowering.
30///
31/// Unlike [`Exp`] and [`crate::core::Val`], lists here are flat
32/// `Vec<Sexp>`, which is what the shape-matching in lowering wants.
33#[derive(Debug, Clone, PartialEq)]
34pub enum Sexp {
35    /// Integer atom: a maximal symbol-char run matching `-?[0-9]+`.
36    Num(i64),
37    /// Symbol atom.
38    /// SmolStr: inline storage for strings ≤22 bytes (all common symbol names).
39    Sym(SmolStr),
40    /// Parenthesized list.
41    List(Vec<Sexp>),
42    /// The atom `nil`, classified at read time.
43    Nil,
44}
45
46// ── Parser ────────────────────────────────────────────────────────────────────
47//
48// Hand-written explicit-stack parser: paren nesting lives in a heap `Vec`
49// of frames, never on the native call stack. The previous chumsky
50// combinator recursed one (large, debug-mode) frame cycle per nesting
51// level, which overflowed the 2 MiB default test-thread stack once
52// lib/pink-forms.naj pushed source depth past ~70 parens.
53//
54// Grammar (unchanged): atoms are maximal runs of symbol chars, classified
55// as Num iff `-?[0-9]+` (else Sym; `nil` → Nil); `'`/`` ` `` → quote /
56// quasiquote; `,` and `,@` both → unquote; `;` comments to end of line.
57
58/// Character set for atoms: alphanumerics plus Scheme-ish punctuation.
59fn is_sym_char(c: char) -> bool {
60    c.is_alphanumeric()
61        || matches!(
62            c,
63            '+' | '-' | '*' | '/' | '?' | '!' | '<' | '>' | '=' | '_' | '#' | '.'
64        )
65}
66
67/// Classify a completed atom token: `nil`, then number, else symbol.
68/// A numeric-looking token that overflows `i64` falls back to symbol.
69fn classify_atom(tok: &str) -> Sexp {
70    if tok == "nil" {
71        return Sexp::Nil;
72    }
73    let digits = tok.strip_prefix('-').unwrap_or(tok);
74    if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) {
75        if let Ok(n) = tok.parse::<i64>() {
76            return Sexp::Num(n);
77        }
78    }
79    Sexp::Sym(SmolStr::new(tok))
80}
81
82/// A finished value gets wrapped in any pending quote-family prefixes
83/// (innermost = most recently seen, so pop order is correct), then lands
84/// in the enclosing list frame or the top-level output.
85fn finish(
86    mut v: Sexp,
87    pending: &mut Vec<&'static str>,
88    stack: &mut Vec<(Vec<Sexp>, Vec<&'static str>)>,
89    out: &mut Vec<Sexp>,
90) {
91    while let Some(tag) = pending.pop() {
92        v = Sexp::List(vec![Sexp::Sym(tag.into()), v]);
93    }
94    match stack.last_mut() {
95        Some((items, _)) => items.push(v),
96        None => out.push(v),
97    }
98}
99
100/// Parse top-level forms iteratively. If `stop_after_one`, return as soon
101/// as the first complete form closes (trailing input ignored — matching
102/// the old `parse_one`, which ran the combinator without `end()`).
103fn parse_forms(src: &str, stop_after_one: bool) -> Result<Vec<Sexp>, String> {
104    let mut out: Vec<Sexp> = Vec::new();
105    // Each frame: (items so far, quote-prefixes that wrap the list on close).
106    let mut stack: Vec<(Vec<Sexp>, Vec<&'static str>)> = Vec::new();
107    // Quote-family prefixes awaiting the next value at the current point.
108    let mut pending: Vec<&'static str> = Vec::new();
109    let mut i = 0;
110    while i < src.len() {
111        let c = src[i..].chars().next().expect("i is on a char boundary");
112        if c.is_whitespace() {
113            i += c.len_utf8();
114            continue;
115        }
116        match c {
117            ';' => {
118                i += src[i..].find('\n').map_or(src.len() - i, |n| n + 1);
119            }
120            '(' => {
121                stack.push((Vec::new(), std::mem::take(&mut pending)));
122                i += 1;
123            }
124            ')' => {
125                if let Some(tag) = pending.last() {
126                    return Err(format!("expected expression after {tag}, found ')'"));
127                }
128                let (items, wrappers) = stack
129                    .pop()
130                    .ok_or_else(|| "unexpected ')'".to_string())?;
131                pending = wrappers;
132                finish(Sexp::List(items), &mut pending, &mut stack, &mut out);
133                i += 1;
134                if stop_after_one && stack.is_empty() {
135                    return Ok(out);
136                }
137            }
138            '\'' => {
139                pending.push("quote");
140                i += 1;
141            }
142            '`' => {
143                pending.push("quasiquote");
144                i += 1;
145            }
146            ',' => {
147                i += 1;
148                if src[i..].starts_with('@') {
149                    i += 1;
150                }
151                pending.push("unquote");
152            }
153            c if is_sym_char(c) => {
154                let start = i;
155                while let Some(ch) = src[i..].chars().next() {
156                    if !is_sym_char(ch) {
157                        break;
158                    }
159                    i += ch.len_utf8();
160                }
161                finish(classify_atom(&src[start..i]), &mut pending, &mut stack, &mut out);
162                if stop_after_one && stack.is_empty() {
163                    return Ok(out);
164                }
165            }
166            other => return Err(format!("unexpected character '{other}'")),
167        }
168    }
169    if !stack.is_empty() {
170        return Err(format!(
171            "unexpected end of input: {} unclosed '('",
172            stack.len()
173        ));
174    }
175    if let Some(tag) = pending.last() {
176        return Err(format!("unexpected end of input after {tag}"));
177    }
178    Ok(out)
179}
180
181// ── Surface desugaring ────────────────────────────────────────────────────────
182
183/// pink.scm's `sug` pre-pass (pink.scm:11): desugar `cadr`/`caddr`/`cadddr`
184/// into car/cdr chains over the whole surface tree — **including quoted
185/// data**, since Pink source is itself a quoted list. Oracle under Chez:
186/// `(sug '(quote (cadr x)))` => `(quote (car (cdr x)))`.
187///
188/// Applied exactly once, at the surface-source boundary
189/// (`TopLevel::eval_exp`) — not inside `lower`'s recursion, and not on the
190/// `read` primitive's runtime data (the reference sugs source only).
191///
192/// ```rust
193/// use narju::parse::{parse_one, sug};
194///
195/// let form = parse_one("(cadr xs)").unwrap();
196/// assert_eq!(sug(&form), parse_one("(car (cdr xs))").unwrap());
197/// ```
198pub fn sug(sexp: &Sexp) -> Sexp {
199    if let Sexp::List(items) = sexp {
200        if let [Sexp::Sym(op), arg] = items.as_slice() {
201            let cdrs = match op.as_str() {
202                "cadr" => Some(1),
203                "caddr" => Some(2),
204                "cadddr" => Some(3),
205                _ => None,
206            };
207            if let Some(cdrs) = cdrs {
208                let mut acc = sug(arg);
209                for _ in 0..cdrs {
210                    acc = Sexp::List(vec![Sexp::Sym("cdr".into()), acc]);
211                }
212                return Sexp::List(vec![Sexp::Sym("car".into()), acc]);
213            }
214        }
215        return Sexp::List(items.iter().map(sug).collect());
216    }
217    sexp.clone()
218}
219
220// ── Name resolution: Sexp -> Exp ─────────────────────────────────────────────
221
222/// Lower a read s-expression to an [`Exp`], resolving names against
223/// `names` — the compile-time stack of binders in scope, which mirrors
224/// the runtime environment slot-for-slot. Innermost binding wins
225/// (`rposition`), so shadowing works; an unresolved name is an error at
226/// lowering time, not runtime.
227///
228/// `names` is borrowed mutably because binders push onto it during
229/// their body's lowering; it is restored before returning. Callers
230/// evaluating closed top-level forms pass a fresh `Vec`.
231pub fn lower(sexp: &Sexp, names: &mut Vec<SmolStr>) -> Result<Exp, String> {
232    match sexp {
233        Sexp::Num(n) => Ok(Exp::Lit(*n)),
234        Sexp::Nil => Ok(Exp::Nil),
235
236        Sexp::Sym(s) => {
237            if s == "#t" { return Ok(Exp::Lit(1)); }
238            if s == "#f" { return Ok(Exp::Lit(0)); }
239            names
240                .iter()
241                .rposition(|n| n == s)
242                .map(Exp::Var)
243                .ok_or_else(|| format!("unbound variable: {s}"))
244        }
245
246        Sexp::List(items) => lower_list(items, names).map_err(|e| {
247            if e.starts_with("unbound") {
248                let head = items.first().map(|s| format!("{s:?}")).unwrap_or_default();
249                format!("{e} [in list starting with {head}]")
250            } else {
251                e
252            }
253        }),
254    }
255}
256
257/// Lower a list form: special forms first (matched by head symbol and
258/// arity), then binary application, then the n-ary case, which curries
259/// left-to-right — `(f a b)` is `((f a) b)`.
260///
261/// A special-form head with the wrong arity falls through to the
262/// application arms, matching pink.scm's behavior — e.g. `(car)` is an
263/// application of the variable `car`, and errors as unbound. `let` is
264/// the one exception with an explicit arity error, since a malformed
265/// `let` otherwise fails with a confusing unbound-variable message.
266fn lower_list(items: &[Sexp], names: &mut Vec<SmolStr>) -> Result<Exp, String> {
267    match items {
268        [] => Err("empty list expression".into()),
269
270        [Sexp::Sym(q), rest @ ..] if q == "quote" => {
271            if rest.len() != 1 {
272                return Err("quote takes exactly one argument".into());
273            }
274            lower_quote(&rest[0])
275        }
276
277        [Sexp::Sym(q), rest @ ..] if q == "quasiquote" => {
278            if rest.len() != 1 {
279                return Err("quasiquote takes exactly one argument".into());
280            }
281            lower_quasiquote(&rest[0], names)
282        }
283
284        [Sexp::Sym(kw), Sexp::Sym(f), Sexp::Sym(x), body] if kw == "lambda" => {
285            let base = names.len();
286            names.push(f.clone());
287            names.push(x.clone());
288            // Truncate before `?`: an error mid-body must not leave the
289            // binders on the caller's name stack.
290            let body_exp = lower(body, names);
291            names.truncate(base);
292            Ok(Exp::Lam(rc_exp(body_exp?)))
293        }
294
295        [Sexp::Sym(kw), Sexp::Sym(x), e, body] if kw == "let" => {
296            let e_exp = lower(e, names)?;
297            let base = names.len();
298            names.push(x.clone());
299            let body_exp = lower(body, names);
300            names.truncate(base);
301            Ok(Exp::Let(rc_exp(e_exp), rc_exp(body_exp?)))
302        }
303
304        [Sexp::Sym(kw), rest @ ..] if kw == "let" => Err(format!(
305            "let requires (let name init body), got {} args after let",
306            rest.len()
307        )),
308
309        [Sexp::Sym(kw), c, t, e] if kw == "if" => Ok(Exp::If(
310            rc_exp(lower(c, names)?),
311            rc_exp(lower(t, names)?),
312            rc_exp(lower(e, names)?),
313        )),
314
315        [Sexp::Sym(op), a] if op == "number?" => Ok(Exp::IsNum(rc_exp(lower(a, names)?))),
316        [Sexp::Sym(op), a] if op == "symbol?" => Ok(Exp::IsSym(rc_exp(lower(a, names)?))),
317        [Sexp::Sym(op), a] if op == "null?" => {
318            Ok(Exp::IsNil(rc_exp(lower(a, names)?)))
319        }
320        [Sexp::Sym(op), a] if op == "pair?" => Ok(Exp::IsPair(rc_exp(lower(a, names)?))),
321        [Sexp::Sym(op), a, b] if op == "code?" => Ok(Exp::IsCode(
322            rc_exp(lower(a, names)?),
323            rc_exp(lower(b, names)?),
324        )),
325        [Sexp::Sym(op), a] if op == "car" => Ok(Exp::Car(rc_exp(lower(a, names)?))),
326        [Sexp::Sym(op), a] if op == "cdr" => Ok(Exp::Cdr(rc_exp(lower(a, names)?))),
327
328        [Sexp::Sym(op), a, b] if op == "cons" => Ok(Exp::Cons(
329            rc_exp(lower(a, names)?),
330            rc_exp(lower(b, names)?),
331        )),
332        [Sexp::Sym(op), a, b] if op == "+" => Ok(Exp::Plus(
333            rc_exp(lower(a, names)?),
334            rc_exp(lower(b, names)?),
335        )),
336        [Sexp::Sym(op), a, b] if op == "-" => Ok(Exp::Minus(
337            rc_exp(lower(a, names)?),
338            rc_exp(lower(b, names)?),
339        )),
340        [Sexp::Sym(op), a, b] if op == "*" => Ok(Exp::Times(
341            rc_exp(lower(a, names)?),
342            rc_exp(lower(b, names)?),
343        )),
344        [Sexp::Sym(op), a, b] if op == "eq?" => Ok(Exp::Eq(
345            rc_exp(lower(a, names)?),
346            rc_exp(lower(b, names)?),
347        )),
348
349        [Sexp::Sym(kw), e] if kw == "lift" => Ok(Exp::Lift(rc_exp(lower(e, names)?))),
350        [Sexp::Sym(kw), b, e] if kw == "run" => Ok(Exp::Run(
351            rc_exp(lower(b, names)?),
352            rc_exp(lower(e, names)?),
353        )),
354        [Sexp::Sym(kw), e1, e2] if kw == "lift-ref" => Ok(Exp::LiftRef(
355            rc_exp(lower(e1, names)?),
356            rc_exp(lower(e2, names)?),
357        )),
358        [Sexp::Sym(kw), b, v] if kw == "log" => Ok(Exp::Log(
359            rc_exp(lower(b, names)?),
360            rc_exp(lower(v, names)?),
361        )),
362        // catch is the single restricted floor primitive for error handling.
363        // It is written for the boot script (lib/purple.naj) — not intended
364        // for user code. It IS parseable here so the boot script can be
365        // loaded, but user programs should use the REPL's error handling via
366        // define/try at the Pink level rather than calling catch directly.
367        [Sexp::Sym(kw), e] if kw == "catch" => Ok(Exp::Catch(rc_exp(lower(e, names)?))),
368        [Sexp::Sym(kw), e] if kw == "throw" => Ok(Exp::Throw(rc_exp(lower(e, names)?))),
369        [Sexp::Sym(kw), v] if kw == "cell-new" => {
370            Ok(Exp::CellNew(rc_exp(lower(v, names)?)))
371        }
372        [Sexp::Sym(kw), c] if kw == "cell-read" => {
373            Ok(Exp::CellRead(rc_exp(lower(c, names)?)))
374        }
375        [Sexp::Sym(kw), c, v] if kw == "cell-set!" => Ok(Exp::CellSet(
376            rc_exp(lower(c, names)?),
377            rc_exp(lower(v, names)?),
378        )),
379        [Sexp::Sym(kw), p] if kw == "read" => Ok(Exp::Read(rc_exp(lower(p, names)?))),
380        [Sexp::Sym(kw), p] if kw == "read-file" => {
381            Ok(Exp::ReadFile(rc_exp(lower(p, names)?)))
382        }
383        [Sexp::Sym(kw), e] if kw == "print" => Ok(Exp::Print(rc_exp(lower(e, names)?))),
384        [Sexp::Sym(kw), el, ex] if kw == "evalms" => Ok(Exp::Evalms(
385            rc_exp(lower(el, names)?),
386            rc_exp(lower(ex, names)?),
387        )),
388        [Sexp::Sym(kw), e, env_list] if kw == "trans" => Ok(Exp::Trans(
389            rc_exp(lower(e, names)?),
390            rc_exp(lower(env_list, names)?),
391        )),
392
393        [f, arg] => Ok(Exp::App(
394            rc_exp(lower(f, names)?),
395            rc_exp(lower(arg, names)?),
396        )),
397
398        [f, args @ ..] => {
399            let mut acc = lower(f, names)?;
400            for arg in args {
401                acc = Exp::App(rc_exp(acc), rc_exp(lower(arg, names)?));
402            }
403            Ok(acc)
404        }
405    }
406}
407
408// ── Quasiquote expansion ──────────────────────────────────────────────────────
409
410/// Lower a quasiquoted datum: atoms become literals, `(unquote e)`
411/// splices `e` as a host expression, lists become cons-chains.
412fn lower_quasiquote(sexp: &Sexp, names: &mut Vec<SmolStr>) -> Result<Exp, String> {
413    match sexp {
414        Sexp::Num(n) => Ok(Exp::Lit(*n)),
415        Sexp::Nil => Ok(Exp::Nil),
416        Sexp::Sym(s) => Ok(Exp::Sym(s.clone())),
417
418        Sexp::List(items) => {
419            if let [Sexp::Sym(tag), inner] = items.as_slice() {
420                if tag == "unquote" {
421                    return lower(inner, names);
422                }
423            }
424            lower_qq_list(items, names)
425        }
426    }
427}
428
429/// Build the cons-chain for a quasiquoted list by folding right-to-left.
430///
431/// Iterative on the list axis so deep quasiquoted lists (e.g. the spliced
432/// `pink-tie-src` in `lib/purple.naj`) can't overflow the native stack.
433/// Per-item dispatch into `lower` / `lower_quasiquote` is still recursive
434/// on Sexp *tree* depth, but real inputs are wide-and-shallow there so
435/// that recursion is not at risk in practice.
436fn lower_qq_list(items: &[Sexp], names: &mut Vec<SmolStr>) -> Result<Exp, String> {
437    let mut acc = Exp::Nil;
438    for item in items.iter().rev() {
439        // An `,unquote` head splices its argument as a host expression.
440        // Anything else recurses as a quasiquoted datum.
441        let head_exp = match item {
442            Sexp::List(inner) => match inner.as_slice() {
443                [Sexp::Sym(tag), splice_expr] if tag == "unquote" => {
444                    lower(splice_expr, names)?
445                }
446                _ => lower_quasiquote(item, names)?,
447            },
448            _ => lower_quasiquote(item, names)?,
449        };
450        acc = Exp::Cons(rc_exp(head_exp), rc_exp(acc));
451    }
452    Ok(acc)
453}
454
455// ── Read-time quasiquote expansion (read-file only) ──────────────────────────
456
457/// Expand quasiquote in loaded `.naj` source into plain cons/quote *source*.
458///
459/// Like `sug`, this is a read-time source transform, applied by the
460/// `read-file` primitive only — the `read` primitive stays un-transformed.
461/// The reference gets quasiquote from Scheme (mk.scm is a Scheme-level
462/// template over Pink source); narju's floor is the Scheme analogue, so
463/// loaded libraries get the same affordance without touching pink-poly.
464///
465/// `(quote x)` is opaque outside quasiquote. Inside a template, a quoted
466/// form is ordinary list data — `'b` in a template builds the datum
467/// `(quote b)`, matching Scheme, where unquotes under it still splice.
468pub fn expand_qq(sexp: &Sexp) -> Result<Sexp, String> {
469    match sexp {
470        Sexp::List(items) => {
471            if let [Sexp::Sym(tag), inner] = items.as_slice() {
472                match tag.as_str() {
473                    "quote" => return Ok(sexp.clone()),
474                    "quasiquote" => return qq_datum(inner),
475                    _ => {}
476                }
477            }
478            let expanded = items.iter().map(expand_qq).collect::<Result<Vec<_>, _>>()?;
479            Ok(Sexp::List(expanded))
480        }
481        _ => Ok(sexp.clone()),
482    }
483}
484
485/// Turn a quasiquoted datum into source that reconstructs it: atoms become
486/// `(quote atom)`, `(unquote e)` splices `e` (itself qq-expanded), lists
487/// become right-folded `(cons …)` chains ending in `(quote ())`.
488fn qq_datum(sexp: &Sexp) -> Result<Sexp, String> {
489    match sexp {
490        Sexp::Num(_) => Ok(sexp.clone()),
491        Sexp::Sym(_) | Sexp::Nil => Ok(quote_datum(sexp)),
492        Sexp::List(items) => {
493            if let [Sexp::Sym(tag), inner] = items.as_slice() {
494                match tag.as_str() {
495                    "unquote" => return expand_qq(inner),
496                    "quasiquote" => {
497                        return Err("nested quasiquote unsupported in loaded source".into())
498                    }
499                    _ => {}
500                }
501            }
502            let mut acc = quote_datum(&Sexp::Nil);
503            for item in items.iter().rev() {
504                let head = qq_datum(item)?;
505                acc = Sexp::List(vec![Sexp::Sym("cons".into()), head, acc]);
506            }
507            Ok(acc)
508        }
509    }
510}
511
512/// Wrap a datum as `(quote datum)` source.
513fn quote_datum(sexp: &Sexp) -> Sexp {
514    Sexp::List(vec![Sexp::Sym("quote".into()), sexp.clone()])
515}
516
517// ── Quoted datum lowering ─────────────────────────────────────────────────────
518
519/// Lower a quoted datum: atoms become literal expressions, lists
520/// become cons-chains of quoted elements. No names are resolved —
521/// everything under `quote` is data.
522fn lower_quote(sexp: &Sexp) -> Result<Exp, String> {
523    match sexp {
524        Sexp::Sym(s) => Ok(Exp::Sym(s.clone())),
525        Sexp::Num(n) => Ok(Exp::Lit(*n)),
526        Sexp::Nil => Ok(Exp::Nil),
527        Sexp::List(items) => lower_quoted_list(items),
528    }
529}
530
531/// Build the cons-chain for a quoted list by folding right-to-left.
532///
533/// Same iteration shape as `lower_qq_list`, minus the unquote dispatch
534/// — quoted contexts treat everything as data. Per-item recursion into
535/// `lower_quote` is still tree-depth-bounded, which is fine for realistic
536/// inputs.
537fn lower_quoted_list(items: &[Sexp]) -> Result<Exp, String> {
538    let mut acc = Exp::Nil;
539    for item in items.iter().rev() {
540        let head = lower_quote(item)?;
541        acc = Exp::Cons(rc_exp(head), rc_exp(acc));
542    }
543    Ok(acc)
544}
545
546// ── Runtime helpers ───────────────────────────────────────────────────────────
547
548/// Convert a read s-expression directly into a runtime value — lists
549/// become `Tup` chains, no lowering, no name resolution. This is how
550/// the `read` and `read-file` primitives hand parsed input to the
551/// evaluator as data.
552pub fn sexp_to_val(sexp: &Sexp) -> crate::core::Val {
553    use crate::core::{tup, Val};
554    match sexp {
555        Sexp::Num(n) => Val::Cst(*n),
556        Sexp::Sym(s) => Val::Sym(s.clone()),
557        Sexp::Nil => Val::Nil,
558        Sexp::List(items) => items.iter().rev().fold(Val::Nil, |acc, item| {
559            tup(sexp_to_val(item), acc)
560        }),
561    }
562}
563
564/// Read the first complete form from `src`, ignoring anything after it.
565pub fn parse_one(src: &str) -> Result<Sexp, String> {
566    let mut forms = parse_forms(src.trim(), true)?;
567    forms
568        .pop()
569        .ok_or_else(|| "unexpected end of input: expected an s-expression".to_string())
570}
571
572/// Parse every top-level form in `src`. String-error twin of
573/// `repl::parse_all`, callable from the evaluator (`read-file`) without a
574/// layering inversion.
575pub fn parse_all(src: &str) -> Result<Vec<Sexp>, String> {
576    if src.trim().is_empty() {
577        return Ok(vec![]);
578    }
579    parse_forms(src, false)
580}