Repository: yshavit/mdq
Pull request #30 (2024-06-16 07:59:47)

markdown rendering tests and fixes
- Add a more generic version of the variants checker I wrote for the Node -> MdqNode conversions
- Add unit tests for all the MdqNode -> markdown renderings
- Various fixes to the prod code that shook out from those tests


Patch:
diff --git a/src/fmt_md.rs b/src/fmt_md.rs
index 62bbc2c..0f72d4d 100644
--- a/src/fmt_md.rs
+++ b/src/fmt_md.rs
@@ -81,8 +81,7 @@ where
     // Always write the pending definitions at the end of the doc. If there were no sections, then BottomOfSection
     // won't have been triggered, but we still want to write them
     // TODO test this specific case
-    writer_state.write_link_definitions(out);
-    writer_state.write_footnote_definitions(out);
+    writer_state.write_definitions(out, DefinitionsToWrite::Both);
 }
 
 struct MdWriterState<'a> {
@@ -134,19 +133,22 @@ impl<'a> MdWriterState<'a> {
                     for _ in 0..*depth {
                         out.write_str("#");
                     }
-                    out.write_str(" ");
-                    self.write_line(out, title);
+                    if !title.is_empty() {
+                        out.write_str(" ");
+                        self.write_line(out, title);
+                    }
                 });
                 self.write_md(out, body);
