Repository: yshavit/mdq
Pull request #286 (2025-03-30 10:06:57)

option to output with or without breaks
- In markdown output, this controls whether matched elements have thematic break ( `-----` ) between them. The default is yes, and `--no-br` turns that off.
- In plain output, this controls whether matched elements have a newline between them. The default is no, and `--br` turns it on.

This resolvse #270.

coderabbitai[bot]: <!-- This is an auto-generated comment: summarize by coderabbit.ai -->
<!-- walkthrough_start -->

<details>
<summary>📝 Walkthrough</summary>

## Walkthrough

The pull request introduces multiple updates across different modules. In the release workflow, the command processing pull request bodies is simplified by removing the additional thematic break filter, while keeping the logic and exit-status handling intact. The CLI now includes three new boolean fields and a helper method to manage output breaks, alongside extra validation for conflicting options. In the Markdown module, a new option allows toggling the inclusion of thematic breaks. The plain text formatter is updated to accept a new options struct that controls line break behavior, and corresponding changes were made in its test cases. Additionally, the library integrates the new plain text options using the updated CLI output settings and default test options. Test cases in the TOML file have been extended to cover scenarios for both Markdown and plain outputs, including handling of Bash code blocks.

</details>

<!-- walkthrough_end -->
<!-- internal state start -->


<!-- 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 -->

<!-- internal state end -->
<!-- finishing_touch_checkbox_start -->

<details>
<summary>✨ Finishing Touches</summary>

- [ ] <!-- {"checkboxId": "7962f53c-55bc-4827-bfbf-6a18da830691"} --> 📝 Generate Docstrings

</details>

<!-- finishing_touch_checkbox_end -->
<!-- tips_start -->

---



<details>
<summary>🪧 Tips</summary>

### Chat

There are 3 ways to chat with [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=yshavit/mdq&utm_content=286):

- Review comments: Directly reply to a review comment made by CodeRabbit. Example:
  - `I pushed a fix in commit <commit_id>, please review it.`
  - `Generate unit testing code for this file.`
  - `Open a follow-up GitHub issue for this discussion.`
- Files and specific lines of code (under the "Files changed" tab): Tag `@coderabbitai` in a new review comment at the desired location with your query. Examples:
  - `@coderabbitai generate unit testing code for this file.`
  -	`@coderabbitai modularize this function.`
- PR comments: Tag `@coderabbitai` in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
  - `@coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.`
  - `@coderabbitai read src/utils.ts and generate unit testing code.`
  - `@coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.`
  - `@coderabbitai help me debug CodeRabbit configuration file.`

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

### CodeRabbit Commands (Invoked using PR comments)

