Skip to main content

pretty_lang/
lib.rs

1//! # pretty_lang
2//!
3//! A language-agnostic pretty-printer: turn any syntax tree into laid-out source
4//! text that reflows to a target line width. It is the rendering half of a
5//! formatter — a `gofmt`-style tool for any language, nearly free — and knows
6//! nothing about grammars. You describe the layout with a handful of combinators
7//! and pretty_lang decides where the lines break.
8//!
9//! ## The idea
10//!
11//! You do not print strings directly. Instead you build a [`Doc`]: a lazy
12//! description that says *these pieces belong together*, *put a break here that
13//! becomes a space or a newline*, *indent the inside by four*, *keep this on one
14//! line if it fits*. Rendering a [`Doc`] against a width then chooses concrete
15//! line breaks. The same document renders compactly at width 100 and stacked at
16//! width 20, with no branching in your code.
17//!
18//! The engine is Wadler's *A Prettier Printer* in Lindig's linear-time
19//! imperative form: rendering is `O(document size)`, look-ahead is bounded by
20//! the target width, and neither pass recurses on the tree, so deeply nested
21//! documents cannot overflow the stack.
22//!
23//! ## Quick start
24//!
25//! ```
26//! use pretty_lang::Doc;
27//!
28//! // Build `f(a, b, c)` as a document that can break into one-argument-per-line.
29//! let call = Doc::text("f(")
30//!     .append(
31//!         Doc::softline()
32//!             .append(Doc::join(
33//!                 Doc::text(",").append(Doc::line()),
34//!                 ["a", "b", "c"].map(Doc::text),
35//!             ))
36//!             .nest(4),
37//!     )
38//!     .append(Doc::softline())
39//!     .append(Doc::text(")"))
40//!     .group();
41//!
42//! // Wide: it all fits on one line.
43//! assert_eq!(call.render(80), "f(a, b, c)");
44//!
45//! // Narrow: the group breaks and the arguments stack, indented.
46//! assert_eq!(call.render(6), "f(\n    a,\n    b,\n    c\n)");
47//! ```
48//!
49//! ## The combinators
50//!
51//! | Build with | Meaning |
52//! |------------|---------|
53//! | [`Doc::text`] | literal, unbreakable text |
54//! | [`Doc::line`] | space when flat, newline when broken |
55//! | [`Doc::softline`] | nothing when flat, newline when broken |
56//! | [`Doc::hardline`] | always a newline; forces enclosing groups to break |
57//! | [`Doc::append`] | put one document after another |
58//! | [`Doc::concat`] / [`Doc::join`] | fold / intersperse a sequence |
59//! | [`Doc::nest`] | indent the line breaks inside a document |
60//! | [`Doc::group`] | lay flat if it fits, otherwise break every flexible line |
61//!
62//! Render with [`Doc::render`] (to a [`String`]), [`Doc::render_into`] (into any
63//! [`core::fmt::Write`]), or [`Doc::render_writer`] (into a [`std::io::Write`],
64//! behind the `std` feature).
65//!
66//! ## `no_std`
67//!
68//! The crate is `no_std` and needs only `alloc`. The default `std` feature adds
69//! the [`Doc::render_writer`] I/O sink. There is no `unsafe` anywhere
70//! (`#![forbid(unsafe_code)]`).
71//!
72//! ## Stability
73//!
74//! As of `1.0.0` the public surface — the [`Doc`] type, its constructors,
75//! combinators, render methods, and trait implementations, together with the
76//! `std` feature flag — is stable and frozen under [Semantic Versioning]. It
77//! will not change in a breaking way within the `1.x` series; `1.x` releases may
78//! only add. See `docs/API.md` for the full promise.
79//!
80//! [Semantic Versioning]: https://semver.org
81
82#![cfg_attr(not(feature = "std"), no_std)]
83#![cfg_attr(docsrs, feature(doc_cfg))]
84#![deny(missing_docs)]
85#![forbid(unsafe_code)]
86
87extern crate alloc;
88
89mod doc;
90mod render;
91
92pub use doc::Doc;
93
94#[cfg(test)]
95mod tests {
96    use super::Doc;
97    use alloc::string::String;
98    use alloc::vec::Vec;
99
100    #[test]
101    fn test_nil_renders_empty() {
102        assert_eq!(Doc::nil().render(80), "");
103        assert_eq!(Doc::default().render(80), "");
104    }
105
106    #[test]
107    fn test_text_renders_verbatim() {
108        assert_eq!(Doc::text("hello").render(80), "hello");
109        assert_eq!(Doc::text(String::from("owned")).render(80), "owned");
110    }
111
112    #[test]
113    fn test_append_is_left_to_right() {
114        let doc = Doc::text("a").append(Doc::text("b")).append(Doc::text("c"));
115        assert_eq!(doc.render(80), "abc");
116    }
117
118    #[test]
119    fn test_nil_is_append_identity() {
120        let a = Doc::text("x");
121        assert_eq!(a.clone().append(Doc::nil()).render(80), "x");
122        assert_eq!(Doc::nil().append(a).render(80), "x");
123    }
124
125    #[test]
126    fn test_line_flat_is_space_broken_is_newline() {
127        let doc = Doc::text("a").append(Doc::line()).append(Doc::text("b"));
128        // Grouped and fitting: flat, so the line is a space.
129        assert_eq!(doc.clone().group().render(80), "a b");
130        // Grouped and too narrow: broken, so the line is a newline.
131        assert_eq!(doc.clone().group().render(1), "a\nb");
132        // Ungrouped: the root is always broken.
133        assert_eq!(doc.render(80), "a\nb");
134    }
135
136    #[test]
137    fn test_softline_flat_is_empty() {
138        let doc = Doc::text("(")
139            .append(Doc::softline())
140            .append(Doc::text("x"))
141            .group();
142        assert_eq!(doc.clone().render(80), "(x");
143        assert_eq!(doc.render(1), "(\nx");
144    }
145
146    #[test]
147    fn test_hardline_forces_break_even_when_it_fits() {
148        let doc = Doc::text("a")
149            .append(Doc::hardline())
150            .append(Doc::text("b"))
151            .group();
152        assert_eq!(doc.render(80), "a\nb");
153    }
154
155    #[test]
156    fn test_hardline_forces_all_enclosing_groups() {
157        // An inner group that would fit is still broken because the outer group
158        // is forced by the hardline it also contains.
159        let inner = Doc::text("x")
160            .append(Doc::line())
161            .append(Doc::text("y"))
162            .group();
163        let doc = inner.append(Doc::hardline()).append(Doc::text("z")).group();
164        assert_eq!(doc.render(80), "x y\nz");
165    }
166
167    #[test]
168    fn test_nest_indents_broken_lines_only() {
169        let doc = Doc::text("{")
170            .append(Doc::line().append(Doc::text("body")).nest(4))
171            .append(Doc::line())
172            .append(Doc::text("}"))
173            .group();
174        assert_eq!(doc.clone().render(80), "{ body }");
175        assert_eq!(doc.render(4), "{\n    body\n}");
176    }
177
178    #[test]
179    fn test_nest_nests_additively() {
180        let doc = Doc::text("a")
181            .append(
182                Doc::line()
183                    .append(Doc::text("b"))
184                    .append(Doc::line().append(Doc::text("c")).nest(2))
185                    .nest(2),
186            )
187            .group();
188        assert_eq!(doc.render(1), "a\n  b\n    c");
189    }
190
191    #[test]
192    fn test_negative_nest_clamps_at_zero() {
193        let doc = Doc::text("a")
194            .append(Doc::line().append(Doc::text("b")).nest(-10))
195            .group();
196        assert_eq!(doc.render(1), "a\nb");
197    }
198
199    #[test]
200    fn test_group_all_or_nothing() {
201        // Two breaks in one group break together, never one-of-two.
202        let doc = Doc::join(Doc::line(), ["a", "b", "c"].map(Doc::text)).group();
203        assert_eq!(doc.clone().render(80), "a b c");
204        assert_eq!(doc.render(3), "a\nb\nc");
205    }
206
207    #[test]
208    fn test_concat_folds_in_order() {
209        let doc = Doc::concat(["1", "2", "3"].map(Doc::text));
210        assert_eq!(doc.render(80), "123");
211    }
212
213    #[test]
214    fn test_concat_empty_is_nil() {
215        assert_eq!(Doc::concat(core::iter::empty()).render(80), "");
216    }
217
218    #[test]
219    fn test_join_intersperses_separator() {
220        let doc = Doc::join(Doc::text("::"), ["a", "b", "c"].map(Doc::text));
221        assert_eq!(doc.render(80), "a::b::c");
222    }
223
224    #[test]
225    fn test_join_single_item_has_no_separator() {
226        let doc = Doc::join(Doc::text(","), core::iter::once(Doc::text("solo")));
227        assert_eq!(doc.render(80), "solo");
228    }
229
230    #[test]
231    fn test_join_empty_is_nil() {
232        assert_eq!(
233            Doc::join(Doc::text(","), core::iter::empty()).render(80),
234            ""
235        );
236    }
237
238    #[test]
239    fn test_render_into_matches_render() {
240        let doc = Doc::text("a")
241            .append(Doc::line())
242            .append(Doc::text("b"))
243            .group();
244        let mut buf = String::new();
245        doc.render_into(80, &mut buf).unwrap();
246        assert_eq!(buf, doc.render(80));
247    }
248
249    #[test]
250    fn test_wide_text_overflows_when_no_break_offered() {
251        // The renderer never invents break points: an unbreakable word wider
252        // than the target width is emitted as-is.
253        let doc = Doc::text("unbreakable");
254        assert_eq!(doc.render(3), "unbreakable");
255    }
256
257    #[test]
258    fn test_from_impls() {
259        let a: Doc = "static".into();
260        let b: Doc = String::from("owned").into();
261        assert_eq!(a.render(80), "static");
262        assert_eq!(b.render(80), "owned");
263    }
264
265    #[test]
266    fn test_unicode_width_counts_scalars_not_bytes() {
267        // "café" is 5 bytes but 4 columns; at width 4 it still fits flat.
268        let doc = Doc::text("café")
269            .append(Doc::line())
270            .append(Doc::text("x"))
271            .group();
272        assert_eq!(doc.render(4), "café\nx");
273    }
274
275    #[test]
276    fn test_deeply_nested_does_not_overflow_stack() {
277        // Build a left-leaning spine far deeper than the call stack allows for
278        // recursion; the iterative engine must handle it.
279        let mut doc = Doc::text("end");
280        for _ in 0..100_000 {
281            doc = Doc::text("x").append(doc);
282        }
283        let out = doc.render(80);
284        assert!(out.ends_with("end"));
285        assert_eq!(out.len(), 100_000 + 3);
286    }
287
288    #[test]
289    fn test_debug_is_structural() {
290        let doc = Doc::text("a").append(Doc::line()).group();
291        let s = alloc::format!("{doc:?}");
292        assert_eq!(s, "Group(Cat(Text(\"a\"), Line))");
293    }
294
295    #[cfg(feature = "std")]
296    #[test]
297    fn test_render_writer_to_vec() {
298        let doc = Doc::text("io").append(Doc::text(" sink"));
299        let mut buf: Vec<u8> = Vec::new();
300        doc.render_writer(80, &mut buf).unwrap();
301        assert_eq!(buf, b"io sink");
302    }
303
304    #[cfg(feature = "std")]
305    #[test]
306    fn test_render_writer_propagates_io_error() {
307        // A sink that fails on first write must surface its error, not a bare
308        // formatting error.
309        struct Failing;
310        impl std::io::Write for Failing {
311            fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
312                Err(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "nope"))
313            }
314            fn flush(&mut self) -> std::io::Result<()> {
315                Ok(())
316            }
317        }
318        let doc = Doc::text("data");
319        let err = doc.render_writer(80, &mut Failing).unwrap_err();
320        assert_eq!(err.kind(), std::io::ErrorKind::BrokenPipe);
321    }
322}