Skip to main content

tatara_lisp/
spanned.rs

1//! Parallel spanned AST.
2//!
3//! Mirror of `ast::Sexp` where every node carries a `Span` back to its
4//! source. Produced by `reader::read_spanned`, consumed by
5//! `spanned_expand::SpannedExpander` and by downstream evaluators that want
6//! to report error locations.
7//!
8//! The plain `Sexp` AST is unaffected. `Spanned::to_sexp` projects away the
9//! span information when a consumer wants the canonical spanless form.
10
11use crate::ast::{Atom, QuoteForm, Sexp};
12use crate::span::Span;
13
14/// An S-expression node with source position.
15#[derive(Clone, Debug, PartialEq)]
16pub struct Spanned {
17    pub span: Span,
18    pub form: SpannedForm,
19}
20
21/// Same variants as `Sexp`, but children are `Spanned` so every subtree
22/// carries its own position.
23#[derive(Clone, Debug, PartialEq)]
24pub enum SpannedForm {
25    Nil,
26    Atom(Atom),
27    List(Vec<Spanned>),
28    Quote(Box<Spanned>),
29    Quasiquote(Box<Spanned>),
30    Unquote(Box<Spanned>),
31    UnquoteSplice(Box<Spanned>),
32}
33
34impl SpannedForm {
35    /// Project a typed [`QuoteForm`] marker into its `SpannedForm` wrapper
36    /// variant — the span-carrying dual of [`QuoteForm::wrap`].
37    ///
38    /// The reader's spanned quote arm routes through here for the same
39    /// reason its plain arm routes through `QuoteForm::wrap`: the
40    /// (marker, constructor) pairing binds at ONE site on the closed-set
41    /// algebra rather than at four per-arm constructor literals, so adding
42    /// a fifth homoiconic prefix extends [`QuoteForm`] AND both wrap
43    /// tables in lockstep, with rustc binding the extension through
44    /// exhaustiveness over the closed enum.
45    ///
46    /// Duality law, pinned by
47    /// `spanned_form_wrap_is_the_span_carrying_dual_of_quote_form_wrap`:
48    /// `SpannedForm::wrap(qf, s).to_sexp() == qf.wrap(s.to_sexp())` for
49    /// every `qf` in `QuoteForm::ALL`. This is a real equivalence over the
50    /// closed set, not the `assert!(true)`-shaped claim the pre-
51    /// consolidation spanned expander made about its own duplicate.
52    #[must_use]
53    pub fn wrap(qf: QuoteForm, inner: Spanned) -> Self {
54        let boxed = Box::new(inner);
55        match qf {
56            QuoteForm::Quote => Self::Quote(boxed),
57            QuoteForm::Quasiquote => Self::Quasiquote(boxed),
58            QuoteForm::Unquote => Self::Unquote(boxed),
59            QuoteForm::UnquoteSplice => Self::UnquoteSplice(boxed),
60        }
61    }
62}
63
64impl Spanned {
65    pub fn new(span: Span, form: SpannedForm) -> Self {
66        Self { span, form }
67    }
68
69    /// Synthetic nil — useful as a placeholder in macro expansion when no
70    /// real span is available.
71    pub fn synthetic_nil() -> Self {
72        Self {
73            span: Span::synthetic(),
74            form: SpannedForm::Nil,
75        }
76    }
77
78    /// Project away span information. Allocates a full `Sexp` tree.
79    pub fn to_sexp(&self) -> Sexp {
80        match &self.form {
81            SpannedForm::Nil => Sexp::Nil,
82            SpannedForm::Atom(a) => Sexp::Atom(a.clone()),
83            SpannedForm::List(xs) => Sexp::List(xs.iter().map(Spanned::to_sexp).collect()),
84            SpannedForm::Quote(inner) => Sexp::Quote(Box::new(inner.to_sexp())),
85            SpannedForm::Quasiquote(inner) => Sexp::Quasiquote(Box::new(inner.to_sexp())),
86            SpannedForm::Unquote(inner) => Sexp::Unquote(Box::new(inner.to_sexp())),
87            SpannedForm::UnquoteSplice(inner) => Sexp::UnquoteSplice(Box::new(inner.to_sexp())),
88        }
89    }
90
91    /// Lift a plain `Sexp` to a `Spanned` with every node marked synthetic.
92    /// Useful when a macro template generates literal structure that has no
93    /// user-source origin.
94    pub fn from_sexp_synthetic(s: &Sexp) -> Self {
95        Self::from_sexp_at(s, Span::synthetic())
96    }
97
98    /// Lift a plain `Sexp` to a `Spanned` with every node assigned the given
99    /// span. Used by the expander to stamp macro-generated subtrees with
100    /// the call-site span so errors point somewhere useful.
101    pub fn from_sexp_at(s: &Sexp, span: Span) -> Self {
102        let form = match s {
103            Sexp::Nil => SpannedForm::Nil,
104            Sexp::Atom(a) => SpannedForm::Atom(a.clone()),
105            Sexp::List(xs) => {
106                SpannedForm::List(xs.iter().map(|x| Spanned::from_sexp_at(x, span)).collect())
107            }
108            Sexp::Quote(inner) => SpannedForm::Quote(Box::new(Spanned::from_sexp_at(inner, span))),
109            Sexp::Quasiquote(inner) => {
110                SpannedForm::Quasiquote(Box::new(Spanned::from_sexp_at(inner, span)))
111            }
112            Sexp::Unquote(inner) => {
113                SpannedForm::Unquote(Box::new(Spanned::from_sexp_at(inner, span)))
114            }
115            Sexp::UnquoteSplice(inner) => {
116                SpannedForm::UnquoteSplice(Box::new(Spanned::from_sexp_at(inner, span)))
117            }
118        };
119        Spanned { span, form }
120    }
121
122    // ── Convenience accessors mirroring Sexp ─────────────────────────
123
124    pub fn is_list(&self) -> bool {
125        matches!(self.form, SpannedForm::List(_))
126    }
127    pub fn as_list(&self) -> Option<&[Spanned]> {
128        match &self.form {
129            SpannedForm::List(xs) => Some(xs),
130            _ => None,
131        }
132    }
133    pub fn as_symbol(&self) -> Option<&str> {
134        match &self.form {
135            SpannedForm::Atom(Atom::Symbol(s)) => Some(s),
136            _ => None,
137        }
138    }
139    pub fn as_keyword(&self) -> Option<&str> {
140        match &self.form {
141            SpannedForm::Atom(Atom::Keyword(s)) => Some(s),
142            _ => None,
143        }
144    }
145    pub fn as_string(&self) -> Option<&str> {
146        match &self.form {
147            SpannedForm::Atom(Atom::Str(s)) => Some(s),
148            _ => None,
149        }
150    }
151    pub fn as_int(&self) -> Option<i64> {
152        match &self.form {
153            SpannedForm::Atom(Atom::Int(n)) => Some(*n),
154            _ => None,
155        }
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn to_sexp_roundtrip_preserves_structure() {
165        let s = Spanned::new(
166            Span::new(0, 10),
167            SpannedForm::List(vec![
168                Spanned::new(
169                    Span::new(1, 4),
170                    SpannedForm::Atom(Atom::Symbol("foo".into())),
171                ),
172                Spanned::new(Span::new(5, 6), SpannedForm::Atom(Atom::Int(42))),
173            ]),
174        );
175        let plain = s.to_sexp();
176        assert_eq!(plain, Sexp::List(vec![Sexp::symbol("foo"), Sexp::int(42)]));
177    }
178
179    #[test]
180    fn from_sexp_synthetic_marks_all_nodes() {
181        let s = Sexp::List(vec![Sexp::symbol("a"), Sexp::List(vec![Sexp::int(1)])]);
182        let lifted = Spanned::from_sexp_synthetic(&s);
183        assert!(lifted.span.is_synthetic());
184        let SpannedForm::List(children) = &lifted.form else {
185            panic!("expected list")
186        };
187        assert!(children[0].span.is_synthetic());
188        let SpannedForm::List(inner) = &children[1].form else {
189            panic!("expected inner list")
190        };
191        assert!(inner[0].span.is_synthetic());
192    }
193
194    #[test]
195    fn spanned_form_wrap_is_the_span_carrying_dual_of_quote_form_wrap() {
196        // The duality law `SpannedForm::wrap` documents, swept over the
197        // whole closed set rather than sampled. A regression that crosses
198        // two arms (`Quote` built where `Quasiquote` was asked for) fails
199        // here; the pre-consolidation spanned expander's equivalent claim
200        // could not, because it compared a path against itself.
201        let inner_plain = Sexp::symbol("payload");
202        for qf in QuoteForm::ALL {
203            let inner = Spanned::from_sexp_at(&inner_plain, Span::new(1, 8));
204            let wrapped = Spanned::new(Span::new(0, 8), SpannedForm::wrap(qf, inner));
205            assert_eq!(
206                wrapped.to_sexp(),
207                qf.wrap(inner_plain.clone()),
208                "QuoteForm::{qf:?} — SpannedForm::wrap drifted from QuoteForm::wrap"
209            );
210        }
211    }
212
213    #[test]
214    fn from_sexp_at_stamps_span_on_every_node() {
215        let s = Sexp::List(vec![Sexp::symbol("a"), Sexp::int(1)]);
216        let sp = Span::new(10, 20);
217        let lifted = Spanned::from_sexp_at(&s, sp);
218        assert_eq!(lifted.span, sp);
219        let SpannedForm::List(children) = &lifted.form else {
220            panic!()
221        };
222        assert_eq!(children[0].span, sp);
223        assert_eq!(children[1].span, sp);
224    }
225}