- `@coderabbitai pause` to pause the reviews on a PR.
- `@coderabbitai resume` to resume the paused reviews.
- `@coderabbitai review` to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
- `@coderabbitai full review` to do a full review from scratch and review all the files again.
- `@coderabbitai summary` to regenerate the summary of the PR.
- `@coderabbitai generate docstrings` to [generate docstrings](https://docs.coderabbit.ai/finishing-touches/docstrings) for this PR.
- `@coderabbitai resolve` resolve all the CodeRabbit review comments.
- `@coderabbitai plan` to trigger planning for file edits and PR creation.
- `@coderabbitai configuration` to show the current CodeRabbit configuration for the repository.
- `@coderabbitai help` to get help.

### Other keywords and placeholders

- Add `@coderabbitai ignore` anywhere in the PR description to prevent this PR from being reviewed.
- Add `@coderabbitai summary` to generate the high-level summary at a specific location in the PR description.
- Add `@coderabbitai` anywhere in the PR title to generate the title automatically.

### CodeRabbit Configuration File (`.coderabbit.yaml`)

- You can programmatically configure CodeRabbit by adding a `.coderabbit.yaml` file to the root of your repository.
- Please see the [configuration documentation](https://docs.coderabbit.ai/guides/configure-coderabbit) for more information.
- If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: `# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json`

### Documentation and Community

- Visit our [Documentation](https://docs.coderabbit.ai) for detailed information on how to use CodeRabbit.
- Join our [Discord Community](http://discord.gg/coderabbit) to get help, request features, and share feedback.
- Follow us on [X/Twitter](https://twitter.com/coderabbitai) for updates and announcements.

</details>

<!-- tips_end -->
yshavit: @coderabbitai please re-review
coderabbitai[bot]: <!-- This is an auto-generated reply by CodeRabbit -->
<details>
<summary>🧩 Analysis chain</summary>

🏁 Script executed:
```
#!/bin/bash
# Get the list of files changed in this PR
git diff --name-only HEAD^...HEAD
```

Length of output: 230

---

🏁 Script executed:
```
#!/bin/bash
# Get PR details
gh pr view 286

# List files changed in PR
gh pr view 286 --files

# View the PR diff
gh pr diff 286
```

Length of output: 23857

---



</details>

@yshavit I've reviewed the changes in this PR for the "option to output with or without breaks" feature. Here's my assessment:

The implementation successfully adds the ability to control breaks between elements in output formats:
- For markdown: Thematic breaks (`-----`) can be disabled with `--no-br` (default: enabled)
- For plain text: Newlines can be enabled with `--br` (default: disabled)

The code changes are well-structured and include:
1. Well-designed CLI arguments with appropriate validation
2. Proper propagation of user preferences to the formatting modules
3. Comprehensive test coverage for both formats (with and without breaks)
4. Good documentation in the CLI help text

The approach to handling boolean flags with `--br` and `--no-br` using a hidden umbrella argument is clever and maintains a clean user interface.

All test cases have been updated to verify both output variants, ensuring the feature works as expected.

Grade: A

<details>
<summary>:white_check_mark: Actions performed</summary>

Review triggered.

> Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

</details>

Patch:
diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml
index a1aed62..fe77f21 100644
--- a/.github/workflows/release-publish.yml
+++ b/.github/workflows/release-publish.yml
@@ -56,10 +56,7 @@ jobs:
           while read -r line ; do
             echo "::error title=unfinished task::$line"
             exit_status=1
-          done < <(
-            echo "$PR_JSON" | jq -r .body | docker run --rm -i "$DOCKER_RC_TAG" -o plain '- [ ]' | grep -v '^ *-* *$'
-            # note: the grep is to remove the thematic breaks; see https://github.com/yshavit/mdq/issues/270
-          )
+          done < <(echo "$PR_JSON" | jq -r .body | docker run --rm -i "$DOCKER_RC_TAG" -o plain '- [ ]')
           exit "$exit_status"
         env:
           PR_JSON: ${{ steps.pr-info.outputs.json }}
diff --git a/src/cli.rs b/src/cli.rs
index b6db6c5..4e45a7d 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -29,6 +29,28 @@ pub struct Cli {
     #[arg(long, short, default_value_t = OutputFormat::Markdown)]
     pub(crate) output: OutputFormat,
 
+    /// Include breaks between elements in plain and markdown output mode.
+    ///
+    /// For plain, this will add a blank line between elements. For markdown, this will add a thematic break
+    /// ("-----") between elements.
+    ///
+    /// This has no effect in JSON output mode.
+    ///
+    /// This defaults to true for Markdown output, and false for plain text output.
+    // Note: this is a fake arg so we have explicit validation below to ensure it isn't invoked. Clap doesn't let us
+    // add boolean flags with 'no-' to disable, so I'm using this trick to fake that out. Basically, this fake arg is
+    // only for the help text, and then --br and --no-br are fake but hidden args.
+    #[arg(long = "[no]-br", action)]
+    pub(crate) br_umbrella: bool,
+
+    /// Negates the --br option.
+    #[arg(long, hide = true)]
+    pub(crate) br: bool,
+
+    /// Negates the --br option.
+    #[arg(long, conflicts_with = "br", hide = true)]
+    pub(crate) no_br: bool,
+
     /// The number of characters to wrap text at. This is only valid when the output format is
     /// markdown.
     ///
@@ -75,6 +97,14 @@ impl Cli {
         }
     }
 
+    pub fn should_add_breaks(&self) -> bool {
+        match self.output {
+            OutputFormat::Json => false,
+            OutputFormat::Markdown | OutputFormat::Md => !self.no_br,
+            OutputFormat::Plain => self.br,
+        }
+    }
+
     pub fn extra_validation(&self) -> bool {
         match self.output {
             OutputFormat::Json => {
@@ -85,14 +115,22 @@ impl Cli {
                             "Can't set text width with JSON output format",
                         )
                         .print();
-                    false
-                } else {
-                    true
+                    return false;
                 }
             }
-            OutputFormat::Markdown | OutputFormat::Md => true,
-            OutputFormat::Plain => true,
+            OutputFormat::Markdown | OutputFormat::Md => {}
+            OutputFormat::Plain => {}
+        }
+        if self.br_umbrella {
+            let _ = Cli::command()
+                .error(
+                    ErrorKind::UnknownArgument,
+                    r"invalid argument '--[no]-br'; use '--br' or '--no-br'.",
+                )
+                .print();
+            return false;
         }
+        true
     }
 }
 
diff --git a/src/fmt_md.rs b/src/fmt_md.rs
index 8bde7dc..d73e588 100644
--- a/src/fmt_md.rs
+++ b/src/fmt_md.rs
@@ -14,6 +14,7 @@ pub struct MdOptions {
     pub link_reference_placement: ReferencePlacement,
     pub footnote_reference_placement: ReferencePlacement,
     pub inline_options: MdInlinesWriterOptions,
+    pub include_thematic_breaks: bool,
 }
 
 #[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
@@ -68,7 +69,7 @@ where
         prev_was_thematic_break: false,
         inlines_writer: &mut MdInlinesWriter::new(ctx, options.inline_options),
     };
-    let nodes_count = writer_state.write_md(out, nodes, true);
+    let nodes_count = writer_state.write_md(out, nodes, options.include_thematic_breaks);
 
     // 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. We'll add a thematic break before the links if there
diff --git a/src/fmt_plain.rs b/src/fmt_plain.rs
index fd9beee..6fbe967 100644
--- a/src/fmt_plain.rs
+++ b/src/fmt_plain.rs
@@ -3,20 +3,26 @@ use crate::tree::{Formatting, Image, Inline, Line, Link, List, Text};
 use crate::tree_ref::{ListItemRef, MdElemRef};
 use std::io::{Error, LineWriter, Write};
 
-pub fn write_plain<'md, I, W>(out: &mut W, nodes: I)
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub struct PlainOutputOpts {
+    pub include_breaks: bool,
+}
+
+pub fn write_plain<'md, I, W>(out: &mut W, opts: PlainOutputOpts, nodes: I)
 where
     I: Iterator<Item = MdElemRef<'md>>,
     W: Write,
 {
-    write_top_level(out, nodes).expect("while writing output");
+    write_top_level(out, opts, nodes).expect("while writing output");
 }
 
-fn write_top_level<'md, I, W>(out: &mut W, nodes: I) -> std::io::Result<()>
+fn write_top_level<'md, I, W>(out: &mut W, opts: PlainOutputOpts, nodes: I) -> std::io::Result<()>
 where
     I: Iterator<Item = MdElemRef<'md>>,
     W: Write,
 {
-    let mut writer = NewlineCollapser::new(LineWriter::new(out), 2);
+    let newlines_max = if opts.include_breaks { 2 } else { 1 };
+    let mut writer = NewlineCollapser::new(LineWriter::new(out), newlines_max);
     write_plain_result(&mut writer, nodes)?;
     writer.flush()?;
     if writer.have_pending_newlines() {
@@ -173,20 +179,38 @@ mod test {
     fn doc_empty() {
         let empty = vec![];
         let doc = MdElemRef::Doc(&empty);
-        check_plain(doc, "");
+        check_plain(
+            doc,
+            Expect {
+                with_breaks: "",
+                no_breaks: "",
+            },
+        );
     }
 
     #[test]
     fn doc_not_empty() {
         let body = md_elems!("hello", "world");
         let doc = MdElemRef::Doc(&body);
-        check_plain(doc, "hello\n\nworld\n");
+        check_plain(
+            doc,
+            Expect {
+                with_breaks: "hello\n\nworld\n",
+                no_breaks: "hello\nworld\n",
+            },
+        );
     }
 
     #[test]
     fn block_quote_empty() {
         let md_elem = md_elem!(BlockQuote { body: vec![] });
-        check_plain(checked_elem_ref!(md_elem => MdElemRef::BlockQuote(_)), "")
+        check_plain(
+            checked_elem_ref!(md_elem => MdElemRef::BlockQuote(_)),
+            Expect {
+                with_breaks: "",
+                no_breaks: "",
+            },
+        )
     }
 
     #[test]
@@ -194,7 +218,13 @@ mod test {
         let md_elem = md_elem!(BlockQuote {
             body: md_elems!("hello, world")
         });
-        check_plain(checked_elem_ref!(md_elem => MdElemRef::BlockQuote(_)), "hello, world\n")
+        check_plain(
+            checked_elem_ref!(md_elem => MdElemRef::BlockQuote(_)),
+            Expect {
+                with_breaks: "hello, world\n",
+                no_breaks: "hello, world\n",
+            },
+        )
     }
 
     #[test]
@@ -203,7 +233,13 @@ mod test {
             variant: CodeVariant::Code(None),
             value: "".to_string()
         });
-        check_plain(checked_elem_ref!(md_elem => MdElemRef::CodeBlock(_)), "")
+        check_plain(
+            checked_elem_ref!(md_elem => MdElemRef::CodeBlock(_)),
+            Expect {
+                with_breaks: "",
+                no_breaks: "",
+            },
+        )
     }
 
     #[test]
@@ -212,14 +248,26 @@ mod test {
             variant: CodeVariant::Code(None),
             value: "hello, world".to_string()
         });
-        check_plain(checked_elem_ref!(md_elem => MdElemRef::CodeBlock(_)), "hello, world\n")
+        check_plain(
+            checked_elem_ref!(md_elem => MdElemRef::CodeBlock(_)),
+            Expect {
+                with_breaks: "hello, world\n",
+                no_breaks: "hello, world\n",
+            },
+        )
     }
 
     #[test]
     fn inline() {
         let md_elem =
             mdq_inline!(span Emphasis [mdq_inline!("hello, "), mdq_inline!(span Strong [mdq_inline!("world")])]);
-        check_plain(checked_elem_ref!(md_elem => MdElemRef::Inline(_)), "hello, world\n");
+        check_plain(
+            checked_elem_ref!(md_elem => MdElemRef::Inline(_)),
+            Expect {
+                with_breaks: "hello, world\n",
+                no_breaks: "hello, world\n",
+            },
+        );
     }
 
     #[test]
@@ -228,7 +276,13 @@ mod test {
             starting_index: None,
             items: vec![]
         });
