spirt/print/
pretty.rs

1//! Pretty-printing functionality (such as automatic indentation).
2
3use indexmap::IndexSet;
4use internal_iterator::{
5    FromInternalIterator, InternalIterator, IntoInternalIterator, IteratorExt,
6};
7use smallvec::SmallVec;
8use std::borrow::Cow;
9use std::fmt::Write as _;
10use std::ops::ControlFlow;
11use std::rc::Rc;
12use std::{fmt, iter, mem};
13
14/// Part of a pretty document, made up of [`Node`]s.
15//
16// FIXME(eddyb) `Document` might be too long, what about renaming this to `Doc`?
17#[derive(Clone, Default, PartialEq)]
18pub struct Fragment {
19    pub nodes: SmallVec<[Node; 8]>,
20}
21
22#[derive(Clone, PartialEq)]
23pub enum Node {
24    Text(Option<Styles>, Cow<'static, str>),
25
26    /// Anchor (HTML `<a href="#...">`, optionally with `id="..."` when `is_def`),
27    /// using [`Node::Text`]-like "styled text" nodes for its text contents.
28    //
29    // FIXME(eddyb) could this use `Box<Fragment>` instead? may complicate layout
30    Anchor {
31        is_def: bool,
32        anchor: Rc<str>,
33        text: Box<[(Option<Styles>, Cow<'static, str>)]>,
34    },
35
36    /// Container for [`Fragment`]s, using block layout (indented on separate lines).
37    IndentedBlock(Vec<Fragment>),
38
39    /// Container for [`Fragment`]s, either using inline layout (all on one line)
40    /// or block layout (indented on separate lines).
41    InlineOrIndentedBlock(Vec<Fragment>),
42
43    /// Require that nodes before and after this node, are separated by some
44    /// whitespace (either by a single space, or by being on different lines).
45    ///
46    /// This is similar in effect to a `Text(" ")`, except that it doesn't add
47    /// leading/trailing spaces when found at the start/end of a line, as the
48    /// adjacent `\n` is enough of a "breaking space".
49    ///
50    /// Conversely, `Text(" ")` can be considered a "non-breaking space" (NBSP).
51    BreakingOnlySpace,
52
53    /// Require that nodes before and after this node, go on different lines.
54    ///
55    /// This is similar in effect to a `Text("\n")`, except that it doesn't
56    /// introduce a new `\n` when the previous/next node(s) already end/start
57    /// on a new line (whether from `Text("\n")` or another `ForceLineStart`).
58    ForceLineSeparation,
59
60    // FIXME(eddyb) replace this with something lower-level than layout.
61    IfBlockLayout(&'static str),
62}
63
64#[derive(Copy, Clone, Default, PartialEq)]
65pub struct Styles {
66    /// RGB color.
67    pub color: Option<[u8; 3]>,
68
69    /// `0.0` is fully transparent, `1.0` is fully opaque.
70    //
71    // FIXME(eddyb) move this into `color` (which would become RGBA).
72    pub color_opacity: Option<f32>,
73
74    /// `0` corresponds to the default, with positive values meaning thicker,
75    /// and negative values thinner text, respectively.
76    ///
77    /// For HTML output, each unit is equivalent to `±100` in CSS `font-weight`.
78    pub thickness: Option<i8>,
79
80    /// `0` corresponds to the default, with positive values meaning larger,
81    /// and negative values smaller text, respectively.
82    ///
83    /// For HTML output, each unit is equivalent to `±0.1em` in CSS `font-size`.
84    pub size: Option<i8>,
85
86    pub subscript: bool,
87    pub superscript: bool,
88
89    // FIXME(eddyb) maybe a more general `filter` system would be better?
90    pub desaturate_and_dim_for_unchanged_multiversion_line: bool,
91}
92
93impl Styles {
94    pub fn color(color: [u8; 3]) -> Self {
95        Self { color: Some(color), ..Self::default() }
96    }
97
98    pub fn apply(self, text: impl Into<Cow<'static, str>>) -> Node {
99        Node::Text(Some(self), text.into())
100    }
101
102    // HACK(eddyb) this allows us to control `<sub>`/`<sup>` `font-size` exactly,
103    // and use the same information for both layout and the CSS we emit.
104    fn effective_size(&self) -> Option<i8> {
105        self.size.or(if self.subscript || self.superscript { Some(-2) } else { None })
106    }
107}
108
109/// Color palettes built-in for convenience (colors are RGB, as `[u8; 3]`).
110pub mod palettes {
111    /// Minimalist palette, chosen to work with both light and dark backgrounds.
112    pub mod simple {
113        pub const DARK_GRAY: [u8; 3] = [0x44, 0x44, 0x44];
114        pub const LIGHT_GRAY: [u8; 3] = [0x88, 0x88, 0x88];
115
116        pub const RED: [u8; 3] = [0xcc, 0x55, 0x55];
117        pub const GREEN: [u8; 3] = [0x44, 0x99, 0x44];
118        pub const BLUE: [u8; 3] = [0x44, 0x66, 0xcc];
119
120        pub const YELLOW: [u8; 3] = [0xcc, 0x99, 0x44];
121        pub const MAGENTA: [u8; 3] = [0xcc, 0x44, 0xcc];
122        pub const CYAN: [u8; 3] = [0x44, 0x99, 0xcc];
123
124        pub const ORANGE: [u8; 3] = [0xcc, 0x77, 0x55];
125    }
126}
127
128impl From<&'static str> for Node {
129    fn from(text: &'static str) -> Self {
130        Self::Text(None, text.into())
131    }
132}
133
134impl From<String> for Node {
135    fn from(text: String) -> Self {
136        Self::Text(None, text.into())
137    }
138}
139
140impl<T: Into<Node>> From<T> for Fragment {
141    fn from(x: T) -> Self {
142        Self { nodes: [x.into()].into_iter().collect() }
143    }
144}
145
146impl Fragment {
147    pub fn new(fragments: impl IntoIterator<Item = impl Into<Self>>) -> Self {
148        Self { nodes: fragments.into_iter().flat_map(|fragment| fragment.into().nodes).collect() }
149    }
150
151    /// Perform layout on the [`Fragment`], limiting lines to `max_line_width`
152    /// columns where possible.
153    pub fn layout_with_max_line_width(mut self, max_line_width: usize) -> FragmentPostLayout {
154        // FIXME(eddyb) maybe make this a method on `Columns`?
155        let max_line_width =
156            Columns { char_width_tenths: max_line_width.try_into().unwrap_or(u16::MAX) * 10 };
157
158        self.approx_layout(MaxWidths { inline: max_line_width, block: max_line_width });
159        FragmentPostLayout(self)
160    }
161}
162
163// HACK(eddyb) simple wrapper to avoid misuse externally.
164pub struct FragmentPostLayout(Fragment);
165
166impl fmt::Display for FragmentPostLayout {
167    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168        let result = self
169            .render_to_text_ops()
170            .filter_map(|op| match op {
171                TextOp::Text(text) => Some(text),
172                _ => None,
173            })
174            .try_for_each(|text| {
175                f.write_str(text).map_or_else(ControlFlow::Break, ControlFlow::Continue)
176            });
177        match result {
178            ControlFlow::Continue(()) => Ok(()),
179            ControlFlow::Break(e) => Err(e),
180        }
181    }
182}
183
184impl FragmentPostLayout {
185    /// Flatten the [`Fragment`] to [`TextOp`]s.
186    pub(super) fn render_to_text_ops(&self) -> impl InternalIterator<Item = TextOp<'_>> {
187        self.0.render_to_text_ops()
188    }
189
190    /// Flatten the [`Fragment`] to HTML, producing a [`HtmlSnippet`].
191    //
192    // FIXME(eddyb) provide a non-allocating version.
193    pub fn render_to_html(&self) -> HtmlSnippet {
194        self.render_to_text_ops().collect()
195    }
196}
197
198#[derive(Default)]
199pub struct HtmlSnippet {
200    pub head_deduplicatable_elements: IndexSet<String>,
201    pub body: String,
202}
203
204impl HtmlSnippet {
205    /// Inject (using JavaScript) the ability to use `?dark` to choose a simple
206    /// "dark mode" (only different default background and foreground colors),
207    /// auto-detection using media queries, and `?light` to force-disable it.
208    pub fn with_dark_mode_support(&mut self) -> &mut Self {
209        self.head_deduplicatable_elements.insert(
210            r#"
211<script>
212    (function() {
213        var params = new URLSearchParams(document.location.search);
214        var dark = params.has("dark"), light = params.has("light");
215        if(dark || light) {
216            if(dark && !light) {
217                document.documentElement.classList.add("simple-dark-theme");
218
219                // HACK(eddyb) forcefully disable Dark Reader, for two reasons:
220                // - its own detection of websites with built-in dark themes
221                //   (https://github.com/darkreader/darkreader/pull/7995)
222                //   isn't on by default, and the combination is jarring
223                // - it interacts badly with whole-document-replacement
224                //   (as used by htmlpreview.github.io)
225                document.documentElement.removeAttribute('data-darkreader-scheme');
226                document.querySelectorAll('style.darkreader')
227                    .forEach(style => style.disabled = true);
228            }
229        } else if(matchMedia("(prefers-color-scheme: dark)").matches) {
230            // FIXME(eddyb) also use media queries in CSS directly, to ensure dark mode
231            // still works with JS disabled (sadly that likely requires CSS duplication).
232            document.location.search += (document.location.search ? "&" : "?") + "dark";
233        }
234    })();
235</script>
236
237<style>
238    /* HACK(eddyb) `[data-darkreader-scheme="dark"]` is for detecting Dark Reader,
239      to avoid transient interactions (see also comment in the `<script>`). */
240
241    html.simple-dark-theme:not([data-darkreader-scheme="dark"]) {
242        background: #16181a;
243        color: #dbd8d6;
244
245        /* Request browser UI elements to be dark-themed if possible. */
246        color-scheme: dark;
247    }
248</style>
249        "#
250            .into(),
251        );
252        self
253    }
254
255    /// Combine `head` and `body` into a complete HTML document, which starts
256    /// with `<!doctype html>`. Ideal for writing out a whole `.html` file.
257    //
258    // FIXME(eddyb) provide a non-allocating version.
259    pub fn to_html_doc(&self) -> String {
260        let mut html = String::new();
261        html += "<!doctype html>\n";
262        html += "<html>\n";
263
264        html += "<head>\n";
265        html += "<meta charset=\"utf-8\">\n";
266        for elem in &self.head_deduplicatable_elements {
267            html += elem;
268            html += "\n";
269        }
270        html += "</head>\n";
271
272        html += "<body>";
273        html += &self.body;
274        html += "</body>\n";
275
276        html += "</html>\n";
277
278        html
279    }
280}
281
282// FIXME(eddyb) is this impl the best way? (maybe it should be a inherent method)
283impl<'a> FromInternalIterator<TextOp<'a>> for HtmlSnippet {
284    fn from_iter<T>(text_ops: T) -> Self
285    where
286        T: IntoInternalIterator<Item = TextOp<'a>>,
287    {
288        // HACK(eddyb) using an UUID as a class name in lieu of "scoped <style>".
289        const ROOT_CLASS_NAME: &str = "spirt-90c2056d-5b38-4644-824a-b4be1c82f14d";
290
291        // FIXME(eddyb) consider interning styles into CSS classes, to avoid
292        // using inline `style="..."` attributes.
293        let style_elem = "
294<style>
295    SCOPE {
296        /* HACK(eddyb) reset default margin to something reasonable. */
297        margin: 1ch;
298
299        /* HACK(eddyb) avoid unnecessarily small or thin text. */
300        font-size: 17px;
301        font-weight: 500;
302    }
303    SCOPE a {
304        color: unset;
305        font-weight: 900;
306    }
307    SCOPE a:not(:hover) {
308        text-decoration: unset;
309    }
310    SCOPE sub, SCOPE sup {
311        line-height: 0;
312    }
313
314    /* HACK(eddyb) using a class (instead of an inline style) so that hovering
315       over a multiversion table cell can disable its desaturation/dimming */
316    SCOPE:not(:hover) .unchanged {
317        filter: saturate(0.3) opacity(0.5);
318    }
319</style>
320"
321        .replace("SCOPE", &format!("pre.{ROOT_CLASS_NAME}"));
322
323        let push_attr = |body: &mut String, attr, value: &str| {
324            // Quick sanity check.
325            assert!(value.chars().all(|c| !(c == '"' || c == '&')));
326
327            body.extend([" ", attr, "=\"", value, "\""]);
328        };
329
330        // HACK(eddyb) load-bearing newline after `<pre ...>`, to front-load any
331        // weird HTML whitespace handling, and allow the actual contents to start
332        // with empty lines (i.e. `\n\n...`), without e.g. losing the first one.
333        let mut body = format!("<pre class=\"{ROOT_CLASS_NAME}\">\n");
334        text_ops.into_internal_iter().for_each(|op| match op {
335            TextOp::PushStyles(styles) | TextOp::PopStyles(styles) => {
336                let mut special_tags = [("sub", styles.subscript), ("sup", styles.superscript)]
337                    .into_iter()
338                    .filter(|&(_, cond)| cond)
339                    .map(|(tag, _)| tag);
340                let tag = special_tags.next().unwrap_or("span");
341                if let Some(other_tag) = special_tags.next() {
342                    // FIXME(eddyb) support by opening/closing multiple tags.
343                    panic!("`<{tag}>` conflicts with `<{other_tag}>`");
344                }
345
346                body += "<";
347                if let TextOp::PopStyles(_) = op {
348                    body += "/";
349                }
350                body += tag;
351
352                if let TextOp::PushStyles(_) = op {
353                    let Styles {
354                        color,
355                        color_opacity,
356                        thickness,
357                        size: _,
358                        subscript,
359                        superscript,
360                        desaturate_and_dim_for_unchanged_multiversion_line,
361                    } = *styles;
362
363                    let mut css_style = String::new();
364
365                    if let Some(a) = color_opacity {
366                        let [r, g, b] = color.expect("color_opacity without color");
367                        write!(css_style, "color:rgba({r},{g},{b},{a});").unwrap();
368                    } else if let Some([r, g, b]) = color {
369                        write!(css_style, "color:#{r:02x}{g:02x}{b:02x};").unwrap();
370                    }
371                    if let Some(thickness) = thickness {
372                        write!(css_style, "font-weight:{};", 500 + (thickness as i32) * 100)
373                            .unwrap();
374                    }
375                    if let Some(size) = styles.effective_size() {
376                        write!(css_style, "font-size:{}em;", 1.0 + (size as f64) * 0.1).unwrap();
377                        if !(subscript || superscript) {
378                            // HACK(eddyb) without this, small text is placed too low.
379                            write!(css_style, "vertical-align:middle;").unwrap();
380                        }
381                    }
382                    if !css_style.is_empty() {
383                        push_attr(&mut body, "style", &css_style);
384                    }
385
386                    if desaturate_and_dim_for_unchanged_multiversion_line {
387                        push_attr(&mut body, "class", "unchanged");
388                    }
389                }
390
391                body += ">";
392            }
393            TextOp::PushAnchor { is_def, anchor } => {
394                body += "<a";
395
396                // HACK(eddyb) this avoids `push_attr` because anchors are pre-escaped.
397                // FIXME(eddyb) should escaping anchors be left to here?
398                assert!(anchor.chars().all(|c| c != '"'));
399                if is_def {
400                    write!(body, " id=\"{anchor}\"").unwrap();
401                }
402                write!(body, " href=\"#{anchor}\">").unwrap();
403            }
404            TextOp::PopAnchor { .. } => body += "</a>",
405            TextOp::Text(text) => {
406                // Minimal escaping, just enough to produce valid HTML.
407                let escape_from = ['&', '<'];
408                let escape_to = ["&amp;", "&lt;"];
409                for piece in text.split_inclusive(escape_from) {
410                    let mut chars = piece.chars();
411                    let maybe_needs_escape = chars.next_back();
412                    body += chars.as_str();
413
414                    if let Some(maybe_needs_escape) = maybe_needs_escape {
415                        match escape_from.iter().position(|&c| maybe_needs_escape == c) {
416                            Some(escape_idx) => body += escape_to[escape_idx],
417                            None => body.push(maybe_needs_escape),
418                        }
419                    }
420                }
421            }
422        });
423        body += "</pre>";
424
425        HtmlSnippet { head_deduplicatable_elements: [style_elem].into_iter().collect(), body }
426    }
427}
428
429// Rendering implementation details (including approximate layout).
430
431/// Fractional number of columns, used here to account for `Node::StyledText`
432/// being used to intentionally reduce the size of many "helper" pieces of text,
433/// at least for the HTML output (while this may lead to a less consistently
434/// formatted plaintext output, making good use of width is far more important
435/// for the HTML output, especially when used with `multiversion` tables).
436#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord)]
437struct Columns {
438    /// As our `font-size` control granularity is in multiples of `0.1em`,
439    /// the overall width of a line should end up a multiple of `0.1ch`,
440    /// i.e. we're counting tenths of a column's width at the default font size.
441    char_width_tenths: u16,
442}
443
444impl Columns {
445    const ZERO: Self = Self { char_width_tenths: 0 };
446
447    fn text_width(text: &str) -> Self {
448        Self::maybe_styled_text_width(text, None)
449    }
450
451    fn maybe_styled_text_width(text: &str, style: Option<&Styles>) -> Self {
452        assert!(!text.contains('\n'));
453
454        let font_size =
455            u16::try_from(10 + style.and_then(|style| style.effective_size()).unwrap_or(0))
456                .unwrap_or(0);
457
458        // FIXME(eddyb) use `unicode-width` crate for accurate column count.
459        Self {
460            char_width_tenths: text.len().try_into().unwrap_or(u16::MAX).saturating_mul(font_size),
461        }
462    }
463
464    fn saturating_add(self, other: Self) -> Self {
465        Self { char_width_tenths: self.char_width_tenths.saturating_add(other.char_width_tenths) }
466    }
467
468    fn saturating_sub(self, other: Self) -> Self {
469        Self { char_width_tenths: self.char_width_tenths.saturating_sub(other.char_width_tenths) }
470    }
471}
472
473/// The approximate shape of a [`Node`], regarding its 2D placement.
474#[derive(Copy, Clone)]
475enum ApproxLayout {
476    /// Only occupies part of a line, (at most) `worst_width` columns wide.
477    ///
478    /// `worst_width` can exceed the `inline` field of [`MaxWidths`], in which
479    /// case the choice of inline vs block is instead made by a surrounding node.
480    Inline {
481        worst_width: Columns,
482
483        /// How much of `worst_width` comes from `Node::IfBlockLayout` - that is,
484        /// `worst_width` still includes `Node::IfBlockLayout`, so conservative
485        /// decisions will still be made, but `excess_width_from_only_if_block`
486        /// can be used to reduce `worst_width` when block layout is no longer
487        /// a possibility (i.e. by the enclosing `Node::InlineOrIndentedBlock`).
488        excess_width_from_only_if_block: Columns,
489    },
490
491    /// Needs to occupy multiple lines, but may also have the equivalent of
492    /// an `Inline` before (`pre_`) and after (`post_`) the multi-line block.
493    //
494    // FIXME(eddyb) maybe turn `ApproxLayout` into a `struct` instead?
495    BlockOrMixed { pre_worst_width: Columns, post_worst_width: Columns },
496}
497
498impl ApproxLayout {
499    fn append(self, other: Self) -> Self {
500        match (self, other) {
501            (
502                Self::Inline { worst_width: a, excess_width_from_only_if_block: a_excess_foib },
503                Self::Inline { worst_width: b, excess_width_from_only_if_block: b_excess_foib },
504            ) => Self::Inline {
505                worst_width: a.saturating_add(b),
506                excess_width_from_only_if_block: a_excess_foib.saturating_add(b_excess_foib),
507            },
508            (
509                Self::BlockOrMixed { pre_worst_width, .. },
510                Self::BlockOrMixed { post_worst_width, .. },
511            ) => Self::BlockOrMixed { pre_worst_width, post_worst_width },
512            (
513                Self::BlockOrMixed { pre_worst_width, post_worst_width: post_a },
514                Self::Inline { worst_width: post_b, excess_width_from_only_if_block: _ },
515            ) => Self::BlockOrMixed {
516                pre_worst_width,
517                post_worst_width: post_a.saturating_add(post_b),
518            },
519            (
520                Self::Inline { worst_width: pre_a, excess_width_from_only_if_block: _ },
521                Self::BlockOrMixed { pre_worst_width: pre_b, post_worst_width },
522            ) => Self::BlockOrMixed {
523                pre_worst_width: pre_a.saturating_add(pre_b),
524                post_worst_width,
525            },
526        }
527    }
528}
529
530/// Maximum numbers of columns, available to a [`Node`], for both inline layout
531/// and block layout (i.e. multi-line with indentation).
532///
533/// That is, these are the best-case scenarios across all possible choices of
534/// inline vs block for all surrounding nodes (up to the root) that admit both
535/// cases, and those choices will be made inside-out based on actual widths.
536#[derive(Copy, Clone)]
537struct MaxWidths {
538    inline: Columns,
539    block: Columns,
540}
541
542// FIXME(eddyb) make this configurable.
543pub(super) const INDENT: &str = "  ";
544
545impl Node {
546    /// Determine the "rigid" component of the [`ApproxLayout`] of this [`Node`].
547    ///
548    /// That is, this accounts for the parts of the [`Node`] that don't depend on
549    /// contextual sizing, i.e. [`MaxWidths`] (see also `approx_flex_layout`).
550    fn approx_rigid_layout(&self) -> ApproxLayout {
551        // HACK(eddyb) workaround for the `Self::StyledText` arm not being able
552        // to destructure through the `Box<(_, Cow<str>)>`.
553        let text_approx_rigid_layout = |styles: &Option<_>, text: &str| {
554            let styles = styles.as_ref();
555            if let Some((pre, non_pre)) = text.split_once('\n') {
556                let (_, post) = non_pre.rsplit_once('\n').unwrap_or(("", non_pre));
557
558                ApproxLayout::BlockOrMixed {
559                    pre_worst_width: Columns::maybe_styled_text_width(pre, styles),
560                    post_worst_width: Columns::maybe_styled_text_width(post, styles),
561                }
562            } else {
563                ApproxLayout::Inline {
564                    worst_width: Columns::maybe_styled_text_width(text, styles),
565                    excess_width_from_only_if_block: Columns::ZERO,
566                }
567            }
568        };
569
570        #[allow(clippy::match_same_arms)]
571        match self {
572            Self::Text(styles, text) => text_approx_rigid_layout(styles, text),
573
574            Self::Anchor { is_def: _, anchor: _, text } => text
575                .iter()
576                .map(|(styles, text)| text_approx_rigid_layout(styles, text))
577                .reduce(ApproxLayout::append)
578                .unwrap_or(ApproxLayout::Inline {
579                    worst_width: Columns::ZERO,
580                    excess_width_from_only_if_block: Columns::ZERO,
581                }),
582
583            Self::IndentedBlock(_) => ApproxLayout::BlockOrMixed {
584                pre_worst_width: Columns::ZERO,
585                post_worst_width: Columns::ZERO,
586            },
587
588            Self::BreakingOnlySpace => ApproxLayout::Inline {
589                worst_width: Columns::text_width(" "),
590                excess_width_from_only_if_block: Columns::ZERO,
591            },
592            Self::ForceLineSeparation => ApproxLayout::BlockOrMixed {
593                pre_worst_width: Columns::ZERO,
594                post_worst_width: Columns::ZERO,
595            },
596            &Self::IfBlockLayout(text) => {
597                // Keep the inline `worst_width`, just in case this node is
598                // going to be used as part of an inline child of a block.
599                // NOTE(eddyb) this is currently only the case for the trailing
600                // comma added by `join_comma_sep`.
601                let text_layout = Self::Text(None, text.into()).approx_rigid_layout();
602                let worst_width = match text_layout {
603                    ApproxLayout::Inline { worst_width, excess_width_from_only_if_block: _ } => {
604                        worst_width
605                    }
606                    ApproxLayout::BlockOrMixed { .. } => Columns::ZERO,
607                };
608                ApproxLayout::Inline { worst_width, excess_width_from_only_if_block: worst_width }
609            }
610
611            // Layout computed only in `approx_flex_layout`.
612            Self::InlineOrIndentedBlock(_) => ApproxLayout::Inline {
613                worst_width: Columns::ZERO,
614                excess_width_from_only_if_block: Columns::ZERO,
615            },
616        }
617    }
618
619    /// Determine the "flexible" component of the [`ApproxLayout`] of this [`Node`],
620    /// potentially making adjustments in order to fit within `max_widths`.
621    ///
622    /// That is, this accounts for the parts of the [`Node`] that do depend on
623    /// contextual sizing, i.e. [`MaxWidths`] (see also `approx_rigid_layout`).
624    fn approx_flex_layout(&mut self, max_widths: MaxWidths) -> ApproxLayout {
625        match self {
626            Self::IndentedBlock(fragments) => {
627                // Apply one more level of indentation to the block layout.
628                let indented_block_max_width =
629                    max_widths.block.saturating_sub(Columns::text_width(INDENT));
630
631                // Recurse on `fragments`, so they can compute their own layouts.
632                for fragment in &mut fragments[..] {
633                    fragment.approx_layout(MaxWidths {
634                        inline: indented_block_max_width,
635                        block: indented_block_max_width,
636                    });
637                }
638
639                ApproxLayout::BlockOrMixed {
640                    pre_worst_width: Columns::ZERO,
641                    post_worst_width: Columns::ZERO,
642                }
643            }
644
645            Self::InlineOrIndentedBlock(fragments) => {
646                // Apply one more level of indentation to the block layout.
647                let indented_block_max_width =
648                    max_widths.block.saturating_sub(Columns::text_width(INDENT));
649
650                // Maximize the inline width available to `fragments`, usually
651                // increasing it to the maximum allowed by the block layout.
652                // However, block layout is only needed if the extra width is
653                // actually used by `fragments` (i.e. staying within the original
654                // `max_widths.inline` will keep inline layout).
655                let inner_max_widths = MaxWidths {
656                    inline: max_widths.inline.max(indented_block_max_width),
657                    block: indented_block_max_width,
658                };
659
660                let mut layout = ApproxLayout::Inline {
661                    worst_width: Columns::ZERO,
662                    excess_width_from_only_if_block: Columns::ZERO,
663                };
664                for fragment in &mut fragments[..] {
665                    // Offer the same `inner_max_widths` to each `fragment`.
666                    // Worst case, they all remain inline and block layout is
667                    // needed, but even then, `inner_max_widths` has limited each
668                    // `fragment` to a maximum appropriate for that block layout.
669                    layout = layout.append(fragment.approx_layout(inner_max_widths));
670                }
671
672                // *If* we pick the inline layout, it will not end up using *any*
673                // `Node::OnlyIfBlock`s, so `excess_width_from_only_if_block` can
674                // be safely subtracted from the "candidate" inline `worst_width`.
675                let candidate_inline_worst_width = match layout {
676                    ApproxLayout::Inline { worst_width, excess_width_from_only_if_block } => {
677                        Some(worst_width.saturating_sub(excess_width_from_only_if_block))
678                    }
679
680                    ApproxLayout::BlockOrMixed { .. } => None,
681                };
682
683                let inline_layout = candidate_inline_worst_width
684                    .filter(|&worst_width| worst_width <= max_widths.inline)
685                    .map(|worst_width| ApproxLayout::Inline {
686                        worst_width,
687                        excess_width_from_only_if_block: Columns::ZERO,
688                    });
689
690                layout = inline_layout.unwrap_or(
691                    // Even if `layout` is already `ApproxLayout::BlockOrMixed`,
692                    // always reset it to a plain block, with no pre/post widths.
693                    ApproxLayout::BlockOrMixed {
694                        pre_worst_width: Columns::ZERO,
695                        post_worst_width: Columns::ZERO,
696                    },
697                );
698
699                match layout {
700                    ApproxLayout::Inline { .. } => {
701                        // Leave `self` as `Node::InlineOrIndentedBlock` and
702                        // have that be implied to be in inline layout.
703                    }
704                    ApproxLayout::BlockOrMixed { .. } => {
705                        *self = Self::IndentedBlock(mem::take(fragments));
706                    }
707                }
708
709                layout
710            }
711
712            // Layout computed only in `approx_rigid_layout`.
713            Self::Text(..)
714            | Self::Anchor { .. }
715            | Self::BreakingOnlySpace
716            | Self::ForceLineSeparation
717            | Self::IfBlockLayout(_) => ApproxLayout::Inline {
718                worst_width: Columns::ZERO,
719                excess_width_from_only_if_block: Columns::ZERO,
720            },
721        }
722    }
723}
724
725impl Fragment {
726    /// Determine the [`ApproxLayout`] of this [`Fragment`], potentially making
727    /// adjustments in order to fit within `max_widths`.
728    fn approx_layout(&mut self, max_widths: MaxWidths) -> ApproxLayout {
729        let mut layout = ApproxLayout::Inline {
730            worst_width: Columns::ZERO,
731            excess_width_from_only_if_block: Columns::ZERO,
732        };
733
734        let child_max_widths = |layout| MaxWidths {
735            inline: match layout {
736                ApproxLayout::Inline { worst_width, excess_width_from_only_if_block: _ } => {
737                    max_widths.inline.saturating_sub(worst_width)
738                }
739                ApproxLayout::BlockOrMixed { post_worst_width, .. } => {
740                    max_widths.block.saturating_sub(post_worst_width)
741                }
742            },
743            block: max_widths.block,
744        };
745
746        // Compute rigid `ApproxLayout`s as long as they remain inline, only
747        // going back for flexible ones on block boundaries (and at the end),
748        // ensuring that the `MaxWidths` are as contraining as possible.
749        let mut next_flex_idx = 0;
750        for rigid_idx in 0..self.nodes.len() {
751            match self.nodes[rigid_idx].approx_rigid_layout() {
752                rigid_layout @ ApproxLayout::Inline { .. } => {
753                    layout = layout.append(rigid_layout);
754                }
755                ApproxLayout::BlockOrMixed { pre_worst_width, post_worst_width } => {
756                    // Split the `BlockOrMixed` just before the block, and
757                    // process "recent" flexible nodes in between the halves.
758                    layout = layout.append(ApproxLayout::Inline {
759                        worst_width: pre_worst_width,
760                        excess_width_from_only_if_block: Columns::ZERO,
761                    });
762                    // FIXME(eddyb) what happens if the same node has both
763                    // rigid and flexible `ApproxLayout`s?
764                    while next_flex_idx <= rigid_idx {
765                        layout = layout.append(
766                            self.nodes[next_flex_idx].approx_flex_layout(child_max_widths(layout)),
767                        );
768                        next_flex_idx += 1;
769                    }
770                    layout = layout.append(ApproxLayout::BlockOrMixed {
771                        pre_worst_width: Columns::ZERO,
772                        post_worst_width,
773                    });
774                }
775            }
776        }
777
778        // Process all remaining flexible nodes (i.e. after the last line split).
779        for flex_idx in next_flex_idx..self.nodes.len() {
780            layout =
781                layout.append(self.nodes[flex_idx].approx_flex_layout(child_max_widths(layout)));
782        }
783
784        layout
785    }
786}
787
788/// Line-oriented operation (i.e. as if lines are stored separately).
789///
790/// However, a representation that stores lines separately doesn't really exist,
791/// and instead [`LineOp`]s are (statefully) transformed into [`TextOp`]s on the fly
792/// (see [`LineOp::interpret_with`]).
793#[derive(Copy, Clone)]
794enum LineOp<'a> {
795    PushIndent,
796    PopIndent,
797    PushStyles(&'a Styles),
798    PopStyles(&'a Styles),
799    PushAnchor { is_def: bool, anchor: &'a str },
800    PopAnchor { is_def: bool, anchor: &'a str },
801
802    // HACK(eddyb) `PushAnchor`+`PopAnchor`, indicating no visible text is needed
803    // (i.e. this is only for helper anchors, which only need vertical positioning).
804    EmptyAnchor { is_def: bool, anchor: &'a str },
805
806    AppendToLine(&'a str),
807    StartNewLine,
808    BreakIfWithinLine(Break),
809}
810
811#[derive(Copy, Clone)]
812enum Break {
813    Space,
814    NewLine,
815}
816
817impl Node {
818    /// Flatten the [`Node`] to [`LineOp`]s.
819    fn render_to_line_ops(
820        &self,
821        directly_in_block: bool,
822    ) -> impl InternalIterator<Item = LineOp<'_>> {
823        // FIXME(eddyb) a better helper for this may require type-generic closures.
824        struct RenderToLineOps<'a>(&'a Node, bool);
825        impl<'a> InternalIterator for RenderToLineOps<'a> {
826            type Item = LineOp<'a>;
827
828            fn try_for_each<T, F>(self, mut f: F) -> ControlFlow<T>
829            where
830                F: FnMut(LineOp<'a>) -> ControlFlow<T>,
831            {
832                // HACK(eddyb) this is terrible but the `internal_iterator`
833                // library uses `F` instead of `&mut F` which means it has to
834                // add an extra `&mut` for every `flat_map` level, causing
835                // polymorphic recursion...
836                let f = &mut f as &mut dyn FnMut(_) -> _;
837
838                self.0.render_to_line_ops_try_for_each_helper(self.1, f)
839            }
840        }
841        RenderToLineOps(self, directly_in_block)
842    }
843
844    // HACK(eddyb) helper for `render_to_line_ops` returning a `InternalIterator`.
845    fn render_to_line_ops_try_for_each_helper<'a, T>(
846        &'a self,
847        directly_in_block: bool,
848        mut each_line_op: impl FnMut(LineOp<'a>) -> ControlFlow<T>,
849    ) -> ControlFlow<T> {
850        let text_render_to_line_ops = |styles: &'a Option<Styles>, text: &'a str| {
851            let styles = styles.as_ref();
852            let mut lines = text.split('\n');
853            styles
854                .map(LineOp::PushStyles)
855                .into_internal_iter()
856                .chain([LineOp::AppendToLine(lines.next().unwrap())])
857                .chain(
858                    lines
859                        .into_internal()
860                        .flat_map(|line| [LineOp::StartNewLine, LineOp::AppendToLine(line)]),
861                )
862                .chain(styles.map(LineOp::PopStyles))
863        };
864        match self {
865            Self::Text(styles, text) => {
866                text_render_to_line_ops(styles, text).try_for_each(each_line_op)?;
867            }
868
869            &Self::Anchor { is_def, ref anchor, ref text } => {
870                if text.is_empty() {
871                    each_line_op(LineOp::EmptyAnchor { is_def, anchor })?;
872                } else {
873                    [LineOp::PushAnchor { is_def, anchor }]
874                        .into_internal_iter()
875                        .chain(
876                            text.into_internal_iter()
877                                .flat_map(|(styles, text)| text_render_to_line_ops(styles, text)),
878                        )
879                        .chain([LineOp::PopAnchor { is_def, anchor }])
880                        .try_for_each(each_line_op)?;
881                }
882            }
883
884            Self::IndentedBlock(fragments) => {
885                [LineOp::PushIndent, LineOp::BreakIfWithinLine(Break::NewLine)]
886                    .into_internal_iter()
887                    .chain(fragments.into_internal_iter().flat_map(|fragment| {
888                        fragment
889                            .render_to_line_ops(true)
890                            .chain([LineOp::BreakIfWithinLine(Break::NewLine)])
891                    }))
892                    .chain([LineOp::PopIndent])
893                    .try_for_each(each_line_op)?;
894            }
895            // Post-layout, this is only used for the inline layout.
896            Self::InlineOrIndentedBlock(fragments) => {
897                fragments
898                    .into_internal_iter()
899                    .flat_map(|fragment| fragment.render_to_line_ops(false))
900                    .try_for_each(each_line_op)?;
901            }
902
903            Self::BreakingOnlySpace => each_line_op(LineOp::BreakIfWithinLine(Break::Space))?,
904            Self::ForceLineSeparation => each_line_op(LineOp::BreakIfWithinLine(Break::NewLine))?,
905            &Self::IfBlockLayout(text) => {
906                if directly_in_block {
907                    text_render_to_line_ops(&None, text).try_for_each(each_line_op)?;
908                }
909            }
910        }
911        ControlFlow::Continue(())
912    }
913}
914
915impl Fragment {
916    /// Flatten the [`Fragment`] to [`LineOp`]s.
917    fn render_to_line_ops(
918        &self,
919        directly_in_block: bool,
920    ) -> impl InternalIterator<Item = LineOp<'_>> {
921        self.nodes
922            .iter()
923            .into_internal()
924            .flat_map(move |node| node.render_to_line_ops(directly_in_block))
925    }
926
927    /// Flatten the [`Fragment`] to [`TextOp`]s.
928    fn render_to_text_ops(&self) -> impl InternalIterator<Item = TextOp<'_>> {
929        LineOp::interpret(self.render_to_line_ops(false))
930    }
931}
932
933/// Text-oriented operation (plain text snippets interleaved with style/anchor push/pop).
934#[derive(Copy, Clone, PartialEq)]
935pub(super) enum TextOp<'a> {
936    PushStyles(&'a Styles),
937    PopStyles(&'a Styles),
938    PushAnchor { is_def: bool, anchor: &'a str },
939    PopAnchor { is_def: bool, anchor: &'a str },
940
941    Text(&'a str),
942}
943
944impl<'a> LineOp<'a> {
945    /// Expand [`LineOp`]s to [`TextOp`]s.
946    fn interpret(
947        line_ops: impl InternalIterator<Item = LineOp<'a>>,
948    ) -> impl InternalIterator<Item = TextOp<'a>> {
949        // FIXME(eddyb) a better helper for this may require type-generic closures.
950        struct Interpret<I>(I);
951        impl<'a, I: InternalIterator<Item = LineOp<'a>>> InternalIterator for Interpret<I> {
952            type Item = TextOp<'a>;
953
954            fn try_for_each<T, F>(self, f: F) -> ControlFlow<T>
955            where
956                F: FnMut(TextOp<'a>) -> ControlFlow<T>,
957            {
958                LineOp::interpret_try_for_each_helper(self.0, f)
959            }
960        }
961        Interpret(line_ops)
962    }
963
964    // HACK(eddyb) helper for `interpret` returning a `InternalIterator`.
965    fn interpret_try_for_each_helper<T>(
966        line_ops: impl InternalIterator<Item = LineOp<'a>>,
967        mut each_text_op: impl FnMut(TextOp<'a>) -> ControlFlow<T>,
968    ) -> ControlFlow<T> {
969        let mut indent = 0;
970
971        enum LineState {
972            /// This line was just started, lacking any text.
973            ///
974            /// The first (non-empty) `LineOp::AppendToLine` on that line, or
975            /// `LineOp::{Push,Pop}{Styles,Anchor}`, needs to materialize
976            /// `indent` levels of indentation (before emitting its `TextOp`s).
977            //
978            // NOTE(eddyb) indentation is not immediatelly materialized in order
979            // to avoid trailing whitespace on otherwise-empty lines.
980            Empty,
981
982            /// This line has `indent_so_far` levels of indentation, and may have
983            /// styling applied to it, but lacks any other text.
984            ///
985            /// Only used by `LineOp::EmptyAnchor` (i.e. helper anchors),
986            /// to avoid them adding trailing-whitespace-only lines.
987            //
988            // NOTE(eddyb) the new line is started by `EmptyAnchor` so that
989            // there remains separation with the previous (unrelated) line,
990            // whereas the following lines are very likely related to the
991            // helper anchor (but if that changes, this would need to be fixed).
992            // HACK(eddyb) `EmptyAnchor` uses `indent_so_far: 0` to
993            // allow lower-indentation text to follow on the same line.
994            OnlyIndentedOrAnchored { indent_so_far: usize },
995
996            /// This line has had text emitted (other than indentation).
997            HasText,
998        }
999        let mut line_state = LineState::Empty;
1000
1001        // Deferred `LineOp::BreakIfWithinLine`, which will be materialized
1002        // only between two consecutive `LineOp::AppendToLine { text, .. }`
1003        // (with non-empty `text`), that (would) share the same line.
1004        let mut pending_break_if_within_line = None;
1005
1006        line_ops.try_for_each(move |op| {
1007            // Do not allow (accidental) side-effects from no-op `op`s.
1008            if let LineOp::AppendToLine("") = op {
1009                return ControlFlow::Continue(());
1010            }
1011
1012            if let LineOp::AppendToLine(_)
1013            | LineOp::PushStyles(_)
1014            | LineOp::PopStyles(_)
1015            | LineOp::PushAnchor { .. }
1016            | LineOp::PopAnchor { .. }
1017            | LineOp::EmptyAnchor { .. } = op
1018            {
1019                if let Some(br) = pending_break_if_within_line.take() {
1020                    each_text_op(TextOp::Text(match br {
1021                        Break::Space => " ",
1022                        Break::NewLine => "\n",
1023                    }))?;
1024                    if matches!(br, Break::NewLine) {
1025                        line_state = LineState::Empty;
1026                    }
1027                }
1028
1029                let target_indent = match line_state {
1030                    // HACK(eddyb) `EmptyAnchor` uses `indent_so_far: 0` to
1031                    // allow lower-indentation text to follow on the same line.
1032                    LineState::Empty | LineState::OnlyIndentedOrAnchored { indent_so_far: 0 }
1033                        if matches!(op, LineOp::EmptyAnchor { .. }) =>
1034                    {
1035                        Some(0)
1036                    }
1037
1038                    LineState::Empty | LineState::OnlyIndentedOrAnchored { .. } => Some(indent),
1039                    LineState::HasText => None,
1040                };
1041
1042                if let Some(target_indent) = target_indent {
1043                    let indent_so_far = match line_state {
1044                        LineState::Empty => 0,
1045
1046                        // FIXME(eddyb) `EmptyAnchor` doesn't need this, so this
1047                        // is perhaps unnecessarily over-engineered? (see above)
1048                        LineState::OnlyIndentedOrAnchored { indent_so_far } => {
1049                            // Disallow reusing lines already indented too much.
1050                            if indent_so_far > target_indent {
1051                                each_text_op(TextOp::Text("\n"))?;
1052                                line_state = LineState::Empty;
1053                                0
1054                            } else {
1055                                indent_so_far
1056                            }
1057                        }
1058
1059                        LineState::HasText => unreachable!(),
1060                    };
1061                    for _ in indent_so_far..target_indent {
1062                        each_text_op(TextOp::Text(INDENT))?;
1063                    }
1064                    line_state = LineState::OnlyIndentedOrAnchored { indent_so_far: target_indent };
1065                }
1066            }
1067
1068            match op {
1069                LineOp::PushIndent => {
1070                    indent += 1;
1071                }
1072
1073                LineOp::PopIndent => {
1074                    assert!(indent > 0);
1075                    indent -= 1;
1076                }
1077
1078                LineOp::PushStyles(styles) => each_text_op(TextOp::PushStyles(styles))?,
1079                LineOp::PopStyles(styles) => each_text_op(TextOp::PopStyles(styles))?,
1080
1081                LineOp::PushAnchor { is_def, anchor } => {
1082                    each_text_op(TextOp::PushAnchor { is_def, anchor })?;
1083                }
1084                LineOp::PopAnchor { is_def, anchor } => {
1085                    each_text_op(TextOp::PopAnchor { is_def, anchor })?;
1086                }
1087
1088                LineOp::EmptyAnchor { is_def, anchor } => {
1089                    each_text_op(TextOp::PushAnchor { is_def, anchor })?;
1090                    each_text_op(TextOp::PopAnchor { is_def, anchor })?;
1091                }
1092
1093                LineOp::AppendToLine(text) => {
1094                    each_text_op(TextOp::Text(text))?;
1095
1096                    line_state = LineState::HasText;
1097                }
1098
1099                LineOp::StartNewLine => {
1100                    each_text_op(TextOp::Text("\n"))?;
1101
1102                    line_state = LineState::Empty;
1103                    pending_break_if_within_line = None;
1104                }
1105
1106                LineOp::BreakIfWithinLine(br) => {
1107                    let elide = match line_state {
1108                        LineState::Empty => true,
1109                        LineState::OnlyIndentedOrAnchored { indent_so_far } => {
1110                            indent_so_far <= indent
1111                        }
1112                        LineState::HasText => false,
1113                    };
1114                    if !elide {
1115                        // Merge two pending `Break`s if necessary,
1116                        // preferring newlines over spaces.
1117                        let br = match (pending_break_if_within_line, br) {
1118                            (Some(Break::NewLine), _) | (_, Break::NewLine) => Break::NewLine,
1119                            (None | Some(Break::Space), Break::Space) => Break::Space,
1120                        };
1121
1122                        pending_break_if_within_line = Some(br);
1123                    }
1124                }
1125            }
1126            ControlFlow::Continue(())
1127        })
1128    }
1129}
1130
1131// Pretty fragment "constructors".
1132//
1133// FIXME(eddyb) should these be methods on `Node`/`Fragment`?
1134
1135/// Constructs the [`Fragment`] corresponding to one of:
1136/// * inline layout: `header + " " + contents.join(" ")`
1137/// * block layout: `header + "\n" + indent(contents).join("\n")`
1138pub fn join_space(
1139    header: impl Into<Node>,
1140    contents: impl IntoIterator<Item = impl Into<Fragment>>,
1141) -> Fragment {
1142    Fragment::new([
1143        header.into(),
1144        Node::InlineOrIndentedBlock(
1145            contents
1146                .into_iter()
1147                .map(|entry| {
1148                    Fragment::new(iter::once(Node::BreakingOnlySpace).chain(entry.into().nodes))
1149                })
1150                .collect(),
1151        ),
1152    ])
1153}
1154
1155/// Constructs the [`Fragment`] corresponding to one of:
1156/// * inline layout: `prefix + contents.join(", ") + suffix`
1157/// * block layout: `prefix + "\n" + indent(contents).join(",\n") + ",\n" + suffix`
1158pub fn join_comma_sep(
1159    prefix: impl Into<Node>,
1160    contents: impl IntoIterator<Item = impl Into<Fragment>>,
1161    suffix: impl Into<Node>,
1162) -> Fragment {
1163    let mut children: Vec<_> = contents.into_iter().map(Into::into).collect();
1164
1165    if let Some((last_child, non_last_children)) = children.split_last_mut() {
1166        for non_last_child in non_last_children {
1167            non_last_child.nodes.extend([",".into(), Node::BreakingOnlySpace]);
1168        }
1169
1170        // Trailing comma is only needed after the very last element.
1171        last_child.nodes.push(Node::IfBlockLayout(","));
1172    }
1173
1174    Fragment::new([prefix.into(), Node::InlineOrIndentedBlock(children), suffix.into()])
1175}