extern crate alloc;
mod collation;
mod comparer;
mod error;
mod gitignore;
mod logging;
mod sorter;
use crate::{error::CheckError, gitignore::Grouper};
use anyhow::{anyhow, Context, Result};
use clap::{CommandFactory, FromArgMatches, Parser};
use log::{debug, error};
use sorter::{Sorter, Strategy};
use std::{
collections::hash_map::DefaultHasher,
env::args_os,
ffi::OsString,
fs::{copy, File},
hash::{Hash, Hasher},
io::{stdout, BufRead, BufReader, BufWriter, Chain, Cursor, Read, Write},
path::{Path, PathBuf},
};
use tempfile::NamedTempFile;
use termimad::MadSkin;
const MAX_TERM_WIDTH: usize = 100;
#[derive(Parser)]
#[command(author, version, about)]
#[clap(max_term_width = MAX_TERM_WIDTH)]
#[clap(after_long_help = long_help())]
#[allow(clippy::struct_excessive_bools)]
struct Cli {
#[arg(short, long, value_enum)]
sort: Strategy,
#[arg(short, long, value_name = "CODE")]
locale: Option<String>,
#[arg(short, long)]
unique: bool,
#[arg(long, value_name = "PREFIX")]
comment_prefix: Option<String>,
#[arg(short, long)]
case_insensitive: bool,
#[arg(short, long)]
reverse: bool,
#[arg(long)]
windows: bool,
#[arg(short, long, group = "output")]
in_place: bool,
#[arg(long, group = "output")]
stdout: bool,
#[arg(long, group = "output")]
check: bool,
file: PathBuf,
#[arg(long)]
debug: bool,
}
fn main() {
let status = match Cli::new_from_args(args_os()) {
Ok(cli) => cli.run(),
Err(e) => {
if let Some(e) = e.downcast_ref::<clap::Error>() {
e.exit()
} else {
error!("{e}");
42
}
}
};
std::process::exit(status);
}
const SORTING_METHODS_START: &str = "<!-- sorting-methods -->";
const SORTING_METHODS_END: &str = "<!-- /sorting-methods -->";
fn long_help() -> String {
const INTRO: &str = "There are a number of different sorting methods available.\n";
let skin = MadSkin::default();
let help = format!("{INTRO}\n{}", sorting_methods_from_readme());
format!("{}", skin.text(&help, Some(MAX_TERM_WIDTH)))
}
fn sorting_methods_from_readme() -> String {
const README: &str = include_str!("../README.md");
sorting_methods_from(README)
}
fn sorting_methods_from(readme: &str) -> String {
let start = readme
.find(SORTING_METHODS_START)
.expect("README.md has a sorting-methods start marker")
+ SORTING_METHODS_START.len();
let end = readme[start..]
.find(SORTING_METHODS_END)
.expect("README.md has a sorting-methods end marker")
+ start;
readme[start..end].trim().replace("\r\n", "\n")
}
impl Cli {
fn new_from_args<I, T>(args: I) -> Result<Self>
where
I: IntoIterator<Item = T>,
T: Into<OsString> + Clone,
{
let command = Cli::command();
Cli::from_arg_matches(&command.get_matches_from(args)).map_err(std::convert::Into::into)
}
fn run(&self) -> i32 {
if let Err(e) = logging::init(self.debug) {
error!("{e}");
return 100;
}
if let Err(e) = self.validate_args() {
error!("{e}");
return 101;
}
if let Err(e) = self.execute() {
error!("{e}");
let status = match e.downcast::<CheckError>() {
Ok(
CheckError::HasUnexpectedEmptyLines
| CheckError::NotSorted { .. }
| CheckError::NotUnique { .. },
) => 1,
_ => 2,
};
return status;
}
0
}
fn validate_args(&self) -> Result<()> {
if self.locale.is_some() && !self.sort.supports_locale() {
return Err(anyhow!(
"you cannot set a locale when sorting by {:?}",
self.sort,
));
}
if self.windows && !self.sort.supports_path_type() {
return Err(anyhow!(
"you cannot pass the --windows flag when sorting {:?}",
self.sort,
));
}
if self.reverse && !self.sort.supports_reverse() {
return Err(anyhow!(
"you cannot pass the --reverse flag when sorting {:?}, because reversing these files would change what they ignore",
self.sort,
));
}
if self.comment_prefix.is_some() && self.sort.keeps_file_structure() {
return Err(anyhow!(
"you cannot set a comment prefix when sorting {:?}, because comments are part of the format and are always left where they are",
self.sort,
));
}
if self.in_place && self.check {
return Err(anyhow!("you cannot set both --in-place and --stdout"));
}
Ok(())
}
fn execute(&self) -> Result<()> {
let sorter = Sorter::new(
self.sort,
self.locale.as_deref(),
self.unique,
self.case_insensitive,
self.reverse,
self.windows,
)?;
let contents = read_lines(&self.file, self.sort, self.comment_prefix.as_deref())?;
if self.check {
if contents.has_empty_lines {
return Err(CheckError::HasUnexpectedEmptyLines.into());
}
if sorter.lines_are_sorted(&contents.lines)? {
return Ok(());
}
}
self.sort_lines(contents, &sorter)
}
fn sort_lines(&self, mut contents: FileContents, sorter: &Sorter) -> Result<()> {
let orig_hash = if contents.has_empty_lines {
None
} else {
Some(hash_lines(&contents.lines))
};
contents.lines = sorter.sort_lines(contents.lines)?;
if !contents.has_empty_lines {
let new_hash = hash_lines(&contents.lines);
if orig_hash.unwrap() == new_hash && !self.stdout {
debug!("file is already sorted");
return Ok(());
}
}
if self.stdout {
return write_lines_to_writer(contents, &mut stdout());
}
if !self.in_place {
let mut bak_file = self.file.clone();
let ext = bak_file
.extension()
.map_or("", |e| e.to_str().unwrap_or(""));
bak_file.set_extension(if ext.is_empty() {
String::from("bak")
} else {
format!("{ext}.bak")
});
copy(&self.file, bak_file)?;
}
let mut file = NamedTempFile::new_in(self.file.parent().unwrap())?;
write_lines_to_writer(contents, &mut file)?;
let temp_path = file.path().to_path_buf();
file.persist(&self.file).with_context(|| {
format!(
"error renaming {} to {}",
temp_path.display(),
self.file.display(),
)
})?;
Ok(())
}
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub(crate) struct SortableLine {
line_number: usize,
line: String,
comment: Option<Comment>,
group: usize,
kind: LineKind,
}
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(crate) enum LineKind {
Sortable,
Fence,
}
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
struct Comment {
is_preceded_by_empty_line: bool,
lines: Vec<String>,
}
impl SortableLine {
#[allow(dead_code)]
fn from_number_and_str(from: (usize, &str)) -> Self {
Self::for_test(from.0, from.1, 0, LineKind::Sortable)
}
#[allow(dead_code)]
fn for_test(line_number: usize, line: &str, group: usize, kind: LineKind) -> Self {
Self {
line_number,
line: line.to_string(),
comment: None,
group,
kind,
}
}
}
struct FileContents {
lines: Vec<SortableLine>,
has_empty_lines: bool,
line_ending: &'static str,
has_bom: bool,
}
fn read_lines<P: AsRef<Path>>(
file: P,
sort: Strategy,
comment_prefix: Option<&str>,
) -> Result<FileContents> {
let mut f = File::open(file.as_ref())?;
let LineEndingChain {
reader,
line_ending,
has_bom,
} = determine_line_ending(&mut f)?;
let (lines, has_empty_lines) = lines_from_reader(sort, comment_prefix, reader)?;
Ok(FileContents {
lines,
has_empty_lines,
line_ending,
has_bom,
})
}
fn lines_from_reader<R: Read>(
sort: Strategy,
comment_prefix: Option<&str>,
read: R,
) -> Result<(Vec<SortableLine>, bool)> {
if sort.keeps_file_structure() {
return Ok((grouped_lines_from_reader(read)?, false));
}
let reader = BufReader::new(read);
let mut lines = vec![];
let mut comment: Option<Comment> = None;
let mut last_line_was_empty = false;
let mut has_empty_lines = false;
for (i, line) in reader.lines().enumerate() {
let line = line?;
if line.is_empty() {
last_line_was_empty = true;
continue;
}
if comment_prefix.is_some() && line.trim().starts_with(comment_prefix.unwrap()) {
if let Some(ref mut comment) = comment {
comment.lines.push(line);
} else {
comment = Some(Comment {
lines: vec![line],
is_preceded_by_empty_line: last_line_was_empty,
});
last_line_was_empty = false;
}
continue;
}
if last_line_was_empty {
has_empty_lines = true;
}
lines.push(SortableLine {
line_number: i + 1,
line,
comment,
group: 0,
kind: LineKind::Sortable,
});
last_line_was_empty = false;
comment = None;
}
Ok((lines, has_empty_lines))
}
fn grouped_lines_from_reader<R: Read>(read: R) -> Result<Vec<SortableLine>> {
let reader = BufReader::new(read);
let mut grouper = Grouper::default();
let mut lines = vec![];
for (i, line) in reader.lines().enumerate() {
let line = line?;
let (group, kind) = grouper.next(&line);
lines.push(SortableLine {
line_number: i + 1,
line,
comment: None,
group,
kind,
});
}
Ok(lines)
}
fn hash_lines(lines: &[SortableLine]) -> u64 {
let mut hasher = DefaultHasher::new();
for l in lines {
l.hash(&mut hasher);
}
hasher.finish()
}
fn write_lines_to_writer<W: Write>(contents: FileContents, out: &mut W) -> Result<()> {
let FileContents {
lines,
line_ending,
has_bom,
..
} = contents;
let mut bw = BufWriter::new(out);
if has_bom || first_line_written(&lines).starts_with('\u{feff}') {
bw.write_all(&UTF8_BOM)?;
}
for (i, l) in lines.into_iter().enumerate() {
if let Some(comment) = l.comment {
if comment.is_preceded_by_empty_line && i != 0 {
bw.write_all(line_ending.as_bytes())?;
}
for line in comment.lines {
bw.write_all(line.as_bytes())?;
bw.write_all(line_ending.as_bytes())?;
}
}
bw.write_all(l.line.as_bytes())?;
bw.write_all(line_ending.as_bytes())?;
}
Ok(())
}
fn first_line_written(lines: &[SortableLine]) -> &str {
let Some(first) = lines.first() else {
return "";
};
first
.comment
.as_ref()
.and_then(|c| c.lines.first())
.unwrap_or(&first.line)
}
const UTF8_BOM: [u8; 3] = [0xEF, 0xBB, 0xBF];
const READ_CHUNK_SIZE: usize = 2048;
const LINE_ENDINGS: [&str; 3] = ["\r\n", "\n", "\r"];
const DEFAULT_LINE_ENDING: &str = "\n";
struct LineEndingChain<'a> {
reader: Chain<Cursor<Vec<u8>>, &'a mut File>,
line_ending: &'static str,
has_bom: bool,
}
fn determine_line_ending(file: &mut File) -> Result<LineEndingChain<'_>> {
let mut buf = vec![];
let mut chunk = [0; READ_CHUNK_SIZE];
let mut line_ending = DEFAULT_LINE_ENDING;
let longest_line_ending = LINE_ENDINGS.iter().map(|le| le.len()).max().unwrap();
let mut search_from = 0;
loop {
let read = file.read(&mut chunk)?;
let at_eof = read == 0;
buf.extend_from_slice(&chunk[..read]);
if let Some(le) = line_ending_in(&buf, search_from, at_eof) {
line_ending = le;
break;
}
if at_eof {
break;
}
search_from = buf.len().saturating_sub(longest_line_ending - 1);
}
let has_bom = buf.starts_with(&UTF8_BOM);
if has_bom {
buf.drain(..UTF8_BOM.len());
}
Ok(LineEndingChain {
reader: Cursor::new(buf).chain(file),
line_ending,
has_bom,
})
}
fn line_ending_in(buf: &[u8], search_from: usize, at_eof: bool) -> Option<&'static str> {
for le in LINE_ENDINGS {
let Some(pos) = buf_find_str(le, &buf[search_from..]) else {
continue;
};
if le == "\r" && !at_eof && pos + search_from == buf.len() - 1 {
return None;
}
return Some(le);
}
None
}
fn buf_find_str(needle: &str, haystack: &[u8]) -> Option<usize> {
let needle = needle.as_bytes();
if needle.len() == 1 {
return haystack.iter().position(|b| *b == needle[0]);
}
haystack.windows(needle.len()).position(|w| w == needle)
}
#[cfg(test)]
mod test {
use crate::{CheckError, Cli};
use super::{Comment, FileContents, LineEndingChain, LineKind, SortableLine};
use crate::sorter::Strategy;
use anyhow::Result;
use std::{
fs::{metadata, read_dir, read_to_string, write, File},
io::{Read, Write},
path::PathBuf,
};
use tempfile::tempdir;
use test_case::test_case;
use test_log::test;
const WITH_COMMENTS: &str = r"
foo
bar
# comment 1
baz
# comment 2
quux
";
const WITH_REPEATED_LINES: &str = r"
# first foo
foo
bar
# first baz
baz
# second foo
foo
quux
# second baz
baz
";
#[test]
fn sorting_methods_come_from_the_readme() {
let methods = super::sorting_methods_from_readme();
assert!(
methods.starts_with("### Text (`--sort text`)"),
"the section starts at the first sorting method",
);
assert!(
methods.ends_with("This sorting method accepts the `--reverse` flag."),
"the section ends with the last sorting method",
);
assert!(
!methods.contains("## Linting and Tidying this Code"),
"the section stops before the rest of the README",
);
}
#[test]
fn sorting_methods_survive_crlf_line_endings() {
let readme = concat!(
"# omegasort\r\n",
"\r\n",
"<!-- sorting-methods -->\r\n",
"\r\n",
"### Text (`--sort text`)\r\n",
"\r\n",
"This sorts each line.\r\n",
"\r\n",
"<!-- /sorting-methods -->\r\n",
"\r\n",
"## Linting and Tidying this Code\r\n",
);
assert_eq!(
super::sorting_methods_from(readme),
"### Text (`--sort text`)\n\nThis sorts each line.",
);
}
#[allow(clippy::too_many_lines)]
#[test]
fn lines_from_reader() -> Result<()> {
let lines = ["foo", "bar", "baz", "quux"]
.map(|l| format!("{l}\n"))
.join("");
assert_eq!(
super::lines_from_reader(Strategy::Text, None, lines.trim().as_bytes())?,
(
[(1, "foo"), (2, "bar"), (3, "baz"), (4, "quux")]
.into_iter()
.map(SortableLine::from_number_and_str)
.collect::<Vec<_>>(),
false
),
);
let lines = ["foo", "", "bar", "", "baz", "quux"]
.map(|l| format!("{l}\n"))
.join("");
assert_eq!(
super::lines_from_reader(Strategy::Text, None, lines.trim().as_bytes())?,
(
[(1, "foo"), (3, "bar"), (5, "baz"), (6, "quux")]
.into_iter()
.map(SortableLine::from_number_and_str)
.collect::<Vec<_>>(),
true,
),
"empty lines are skipped",
);
assert_eq!(
super::lines_from_reader(Strategy::Text, None, WITH_COMMENTS.trim_start().as_bytes())?,
(
vec![
SortableLine {
line_number: 1,
line: "foo".to_string(),
comment: None,
group: 0,
kind: LineKind::Sortable,
},
SortableLine {
line_number: 2,
line: "bar".to_string(),
comment: None,
group: 0,
kind: LineKind::Sortable,
},
SortableLine {
line_number: 3,
line: "# comment 1".to_string(),
comment: None,
group: 0,
kind: LineKind::Sortable,
},
SortableLine {
line_number: 4,
line: "baz".to_string(),
comment: None,
group: 0,
kind: LineKind::Sortable,
},
SortableLine {
line_number: 6,
line: "# comment 2".to_string(),
comment: None,
group: 0,
kind: LineKind::Sortable,
},
SortableLine {
line_number: 7,
line: "quux".to_string(),
comment: None,
group: 0,
kind: LineKind::Sortable,
},
],
true,
),
);
assert_eq!(
super::lines_from_reader(
Strategy::Text,
Some("#"),
WITH_COMMENTS.trim_start().as_bytes()
)?,
(
vec![
SortableLine {
line_number: 1,
line: "foo".to_string(),
comment: None,
group: 0,
kind: LineKind::Sortable,
},
SortableLine {
line_number: 2,
line: "bar".to_string(),
comment: None,
group: 0,
kind: LineKind::Sortable,
},
SortableLine {
line_number: 4,
line: "baz".to_string(),
comment: Some(Comment {
lines: vec!["# comment 1".to_string()],
is_preceded_by_empty_line: false,
}),
group: 0,
kind: LineKind::Sortable,
},
SortableLine {
line_number: 7,
line: "quux".to_string(),
comment: Some(Comment {
lines: vec!["# comment 2".to_string()],
is_preceded_by_empty_line: true,
}),
group: 0,
kind: LineKind::Sortable,
},
],
false
),
);
Ok(())
}
fn contents(lines: Vec<SortableLine>, has_bom: bool) -> FileContents {
FileContents {
lines,
has_empty_lines: false,
line_ending: "\n",
has_bom,
}
}
#[test_case(WITH_COMMENTS ; "comments are kept where they are")]
#[test_case(WITH_REPEATED_LINES ; "repeated lines are all written back")]
fn write_lines_to_writer(content: &str) -> Result<()> {
let content = content.trim_start();
let mut buf = vec![];
let (lines, _) = super::lines_from_reader(Strategy::Text, Some("#"), content.as_bytes())?;
super::write_lines_to_writer(contents(lines, false), &mut buf)?;
assert_eq!(unsafe { String::from_utf8_unchecked(buf) }, content);
Ok(())
}
#[test]
fn write_lines_to_writer_and_boms() -> Result<()> {
let mut buf = vec![];
let (lines, _) = super::lines_from_reader(Strategy::Text, None, "a\nb\n".as_bytes())?;
super::write_lines_to_writer(contents(lines, true), &mut buf)?;
assert_eq!(
unsafe { String::from_utf8_unchecked(buf) },
"\u{feff}a\nb\n",
"a file that had a BOM gets it back, in front of the first line",
);
let mut buf = vec![];
let (lines, _) =
super::lines_from_reader(Strategy::Text, None, "\u{feff}a\nb\n".as_bytes())?;
super::write_lines_to_writer(contents(lines, false), &mut buf)?;
assert_eq!(
unsafe { String::from_utf8_unchecked(buf) },
"\u{feff}\u{feff}a\nb\n",
"a first line that starts with a BOM gets one written in front of it, so that \
reading the file back does not take the line's own BOM for the file's",
);
let mut buf = vec![];
let (lines, _) =
super::lines_from_reader(Strategy::Text, None, "\u{feff}a\nb\n".as_bytes())?;
super::write_lines_to_writer(contents(lines, true), &mut buf)?;
assert_eq!(
unsafe { String::from_utf8_unchecked(buf) },
"\u{feff}\u{feff}a\nb\n",
"one BOM for the file and one for the line, and no third one",
);
Ok(())
}
#[test]
fn determine_line_ending() -> Result<()> {
let mut long_str = "Lorem ipsum dolor sit amet".repeat(100);
long_str.push('\n');
let mut crlf_across_reads = "x".repeat(super::READ_CHUNK_SIZE - 1);
crlf_across_reads.push_str("\r\nconsectetur adipiscing elit\r\n");
let mut cr_across_reads = "x".repeat(super::READ_CHUNK_SIZE - 1);
cr_across_reads.push_str("\rconsectetur adipiscing elit\r");
let mut bom_across_reads = String::from("\u{feff}");
bom_across_reads.push_str(&"x".repeat(super::READ_CHUNK_SIZE));
bom_across_reads.push('\n');
let mut cr_at_end_of_file = String::from("b");
cr_at_end_of_file.push_str(&"x".repeat(super::READ_CHUNK_SIZE - 2));
cr_at_end_of_file.push('\r');
let tests: &[(&str, &str, bool)] = &[
(
"Lorem ipsum dolor sit amet\nconsectetur adipiscing elit",
"\n",
false,
),
(
"Lorem ipsum dolor sit amet\rconsectetur adipiscing elit",
"\r",
false,
),
(
"Lorem ipsum dolor sit amet\r\nconsectetur adipiscing elit",
"\r\n",
false,
),
(
"\u{feff}Lorem ipsum dolor sit amet\nconsectetur adipiscing elit",
"\n",
true,
),
(
"Lorem ipsum\u{feff} dolor sit amet\nconsectetur adipiscing elit",
"\n",
false,
),
(
"Lorem ipsum dolor sit amet\tconsectetur adipiscing elit",
"\n",
false,
),
("a\rb\nc\n", "\n", false),
("", "\n", false),
("\u{feff}", "\n", true),
(long_str.as_str(), "\n", false),
(crlf_across_reads.as_str(), "\r\n", false),
(cr_across_reads.as_str(), "\r", false),
(bom_across_reads.as_str(), "\n", true),
(cr_at_end_of_file.as_str(), "\r", false),
];
for t in tests {
let dir = tempdir()?;
let mut filename = dir.path().to_path_buf();
filename.push("le-test");
let mut file = File::create(&filename)?;
write!(file, "{}", t.0)?;
drop(file);
let mut file = File::open(&filename)?;
let LineEndingChain {
mut reader,
line_ending,
has_bom,
} = super::determine_line_ending(&mut file)?;
assert_eq!(line_ending, t.1, "line ending for {:?}", t.0);
assert_eq!(has_bom, t.2, "BOM for {:?}", t.0);
let mut rest = String::new();
reader.read_to_string(&mut rest)?;
assert_eq!(
rest,
t.0.strip_prefix('\u{feff}').unwrap_or(t.0),
"the reader hands back the file with any leading BOM taken off",
);
}
Ok(())
}
fn validate_gitignore_args(extra: &[&str]) -> Result<()> {
let mut args = vec![
String::from("omegasort"),
String::from("--sort"),
String::from("gitignore"),
];
args.extend(extra.iter().map(ToString::to_string));
args.push(String::from("ignored.txt"));
Cli::new_from_args(args)?.validate_args()
}
#[test_case(&["--reverse"] ; "reverse")]
#[test_case(&["--windows"] ; "windows")]
#[test_case(&["--comment-prefix", "#"] ; "comment prefix")]
fn gitignore_rejects_flags_that_do_not_fit_the_format(extra: &[&str]) {
assert!(
validate_gitignore_args(extra).is_err(),
"{extra:?} is rejected when sorting a gitignore file",
);
}
#[test_case(&[] ; "no extra flags")]
#[test_case(&["--unique"] ; "unique")]
#[test_case(&["--case-insensitive"] ; "case insensitive")]
#[test_case(&["--locale", "en-US"] ; "locale")]
fn gitignore_accepts_flags_that_fit_the_format(extra: &[&str]) {
assert!(
validate_gitignore_args(extra).is_ok(),
"{extra:?} is accepted when sorting a gitignore file",
);
}
#[test]
fn a_bom_stays_at_the_front_of_the_file() -> Result<()> {
let sorted = |strategy: &str, extra: &[&str], content: &str| -> Result<String> {
let td = tempdir()?;
let mut filename = td.path().to_path_buf();
filename.push("input.txt");
write(&filename, content)?;
let mut args = vec![
String::from("omegasort"),
String::from("--sort"),
String::from(strategy),
String::from("--in-place"),
];
args.extend(extra.iter().map(|a| String::from(*a)));
args.push(filename.to_string_lossy().to_string());
Cli::new_from_args(args)?.execute()?;
Ok(read_to_string(filename)?)
};
assert_eq!(
sorted("gitignore", &[], "\u{feff}zebra\napple\n")?,
"\u{feff}apple\nzebra\n",
"the BOM does not travel with the line it was in front of",
);
assert_eq!(
sorted("text", &[], "\u{feff}zebra\napple\n")?,
"\u{feff}apple\nzebra\n",
"every sorting method leaves the BOM at the front, not just this one",
);
assert_eq!(
sorted("gitignore", &[], "\u{feff}!foo\n!bar\nbaz\n")?,
"\u{feff}!bar\n!foo\nbaz\n",
"the first line is a negation, as it is to git, so it groups with the next one",
);
assert_eq!(
sorted("gitignore", &[], "zb\nza\n\u{feff}x\nb\na\n")?,
"za\nzb\n\u{feff}x\na\nb\n",
"a BOM after the first line is part of the pattern, so that line stays where it is \
and splits the run in two. Sorting it to the front would turn a `<BOM>!foo` into \
the negation `!foo` and change what the file ignores.",
);
assert_eq!(
sorted("gitignore", &["--unique"], "a\n\u{feff}x\na\n")?,
"\u{feff}\u{feff}x\na\n",
"--unique can drop every line above a BOM line and leave it first. It gets a BOM \
written in front of it so that git still reads it as a pattern for a file whose \
name starts with a mark, not as the pattern `x`.",
);
assert_eq!(
sorted("text", &["--locale", "en-US"], "zzz\n\u{feff}aaa\n")?,
"\u{feff}\u{feff}aaa\nzzz\n",
"a collator can sort a BOM line to the front of a file that had no BOM, so this is \
not only a gitignore problem. Without the extra BOM the line would lose its own.",
);
Ok(())
}
fn sorted_in_place(strategy: &str, extra: &[&str], content: &str) -> Result<String> {
let td = tempdir()?;
let mut filename = td.path().to_path_buf();
filename.push("input.txt");
write(&filename, content)?;
let mut args = vec![
String::from("omegasort"),
String::from("--sort"),
String::from(strategy),
];
if !extra.contains(&"--check") {
args.push(String::from("--in-place"));
}
args.extend(extra.iter().map(|a| String::from(*a)));
args.push(filename.to_string_lossy().to_string());
Cli::new_from_args(args)?.execute()?;
Ok(read_to_string(filename)?)
}
#[test_case("text", &[] ; "text")]
#[test_case("text", &["--check"] ; "text with check")]
#[test_case("gitignore", &[] ; "gitignore")]
#[test_case("gitignore", &["--unique"] ; "gitignore with unique")]
fn a_file_with_no_line_ending_is_not_an_error(strategy: &str, extra: &[&str]) -> Result<()> {
assert_eq!(
sorted_in_place(strategy, extra, "foo")?,
"foo",
"one line with no line ending after it is left alone by --sort {strategy} {extra:?}",
);
assert_eq!(
sorted_in_place(strategy, extra, "")?,
"",
"an empty file is left alone by --sort {strategy} {extra:?}",
);
Ok(())
}
#[test]
fn a_file_with_no_line_ending_gets_the_fallback_when_it_is_written_out() -> Result<()> {
let td = tempdir()?;
let mut filename = td.path().to_path_buf();
filename.push("input.txt");
write(&filename, "foo")?;
let contents = super::read_lines(&filename, Strategy::Text, None)?;
assert_eq!(contents.line_ending, "\n", "the fallback line ending");
let mut buf = vec![];
super::write_lines_to_writer(contents, &mut buf)?;
assert_eq!(
String::from_utf8(buf)?,
"foo\n",
"written out, the line gets a newline after it like every other line does",
);
Ok(())
}
#[test]
fn a_crlf_file_is_not_mistaken_for_one_that_uses_a_lone_cr() -> Result<()> {
let long_line = "x".repeat(super::READ_CHUNK_SIZE - 1);
let content = format!("{long_line}\r\nzebra\r\napple\r\n");
let td = tempdir()?;
let mut filename = td.path().to_path_buf();
filename.push("input.txt");
write(&filename, &content)?;
Cli::new_from_args(vec![
String::from("omegasort"),
String::from("--sort"),
String::from("text"),
String::from("--in-place"),
filename.to_string_lossy().to_string(),
])?
.execute()?;
assert_eq!(
read_to_string(&filename)?,
format!("apple\r\n{long_line}\r\nzebra\r\n"),
"the file keeps its CRLF line endings and its three lines",
);
Ok(())
}
#[test]
fn bak_file_by_default() -> Result<()> {
let td = tempdir()?;
let mut filename = td.path().to_path_buf();
filename.push("input.txt");
let orig_content = "foo\nbar\nbaz\n";
write(&filename, orig_content)?;
let cli = Cli::new_from_args([
String::from("omegasort"),
String::from("--sort"),
String::from("text"),
filename.to_string_lossy().to_string(),
])?;
cli.execute()?;
let mut new_filename = td.path().to_path_buf();
new_filename.push("input.txt.bak");
assert_eq!(read_to_string(new_filename)?, orig_content);
assert_eq!(read_to_string(filename)?, "bar\nbaz\nfoo\n");
Ok(())
}
#[test]
fn do_not_rewrite_sorted_file() -> Result<()> {
let td = tempdir()?;
let mut filename = td.path().to_path_buf();
filename.push("input.txt");
write(&filename, "bar\nbaz\nfoo\n")?;
let orig_meta = metadata(&filename)?;
let cli = Cli::new_from_args([
String::from("omegasort"),
String::from("--sort"),
String::from("text"),
String::from("--in-place"),
filename.to_string_lossy().to_string(),
])?;
cli.execute()?;
let new_meta = metadata(&filename)?;
assert_eq!(orig_meta.modified()?, new_meta.modified()?);
Ok(())
}
#[test]
fn integration() -> Result<()> {
let mut test_case_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
test_case_dir.push("./src/test-cases");
let paths = read_dir(test_case_dir)?;
let mut files = vec![];
for path in paths {
let path = path?.path();
if let Some(ext) = path.extension() {
if ext.to_string_lossy() == "test" {
files.push(path);
}
}
}
files.sort();
for file in files {
run_one_integration_test(file)?;
}
Ok(())
}
fn run_one_integration_test(path: PathBuf) -> Result<()> {
println!("{}", path.file_name().unwrap().to_string_lossy());
let case = read_to_string(path)?.replace('\r', "");
let mut elts = case.split("####\n");
let mut args = vec![String::from("omegasort")];
args.append(
&mut elts
.next()
.unwrap()
.trim()
.split(' ')
.map(String::from)
.collect::<Vec<_>>(),
);
let expected_check_failure = elts.next().unwrap().trim();
let input = elts.next().unwrap().trim_start();
let expect = elts.next().unwrap().trim_start();
let td = tempdir()?;
let mut filename = td.path().to_path_buf();
filename.push("input.txt");
write(&filename, input)?;
let mut check_args = args.clone();
check_args.append(&mut vec![
String::from("--check"),
filename.to_string_lossy().to_string(),
]);
let cli = Cli::new_from_args(check_args)?;
let res = cli.execute();
assert!(
res.is_err(),
"file is not sorted so --check should not pass",
);
let e = res.unwrap_err();
let dc = e.downcast_ref::<CheckError>();
assert!(dc.is_some(), "got a CheckError from execute: {e}");
let check_error = dc.unwrap();
match expected_check_failure {
"HasUnexpectedEmptyLines" => assert!(
matches!(check_error, CheckError::HasUnexpectedEmptyLines),
"check_error ({check_error:?}) is a HasUnexpectedEmptyLines error"
),
"NotSorted" => assert!(
matches!(check_error, CheckError::NotSorted { .. }),
"check_error ({check_error:?}) is a NotSorted error "
),
"NotUnique" => assert!(
matches!(check_error, CheckError::NotUnique { .. }),
"check_error ({check_error:?}) is a NotUnique error from --check"
),
_ => unreachable!(
"unexpected expected_check_failure value in test file: {expected_check_failure}"
),
}
let mut sort_args = args.clone();
sort_args.append(&mut vec![
String::from("--in-place"),
filename.to_string_lossy().to_string(),
]);
let cli = Cli::new_from_args(sort_args)?;
let res = cli.execute();
assert!(res.is_ok(), "no error sorting file: {res:?}");
assert_eq!(read_to_string(&filename)?, expect);
let mut recheck_args = args.clone();
recheck_args.append(&mut vec![
String::from("--check"),
filename.to_string_lossy().to_string(),
]);
let res = Cli::new_from_args(recheck_args)?.execute();
assert!(res.is_ok(), "sorted output passes --check: {res:?}");
let mut resort_args = args;
resort_args.append(&mut vec![
String::from("--in-place"),
filename.to_string_lossy().to_string(),
]);
Cli::new_from_args(resort_args)?.execute()?;
assert_eq!(
read_to_string(&filename)?,
expect,
"sorting the output again does not change it",
);
Ok(())
}
}