-        check_plain(checked_elem_ref!(md_elem => MdElemRef::List(_)), "");
+        check_plain(
+            checked_elem_ref!(md_elem => MdElemRef::List(_)),
+            Expect {
+                with_breaks: "",
+                no_breaks: "",
+            },
+        );
     }
 
     #[test]
@@ -240,7 +294,13 @@ mod test {
                 item: md_elems!("only item"),
             }]
         });
-        check_plain(checked_elem_ref!(md_elem => MdElemRef::List(_)), "only item\n");
+        check_plain(
+            checked_elem_ref!(md_elem => MdElemRef::List(_)),
+            Expect {
+                with_breaks: "only item\n",
+                no_breaks: "only item\n",
+            },
+        );
     }
 
     #[test]
@@ -260,14 +320,23 @@ mod test {
         });
         check_plain(
             checked_elem_ref!(md_elem => MdElemRef::List(_)),
-            "first item\n\nsecond item\n",
+            Expect {
+                with_breaks: "first item\n\nsecond item\n",
+                no_breaks: "first item\nsecond item\n",
+            },
         );
     }
 
     #[test]
     fn paragraph() {
         let md_elem = md_elem!("hello, world");
-        check_plain(checked_elem_ref!(md_elem => MdElemRef::Paragraph(_)), "hello, world\n");
+        check_plain(
+            checked_elem_ref!(md_elem => MdElemRef::Paragraph(_)),
+            Expect {
+                with_breaks: "hello, world\n",
+                no_breaks: "hello, world\n",
+            },
+        );
     }
 
     #[test]
