use crate::{LineKind, SortableLine};
use std::collections::HashMap;
#[allow(clippy::struct_excessive_bools)]
pub(crate) struct GitignorePattern<'a> {
pub(crate) negated: bool,
pub(crate) anchored: bool,
pub(crate) dir_only: bool,
pub(crate) double_star: bool,
pub(crate) path: &'a str,
}
fn trim_trailing_spaces(line: &str) -> &str {
let bytes = line.as_bytes();
let mut last_space = None;
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b' ' => {
if last_space.is_none() {
last_space = Some(i);
}
}
b'\\' => {
i += 1;
if i >= bytes.len() {
return line;
}
last_space = None;
}
_ => last_space = None,
}
i += 1;
}
match last_space {
Some(i) => &line[..i],
None => line,
}
}
impl<'a> GitignorePattern<'a> {
pub(crate) fn new(line: &'a str) -> Self {
let mut line = trim_trailing_spaces(line);
let negated = if line.starts_with(r"\!") || line.starts_with(r"\#") {
line = &line[1..];
false
} else if let Some(r) = line.strip_prefix('!') {
line = r;
true
} else {
false
};
let mut anchored = match line.strip_prefix('/') {
Some(r) if !r.is_empty() => {
line = r;
true
}
_ => false,
};
let dir_only = match line.strip_suffix('/') {
Some(r) if !r.is_empty() => {
line = r;
true
}
_ => false,
};
let double_star = if line.starts_with("**/") {
anchored = false;
match line.strip_prefix("**/") {
Some(r) if !r.is_empty() && !r.contains('/') => {
line = r;
true
}
_ => false,
}
} else {
false
};
Self {
negated,
anchored,
dir_only,
double_star,
path: line,
}
}
}
pub(crate) fn unique_key(line: &str) -> String {
let pattern = GitignorePattern::new(line);
format!(
"{}\0{}\0{}\0{}",
pattern.negated, pattern.anchored, pattern.dir_only, pattern.path,
)
}
#[derive(Default)]
pub(crate) struct Grouper {
group: usize,
open_run: Option<bool>,
}
impl Grouper {
pub(crate) fn next(&mut self, line: &str) -> (usize, LineKind) {
if trim_trailing_spaces(line).is_empty()
|| line.starts_with('#')
|| line.starts_with('\u{feff}')
{
self.open_run = None;
self.group += 1;
return (self.group, LineKind::Fence);
}
let negated = GitignorePattern::new(line).negated;
if self.open_run != Some(negated) {
self.open_run = Some(negated);
self.group += 1;
}
(self.group, LineKind::Sortable)
}
}
pub(crate) fn dedup_keeping_last(lines: Vec<SortableLine>) -> Vec<SortableLine> {
let mut last_seen = HashMap::new();
for (i, line) in lines.iter().enumerate() {
if line.kind == LineKind::Sortable {
last_seen.insert(unique_key(&line.line), i);
}
}
let mut grouper = Grouper::default();
lines
.into_iter()
.enumerate()
.filter(|(i, line)| {
line.kind == LineKind::Fence || last_seen.get(&unique_key(&line.line)) == Some(i)
})
.map(|(_, mut line)| {
let (group, kind) = grouper.next(&line.line);
line.group = group;
line.kind = kind;
line
})
.collect()
}
#[cfg(test)]
mod test {
use super::{dedup_keeping_last, trim_trailing_spaces, unique_key, GitignorePattern, Grouper};
use crate::{LineKind, SortableLine};
use test_log::test;
fn groups(lines: &[&str]) -> Vec<usize> {
let mut grouper = Grouper::default();
lines.iter().map(|l| grouper.next(l).0).collect()
}
fn shape(lines: &[&str]) -> String {
let groups = groups(lines);
let mut seen = vec![];
groups
.into_iter()
.map(|g| {
let i = seen.iter().position(|s| *s == g).unwrap_or_else(|| {
seen.push(g);
seen.len() - 1
});
char::from(b'a' + u8::try_from(i).unwrap())
})
.collect()
}
#[test]
fn a_run_of_one_polarity_is_one_group() {
assert_eq!(shape(&["a", "b", "c"]), "aaa", "a flat file is one group");
assert_eq!(
shape(&["a", "!b", "c"]),
"abc",
"switching polarity twice makes three groups",
);
assert_eq!(
shape(&["a", "!b", "!c", "d"]),
"abbc",
"consecutive negations share a group",
);
}
#[test]
fn blank_lines_and_comments_are_fences() {
assert_eq!(
shape(&["a", "", "b"]),
"abc",
"a blank line splits a run in two",
);
assert_eq!(
shape(&["a", "# c", "b"]),
"abc",
"a comment splits a run in two",
);
assert_eq!(
shape(&["a", " ", "b"]),
"abc",
"a whitespace-only line is blank, since git strips trailing spaces",
);
assert_eq!(
shape(&["a", "\t", "b"]),
"aaa",
"a tab-only line is a pattern, since git only strips spaces",
);
assert_eq!(
shape(&[" #a", " #b"]),
"aa",
"a `#` that is not in the first column is part of a pattern",
);
assert_eq!(
shape(&["#a", "#b"]),
"ab",
"each comment is a fence of its own, so comments never reorder",
);
}
#[test]
fn which_lines_are_fences() {
let mut grouper = Grouper::default();
let kinds = ["a", "", "# c", "!d", " ", r"\!e", " #f", "\u{feff}g"]
.into_iter()
.map(|l| grouper.next(l).1)
.collect::<Vec<_>>();
assert_eq!(
kinds,
[
LineKind::Sortable,
LineKind::Fence,
LineKind::Fence,
LineKind::Sortable,
LineKind::Fence,
LineKind::Sortable,
LineKind::Sortable,
LineKind::Fence,
],
);
}
#[test]
fn a_line_starting_with_a_byte_order_mark_is_a_fence() {
assert_eq!(
shape(&["a", "\u{feff}b", "c"]),
"abc",
"the line cannot move, so the run around it is split in two",
);
assert_eq!(
shape(&["\u{feff}a", "\u{feff}b"]),
"ab",
"neither of them can move, so they do not sort against each other",
);
}
#[test]
fn an_escaped_bang_is_not_a_polarity_switch() {
assert_eq!(
shape(&["a", r"\!b", "c"]),
"aaa",
r"`\!b` is the file `!b`, not a negation",
);
}
fn deduped(lines: &[&str]) -> Vec<String> {
let mut grouper = Grouper::default();
let lines = lines
.iter()
.enumerate()
.map(|(i, l)| {
let (group, kind) = grouper.next(l);
SortableLine::for_test(i + 1, l, group, kind)
})
.collect::<Vec<_>>();
dedup_keeping_last(lines)
.into_iter()
.map(|l| l.line)
.collect()
}
fn deduped_shape(lines: &[&str]) -> String {
let mut grouper = Grouper::default();
let lines = lines
.iter()
.enumerate()
.map(|(i, l)| {
let (group, kind) = grouper.next(l);
SortableLine::for_test(i + 1, l, group, kind)
})
.collect::<Vec<_>>();
let mut seen = vec![];
dedup_keeping_last(lines)
.into_iter()
.map(|l| {
let i = seen.iter().position(|s| *s == l.group).unwrap_or_else(|| {
seen.push(l.group);
seen.len() - 1
});
char::from(b'a' + u8::try_from(i).unwrap())
})
.collect()
}
#[test]
fn dedup_keeps_the_last_copy() {
assert_eq!(
deduped(&["foo", "!foo", "foo"]),
["!foo", "foo"],
"the surviving `foo` is the one that was last, so it still wins",
);
assert_eq!(
deduped(&["foo", "**/foo", "/**/foo"]),
["/**/foo"],
"the three spellings of one pattern are duplicates",
);
assert_eq!(
deduped(&["foo", r"\!foo", "!foo"]),
["foo", r"\!foo", "!foo"],
"the markers are part of the key, so these are three patterns",
);
assert_eq!(
deduped(&["a", "", "", "a"]),
["", "", "a"],
"blank lines are never removed as duplicates of each other",
);
}
#[test]
fn dedup_merges_the_runs_around_a_group_it_empties() {
assert_eq!(
deduped(&["a", "!x", "b", "!x"]),
["a", "b", "!x"],
"the first `!x` goes, so the two patterns end up next to each other",
);
assert_eq!(
deduped_shape(&["a", "!x", "b", "!x"]),
"aab",
"and they are one group, so a second sort would not move them again",
);
assert_eq!(
deduped_shape(&["a", "!x", "b", "!x", "", "c"]),
"aabcd",
"an emptied group does not merge runs that a fence still separates",
);
}
#[test]
fn trailing_spaces_are_trimmed_like_git_does() {
let cases = [
("foo", "foo"),
("foo ", "foo"),
("foo ", "foo"),
("a b", "a b"),
("a b ", "a b"),
(" ", ""),
("\t", "\t"),
(" \t ", " \t"),
(r"foo\ ", r"foo\ "),
(r"foo\ ", r"foo\ "),
(r"\", r"\"),
(r"a \", r"a \"),
];
for (line, expect) in cases {
assert_eq!(trim_trailing_spaces(line), expect, "trimmed `{line}`");
}
}
#[test]
fn pattern_parsing() {
let cases = [
("foo", false, false, false, false, "foo"),
("!foo", true, false, false, false, "foo"),
("/foo", false, true, false, false, "foo"),
("foo/", false, false, true, false, "foo"),
("!/foo/", true, true, true, false, "foo"),
(r"\!foo", false, false, false, false, "!foo"),
(r"\#foo", false, false, false, false, "#foo"),
("/", false, false, false, false, "/"),
("**/foo", false, false, false, true, "foo"),
("/**/foo", false, false, false, true, "foo"),
("/**/foo/", false, false, true, true, "foo"),
("**/a/b", false, false, false, false, "**/a/b"),
("/**/a/b", false, false, false, false, "**/a/b"),
("a/b", false, false, false, false, "a/b"),
("/**", false, true, false, false, "**"),
("foo ", false, false, false, false, "foo"),
("!/foo/ ", true, true, true, false, "foo"),
(r"foo\ ", false, false, false, false, r"foo\ "),
];
for (line, negated, anchored, dir_only, double_star, path) in cases {
let pattern = GitignorePattern::new(line);
assert_eq!(pattern.negated, negated, "negated for `{line}`");
assert_eq!(pattern.anchored, anchored, "anchored for `{line}`");
assert_eq!(pattern.dir_only, dir_only, "dir_only for `{line}`");
assert_eq!(pattern.double_star, double_star, "double_star for `{line}`");
assert_eq!(pattern.path, path, "path for `{line}`");
}
}
#[test]
fn spellings_of_one_pattern_share_a_unique_key() {
for same in [
["foo", "**/foo"],
["foo", "/**/foo"],
["**/a/b", "/**/a/b"],
["!foo", "!**/foo"],
] {
assert_eq!(
unique_key(same[0]),
unique_key(same[1]),
"`{}` and `{}` are the same pattern",
same[0],
same[1],
);
}
for same_with_spaces in [["foo", "foo "], ["!/foo/", "!/foo/ "]] {
assert_eq!(
unique_key(same_with_spaces[0]),
unique_key(same_with_spaces[1]),
"`{}` and `{}` are the same pattern, since git strips the trailing spaces",
same_with_spaces[0],
same_with_spaces[1],
);
}
for different in [
["foo", "/foo"],
["! ", "!**/ "],
[r"foo\ ", "foo"],
["foo", "foo/"],
["foo", "!foo"],
[r"\!foo", "!foo"],
["a/b", "**/a/b"],
] {
assert_ne!(
unique_key(different[0]),
unique_key(different[1]),
"`{}` and `{}` are different patterns",
different[0],
different[1],
);
}
}
}