Skip to main content

scheme_edit/
cst.rs

1/// One element of a list body or the top level. Whitespace and comments
2/// are items like any other, so emitting a document is pure concatenation.
3#[derive(Debug, Clone, PartialEq, Eq)]
4pub enum Item {
5    /// Exact run of whitespace as it appeared, e.g. "  \n\t".
6    Ws(String),
7    /// ";..." up to but excluding the newline (the newline is Ws).
8    LineComment(String),
9    /// "#|...|#" including delimiters; nesting preserved verbatim.
10    BlockComment(String),
11    /// "#;" plus the complete commented-out datum, verbatim.
12    DatumComment(String),
13    Node(Node),
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum ListKind {
18    Paren,   // ( )
19    Bracket, // [ ]
20    Vector,  // #( )
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum Node {
25    List {
26        kind: ListKind,
27        items: Vec<Item>,
28    },
29    /// Symbols, numbers, booleans (#t/#f), chars (#\x), keywords (#:k), and `.`
30    Atom(String),
31    /// Raw string literal INCLUDING surrounding quotes, escapes as written.
32    Str(String),
33    /// prefix is one of: ' ` , ,@ #' #` #, #,@ #~ #$ #$@ #+ (gexp syntax).
34    Prefixed {
35        prefix: String,
36        inner: Box<Node>,
37    },
38}
39
40impl Node {
41    /// Verbatim source text of this node.
42    pub fn to_source(&self) -> String {
43        self.to_string()
44    }
45
46    /// For a list, the text of its first data child if that child is an Atom.
47    pub fn head_symbol(&self) -> Option<&str> {
48        match self {
49            Node::List { items, .. } => {
50                for item in items {
51                    if let Item::Node(n) = item {
52                        return match n {
53                            Node::Atom(a) => Some(a.as_str()),
54                            _ => None,
55                        };
56                    }
57                }
58                None
59            }
60            _ => None,
61        }
62    }
63
64    /// Symbol text, drilling through `'sym` and `(quote sym)`.
65    pub fn as_symbol(&self) -> Option<&str> {
66        match self {
67            Node::Atom(a) => {
68                let first = a.chars().next()?;
69                if first.is_ascii_digit() || first == '#' {
70                    None
71                } else {
72                    Some(a.as_str())
73                }
74            }
75            Node::Prefixed { prefix, inner } if prefix.trim() == "'" => inner.as_symbol(),
76            Node::List { .. } if self.head_symbol() == Some("quote") => {
77                let args: Vec<&Node> = self.list_nodes().skip(1).collect();
78                match args.as_slice() {
79                    [Node::Atom(a)] => Some(a.as_str()),
80                    _ => None,
81                }
82            }
83            _ => None,
84        }
85    }
86
87    /// Unescaped contents of a string literal.
88    pub fn as_string_lit(&self) -> Option<String> {
89        let Node::Str(raw) = self else { return None };
90        // Str is a public variant; guard against hand-built malformed nodes.
91        if raw.len() < 2 || !raw.starts_with('"') || !raw.ends_with('"') {
92            return None;
93        }
94        let inner = &raw[1..raw.len() - 1];
95        let mut out = String::with_capacity(inner.len());
96        let mut chars = inner.chars();
97        while let Some(c) = chars.next() {
98            if c == '\\' {
99                // Permissive: any escaped char yields itself, matching the
100                // subset of guile escapes that appear in guix files.
101                if let Some(next) = chars.next() {
102                    out.push(next);
103                }
104            } else {
105                out.push(c);
106            }
107        }
108        Some(out)
109    }
110
111    /// Data children of a list, trivia skipped. Empty for non-lists.
112    pub fn list_nodes(&self) -> impl Iterator<Item = &Node> {
113        let items: &[Item] = match self {
114            Node::List { items, .. } => items,
115            _ => &[],
116        };
117        items.iter().filter_map(|i| match i {
118            Item::Node(n) => Some(n),
119            _ => None,
120        })
121    }
122}
123
124impl std::fmt::Display for Item {
125    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
126        match self {
127            Item::Ws(s) | Item::LineComment(s) | Item::BlockComment(s) | Item::DatumComment(s) => {
128                f.write_str(s)
129            }
130            Item::Node(n) => n.fmt(f),
131        }
132    }
133}
134
135impl std::fmt::Display for Node {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        match self {
138            Node::Atom(s) | Node::Str(s) => f.write_str(s),
139            Node::List { kind, items } => {
140                f.write_str(match kind {
141                    ListKind::Paren => "(",
142                    ListKind::Bracket => "[",
143                    ListKind::Vector => "#(",
144                })?;
145                for item in items {
146                    item.fmt(f)?;
147                }
148                f.write_str(match kind {
149                    ListKind::Bracket => "]",
150                    _ => ")",
151                })
152            }
153            Node::Prefixed { prefix, inner } => {
154                f.write_str(prefix)?;
155                inner.fmt(f)
156            }
157        }
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use crate::Document;
164
165    #[test]
166    fn head_symbol_skips_trivia() {
167        let doc = Document::parse("( ;; c\n  channel (name 'guix))").unwrap();
168        let form = doc.forms().next().unwrap();
169        assert_eq!(form.head_symbol(), Some("channel"));
170    }
171
172    #[test]
173    fn as_symbol_drills_quote_forms() {
174        let doc = Document::parse("'guix (quote nonguix) plain \"str\"").unwrap();
175        let f: Vec<_> = doc.forms().collect();
176        assert_eq!(f[0].as_symbol(), Some("guix"));
177        assert_eq!(f[1].as_symbol(), Some("nonguix"));
178        assert_eq!(f[2].as_symbol(), Some("plain"));
179        assert_eq!(f[3].as_symbol(), None);
180    }
181
182    #[test]
183    fn as_string_lit_rejects_malformed_str_nodes() {
184        use crate::Node;
185        assert_eq!(Node::Str(String::new()).as_string_lit(), None);
186        assert_eq!(Node::Str("x".into()).as_string_lit(), None);
187        assert_eq!(Node::Str("\"".into()).as_string_lit(), None);
188        assert_eq!(Node::Str("\"unterminated".into()).as_string_lit(), None);
189    }
190
191    #[test]
192    fn as_string_lit_unescapes() {
193        let doc = Document::parse(r#""a\"b\\c""#).unwrap();
194        assert_eq!(
195            doc.forms().next().unwrap().as_string_lit().unwrap(),
196            r#"a"b\c"#
197        );
198    }
199}