@@ -279,7 +348,10 @@ mod test {
         });
         check_plain(
             checked_elem_ref!(md_elem => MdElemRef::Section(_)),
-            "section heading\n\nsection body\n",
+            Expect {
+                with_breaks: "section heading\n\nsection body\n",
+                no_breaks: "section heading\nsection body\n",
+            },
         );
     }
 
@@ -288,35 +360,41 @@ mod test {
         let md_elem = md_elem!(Table {
             alignments: vec![mdast::AlignKind::None, mdast::AlignKind::Center],
             rows: vec![
-                // first row:
-                vec![
-                    // column 1
-                    vec![mdq_inline!("1A")],
-                    // column 2
-                    vec![mdq_inline!("1B")],
-                ],
-                // second row:
-                vec![
-                    // column 1
-                    vec![mdq_inline!("2A")],
-                    // column 2
-                    vec![mdq_inline!("2B")],
-                ],
+                vec![vec![mdq_inline!("1A")], vec![mdq_inline!("1B")]],
+                vec![vec![mdq_inline!("2A")], vec![mdq_inline!("2B")]],
             ]
         });
-        check_plain(checked_elem_ref!(md_elem => MdElemRef::Table(_)), "1A 1B\n2A 2B\n");
+        check_plain(
+            checked_elem_ref!(md_elem => MdElemRef::Table(_)),
+            Expect {
+                with_breaks: "1A 1B\n2A 2B\n",
+                no_breaks: "1A 1B\n2A 2B\n",
+            },
+        );
     }
 
     #[test]
     fn html() {
         let md_elem = MdElem::Html("<div>".to_string());
-        check_plain(checked_elem_ref!(md_elem => MdElemRef::Html(_)), "<div>\n");
+        check_plain(
+            checked_elem_ref!(md_elem => MdElemRef::Html(_)),
+            Expect {
+                with_breaks: "<div>\n",
+                no_breaks: "<div>\n",
+            },
+        );
     }
 
     #[test]
     fn thematic_break() {
         let md_elem = MdElem::ThematicBreak;
-        check_plain(checked_elem_ref!(md_elem => MdElemRef::ThematicBreak), "");
+        check_plain(
+            checked_elem_ref!(md_elem => MdElemRef::ThematicBreak),
+            Expect {
+                with_breaks: "",
+                no_breaks: "",
+            },
+        );
     }
 
     #[test]
