1use std::fmt::Write as _;
18use std::fs;
19use std::io::ErrorKind;
20use std::path::{Path, PathBuf};
21
22use crate::ignore::{IGNORE_FILE_NAME, IgnoreRules};
23use crate::scan::{py_splitlines_keepends, py_trim, split_eol};
24use crate::transcript::is_transcript_like_markdown;
25use crate::unwrap_markdown_prose;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct Outcome {
30 pub stdout: String,
32 pub stderr: String,
34 pub code: u8,
36}
37
38#[derive(Debug, Clone, Default, PartialEq, Eq)]
40pub struct Args {
41 pub paths: Vec<String>,
43 pub files_from: Option<String>,
45 pub write: bool,
47 pub json: bool,
49 pub fail_on_change: bool,
51 pub ignore_file: Option<String>,
53 pub exclude: Vec<String>,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59enum Takes {
60 Nothing,
61 OneValue,
62}
63
64const OPTIONS: [(&str, Takes); 7] = [
66 ("--help", Takes::Nothing),
67 ("--files-from", Takes::OneValue),
68 ("--write", Takes::Nothing),
69 ("--json", Takes::Nothing),
70 ("--fail-on-change", Takes::Nothing),
71 ("--ignore-file", Takes::OneValue),
72 ("--exclude", Takes::OneValue),
73];
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77struct FileReport {
78 path: String,
79 changed: bool,
80 paragraphs_unwrapped: usize,
81 line_breaks_removed: usize,
82}
83
84#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93enum ReadError {
94 Io(ErrorKind),
95 NotUtf8,
96}
97
98#[must_use]
105pub fn run(argv: &[String], root: &Path) -> Outcome {
106 let args = match parse_args(argv) {
107 Ok(args) => args,
108 Err(message) => {
109 return Outcome {
110 stdout: String::new(),
111 stderr: format!("error: {message}\n"),
112 code: 2,
115 };
116 }
117 };
118 if args.paths.is_empty() && args.files_from.is_none() && wants_help(argv) {
119 return Outcome {
120 stdout: usage(),
121 stderr: String::new(),
122 code: 0,
123 };
124 }
125
126 let mut errors: Vec<String> = Vec::new();
127 let raw_paths = collect_input_paths(&args, root, &mut errors);
128 let rules = build_ignore_rules(&args, root, &mut errors);
129 let mut reports: Vec<FileReport> = Vec::new();
130
131 for raw in &raw_paths {
132 if rules.excludes(raw) {
138 continue;
139 }
140 let full = root.join(raw);
141 let reported = posix_display(raw);
142 let Ok(metadata) = fs::symlink_metadata(&full) else {
146 continue;
147 };
148 if metadata.file_type().is_symlink() || !full.is_file() {
149 continue;
150 }
151 match process_file(&full, &reported, args.write) {
152 Ok(report) => reports.push(report),
153 Err(error) => errors.push(format!(
156 "{reported}: cannot read ({})",
157 describe(&full, error)
158 )),
159 }
160 }
161
162 let changed = reports.iter().any(|report| report.changed);
163 let mut stdout = String::new();
164 let mut stderr = String::new();
165 if args.json {
166 stdout.push_str(&json_payload(changed, &reports, &errors));
167 stdout.push('\n');
168 } else {
169 for report in &reports {
170 if report.changed {
171 let _ = writeln!(
172 stdout,
173 "{}: removed {} manual line break(s)",
174 report.path, report.line_breaks_removed
175 );
176 }
177 }
178 for error in &errors {
179 let _ = writeln!(stderr, "{error}");
180 }
181 }
182 let code = u8::from(args.fail_on_change && changed || !errors.is_empty());
187 Outcome {
188 stdout,
189 stderr,
190 code,
191 }
192}
193
194pub fn parse_args(argv: &[String]) -> Result<Args, String> {
201 let tokens = classify(argv);
202 let mut args = Args::default();
203 let mut paths_taken = false;
204 let mut extras: Vec<String> = Vec::new();
205 let mut index = 0;
206 while index < tokens.len() {
207 let Token::Option { name, inline } = &tokens[index] else {
208 let start = index;
213 while matches!(tokens.get(index), Some(Token::Positional(_))) {
214 index += 1;
215 }
216 let run = tokens[start..index]
217 .iter()
218 .map(Token::value)
219 .collect::<Vec<String>>();
220 if paths_taken {
221 extras.extend(run);
222 } else {
223 args.paths = run;
224 paths_taken = true;
225 }
226 continue;
227 };
228 let (option, takes) = resolve(name)?;
229 index += 1;
230 if takes == Takes::Nothing {
231 if inline.is_some() {
232 return Err(format!("argument {option}: ignored explicit argument"));
233 }
234 match option {
235 "--write" => args.write = true,
236 "--json" => args.json = true,
237 "--fail-on-change" => args.fail_on_change = true,
238 _ => {}
239 }
240 continue;
241 }
242 let value = match inline {
243 Some(value) => value.clone(),
244 None => match tokens.get(index) {
245 Some(Token::Positional(value)) => {
249 index += 1;
250 value.clone()
251 }
252 _ => return Err(format!("argument {option}: expected one argument")),
253 },
254 };
255 match option {
256 "--files-from" => args.files_from = Some(value),
257 "--ignore-file" => args.ignore_file = Some(value),
258 "--exclude" => args.exclude.push(value),
260 _ => {}
261 }
262 }
263 if extras.is_empty() {
264 Ok(args)
265 } else {
266 Err(format!("unrecognized arguments: {}", extras.join(" ")))
267 }
268}
269
270#[derive(Debug, Clone, PartialEq, Eq)]
272enum Token {
273 Option {
274 name: String,
275 inline: Option<String>,
276 },
277 Positional(String),
278}
279
280impl Token {
281 fn value(&self) -> String {
282 match self {
283 Token::Positional(value) => value.clone(),
284 Token::Option { name, .. } => name.clone(),
285 }
286 }
287}
288
289fn classify(argv: &[String]) -> Vec<Token> {
295 let mut tokens = Vec::with_capacity(argv.len());
296 let mut rest_are_positional = false;
297 for arg in argv {
298 if rest_are_positional {
299 tokens.push(Token::Positional(arg.clone()));
300 continue;
301 }
302 if arg == "--" {
303 rest_are_positional = true;
307 continue;
308 }
309 if !is_option_like(arg) {
310 tokens.push(Token::Positional(arg.clone()));
311 continue;
312 }
313 match arg.split_once('=') {
314 Some((name, value)) => tokens.push(Token::Option {
315 name: name.to_owned(),
316 inline: Some(value.to_owned()),
317 }),
318 None => tokens.push(Token::Option {
319 name: arg.clone(),
320 inline: None,
321 }),
322 }
323 }
324 tokens
325}
326
327fn is_option_like(arg: &str) -> bool {
329 if !arg.starts_with('-') || arg.chars().count() == 1 {
330 return false;
331 }
332 !is_negative_number(arg) && !arg.contains(' ')
334}
335
336fn is_negative_number(arg: &str) -> bool {
352 let Some(rest) = arg.strip_prefix('-') else {
353 return false;
354 };
355 if !rest.is_empty() && rest.bytes().all(|b| b.is_ascii_digit()) {
356 return true;
357 }
358 match rest.split_once('.') {
361 Some((whole, fraction)) => {
362 whole.bytes().all(|b| b.is_ascii_digit())
363 && !fraction.is_empty()
364 && fraction.bytes().all(|b| b.is_ascii_digit())
365 }
366 None => false,
367 }
368}
369
370fn resolve(name: &str) -> Result<(&'static str, Takes), String> {
372 if let Some((option, takes)) = OPTIONS.iter().find(|(option, _)| *option == name) {
373 return Ok((option, *takes));
374 }
375 if name == "-h" {
376 return Ok(("--help", Takes::Nothing));
377 }
378 let candidates: Vec<&(&'static str, Takes)> = OPTIONS
379 .iter()
380 .filter(|(option, _)| option.starts_with(name))
381 .collect();
382 match candidates.as_slice() {
383 [(option, takes)] => Ok((option, *takes)),
384 [] => Err(format!("unrecognized arguments: {name}")),
385 many => Err(format!(
386 "ambiguous option: {name} could match {}",
387 many.iter()
388 .map(|(option, _)| *option)
389 .collect::<Vec<&str>>()
390 .join(", ")
391 )),
392 }
393}
394
395fn wants_help(argv: &[String]) -> bool {
397 argv.iter().any(|arg| {
398 arg == "-h" || (is_option_like(arg) && matches!(resolve(arg), Ok(("--help", _))))
399 })
400}
401
402fn usage() -> String {
408 let mut text = String::from("Detect or remove manual line breaks in Markdown prose.\n\n");
409 text.push_str("usage: unwrap-markdown-prose-rs [options] [paths ...]\n\n");
410 for (option, takes) in OPTIONS {
411 let value = if takes == Takes::OneValue {
412 " VALUE"
413 } else {
414 ""
415 };
416 let _ = writeln!(text, " {option}{value}");
417 }
418 text
419}
420
421fn collect_input_paths(args: &Args, root: &Path, errors: &mut Vec<String>) -> Vec<String> {
423 let mut paths = args.paths.clone();
424 let Some(files_from) = &args.files_from else {
425 return paths;
426 };
427 let full = root.join(files_from);
428 match read_text(&full) {
429 Ok(contents) => {
433 let translated = contents.replace("\r\n", "\n").replace('\r', "\n");
434 paths.extend(
435 py_splitlines_keepends(&translated)
436 .into_iter()
437 .map(|line| split_eol(line).0)
438 .filter(|line| !py_trim(line).is_empty())
439 .map(str::to_owned),
440 );
441 }
442 Err(error) => errors.push(format!(
443 "{}: cannot read --files-from ({})",
444 posix_display(files_from),
445 describe(&full, error)
446 )),
447 }
448 paths
449}
450
451fn build_ignore_rules(args: &Args, root: &Path, errors: &mut Vec<String>) -> IgnoreRules {
453 let explicit = args.ignore_file.as_deref();
454 let name = explicit.unwrap_or(IGNORE_FILE_NAME);
455 let full = root.join(name);
456 let text = if explicit.is_some() || full.is_file() {
460 match read_text(&full) {
461 Ok(text) => Some(text),
462 Err(error) => {
463 errors.push(format!(
464 "{}: cannot read --ignore-file ({})",
465 posix_display(name),
466 describe(&full, error)
467 ));
468 None
469 }
470 }
471 } else {
472 None
473 };
474 IgnoreRules::new(text.as_deref(), args.exclude.iter().map(String::as_str))
475}
476
477fn process_file(full: &Path, reported: &str, write: bool) -> Result<FileReport, ReadError> {
479 let original = read_text(full)?;
480 if is_transcript_like_markdown(&original) {
481 return Ok(FileReport {
482 path: reported.to_owned(),
483 changed: false,
484 paragraphs_unwrapped: 0,
485 line_breaks_removed: 0,
486 });
487 }
488 let result = unwrap_markdown_prose(&original);
489 let changed = result.content != original;
490 if write && changed {
491 fs::write(full, result.content.as_bytes()).map_err(|e| ReadError::Io(e.kind()))?;
495 }
496 Ok(FileReport {
497 path: reported.to_owned(),
498 changed,
499 paragraphs_unwrapped: result.paragraphs_unwrapped,
500 line_breaks_removed: result.line_breaks_removed,
501 })
502}
503
504fn read_text(path: &Path) -> Result<String, ReadError> {
506 let bytes = fs::read(path).map_err(|error| ReadError::Io(error.kind()))?;
507 String::from_utf8(bytes).map_err(|_| ReadError::NotUtf8)
508}
509
510fn describe(path: &Path, error: ReadError) -> &'static str {
517 let kind = match error {
518 ReadError::NotUtf8 => return "not valid UTF-8",
521 ReadError::Io(kind) => kind,
522 };
523 if path.is_dir() {
524 return "is a directory";
525 }
526 match kind {
527 ErrorKind::NotFound => "not found",
528 ErrorKind::IsADirectory => "is a directory",
529 ErrorKind::NotADirectory => "not a directory",
530 ErrorKind::PermissionDenied => "permission denied",
531 _ => "unreadable",
532 }
533}
534
535#[must_use]
545pub fn posix_display(raw: &str) -> String {
546 let separators: &[char] = if cfg!(windows) { &['/', '\\'] } else { &['/'] };
547 let leading = raw.chars().take_while(|c| separators.contains(c)).count();
548 let root = match leading {
551 0 => "",
552 2 if !cfg!(windows) => "//",
553 _ => "/",
554 };
555 let parts: Vec<&str> = raw
556 .split(separators)
557 .filter(|part| !part.is_empty() && *part != ".")
558 .collect();
559 if parts.is_empty() {
560 return if root.is_empty() { "." } else { root }.to_owned();
561 }
562 format!("{root}{}", parts.join("/"))
563}
564
565fn json_payload(changed: bool, reports: &[FileReport], errors: &[String]) -> String {
567 let mut out = String::from("{\n \"changed\": ");
568 out.push_str(if changed { "true" } else { "false" });
569 out.push_str(",\n \"errors\": ");
570 if errors.is_empty() {
571 out.push_str("[]");
572 } else {
573 out.push_str("[\n");
574 for (index, error) in errors.iter().enumerate() {
575 out.push_str(" ");
576 json_string(error, &mut out);
577 out.push_str(if index + 1 == errors.len() {
578 "\n"
579 } else {
580 ",\n"
581 });
582 }
583 out.push_str(" ]");
584 }
585 out.push_str(",\n \"files\": ");
586 if reports.is_empty() {
587 out.push_str("[]");
588 } else {
589 out.push_str("[\n");
590 for (index, report) in reports.iter().enumerate() {
591 let _ = write!(
593 out,
594 " {{\n \"changed\": {},\n \"line_breaks_removed\": {},\n \"paragraphs_unwrapped\": {},\n \"path\": ",
595 report.changed, report.line_breaks_removed, report.paragraphs_unwrapped
596 );
597 json_string(&report.path, &mut out);
598 out.push_str("\n }");
599 out.push_str(if index + 1 == reports.len() {
600 "\n"
601 } else {
602 ",\n"
603 });
604 }
605 out.push_str(" ]");
606 }
607 out.push_str("\n}");
608 out
609}
610
611fn json_string(text: &str, out: &mut String) {
617 out.push('"');
618 for c in text.chars() {
619 match c {
620 '"' => out.push_str("\\\""),
621 '\\' => out.push_str("\\\\"),
622 '\u{8}' => out.push_str("\\b"),
623 '\u{c}' => out.push_str("\\f"),
624 '\n' => out.push_str("\\n"),
625 '\r' => out.push_str("\\r"),
626 '\t' => out.push_str("\\t"),
627 '\u{20}'..='\u{7e}' => out.push(c),
628 _ => {
629 let mut units = [0u16; 2];
632 for unit in c.encode_utf16(&mut units) {
633 let _ = write!(out, "\\u{unit:04x}");
634 }
635 }
636 }
637 }
638 out.push('"');
639}
640
641#[must_use]
643pub fn working_directory() -> PathBuf {
644 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650
651 fn parse(argv: &[&str]) -> Result<Args, String> {
652 parse_args(
653 &argv
654 .iter()
655 .map(|s| (*s).to_owned())
656 .collect::<Vec<String>>(),
657 )
658 }
659
660 #[test]
661 fn the_first_positional_run_is_the_path_list() {
662 assert_eq!(parse(&["a.md", "b.md"]).unwrap().paths, ["a.md", "b.md"]);
663 assert_eq!(parse(&["--exclude", "x", "a.md"]).unwrap().paths, ["a.md"]);
664 assert_eq!(
665 parse(&["--write", "--json", "a.md", "b.md", "--exclude", "x"])
666 .unwrap()
667 .paths,
668 ["a.md", "b.md"]
669 );
670 }
671
672 #[test]
673 fn a_second_positional_run_is_unrecognized() {
674 assert!(parse(&["a.md", "--exclude", "x", "b.md"]).is_err());
677 assert!(parse(&["--json", "a.md", "--json", "b.md"]).is_err());
678 }
679
680 #[test]
681 fn an_abbreviation_is_taken_when_it_is_unambiguous() {
682 assert!(parse(&["--wr"]).unwrap().write);
683 assert!(parse(&["--w"]).unwrap().write);
684 assert!(parse(&["--j"]).unwrap().json);
685 assert!(parse(&["--fa"]).unwrap().fail_on_change);
686 assert_eq!(parse(&["--e", "x"]).unwrap().exclude, ["x"]);
687 assert_eq!(
688 parse(&["--i", "x"]).unwrap().ignore_file.as_deref(),
689 Some("x")
690 );
691 assert!(parse(&["--f", "x"]).is_err());
693 }
694
695 #[test]
696 fn a_value_may_be_given_inline_or_after() {
697 assert_eq!(parse(&["--exclude=x"]).unwrap().exclude, ["x"]);
698 assert_eq!(parse(&["--exc=x"]).unwrap().exclude, ["x"]);
699 assert_eq!(
700 parse(&["--files-from=list.txt"])
701 .unwrap()
702 .files_from
703 .as_deref(),
704 Some("list.txt")
705 );
706 assert!(parse(&["--exclude"]).is_err());
707 assert!(parse(&["--exclude", "--json"]).is_err());
709 }
710
711 #[test]
712 fn the_last_value_wins_and_exclude_accumulates() {
713 assert_eq!(
714 parse(&["--ignore-file", "x", "--ignore-file", "y"])
715 .unwrap()
716 .ignore_file
717 .as_deref(),
718 Some("y")
719 );
720 assert_eq!(
721 parse(&["--exclude", "a", "--exclude", "b"])
722 .unwrap()
723 .exclude,
724 ["a", "b"]
725 );
726 }
727
728 #[test]
729 fn a_dash_leading_token_is_a_positional_when_argparse_says_so() {
730 assert_eq!(parse(&["--json", "-12"]).unwrap().paths, ["-12"]);
731 assert_eq!(parse(&["--json", "-1.5"]).unwrap().paths, ["-1.5"]);
732 assert_eq!(parse(&["--json", "-.5"]).unwrap().paths, ["-.5"]);
733 assert_eq!(parse(&["--json", "-0"]).unwrap().paths, ["-0"]);
734 assert_eq!(parse(&["-"]).unwrap().paths, ["-"]);
735 assert_eq!(parse(&["--json", "-a b"]).unwrap().paths, ["-a b"]);
736 assert!(parse(&["--json", "-x"]).is_err());
737 assert!(parse(&["--json", "-1a"]).is_err());
738 assert!(parse(&["--json", "-5."]).is_err());
740 assert_eq!(parse(&["--exclude", "-12"]).unwrap().exclude, ["-12"]);
742 }
743
744 #[test]
745 fn a_double_dash_ends_the_options_without_breaking_the_run() {
746 assert_eq!(
747 parse(&["a.md", "--", "b.md"]).unwrap().paths,
748 ["a.md", "b.md"]
749 );
750 assert_eq!(parse(&["--", "a.md"]).unwrap().paths, ["a.md"]);
751 assert_eq!(
752 parse(&["--json", "--"]).unwrap().paths,
753 Vec::<String>::new()
754 );
755 assert_eq!(parse(&["--json", "--", "-x"]).unwrap().paths, ["-x"]);
756 assert_eq!(parse(&["--", "--", "a.md"]).unwrap().paths, ["--", "a.md"]);
758 let args = parse(&["--write", "--", "--write"]).unwrap();
759 assert!(args.write);
760 assert_eq!(args.paths, ["--write"]);
761 }
762
763 #[test]
764 fn the_negative_number_rule_is_ascii_and_says_so() {
765 assert!(is_negative_number("-12"));
766 assert!(is_negative_number("-.5"));
767 assert!(is_negative_number("-1.5"));
768 assert!(!is_negative_number("-5."));
769 assert!(!is_negative_number("-"));
770 assert!(!is_negative_number("-1a"));
771 assert!(!is_negative_number("-\u{661}\u{662}"));
773 }
774
775 #[test]
776 fn a_path_is_reported_with_posix_separators() {
777 assert_eq!(posix_display("a.md"), "a.md");
778 assert_eq!(posix_display("./a.md"), "a.md");
779 assert_eq!(posix_display("a//b.md"), "a/b.md");
780 assert_eq!(posix_display("a/b.md/"), "a/b.md");
781 assert_eq!(posix_display("a/../b.md"), "a/../b.md");
782 assert_eq!(posix_display(""), ".");
783 assert_eq!(posix_display("/"), "/");
784 assert_eq!(posix_display("///"), "/");
785 assert_eq!(posix_display(".."), "..");
786 }
787
788 #[test]
789 fn the_json_payload_matches_pythons_dump() {
790 let payload = json_payload(
791 true,
792 &[FileReport {
793 path: "fine.md".to_owned(),
794 changed: true,
795 paragraphs_unwrapped: 1,
796 line_breaks_removed: 1,
797 }],
798 &["bad.md: cannot read (not valid UTF-8)".to_owned()],
799 );
800 assert_eq!(
801 payload,
802 "{\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}"
803 );
804 assert_eq!(
805 json_payload(false, &[], &[]),
806 "{\n \"changed\": false,\n \"errors\": [],\n \"files\": []\n}"
807 );
808 }
809
810 #[test]
811 fn json_escapes_everything_outside_printable_ascii() {
812 let mut out = String::new();
813 json_string("a\u{7f}b", &mut out);
814 assert_eq!(out, "\"a\\u007fb\"");
816 out.clear();
817 json_string("\u{e9}\u{1f600}\"\\\n\t", &mut out);
818 assert_eq!(out, "\"\\u00e9\\ud83d\\ude00\\\"\\\\\\n\\t\"");
819 }
820}