knave-expand 0.14.21

The reading, scoping and expansion stages of knave
Documentation
use std::ops::Range;

use crate::reader::{Token, TokenKind, TokenScan};
use crate::stack::DeepRecursion;

pub struct SyntaxTree {
    pub entries: Vec<Entry>,
    pub closing_comments: Vec<Comment>,
}

pub struct Entry {
    pub leading_comments: Vec<Comment>,
    pub blank_before_node: bool,
    pub node: SyntaxNode,
    pub trailing_comment: Option<String>,
}

pub struct Comment {
    pub text: String,
    pub blank_before: bool,
}

pub enum SyntaxNode {
    Atom(Atom),
    List(ListNode),
}

pub struct Atom {
    pub text: String,
    pub kind: TokenKind,
    pub written_column: usize,
}

pub struct ListNode {
    pub id: usize,
    pub entries: Vec<Entry>,
    pub closing_comments: Vec<Comment>,
    pub nested_lists: usize,
    pub ends_with_comment: bool,
}

impl SyntaxTree {
    pub fn build(text: &str) -> Self {
        let mut builder = TreeBuilder::new(text);
        let (entries, closing_comments) = builder.entries();
        Self {
            entries,
            closing_comments,
        }
    }
}

impl SyntaxNode {
    pub fn as_atom(&self) -> Option<&Atom> {
        match self {
            SyntaxNode::Atom(atom) => Some(atom),
            SyntaxNode::List(_) => None,
        }
    }

    pub fn as_list(&self) -> Option<&ListNode> {
        match self {
            SyntaxNode::List(list) => Some(list),
            SyntaxNode::Atom(_) => None,
        }
    }

    pub fn ends_with_comment(&self) -> bool {
        match self {
            SyntaxNode::List(list) => list.ends_with_comment,
            SyntaxNode::Atom(_) => false,
        }
    }

    pub fn symbol_name(&self) -> Option<&str> {
        self.as_atom()
            .filter(|atom| atom.kind == TokenKind::Symbol)
            .map(|atom| atom.text.as_str())
    }
}

impl Atom {
    pub fn is_multi_line(&self) -> bool {
        self.text.contains('\n')
    }

    pub fn is_string(&self) -> bool {
        self.kind == TokenKind::String
    }

    pub fn docstring_shift(&self, column: usize) -> isize {
        let shift = column as isize - self.written_column as isize;
        let least_indentation = self
            .text
            .split('\n')
            .skip(1)
            .filter(|line| !line.trim().is_empty())
            .map(|line| line.len() - line.trim_start_matches(' ').len())
            .min()
            .unwrap_or(0);
        shift.max(-(least_indentation as isize))
    }
}

impl ListNode {
    pub fn head_name(&self) -> Option<&str> {
        self.entries.first()?.node.symbol_name()
    }
}

impl Entry {
    pub fn breaks_line(&self, index: usize) -> bool {
        self.trailing_comment.is_some() || self.interrupts_line(index)
    }

    pub fn is_interrupted(&self) -> bool {
        !self.leading_comments.is_empty() || self.blank_before_node
    }

    pub fn interrupts_line(&self, index: usize) -> bool {
        !self.leading_comments.is_empty() || self.keeps_blank_line(index)
    }

    fn ends_with_comment(&self) -> bool {
        self.trailing_comment.is_some() || self.node.ends_with_comment()
    }

    fn lists_within(&self) -> usize {
        match &self.node {
            SyntaxNode::List(list) => 1 + list.nested_lists,
            SyntaxNode::Atom(_) => 0,
        }
    }

    pub fn keeps_blank_line(&self, index: usize) -> bool {
        index > 0 && self.blank_before_node
    }
}

struct TreeBuilder<'a> {
    text: &'a str,
    tokens: Vec<Token>,
    position: usize,
    next_list_id: usize,
}

impl<'a> TreeBuilder<'a> {
    fn new(text: &'a str) -> Self {
        Self {
            text,
            tokens: TokenScan::new(text).collect(),
            position: 0,
            next_list_id: 0,
        }
    }

    fn entries(&mut self) -> (Vec<Entry>, Vec<Comment>) {
        let mut entries: Vec<Entry> = Vec::new();
        let mut pending = Vec::new();
        while let Some(token) = self.peek().filter(|token| token.kind != TokenKind::Close) {
            if token.kind == TokenKind::Comment {
                self.add_comment(&mut entries, &mut pending);
            } else {
                entries.push(self.entry(std::mem::take(&mut pending)));
            }
        }
        (entries, pending)
    }