@@ -325,7 +403,13 @@ mod test {
             checked: None,
             item: md_elems!("hello, world"),
         };
-        check_plain(MdElemRef::ListItem(ListItemRef(None, &list_item)), "hello, world\n");
+        check_plain(
+            MdElemRef::ListItem(ListItemRef(None, &list_item)),
+            Expect {
+                with_breaks: "hello, world\n",
+                no_breaks: "hello, world\n",
+            },
+        );
     }
 
     #[test]
@@ -334,7 +418,13 @@ mod test {
             checked: None,
             item: md_elems!("first", "second"),
         };
-        check_plain(MdElemRef::ListItem(ListItemRef(None, &list_item)), "first\n\nsecond\n");
+        check_plain(
+            MdElemRef::ListItem(ListItemRef(None, &list_item)),
+            Expect {
+                with_breaks: "first\n\nsecond\n",
+                no_breaks: "first\nsecond\n",
+            },
+        );
     }
 
     #[test]
@@ -347,7 +437,13 @@ mod test {
                 reference: LinkReference::Inline,
             },
         };
-        check_plain(checked_elem_ref!(link => MdElemRef::Link(_)), "display text\n");
+        check_plain(
+            checked_elem_ref!(link => MdElemRef::Link(_)),
+            Expect {
+                with_breaks: "display text\n",
+                no_breaks: "display text\n",
+            },
+        );
     }
 
     #[test]
@@ -360,7 +456,13 @@ mod test {
                 reference: LinkReference::Inline,
             },
         };
-        check_plain(checked_elem_ref!(image => MdElemRef::Image(_)), "alt text\n");
+        check_plain(
+            checked_elem_ref!(image => MdElemRef::Image(_)),
+            Expect {
+                with_breaks: "alt text\n",
+                no_breaks: "alt text\n",
+            },
+        );
     }
 
     #[test]