-                if matches!(self.opts.link_reference_options, ReferencePlacement::BottomOfSection) {
-                    self.write_link_definitions(out);
-                }
-                if matches!(
-                    self.opts.footnote_reference_options,
-                    ReferencePlacement::BottomOfSection
-                ) {
-                    self.write_footnote_definitions(out);
-                }
+                let which_defs_to_write =
+                    match (&self.opts.link_reference_options, &self.opts.footnote_reference_options) {
+                        (ReferencePlacement::BottomOfSection, ReferencePlacement::BottomOfSection) => {
+                            DefinitionsToWrite::Both
+                        }
+                        (_, ReferencePlacement::BottomOfSection) => DefinitionsToWrite::Footnotes,
+                        (ReferencePlacement::BottomOfSection, _) => DefinitionsToWrite::Links,
+                        (_, _) => DefinitionsToWrite::Neither,
+                    };
+                self.write_definitions(out, which_defs_to_write);
             }
             MdqNode::Paragraph(Paragraph { body }) => {
                 out.with_block(Block::Plain, |out| {
@@ -190,8 +192,9 @@ impl<'a> MdWriterState<'a> {
                 if !alignments.is_empty() {
                     for (idx, alignment) in alignments.iter().enumerate() {
                         let width = match standard_align(alignment) {
-                            Alignment::Left | Alignment::Right => 2,
-                            Alignment::Center => 3,
+                            Some(Alignment::Left | Alignment::Right) => 2,
+                            Some(Alignment::Center) => 3,
+                            None => 1,
                         };
                         column_widths[idx] = width;
                     }
@@ -202,30 +205,44 @@ impl<'a> MdWriterState<'a> {
                     let mut col_strs = Vec::with_capacity(row.len());
                     for (idx, col) in row.iter().enumerate() {
                         let col_str = self.line_to_string(col);
-                        // Extend the row_sizes if needed. This happens if we had fewer alignments than columns in any row.
-                        // I'm not sure if that's possible, but it's easy to guard against
-                        while column_widths.len() < idx {
+                        // Extend the row_sizes if needed. This happens if we had fewer alignments than columns in any
+                        // row. I'm not sure if that's possible, but it's easy to guard against.
+                        while column_widths.len() <= idx {
                             column_widths.push(0);
                         }
-                        column_widths[idx] = max(column_widths[idx], col_str.len());
+                        // +1 for the padding on either side
+                        let cell_width = if col_str.is_empty() { 1 } else { col_str.len() + 2 };
+                        column_widths[idx] = max(column_widths[idx], cell_width);
                         col_strs.push(col_str);
                     }
                     row_strs.push(col_strs);
                 }
 
                 // Create column formatters for each column
-                let write_row = |out: &mut Output<W>, row: Vec<String>| {
+                let write_row = |out: &mut Output<W>, row: Vec<String>, add_newline: bool| {
                     if row.is_empty() {
                         out.write_str("||\n");
                         return;
                     }
                     out.write_char('|');
                     for (idx, col) in row.iter().enumerate() {
-                        out.write_char(' ');
-                        pad_to(out, &col, *column_widths.get(idx).unwrap_or(&0), alignments.get(idx));
+                        let left_padding_count = if col.is_empty() {
+                            0
+                        } else {
+                            out.write_char(' ');
+                            1
+                        };
+                        pad_to(
+                            out,
+                            &col,
+                            *column_widths.get(idx).unwrap_or(&0) - left_padding_count - 1, // -1 for right padding
+                            alignments.get(idx),
+                        );
                         out.write_str(" |");
                     }
-                    out.write_char('\n');
+                    if add_newline {
+                        out.write_char('\n');
+                    }
                 };
 
                 let mut rows_iter = row_strs.into_iter();
@@ -234,7 +251,7 @@ impl<'a> MdWriterState<'a> {
                 let Some(first_row) = rows_iter.next() else {
                     return; // unexpected!
                 };
-                write_row(out, first_row);
+                write_row(out, first_row, true);
 
                 // Headers
                 if !alignments.is_empty() {
@@ -243,25 +260,28 @@ impl<'a> MdWriterState<'a> {
                         let width = column_widths
                             .get(idx)
                             .unwrap_or_else(|| match standard_align(align) {
-                                Alignment::Left | Alignment::Right => &2,
-                                Alignment::Center => &3,
+                                Some(Alignment::Left | Alignment::Right) => &2,
+                                Some(Alignment::Center) => &3,
+                                None => &1,
                             })
-                            .to_owned()
-                            + 2; // +2 for the ' ' padding on either side
+                            .to_owned();
                         match standard_align(align) {
-                            Alignment::Left => {
+                            Some(Alignment::Left) => {
                                 out.write_char(':');
                                 out.write_str(&"-".repeat(width - 1));
                             }
-                            Alignment::Right => {
+                            Some(Alignment::Right) => {
                                 out.write_str(&"-".repeat(width - 1));
                                 out.write_char(':');
                             }
-                            Alignment::Center => {
+                            Some(Alignment::Center) => {
                                 out.write_char(':');
                                 out.write_str(&"-".repeat(width - 2));
                                 out.write_char(':');
                             }
+                            None => {
+                                out.write_str(&"-".repeat(width));
+                            }
                         };
                         out.write_char('|');
                     }
@@ -269,12 +289,13 @@ impl<'a> MdWriterState<'a> {
                 }
 
                 // And finally, the rows
-                for row in rows_iter {
-                    write_row(out, row);
+                let mut rows_iter = rows_iter.peekable();
+                while let Some(row) = rows_iter.next() {
+                    write_row(out, row, rows_iter.peek().is_some());
                 }
             }
             MdqNode::ThematicBreak => {
-                // out.with_block(Block::Plain, |out| out.write_str("***"));
+                out.with_block(Block::Plain, |out| out.write_str("***"));
             }
             MdqNode::CodeBlock(CodeBlock { variant, value }) => {
                 let (surround, meta) = match variant {
@@ -293,7 +314,10 @@ impl<'a> MdWriterState<'a> {
                     }
                     CodeVariant::Math { metadata } => {
                         let meta = if let Some(meta) = metadata {
-                            Some(meta.to_string())
+                            let mut meta_with_space = String::with_capacity(meta.len() + 1);
+                            meta_with_space.push(' ');
+                            meta_with_space.push_str(meta);
+                            Some(meta_with_space)
                         } else {
                             None
                         };
@@ -338,7 +362,7 @@ impl<'a> MdWriterState<'a> {
             Inline::Span { variant, children } => {
                 let surround = match variant {
                     SpanVariant::Delete => "~~",
-                    SpanVariant::Emphasis => "*",
+                    SpanVariant::Emphasis => "_",
                     SpanVariant::Strong => "**",
                 };
                 out.write_str(surround);
@@ -392,7 +416,7 @@ impl<'a> MdWriterState<'a> {
         W: SimpleWrite,
         F: FnOnce(&mut Self, &mut Output<W>),
     {
-        out.write_str("![");
+        out.write_char('[');
         contents(self, out);
         out.write_char(']');
         let reference_to_add = match &link.reference {
@@ -433,48 +457,64 @@ impl<'a> MdWriterState<'a> {
         }
     }
 
-    fn write_link_definitions<W>(&mut self, out: &mut Output<W>)
+    fn write_definitions<W>(&mut self, out: &mut Output<W>, which: DefinitionsToWrite)
     where
         W: SimpleWrite,
     {
-        if self.pending_references.links.is_empty() && self.seen_footnotes.is_empty() {
+        let is_empty = match which {
+            DefinitionsToWrite::Links => self.pending_references.links.is_empty(),
+            DefinitionsToWrite::Footnotes => self.pending_references.footnotes.is_empty(),
+            DefinitionsToWrite::Both => {
+                self.pending_references.links.is_empty() && self.pending_references.footnotes.is_empty()
+            }
+            DefinitionsToWrite::Neither => true,
+        };
+        if is_empty {
             return;
         }
         out.with_block(Block::Plain, move |out| {
-            // TODO sort them
-            let defs_to_write: Vec<_> = self.pending_references.links.drain().collect();
-            for (link_ref, link_def) in defs_to_write {
-                out.write_char('[');
-                match link_ref {
-                    ReifiedLabel::Identifier(identifier) => out.write_str(identifier),
-                    ReifiedLabel::Inline(text) => self.write_line(out, text),
-                }
-                out.write_str("]: ");
-                out.write_str(&link_def.url);
-                self.write_url_title(out, &link_def.title);
-                out.write_char('\n');
+            let mut remaining_defs = 0;
+            if matches!(which, DefinitionsToWrite::Links | DefinitionsToWrite::Both) {
+                remaining_defs += self.pending_references.links.len()
             }
-        });
-    }
+            if matches!(which, DefinitionsToWrite::Footnotes | DefinitionsToWrite::Both) {
+                remaining_defs += self.pending_references.footnotes.len()
+            }
+            let mut newline = |out: &mut Output<W>| {
+                if remaining_defs > 1 {
+                    out.write_char('\n');
+                }
+                remaining_defs -= 1;
+            };
 
-    fn write_footnote_definitions<W>(&mut self, out: &mut Output<W>)
-    where
-        W: SimpleWrite,
-    {
-        // TODO combine this block with the link definitions
-        out.with_block(Block::Plain, move |out| {
-            // TODO sort them
-            let mut defs_to_write: Vec<_> = self.pending_references.footnotes.drain().collect();
-            defs_to_write.sort_by_key(|&kv| kv.0);
+            if matches!(which, DefinitionsToWrite::Links | DefinitionsToWrite::Both) {
+                // TODO sort them
+                let defs_to_write: Vec<_> = self.pending_references.links.drain().collect();
+                for (link_ref, link_def) in defs_to_write {
+                    out.write_char('[');
+                    match link_ref {
+                        ReifiedLabel::Identifier(identifier) => out.write_str(identifier),
+                        ReifiedLabel::Inline(text) => self.write_line(out, text),
+                    }
+                    out.write_str("]: ");
+                    out.write_str(&link_def.url);
+                    self.write_url_title(out, &link_def.title);
+                    newline(out);
+                }
+            }
+            if matches!(which, DefinitionsToWrite::Footnotes | DefinitionsToWrite::Both) {
+                let mut defs_to_write: Vec<_> = self.pending_references.footnotes.drain().collect();
+                defs_to_write.sort_by_key(|&kv| kv.0);
 
-            for (link_ref, text) in defs_to_write {
-                out.write_str("[^");
-                out.write_str(link_ref);
-                out.write_str("]: ");
-                out.with_block(Block::Inlined(0), |out| {
-                    self.write_md(out, text);
-                    out.write_char('\n');
-                });
+                for (link_ref, text) in defs_to_write {
+                    out.write_str("[^");
+                    out.write_str(link_ref);
+                    out.write_str("]: ");
+                    out.with_block(Block::Inlined(2), |out| {
+                        self.write_md(out, text);
+                        newline(out);
+                    });
+                }
             }
         });
     }
@@ -505,3 +545,1293 @@ impl<'a> MdWriterState<'a> {
         out.take_underlying().unwrap()
     }
 }
+
+enum DefinitionsToWrite {
+    // simple enum-set-like definition
+    Links,
+    Footnotes,
+    Both,
+    Neither,
+}
+
+#[cfg(test)]
+pub mod tests {
+    use indoc::indoc;
+    use lazy_static::lazy_static;
+
+    use crate::fmt_md::MdOptions;
+    use crate::mdq_inline;
+    use crate::mdq_nodes;
+    use crate::output::Output;
+    use crate::tree::*;
+    use crate::utils_for_test::*;
+
+    use super::write_md;
+
+    lazy_static! {
+        static ref VARIANTS_CHECKER: VariantsChecker<MdqNode> = crate::new_variants_checker! {MdqNode:
+            Root(_),
+            Header(_),
+            Paragraph(_),
+            BlockQuote(_),
+            List(_),
+            Table(_),
+            ThematicBreak,
+            CodeBlock(crate::tree::CodeBlock{variant: CodeVariant::Code(None), ..}),
+            CodeBlock(crate::tree::CodeBlock{variant: CodeVariant::Code(Some(CodeOpts{metadata: None, ..})), ..}),
+            CodeBlock(crate::tree::CodeBlock{variant: CodeVariant::Code(Some(CodeOpts{metadata: Some(_), ..})), ..}),
+            CodeBlock(crate::tree::CodeBlock{variant: CodeVariant::Math{metadata: None}, ..}),
+            CodeBlock(crate::tree::CodeBlock{variant: CodeVariant::Math{metadata: Some(_)}, ..}),
+            CodeBlock(crate::tree::CodeBlock{variant: CodeVariant::Toml, ..}),
+            CodeBlock(crate::tree::CodeBlock{variant: CodeVariant::Yaml, ..}),
+
+            Inline(crate::tree::Inline::Span{variant: SpanVariant::Delete, ..}),
+            Inline(crate::tree::Inline::Span{variant: SpanVariant::Emphasis, ..}),
+            Inline(crate::tree::Inline::Span{variant: SpanVariant::Strong, ..}),
+
+            Inline(crate::tree::Inline::Text{variant: InlineVariant::Text, ..}),
+            Inline(crate::tree::Inline::Text{variant: InlineVariant::Code, ..}),
+            Inline(crate::tree::Inline::Text{variant: InlineVariant::Math, ..}),
+            Inline(crate::tree::Inline::Text{variant: InlineVariant::Html, ..}),
+
+            Inline(crate::tree::Inline::Link{link: Link{title: None, reference: LinkReference::Inline, ..}, ..}),
+            Inline(crate::tree::Inline::Link{link: Link{title: None, reference: LinkReference::Full(_), ..}, ..}),
+            Inline(crate::tree::Inline::Link{link: Link{title: None, reference: LinkReference::Collapsed, ..}, ..}),
+            Inline(crate::tree::Inline::Link{link: Link{title: None, reference: LinkReference::Shortcut, ..}, ..}),
+            Inline(crate::tree::Inline::Link{link: Link{title: Some(_), reference: LinkReference::Inline, ..}, ..}),
+            Inline(crate::tree::Inline::Link{link: Link{title: Some(_), reference: LinkReference::Full(_), ..}, ..}),
+            Inline(crate::tree::Inline::Link{link: Link{title: Some(_), reference: LinkReference::Collapsed, ..}, ..}),
+            Inline(crate::tree::Inline::Link{link: Link{title: Some(_), reference: LinkReference::Shortcut, ..}, ..}),
+
+            Inline(crate::tree::Inline::Image{link: Link{title: None, reference: LinkReference::Inline, ..}, ..}),
+            Inline(crate::tree::Inline::Image{link: Link{title: None, reference: LinkReference::Full(_), ..}, ..}),
+            Inline(crate::tree::Inline::Image{link: Link{title: None, reference: LinkReference::Collapsed, ..}, ..}),
+            Inline(crate::tree::Inline::Image{link: Link{title: None, reference: LinkReference::Shortcut, ..}, ..}),
+            Inline(crate::tree::Inline::Image{link: Link{title: Some(_), reference: LinkReference::Inline, ..}, ..}),
+            Inline(crate::tree::Inline::Image{link: Link{title: Some(_), reference: LinkReference::Full(_), ..}, ..}),
+            Inline(crate::tree::Inline::Image{link: Link{title: Some(_), reference: LinkReference::Collapsed, ..}, ..}),
+            Inline(crate::tree::Inline::Image{link: Link{title: Some(_), reference: LinkReference::Shortcut, ..}, ..}),
+
+            Inline(crate::tree::Inline::Footnote{..}),
+        };
+    }
+
+    #[test]
+    fn empty() {
+        check_render(vec![], indoc! {r#""#});
+    }
+
+    mod root {
+        use super::*;
+
+        #[test]
+        fn one_paragraph() {
+            check_render(
+                mdq_nodes![Root {
+                    body: mdq_nodes![Paragraph {
+                        body: vec![Inline::Text {
+                            variant: InlineVariant::Text,
+                            value: "Hello, world".to_string()
+                        }]
+                    }]
+                }],
+                indoc! {r#"
+                Hello, world"#},
+            );
+        }
+
+        #[test]
+        fn two_paragraphs() {
+            check_render(
+                mdq_nodes![Root {
+                    body: mdq_nodes![
+                        Paragraph {
+                            body: vec![Inline::Text {
+                                variant: InlineVariant::Text,
+                                value: "First".to_string()
+                            }]
+                        },
+                        Paragraph {
+                            body: vec![Inline::Text {
+                                variant: InlineVariant::Text,
+                                value: "Second".to_string()
+                            }]
+                        },
+                    ]
+                }],
+                indoc! {r#"
+                First
+
+                Second"#},
+            )
+        }
+    }
+
+    mod header {
+        use super::*;
+        use crate::mdq_inline;
+
+        #[test]
+        fn totally_empty() {
+            check_render(
+                mdq_nodes![Header {
+                    depth: 3,
+                    title: vec![],
+                    body: vec![],
+                }],
+                indoc! {r#"
+                ###"#},
+            )
+        }
+
+        #[test]
+        fn only_title() {
+            check_render(
+                mdq_nodes![Header {
+                    depth: 3,
+                    title: vec![mdq_inline!("My header")],
+                    body: vec![],
+                }],
+                indoc! {r#"
+                ### My header"#},
+            )
+        }
+
+        #[test]
+        fn only_body() {
+            check_render(
+                mdq_nodes![Header {
+                    depth: 3,
+                    title: vec![],
+                    body: mdq_nodes![Paragraph {
+                        body: vec![mdq_inline!("Hello, world.")]
+                    },],
+                }],
+                indoc! {r#"
+                    ###
+
+                    Hello, world."#},
+            )
+        }
+
+        #[test]
+        fn title_and_body() {
+            check_render(
+                mdq_nodes![Header {
+                    depth: 1,
+                    title: vec![mdq_inline!("My title")],
+                    body: mdq_nodes![BlockQuote {
+                        body: mdq_nodes![Paragraph {
+                            body: vec![mdq_inline!("Hello, world.")]
+                        },],
+                    },],
+                }],
+                indoc! {r#"
+                    # My title
+
+                    > Hello, world."#},
+            )
+        }
+    }
+
+    mod paragraph {
+        use super::*;
+
+        #[test]
+        fn simple() {
+            check_render(
+                mdq_nodes![Paragraph {
+                    body: vec![Inline::Text {
+                        variant: InlineVariant::Text,
+                        value: "Hello, world".to_string()
+                    }]
+                }],
+                indoc! {r#"
+                Hello, world"#},
+            );
+        }
+    }
+
+    mod block_quote {
+        use super::*;
+        use crate::mdq_inline;
+
+        #[test]
+        fn single_level() {
+            check_render(
+                mdq_nodes![BlockQuote {
+                    body: mdq_nodes![Paragraph {
+                        body: vec![Inline::Text {
+                            variant: InlineVariant::Text,
+                            value: "Hello, world".to_string()
+                        }]
+                    }]
+                }],
+                indoc! {
+                    r#"> Hello, world"#
+                },
+            );
+        }
+
+        #[test]
+        fn two_levels() {
+            check_render(
+                mdq_nodes![BlockQuote {
+                    body: mdq_nodes![
+                        Paragraph {
+                            body: vec![mdq_inline!("Outer")],
+                        },
+                        BlockQuote {
+                            body: mdq_nodes![Paragraph {
+                                body: vec![mdq_inline!("Inner")],
+                            },]
+                        },
+                    ]
+                }],
+                indoc! {r#"
+                    > Outer
+                    >
+                    > > Inner"#
+                },
+            );
+        }
+    }
+
+    mod list {
+        use super::*;
+
+        #[test]
+        fn ordered() {
+            check_render(
+                mdq_nodes![List {
+                    starting_index: Some(3),
+                    items: vec![
+                        ListItem {
+                            checked: None,
+                            item: mdq_nodes!("normal")
+                        },
+                        ListItem {
+                            checked: Some(true),
+                            item: mdq_nodes!("checked")
+                        },
+                        ListItem {
+                            checked: Some(false),
+                            item: mdq_nodes!("unchecked")
+                        },
+                    ],
+                }],
+                indoc! {r#"
+                3. normal
+                4. [x] checked
+                5. [ ] unchecked"#},
+            );
+        }
+
+        #[test]
+        fn unordered() {
+            check_render(
+                mdq_nodes![List {
+                    starting_index: None,
+                    items: vec![
+                        ListItem {
+                            checked: None,
+                            item: mdq_nodes!("normal")
+                        },
+                        ListItem {
+                            checked: Some(true),
+                            item: mdq_nodes!("checked")
+                        },
+                        ListItem {
+                            checked: Some(false),
+                            item: mdq_nodes!("unchecked")
+                        },
+                    ],
+                }],
+                indoc! {r#"
+                - normal
+                - [x] checked
+                - [ ] unchecked"#},
+            );
+        }
+
+        #[test]
+        fn block_alignments() {
+            check_render(
+                mdq_nodes![List {
+                    starting_index: None,
+                    items: vec![
+                        ListItem {
+                            checked: None,
+                            item: mdq_nodes!["first paragraph", "second paragraph"],
+                        },
+                        ListItem {
+                            checked: None,
+                            item: mdq_nodes!(BlockQuote {
+                                body: mdq_nodes!["quoted block one", "quoted block two"]
+                            })
+                        },
+                        ListItem {
+                            checked: None,
+                            item: mdq_nodes!(CodeBlock {
+                                variant: CodeVariant::Code(None),
+                                value: "line 1\nline 2".to_string(),
+                            })
+                        },
+                        ListItem {
+                            checked: Some(false),
+                            item: mdq_nodes![
+                                Paragraph {
+                                    body: vec![mdq_inline!("closing argument")]
+                                },
+                                BlockQuote {
+                                    body: mdq_nodes!["supporting evidence"]
+                                },
+                                CodeBlock {
+                                    variant: CodeVariant::Code(None),
+                                    value: "line a\nline b".to_string(),
+                                },
+                            ]
+                        },
+                    ],
+                }],
+                indoc! {r#"
+                - first paragraph
+
+                  second paragraph
+                - > quoted block one
+                  >
+                  > quoted block two
+                - ```
+                  line 1
+                  line 2
+                  ```
+                - [ ] closing argument
+
+                      > supporting evidence
+
+                      ```
+                      line a
+                      line b
+                      ```"#},
+            )
+        }
+    }
+
+    mod table {
+        use super::*;
+        use markdown::mdast;
+
+        #[test]
+        fn simple() {
+            check_render(
+                mdq_nodes![Table {
+                    alignments: vec![
+                        mdast::AlignKind::Left,
+                        mdast::AlignKind::Right,
+                        mdast::AlignKind::Center,
+                        mdast::AlignKind::None,
+                    ],
+                    rows: vec![
+                        // Header row
+                        vec![
+                            // columns
+                            vec![mdq_inline!("Left")],
+                            vec![mdq_inline!("Right")],
+                            vec![mdq_inline!("Center")],
+                            vec![mdq_inline!("Default")],
+                        ],
+                        // Data row
+                        vec![
+                            // columns
+                            vec![mdq_inline!("a")],
+                            vec![mdq_inline!("b")],
+                            vec![mdq_inline!("c")],
+                            vec![mdq_inline!("d")],
+                        ],
+                    ],
+                }],
+                indoc! {r#"
+                | Left | Right | Center | Default |
+                |:-----|------:|:------:|---------|
+                | a    |     b |   c    | d       |"#},
+            );
+        }
+
+        #[test]
+        fn single_char_cells() {
+            // This checks the minimum padding aspects
+            check_render(
+                mdq_nodes![Table {
+                    alignments: vec![
+                        mdast::AlignKind::Left,
+                        mdast::AlignKind::Right,
+                        mdast::AlignKind::Center,
+                        mdast::AlignKind::None,
+                    ],
+                    rows: vec![
+                        // Header row
+                        vec![
+                            // columns
+                            vec![mdq_inline!("a")],
+                            vec![mdq_inline!("b")],
+                            vec![mdq_inline!("c")],
+                            vec![mdq_inline!("d")],
+                        ],
+                        // Data row
+                        vec![
+                            // columns
+                            vec![mdq_inline!("1")],
+                            vec![mdq_inline!("2")],
+                            vec![mdq_inline!("3")],
+                            vec![mdq_inline!("4")],
+                        ],
+                    ],
+                }],
+                indoc! {r#"
+                | a | b | c | d |
+                |:--|--:|:-:|---|
+                | 1 | 2 | 3 | 4 |"#},
+            );
+        }
+
+        #[test]
+        fn empty_cells() {
+            // This checks the minimum padding aspects
+            check_render(
+                mdq_nodes![Table {
+                    alignments: vec![
+                        mdast::AlignKind::Left,
+                        mdast::AlignKind::Right,
+                        mdast::AlignKind::Center,
+                        mdast::AlignKind::None,
+                    ],
+                    rows: vec![
+                        // Header row
+                        vec![
+                            // columns
+                            vec![mdq_inline!("")],
+                            vec![mdq_inline!("")],
+                            vec![mdq_inline!("")],
+                            vec![mdq_inline!("")],
+                        ],
+                        // Data row
+                        vec![
+                            // columns
+                            vec![mdq_inline!("")],
+                            vec![mdq_inline!("")],
+                            vec![mdq_inline!("")],
+                            vec![mdq_inline!("")],
+                        ],
+                    ],
+                }],
+                indoc! {r#"
+                |  |  |   | |
+                |:-|-:|:-:|-|
+                |  |  |   | |"#},
+            );
+        }
+
+        #[test]
+        fn row_counts_inconsistent() {
+            // This is an invalid table, but we should still support it
+            check_render(
+                mdq_nodes![Table {
+                    alignments: vec![mdast::AlignKind::None, mdast::AlignKind::None,],
+                    rows: vec![
+                        // Header row: two values
+                        vec![
+                            // columns
+                            vec![mdq_inline!("A")],
+                            vec![mdq_inline!("B")],
+                        ],
+                        // First row: only one value
+                        vec![
+                            // columns
+                            vec![mdq_inline!("1")],
+                        ],
+                        // Second row: three values
+                        vec![
+                            // columns
+                            vec![mdq_inline!("i")],
+                            vec![mdq_inline!("ii")],
+                            vec![mdq_inline!("iii")],
+                        ],
+                    ],
+                }],
+                indoc! {r#"
+                | A | B  |
+                |---|----|
+                | 1 |
+                | i | ii | iii |"#},
+            );
+        }
+    }
+
+    mod thematic_break {
+        use super::*;
+        use crate::mdq_node;
+
+        #[test]
+        fn by_itself() {
+            check_render(
+                vec![MdqNode::ThematicBreak],
+                indoc! {r#"
+                ***"#},
+            );
+        }
+
+        #[test]
+        fn with_paragraphs() {
+            check_render(
+                vec![mdq_node!("before"), MdqNode::ThematicBreak, mdq_node!("after")],
+                indoc! {r#"
+                before
+
+                ***
+
+                after"#},
+            );
+        }
+    }
+
+    mod code_block {
+        use super::*;
+
+        #[test]
+        fn code_no_lang() {
+            check_render(
+                mdq_nodes![CodeBlock {
+                    variant: CodeVariant::Code(None),
+                    value: "one\ntwo".to_string(),
+                }],
+                indoc! {r#"
+                ```
+                one
+                two
+                ```"#},
+            );
+        }
+
+        #[test]
+        fn code_with_lang() {
+            check_render(
+                mdq_nodes![CodeBlock {
+                    variant: CodeVariant::Code(Some(CodeOpts {
+                        language: "rust".to_string(),
+                        metadata: None
+                    })),
+                    value: "one\ntwo".to_string(),
+                }],
+                indoc! {r#"
+                ```rust
+                one
+                two
+                ```"#},
+            );
+        }
+
+        #[test]
+        fn code_with_lang_and_title() {
+            check_render(
+                mdq_nodes![CodeBlock {
+                    variant: CodeVariant::Code(Some(CodeOpts {
+                        language: "rust".to_string(),
+                        metadata: Some(r#"title="my code""#.to_string())
+                    })),
+                    value: "one\ntwo".to_string(),
+                }],
+                indoc! {r#"
+                ```rust title="my code"
+                one
+                two
+                ```"#},
+            );
+        }
+
+        #[test]
+        fn math_no_metadata() {
+            check_render(
+                mdq_nodes![CodeBlock {
+                    variant: CodeVariant::Math { metadata: None },
+                    value: "one\ntwo".to_string(),
+                }],
+                indoc! {r#"
+                $$
+                one
+                two
+                $$"#},
+            );
+        }
+
+        #[test]
+        fn math_with_metadata() {
+            check_render(
+                mdq_nodes![CodeBlock {
+                    variant: CodeVariant::Math {
+                        metadata: Some("metadata".to_string())
+                    },
+                    value: "one\ntwo".to_string(),
+                }],
+                indoc! {r#"
+                $$ metadata
+                one
+                two
+                $$"#},
+            );
+        }
+
+        #[test]
+        fn toml() {
+            check_render(
+                mdq_nodes![CodeBlock {
+                    variant: CodeVariant::Toml,
+                    value: "one\ntwo".to_string(),
+                }],
+                indoc! {r#"
+                +++
+                one
+                two
+                +++"#},
+            );
+        }
+
+        #[test]
+        fn yaml() {
+            check_render(
+                mdq_nodes![CodeBlock {
+                    variant: CodeVariant::Yaml,
+                    value: "one\ntwo".to_string(),
+                }],
+                indoc! {r#"
+                ---
+                one
+                two
+                ---"#},
+            );
+        }
+    }
+
+    mod inline {
+        use super::*;
+
+        mod span {
+            use super::*;
+
+            #[test]
+            fn delete() {
+                check_render(
+                    vec![MdqNode::Inline(mdq_inline!(span Delete [mdq_inline!("hello world")]))],
+                    indoc! {"~~hello world~~"},
+                );
+            }
+
+            #[test]
+            fn emphasis() {
+                check_render(
+                    vec![MdqNode::Inline(mdq_inline!(span Emphasis [mdq_inline!("hello world")]))],
+                    indoc! {"_hello world_"},
+                );
+            }
+
+            #[test]
+            fn strong() {
+                check_render(
+                    vec![MdqNode::Inline(mdq_inline!(span Strong [mdq_inline!("hello world")]))],
+                    indoc! {"**hello world**"},
+                );
+            }
+
+            #[test]
+            fn mixed() {
+                check_render(
+                    vec![MdqNode::Inline(mdq_inline!(span Emphasis [
+                        mdq_inline!("one "),
+                        mdq_inline!(span Strong [
+                            mdq_inline!("two "),
+                            mdq_inline!(span Delete [
+                                mdq_inline!("three")
+                            ]),
+                        ]),
+                    ]))],
+                    indoc! {"_one **two ~~three~~**_"},
+                );
+            }
+        }
+
+        mod text {
+            use super::*;
+
+            #[test]
+            fn text() {
+                check_render(
+                    vec![MdqNode::Inline(Inline::Text {
+                        variant: InlineVariant::Text,
+                        value: "hello world".to_string(),
+                    })],
+                    indoc! {"hello world"},
+                );
+            }
+
+            #[test]
+            fn code() {
+                check_render(
+                    vec![MdqNode::Inline(Inline::Text {
+                        variant: InlineVariant::Code,
+                        value: "hello world".to_string(),
+                    })],
+                    indoc! {"`hello world`"},
+                );
+            }
+
+            #[test]
+            fn math() {
+                check_render(
+                    vec![MdqNode::Inline(Inline::Text {
+                        variant: InlineVariant::Math,
+                        value: "hello world".to_string(),
+                    })],
+                    indoc! {"$hello world$"},
+                );
+            }
+
+            #[test]
+            fn html() {
+                check_render(
+                    vec![MdqNode::Inline(Inline::Text {
+                        variant: InlineVariant::Html,
+                        value: "<a hello />".to_string(),
+                    })],
+                    indoc! {"<a hello />"},
+                );
+            }
+        }
+
+        mod link {
+            use super::*;
+            use crate::tree::{Inline, Link, MdqNode};
+
+            #[test]
+            fn inline_no_title() {
+                check_link(
+                    Link {
+                        url: "https://example.com".to_string(),
+                        title: None,
+                        reference: LinkReference::Inline,
+                    },
+                    indoc! {r#"
+                        [hello _world_!](https://example.com)
+
+                        ***"#},
+                );
+            }
+
+            #[test]
+            fn full_no_title() {
+                check_link(
+                    Link {
+                        url: "https://example.com".to_string(),
+                        title: None,
+                        reference: LinkReference::Full("1".to_string()),
+                    },
+                    indoc! {r#"
+                        [hello _world_!][1]
+
+                        ***
+
+                        [1]: https://example.com"#},
+                );
+            }
+
+            #[test]
+            fn collapsed_no_title() {
+                check_link(
+                    Link {
+                        url: "https://example.com".to_string(),
+                        title: None,
+                        reference: LinkReference::Collapsed,
+                    },
+                    indoc! {r#"
+                        [hello _world_!][]
+
+                        ***
+
+                        [hello _world_!]: https://example.com"#},
+                );
+            }
+
+            #[test]
+            fn shortcut_no_title() {
+                check_link(
+                    Link {
+                        url: "https://example.com".to_string(),
+                        title: None,
+                        reference: LinkReference::Shortcut,
+                    },
+                    indoc! {r#"
+                        [hello _world_!]
+
+                        ***
+
+                        [hello _world_!]: https://example.com"#},
+                );
+            }
+
+            #[test]
+            fn inline_with_title() {
+                check_link(
+                    Link {
+                        url: "https://example.com".to_string(),
+                        title: Some("my title".to_string()),
+                        reference: LinkReference::Inline,
+                    },
+                    indoc! {r#"
+                        [hello _world_!](https://example.com "my title")
+
+                        ***"#},
+                );
+            }
+
+            #[test]
+            fn full_with_title() {
+                check_link(
+                    Link {
+                        url: "https://example.com".to_string(),
+                        title: Some("my title".to_string()),
+                        reference: LinkReference::Full("1".to_string()),
+                    },
+                    indoc! {r#"
+                        [hello _world_!][1]
+
+                        ***
+
+                        [1]: https://example.com "my title""#},
+                );
+            }
+
+            #[test]
+            fn collapsed_with_title() {
+                check_link(
+                    Link {
+                        url: "https://example.com".to_string(),
+                        title: Some("my title".to_string()),
+                        reference: LinkReference::Collapsed,
+                    },
+                    indoc! {r#"
+                        [hello _world_!][]
+
+                        ***
+
+                        [hello _world_!]: https://example.com "my title""#},
+                );
+            }
+
+            #[test]
+            fn shortcut_with_title() {
+                check_link(
+                    Link {
+                        url: "https://example.com".to_string(),
+                        title: Some("my title".to_string()),
+                        reference: LinkReference::Shortcut,
+                    },
+                    indoc! {r#"
+                        [hello _world_!]
+
+                        ***
+
+                        [hello _world_!]: https://example.com "my title""#},
+                );
+            }
+
+            fn check_link(link: Link, expect: &str) {
+                let nodes = vec![
+                    MdqNode::Inline(Inline::Link {
+                        text: vec![
+                            mdq_inline!("hello "),
+                            mdq_inline!(span Emphasis [mdq_inline!("world")]),
+                            mdq_inline!("!"),
+                        ],
+                        link,
+                    }),
+                    MdqNode::ThematicBreak,
+                ];
+                check_render(nodes, expect);
+            }
+        }
+
+        mod image {
+            use super::*;
+            use crate::tree::{Inline, Link, MdqNode};
+
+            #[test]
+            fn inline_no_title() {
+                check_image(
+                    Link {
+                        url: "https://example.com".to_string(),
+                        title: None,
+                        reference: LinkReference::Inline,
+                    },
+                    indoc! {r#"
+                        ![hello _world_!](https://example.com)
+
+                        ***"#},
+                );
+            }
+
+            #[test]
+            fn full_no_title() {
+                check_image(
+                    Link {
+                        url: "https://example.com".to_string(),
+                        title: None,
+                        reference: LinkReference::Full("1".to_string()),
+                    },
+                    indoc! {r#"
+                        ![hello _world_!][1]
+
+                        ***
+
+                        [1]: https://example.com"#},
+                );
+            }
+
+            #[test]
+            fn collapsed_no_title() {
+                check_image(
+                    Link {
+                        url: "https://example.com".to_string(),
+                        title: None,
+                        reference: LinkReference::Collapsed,
+                    },
+                    indoc! {r#"
+                        ![hello _world_!][]
+
+                        ***
+
+                        [hello _world_!]: https://example.com"#},
+                );
+            }
+
+            #[test]
+            fn shortcut_no_title() {
+                check_image(
+                    Link {
+                        url: "https://example.com".to_string(),
+                        title: None,
+                        reference: LinkReference::Shortcut,
+                    },
+                    indoc! {r#"
+                        ![hello _world_!]
+
+                        ***
+
+                        [hello _world_!]: https://example.com"#},
+                );
+            }
+
+            #[test]
+            fn inline_with_title() {
+                check_image(
+                    Link {
+                        url: "https://example.com".to_string(),
+                        title: Some("my title".to_string()),
+                        reference: LinkReference::Inline,
+                    },
+                    indoc! {r#"
+                        ![hello _world_!](https://example.com "my title")
+
+                        ***"#},
+                );
+            }
+
+            #[test]
+            fn full_with_title() {
+                check_image(
+                    Link {
+                        url: "https://example.com".to_string(),
+                        title: Some("my title".to_string()),
+                        reference: LinkReference::Full("1".to_string()),
+                    },
+                    indoc! {r#"
+                        ![hello _world_!][1]
+
+                        ***
+
+                        [1]: https://example.com "my title""#},
+                );
+            }
+
+            #[test]
+            fn collapsed_with_title() {
+                check_image(
+                    Link {
+                        url: "https://example.com".to_string(),
+                        title: Some("my title".to_string()),
+                        reference: LinkReference::Collapsed,
+                    },
+                    indoc! {r#"
+                        ![hello _world_!][]
+
+                        ***
+
+                        [hello _world_!]: https://example.com "my title""#},
+                );
+            }
+
+            #[test]
+            fn shortcut_with_title() {
+                check_image(
+                    Link {
+                        url: "https://example.com".to_string(),
+                        title: Some("my title".to_string()),
+                        reference: LinkReference::Shortcut,
+                    },
+                    indoc! {r#"
+                        ![hello _world_!]
+
+                        ***
+
+                        [hello _world_!]: https://example.com "my title""#},
+                );
+            }
+
+            fn check_image(link: Link, expect: &str) {
+                let nodes = vec![
+                    MdqNode::Inline(Inline::Image {
+                        alt: "hello _world_!".to_string(),
+                        link,
+                    }),
+                    MdqNode::ThematicBreak,
+                ];
+                check_render(nodes, expect);
+            }
+        }
+    }
+
+    mod footnote {
+        use super::*;
+
+        #[test]
+        fn single_line() {
+            check_render(
+                vec![
+                    MdqNode::Inline(Inline::Footnote(Footnote {
+                        label: "a".to_string(),
+                        text: mdq_nodes![Paragraph {
+                            body: vec![mdq_inline!("Hello, world.")]
+                        }],
+                    })),
+                    MdqNode::ThematicBreak,
+                ],
+                indoc! {r#"
+                    [^a]
+
+                    ***
+
+                    [^a]: Hello, world."#},
+            )
+        }
+
+        #[test]
+        fn two_lines() {
+            check_render(
+                vec![
+                    MdqNode::Inline(Inline::Footnote(Footnote {
+                        label: "a".to_string(),
+                        text: mdq_nodes![Paragraph {
+                            body: vec![mdq_inline!("Hello,\nworld.")]
+                        }],
+                    })),
+                    MdqNode::ThematicBreak,
+                ],
+                indoc! {r#"
+                    [^a]
+
+                    ***
+
+                    [^a]: Hello,
+                      world."#},
+            )
+        }
+    }
+
+    mod annotation_and_footnote_layouts {
+        use super::*;
+        use crate::fmt_md::ReferencePlacement;
+
+        #[test]
+        fn link_and_footnote() {
+            check_render(
+                mdq_nodes![Paragraph {
+                    body: vec![
+                        mdq_inline!("Hello, "),
+                        Inline::Link {
+                            text: vec![mdq_inline!("world"),],
+                            link: Link {
+                                url: "https://example.com".to_string(),
+                                title: None,
+                                reference: LinkReference::Full("1".to_string()),
+                            }
+                        },
+                        mdq_inline!("! This is interesting"),
+                        Inline::Footnote(Footnote {
+                            label: "a".to_string(),
+                            text: mdq_nodes![Paragraph {
+                                body: vec![mdq_inline!("this is my note")]
+                            }],
+                        }),
+                        mdq_inline!("."),
+                    ],
+                }],
+                indoc! {r#"
+                    Hello, [world][1]! This is interesting[^a].
+
+                    [1]: https://example.com
+                    [^a]: this is my note"#},
+            );
+        }
+
+        #[test]
+        fn both_in_sections() {
+            check_render_with(
+                &MdOptions {
+                    link_reference_options: ReferencePlacement::BottomOfSection,
+                    footnote_reference_options: ReferencePlacement::BottomOfSection,
+                },
+                link_and_footnote_markdown(),
+                indoc! {r#"
+                    # First section
+
+                    [link description][1] and then a thought[^a].
+
+                    [1]: https://exampl.com
+                    [^a]: the footnote
+
+                    # Second section
+
+                    Second section contents."#},
+            );
+        }
+
+        #[test]
+        fn only_link_in_section() {
+            check_render_with(
+                &MdOptions {
+                    link_reference_options: ReferencePlacement::BottomOfSection,
+                    footnote_reference_options: ReferencePlacement::BottomOfDoc,
+                },
+                link_and_footnote_markdown(),
+                indoc! {r#"
+                    # First section
+
+                    [link description][1] and then a thought[^a].
+
+                    [1]: https://exampl.com
+
+                    # Second section
+
+                    Second section contents.
+
+                    [^a]: the footnote"#},
+            );
+        }
+
+        #[test]
+        fn only_footnote_in_section() {
+            check_render_with(
+                &MdOptions {
+                    link_reference_options: ReferencePlacement::BottomOfDoc,
+                    footnote_reference_options: ReferencePlacement::BottomOfSection,
+                },
+                link_and_footnote_markdown(),
+                indoc! {r#"
+                    # First section
+
+                    [link description][1] and then a thought[^a].
+
+                    [^a]: the footnote
+
+                    # Second section
+
+                    Second section contents.
+
+                    [1]: https://exampl.com"#},
+            );
+        }
+
+        #[test]
+        fn both_bottom_of_doc() {
+            check_render_with(
+                &MdOptions {
+                    link_reference_options: ReferencePlacement::BottomOfDoc,
+                    footnote_reference_options: ReferencePlacement::BottomOfDoc,
+                },
+                link_and_footnote_markdown(),
+                indoc! {r#"
+                    # First section
+
+                    [link description][1] and then a thought[^a].
+
+                    # Second section
+
+                    Second section contents.
+
+                    [1]: https://exampl.com
+                    [^a]: the footnote"#},
+            );
+        }
+
+        fn link_and_footnote_markdown() -> Vec<MdqNode> {
+            mdq_nodes![
+                Header {
+                    depth: 1,
+                    title: vec![mdq_inline!("First section")],
+                    body: mdq_nodes![Paragraph {
+                        body: vec![
+                            Inline::Link {
+                                text: vec![mdq_inline!("link description")],
+                                link: Link {
+                                    url: "https://exampl.com".to_string(),
+                                    title: None,
+                                    reference: LinkReference::Full("1".to_string()),
+                                },
+                            },
+                            mdq_inline!(" and then a thought"),
+                            Inline::Footnote(Footnote {
+                                label: "a".to_string(),
+                                text: mdq_nodes![Paragraph {
+                                    body: vec![mdq_inline!("the footnote")]
+                                }],
+                            }),
+                            mdq_inline!("."),
+                        ],
+                    }],
+                },
+                Header {
+                    depth: 1,
+                    title: vec![mdq_inline!("Second section")],
+                    body: mdq_nodes![Paragraph {
+                        body: vec![mdq_inline!("Second section contents.")]
+                    }],
+                },
+            ]
+        }
+    }
+
+    #[test]
+    fn all_variants_checked() {
+        VARIANTS_CHECKER.wait_for_all();
+    }
+
+    fn check_render(nodes: Vec<MdqNode>, expect: &str) {
+        check_render_with(&MdOptions::default(), nodes, expect);
+    }
+
+    fn check_render_with(options: &MdOptions, nodes: Vec<MdqNode>, expect: &str) {
+        nodes.iter().for_each(|n| VARIANTS_CHECKER.see(n));
+
+        let mut out = Output::new(String::default());
+        write_md(options, &mut out, &nodes);
+        let actual = out.take_underlying().unwrap();
+        assert_eq!(&actual, expect);
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 82623e5..b6d7c4a 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -13,6 +13,8 @@ mod output;
 mod select;
 mod str_utils;
 mod tree;
+mod tree_test_utils;
+mod utils_for_test;
 
 fn main() {
     let mut contents = String::new();
diff --git a/src/output.rs b/src/output.rs
index 3a1fbe9..b3810fc 100644
--- a/src/output.rs
+++ b/src/output.rs
@@ -153,10 +153,10 @@ impl<W: SimpleWrite> Output<W> {
         }
 
         // print the newlines before we append the new blocks
-        let need_indent = if self.pending_newlines > 0 {
+        let need_full_indent = if self.pending_newlines > 0 {
             for _ in 0..(self.pending_newlines - 1) {
                 self.write_raw("\n");
-                self.write_indent(None);
+                self.write_indent(true, None);
             }
             self.write_raw("\n"); // we'll do this indent after we add the pending blocks
             self.pending_newlines = 0;
@@ -167,15 +167,20 @@ impl<W: SimpleWrite> Output<W> {
 
         // Append the new blocks, and then write the indent if we need it
         // When we write that indent, though, only write it until the first new Inlined (exclusive).
-        let indent_end_idx = self.blocks.len()
-            + self
-                .pending_blocks
-                .iter()
-                .position(|b| matches!(b, Block::Inlined(_)))
-                .unwrap_or_else(|| self.pending_blocks.len());
-        self.blocks.append(&mut self.pending_blocks);
-        if need_indent {
-            self.write_indent(Some(0..indent_end_idx));
+        if need_full_indent {
+            let indent_end_idx = self.blocks.len()
+                + self
+                    .pending_blocks
+                    .iter()
+                    .position(|b| matches!(b, Block::Inlined(_)))
+                    .unwrap_or_else(|| self.pending_blocks.len());
+            self.blocks.append(&mut self.pending_blocks);
+            self.write_indent(true, Some(0..indent_end_idx));
+        } else {
+            self.write_padding_after_indent();
+            let prev_blocks_len = self.blocks.len();
+            self.blocks.append(&mut self.pending_blocks);
+            self.write_indent(false, Some(prev_blocks_len..self.blocks.len()));
         }
 
         // Finally, write the text
@@ -185,7 +190,9 @@ impl<W: SimpleWrite> Output<W> {
         self.write_raw(text);
     }
 
-    fn write_indent(&mut self, range: Option<Range<usize>>) {
+    /// Write an indentation for a given range of indentation block. If `include_inlines` is false, `Inlined` blocks
+    /// will be disregarded. Do this for the first line in which those inlines appear (and only there).
+    fn write_indent(&mut self, include_inlines: bool, range: Option<Range<usize>>) {
         self.pending_padding_after_indent = 0;
         for idx in range.unwrap_or_else(|| 0..self.blocks.len()) {
             match &self.blocks[idx] {
@@ -196,7 +203,9 @@ impl<W: SimpleWrite> Output<W> {
                     self.pending_padding_after_indent += 1;
                 }
                 Block::Inlined(size) => {
-                    self.pending_padding_after_indent += size;
+                    if include_inlines {
+                        self.pending_padding_after_indent += size;
+                    }
                 }
             }
         }
@@ -239,6 +248,9 @@ impl<W: SimpleWrite> Output<W> {
             eprintln!("error while writing output: {}", e);
             self.writing_state = WritingState::Error;
         }
+        if matches!(self.writing_state, WritingState::HaveNotWrittenAnything) {
+            self.writing_state = WritingState::Normal;
+        }
     }
 }
 
@@ -357,6 +369,23 @@ mod tests {
         );
     }
 
+    #[test]
+    fn quote_block_one_char_at_a_time_as_initial_output() {
+        // We haven't written anything, and then we start writing quote blocks
+        assert_eq!(
+            out_to_str(|out| {
+                out.with_block(Block::Quote, |out| {
+                    out.write_str(""); // empty prefix char
+                    "hello\nworld".chars().for_each(|c| out.write_char(c));
+                    out.write_str(""); // empty suffix char
+                });
+            }),
+            indoc! {r#"
+                > hello
+                > world"#}
+        );
+    }
+
     #[test]
     fn inlined_block() {
         assert_eq!(
diff --git a/src/str_utils.rs b/src/str_utils.rs
index d8c8912..9708e2b 100644
--- a/src/str_utils.rs
+++ b/src/str_utils.rs
@@ -17,59 +17,57 @@ where
     let padding = min_width - input.len();
 
     match standard_align(alignment) {
-        Alignment::Left => {
+        Some(Alignment::Left) | None => {
             output.write_str(input);
             (0..padding).for_each(|_| output.write_char(' '));
         }
-        Alignment::Center => {
+        Some(Alignment::Center) => {
             let left_pad = padding / 2; // round down
             let right_pad = padding - left_pad;
             (0..left_pad).for_each(|_| output.write_char(' '));
             output.write_str(input);
             (0..right_pad).for_each(|_| output.write_char(' '));
         }
-        Alignment::Right => {
+        Some(Alignment::Right) => {
             (0..padding).for_each(|_| output.write_char(' '));
             output.write_str(input);
         }
     }
 }
 
-pub fn standard_align<A>(mdast_align: A) -> Alignment
+pub fn standard_align<A>(mdast_align: A) -> Option<Alignment>
 where
     A: ToAlignment,
 {
     mdast_align.to_alignment()
 }
 
-const DEFAULT_ALIGNMENT: Alignment = Alignment::Left;
-
 pub trait ToAlignment {
-    fn to_alignment(&self) -> Alignment;
+    fn to_alignment(&self) -> Option<Alignment>;
 }
 
 impl ToAlignment for Alignment {
-    fn to_alignment(&self) -> Alignment {
-        *self
+    fn to_alignment(&self) -> Option<Alignment> {
+        Some(*self)
     }
 }
 
 impl ToAlignment for &AlignKind {
-    fn to_alignment(&self) -> Alignment {
+    fn to_alignment(&self) -> Option<Alignment> {
         match self {
-            AlignKind::Left => Alignment::Left,
-            AlignKind::Right => Alignment::Right,
-            AlignKind::Center => Alignment::Center,
-            _ => DEFAULT_ALIGNMENT,
+            AlignKind::Left => Some(Alignment::Left),
+            AlignKind::Right => Some(Alignment::Right),
+            AlignKind::Center => Some(Alignment::Center),
+            AlignKind::None => None,
         }
     }
 }
 
 impl<A: Borrow<AlignKind>> ToAlignment for Option<A> {
-    fn to_alignment(&self) -> Alignment {
+    fn to_alignment(&self) -> Option<Alignment> {
         match self {
             Some(a) => a.borrow().to_alignment(),
-            None => DEFAULT_ALIGNMENT,
+            None => None,
         }
     }
 }
diff --git a/src/tree.rs b/src/tree.rs
index 49e7807..1c2a98e 100644
--- a/src/tree.rs
+++ b/src/tree.rs
@@ -23,7 +23,7 @@ pub enum MdqNode {
     CodeBlock(CodeBlock),
 
     // inline spans
-    Inline(Inline), // TODO rename to "span"?
+    Inline(Inline),
 }
 
 #[derive(Debug, PartialEq)]


Test patch:
diff --git a/src/tree_test_utils.rs b/src/tree_test_utils.rs
new file mode 100644
index 0000000..06fd093
--- /dev/null
+++ b/src/tree_test_utils.rs
@@ -0,0 +1,47 @@
+#[cfg(test)]
+mod test_utils {
+
+    #[macro_export]
+    macro_rules! mdq_node {
+        ($node_type:tt {$($attr:ident: $val:expr),*}) => {
+            MdqNode::$node_type($node_type{$($attr: $val),*})
+        };
+        ($paragraph_text:literal) => {
+            crate::mdq_node!(Paragraph{body: vec![crate::mdq_inline!($paragraph_text)]})
+        };
+    }
+
+    #[macro_export]
+    macro_rules! mdq_nodes {
+        [$($node_type:tt {$($attr:ident: $val:expr),*$(,)?}),*$(,)?] => {
+            vec![$(
+                crate::mdq_node!($node_type {
+                    $($attr: $val),*
+                })
+                ),*
+            ]
+        };
+        [$($paragraph_text:literal),*$(,)?] => {
+            vec![$(
+                    crate::mdq_node!($paragraph_text)
+                ),*
+            ]
+        }
+    }
+
+    #[macro_export]
+    macro_rules! mdq_inline {
+        (span $which:ident [$($contents:expr),*$(,)?]) => {
+            crate::tree::Inline::Span {
+                variant: crate::tree::SpanVariant::$which,
+                children: vec![$($contents),*],
+            }
+        };
+        ($text:literal) => {
+            crate::tree::Inline::Text {
+                variant: crate::tree::InlineVariant::Text,
+                value: $text.to_string(),
+            }
+        };
+    }
+}
diff --git a/src/utils_for_test.rs b/src/utils_for_test.rs
new file mode 100644
index 0000000..b49f2b9
--- /dev/null
+++ b/src/utils_for_test.rs
@@ -0,0 +1,73 @@
+#[cfg(test)]
+pub use test_utils::*;
+
+// We this file's contents from prod by putting them in a submodule guarded by cfg(test), but then "pub use" it to
+// export its contents.
+#[cfg(test)]
+mod test_utils {
+    use std::{thread, time};
+
+    // TODO unify this with the one in tree.rs.
+    pub struct VariantsChecker<E> {
+        require: std::sync::Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
+        resolver: fn(&E) -> &str,
+    }
+
+    impl<E> VariantsChecker<E> {
+        pub fn new<I>(require: I, resolver: fn(&E) -> &str) -> Self
+        where
+            I: IntoIterator<Item = String>,
+        {
+            Self {
+                require: std::sync::Arc::new(std::sync::Mutex::new(require.into_iter().collect())),
+                resolver,
+            }
+        }
+
+        pub fn see(&self, node: &E) {
+            let node_str = (self.resolver)(node);
+            self.require.lock().map(|mut set| set.remove(node_str)).unwrap();
+        }
+
+        pub fn wait_for_all(&self) {
+            let timeout = time::Duration::from_millis(500);
+            let retry_delay = time::Duration::from_millis(50);
+            let start = time::Instant::now();
+            loop {
+                if self.require.lock().map(|set| set.is_empty()).unwrap() {
+                    break;
+                }
+                if start.elapsed() >= timeout {
+                    let mut remaining: Vec<String> = self
+                        .require
+                        .lock()
+                        .map(|set| set.iter().map(|s| s.to_owned()).collect())
+                        .unwrap();
+                    remaining.sort();
+                    panic!(
+                        "Timed out, and missing {} variants:\n- {}",
+                        remaining.len(),
+                        remaining.join("\n- ")
+                    )
+                }
+                thread::sleep(retry_delay);
+            }
+        }
+    }
+
+    #[macro_export]
+    macro_rules! new_variants_checker {
+        {$enum_type:ty : $($variant:pat),* $(,)?} => {
+            {
+                use $enum_type::*;
+
+                VariantsChecker::new(
+                    vec![$(stringify!($variant).to_string(),)*],
+                    {|elem| match elem {
+                        $($variant => stringify!($variant),)*
+                    }}
+                )
+            }
+        };
+    }
+}