    fn add_comment(&mut self, entries: &mut [Entry], pending: &mut Vec<Comment>) {
        let trails_entry = self.continues_line();
        let open_entry = entries
            .last_mut()
            .filter(|entry| trails_entry && !entry.node.ends_with_comment());
        let text = self.token_text().to_string();
        let blank_before = self.follows_blank_line();
        self.position += 1;
        match open_entry {
            Some(entry) => entry.trailing_comment = Some(text),
            None => pending.push(Comment { text, blank_before }),
        }
    }

    fn entry(&mut self, leading_comments: Vec<Comment>) -> Entry {
        let blank_before_node = self.follows_blank_line();
        Entry {
            leading_comments,
            blank_before_node,
            node: DeepRecursion::guard(|| self.node()),
            trailing_comment: None,
        }
    }

    fn node(&mut self) -> SyntaxNode {
        let token = self.peek().expect("an entry starts at a token");
        self.position += 1;
        match token.kind {
            TokenKind::Open => SyntaxNode::List(self.list()),
            kind => SyntaxNode::Atom(self.atom(kind, token.range)),
        }
    }

    fn list(&mut self) -> ListNode {
        let id = self.next_list_id;
        self.next_list_id += 1;
        let (entries, closing_comments) = self.entries();
        self.position += 1;
        ListNode {
            id,
            nested_lists: entries.iter().map(Entry::lists_within).sum(),
            ends_with_comment: closing_comments.is_empty()
                && entries.last().is_some_and(Entry::ends_with_comment),
            entries,
            closing_comments,
        }
    }

    fn atom(&self, kind: TokenKind, range: Range<usize>) -> Atom {
        let line_start = self.text[..range.start].rfind('\n').map_or(0, |at| at + 1);
        Atom {
            text: self.text[range.clone()].to_string(),
            kind,
            written_column: self.text[line_start..range.start].chars().count(),
        }
    }

    fn peek(&self) -> Option<Token> {
        self.tokens.get(self.position).cloned()
    }

    fn token_text(&self) -> &'a str {
        &self.text[self.tokens[self.position].range.clone()]
    }

    fn continues_line(&self) -> bool {
        self.position > 0 && self.newlines_before() == 0
    }

    fn follows_blank_line(&self) -> bool {
        self.position > 0 && self.newlines_before() > 1
    }

    fn newlines_before(&self) -> usize {
        let start = self.tokens[self.position].range.start;
        let previous_end = self.tokens[self.position - 1].range.end;
        self.text[previous_end..start].matches('\n').count()
    }
}

#[cfg(test)]
mod tests {
    use super::{SyntaxNode, SyntaxTree};

    struct TreeFixture {
        tree: SyntaxTree,
    }

    impl TreeFixture {
        fn of(text: &str) -> Self {
            Self {
                tree: SyntaxTree::build(text),
            }
        }

        fn list_entries(&self) -> &[super::Entry] {
            match &self.tree.entries[0].node {
                SyntaxNode::List(list) => &list.entries,
                SyntaxNode::Atom(_) => panic!("the first form is a list"),
            }
        }
    }

    #[test]
    fn a_comment_on_the_line_of_a_form_trails_it() {
        let fixture = TreeFixture::of("(a b ; why\n c)");
        let entries = fixture.list_entries();
        assert_eq!(entries[1].trailing_comment.as_deref(), Some("; why"));
        assert!(entries[2].leading_comments.is_empty());
    }

    #[test]
    fn a_comment_on_its_own_line_leads_the_next_form() {
        let fixture = TreeFixture::of("(a\n ; note\n b)");
        let entries = fixture.list_entries();
        assert_eq!(entries[0].trailing_comment, None);
        assert_eq!(entries[1].leading_comments[0].text, "; note");
    }

    #[test]
    fn a_comment_before_the_close_stays_inside_the_list() {
        let fixture = TreeFixture::of("(a\n ; last\n)");
        let SyntaxNode::List(list) = &fixture.tree.entries[0].node else {
            panic!("the first form is a list");
        };
        assert_eq!(list.closing_comments[0].text, "; last");
    }

    #[test]
    fn a_blank_line_between_forms_is_kept_and_a_single_break_is_not() {
        let fixture = TreeFixture::of("(a\n b\n\n c)");
        let entries = fixture.list_entries();
        assert!(!entries[1].blank_before_node);
        assert!(entries[2].blank_before_node);
    }

    #[test]
    fn an_atom_keeps_its_text_and_its_written_column() {
        let fixture = TreeFixture::of("(a\n   1_000)");
        let atom = fixture.list_entries()[1].node.as_atom().expect("an atom");
        assert_eq!((atom.text.as_str(), atom.written_column), ("1_000", 3));
    }
}