Skip to main content

rdf_parsers/
format.rs

1/// Wadler-Lindig pretty-printing combinators.
2///
3/// A `Doc` describes a document that can be rendered at a given line width.
4/// `Group(d)` attempts to render `d` on a single line; if it doesn't fit
5/// within the remaining width the group "breaks" and any `Line` inside
6/// becomes a newline followed by the current indentation.
7#[derive(Debug, Clone)]
8pub enum Doc {
9    Nil,
10    Text(String),
11    /// Soft line: a space when the enclosing group is flat, a newline +
12    /// indentation when it is broken.
13    Line,
14    /// Hard line: always a newline + indentation regardless of group mode.
15    HardLine,
16    Concat(Vec<Doc>),
17    /// Increase indentation for the inner document by `n` spaces.
18    Nest(i32, Box<Doc>),
19    /// Try to render the inner document flat; break if it exceeds the width.
20    Group(Box<Doc>),
21}
22
23impl Doc {
24    pub fn nil() -> Self {
25        Doc::Nil
26    }
27
28    pub fn text(s: impl Into<String>) -> Self {
29        Doc::Text(s.into())
30    }
31
32    pub fn concat(docs: Vec<Doc>) -> Self {
33        let flat: Vec<Doc> = docs
34            .into_iter()
35            .flat_map(|d| match d {
36                Doc::Concat(inner) => inner,
37                Doc::Nil => vec![],
38                other => vec![other],
39            })
40            .collect();
41        match flat.len() {
42            0 => Doc::Nil,
43            1 => flat.into_iter().next().unwrap(),
44            _ => Doc::Concat(flat),
45        }
46    }
47
48    pub fn nest(indent: i32, doc: Doc) -> Self {
49        Doc::Nest(indent, Box::new(doc))
50    }
51
52    pub fn group(doc: Doc) -> Self {
53        Doc::Group(Box::new(doc))
54    }
55}
56
57#[derive(Clone, Copy, PartialEq, Eq)]
58enum Mode {
59    Flat,
60    Break,
61}
62
63/// Returns `true` if `docs` (in the current work list) fit within `width`
64/// remaining characters when rendered flat.
65fn fits(mut width: isize, docs: &[(&Doc, i32, Mode)]) -> bool {
66    let mut i = docs.len();
67    loop {
68        if width < 0 {
69            return false;
70        }
71        if i == 0 {
72            return true;
73        }
74        i -= 1;
75        let (doc, indent, mode) = docs[i];
76        match doc {
77            Doc::Nil => {}
78            Doc::Text(s) => width -= s.len() as isize,
79            Doc::Line => {
80                if mode == Mode::Flat {
81                    width -= 1; // space
82                } else {
83                    return true; // a break always fits "the rest"
84                }
85            }
86            Doc::HardLine => return true,
87            Doc::Concat(ds) => {
88                // Push children in reverse so we process them in order
89                // We need a local copy to extend the slice view — handled
90                // by the iterative approach below via a local vec.
91                //
92                // Instead, recurse with an extended slice.
93                let mut extended: Vec<(&Doc, i32, Mode)> = docs[..i].to_vec();
94                for d in ds.iter().rev() {
95                    extended.push((d, indent, mode));
96                }
97                return fits(width, &extended);
98            }
99            Doc::Nest(n, inner) => {
100                let mut extended: Vec<(&Doc, i32, Mode)> = docs[..i].to_vec();
101                extended.push((inner, indent + n, mode));
102                return fits(width, &extended);
103            }
104            Doc::Group(inner) => {
105                let mut extended: Vec<(&Doc, i32, Mode)> = docs[..i].to_vec();
106                extended.push((inner, indent, Mode::Flat));
107                return fits(width, &extended);
108            }
109        }
110    }
111}
112
113/// Render a `Doc` to a `String` within the given line `width`.
114pub fn render(doc: &Doc, width: usize) -> String {
115    let mut out = String::new();
116    let mut col: i32 = 0;
117
118    // Work list: (doc, indent, mode). Processed front-to-back.
119    let mut work: Vec<(&Doc, i32, Mode)> = vec![(doc, 0, Mode::Break)];
120
121    let mut should_ident = false;
122
123    while let Some((doc, indent, mode)) = work.pop() {
124        match doc {
125            Doc::Nil => {}
126            Doc::Text(s) => {
127                if should_ident {
128                    for _ in 0..indent {
129                        out.push(' ');
130                        // col += 1;
131                    }
132                    should_ident = false;
133                }
134                out.push_str(s);
135                col += s.len() as i32;
136            }
137            Doc::Line => {
138                if mode == Mode::Flat {
139                    out.push(' ');
140                    col += 1;
141                } else {
142                    out.push('\n');
143                    col = 0;
144                    should_ident = true;
145                }
146            }
147            Doc::HardLine => {
148                out.push('\n');
149                col = 0;
150                should_ident = true;
151            }
152            Doc::Concat(ds) => {
153                // Push in reverse so the first element is processed first.
154                for d in ds.iter().rev() {
155                    work.push((d, indent, mode));
156                }
157            }
158            Doc::Nest(n, inner) => {
159                work.push((inner, indent + n, mode));
160            }
161            Doc::Group(inner) => {
162                // Build a temporary slice to pass to `fits`.
163                let remaining = if should_ident {
164                    width as isize - (indent + col) as isize
165                } else {
166                    width as isize - col as isize
167                };
168                // Collect current work list plus the inner doc so fits() can
169                // measure the whole upcoming context.
170                let mut probe: Vec<(&Doc, i32, Mode)> = work.clone();
171                probe.push((inner, indent, Mode::Flat));
172                let chosen = if fits(remaining, &probe) {
173                    Mode::Flat
174                } else {
175                    Mode::Break
176                };
177                work.push((inner, indent, chosen));
178            }
179        }
180    }
181
182    out
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    #[test]
190    fn text_renders_literally() {
191        let doc = Doc::text("hello");
192        assert_eq!(render(&doc, 80), "hello");
193    }
194
195    #[test]
196    fn group_fits_on_one_line() {
197        // ["a", "b", "c"] rendered with width=80 → fits flat → "a b c"
198        let doc = Doc::group(Doc::concat(vec![
199            Doc::text("a"),
200            Doc::Line,
201            Doc::text("b"),
202            Doc::Line,
203            Doc::text("c"),
204        ]));
205        assert_eq!(render(&doc, 80), "a b c");
206    }
207
208    #[test]
209    fn group_breaks_when_too_wide() {
210        // Same doc but width=4 → breaks → "a\nb\nc"
211        let doc = Doc::group(Doc::concat(vec![
212            Doc::text("a"),
213            Doc::Line,
214            Doc::text("b"),
215            Doc::Line,
216            Doc::text("c"),
217        ]));
218        assert_eq!(render(&doc, 4), "a\nb\nc");
219    }
220
221    #[test]
222    fn nest_indents_broken_lines() {
223        let doc = Doc::group(Doc::concat(vec![
224            Doc::text("{"),
225            Doc::nest(
226                2,
227                Doc::concat(vec![Doc::Line, Doc::text("x"), Doc::Line, Doc::text("y")]),
228            ),
229            Doc::Line,
230            Doc::text("}"),
231        ]));
232        // Width=80 → fits flat
233        assert_eq!(render(&doc, 80), "{ x y }");
234        // Width=5 → breaks: "{\n  x\n  y\n}"
235        assert_eq!(render(&doc, 5), "{\n  x\n  y\n}");
236    }
237
238    #[test]
239    fn hardline_always_breaks() {
240        let doc = Doc::group(Doc::concat(vec![
241            Doc::text("a"),
242            Doc::HardLine,
243            Doc::text("b"),
244        ]));
245        // Even with large width, HardLine always breaks.
246        assert_eq!(render(&doc, 999), "a\nb");
247    }
248
249    #[test]
250    fn concat_flattens_nils() {
251        let doc = Doc::concat(vec![Doc::Nil, Doc::text("x"), Doc::Nil]);
252        assert_eq!(render(&doc, 80), "x");
253    }
254
255    #[test]
256    fn nest_propagates_to_all_lines_in_concat() {
257        // Regression: Nest(2, concat([Line, "a", ",", Line, "b"])) should
258        // indent BOTH Lines, not just the first.
259        let inner = Doc::concat(vec![
260            Doc::Line,
261            Doc::text("a"),
262            Doc::text(","),
263            Doc::Line,
264            Doc::text("b"),
265        ]);
266        let doc = Doc::group(Doc::concat(vec![
267            Doc::text("{"),
268            Doc::nest(2, inner),
269            Doc::Line,
270            Doc::text("}"),
271        ]));
272        // Too narrow to fit flat → must break
273        assert_eq!(render(&doc, 5), "{\n  a,\n  b\n}");
274    }
275}