@@ -368,24 +470,18 @@ mod test {
         let table = Table {
             alignments: vec![mdast::AlignKind::None, mdast::AlignKind::Center],
             rows: vec![
-                // first row:
-                vec![
-                    // column 1
-                    vec![mdq_inline!("1A")],
-                    // column 2
-                    vec![mdq_inline!("1B")],
-                ],
-                // second row:
-                vec![
-                    // column 1
-                    vec![mdq_inline!("2A")],
-                    // column 2
-                    vec![mdq_inline!("2B")],
-                ],
+                vec![vec![mdq_inline!("1A")], vec![mdq_inline!("1B")]],
+                vec![vec![mdq_inline!("2A")], vec![mdq_inline!("2B")]],
             ],
         };
         let slice = TableSlice::from(&table);
-        check_plain(MdElemRef::TableSlice(slice), "1A 1B\n2A 2B\n");
+        check_plain(
+            MdElemRef::TableSlice(slice),
+            Expect {
+                with_breaks: "1A 1B\n2A 2B\n",
+                no_breaks: "1A 1B\n2A 2B\n",
+            },
+        );
     }
 
     #[test]
@@ -406,7 +502,13 @@ mod test {
                 mdq_inline!("."),
             ]
         });
-        check_plain(MdElemRef::from(&md), "hello world! sponsored by Example Corp.\n");
+        check_plain(
+            MdElemRef::from(&md),
+            Expect {
+                with_breaks: "hello world! sponsored by Example Corp.\n",
+                no_breaks: "hello world! sponsored by Example Corp.\n",
+            },
+        );
     }
 
     #[test]
@@ -438,36 +540,69 @@ mod test {
         ];
         check_plain(
             MdElemRef::Doc(&md),
-            indoc! {r"
-            paragraph 1
+            Expect {
+                with_breaks: indoc! {r"
+                paragraph 1
 
-            paragraph 2
+                paragraph 2
 
-            block quote paragraph 1
+                block quote paragraph 1
 
-            block quote paragraph 2
+                block quote paragraph 2
 
-            code block 1 line 1
-            code block 1 line 2
+                code block 1 line 1
+                code block 1 line 2
 
-            <hr>
+                <hr>
 
-            paragraph 3
+                paragraph 3
 
-            code block 2
+                code block 2
 
-            next section
+                next section
 
-            code block 3
-        "},
+                code block 3
+            "},
+                no_breaks: indoc! {r"
+                paragraph 1
+                paragraph 2
+                block quote paragraph 1
+                block quote paragraph 2
+                code block 1 line 1
+                code block 1 line 2
+                <hr>
+                paragraph 3
+                code block 2
+                next section
+                code block 3
+            "},
+            },
         )
     }
 
-    fn check_plain(input: MdElemRef, expect: &str) {
+    struct Expect {
+        with_breaks: &'static str,
+        no_breaks: &'static str,
+    }
+
+    fn check_plain(input: MdElemRef, expect: Expect) {
         VARIANTS_CHECKER.see(&input);
-        let mut bytes = Vec::with_capacity(expect.len());
-        write_plain(&mut bytes, [input].into_iter());
+        let mut bytes = Vec::with_capacity(expect.with_breaks.len());
+        write_plain(
+            &mut bytes,
+            PlainOutputOpts { include_breaks: true },
+            [input.clone()].into_iter(),
+        );
+        let actual = String::from_utf8(bytes).expect("got invalid utf8");
+        assert_eq!(actual, expect.with_breaks);
+
+        let mut bytes = Vec::with_capacity(expect.no_breaks.len());
+        write_plain(
+            &mut bytes,
+            PlainOutputOpts { include_breaks: false },
+            [input].into_iter(),
+        );
         let actual = String::from_utf8(bytes).expect("got invalid utf8");
-        assert_eq!(actual, expect);
+        assert_eq!(actual, expect.no_breaks);
     }
 }
diff --git a/src/lib.rs b/src/lib.rs
index 98bfe9a..64ecbbc 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,5 +1,6 @@
 use crate::fmt_md::MdOptions;
 use crate::fmt_md_inlines::MdInlinesWriterOptions;
+use crate::fmt_plain::PlainOutputOpts;
 use crate::output::{OutputOpts, Stream};
 use crate::select::{ParseError, SelectorAdapter};
 use crate::tree::{InvalidMd, MdDoc, ReadOptions};
@@ -130,6 +131,7 @@ where
             link_format: cli.link_format,
             renumber_footnotes: cli.renumber_footnotes,
         },
+        include_thematic_breaks: cli.should_add_breaks(),
     };
 
     let found_any = !pipeline_nodes.is_empty();
