Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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.

Expand Down
33 changes: 28 additions & 5 deletions src/html.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,15 +212,38 @@ fn push_html_line(
///
/// # Examples
///
/// ```ignore
/// use mdtablefix::html::html_table_to_markdown;
/// ```no_run
/// use mdtablefix::html_table_to_markdown;
/// let html_lines = vec![
/// "<table><tr><th>Header</th></tr><tr><td>Cell</td></tr></table>".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<String> {
#[doc(hidden)]
/// Converts HTML tables in the provided lines to Markdown table syntax.
///
/// Scans the input lines for HTML `<table>` 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![
/// "<table>".to_string(),
/// " <tr><th>Header</th></tr>".to_string(),
/// " <tr><td>Cell</td></tr>".to_string(),
/// "</table>".to_string(),
/// ];
/// let result = html_table_to_markdown(&lines);
/// assert_eq!(result, vec![
/// "| Header |",
/// "| ------ |",
/// "| Cell |",
/// ]);
/// ```
pub fn html_table_to_markdown(lines: &[String]) -> Vec<String> {
let mut out = Vec::new();
let mut buf = Vec::new();
let mut depth = 0usize;
Expand Down Expand Up @@ -260,8 +283,8 @@ pub(crate) fn html_table_to_markdown(lines: &[String]) -> Vec<String> {
///
/// # Examples
///
/// ```ignore
/// use mdtablefix::html::convert_html_tables;
/// ```no_run
/// use mdtablefix::convert_html_tables;
/// let lines = vec![
/// "<table>".to_string(),
/// " <tr><th>Header</th></tr>".to_string(),
Expand Down
119 changes: 111 additions & 8 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@

mod html;

#[doc(hidden)]
pub use html::html_table_to_markdown;

pub use html::convert_html_tables;

use regex::Regex;
Expand Down Expand Up @@ -249,17 +252,28 @@ static FENCE_RE: std::sync::LazyLock<Regex> =
static BULLET_RE: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r"^(\s*(?:[-*+]|\d+[.)])\s+)(.*)").unwrap());


/// Returns `true` if the line is a fenced code block delimiter (e.g., three backticks or "~~~").
///
/// # 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)]
/// 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)
}

Expand Down Expand Up @@ -303,7 +317,7 @@ fn flush_paragraph(out: &mut Vec<String>, 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(),
Expand All @@ -322,7 +336,34 @@ fn flush_paragraph(out: &mut Vec<String>, buf: &[(String, bool)], indent: &str,
/// assert_eq!(wrapped[6], "let x = 42;");
/// assert_eq!(wrapped[7], "```");
/// ```
fn wrap_text(lines: &[String], width: usize) -> Vec<String> {
#[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 `<br>` 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<String> {
let mut out = Vec::new();
let mut buf: Vec<(String, bool)> = Vec::new();
let mut indent = String::new();
Expand Down Expand Up @@ -417,7 +458,7 @@ fn wrap_text(lines: &[String], width: usize) -> Vec<String> {
///
/// # Examples
///
/// ```
/// ```no_run
/// use mdtablefix::process_stream;
/// let input = vec![
/// "<table><tr><td>foo</td><td>bar</td></tr></table>".to_string(),
Expand All @@ -430,8 +471,10 @@ fn wrap_text(lines: &[String], width: usize) -> Vec<String> {
/// let output = process_stream(&input);
/// assert!(output.iter().any(|line| line.contains("| foo | bar |")));
/// assert!(output.iter().any(|line| line.len() <= 80));
/// ```
pub fn process_stream(lines: &[String]) -> Vec<String> {
/// 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<String> {
let pre = html::convert_html_tables(lines);

let mut out = Vec::new();
Expand Down Expand Up @@ -493,7 +536,45 @@ pub fn process_stream(lines: &[String]) -> Vec<String> {
}
}

wrap_text(&out, 80)
if wrap { wrap_text(&out, 80) } else { out }
}

#[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<String> {
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<String> {
process_stream_inner(lines, false)
}

/// Rewrite a file in place with fixed tables.
Expand All @@ -510,10 +591,32 @@ pub fn process_stream(lines: &[String]) -> Vec<String> {
/// 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)?;
let lines: Vec<String> = text.lines().map(str::to_string).collect();
let fixed = process_stream(&lines);
fs::write(path, fixed.join("\n") + "\n")
}

/// 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)?;
let lines: Vec<String> = text.lines().map(str::to_string).collect();
let fixed = process_stream_no_wrap(&lines);
fs::write(path, fixed.join("\n") + "\n")
}
35 changes: 31 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<PathBuf>,
}
Expand All @@ -33,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();
Expand All @@ -41,18 +56,30 @@ fn main() -> anyhow::Result<()> {
let mut input = String::new();
io::stdin().read_to_string(&mut input)?;
let lines: Vec<String> = 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<String> = 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"));
}
}
Expand Down
41 changes: 41 additions & 0 deletions tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) {
let dir = tempdir().unwrap();
Expand All @@ -298,6 +311,34 @@ fn test_cli_process_file(broken_table: Vec<String>) {
}

#[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")
.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]
/// 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(),
Expand Down