use std::{
borrow::Cow,
io::{self, Read},
path::{Path, PathBuf},
};
use anyhow::Context;
use camino::{Utf8Path, Utf8PathBuf};
use cap_std::{ambient_authority, fs_utf8::Dir};
use clap::Parser;
use mdtablefix::{
Options,
format_breaks,
process::{process_stream_inner, process_with_frontmatter},
renumber_lists,
};
use rayon::prelude::*;
#[derive(Parser)]
#[command(version, about = "Reflow broken markdown tables")]
struct Cli {
#[arg(long = "in-place", requires = "files")]
in_place: bool,
#[command(flatten)]
opts: FormatOpts,
files: Vec<PathBuf>,
}
#[derive(clap::Args, Clone, Copy)]
#[expect(
clippy::struct_excessive_bools,
reason = "CLI exposes independent flags via separate switches"
)]
struct FormatOpts {
#[arg(long = "wrap")]
wrap: bool,
#[arg(long = "renumber")]
renumber: bool,
#[arg(long = "breaks")]
breaks: bool,
#[arg(long = "ellipsis")]
ellipsis: bool,
#[arg(long = "fences")]
fences: bool,
#[arg(long = "footnotes")]
footnotes: bool,
#[arg(long = "code-emphasis")]
code_emphasis: bool,
#[arg(long = "headings")]
headings: bool,
}
impl From<FormatOpts> for Options {
fn from(opts: FormatOpts) -> Self {
Self {
wrap: opts.wrap,
ellipsis: opts.ellipsis,
fences: opts.fences,
footnotes: opts.footnotes,
code_emphasis: opts.code_emphasis,
headings: opts.headings,
}
}
}
fn process_lines(lines: &[String], opts: FormatOpts) -> Vec<String> {
process_with_frontmatter(lines, |body| {
let mut out = process_stream_inner(body, opts.into());
if opts.renumber {
out = renumber_lists(&out);
}
if opts.breaks {
out = format_breaks(&out)
.into_iter()
.map(Cow::into_owned)
.collect();
}
out
})
}
fn open_file_parent(path: &Path) -> anyhow::Result<(Dir, Utf8PathBuf)> {
let path = Utf8Path::from_path(path)
.with_context(|| format!("converting {} to a UTF-8 path", path.display()))?;
let parent = path
.parent()
.filter(|parent| !parent.as_str().is_empty())
.unwrap_or(Utf8Path::new("."));
let file_name = path
.file_name()
.with_context(|| format!("selecting file name from {path}"))?;
let directory = Dir::open_ambient_dir(parent, ambient_authority())
.with_context(|| format!("opening {parent}"))?;
Ok((directory, Utf8PathBuf::from(file_name)))
}
fn format_to_string(directory: &Dir, path: &Utf8Path, opts: FormatOpts) -> anyhow::Result<String> {
let content = directory
.read_to_string(path)
.with_context(|| format!("reading {path}"))?;
let lines: Vec<String> = content.lines().map(str::to_string).collect();
let fixed = process_lines(&lines, opts);
Ok(if fixed.is_empty() {
String::new()
} else {
fixed.join("\n") + "\n"
})
}
fn rewrite_in_place(directory: &Dir, path: &Utf8Path, opts: FormatOpts) -> anyhow::Result<()> {
let output = format_to_string(directory, path, opts)?;
directory
.write(path, output)
.with_context(|| format!("writing {path}"))
}
fn report_results<T, F>(results: Vec<anyhow::Result<T>>, mut on_ok: F) -> anyhow::Result<()>
where
F: FnMut(T),
{
let mut first_err: Option<anyhow::Error> = None;
for res in results {
match res {
Ok(val) => on_ok(val),
Err(e) => {
eprintln!("{e}");
if first_err.is_none() {
first_err = Some(e);
}
}
}
}
if let Some(err) = first_err {
Err(err)
} else {
Ok(())
}
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
if cli.files.is_empty() {
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_lines(&lines, cli.opts);
println!("{}", fixed.join("\n"));
return Ok(());
}
if cli.in_place {
let results: Vec<anyhow::Result<()>> = cli
.files
.par_iter()
.map(|path| {
let (directory, file_name) = open_file_parent(path)?;
rewrite_in_place(&directory, &file_name, cli.opts)
})
.collect();
report_results(results, |()| {})?;
} else {
let results: Vec<anyhow::Result<String>> = cli
.files
.par_iter()
.map(|path| {
let (directory, file_name) = open_file_parent(path)?;
format_to_string(&directory, &file_name, cli.opts)
})
.collect();
report_results(results, |out| print!("{out}"))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use camino::{Utf8Path, Utf8PathBuf};
use cap_std::{ambient_authority, fs_utf8::Dir};
use proptest::prelude::*;
use super::{FormatOpts, format_to_string, rewrite_in_place};
fn prose_word_strategy() -> impl Strategy<Value = String> {
prop::collection::vec(
prop_oneof![
Just("alpha".to_string()),
Just("beta".to_string()),
Just("gamma".to_string()),
Just("delta".to_string()),
Just("evidence".to_string()),
Just("formatting".to_string()),
],
1..20,
)
.prop_map(|words| words.join(" "))
}
proptest! {
#[test]
fn formatting_matches_in_place_output(
prose in prose_word_strategy(),
table_cell in prose_word_strategy(),
) {
let input = format!(
"{prose}\n\n| Name | Notes |\n|---|---|\n| {table_cell} | value |\n"
);
let directory = tempfile::tempdir()
.map_err(|error| TestCaseError::fail(error.to_string()))?;
let directory_path = Utf8PathBuf::from_path_buf(directory.path().to_path_buf())
.map_err(|path| TestCaseError::fail(path.display().to_string()))?;
let directory = Dir::open_ambient_dir(&directory_path, ambient_authority())
.map_err(|error| TestCaseError::fail(error.to_string()))?;
let formatted_path = Utf8Path::new("formatted.md");
let rewritten_path = Utf8Path::new("rewritten.md");
directory.write(formatted_path, &input)
.map_err(|error| TestCaseError::fail(error.to_string()))?;
directory.write(rewritten_path, input)
.map_err(|error| TestCaseError::fail(error.to_string()))?;
let formatted = format_to_string(&directory, formatted_path, FormatOpts {
wrap: false,
renumber: false,
breaks: false,
ellipsis: false,
fences: false,
footnotes: false,
code_emphasis: false,
headings: false,
})
.map_err(|error| TestCaseError::fail(error.to_string()))?;
rewrite_in_place(&directory, rewritten_path, FormatOpts {
wrap: false,
renumber: false,
breaks: false,
ellipsis: false,
fences: false,
footnotes: false,
code_emphasis: false,
headings: false,
})
.map_err(|error| TestCaseError::fail(error.to_string()))?;
let rewritten = directory.read_to_string(rewritten_path)
.map_err(|error| TestCaseError::fail(error.to_string()))?;
prop_assert_eq!(formatted, rewritten);
}
}
}