From 84568aad7e75e25104c9287f9bd0907b45e97379 Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 15 Jun 2025 05:47:22 +0100 Subject: [PATCH 1/3] Add optional text wrapping --- README.md | 6 ++++-- src/html.rs | 4 ++-- src/lib.rs | 33 +++++++++++++++++++++++++++------ src/main.rs | 23 +++++++++++++++++++---- tests/integration.rs | 15 +++++++++++++++ 5 files changed, 67 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index cd6f3190..a92086b4 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # mdtablefix `mdtablefix` reflows Markdown tables so that each column has a uniform width. -It also wraps paragraphs and list items to 80 columns. +It can wrap paragraphs and list items to 80 columns when the `--wrap` option is +used. The tool ignores fenced code blocks and respects escaped pipes (`\|`), making it safe for mixed content. @@ -20,10 +21,11 @@ cargo install --path . ## Command-line usage ```bash -mdtablefix [--in-place] [FILE...] +mdtablefix [--wrap] [--in-place] [FILE...] ``` - With file paths provided, the corrected tables are printed to stdout. +- Use `--wrap` to also reflow paragraphs and list items to 80 columns. - Use `--in-place` to overwrite files. - If no files are supplied, input is read from stdin and results are written to stdout. diff --git a/src/html.rs b/src/html.rs index 647b8b01..e929cf4c 100644 --- a/src/html.rs +++ b/src/html.rs @@ -212,7 +212,7 @@ fn push_html_line( /// /// # Examples /// -/// ``` +/// ```ignore /// let html_lines = vec![ /// "
Header
Cell
".to_string() /// ]; @@ -259,7 +259,7 @@ pub(crate) fn html_table_to_markdown(lines: &[String]) -> Vec { /// /// # Examples /// -/// ``` +/// ```ignore /// let lines = vec![ /// "".to_string(), /// " ".to_string(), diff --git a/src/lib.rs b/src/lib.rs index 5cd9be4d..3259eb64 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -249,11 +249,11 @@ static FENCE_RE: std::sync::LazyLock = static BULLET_RE: std::sync::LazyLock = std::sync::LazyLock::new(|| Regex::new(r"^(\s*(?:[-*+]|\d+[.)])\s+)(.*)").unwrap()); -/// Returns `true` if the line is a fenced code block delimiter (e.g., "```" or "~~~"). +/// Returns `true` if the line is a fenced code block delimiter (e.g., "\`\`\`" or "~~~"). /// /// # Examples /// -/// ``` +/// ```ignore /// assert!(is_fence("```")); /// assert!(is_fence("~~~")); /// assert!(!is_fence("| foo | bar |")); @@ -302,7 +302,7 @@ fn flush_paragraph(out: &mut Vec, buf: &[(String, bool)], indent: &str, /// /// # Examples /// -/// ``` +/// ```ignore /// let input = vec![ /// "This is a long paragraph that should be wrapped to a shorter width.".to_string(), /// "".to_string(), @@ -415,7 +415,7 @@ fn wrap_text(lines: &[String], width: usize) -> Vec { /// /// # Examples /// -/// ``` +/// ```ignore /// let input = vec![ /// "
Header
foobar
".to_string(), /// "| a | b |".to_string(), @@ -428,7 +428,7 @@ fn wrap_text(lines: &[String], width: usize) -> Vec { /// assert!(output.iter().any(|line| line.contains("| foo | bar |"))); /// assert!(output.iter().any(|line| line.len() <= 80)); /// ``` -pub fn process_stream(lines: &[String]) -> Vec { +fn process_stream_inner(lines: &[String], wrap: bool) -> Vec { let pre = html::convert_html_tables(lines); let mut out = Vec::new(); @@ -490,7 +490,17 @@ pub fn process_stream(lines: &[String]) -> Vec { } } - wrap_text(&out, 80) + if wrap { wrap_text(&out, 80) } else { out } +} + +#[must_use] +pub fn process_stream(lines: &[String]) -> Vec { + process_stream_inner(lines, true) +} + +#[must_use] +pub fn process_stream_no_wrap(lines: &[String]) -> Vec { + process_stream_inner(lines, false) } /// Rewrite a file in place with fixed tables. @@ -514,3 +524,14 @@ pub fn rewrite(path: &Path) -> std::io::Result<()> { let fixed = process_stream(&lines); fs::write(path, fixed.join("\n") + "\n") } + +/// Rewrite a file in place with fixed tables without wrapping text. +/// +/// # Errors +/// Returns an error if the file cannot be read or written. +pub fn rewrite_no_wrap(path: &Path) -> std::io::Result<()> { + let text = fs::read_to_string(path)?; + let lines: Vec = text.lines().map(str::to_string).collect(); + let fixed = process_stream_no_wrap(&lines); + fs::write(path, fixed.join("\n") + "\n") +} diff --git a/src/main.rs b/src/main.rs index b9aaf7d1..f3847059 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,5 @@ use clap::Parser; -use mdtablefix::{process_stream, rewrite}; +use mdtablefix::{process_stream, process_stream_no_wrap, rewrite, rewrite_no_wrap}; use std::fs; use std::io::{self, Read}; use std::path::PathBuf; @@ -10,6 +10,9 @@ struct Cli { /// Rewrite files in place #[arg(long = "in-place", requires = "files")] in_place: bool, + /// Wrap paragraphs and list items to 80 columns + #[arg(long = "wrap")] + wrap: bool, /// Markdown files to fix files: Vec, } @@ -41,18 +44,30 @@ fn main() -> anyhow::Result<()> { let mut input = String::new(); io::stdin().read_to_string(&mut input)?; let lines: Vec = input.lines().map(str::to_string).collect(); - let fixed = process_stream(&lines); + let fixed = if cli.wrap { + process_stream(&lines) + } else { + process_stream_no_wrap(&lines) + }; println!("{}", fixed.join("\n")); return Ok(()); } for path in cli.files { if cli.in_place { - rewrite(&path)?; + if cli.wrap { + rewrite(&path)?; + } else { + rewrite_no_wrap(&path)?; + } } else { let content = fs::read_to_string(&path)?; let lines: Vec = content.lines().map(str::to_string).collect(); - let fixed = process_stream(&lines); + let fixed = if cli.wrap { + process_stream(&lines) + } else { + process_stream_no_wrap(&lines) + }; println!("{}", fixed.join("\n")); } } diff --git a/tests/integration.rs b/tests/integration.rs index b54b8edb..e367f6ac 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -297,6 +297,21 @@ fn test_cli_process_file(broken_table: Vec) { .stdout("| A | B |\n| 1 | 2 |\n| 3 | 4 |\n"); } +#[test] +fn test_cli_wrap_option() { + let input = "This line is long enough to require wrapping when the option is enabled."; + let output = Command::cargo_bin("mdtablefix") + .unwrap() + .arg("--wrap") + .write_stdin(format!("{input}\n")) + .output() + .unwrap(); + assert!(output.status.success()); + let text = String::from_utf8_lossy(&output.stdout); + assert!(text.contains('\n')); + assert!(text.contains("This line")); +} + #[test] fn test_uniform_example_one() { let input = vec![ From 12b85bd20e27aa3237028aa244c80d54a359463f Mon Sep 17 00:00:00 2001 From: Leynos Date: Sun, 15 Jun 2025 05:58:37 +0100 Subject: [PATCH 2/3] Update doctests to use no_run --- src/html.rs | 9 ++++++--- src/lib.rs | 26 +++++++++++++++++--------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/html.rs b/src/html.rs index e929cf4c..9b498c0c 100644 --- a/src/html.rs +++ b/src/html.rs @@ -212,14 +212,16 @@ fn push_html_line( /// /// # Examples /// -/// ```ignore +/// ```no_run +/// use mdtablefix::html_table_to_markdown; /// let html_lines = vec![ /// "
Header
Cell
".to_string() /// ]; /// let md_lines = html_table_to_markdown(&html_lines); /// assert!(md_lines[0].starts_with("| Header |")); /// ``` -pub(crate) fn html_table_to_markdown(lines: &[String]) -> Vec { +#[doc(hidden)] +pub fn html_table_to_markdown(lines: &[String]) -> Vec { let mut out = Vec::new(); let mut buf = Vec::new(); let mut depth = 0usize; @@ -259,7 +261,8 @@ pub(crate) fn html_table_to_markdown(lines: &[String]) -> Vec { /// /// # Examples /// -/// ```ignore +/// ```no_run +/// use mdtablefix::convert_html_tables; /// let lines = vec![ /// "".to_string(), /// " ".to_string(), diff --git a/src/lib.rs b/src/lib.rs index 3259eb64..5287aace 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,6 +6,9 @@ mod html; +#[doc(hidden)] +pub use html::html_table_to_markdown; + pub use html::convert_html_tables; use regex::Regex; @@ -19,7 +22,7 @@ use textwrap::fill; /// /// # Examples /// -/// ```ignore +/// ```no_run /// use mdtablefix::split_cells; /// let line = "| cell1 | cell2 | cell3 |"; /// let cells = split_cells(line); @@ -102,7 +105,7 @@ fn format_separator_cells(widths: &[usize], sep_cells: &[String]) -> Vec /// /// # Examples /// -/// ```ignore +/// ```no_run /// use mdtablefix::reflow_table; /// let lines = vec![ /// "| a | b |".to_string(), @@ -223,7 +226,7 @@ pub fn reflow_table(lines: &[String]) -> Vec { /// /// # Examples /// -/// ```ignore +/// ```no_run /// use mdtablefix::process_stream; /// let input = vec![ /// "| a | b |".to_string(), @@ -253,12 +256,14 @@ static BULLET_RE: std::sync::LazyLock = /// /// # Examples /// -/// ```ignore +/// ```no_run +/// use mdtablefix::is_fence; /// assert!(is_fence("```")); /// assert!(is_fence("~~~")); /// assert!(!is_fence("| foo | bar |")); /// ``` -pub(crate) fn is_fence(line: &str) -> bool { +#[doc(hidden)] +pub fn is_fence(line: &str) -> bool { FENCE_RE.is_match(line) } @@ -302,7 +307,8 @@ fn flush_paragraph(out: &mut Vec, buf: &[(String, bool)], indent: &str, /// /// # Examples /// -/// ```ignore +/// ```no_run +/// use mdtablefix::wrap_text; /// let input = vec![ /// "This is a long paragraph that should be wrapped to a shorter width.".to_string(), /// "".to_string(), @@ -320,7 +326,8 @@ fn flush_paragraph(out: &mut Vec, buf: &[(String, bool)], indent: &str, /// assert_eq!(wrapped[6], "let x = 42;"); /// assert_eq!(wrapped[7], "```"); /// ``` -fn wrap_text(lines: &[String], width: usize) -> Vec { +#[doc(hidden)] +pub fn wrap_text(lines: &[String], width: usize) -> Vec { let mut out = Vec::new(); let mut buf: Vec<(String, bool)> = Vec::new(); let mut indent = String::new(); @@ -415,7 +422,8 @@ fn wrap_text(lines: &[String], width: usize) -> Vec { /// /// # Examples /// -/// ```ignore +/// ```no_run +/// use mdtablefix::process_stream; /// let input = vec![ /// "
Header
foobar
".to_string(), /// "| a | b |".to_string(), @@ -512,7 +520,7 @@ pub fn process_stream_no_wrap(lines: &[String]) -> Vec { /// /// # Examples /// -/// ```ignore +/// ```no_run /// use std::path::Path; /// use mdtablefix::rewrite; /// let path = Path::new("example.md"); From 0be652b7dd38b8fcd4d1b7d04b077b42792bfd51 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 15 Jun 2025 08:43:50 +0000 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`cod?= =?UTF-8?q?ex/make-text-wrapping-optional`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @leynos. * https://github.com/leynos/mdtablefix/pull/38#issuecomment-2973493021 The following files were modified: * `src/html.rs` * `src/lib.rs` * `src/main.rs` * `tests/integration.rs` --- src/html.rs | 22 +++++++++++++ src/lib.rs | 78 +++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 12 +++++++ tests/integration.rs | 26 +++++++++++++++ 4 files changed, 137 insertions(+), 1 deletion(-) diff --git a/src/html.rs b/src/html.rs index 9b498c0c..d00064cd 100644 --- a/src/html.rs +++ b/src/html.rs @@ -221,6 +221,28 @@ fn push_html_line( /// assert!(md_lines[0].starts_with("| Header |")); /// ``` #[doc(hidden)] +/// Converts HTML tables in the provided lines to Markdown table syntax. +/// +/// Scans the input lines for HTML `` blocks, including nested tables, and replaces each with an equivalent Markdown table. Lines outside of table blocks are preserved unchanged. If a table block is incomplete at the end of input, its lines are appended as-is. +/// +/// # Examples +/// +/// ```no_run +/// use mdtablefix::html_table_to_markdown; +/// +/// let lines = vec![ +/// "
".to_string(), +/// " ".to_string(), +/// " ".to_string(), +/// "
Header
Cell
".to_string(), +/// ]; +/// let result = html_table_to_markdown(&lines); +/// assert_eq!(result, vec![ +/// "| Header |", +/// "| ------ |", +/// "| Cell |", +/// ]); +/// ``` pub fn html_table_to_markdown(lines: &[String]) -> Vec { let mut out = Vec::new(); let mut buf = Vec::new(); diff --git a/src/lib.rs b/src/lib.rs index ba5409de..b534429e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -264,6 +264,15 @@ static BULLET_RE: std::sync::LazyLock = /// assert!(!is_fence("| foo | bar |")); /// ``` #[doc(hidden)] +/// Returns `true` if the line is a fenced code block delimiter (e.g., triple backticks or tildes). +/// +/// # Examples +/// +/// ``` +/// assert!(is_fence("```")); +/// assert!(is_fence("~~~")); +/// assert!(!is_fence(" code block")); +/// ``` pub fn is_fence(line: &str) -> bool { FENCE_RE.is_match(line) } @@ -328,6 +337,32 @@ fn flush_paragraph(out: &mut Vec, buf: &[(String, bool)], indent: &str, /// assert_eq!(wrapped[7], "```"); /// ``` #[doc(hidden)] +/// Wraps markdown text lines to a specified width, preserving markdown structure. +/// +/// This function wraps paragraphs and list items to the given width, while leaving code blocks, tables, headers, and blank lines unchanged. It preserves indentation, bullet or number prefixes for lists, and respects hard line breaks (two spaces or `
` tags). +/// +/// # Parameters +/// - `lines`: The input markdown lines to wrap. +/// - `width`: The maximum line width for wrapping. +/// +/// # Returns +/// A vector of strings containing the wrapped markdown lines, with structure and formatting preserved. +/// +/// # Examples +/// +/// ``` +/// let input = vec![ +/// "This is a long paragraph that should be wrapped to a shorter width.".to_string(), +/// "".to_string(), +/// "```".to_string(), +/// "let x = 42;".to_string(), +/// "```".to_string(), +/// ]; +/// let wrapped = wrap_text(&input, 20); +/// assert!(wrapped[0].len() <= 20); +/// assert_eq!(wrapped[2], "```"); +/// assert_eq!(wrapped[3], "let x = 42;"); +/// ``` pub fn wrap_text(lines: &[String], width: usize) -> Vec { let mut out = Vec::new(); let mut buf: Vec<(String, bool)> = Vec::new(); @@ -436,7 +471,9 @@ pub fn wrap_text(lines: &[String], width: usize) -> Vec { /// let output = process_stream(&input); /// assert!(output.iter().any(|line| line.contains("| foo | bar |"))); /// assert!(output.iter().any(|line| line.len() <= 80)); -/// ``` +/// Processes a stream of markdown lines, converting HTML tables to markdown, reflowing markdown tables, and optionally wrapping text. +/// +/// Converts simple HTML tables to markdown, detects and reflows markdown tables for consistent formatting, and preserves code blocks. If `wrap` is true, wraps the output text to 80 columns while maintaining markdown structure. Returns the processed lines as a vector of strings. fn process_stream_inner(lines: &[String], wrap: bool) -> Vec { let pre = html::convert_html_tables(lines); @@ -503,11 +540,39 @@ fn process_stream_inner(lines: &[String], wrap: bool) -> Vec { } #[must_use] +/// Processes markdown lines, fixing tables and wrapping text to 80 columns. +/// +/// This function reflows markdown tables, converts HTML tables to markdown, and wraps text while preserving markdown structure such as code blocks and lists. +/// +/// # Examples +/// +/// ``` +/// let input = vec![ +/// "| a | b |".to_string(), +/// "|---|---|".to_string(), +/// "| 1 | 2 |".to_string(), +/// "", +/// "A paragraph that will be wrapped to fit within 80 columns.".to_string(), +/// ]; +/// let output = process_stream(&input); +/// assert!(output.iter().any(|line| line.contains("| a | b |"))); +/// ``` pub fn process_stream(lines: &[String]) -> Vec { process_stream_inner(lines, true) } #[must_use] +/// Processes a stream of markdown lines, fixing tables and converting HTML tables, without wrapping text. +/// +/// Returns the processed lines as a vector of strings. +/// +/// # Examples +/// +/// ``` +/// let input = vec![String::from("| a | b |"), String::from("|-|-|"), String::from("| 1 | 2 |")]; +/// let output = process_stream_no_wrap(&input); +/// assert_eq!(output, vec![String::from("| a | b |"), String::from("| - | - |"), String::from("| 1 | 2 |")]); +/// ``` pub fn process_stream_no_wrap(lines: &[String]) -> Vec { process_stream_inner(lines, false) } @@ -526,6 +591,15 @@ pub fn process_stream_no_wrap(lines: &[String]) -> Vec { /// use mdtablefix::rewrite; /// let path = Path::new("example.md"); /// rewrite(path).unwrap(); +/// Reads a markdown file, fixes tables and wraps text, then writes the updated content back to the same file. +/// +/// Returns an I/O error if reading or writing the file fails. +/// +/// # Examples +/// +/// ```no_run +/// use std::path::Path; +/// rewrite(Path::new("README.md")).unwrap(); /// ``` pub fn rewrite(path: &Path) -> std::io::Result<()> { let text = fs::read_to_string(path)?; @@ -537,6 +611,8 @@ pub fn rewrite(path: &Path) -> std::io::Result<()> { /// Rewrite a file in place with fixed tables without wrapping text. /// /// # Errors +/// Processes a markdown file at the given path, fixing tables without wrapping text, and overwrites the file with the updated content. +/// /// Returns an error if the file cannot be read or written. pub fn rewrite_no_wrap(path: &Path) -> std::io::Result<()> { let text = fs::read_to_string(path)?; diff --git a/src/main.rs b/src/main.rs index f3847059..9b2f6756 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,18 @@ struct Cli { /// /// # Fix tables from standard input /// cat myfile.md | mdtablefix +/// Entry point for the command-line tool that reflows markdown tables and optionally wraps text. +/// +/// Parses command-line arguments to determine input sources, output behaviour, and whether to wrap text. Processes either standard input or specified files, printing results to standard output or rewriting files in place as requested. Propagates errors from argument parsing, file I/O, and markdown processing. +/// +/// # Examples +/// +/// ```no_run +/// // Run with standard input, wrapping enabled +/// // $ echo "|a|b|\n|---|---|\n|1|2|" | mdtablefix --wrap +/// +/// // Run on files, in-place modification +/// // $ mdtablefix --in-place --wrap file.md /// ``` fn main() -> anyhow::Result<()> { let cli = Cli::parse(); diff --git a/tests/integration.rs b/tests/integration.rs index e367f6ac..9d9c08fe 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -279,6 +279,19 @@ fn test_cli_in_place_requires_file() { /// "| 3 | 4 |".to_string(), /// ]; /// test_cli_process_file(broken_table); +/// Runs the CLI on a file containing a broken Markdown table and asserts the output is the corrected table. +/// +/// This test creates a temporary file with a malformed Markdown table, invokes the `mdtablefix` CLI on it, +/// and verifies that the output matches the expected well-formed table. +/// +/// # Examples +/// +/// ``` +/// test_cli_process_file(vec![ +/// "| A | B |".to_string(), +/// "| 1 | 2 |".to_string(), +/// "| 3 | 4 |".to_string(), +/// ]); /// ``` fn test_cli_process_file(broken_table: Vec) { let dir = tempdir().unwrap(); @@ -298,6 +311,16 @@ fn test_cli_process_file(broken_table: Vec) { } #[test] +/// Tests that the CLI's `--wrap` option wraps long input lines as expected. +/// +/// Runs the `mdtablefix` binary with the `--wrap` flag, provides a long line via stdin, +/// and asserts that the output contains line breaks and the expected text. +/// +/// # Examples +/// +/// ``` +/// test_cli_wrap_option(); +/// ``` fn test_cli_wrap_option() { let input = "This line is long enough to require wrapping when the option is enabled."; let output = Command::cargo_bin("mdtablefix") @@ -313,6 +336,9 @@ fn test_cli_wrap_option() { } #[test] +/// Verifies that `reflow_table` produces a Markdown table with uniform column widths across all rows. +/// +/// This test checks that each column in the output table has consistent width, ensuring proper alignment after reflow. fn test_uniform_example_one() { let input = vec![ "| Logical type | PostgreSQL | SQLite notes |".to_string(),