@@ -152,7 +154,10 @@ where
                 .unwrap();
             }
             OutputFormat::Plain => {
-                fmt_plain::write_plain(&mut stdout, pipeline_nodes.into_iter());
+                let output_opts = PlainOutputOpts {
+                    include_breaks: cli.should_add_breaks(),
+                };
+                fmt_plain::write_plain(&mut stdout, output_opts, pipeline_nodes.into_iter());
             }
         }
     }


Test patch:
diff --git a/src/utils_for_test.rs b/src/utils_for_test.rs
index 9b7ce9a..1607c96 100644
--- a/src/utils_for_test.rs
+++ b/src/utils_for_test.rs
@@ -31,6 +31,7 @@ mod test_utils {
                     link_format: LinkTransform::default_for_tests(),
                     renumber_footnotes: false,
                 },
+                include_thematic_breaks: true,
             }
         }
 
diff --git a/tests/md_cases/output_format.toml b/tests/md_cases/output_format.toml
index cd7aff2..7559daf 100644
--- a/tests/md_cases/output_format.toml
+++ b/tests/md_cases/output_format.toml
@@ -6,6 +6,10 @@ Test _one_ [two][1] three.
 some_markdown("rust");
 ```
 
+```bash
+echo 'some bash'
+```
+
 [1]: https://example.com/1
 '''
 
@@ -22,9 +26,41 @@ Test _one_ [two][1] three.
 some_markdown("rust");
 ```
 
+```bash
+echo 'some bash'
+```
+
 [1]: https://example.com/1
 '''
 
+[expect."default codes"]
+cli_args = ['```']
+output = '''
+```rust
+some_markdown("rust");
+```
+
+   -----
+
+```bash
+echo 'some bash'
+```
+'''
+
+
+
+[expect."codes with no breaks"]
+cli_args = ['--no-br', '```']
+output = '''
+```rust
+some_markdown("rust");
+```
+
+```bash
+echo 'some bash'
+```
+'''
+
 
 [expect."md"]
 cli_args = ['-o', 'md']
@@ -35,10 +71,42 @@ Test _one_ [two][1] three.
 some_markdown("rust");
 ```
 
+```bash
+echo 'some bash'
+```
+
 [1]: https://example.com/1
 '''
 
 
+[expect."md codes"]
+cli_args = ['--output', 'md', '```']
+output = '''
+```rust
+some_markdown("rust");
+```
+
+   -----
+
+```bash
+echo 'some bash'
+```
+'''
+
+
+[expect."md with no breaks"]
+cli_args = ['--no-br', '--output', 'md', '```']
+output = '''
+```rust
+some_markdown("rust");
+```
+
+```bash
+echo 'some bash'
+```
+'''
+
+
 [expect."markdown"]
 cli_args = ['--output', 'markdown']
 output = '''
@@ -48,10 +116,41 @@ Test _one_ [two][1] three.
 some_markdown("rust");
 ```
 
+```bash
+echo 'some bash'
+```
+
 [1]: https://example.com/1
 '''
 
 
+[expect."markdown codes"]
+cli_args = ['--output', 'markdown', '```']
+output = '''
+```rust
+some_markdown("rust");
+```
+
+   -----
+
+```bash
+echo 'some bash'
+```
+'''
+
+[expect."markdown with no breaks"]
+cli_args = ['--no-br', '--output', 'markdown', '```']
+output = '''
+```rust
+some_markdown("rust");
+```
+
+```bash
+echo 'some bash'
+```
+'''
+
+
 [expect."json"]
 cli_args = ['--output', 'json']
 output_json = true
@@ -69,6 +168,13 @@ output = '''
                     "language": "rust",
                     "type": "code"
                   }
+                },
+                {
+                  "code_block": {
+                    "code": "echo 'some bash'",
+                    "language": "bash",
+                    "type": "code"
+                  }
                 }
             ]
         }
@@ -85,6 +191,17 @@ output = '''
 cli_args = ['-o', 'plain']
 output = '''
 Test one two three.
+some_markdown("rust");
+echo 'some bash'
+'''
+
+[expect."plain with breaks"]
+cli_args = ['-o', 'plain', '--br']
+output = '''
+Test one two three.
 
 some_markdown("rust");
+
+echo 'some bash'
 '''
+