scheme-edit 0.1.0

Lossless Scheme S-expression parser and editor (toml_edit-style CST)
Documentation
/// One element of a list body or the top level. Whitespace and comments
/// are items like any other, so emitting a document is pure concatenation.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Item {
    /// Exact run of whitespace as it appeared, e.g. "  \n\t".
    Ws(String),
    /// ";..." up to but excluding the newline (the newline is Ws).
    LineComment(String),
    /// "#|...|#" including delimiters; nesting preserved verbatim.
    BlockComment(String),
    /// "#;" plus the complete commented-out datum, verbatim.
    DatumComment(String),
    Node(Node),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ListKind {
    Paren,   // ( )
    Bracket, // [ ]
    Vector,  // #( )
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Node {
    List {
        kind: ListKind,
        items: Vec<Item>,
    },
    /// Symbols, numbers, booleans (#t/#f), chars (#\x), keywords (#:k), and `.`
    Atom(String),
    /// Raw string literal INCLUDING surrounding quotes, escapes as written.
    Str(String),
    /// prefix is one of: ' ` , ,@ #' #` #, #,@ #~ #$ #$@ #+ (gexp syntax).
    Prefixed {
        prefix: String,
        inner: Box<Node>,
    },
}

impl Node {
    /// Verbatim source text of this node.
    pub fn to_source(&self) -> String {
        self.to_string()
    }

    /// For a list, the text of its first data child if that child is an Atom.
    pub fn head_symbol(&self) -> Option<&str> {
        match self {
            Node::List { items, .. } => {
                for item in items {
                    if let Item::Node(n) = item {
                        return match n {
                            Node::Atom(a) => Some(a.as_str()),
                            _ => None,
                        };
                    }
                }
                None
            }
            _ => None,
        }
    }

    /// Symbol text, drilling through `'sym` and `(quote sym)`.
    pub fn as_symbol(&self) -> Option<&str> {
        match self {
            Node::Atom(a) => {
                let first = a.chars().next()?;
                if first.is_ascii_digit() || first == '#' {
                    None
                } else {
                    Some(a.as_str())
                }
            }
            Node::Prefixed { prefix, inner } if prefix.trim() == "'" => inner.as_symbol(),
            Node::List { .. } if self.head_symbol() == Some("quote") => {
                let args: Vec<&Node> = self.list_nodes().skip(1).collect();
                match args.as_slice() {
                    [Node::Atom(a)] => Some(a.as_str()),
                    _ => None,
                }
            }
            _ => None,
        }
    }

    /// Unescaped contents of a string literal.
    pub fn as_string_lit(&self) -> Option<String> {
        let Node::Str(raw) = self else { return None };
        // Str is a public variant; guard against hand-built malformed nodes.
        if raw.len() < 2 || !raw.starts_with('"') || !raw.ends_with('"') {
            return None;
        }
        let inner = &raw[1..raw.len() - 1];
        let mut out = String::with_capacity(inner.len());
        let mut chars = inner.chars();
        while let Some(c) = chars.next() {
            if c == '\\' {
                // Permissive: any escaped char yields itself, matching the
                // subset of guile escapes that appear in guix files.
                if let Some(next) = chars.next() {
                    out.push(next);
                }
            } else {
                out.push(c);
            }
        }
        Some(out)
    }

    /// Data children of a list, trivia skipped. Empty for non-lists.
    pub fn list_nodes(&self) -> impl Iterator<Item = &Node> {
        let items: &[Item] = match self {
            Node::List { items, .. } => items,
            _ => &[],
        };
        items.iter().filter_map(|i| match i {
            Item::Node(n) => Some(n),
            _ => None,
        })
    }
}

impl std::fmt::Display for Item {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Item::Ws(s) | Item::LineComment(s) | Item::BlockComment(s) | Item::DatumComment(s) => {
                f.write_str(s)
            }
            Item::Node(n) => n.fmt(f),
        }
    }
}

impl std::fmt::Display for Node {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Node::Atom(s) | Node::Str(s) => f.write_str(s),
            Node::List { kind, items } => {
                f.write_str(match kind {
                    ListKind::Paren => "(",
                    ListKind::Bracket => "[",
                    ListKind::Vector => "#(",
                })?;
                for item in items {
                    item.fmt(f)?;
                }
                f.write_str(match kind {
                    ListKind::Bracket => "]",
                    _ => ")",
                })
            }
            Node::Prefixed { prefix, inner } => {
                f.write_str(prefix)?;
                inner.fmt(f)
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use crate::Document;

    #[test]
    fn head_symbol_skips_trivia() {
        let doc = Document::parse("( ;; c\n  channel (name 'guix))").unwrap();
        let form = doc.forms().next().unwrap();
        assert_eq!(form.head_symbol(), Some("channel"));
    }

    #[test]
    fn as_symbol_drills_quote_forms() {
        let doc = Document::parse("'guix (quote nonguix) plain \"str\"").unwrap();
        let f: Vec<_> = doc.forms().collect();
        assert_eq!(f[0].as_symbol(), Some("guix"));
        assert_eq!(f[1].as_symbol(), Some("nonguix"));
        assert_eq!(f[2].as_symbol(), Some("plain"));
        assert_eq!(f[3].as_symbol(), None);
    }

    #[test]
    fn as_string_lit_rejects_malformed_str_nodes() {
        use crate::Node;
        assert_eq!(Node::Str(String::new()).as_string_lit(), None);
        assert_eq!(Node::Str("x".into()).as_string_lit(), None);
        assert_eq!(Node::Str("\"".into()).as_string_lit(), None);
        assert_eq!(Node::Str("\"unterminated".into()).as_string_lit(), None);
    }

    #[test]
    fn as_string_lit_unescapes() {
        let doc = Document::parse(r#""a\"b\\c""#).unwrap();
        assert_eq!(
            doc.forms().next().unwrap().as_string_lit().unwrap(),
            r#"a"b\c"#
        );
    }
}