use std::fmt::Write as _;
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
use crate::ignore::{IGNORE_FILE_NAME, IgnoreRules};
use crate::scan::{py_splitlines_keepends, py_trim, split_eol};
use crate::transcript::is_transcript_like_markdown;
use crate::unwrap_markdown_prose;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Outcome {
pub stdout: String,
pub stderr: String,
pub code: u8,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Args {
pub paths: Vec<String>,
pub files_from: Option<String>,
pub write: bool,
pub json: bool,
pub fail_on_change: bool,
pub ignore_file: Option<String>,
pub exclude: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Takes {
Nothing,
OneValue,
}
const OPTIONS: [(&str, Takes); 7] = [
("--help", Takes::Nothing),
("--files-from", Takes::OneValue),
("--write", Takes::Nothing),
("--json", Takes::Nothing),
("--fail-on-change", Takes::Nothing),
("--ignore-file", Takes::OneValue),
("--exclude", Takes::OneValue),
];
#[derive(Debug, Clone, PartialEq, Eq)]
struct FileReport {
path: String,
changed: bool,
paragraphs_unwrapped: usize,
line_breaks_removed: usize,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ReadError {
Io(ErrorKind),
NotUtf8,
}
#[must_use]
pub fn run(argv: &[String], root: &Path) -> Outcome {
let args = match parse_args(argv) {
Ok(args) => args,
Err(message) => {
return Outcome {
stdout: String::new(),
stderr: format!("error: {message}\n"),
code: 2,
};
}
};
if args.paths.is_empty() && args.files_from.is_none() && wants_help(argv) {
return Outcome {
stdout: usage(),
stderr: String::new(),
code: 0,
};
}
let mut errors: Vec<String> = Vec::new();
let raw_paths = collect_input_paths(&args, root, &mut errors);
let rules = build_ignore_rules(&args, root, &mut errors);
let mut reports: Vec<FileReport> = Vec::new();
for raw in &raw_paths {
if rules.excludes(raw) {
continue;
}
let full = root.join(raw);
let reported = posix_display(raw);
let Ok(metadata) = fs::symlink_metadata(&full) else {
continue;
};
if metadata.file_type().is_symlink() || !full.is_file() {
continue;
}
match process_file(&full, &reported, args.write) {
Ok(report) => reports.push(report),
Err(error) => errors.push(format!(
"{reported}: cannot read ({})",
describe(&full, error)
)),
}
}
let changed = reports.iter().any(|report| report.changed);
let mut stdout = String::new();
let mut stderr = String::new();
if args.json {
stdout.push_str(&json_payload(changed, &reports, &errors));
stdout.push('\n');
} else {
for report in &reports {
if report.changed {
let _ = writeln!(
stdout,
"{}: removed {} manual line break(s)",
report.path, report.line_breaks_removed
);
}
}
for error in &errors {
let _ = writeln!(stderr, "{error}");
}
}
let code = u8::from(args.fail_on_change && changed || !errors.is_empty());
Outcome {
stdout,
stderr,
code,
}
}
pub fn parse_args(argv: &[String]) -> Result<Args, String> {
let tokens = classify(argv);
let mut args = Args::default();
let mut paths_taken = false;
let mut extras: Vec<String> = Vec::new();
let mut index = 0;
while index < tokens.len() {
let Token::Option { name, inline } = &tokens[index] else {
let start = index;
while matches!(tokens.get(index), Some(Token::Positional(_))) {
index += 1;
}
let run = tokens[start..index]
.iter()
.map(Token::value)
.collect::<Vec<String>>();
if paths_taken {
extras.extend(run);
} else {
args.paths = run;
paths_taken = true;
}
continue;
};
let (option, takes) = resolve(name)?;
index += 1;
if takes == Takes::Nothing {
if inline.is_some() {
return Err(format!("argument {option}: ignored explicit argument"));
}
match option {
"--write" => args.write = true,
"--json" => args.json = true,
"--fail-on-change" => args.fail_on_change = true,
_ => {}
}
continue;
}
let value = match inline {
Some(value) => value.clone(),
None => match tokens.get(index) {
Some(Token::Positional(value)) => {
index += 1;
value.clone()
}
_ => return Err(format!("argument {option}: expected one argument")),
},
};
match option {
"--files-from" => args.files_from = Some(value),
"--ignore-file" => args.ignore_file = Some(value),
"--exclude" => args.exclude.push(value),
_ => {}
}
}
if extras.is_empty() {
Ok(args)
} else {
Err(format!("unrecognized arguments: {}", extras.join(" ")))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Token {
Option {
name: String,
inline: Option<String>,
},
Positional(String),
}
impl Token {
fn value(&self) -> String {
match self {
Token::Positional(value) => value.clone(),
Token::Option { name, .. } => name.clone(),
}
}
}
fn classify(argv: &[String]) -> Vec<Token> {
let mut tokens = Vec::with_capacity(argv.len());
let mut rest_are_positional = false;
for arg in argv {
if rest_are_positional {
tokens.push(Token::Positional(arg.clone()));
continue;
}
if arg == "--" {
rest_are_positional = true;
continue;
}
if !is_option_like(arg) {
tokens.push(Token::Positional(arg.clone()));
continue;
}
match arg.split_once('=') {
Some((name, value)) => tokens.push(Token::Option {
name: name.to_owned(),
inline: Some(value.to_owned()),
}),
None => tokens.push(Token::Option {
name: arg.clone(),
inline: None,
}),
}
}
tokens
}
fn is_option_like(arg: &str) -> bool {
if !arg.starts_with('-') || arg.chars().count() == 1 {
return false;
}
!is_negative_number(arg) && !arg.contains(' ')
}
fn is_negative_number(arg: &str) -> bool {
let Some(rest) = arg.strip_prefix('-') else {
return false;
};
if !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()) {
return true;
}
match rest.split_once('.') {
Some((whole, fraction)) => {
whole.bytes().all(|b| b.is_ascii_digit())
&& !fraction.is_empty()
&& fraction.bytes().all(|b| b.is_ascii_digit())
}
None => false,
}
}
fn resolve(name: &str) -> Result<(&'static str, Takes), String> {
if let Some((option, takes)) = OPTIONS.iter().find(|(option, _)| *option == name) {
return Ok((option, *takes));
}
if name == "-h" {
return Ok(("--help", Takes::Nothing));
}
let candidates: Vec<&(&'static str, Takes)> = OPTIONS
.iter()
.filter(|(option, _)| option.starts_with(name))
.collect();
match candidates.as_slice() {
[(option, takes)] => Ok((option, *takes)),
[] => Err(format!("unrecognized arguments: {name}")),
many => Err(format!(
"ambiguous option: {name} could match {}",
many.iter()
.map(|(option, _)| *option)
.collect::<Vec<&str>>()
.join(", ")
)),
}
}
fn wants_help(argv: &[String]) -> bool {
argv.iter().any(|arg| {
arg == "-h" || (is_option_like(arg) && matches!(resolve(arg), Ok(("--help", _))))
})
}
fn usage() -> String {
let mut text = String::from("Detect or remove manual line breaks in Markdown prose.\n\n");
text.push_str("usage: unwrap-markdown-prose-rs [options] [paths ...]\n\n");
for (option, takes) in OPTIONS {
let value = if takes == Takes::OneValue {
" VALUE"
} else {
""
};
let _ = writeln!(text, " {option}{value}");
}
text
}
fn collect_input_paths(args: &Args, root: &Path, errors: &mut Vec<String>) -> Vec<String> {
let mut paths = args.paths.clone();
let Some(files_from) = &args.files_from else {
return paths;
};
let full = root.join(files_from);
match read_text(&full) {
Ok(contents) => {
let translated = contents.replace("\r\n", "\n").replace('\r', "\n");
paths.extend(
py_splitlines_keepends(&translated)
.into_iter()
.map(|line| split_eol(line).0)
.filter(|line| !py_trim(line).is_empty())
.map(str::to_owned),
);
}
Err(error) => errors.push(format!(
"{}: cannot read --files-from ({})",
posix_display(files_from),
describe(&full, error)
)),
}
paths
}
fn build_ignore_rules(args: &Args, root: &Path, errors: &mut Vec<String>) -> IgnoreRules {
let explicit = args.ignore_file.as_deref();
let name = explicit.unwrap_or(IGNORE_FILE_NAME);
let full = root.join(name);
let text = if explicit.is_some() || full.is_file() {
match read_text(&full) {
Ok(text) => Some(text),
Err(error) => {
errors.push(format!(
"{}: cannot read --ignore-file ({})",
posix_display(name),
describe(&full, error)
));
None
}
}
} else {
None
};
IgnoreRules::new(text.as_deref(), args.exclude.iter().map(String::as_str))
}
fn process_file(full: &Path, reported: &str, write: bool) -> Result<FileReport, ReadError> {
let original = read_text(full)?;
if is_transcript_like_markdown(&original) {
return Ok(FileReport {
path: reported.to_owned(),
changed: false,
paragraphs_unwrapped: 0,
line_breaks_removed: 0,
});
}
let result = unwrap_markdown_prose(&original);
let changed = result.content != original;
if write && changed {
fs::write(full, result.content.as_bytes()).map_err(|e| ReadError::Io(e.kind()))?;
}
Ok(FileReport {
path: reported.to_owned(),
changed,
paragraphs_unwrapped: result.paragraphs_unwrapped,
line_breaks_removed: result.line_breaks_removed,
})
}
fn read_text(path: &Path) -> Result<String, ReadError> {
let bytes = fs::read(path).map_err(|error| ReadError::Io(error.kind()))?;
String::from_utf8(bytes).map_err(|_| ReadError::NotUtf8)
}
fn describe(path: &Path, error: ReadError) -> &'static str {
let kind = match error {
ReadError::NotUtf8 => return "not valid UTF-8",
ReadError::Io(kind) => kind,
};
if path.is_dir() {
return "is a directory";
}
match kind {
ErrorKind::NotFound => "not found",
ErrorKind::IsADirectory => "is a directory",
ErrorKind::NotADirectory => "not a directory",
ErrorKind::PermissionDenied => "permission denied",
_ => "unreadable",
}
}
#[must_use]
pub fn posix_display(raw: &str) -> String {
let separators: &[char] = if cfg!(windows) { &['/', '\\'] } else { &['/'] };
let leading = raw.chars().take_while(|c| separators.contains(c)).count();
let root = match leading {
0 => "",
2 if !cfg!(windows) => "//",
_ => "/",
};
let parts: Vec<&str> = raw
.split(separators)
.filter(|part| !part.is_empty() && *part != ".")
.collect();
if parts.is_empty() {
return if root.is_empty() { "." } else { root }.to_owned();
}
format!("{root}{}", parts.join("/"))
}
fn json_payload(changed: bool, reports: &[FileReport], errors: &[String]) -> String {
let mut out = String::from("{\n \"changed\": ");
out.push_str(if changed { "true" } else { "false" });
out.push_str(",\n \"errors\": ");
if errors.is_empty() {
out.push_str("[]");
} else {
out.push_str("[\n");
for (index, error) in errors.iter().enumerate() {
out.push_str(" ");
json_string(error, &mut out);
out.push_str(if index + 1 == errors.len() {
"\n"
} else {
",\n"
});
}
out.push_str(" ]");
}
out.push_str(",\n \"files\": ");
if reports.is_empty() {
out.push_str("[]");
} else {
out.push_str("[\n");
for (index, report) in reports.iter().enumerate() {
let _ = write!(
out,
" {{\n \"changed\": {},\n \"line_breaks_removed\": {},\n \"paragraphs_unwrapped\": {},\n \"path\": ",
report.changed, report.line_breaks_removed, report.paragraphs_unwrapped
);
json_string(&report.path, &mut out);
out.push_str("\n }");
out.push_str(if index + 1 == reports.len() {
"\n"
} else {
",\n"
});
}
out.push_str(" ]");
}
out.push_str("\n}");
out
}
fn json_string(text: &str, out: &mut String) {
out.push('"');
for c in text.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\u{8}' => out.push_str("\\b"),
'\u{c}' => out.push_str("\\f"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\u{20}'..='\u{7e}' => out.push(c),
_ => {
let mut units = [0u16; 2];
for unit in c.encode_utf16(&mut units) {
let _ = write!(out, "\\u{unit:04x}");
}
}
}
}
out.push('"');
}
#[must_use]
pub fn working_directory() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(argv: &[&str]) -> Result<Args, String> {
parse_args(
&argv
.iter()
.map(|s| (*s).to_owned())
.collect::<Vec<String>>(),
)
}
#[test]
fn the_first_positional_run_is_the_path_list() {
assert_eq!(parse(&["a.md", "b.md"]).unwrap().paths, ["a.md", "b.md"]);
assert_eq!(parse(&["--exclude", "x", "a.md"]).unwrap().paths, ["a.md"]);
assert_eq!(
parse(&["--write", "--json", "a.md", "b.md", "--exclude", "x"])
.unwrap()
.paths,
["a.md", "b.md"]
);
}
#[test]
fn a_second_positional_run_is_unrecognized() {
assert!(parse(&["a.md", "--exclude", "x", "b.md"]).is_err());
assert!(parse(&["--json", "a.md", "--json", "b.md"]).is_err());
}
#[test]
fn an_abbreviation_is_taken_when_it_is_unambiguous() {
assert!(parse(&["--wr"]).unwrap().write);
assert!(parse(&["--w"]).unwrap().write);
assert!(parse(&["--j"]).unwrap().json);
assert!(parse(&["--fa"]).unwrap().fail_on_change);
assert_eq!(parse(&["--e", "x"]).unwrap().exclude, ["x"]);
assert_eq!(
parse(&["--i", "x"]).unwrap().ignore_file.as_deref(),
Some("x")
);
assert!(parse(&["--f", "x"]).is_err());
}
#[test]
fn a_value_may_be_given_inline_or_after() {
assert_eq!(parse(&["--exclude=x"]).unwrap().exclude, ["x"]);
assert_eq!(parse(&["--exc=x"]).unwrap().exclude, ["x"]);
assert_eq!(
parse(&["--files-from=list.txt"])
.unwrap()
.files_from
.as_deref(),
Some("list.txt")
);
assert!(parse(&["--exclude"]).is_err());
assert!(parse(&["--exclude", "--json"]).is_err());
}
#[test]
fn the_last_value_wins_and_exclude_accumulates() {
assert_eq!(
parse(&["--ignore-file", "x", "--ignore-file", "y"])
.unwrap()
.ignore_file
.as_deref(),
Some("y")
);
assert_eq!(
parse(&["--exclude", "a", "--exclude", "b"])
.unwrap()
.exclude,
["a", "b"]
);
}
#[test]
fn a_dash_leading_token_is_a_positional_when_argparse_says_so() {
assert_eq!(parse(&["--json", "-12"]).unwrap().paths, ["-12"]);
assert_eq!(parse(&["--json", "-1.5"]).unwrap().paths, ["-1.5"]);
assert_eq!(parse(&["--json", "-.5"]).unwrap().paths, ["-.5"]);
assert_eq!(parse(&["--json", "-0"]).unwrap().paths, ["-0"]);
assert_eq!(parse(&["-"]).unwrap().paths, ["-"]);
assert_eq!(parse(&["--json", "-a b"]).unwrap().paths, ["-a b"]);
assert!(parse(&["--json", "-x"]).is_err());
assert!(parse(&["--json", "-1a"]).is_err());
assert!(parse(&["--json", "-5."]).is_err());
assert_eq!(parse(&["--exclude", "-12"]).unwrap().exclude, ["-12"]);
}
#[test]
fn a_double_dash_ends_the_options_without_breaking_the_run() {
assert_eq!(
parse(&["a.md", "--", "b.md"]).unwrap().paths,
["a.md", "b.md"]
);
assert_eq!(parse(&["--", "a.md"]).unwrap().paths, ["a.md"]);
assert_eq!(
parse(&["--json", "--"]).unwrap().paths,
Vec::<String>::new()
);
assert_eq!(parse(&["--json", "--", "-x"]).unwrap().paths, ["-x"]);
assert_eq!(parse(&["--", "--", "a.md"]).unwrap().paths, ["--", "a.md"]);
let args = parse(&["--write", "--", "--write"]).unwrap();
assert!(args.write);
assert_eq!(args.paths, ["--write"]);
}
#[test]
fn the_negative_number_rule_is_ascii_and_says_so() {
assert!(is_negative_number("-12"));
assert!(is_negative_number("-.5"));
assert!(is_negative_number("-1.5"));
assert!(!is_negative_number("-5."));
assert!(!is_negative_number("-"));
assert!(!is_negative_number("-1a"));
assert!(!is_negative_number("-\u{661}\u{662}"));
}
#[test]
fn a_path_is_reported_with_posix_separators() {
assert_eq!(posix_display("a.md"), "a.md");
assert_eq!(posix_display("./a.md"), "a.md");
assert_eq!(posix_display("a//b.md"), "a/b.md");
assert_eq!(posix_display("a/b.md/"), "a/b.md");
assert_eq!(posix_display("a/../b.md"), "a/../b.md");
assert_eq!(posix_display(""), ".");
assert_eq!(posix_display("/"), "/");
assert_eq!(posix_display("///"), "/");
assert_eq!(posix_display(".."), "..");
}
#[test]
fn the_json_payload_matches_pythons_dump() {
let payload = json_payload(
true,
&[FileReport {
path: "fine.md".to_owned(),
changed: true,
paragraphs_unwrapped: 1,
line_breaks_removed: 1,
}],
&["bad.md: cannot read (not valid UTF-8)".to_owned()],
);
assert_eq!(
payload,
"{\n \"changed\": true,\n \"errors\": [\n \"bad.md: cannot read (not valid UTF-8)\"\n ],\n \"files\": [\n {\n \"changed\": true,\n \"line_breaks_removed\": 1,\n \"paragraphs_unwrapped\": 1,\n \"path\": \"fine.md\"\n }\n ]\n}"
);
assert_eq!(
json_payload(false, &[], &[]),
"{\n \"changed\": false,\n \"errors\": [],\n \"files\": []\n}"
);
}
#[test]
fn json_escapes_everything_outside_printable_ascii() {
let mut out = String::new();
json_string("a\u{7f}b", &mut out);
assert_eq!(out, "\"a\\u007fb\"");
out.clear();
json_string("\u{e9}\u{1f600}\"\\\n\t", &mut out);
assert_eq!(out, "\"\\u00e9\\ud83d\\ude00\\\"\\\\\\n\\t\"");
}
}