spirt 0.1.0

Shader-focused IR to target, transform and translate from.
Documentation
//! Pretty-printing functionality (such as automatic indentation).

use indexmap::IndexSet;
use smallvec::SmallVec;
use std::borrow::Cow;
use std::fmt::Write as _;
use std::{fmt, iter, mem};

/// Part of a pretty document, made up of [`Node`]s.
//
// FIXME(eddyb) `Document` might be too long, what about renaming this to `Doc`?
#[derive(Clone, Default, PartialEq)]
pub struct Fragment {
    pub nodes: SmallVec<[Node; 8]>,
}

#[derive(Clone, PartialEq)]
pub enum Node {
    Text(Cow<'static, str>),

    // FIXME(eddyb) should this contain a `Node` instead of being text-only?
    StyledText(Box<(Styles, Cow<'static, str>)>),

    /// Container for [`Fragment`]s, using block layout (indented on separate lines).
    IndentedBlock(Vec<Fragment>),

    /// Container for [`Fragment`]s, either using inline layout (all on one line)
    /// or block layout (indented on separate lines).
    InlineOrIndentedBlock(Vec<Fragment>),

    /// Require that nodes before and after this node, are separated by some
    /// whitespace (either by a single space, or by being on different lines).
    ///
    /// This is similar in effect to a `Text(" ")`, except that it doesn't add
    /// leading/trailing spaces when found at the start/end of a line, as the
    /// adjacent `\n` is enough of a "breaking space".
    ///
    /// Conversely, `Text(" ")` can be considered a "non-breaking space" (NBSP).
    BreakingOnlySpace,

    /// Require that nodes before and after this node, go on different lines.
    ///
    /// This is similar in effect to a `Text("\n")`, except that it doesn't
    /// introduce a new `\n` when the previous/next node(s) already end/start
    /// on a new line (whether from `Text("\n")` or another `ForceLineStart`).
    ForceLineSeparation,

    // FIXME(eddyb) replace this with something lower-level than layout.
    IfBlockLayout(&'static str),
}

#[derive(Clone, Default, PartialEq)]
pub struct Styles {
    pub anchor: Option<String>,
    pub anchor_is_def: bool,

    /// RGB color.
    pub color: Option<[u8; 3]>,

    /// `0.0` is fully transparent, `1.0` is fully opaque.
    //
    // FIXME(eddyb) move this into `color` (which would become RGBA).
    pub color_opacity: Option<f32>,

    /// `0` corresponds to the default, with positive values meaning thicker,
    /// and negative values thinner text, respectively.
    ///
    /// For HTML output, each unit is equivalent to `±100` in CSS `font-weight`.
    pub thickness: Option<i8>,

    pub subscript: bool,
    pub superscript: bool,
}

impl Styles {
    pub fn color(color: [u8; 3]) -> Self {
        Self {
            color: Some(color),
            ..Self::default()
        }
    }

    pub fn apply(self, text: impl Into<Cow<'static, str>>) -> Node {
        Node::StyledText(Box::new((self, text.into())))
    }
}

/// Color palettes built-in for convenience (colors are RGB, as `[u8; 3]`).
pub mod palettes {
    /// Minimalist palette, chosen to work with both light and dark backgrounds.
    pub mod simple {
        pub const DARK_GRAY: [u8; 3] = [0x44, 0x44, 0x44];
        pub const RED: [u8; 3] = [0xcc, 0x55, 0x55];
        pub const GREEN: [u8; 3] = [0x44, 0x99, 0x44];
        pub const BLUE: [u8; 3] = [0x44, 0x66, 0xcc];
        pub const YELLOW: [u8; 3] = [0xcc, 0x99, 0x44];
        pub const MAGENTA: [u8; 3] = [0xcc, 0x44, 0xcc];
        pub const CYAN: [u8; 3] = [0x44, 0x99, 0xcc];
    }
}

impl From<&'static str> for Node {
    fn from(text: &'static str) -> Self {
        Self::Text(text.into())
    }
}

impl From<String> for Node {
    fn from(text: String) -> Self {
        Self::Text(text.into())
    }
}

impl<T: Into<Node>> From<T> for Fragment {
    fn from(x: T) -> Self {
        Self {
            nodes: iter::once(x.into()).into_iter().collect(),
        }
    }
}

impl Fragment {
    pub fn new(fragments: impl IntoIterator<Item = impl Into<Self>>) -> Self {
        Self {
            nodes: fragments
                .into_iter()
                .flat_map(|fragment| fragment.into().nodes)
                .collect(),
        }
    }

    /// Perform layout on the [`Fragment`], limiting lines to `max_line_width`
    /// columns where possible.
    pub fn layout_with_max_line_width(mut self, max_line_width: usize) -> FragmentPostLayout {
        self.approx_layout(MaxWidths {
            inline: max_line_width,
            block: max_line_width,
        });
        FragmentPostLayout(self)
    }
}

// HACK(eddyb) simple wrapper to avoid misuse externally.
pub struct FragmentPostLayout(Fragment);

impl fmt::Display for FragmentPostLayout {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let mut result = Ok(());
        self.0.render_to_line_ops(
            &mut LineOp::interpret_with(|op| {
                if let TextOp::Text(text) = op {
                    result = result.and_then(|_| f.write_str(text));
                }
            }),
            false,
        );
        result
    }
}

#[derive(Default)]
pub struct HtmlSnippet {
    pub head_deduplicatable_elements: IndexSet<String>,
    pub body: String,
}

impl HtmlSnippet {
    /// Inject (using JavaScript) the ability to use `?dark` to choose a simple
    /// "dark mode" (only different default background and foreground colors),
    /// auto-detection using media queries, and `?light` to force-disable it.
    pub fn with_dark_mode_support(&mut self) -> &mut Self {
        self.head_deduplicatable_elements.insert(
            r#"
<script>
    (function() {
        var params = new URLSearchParams(document.location.search);
        var dark = params.has("dark"), light = params.has("light");
        if(dark || light) {
            if(dark && !light)
                document.documentElement.classList.add("simple-dark-theme");
        } else if(matchMedia("(prefers-color-scheme: dark)").matches) {
            // FIXME(eddyb) also use media queries in CSS directly, to ensure dark mode
            // still works with JS disabled (sadly that likely requires CSS duplication).
            document.location.search += (document.location.search ? "&" : "?") + "dark";
        }
    })();
</script>

<style>
    /* HACK(eddyb) `[data-darkreader-scheme="dark"]` is for detecting Dark Reader,
      as its own automatic detection of websites with built-in dark themes
      (https://github.com/darkreader/darkreader/pull/7995) isn't on by default,
      and the result is jarring when both dark modes combine. */

    html.simple-dark-theme:not([data-darkreader-scheme="dark"]) {
        background: #16181a;
        color: #dbd8d6;

        /* Request browser UI elements to be dark-themed if possible. */
        color-scheme: dark;
    }
</style>
        "#
            .into(),
        );
        self
    }

    /// Combine `head` and `body` into a complete HTML document, which starts
    /// with `<!doctype html>`. Ideal for writing out a whole `.html` file.
    //
    // FIXME(eddyb) provide a non-allocating version.
    pub fn to_html_doc(&self) -> String {
        let mut html = String::new();
        html += "<!doctype html>\n";
        html += "<html>\n";

        html += "<head>\n";
        html += "<meta charset=\"utf-8\">\n";
        for elem in &self.head_deduplicatable_elements {
            html += elem;
            html += "\n";
        }
        html += "</head>\n";

        html += "<body>";
        html += &self.body;
        html += "</body>\n";

        html += "</html>\n";

        html
    }
}

impl FragmentPostLayout {
    /// Flatten the [`Fragment`] to HTML, producing a [`HtmlSnippet`].
    //
    // FIXME(eddyb) provide a non-allocating version.
    pub fn render_to_html(&self) -> HtmlSnippet {
        // HACK(eddyb) using an UUID as a class name in lieu of "scoped <style>".
        const ROOT_CLASS_NAME: &str = "spirt-90c2056d-5b38-4644-824a-b4be1c82f14d";

        // FIXME(eddyb) consider interning styles into CSS classes, to avoid
        // using inline `style="..."` attributes.
        let style_elem = "
<style>
    SCOPE {
        /* HACK(eddyb) reset default margin to something reasonable. */
        margin: 1ch;

        /* HACK(eddyb) avoid unnecessarily small or thin text. */
        font-size: 15px;
        font-weight: 500;
    }
    SCOPE a {
        color: unset;
        font-weight: 900;
    }
    SCOPE a:not(:hover) {
        text-decoration: unset;
    }
</style>
"
        .replace("SCOPE", &format!("pre.{ROOT_CLASS_NAME}"));

        let mut body = format!("<pre class=\"{ROOT_CLASS_NAME}\">");
        self.0.render_to_line_ops(
            &mut LineOp::interpret_with(|op| match op {
                TextOp::PushStyles(styles) | TextOp::PopStyles(styles) => {
                    let mut special_tags = [
                        ("a", styles.anchor.is_some()),
                        ("sub", styles.subscript),
                        ("super", styles.superscript),
                    ]
                    .into_iter()
                    .filter(|&(_, cond)| cond)
                    .map(|(tag, _)| tag);
                    let tag = special_tags.next().unwrap_or("span");
                    if let Some(other_tag) = special_tags.next() {
                        // FIXME(eddyb) support by opening/closing multiple tags.
                        panic!("`<{tag}>` conflicts with `<{other_tag}>`");
                    }

                    body += "<";
                    if let TextOp::PopStyles(_) = op {
                        body += "/";
                    }
                    body += tag;

                    if let TextOp::PushStyles(_) = op {
                        let mut push_attr = |attr, value: &str| {
                            // Quick sanity check.
                            assert!(value.chars().all(|c| !(c == '"' || c == '&')));

                            body.extend([" ", attr, "=\"", value, "\""]);
                        };

                        let Styles {
                            ref anchor,
                            anchor_is_def,
                            color,
                            color_opacity,
                            thickness,
                            subscript: _,
                            superscript: _,
                        } = *styles;

                        if let Some(id) = anchor {
                            if anchor_is_def {
                                push_attr("id", id);
                            }
                            push_attr("href", &format!("#{id}"));
                        }

                        let mut css_style = String::new();

                        if let Some(a) = color_opacity {
                            let [r, g, b] = color.expect("color_opacity without color");
                            write!(css_style, "color:rgba({r},{g},{b},{a});").unwrap();
                        } else if let Some([r, g, b]) = color {
                            write!(css_style, "color:#{r:02x}{g:02x}{b:02x};").unwrap();
                        }
                        if let Some(thickness) = thickness {
                            write!(css_style, "font-weight:{};", 500 + (thickness as i32) * 100)
                                .unwrap();
                        }
                        if !css_style.is_empty() {
                            push_attr("style", &css_style);
                        }
                    }

                    body += ">";
                }
                TextOp::Text(text) => {
                    // Minimal escaping, just enough to produce valid HTML.
                    let escape_from = ['&', '<'];
                    let escape_to = ["&amp;", "&lt;"];
                    for piece in text.split_inclusive(escape_from) {
                        let mut chars = piece.chars();
                        let maybe_needs_escape = chars.next_back();
                        body += chars.as_str();

                        if let Some(maybe_needs_escape) = maybe_needs_escape {
                            match escape_from.iter().position(|&c| maybe_needs_escape == c) {
                                Some(escape_idx) => body += escape_to[escape_idx],
                                None => body.push(maybe_needs_escape),
                            }
                        }
                    }
                }
            }),
            false,
        );
        body += "</pre>";

        HtmlSnippet {
            head_deduplicatable_elements: [style_elem].into_iter().collect(),
            body,
        }
    }
}

// Rendering implementation details (including approximate layout).

/// The approximate shape of a [`Node`], regarding its 2D placement.
#[derive(Copy, Clone)]
enum ApproxLayout {
    /// Only occupies part of a line, (at most) `worst_width` columns wide.
    ///
    /// `worst_width` can exceed the `inline` field of [`MaxWidths`], in which
    /// case the choice of inline vs block is instead made by a surrounding node.
    Inline { worst_width: usize },

    /// Needs to occupy multiple lines, but may also have the equivalent of
    /// an `Inline` before (`pre_`) and after (`post_`) the multi-line block.
    //
    // FIXME(eddyb) maybe turn `ApproxLayout` into a `struct` instead?
    BlockOrMixed {
        pre_worst_width: usize,
        post_worst_width: usize,
    },
}

impl ApproxLayout {
    fn append(self, other: Self) -> Self {
        match (self, other) {
            (Self::Inline { worst_width: a }, Self::Inline { worst_width: b }) => Self::Inline {
                worst_width: a.saturating_add(b),
            },
            (
                Self::BlockOrMixed {
                    pre_worst_width, ..
                },
                Self::BlockOrMixed {
                    post_worst_width, ..
                },
            ) => Self::BlockOrMixed {
                pre_worst_width,
                post_worst_width,
            },
            (
                Self::BlockOrMixed {
                    pre_worst_width,
                    post_worst_width: post_a,
                },
                Self::Inline {
                    worst_width: post_b,
                },
            ) => Self::BlockOrMixed {
                pre_worst_width,
                post_worst_width: post_a.saturating_add(post_b),
            },
            (
                Self::Inline { worst_width: pre_a },
                Self::BlockOrMixed {
                    pre_worst_width: pre_b,
                    post_worst_width,
                },
            ) => Self::BlockOrMixed {
                pre_worst_width: pre_a.saturating_add(pre_b),
                post_worst_width,
            },
        }
    }
}

/// Maximum numbers of columns, available to a [`Node`], for both inline layout
/// and block layout (i.e. multi-line with indentation).
///
/// That is, these are the best-case scenarios across all possible choices of
/// inline vs block for all surrounding nodes (up to the root) that admit both
/// cases, and those choices will be made inside-out based on actual widths.
#[derive(Copy, Clone)]
struct MaxWidths {
    inline: usize,
    block: usize,
}

// FIXME(eddyb) make this configurable.
const INDENT: &str = "  ";

impl Node {
    /// Determine the "rigid" component of the [`ApproxLayout`] of this [`Node`].
    ///
    /// That is, this accounts for the parts of the [`Node`] that don't depend on
    /// contextual sizing, i.e. [`MaxWidths`] (see also `approx_flex_layout`).
    fn approx_rigid_layout(&self) -> ApproxLayout {
        // HACK(eddyb) workaround for the `Self::StyledText` arm not being able
        // to destructure through the `Box<(_, Cow<str>)>`.
        let text_approx_rigid_layout = |text: &str| {
            if let Some((pre, non_pre)) = text.split_once('\n') {
                let (_, post) = non_pre.rsplit_once('\n').unwrap_or(("", non_pre));

                // FIXME(eddyb) use `unicode-width` crate for accurate column count.
                let pre_width = pre.len();
                let post_width = post.len();

                ApproxLayout::BlockOrMixed {
                    pre_worst_width: pre_width,
                    post_worst_width: post_width,
                }
            } else {
                // FIXME(eddyb) use `unicode-width` crate for accurate column count.
                let width = text.len();

                ApproxLayout::Inline { worst_width: width }
            }
        };

        #[allow(clippy::match_same_arms)]
        match self {
            Self::Text(text) => text_approx_rigid_layout(text),
            Self::StyledText(styles_and_text) => text_approx_rigid_layout(&styles_and_text.1),

            Self::IndentedBlock(_) => ApproxLayout::BlockOrMixed {
                pre_worst_width: 0,
                post_worst_width: 0,
            },

            Self::BreakingOnlySpace => ApproxLayout::Inline { worst_width: 1 },
            Self::ForceLineSeparation => ApproxLayout::BlockOrMixed {
                pre_worst_width: 0,
                post_worst_width: 0,
            },
            &Self::IfBlockLayout(text) => {
                // Keep the inline `worst_width`, just in case this node is
                // going to be used as part of an inline child of a block.
                // NOTE(eddyb) this is currently only the case for the trailing
                // comma added by `join_comma_sep`.
                let text_layout = Self::Text(text.into()).approx_rigid_layout();
                let worst_width = match text_layout {
                    ApproxLayout::Inline { worst_width } => worst_width,
                    ApproxLayout::BlockOrMixed { .. } => 0,
                };
                ApproxLayout::Inline { worst_width }
            }

            // Layout computed only in `approx_flex_layout`.
            Self::InlineOrIndentedBlock(_) => ApproxLayout::Inline { worst_width: 0 },
        }
    }

    /// Determine the "flexible" component of the [`ApproxLayout`] of this [`Node`],
    /// potentially making adjustments in order to fit within `max_widths`.
    ///
    /// That is, this accounts for the parts of the [`Node`] that do depend on
    /// contextual sizing, i.e. [`MaxWidths`] (see also `approx_rigid_layout`).
    fn approx_flex_layout(&mut self, max_widths: MaxWidths) -> ApproxLayout {
        match self {
            Self::IndentedBlock(fragments) => {
                // Apply one more level of indentation to the block layout.
                let indented_block_max_width = max_widths.block.saturating_sub(INDENT.len());

                // Recurse on `fragments`, so they can compute their own layouts.
                for fragment in &mut fragments[..] {
                    fragment.approx_layout(MaxWidths {
                        inline: indented_block_max_width,
                        block: indented_block_max_width,
                    });
                }

                ApproxLayout::BlockOrMixed {
                    pre_worst_width: 0,
                    post_worst_width: 0,
                }
            }

            Self::InlineOrIndentedBlock(fragments) => {
                // Apply one more level of indentation to the block layout.
                let indented_block_max_width = max_widths.block.saturating_sub(INDENT.len());

                // Maximize the inline width available to `fragments`, usually
                // increasing it to the maximum allowed by the block layout.
                // However, block layout is only needed if the extra width is
                // actually used by `fragments` (i.e. staying within the original
                // `max_widths.inline` will keep inline layout).
                let inner_max_widths = MaxWidths {
                    inline: max_widths.inline.max(indented_block_max_width),
                    block: indented_block_max_width,
                };

                let mut layout = ApproxLayout::Inline { worst_width: 0 };
                for fragment in &mut fragments[..] {
                    // Offer the same `inner_max_widths` to each `fragment`.
                    // Worst case, they all remain inline and block layout is
                    // needed, but even then, `inner_max_widths` has limited each
                    // `fragment` to a maximum appropriate for that block layout.
                    layout = layout.append(fragment.approx_layout(inner_max_widths));
                }

                layout = match layout {
                    ApproxLayout::Inline { worst_width } if worst_width <= max_widths.inline => {
                        layout
                    }

                    // Even if `layout` is already `ApproxLayout::BlockOrMixed`,
                    // always reset it to a plain block, with no pre/post widths.
                    _ => ApproxLayout::BlockOrMixed {
                        pre_worst_width: 0,
                        post_worst_width: 0,
                    },
                };

                match layout {
                    ApproxLayout::Inline { .. } => {
                        // Leave `self` as `Node::InlineOrIndentedBlock` and
                        // have that be implied to be in inline layout.
                    }
                    ApproxLayout::BlockOrMixed { .. } => {
                        *self = Self::IndentedBlock(mem::take(fragments));
                    }
                }

                layout
            }

            // Layout computed only in `approx_rigid_layout`.
            Self::Text(_)
            | Self::StyledText(_)
            | Self::BreakingOnlySpace
            | Self::ForceLineSeparation
            | Self::IfBlockLayout(_) => ApproxLayout::Inline { worst_width: 0 },
        }
    }
}

impl Fragment {
    /// Determine the [`ApproxLayout`] of this [`Fragment`], potentially making
    /// adjustments in order to fit within `max_widths`.
    fn approx_layout(&mut self, max_widths: MaxWidths) -> ApproxLayout {
        let mut layout = ApproxLayout::Inline { worst_width: 0 };

        let child_max_widths = |layout| MaxWidths {
            inline: match layout {
                ApproxLayout::Inline { worst_width } => {
                    max_widths.inline.saturating_sub(worst_width)
                }
                ApproxLayout::BlockOrMixed {
                    post_worst_width, ..
                } => max_widths.block.saturating_sub(post_worst_width),
            },
            block: max_widths.block,
        };

        // Compute rigid `ApproxLayout`s as long as they remain inline, only
        // going back for flexible ones on block boundaries (and at the end),
        // ensuring that the `MaxWidths` are as contraining as possible.
        let mut next_flex_idx = 0;
        for rigid_idx in 0..self.nodes.len() {
            match self.nodes[rigid_idx].approx_rigid_layout() {
                rigid_layout @ ApproxLayout::Inline { .. } => {
                    layout = layout.append(rigid_layout);
                }
                ApproxLayout::BlockOrMixed {
                    pre_worst_width,
                    post_worst_width,
                } => {
                    // Split the `BlockOrMixed` just before the block, and
                    // process "recent" flexible nodes in between the halves.
                    layout = layout.append(ApproxLayout::Inline {
                        worst_width: pre_worst_width,
                    });
                    // FIXME(eddyb) what happens if the same node has both
                    // rigid and flexible `ApproxLayout`s?
                    while next_flex_idx <= rigid_idx {
                        layout = layout.append(
                            self.nodes[next_flex_idx].approx_flex_layout(child_max_widths(layout)),
                        );
                        next_flex_idx += 1;
                    }
                    layout = layout.append(ApproxLayout::BlockOrMixed {
                        pre_worst_width: 0,
                        post_worst_width,
                    });
                }
            }
        }

        // Process all remaining flexible nodes (i.e. after the last line split).
        for flex_idx in next_flex_idx..self.nodes.len() {
            layout =
                layout.append(self.nodes[flex_idx].approx_flex_layout(child_max_widths(layout)));
        }

        layout
    }
}

/// Line-oriented operation (i.e. as if lines are stored separately).
///
/// However, a representation that stores lines separately doesn't really exist,
/// and instead [`LineOp`]s are (statefully) transformed into [`TextOp`]s on the fly
/// (see [`LineOp::interpret_with`]).
#[derive(Copy, Clone)]
enum LineOp<'a> {
    PushIndent,
    PopIndent,
    PushStyles(&'a Styles),
    PopStyles(&'a Styles),

    AppendToLine(&'a str),
    StartNewLine,
    BreakIfWithinLine(Break),
}

#[derive(Copy, Clone)]
enum Break {
    Space,
    NewLine,
}

impl Node {
    /// Flatten the [`Node`] to [`LineOp`]s, passed to `each_line_op`.
    fn render_to_line_ops<'a>(
        &'a self,
        each_line_op: &mut impl FnMut(LineOp<'a>),
        directly_in_block: bool,
    ) {
        // HACK(eddyb) workaround for the `Self::StyledText` arm not being able
        // to destructure through the `Box<(_, Cow<str>)>`.
        let mut text_render_to_line_ops = |styles: Option<&'a Styles>, text: &'a str| {
            if let Some(styles) = styles {
                each_line_op(LineOp::PushStyles(styles));
            }
            let mut lines = text.split('\n');
            each_line_op(LineOp::AppendToLine(lines.next().unwrap()));
            for line in lines {
                each_line_op(LineOp::StartNewLine);
                each_line_op(LineOp::AppendToLine(line));
            }
            if let Some(styles) = styles {
                each_line_op(LineOp::PopStyles(styles));
            }
        };
        match self {
            Self::Text(text) => {
                text_render_to_line_ops(None, text);
            }
            Self::StyledText(styles_and_text) => {
                text_render_to_line_ops(Some(&styles_and_text.0), &styles_and_text.1);
            }

            Self::IndentedBlock(fragments) => {
                each_line_op(LineOp::PushIndent);
                each_line_op(LineOp::BreakIfWithinLine(Break::NewLine));
                for fragment in fragments {
                    fragment.render_to_line_ops(each_line_op, true);
                    each_line_op(LineOp::BreakIfWithinLine(Break::NewLine));
                }
                each_line_op(LineOp::PopIndent);
            }
            // Post-layout, this is only used for the inline layout.
            Self::InlineOrIndentedBlock(fragments) => {
                for fragment in fragments {
                    fragment.render_to_line_ops(each_line_op, false);
                }
            }

            Self::BreakingOnlySpace => each_line_op(LineOp::BreakIfWithinLine(Break::Space)),
            Self::ForceLineSeparation => each_line_op(LineOp::BreakIfWithinLine(Break::NewLine)),
            &Self::IfBlockLayout(text) => {
                if directly_in_block {
                    text_render_to_line_ops(None, text);
                }
            }
        }
    }
}

impl Fragment {
    /// Flatten the [`Fragment`] to [`LineOp`]s, passed to `each_line_op`.
    fn render_to_line_ops<'a>(
        &'a self,
        each_line_op: &mut impl FnMut(LineOp<'a>),
        directly_in_block: bool,
    ) {
        for node in &self.nodes {
            node.render_to_line_ops(each_line_op, directly_in_block);
        }
    }
}

/// Text-oriented operation (plain text snippets interleaved with style push/pop).
enum TextOp<'a> {
    PushStyles(&'a Styles),
    PopStyles(&'a Styles),

    Text(&'a str),
}

impl<'a> LineOp<'a> {
    /// Expand [`LineOp`]s passed to the returned `impl FnMut(LineOp<'a>)` closure,
    /// forwarding the expanded [`TextOp`]s to `each_text_op`.
    //
    // FIXME(eddyb) this'd be nicer if instead of returning a closure, it could
    // be passed to an `impl for<F: FnMut(LineOp<'a>)> FnOnce(F)` callback.
    fn interpret_with(mut each_text_op: impl FnMut(TextOp<'a>)) -> impl FnMut(LineOp<'a>) {
        let mut indent = 0;

        // When `on_empty_new_line` is `true`, a new line was started, but
        // lacks text, so the `LineOp::AppendToLine { indent_before, text }`
        // first on that line (with non-empty `text`) needs to materialize
        // `indent_before` levels of indentation (before its `text` content).
        //
        // NOTE(eddyb) indentation is not immediatelly materialized in order
        // to avoid trailing whitespace on otherwise-empty lines.
        let mut on_empty_new_line = true;

        // Deferred `LineOp::BreakIfWithinLine`, which will be materialized
        // only between two consecutive `LineOp::AppendToLine { text, .. }`
        // (with non-empty `text`), that (would) share the same line.
        let mut pending_break_if_within_line = None;

        move |op| {
            // Do not allow (accidental) side-effects from no-op `op`s.
            if let LineOp::AppendToLine("") = op {
                return;
            }

            if let LineOp::AppendToLine(_) | LineOp::PushStyles(_) = op {
                let need_indent = match pending_break_if_within_line.take() {
                    Some(br) => {
                        each_text_op(TextOp::Text(match br {
                            Break::Space => " ",
                            Break::NewLine => "\n",
                        }));
                        matches!(br, Break::NewLine)
                    }
                    None => on_empty_new_line,
                };
                if need_indent {
                    for _ in 0..indent {
                        each_text_op(TextOp::Text(INDENT));
                    }
                    on_empty_new_line = false;
                }
            }

            match op {
                LineOp::PushIndent => {
                    indent += 1;
                }

                LineOp::PopIndent => {
                    assert!(indent > 0);
                    indent -= 1;
                }

                LineOp::PushStyles(styles) => each_text_op(TextOp::PushStyles(styles)),
                LineOp::PopStyles(styles) => each_text_op(TextOp::PopStyles(styles)),

                LineOp::AppendToLine(text) => each_text_op(TextOp::Text(text)),

                LineOp::StartNewLine => {
                    each_text_op(TextOp::Text("\n"));

                    on_empty_new_line = true;
                    pending_break_if_within_line = None;
                }

                LineOp::BreakIfWithinLine(br) => {
                    if !on_empty_new_line {
                        // Merge two pending `Break`s if necessary,
                        // preferring newlines over spaces.
                        let br = match (pending_break_if_within_line, br) {
                            (Some(Break::NewLine), _) | (_, Break::NewLine) => Break::NewLine,
                            (None | Some(Break::Space), Break::Space) => Break::Space,
                        };

                        pending_break_if_within_line = Some(br);
                    }
                }
            }
        }
    }
}

// Pretty fragment "constructors".
//
// FIXME(eddyb) should these be methods on `Node`/`Fragment`?

/// Constructs the [`Fragment`] corresponding to one of:
/// * inline layout: `header + " " + contents.join(" ")`
/// * block layout: `header + "\n" + indent(contents).join("\n")`
pub fn join_space(
    header: impl Into<Node>,
    contents: impl IntoIterator<Item = impl Into<Fragment>>,
) -> Fragment {
    Fragment::new([
        header.into(),
        Node::InlineOrIndentedBlock(
            contents
                .into_iter()
                .map(|entry| {
                    Fragment::new(iter::once(Node::BreakingOnlySpace).chain(entry.into().nodes))
                })
                .collect(),
        ),
    ])
}

/// Constructs the [`Fragment`] corresponding to one of:
/// * inline layout: `prefix + contents.join(", ") + suffix`
/// * block layout: `prefix + "\n" + indent(contents).join(",\n") + ",\n" + suffix`
pub fn join_comma_sep(
    prefix: impl Into<Node>,
    contents: impl IntoIterator<Item = impl Into<Fragment>>,
    suffix: impl Into<Node>,
) -> Fragment {
    let mut children: Vec<_> = contents.into_iter().map(Into::into).collect();

    if let Some((last_child, non_last_children)) = children.split_last_mut() {
        for non_last_child in non_last_children {
            non_last_child
                .nodes
                .extend([",".into(), Node::BreakingOnlySpace]);
        }

        // Trailing comma is only needed after the very last element.
        last_child.nodes.push(Node::IfBlockLayout(","));
    }

    Fragment::new([
        prefix.into(),
        Node::InlineOrIndentedBlock(children),
        suffix.into(),
    ])
}