topiary_core/
graphviz.rs

1//! GraphViz visualisation for our SyntaxTree representation.
2//! Named syntax nodes are elliptical; anonymous are rectangular.
3use std::{borrow::Cow, fmt, io};
4
5use crate::{tree_sitter::SyntaxNode, FormatterResult};
6
7/// Doubly escapes whitespace (\n and \t) so it is
8/// rendered as the escaped value in the GraphViz output
9fn escape(input: &str) -> Cow<str> {
10    // No allocation happens for an empty string
11    let mut buffer = String::new();
12
13    let mut start: usize = 0;
14    let length = input.len();
15
16    let append = |buffer: &mut String, from: &mut usize, to: usize, suffix: &str| {
17        // Allocate buffer only when necessary
18        if buffer.is_empty() {
19            // Best case:  length + 1  (i.e., single escaped character in input)
20            // Worst case: length * 3  (i.e., every character needs double-escaping)
21            // The input is likely to be short, so no harm in reserving for the worst case
22            buffer.reserve(length * 3);
23        }
24
25        // Decant the unescaped chunk from the input,
26        // followed by the escaped suffix provided
27        *buffer += &input[*from..to];
28        *buffer += suffix;
29
30        // Fast-forward the tracking cursor to the next character
31        *from = to + 1;
32    };
33
34    for (idx, current) in input.chars().enumerate() {
35        match current {
36            // Double-escape whitespace characters
37            '\n' => append(&mut buffer, &mut start, idx, r#"\\n"#),
38            '\t' => append(&mut buffer, &mut start, idx, r#"\\t"#),
39
40            otherwise => {
41                // If char::escape_default starts with a backslash, then we
42                // have an escaped character and we're off the happy path
43                let mut escaped = otherwise.escape_default().peekable();
44                if escaped.peek() == Some(&'\\') {
45                    append(
46                        &mut buffer,
47                        &mut start,
48                        idx,
49                        &otherwise.escape_default().to_string(),
50                    );
51                }
52            }
53        }
54    }
55
56    if buffer.is_empty() {
57        input.into()
58    } else {
59        // Decant whatever's left of the input into the buffer
60        buffer += &input[start..length];
61        buffer.into()
62    }
63}
64
65impl fmt::Display for SyntaxNode {
66    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
67        let shape = if self.is_named { "ellipse" } else { "box" };
68
69        writeln!(
70            f,
71            "  {} [label=\"{}\", shape={shape}];",
72            self.id,
73            escape(&self.kind)
74        )?;
75
76        for child in &self.children {
77            writeln!(f, "  {} -- {};", self.id, child.id)?;
78            write!(f, "{child}")?;
79        }
80
81        Ok(())
82    }
83}
84
85/// Writes the Graphviz Graph in the dot format to the specified output buffer.
86pub fn write(output: &mut dyn io::Write, root: &SyntaxNode) -> FormatterResult<()> {
87    writeln!(output, "graph {{")?;
88    write!(output, "{root}")?;
89    writeln!(output, "}}")?;
90
91    Ok(())
92}
93
94#[cfg(test)]
95mod test {
96    use super::escape;
97    use std::borrow::Cow;
98
99    #[test]
100    fn double_escape() {
101        // Property-based testing would be handy, here...
102        assert_eq!(escape("foo"), "foo");
103        assert_eq!(escape("'"), r#"\'"#);
104        assert_eq!(escape("\n"), r#"\\n"#);
105        assert_eq!(escape("\t"), r#"\\t"#);
106        assert_eq!(
107            escape("Here's something\nlonger"),
108            r#"Here\'s something\\nlonger"#
109        );
110    }
111
112    #[test]
113    fn escape_borrowed() {
114        match escape("foo") {
115            Cow::Borrowed("foo") => (),
116            _ => panic!("Expected a borrowed, unmodified str"),
117        }
118    }
119
120    #[test]
121    fn escape_owned() {
122        match escape("'") {
123            Cow::Owned(s) => assert_eq!(s, r#"\'"#),
124            _ => panic!("Expected an owned, escaped string"),
125        }
126    }
127}