Skip to main content

sql_dialect_fmt_formatter/
doc.rs

1//! A small, self-contained pretty-printing engine in the Wadler → Prettier → rome/biome → ruff
2//! lineage. The grammar-agnostic core is a `Doc` intermediate representation plus a width-aware
3//! [`print()`]er; SQL-specific rules live in the formatter's SQL lowering module and only ever
4//! build `Doc`s.
5//!
6//! The model has three moving parts:
7//! * **Builders** ([`text()`], [`concat()`], [`group()`], [`indent()`], [`line()`],
8//!   [`soft_line()`], [`hard_line()`], [`space()`], [`join()`]) construct the IR.
9//! * **Groups** are the unit of layout choice: a group is printed *flat* (its soft lines become
10//!   spaces or nothing) when it fits within the remaining width, otherwise it *breaks* (its soft
11//!   lines become newlines + indentation).
12//! * A **hard line** forces every group that contains it to break — the classic `breakParent`
13//!   propagation — so caller-mandated line breaks are never silently collapsed.
14//!
15//! See `docs/research/prior-art.md` for the design rationale and references.
16
17use std::borrow::Cow;
18
19use unicode_width::UnicodeWidthStr;
20
21/// The pretty-printer intermediate representation.
22///
23/// Construct values through the builder functions rather than the variants directly; the shape is
24/// public only so tests and future SQL rules can pattern-match if needed.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub enum Doc {
27    /// Verbatim text. Must not contain newlines — use the line builders for those so width
28    /// tracking and indentation stay correct. The display width is folded in at construction time
29    /// (via [`text`]) so the fit/break measurement never re-scans the string.
30    Text(Cow<'static, str>, usize),
31    /// A verbatim slice of source that may contain newlines, reproduced byte-for-byte (modulo the
32    /// printer's per-line right trim in the final output pass). This is the vehicle for verbatim fallback
33    /// regions without smuggling embedded `\n`s through [`Doc::Text`], which would corrupt column
34    /// tracking. Each contained newline re-bases the column to the current indentation.
35    ///
36    /// The cached width is the display width of the slice's last line. Build through
37    /// [`source_code_slice`], which folds the width in.
38    SourceCodeSlice(Cow<'static, str>, usize),
39    /// A line break whose rendering depends on the enclosing group's mode (see [`LineKind`]).
40    Line(LineKind),
41    /// A sequence of documents laid out one after another.
42    Concat(Vec<Doc>),
43    /// Try candidate layouts in order and print the first one that fits on the current line; if no
44    /// candidate fits, print the last one. This is the small Doc-IR escape hatch used by Prettier /
45    /// Biome for constructs with genuinely different layouts rather than just flat-vs-break lines.
46    BestFitting(Vec<Doc>),
47    /// A layout-choice boundary. Printed flat if it fits, otherwise broken. When `expand` is set
48    /// the group always breaks (Prettier's `shouldBreak`) — used for "explode this collection"
49    /// decisions like a magic trailing comma. Unlike a hard line, `expand` does **not** propagate
50    /// to enclosing groups, so an inner collection can explode while its parent stays flat.
51    ///
52    /// `must_break` is `expand || has_forced_break(content)`, computed once when the group is built
53    /// (see [`group`]). Because groups are constructed bottom-up, each group carries its own
54    /// answer and ancestors read it in O(1) instead of re-walking the whole subtree on every
55    /// fit/break decision.
56    Group {
57        content: Box<Doc>,
58        expand: bool,
59        must_break: bool,
60    },
61    /// Picks one of two layouts by the enclosing group's mode: `broken` when that group breaks,
62    /// `flat` when it stays flat (Prettier's `ifBreak`). Built through [`if_group_breaks`].
63    ///
64    /// The broken arm is not consulted when detecting forced breaks, so an `if_group_breaks` whose broken
65    /// arm contains a hard line does not force its own group to break.
66    IfBreak { broken: Box<Doc>, flat: Box<Doc> },
67    /// Increases the indentation level applied to line breaks inside it.
68    Indent(Box<Doc>),
69    /// Content deferred to just before the next newline (or the document's end). The vehicle for
70    /// trailing line comments, which must not have code emitted after them on the same line.
71    LineSuffix(Box<Doc>),
72    /// A zero-width marker that forces every enclosing group to break, without itself emitting a
73    /// newline. Pairs with line suffixes so a trailing `--` comment actually ends its line.
74    BreakParent,
75}
76
77/// How a [`Doc::Line`] renders, as a function of the enclosing group's mode.
78#[derive(Clone, Copy, Debug, PartialEq, Eq)]
79pub enum LineKind {
80    /// Flat: a single space. Broken: a newline + indentation. (Prettier's `line`.)
81    Space,
82    /// Flat: nothing. Broken: a newline + indentation. (Prettier's `softline`.)
83    Soft,
84    /// Always a newline + indentation, and forces every enclosing group to break. (`hardline`.)
85    Hard,
86}
87
88// ---- builders ----
89
90/// Verbatim text (a borrowed `&'static str` or an owned `String`). The display width is measured
91/// once here and cached on the node, so the printer's fit/break measurement never re-scans it.
92pub fn text(s: impl Into<Cow<'static, str>>) -> Doc {
93    let s = s.into();
94    let width = text_width(&s);
95    Doc::Text(s, width)
96}
97
98/// A verbatim slice of source that may span multiple lines. Use this — not [`text`] — for fallback
99/// regions that can contain embedded newlines: it re-bases the column at each newline so the slice
100/// stays aligned under indentation, and caches its last line's display width for fit decisions.
101pub fn source_code_slice(s: impl Into<Cow<'static, str>>) -> Doc {
102    let s = s.into();
103    let width = last_line_width(&s);
104    Doc::SourceCodeSlice(s, width)
105}
106
107/// Lay out the parts one after another.
108pub fn concat(parts: Vec<Doc>) -> Doc {
109    Doc::Concat(parts)
110}
111
112/// A layout-choice group: flat when it fits on the line, broken otherwise.
113pub fn group(inner: Doc) -> Doc {
114    let must_break = has_forced_break(&inner);
115    Doc::Group {
116        content: Box::new(inner),
117        expand: false,
118        must_break,
119    }
120}
121
122/// A group forced to break (its soft lines always become newlines), without forcing enclosing
123/// groups to break. The building block for magic-trailing-comma "keep this exploded".
124pub fn group_expanded(inner: Doc) -> Doc {
125    Doc::Group {
126        content: Box::new(inner),
127        expand: true,
128        must_break: true,
129    }
130}
131
132/// Indent every line break that occurs inside `inner` by one more level.
133pub fn indent(inner: Doc) -> Doc {
134    Doc::Indent(Box::new(inner))
135}
136
137/// A hard-line-delimited, indented block: `inner` is placed on its own indented line(s), framed by a
138/// hard line before and after (Prettier/biome's `block_indent`).
139#[doc(hidden)]
140pub fn block_indent(inner: Doc) -> Doc {
141    concat(vec![indent(concat(vec![hard_line(), inner])), hard_line()])
142}
143
144/// Choose `broken` when the enclosing group breaks and `flat` when it stays flat (Prettier's
145/// `ifBreak`). Neither arm emits a newline by itself; this only selects which content appears.
146#[doc(hidden)]
147pub fn if_group_breaks(broken: Doc, flat: Doc) -> Doc {
148    Doc::IfBreak {
149        broken: Box::new(broken),
150        flat: Box::new(flat),
151    }
152}
153
154/// A space when flat, a newline when broken.
155pub fn line() -> Doc {
156    Doc::Line(LineKind::Space)
157}
158
159/// Nothing when flat, a newline when broken.
160pub fn soft_line() -> Doc {
161    Doc::Line(LineKind::Soft)
162}
163
164/// Always a newline; forces enclosing groups to break.
165pub fn hard_line() -> Doc {
166    Doc::Line(LineKind::Hard)
167}
168
169/// A literal, non-collapsible space.
170pub fn space() -> Doc {
171    Doc::Text(Cow::Borrowed(" "), 1)
172}
173
174/// The empty document (renders to nothing).
175pub fn empty() -> Doc {
176    Doc::Concat(Vec::new())
177}
178
179/// Defer `inner` to just before the next newline (used for trailing line comments).
180pub fn line_suffix(inner: Doc) -> Doc {
181    Doc::LineSuffix(Box::new(inner))
182}
183
184/// Force enclosing groups to break without emitting a newline here.
185pub fn break_parent() -> Doc {
186    Doc::BreakParent
187}
188
189/// Interleave `sep` between `items` (no separator before the first or after the last).
190pub fn join(sep: Doc, items: Vec<Doc>) -> Doc {
191    let mut parts = Vec::with_capacity(items.len().saturating_mul(2));
192    for (i, item) in items.into_iter().enumerate() {
193        if i > 0 {
194            parts.push(sep.clone());
195        }
196        parts.push(item);
197    }
198    Doc::Concat(parts)
199}
200
201/// Try the candidate documents in order, printing the first that fits in the remaining width and
202/// falling back to the last candidate when none fit. Empty candidates render as empty.
203#[doc(hidden)]
204pub fn best_fitting(candidates: Vec<Doc>) -> Doc {
205    Doc::BestFitting(candidates)
206}
207
208// ---- write-style composition layer ----
209
210/// A growable sink of [`Doc`] elements, the target of the [`doc_write!`] macro and of [`Format`]
211/// implementors.
212#[doc(hidden)]
213#[derive(Default)]
214pub struct DocBuffer {
215    parts: Vec<Doc>,
216}
217
218impl DocBuffer {
219    /// A new, empty buffer.
220    pub fn new() -> Self {
221        DocBuffer { parts: Vec::new() }
222    }
223
224    /// Append one already-built [`Doc`] element in order.
225    pub fn write_element(&mut self, doc: Doc) {
226        self.parts.push(doc);
227    }
228
229    /// Append a value by letting it format itself into this buffer.
230    pub fn write_fmt_value<T: Format + ?Sized>(&mut self, value: &T) {
231        value.fmt(self);
232    }
233
234    /// Append a value formatted by an external [`FormatRule`].
235    pub fn write_with_rule<T: ?Sized, R: FormatRule<T>>(&mut self, rule: &R, item: &T) {
236        rule.fmt(item, self);
237    }
238
239    /// Consume the buffer, returning the assembled document.
240    pub fn finish(mut self) -> Doc {
241        if self.parts.len() == 1 {
242            self.parts.pop().expect("len checked == 1")
243        } else {
244            Doc::Concat(self.parts)
245        }
246    }
247}
248
249/// A value that knows how to render itself into a [`DocBuffer`].
250#[doc(hidden)]
251pub trait Format {
252    /// Push this value's document representation onto `buffer`, in source order.
253    fn fmt(&self, buffer: &mut DocBuffer);
254}
255
256/// Formatting logic for a `T` kept outside `T`, matching the biome/ruff rule pattern.
257#[doc(hidden)]
258pub trait FormatRule<T: ?Sized> {
259    /// Push `item`'s document representation onto `buffer`.
260    fn fmt(&self, item: &T, buffer: &mut DocBuffer);
261}
262
263impl Format for Doc {
264    fn fmt(&self, buffer: &mut DocBuffer) {
265        buffer.write_element(self.clone());
266    }
267}
268
269impl<T: Format + ?Sized> Format for &T {
270    fn fmt(&self, buffer: &mut DocBuffer) {
271        (**self).fmt(buffer);
272    }
273}
274
275/// Run a single [`Format`] value to completion, returning its assembled [`Doc`].
276#[doc(hidden)]
277pub fn format_value<T: Format + ?Sized>(value: &T) -> Doc {
278    let mut buffer = DocBuffer::new();
279    buffer.write_fmt_value(value);
280    buffer.finish()
281}
282
283/// Push a comma-bracketed list of [`Format`] elements into a [`DocBuffer`], in order.
284#[doc(hidden)]
285#[macro_export]
286macro_rules! doc_write {
287    ($buffer:expr, [ $( $element:expr ),* $(,)? ]) => {{
288        $( $buffer.write_fmt_value(&$element); )*
289    }};
290}
291
292#[doc(inline)]
293pub use crate::doc_write;
294
295// ---- printing ----
296
297/// Knobs for the printer. Opinionated by design: just a target width and an indent step.
298#[derive(Clone, Copy, Debug)]
299pub struct PrintOptions {
300    /// The column the printer tries to keep lines within.
301    pub line_width: usize,
302    /// Number of spaces added per indentation level.
303    pub indent_width: usize,
304}
305
306impl Default for PrintOptions {
307    fn default() -> Self {
308        PrintOptions {
309            line_width: 100,
310            indent_width: 4,
311        }
312    }
313}
314
315#[derive(Clone, Copy, PartialEq, Eq)]
316enum Mode {
317    Flat,
318    Break,
319}
320
321#[derive(Clone, Copy)]
322struct Cmd<'a> {
323    indent: usize,
324    mode: Mode,
325    doc: &'a Doc,
326}
327
328/// Display width of a string in terminal columns. CJK text, combining marks, and emoji sequences
329/// are measured by their rendered column width rather than by scalar count. This only feeds the
330/// fit/break decision, so refining it never changes which tokens are emitted.
331///
332/// Pure ASCII — the overwhelmingly common case in SQL — has width equal to its byte length, so we
333/// short-circuit on it and only decode/classify code points when a non-ASCII byte is present.
334fn text_width(s: &str) -> usize {
335    if s.is_ascii() {
336        return s.len();
337    }
338    UnicodeWidthStr::width(s)
339}
340
341/// Display width of the last line of a possibly multi-line slice: everything after the final
342/// newline, or the whole string when it has none.
343fn last_line_width(s: &str) -> usize {
344    match s.rfind('\n') {
345        Some(nl) => text_width(&s[nl + 1..]),
346        None => text_width(s),
347    }
348}
349
350/// Does `doc` contain a hard line anywhere within (propagating through nested groups, exactly like
351/// Prettier's `breakParent`)? Such a document can never be printed flat.
352///
353/// Nested groups are consulted via their cached `must_break` flag rather than re-walked. Because
354/// the builders compute this bottom-up (a group's flag is set when it is constructed, by which time
355/// its children already carry theirs), the whole tree is classified in O(nodes) overall.
356fn has_forced_break(doc: &Doc) -> bool {
357    match doc {
358        Doc::Line(LineKind::Hard) | Doc::BreakParent => true,
359        Doc::Line(_) | Doc::Text(..) | Doc::SourceCodeSlice(..) => false,
360        // A line suffix's own content is deferred and must not force the current line to break.
361        Doc::LineSuffix(_) => false,
362        // `if_group_breaks` resolves against its group's mode. Letting its broken arm force that
363        // group to break would be circular; the flat arm is what can force flat layout impossible.
364        Doc::IfBreak { flat, .. } => has_forced_break(flat),
365        Doc::Concat(parts) => parts.iter().any(has_forced_break),
366        Doc::BestFitting(candidates) => {
367            !candidates.is_empty() && candidates.iter().all(has_forced_break)
368        }
369        Doc::Indent(inner) => has_forced_break(inner),
370        // An exploded group propagates to ancestors: a multiline collection can't sit inline, so
371        // every group containing it must break too (cf. Black's magic trailing comma). The answer
372        // was precomputed when the group was built.
373        Doc::Group { must_break, .. } => *must_break,
374    }
375}
376
377/// Would `next`, followed by the not-yet-processed `rest` of the print stack, fit on the current
378/// line within `remaining` columns? Everything is measured as if flat until the first newline that
379/// is actually taken (a hard line, or a soft line in an already-broken group).
380fn fits(mut remaining: isize, rest: &[Cmd], next: Cmd, opts: &PrintOptions) -> bool {
381    if remaining < 0 {
382        return false;
383    }
384    let mut stack: Vec<Cmd> = vec![next];
385    let mut rest_idx = rest.len();
386    loop {
387        let cmd = match stack.pop() {
388            Some(cmd) => cmd,
389            None => {
390                if rest_idx == 0 {
391                    return true;
392                }
393                rest_idx -= 1;
394                rest[rest_idx]
395            }
396        };
397        match cmd.doc {
398            Doc::Text(_, width) => {
399                remaining -= *width as isize;
400                if remaining < 0 {
401                    return false;
402                }
403            }
404            Doc::SourceCodeSlice(s, _) => {
405                let mut pieces = s.split('\n');
406                let first = pieces.next().unwrap_or_default();
407                remaining -= text_width(first) as isize;
408                if remaining < 0 {
409                    return false;
410                }
411                for piece in pieces {
412                    remaining =
413                        opts.line_width as isize - cmd.indent as isize - text_width(piece) as isize;
414                    if remaining < 0 {
415                        return false;
416                    }
417                }
418            }
419            Doc::IfBreak { broken, flat } => {
420                let chosen = if cmd.mode == Mode::Break {
421                    broken
422                } else {
423                    flat
424                };
425                stack.push(Cmd { doc: chosen, ..cmd });
426            }
427            Doc::Concat(parts) => {
428                for part in parts.iter().rev() {
429                    stack.push(Cmd { doc: part, ..cmd });
430                }
431            }
432            Doc::BestFitting(candidates) => {
433                if let Some(candidate) = candidates.first() {
434                    stack.push(Cmd {
435                        doc: candidate,
436                        ..cmd
437                    });
438                }
439            }
440            Doc::Indent(inner) => stack.push(Cmd {
441                indent: cmd.indent + opts.indent_width,
442                doc: inner,
443                mode: cmd.mode,
444            }),
445            Doc::Group {
446                content,
447                must_break,
448                ..
449            } => {
450                let mode = if *must_break { Mode::Break } else { Mode::Flat };
451                stack.push(Cmd {
452                    indent: cmd.indent,
453                    mode,
454                    doc: content,
455                });
456            }
457            // A line suffix is deferred to the next newline; it does not consume current width.
458            // A break parent is a zero-width marker. Neither affects whether the line fits.
459            Doc::LineSuffix(_) | Doc::BreakParent => {}
460            Doc::Line(kind) => match cmd.mode {
461                // A newline is taken here, so everything up to it fit.
462                Mode::Break => return true,
463                Mode::Flat => match kind {
464                    LineKind::Hard => return true,
465                    LineKind::Soft => {}
466                    LineKind::Space => {
467                        remaining -= 1;
468                        if remaining < 0 {
469                            return false;
470                        }
471                    }
472                },
473            },
474        }
475    }
476}
477
478/// Render `doc` to a string. Trailing whitespace is trimmed from every line and the result ends
479/// with exactly one newline (or is empty), so the output is stable under re-formatting.
480pub fn print(doc: &Doc, opts: &PrintOptions) -> String {
481    let mut out = String::new();
482    let mut col = 0usize;
483    let mut cmds: Vec<Cmd> = vec![Cmd {
484        indent: 0,
485        mode: Mode::Break,
486        doc,
487    }];
488    // Content deferred by `LineSuffix`, flushed in order just before the next newline (or at EOF).
489    let mut line_suffixes: Vec<Cmd> = Vec::new();
490
491    loop {
492        let cmd = match cmds.pop() {
493            Some(cmd) => cmd,
494            None if !line_suffixes.is_empty() => {
495                // Flush remaining suffixes at the document's end (no trailing newline followed).
496                while let Some(suffix) = line_suffixes.pop() {
497                    cmds.push(suffix);
498                }
499                continue;
500            }
501            None => break,
502        };
503        match cmd.doc {
504            Doc::Text(s, width) => {
505                out.push_str(s);
506                col += *width;
507            }
508            Doc::SourceCodeSlice(s, width) => {
509                let mut first = true;
510                for piece in s.split('\n') {
511                    if !first {
512                        out.push('\n');
513                        for _ in 0..cmd.indent {
514                            out.push(' ');
515                        }
516                    }
517                    out.push_str(piece);
518                    first = false;
519                }
520                col = if s.contains('\n') {
521                    cmd.indent + *width
522                } else {
523                    col + *width
524                };
525            }
526            Doc::IfBreak { broken, flat } => {
527                let chosen = if cmd.mode == Mode::Break {
528                    broken
529                } else {
530                    flat
531                };
532                cmds.push(Cmd { doc: chosen, ..cmd });
533            }
534            Doc::Concat(parts) => {
535                for part in parts.iter().rev() {
536                    cmds.push(Cmd { doc: part, ..cmd });
537                }
538            }
539            Doc::BestFitting(candidates) => {
540                if candidates.is_empty() {
541                    continue;
542                }
543                let mut chosen = candidates.last().expect("non-empty candidates");
544                for candidate in candidates {
545                    if fits(
546                        opts.line_width as isize - col as isize,
547                        &cmds,
548                        Cmd {
549                            indent: cmd.indent,
550                            mode: cmd.mode,
551                            doc: candidate,
552                        },
553                        opts,
554                    ) {
555                        chosen = candidate;
556                        break;
557                    }
558                }
559                cmds.push(Cmd { doc: chosen, ..cmd });
560            }
561            Doc::LineSuffix(inner) => line_suffixes.push(Cmd { doc: inner, ..cmd }),
562            Doc::BreakParent => {}
563            Doc::Indent(inner) => cmds.push(Cmd {
564                indent: cmd.indent + opts.indent_width,
565                doc: inner,
566                mode: cmd.mode,
567            }),
568            Doc::Group {
569                content,
570                must_break,
571                ..
572            } => {
573                let mode = if *must_break {
574                    Mode::Break
575                } else if fits(
576                    opts.line_width as isize - col as isize,
577                    &cmds,
578                    Cmd {
579                        indent: cmd.indent,
580                        mode: Mode::Flat,
581                        doc: content,
582                    },
583                    opts,
584                ) {
585                    Mode::Flat
586                } else {
587                    Mode::Break
588                };
589                cmds.push(Cmd {
590                    indent: cmd.indent,
591                    mode,
592                    doc: content,
593                });
594            }
595            Doc::Line(kind) => {
596                let newline = match cmd.mode {
597                    Mode::Break => true,
598                    Mode::Flat => match kind {
599                        LineKind::Hard => true,
600                        LineKind::Space => {
601                            out.push(' ');
602                            col += 1;
603                            false
604                        }
605                        LineKind::Soft => false,
606                    },
607                };
608                if newline {
609                    // Before breaking, emit any deferred line suffixes on this line, then
610                    // reprocess the newline with an empty buffer.
611                    if !line_suffixes.is_empty() {
612                        cmds.push(cmd);
613                        while let Some(suffix) = line_suffixes.pop() {
614                            cmds.push(suffix);
615                        }
616                        continue;
617                    }
618                    out.push('\n');
619                    for _ in 0..cmd.indent {
620                        out.push(' ');
621                    }
622                    col = cmd.indent;
623                }
624            }
625        }
626    }
627
628    finalize(out)
629}
630
631/// Trim trailing whitespace from each line, drop leading/trailing blank lines, and ensure a single
632/// trailing newline. This keeps output stable under re-formatting regardless of verbatim spans.
633///
634/// Single streaming pass: each line is `trim_end`ed; leading blank lines are skipped, and interior
635/// blank lines are buffered as `pending_blanks` so any run of them that turns out to be trailing is
636/// dropped rather than emitted. Equivalent to the former collect-join-trim, without the temporary
637/// `Vec<&str>` or the intermediate joined `String`.
638fn finalize(raw: String) -> String {
639    let mut out = String::with_capacity(raw.len());
640    let mut started = false;
641    let mut pending_blanks = 0usize;
642    for line in raw.lines() {
643        let line = line.trim_end();
644        if line.is_empty() {
645            if started {
646                pending_blanks += 1;
647            }
648            // leading blank lines are dropped entirely
649            continue;
650        }
651        if started {
652            // one separator for the previous content line, plus any buffered interior blanks
653            out.push('\n');
654            for _ in 0..pending_blanks {
655                out.push('\n');
656            }
657        }
658        pending_blanks = 0;
659        out.push_str(line);
660        started = true;
661    }
662    if started {
663        out.push('\n');
664    }
665    out
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    fn p(doc: &Doc, width: usize) -> String {
673        print(
674            doc,
675            &PrintOptions {
676                line_width: width,
677                indent_width: 4,
678            },
679        )
680    }
681
682    #[test]
683    fn text_and_concat() {
684        let doc = concat(vec![text("a"), space(), text("b")]);
685        assert_eq!(p(&doc, 80), "a b\n");
686    }
687
688    #[test]
689    fn cjk_text_is_measured_double_width() {
690        assert_eq!(text_width("abc"), 3);
691        assert_eq!(text_width("長芋"), 4); // two wide chars
692        assert_eq!(text_width("a長"), 3); // mixed
693                                          // Flat width is 長(2) + space(1) + 芋(2) = 5: fits at 5, breaks at 4.
694        let doc = group(concat(vec![text("長"), line(), text("芋")]));
695        assert_eq!(p(&doc, 4), "長\n芋\n");
696        assert_eq!(p(&doc, 5), "長 芋\n");
697    }
698
699    #[test]
700    fn non_ascii_width_uses_unicode_sequence_width() {
701        assert_eq!(text_width("e\u{301}"), 1); // e + combining acute accent
702        assert_eq!(text_width("👩\u{200d}💻"), 2); // woman technologist ZWJ sequence
703
704        // Flat width is é(1) + space(1) + 👩‍💻(2) = 4: fits at 4, breaks at 3.
705        let doc = group(concat(vec![text("e\u{301}"), line(), text("👩\u{200d}💻")]));
706        assert_eq!(p(&doc, 3), "e\u{301}\n👩\u{200d}💻\n");
707        assert_eq!(p(&doc, 4), "e\u{301} 👩\u{200d}💻\n");
708    }
709
710    #[test]
711    fn group_stays_flat_when_it_fits() {
712        let doc = group(concat(vec![text("a"), line(), text("b")]));
713        assert_eq!(p(&doc, 80), "a b\n");
714    }
715
716    #[test]
717    fn group_breaks_when_too_wide() {
718        let doc = group(concat(vec![
719            text("aaaa"),
720            indent(concat(vec![line(), text("bbbb")])),
721        ]));
722        assert_eq!(p(&doc, 5), "aaaa\n    bbbb\n");
723    }
724
725    #[test]
726    fn hard_line_forces_enclosing_group_to_break() {
727        let doc = group(concat(vec![text("a"), hard_line(), text("b")]));
728        assert_eq!(p(&doc, 80), "a\nb\n");
729    }
730
731    #[test]
732    fn soft_line_is_nothing_when_flat() {
733        let doc = group(concat(vec![text("a"), soft_line(), text("b")]));
734        assert_eq!(p(&doc, 80), "ab\n");
735    }
736
737    #[test]
738    fn join_interleaves_separator() {
739        let doc = join(text(", "), vec![text("a"), text("b"), text("c")]);
740        assert_eq!(p(&doc, 80), "a, b, c\n");
741    }
742
743    #[test]
744    fn best_fitting_picks_first_candidate_that_fits() {
745        let doc = best_fitting(vec![
746            text("short"),
747            concat(vec![text("fallback"), hard_line(), text("layout")]),
748        ]);
749        assert_eq!(p(&doc, 10), "short\n");
750    }
751
752    #[test]
753    fn best_fitting_falls_back_to_last_candidate() {
754        let doc = best_fitting(vec![
755            text("too-wide"),
756            concat(vec![text("fallback"), hard_line(), text("layout")]),
757        ]);
758        assert_eq!(p(&doc, 4), "fallback\nlayout\n");
759    }
760
761    #[test]
762    fn nested_indent_accumulates() {
763        let doc = concat(vec![
764            text("a"),
765            indent(concat(vec![
766                hard_line(),
767                text("b"),
768                indent(concat(vec![hard_line(), text("c")])),
769            ])),
770        ]);
771        assert_eq!(p(&doc, 80), "a\n    b\n        c\n");
772    }
773
774    #[test]
775    fn expanded_group_breaks_even_when_it_fits() {
776        let doc = group_expanded(concat(vec![
777            text("("),
778            indent(concat(vec![soft_line(), text("a")])),
779            soft_line(),
780            text(")"),
781        ]));
782        assert_eq!(p(&doc, 80), "(\n    a\n)\n");
783    }
784
785    #[test]
786    fn expanded_inner_group_forces_outer_to_break() {
787        // An exploded inner collection can't sit inline, so the outer group breaks too: the soft
788        // line before the inner group becomes a newline.
789        let inner = group_expanded(concat(vec![
790            text("("),
791            indent(concat(vec![soft_line(), text("x")])),
792            soft_line(),
793            text(")"),
794        ]));
795        let outer = group(concat(vec![text("a"), line(), inner]));
796        assert_eq!(p(&outer, 80), "a\n(\n    x\n)\n");
797    }
798
799    #[test]
800    fn line_suffix_defers_content_to_the_end_of_the_line() {
801        // The suffix is emitted before the hard line, even though it appears first in the concat.
802        let doc = concat(vec![
803            line_suffix(text(" -- note")),
804            text("code"),
805            hard_line(),
806            text("next"),
807        ]);
808        assert_eq!(p(&doc, 80), "code -- note\nnext\n");
809    }
810
811    #[test]
812    fn line_suffix_flushes_at_end_of_document() {
813        let doc = concat(vec![line_suffix(text(" -- note")), text("code")]);
814        assert_eq!(p(&doc, 80), "code -- note\n");
815    }
816
817    #[test]
818    fn break_parent_forces_its_group_to_break() {
819        let doc = group(concat(vec![
820            text("a"),
821            break_parent(),
822            indent(concat(vec![line(), text("b")])),
823        ]));
824        assert_eq!(p(&doc, 80), "a\n    b\n");
825    }
826
827    #[test]
828    fn empty_document_prints_nothing() {
829        assert_eq!(p(&empty(), 80), "");
830    }
831
832    #[test]
833    fn trailing_whitespace_is_trimmed() {
834        // A broken group whose indented line is immediately followed by a hard line must not leave
835        // spaces dangling on the blank line.
836        let doc = concat(vec![text("a"), indent(hard_line()), hard_line(), text("b")]);
837        assert_eq!(p(&doc, 80), "a\n\nb\n");
838    }
839
840    #[test]
841    fn text_caches_its_display_width() {
842        // The width folded in by `text` must equal a fresh measurement, for both ASCII (fast path)
843        // and wide CJK input — this is what the fit/break decision now reads instead of re-scanning.
844        for s in ["select", "a, b, c", "長芋", "a長b", ""] {
845            match text(s) {
846                Doc::Text(stored, width) => {
847                    assert_eq!(stored, s);
848                    assert_eq!(width, text_width(s));
849                }
850                other => panic!("text() should build a Text node, got {other:?}"),
851            }
852        }
853        // The ASCII fast path agrees with the Unicode width implementation.
854        assert_eq!(
855            text_width("SELECT a, b"),
856            UnicodeWidthStr::width("SELECT a, b")
857        );
858    }
859
860    #[test]
861    fn group_precomputes_its_forced_break() {
862        // A plain group over flat content stays optional; a group wrapping a hard line is marked
863        // must-break at construction, which is what the printer reads in O(1).
864        match group(concat(vec![text("a"), line(), text("b")])) {
865            Doc::Group { must_break, .. } => assert!(!must_break),
866            other => panic!("expected a group, got {other:?}"),
867        }
868        match group(concat(vec![text("a"), hard_line(), text("b")])) {
869            Doc::Group { must_break, .. } => assert!(must_break),
870            other => panic!("expected a group, got {other:?}"),
871        }
872        // group_expanded is always must-break.
873        match group_expanded(text("x")) {
874            Doc::Group { must_break, .. } => assert!(must_break),
875            other => panic!("expected a group, got {other:?}"),
876        }
877    }
878
879    #[test]
880    fn finalize_drops_leading_and_trailing_blank_lines_but_keeps_interior() {
881        // Leading and trailing blank lines vanish; an interior blank-line run is preserved; every
882        // line is right-trimmed. Equivalent to the previous collect/join/trim implementation.
883        let raw = String::from("\n  \na  \n\n\nb \n\n  \n");
884        assert_eq!(finalize(raw), "a\n\n\nb\n");
885        assert_eq!(finalize(String::new()), "");
886        assert_eq!(finalize(String::from("   \n  ")), "");
887        assert_eq!(finalize(String::from("only")), "only\n");
888    }
889
890    #[test]
891    fn source_code_slice_single_line_behaves_like_text() {
892        let doc = concat(vec![text("a "), source_code_slice("b = c"), text(" d")]);
893        assert_eq!(p(&doc, 80), "a b = c d\n");
894    }
895
896    #[test]
897    fn source_code_slice_caches_its_last_line_width() {
898        match source_code_slice("alpha\nbeta\nlong-tail") {
899            Doc::SourceCodeSlice(s, width) => {
900                assert_eq!(s, "alpha\nbeta\nlong-tail");
901                assert_eq!(width, "long-tail".len());
902            }
903            other => panic!("expected a source slice, got {other:?}"),
904        }
905        match source_code_slice("x\n") {
906            Doc::SourceCodeSlice(_, width) => assert_eq!(width, 0),
907            other => panic!("expected a source slice, got {other:?}"),
908        }
909    }
910
911    #[test]
912    fn source_code_slice_reindents_continuation_lines_under_indent() {
913        let doc = concat(vec![
914            text("header"),
915            indent(concat(vec![hard_line(), source_code_slice("v1\nv2\nv3")])),
916            hard_line(),
917            text("footer"),
918        ]);
919        assert_eq!(p(&doc, 80), "header\n    v1\n    v2\n    v3\nfooter\n");
920    }
921
922    #[test]
923    fn source_code_slice_tail_width_participates_in_fits() {
924        let doc = group(concat(vec![
925            source_code_slice("aa\nbbbb"),
926            line(),
927            text("c"),
928        ]));
929        assert_eq!(p(&doc, 6), "aa\nbbbb c\n");
930        assert_eq!(p(&doc, 5), "aa\nbbbb\nc\n");
931    }
932
933    #[test]
934    fn if_group_breaks_selects_layout_by_group_mode() {
935        let doc = group(concat(vec![
936            text("a"),
937            if_group_breaks(text(","), empty()),
938            line(),
939            text("b"),
940        ]));
941        assert_eq!(p(&doc, 80), "a b\n");
942        assert_eq!(p(&doc, 1), "a,\nb\n");
943    }
944
945    #[test]
946    fn if_group_breaks_flat_arm_forced_break_propagates_but_broken_arm_does_not() {
947        assert!(has_forced_break(&if_group_breaks(empty(), hard_line())));
948        assert!(!has_forced_break(&if_group_breaks(hard_line(), empty())));
949    }
950
951    #[test]
952    fn block_indent_frames_content_on_its_own_indented_line() {
953        let doc = concat(vec![text("{"), block_indent(text("body")), text("}")]);
954        assert_eq!(p(&doc, 80), "{\n    body\n}\n");
955    }
956
957    #[test]
958    fn doc_write_composes_format_values_in_order() {
959        let mut f = DocBuffer::new();
960        doc_write!(f, [text("a"), text(" = "), text("b")]);
961        assert_eq!(p(&group(f.finish()), 80), "a = b\n");
962    }
963
964    #[test]
965    fn format_rule_decouples_layout_from_data() {
966        struct AssignRule;
967        impl FormatRule<str> for AssignRule {
968            fn fmt(&self, item: &str, buffer: &mut DocBuffer) {
969                doc_write!(
970                    buffer,
971                    [text("x => \""), text(item.to_string()), text("\"")]
972                );
973            }
974        }
975
976        let mut f = DocBuffer::new();
977        f.write_with_rule(&AssignRule, "v");
978        assert_eq!(p(&f.finish(), 80), "x => \"v\"\n");
979    }
980}