pub(crate) fn read_patterns(content: &[u8]) -> Result<Vec<String>, String> {
read_patterns_for(content, cfg!(windows))
}
fn read_patterns_for(content: &[u8], windows_separators: bool) -> Result<Vec<String>, String> {
const MAX_LINE_BYTES: usize = 64 * 1024 - 1;
let mut patterns = Vec::new();
for (index, raw_line) in content.split(|&byte| byte == b'\n').enumerate() {
if raw_line.len() > MAX_LINE_BYTES {
return Err(format!(
"ignore pattern line {} is longer than {MAX_LINE_BYTES} bytes, which Docker refuses",
index + 1
));
}
let text = String::from_utf8_lossy(raw_line);
let mut line = text.strip_suffix('\r').unwrap_or(&text);
if index == 0 {
line = line.strip_prefix('\u{feff}').unwrap_or(line);
}
if line.starts_with('#') {
continue;
}
let line = line.trim();
if line.is_empty() {
continue;
}
let (invert, rest) = match line.strip_prefix('!') {
Some(rest) => (true, rest.trim()),
None => (false, line),
};
let mut pattern = rest.to_string();
if !pattern.is_empty() {
pattern = if windows_separators {
clean_windows_pattern(&pattern)
} else {
clean(&pattern)
};
if pattern.len() > 1 && pattern.starts_with('/') {
pattern.remove(0);
}
}
if invert {
pattern.insert(0, '!');
}
patterns.push(pattern);
}
Ok(patterns)
}
fn clean(path: &str) -> String {
let rooted = path.starts_with('/');
let mut components: Vec<&str> = Vec::new();
for component in path.split('/') {
match component {
"" | "." => {}
".." => {
if components.last().is_some_and(|last| *last != "..") {
components.pop();
} else if !rooted {
components.push("..");
}
}
other => components.push(other),
}
}
let joined = components.join("/");
if rooted {
format!("/{joined}")
} else if joined.is_empty() {
".".to_string()
} else {
joined
}
}
fn clean_windows_pattern(pattern: &str) -> String {
let converted = pattern.replace('\\', "/");
let bytes = converted.as_bytes();
let drive = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':';
if !drive && !converted.starts_with("//") {
return clean(&converted);
}
let (volume, rest) = converted.split_at(2);
let cleaned = clean(rest);
if cleaned == "." {
if volume.ends_with('/') {
return volume.to_string();
}
return format!("{volume}.");
}
format!("{volume}{cleaned}")
}
enum Matcher {
Exact(String),
Prefix(String),
Suffix(String),
Regex(regex::Regex),
}
impl Matcher {
fn is_match(&self, path: &str) -> bool {
match self {
Matcher::Exact(pattern) => path == pattern,
Matcher::Prefix(prefix) => path.starts_with(prefix),
Matcher::Suffix(suffix) => {
path.ends_with(suffix) || (suffix.starts_with('/') && path == &suffix[1..])
}
Matcher::Regex(regex) => regex.is_match(path),
}
}
}
struct Pattern {
exclusion: bool,
cleaned: String,
raw: String,
compiled: std::sync::OnceLock<Result<Matcher, String>>,
}
impl Pattern {
fn is_match(&self, path: &str) -> Result<bool, String> {
match self.compiled.get_or_init(|| compile_pattern(&self.cleaned)) {
Ok(matcher) => Ok(matcher.is_match(path)),
Err(err) => Err(format!("invalid ignore pattern {:?}: {err}", self.raw)),
}
}
}
pub(crate) struct DockerPatternMatcher {
patterns: Vec<Pattern>,
exclusions: bool,
}
impl DockerPatternMatcher {
pub(crate) fn new(raw_patterns: &[String]) -> Result<Self, String> {
let mut patterns = Vec::new();
let mut exclusions = false;
for raw in raw_patterns {
let mut pattern = raw.trim().to_string();
if pattern.is_empty() {
continue;
}
pattern = clean(&pattern);
let mut exclusion = false;
if let Some(rest) = pattern.strip_prefix('!') {
if rest.is_empty() {
return Err("illegal exclusion pattern: \"!\"".to_string());
}
exclusion = true;
exclusions = true;
pattern = rest.to_string();
}
if filepath_match_syntax_error(&pattern) {
return Err(format!(
"invalid ignore pattern {raw:?}: syntax error in pattern"
));
}
patterns.push(Pattern {
exclusion,
cleaned: pattern,
raw: raw.clone(),
compiled: std::sync::OnceLock::new(),
});
}
Ok(DockerPatternMatcher {
patterns,
exclusions,
})
}
pub(crate) fn has_exclusions(&self) -> bool {
self.exclusions
}
pub(crate) fn matches(&self, rel_path: &str) -> Result<bool, String> {
let mut matched = false;
let parent_dirs: Vec<&str> = match rel_path.rfind('/') {
Some(split) => rel_path[..split].split('/').collect(),
None => Vec::new(),
};
for pattern in &self.patterns {
if pattern.exclusion != matched {
continue;
}
let mut hit = pattern.is_match(rel_path)?;
for depth in 1..=parent_dirs.len() {
if hit {
break;
}
hit = pattern.is_match(&parent_dirs[..depth].join("/"))?;
}
if hit {
matched = !pattern.exclusion;
}
}
Ok(matched)
}
}
fn filepath_match_syntax_error(pattern: &str) -> bool {
let chars: Vec<char> = pattern.chars().collect();
let mut pattern: &[char] = &chars;
let dot = ['.'];
let mut name: &[char] = ˙
'pattern: while !pattern.is_empty() {
let (star, chunk, rest) = scan_chunk(pattern);
pattern = rest;
if star && chunk.is_empty() {
return false;
}
match match_chunk(chunk, name) {
Err(()) => return true,
Ok(Some(t)) if t.is_empty() || !pattern.is_empty() => {
name = t;
continue;
}
Ok(_) => {}
}
if star {
let mut i = 0;
while i < name.len() && name[i] != '/' {
match match_chunk(chunk, &name[i + 1..]) {
Err(()) => return true,
Ok(Some(t)) => {
if pattern.is_empty() && !t.is_empty() {
i += 1;
continue;
}
name = t;
continue 'pattern;
}
Ok(None) => {}
}
i += 1;
}
}
return false;
}
false
}
fn scan_chunk(mut pattern: &[char]) -> (bool, &[char], &[char]) {
let mut star = false;
while pattern.first() == Some(&'*') {
pattern = &pattern[1..];
star = true;
}
let mut inrange = false;
let mut i = 0;
while i < pattern.len() {
match pattern[i] {
'\\' => {
if i + 1 < pattern.len() {
i += 1;
}
}
'[' => inrange = true,
']' => inrange = false,
'*' if !inrange => break,
_ => {}
}
i += 1;
}
(star, &pattern[..i], &pattern[i..])
}
fn match_chunk<'a>(mut chunk: &[char], mut s: &'a [char]) -> Result<Option<&'a [char]>, ()> {
let mut failed = false;
while !chunk.is_empty() {
if !failed && s.is_empty() {
failed = true;
}
match chunk[0] {
'[' => {
let mut r = '\0';
if !failed {
r = s[0];
s = &s[1..];
}
chunk = &chunk[1..];
let negated = chunk.first() == Some(&'^');
if negated {
chunk = &chunk[1..];
}
let mut matched = false;
let mut nrange = 0;
loop {
if chunk.first() == Some(&']') && nrange > 0 {
chunk = &chunk[1..];
break;
}
let lo;
(lo, chunk) = get_esc(chunk)?;
let mut hi = lo;
if chunk[0] == '-' {
(hi, chunk) = get_esc(&chunk[1..])?;
}
if lo <= r && r <= hi {
matched = true;
}
nrange += 1;
}
if matched == negated {
failed = true;
}
}
'?' => {
if !failed {
if s[0] == '/' {
failed = true;
}
s = &s[1..];
}
chunk = &chunk[1..];
}
'\\' => {
chunk = &chunk[1..];
if chunk.is_empty() {
return Err(());
}
if !failed {
if chunk[0] != s[0] {
failed = true;
}
s = &s[1..];
}
chunk = &chunk[1..];
}
_ => {
if !failed {
if chunk[0] != s[0] {
failed = true;
}
s = &s[1..];
}
chunk = &chunk[1..];
}
}
}
if failed {
Ok(None)
} else {
Ok(Some(s))
}
}
fn get_esc(chunk: &[char]) -> Result<(char, &[char]), ()> {
if chunk.is_empty() || chunk[0] == '-' || chunk[0] == ']' {
return Err(());
}
let mut chunk = chunk;
if chunk[0] == '\\' {
chunk = &chunk[1..];
if chunk.is_empty() {
return Err(());
}
}
let nchunk = &chunk[1..];
if nchunk.is_empty() {
return Err(());
}
Ok((chunk[0], nchunk))
}
fn ascii_class_replacement(shorthand: char) -> Option<&'static str> {
Some(match shorthand {
'd' => "[0-9]",
'D' => "[^0-9]",
's' => "[\\t\\n\\x0C\\r ]",
'S' => "[^\\t\\n\\x0C\\r ]",
'w' => "[0-9A-Za-z_]",
'W' => "[^0-9A-Za-z_]",
'b' => "(?-u:\\b)",
'B' => "(?-u:\\B)",
_ => return None,
})
}
fn scan_repetition(chars: &[char]) -> Option<(usize, bool)> {
let mut i = 1;
let (mut over_limit, len) = scan_repetition_bound(&chars[i..])?;
i += len;
if chars.get(i) == Some(&',') {
i += 1;
if let Some((max_over, len)) = scan_repetition_bound(&chars[i..]) {
i += len;
over_limit |= max_over;
}
}
if chars.get(i) != Some(&'}') {
return None;
}
Some((i + 1, over_limit))
}
fn scan_repetition_bound(chars: &[char]) -> Option<(bool, usize)> {
let mut len = 0;
while chars.get(len).is_some_and(char::is_ascii_digit) {
len += 1;
}
if len == 0 || (len > 1 && chars[0] == '0') {
return None;
}
let over_limit = len > 4
|| chars[..len]
.iter()
.collect::<String>()
.parse::<u32>()
.unwrap()
> 1000;
Some((over_limit, len))
}
fn go_rejects_numeric_escape(digit: char, following: Option<char>) -> bool {
matches!(digit, '1'..='9') && !(digit <= '7' && matches!(following, Some('0'..='7')))
}
fn posix_class_len(chars: &[char]) -> Option<usize> {
let close = chars.windows(2).skip(2).position(|w| w == [':', ']'])?;
Some(close + 4)
}
const GO_UNICODE_CLASS_NAMES: &[&str] = &[
"Adlam",
"Ahom",
"Any",
"Arabic",
"Armenian",
"Ascii",
"Assigned",
"Avestan",
"Balinese",
"Bamum",
"Batak",
"Bengali",
"Bhaiksuki",
"Bopomofo",
"Brahmi",
"Braille",
"Buginese",
"Buhid",
"C",
"Carian",
"Casedletter",
"Cc",
"Cf",
"Chakma",
"Cham",
"Cherokee",
"Chorasmian",
"Closepunctuation",
"Cn",
"Cntrl",
"Co",
"Combiningmark",
"Common",
"Connectorpunctuation",
"Control",
"Coptic",
"Cs",
"Cuneiform",
"Currencysymbol",
"Cypriot",
"Cyrillic",
"Dashpunctuation",
"Decimalnumber",
"Deseret",
"Devanagari",
"Digit",
"Dogra",
"Duployan",
"Elbasan",
"Elymaic",
"Enclosingmark",
"Ethiopic",
"Finalpunctuation",
"Format",
"Georgian",
"Glagolitic",
"Gothic",
"Grantha",
"Greek",
"Gujarati",
"Gurmukhi",
"Han",
"Hangul",
"Hanunoo",
"Hatran",
"Hebrew",
"Hiragana",
"Inherited",
"Initialpunctuation",
"Javanese",
"Kaithi",
"Kannada",
"Katakana",
"Kawi",
"Kharoshthi",
"Khmer",
"Khojki",
"Khudawadi",
"L",
"Lao",
"Latin",
"Lc",
"Lepcha",
"Letter",
"Letternumber",
"Limbu",
"Lineseparator",
"Lisu",
"Ll",
"Lm",
"Lo",
"Lowercaseletter",
"Lt",
"Lu",
"Lycian",
"Lydian",
"M",
"Mahajani",
"Makasar",
"Malayalam",
"Mandaic",
"Manichaean",
"Marchen",
"Mark",
"Mathsymbol",
"Mc",
"Me",
"Medefaidrin",
"Miao",
"Mn",
"Modi",
"Modifierletter",
"Modifiersymbol",
"Mongolian",
"Mro",
"Multani",
"Myanmar",
"N",
"Nabataean",
"Nandinagari",
"Nd",
"Newa",
"Nko",
"Nl",
"No",
"Nonspacingmark",
"Number",
"Nushu",
"Ogham",
"Openpunctuation",
"Oriya",
"Osage",
"Osmanya",
"Other",
"Otherletter",
"Othernumber",
"Otherpunctuation",
"Othersymbol",
"P",
"Palmyrene",
"Paragraphseparator",
"Pc",
"Pd",
"Pe",
"Pf",
"Phoenician",
"Pi",
"Po",
"Privateuse",
"Ps",
"Punct",
"Punctuation",
"Rejang",
"Runic",
"S",
"Samaritan",
"Saurashtra",
"Sc",
"Separator",
"Sharada",
"Shavian",
"Siddham",
"Sinhala",
"Sk",
"Sm",
"So",
"Sogdian",
"Soyombo",
"Spaceseparator",
"Spacingmark",
"Sundanese",
"Surrogate",
"Symbol",
"Syriac",
"Tagalog",
"Tagbanwa",
"Takri",
"Tamil",
"Tangsa",
"Tangut",
"Telugu",
"Thaana",
"Thai",
"Tibetan",
"Tifinagh",
"Tirhuta",
"Titlecaseletter",
"Toto",
"Ugaritic",
"Unassigned",
"Uppercaseletter",
"Vai",
"Vithkuqi",
"Wancho",
"Yezidi",
"Yi",
"Z",
"Zl",
"Zp",
"Zs",
];
const GO_POSIX_CLASS_NAMES: &[&str] = &[
"alnum", "alpha", "ascii", "blank", "cntrl", "digit", "graph", "lower", "print", "punct",
"space", "upper", "word", "xdigit",
];
fn go_canonical_unicode_name(name: &str) -> String {
let mut out = String::with_capacity(name.len());
let mut first = true;
for c in name.chars() {
if matches!(c, '_' | '-' | ' ') {
continue;
}
if first {
out.push(c.to_ascii_uppercase());
first = false;
} else {
out.push(c.to_ascii_lowercase());
}
}
out
}
fn emit_unicode_class(
chars: &[char],
start: usize,
reg: &mut String,
deferred_error: &mut Option<String>,
) -> usize {
let mut negated = chars[start + 1] == 'P';
let (name, len) = if chars.get(start + 2) == Some(&'{') {
let Some(close) = chars[start + 3..].iter().position(|&c| c == '}') else {
let unit: String = chars[start..].iter().collect();
deferred_error.get_or_insert(format!("invalid character class range: {unit}"));
return chars.len() - start;
};
let name: String = chars[start + 3..start + 3 + close].iter().collect();
(name, close + 4)
} else if let Some(&single) = chars.get(start + 2) {
(single.to_string(), 3)
} else {
deferred_error.get_or_insert(format!(
"invalid character class range: \\{}",
chars[start + 1]
));
return 2;
};
let bare = match name.strip_prefix('^') {
Some(rest) => {
negated = !negated;
rest
}
None => name.as_str(),
};
let canonical = go_canonical_unicode_name(bare);
if GO_UNICODE_CLASS_NAMES
.binary_search(&canonical.as_str())
.is_err()
{
let unit: String = chars[start..start + len].iter().collect();
deferred_error.get_or_insert(format!("invalid character class range: {unit}"));
return len;
}
let (negated, emitted) = if canonical == "Cs" || canonical == "Surrogate" {
(!negated, "Any")
} else {
(negated, canonical.as_str())
};
reg.push('\\');
reg.push(if negated { 'P' } else { 'p' });
reg.push('{');
reg.push_str(emitted);
reg.push('}');
len
}
fn emit_class_member(
chars: &[char],
j: &mut usize,
reg: &mut String,
deferred_error: &mut Option<String>,
) -> bool {
let c = chars[*j];
if c == '\\' {
let Some(&next) = chars.get(*j + 1) else {
reg.push('\\');
*j += 1;
return false;
};
if matches!(next, 'p' | 'P') {
*j += emit_unicode_class(chars, *j, reg, deferred_error);
return true;
}
if go_rejects_numeric_escape(next, chars.get(*j + 2).copied())
|| matches!(next, 'b' | 'B' | 'u' | 'U')
{
deferred_error.get_or_insert(format!("invalid escape sequence: \\{next}"));
}
if let Some(replacement) =
ascii_class_replacement(next).filter(|_| !matches!(next, 'b' | 'B'))
{
reg.push_str(replacement);
*j += 2;
return true;
}
reg.push('\\');
reg.push(next);
*j += 2;
return false;
}
if c == '[' {
if chars.get(*j + 1) == Some(&':') {
if let Some(len) = posix_class_len(&chars[*j..]) {
let name: String = chars[*j + 2..*j + len - 2].iter().collect();
if !GO_POSIX_CLASS_NAMES.contains(&name.strip_prefix('^').unwrap_or(&name)) {
deferred_error
.get_or_insert(format!("invalid character class range: [:{name}:]"));
}
reg.extend(&chars[*j..*j + len]);
*j += len;
return true;
}
}
reg.push_str("\\[");
*j += 1;
return false;
}
match c {
'&' | '~' | '-' | ']' | '.' | '+' | '(' | ')' | '$' => {
reg.push('\\');
reg.push(c);
}
_ => reg.push(c),
}
*j += 1;
false
}
fn expand_for_class_scan(chars: &[char]) -> (Vec<char>, Vec<usize>, Vec<usize>) {
fn emit(exp: &mut Vec<char>, orig: &mut Vec<usize>, unit: usize, text: &str) {
for c in text.chars() {
exp.push(c);
orig.push(unit);
}
}
let mut exp = Vec::new();
let mut orig = Vec::new();
let mut at = vec![0; chars.len()];
let mut i = 0;
while i < chars.len() {
let unit = i;
let start = exp.len();
let ch = chars[i];
if ch == '*' && chars.get(i + 1) == Some(&'*') {
i += 1;
if chars.get(i + 1) == Some(&'/') {
i += 1;
}
emit(
&mut exp,
&mut orig,
unit,
if i + 1 >= chars.len() { ".*" } else { "(.*/)?" },
);
} else if ch == '*' {
emit(&mut exp, &mut orig, unit, "[^/]*");
} else if ch == '?' {
emit(&mut exp, &mut orig, unit, "[^/]");
} else if matches!(ch, '.' | '+' | '(' | ')' | '$') {
exp.push('\\');
orig.push(unit);
exp.push(ch);
orig.push(unit);
} else if ch == '\\' {
exp.push('\\');
orig.push(unit);
if let Some(&next) = chars.get(i + 1) {
exp.push(next);
orig.push(unit);
i += 1;
}
} else {
exp.push(ch);
orig.push(unit);
}
at[unit..=i].fill(start);
i += 1;
}
(exp, orig, at)
}
fn translate_class(
exp: &[char],
exp_orig: &[usize],
k0: usize,
pattern_len: usize,
reg: &mut String,
deferred_error: &mut Option<String>,
) -> (usize, bool) {
reg.push('[');
let mut k = k0;
if exp.get(k) == Some(&'^') {
reg.push('^');
k += 1;
}
let mut first = true;
let mut closed = None;
while k < exp.len() {
if exp[k] == ']' && !first {
reg.push(']');
closed = Some(k);
k += 1;
break;
}
first = false;
let lo_is_set = emit_class_member(exp, &mut k, reg, deferred_error);
if !lo_is_set
&& exp.get(k) == Some(&'-')
&& exp.get(k + 1).is_some_and(|&after| after != ']')
{
reg.push('-');
k += 1;
if exp[k] == '[' {
reg.push_str("\\[");
k += 1;
} else {
let escape = (exp[k] == '\\').then(|| exp.get(k + 1).copied()).flatten();
if emit_class_member(exp, &mut k, reg, deferred_error) {
if let Some(next) = escape {
deferred_error.get_or_insert(format!("invalid escape sequence: \\{next}"));
}
}
}
}
}
let mut last_was_repetition = false;
if let Some(closer) = closed {
while k < exp.len() && exp_orig[k] == exp_orig[closer] {
reg.push(exp[k]);
last_was_repetition = exp[k] == '*';
k += 1;
}
}
(
if k < exp.len() {
exp_orig[k]
} else {
pattern_len
},
last_was_repetition,
)
}
fn compile_pattern(pattern: &str) -> Result<Matcher, String> {
enum Kind {
Exact,
Prefix,
Suffix,
Regex,
}
let chars: Vec<char> = pattern.chars().collect();
let mut kind = Kind::Exact;
let mut reg = String::from("^");
let mut deferred_error: Option<String> = None;
let mut last_was_repetition = false;
let mut end_anchor_swallowed = false;
let mut expanded: Option<(Vec<char>, Vec<usize>, Vec<usize>)> = None;
let mut i = 0;
while i < chars.len() {
let ch = chars[i];
if ch == '*' && chars.get(i + 1) == Some(&'*') {
let leading = i == 0;
i += 1;
if chars.get(i + 1) == Some(&'/') {
i += 1;
}
if i + 1 >= chars.len() {
if matches!(kind, Kind::Exact) {
kind = Kind::Prefix;
} else {
reg.push_str(".*");
kind = Kind::Regex;
}
} else {
reg.push_str("(.*/)?");
kind = Kind::Regex;
}
if leading {
kind = Kind::Suffix;
}
last_was_repetition = true;
} else if ch == '*' {
reg.push_str("[^/]*");
kind = Kind::Regex;
last_was_repetition = true;
} else if ch == '?' {
reg.push_str("[^/]");
kind = Kind::Regex;
last_was_repetition = false;
} else if matches!(ch, '.' | '+' | '(' | ')' | '$') {
reg.push('\\');
reg.push(ch);
last_was_repetition = false;
} else if matches!(ch, '{' | '}' | '|') {
if ch == '{' {
if let Some((token_len, over_limit)) = scan_repetition(&chars[i..]) {
let token: String = chars[i..i + token_len].iter().collect();
if over_limit {
deferred_error.get_or_insert(format!("invalid repeat count: {token}"));
} else if last_was_repetition {
deferred_error
.get_or_insert(format!("invalid nested repetition operator: {token}"));
}
reg.push_str(&token);
i += token_len - 1;
last_was_repetition = true;
} else {
reg.push_str("\\{");
last_was_repetition = false;
}
} else {
reg.push(ch);
last_was_repetition = false;
}
} else if ch == '\\' {
if let Some(&next) = chars.get(i + 1) {
if go_rejects_numeric_escape(next, chars.get(i + 2).copied())
|| matches!(next, 'u' | 'U')
{
deferred_error.get_or_insert(format!("invalid escape sequence: \\{next}"));
}
if next == 'Q' {
let mut quoted = String::new();
let mut j = i + 2;
let mut terminated = false;
while j < chars.len() {
let qc = chars[j];
if qc == '*' && chars.get(j + 1) == Some(&'*') {
j += 1;
if chars.get(j + 1) == Some(&'/') {
j += 1;
}
if j + 1 >= chars.len() {
quoted.push_str(".*");
} else {
quoted.push_str("(.*/)?");
}
} else if qc == '*' {
quoted.push_str("[^/]*");
} else if qc == '?' {
quoted.push_str("[^/]");
} else if matches!(qc, '.' | '+' | '(' | ')' | '$') {
quoted.push('\\');
quoted.push(qc);
} else if qc == '\\' {
quoted.push('\\');
if let Some(&pair) = chars.get(j + 1) {
quoted.push(pair);
j += 1;
}
} else {
quoted.push(qc);
}
j += 1;
if quoted.ends_with("\\E") {
quoted.truncate(quoted.len() - 2);
terminated = true;
break;
}
}
if !terminated {
quoted.push('$');
end_anchor_swallowed = true;
}
reg.push_str(®ex::escape("ed));
i = j - 1;
} else if matches!(next, 'p' | 'P') {
i += emit_unicode_class(&chars, i, &mut reg, &mut deferred_error) - 1;
} else if next == 'x' && chars.get(i + 2) == Some(&'{') {
reg.push('\\');
reg.push(next);
i += 1;
while let Some(&braced) = chars.get(i + 1) {
reg.push(braced);
i += 1;
if braced == '}' {
break;
}
}
} else if let Some(replacement) = ascii_class_replacement(next) {
reg.push_str(replacement);
i += 1;
} else {
reg.push('\\');
reg.push(next);
i += 1;
}
kind = Kind::Regex;
} else {
reg.push('\\');
}
last_was_repetition = false;
} else if ch == '[' {
let (exp, exp_orig, exp_at) =
expanded.get_or_insert_with(|| expand_for_class_scan(&chars));
let (resume, was_repetition) = translate_class(
exp,
exp_orig,
exp_at[i] + 1,
chars.len(),
&mut reg,
&mut deferred_error,
);
kind = Kind::Regex;
last_was_repetition = was_repetition;
i = resume;
continue;
} else if ch == ']' {
reg.push(ch);
kind = Kind::Regex;
last_was_repetition = false;
} else {
reg.push(ch);
last_was_repetition = false;
}
i += 1;
}
Ok(match kind {
Kind::Exact => Matcher::Exact(pattern.to_string()),
Kind::Prefix => Matcher::Prefix(pattern[..pattern.len() - 2].to_string()),
Kind::Suffix => Matcher::Suffix(pattern[2..].to_string()),
Kind::Regex => {
if let Some(err) = deferred_error {
return Err(err);
}
if !end_anchor_swallowed {
reg.push('$');
}
regex::RegexBuilder::new(®)
.octal(true)
.build()
.map(Matcher::Regex)
.map_err(|err| err.to_string())?
}
})
}
#[cfg(test)]
mod tests {
use super::*;
fn matcher(patterns: &[&str]) -> DockerPatternMatcher {
let owned: Vec<String> = patterns.iter().map(ToString::to_string).collect();
DockerPatternMatcher::new(&owned).expect("valid patterns")
}
#[test]
fn read_patterns_normalizes_like_docker() {
let content =
b"\xEF\xBB\xBF*.log\r\n# comment\n\n spaced \n/rooted\ndir/\n! keep.log\na/./b//c\n";
assert_eq!(
read_patterns(content).unwrap(),
["*.log", "spaced", "rooted", "dir", "!keep.log", "a/b/c"]
);
}
#[test]
fn read_patterns_windows_treats_backslash_as_separator() {
let content =
b"build\\output\nmixed/sub\\dir\na\\..\\b\n\\rooted\nfoo\\\n!build\\output\\keep.txt\n";
assert_eq!(
read_patterns_for(content, true).unwrap(),
[
"build/output",
"mixed/sub/dir",
"b",
"rooted",
"foo",
"!build/output/keep.txt"
]
);
assert_eq!(
read_patterns_for(content, false).unwrap(),
[
"build\\output",
"mixed/sub\\dir",
"a\\..\\b",
"\\rooted",
"foo\\",
"!build\\output\\keep.txt"
]
);
}
#[test]
fn read_patterns_windows_keeps_volume_patterns_inert() {
let patterns = read_patterns_for(
b"C:\\..\\foo\n\\\\server\\share\\build\nC:\\logs\n",
true,
)
.unwrap();
assert_eq!(patterns, ["C:/foo", "/server/share/build", "C:/logs"]);
let m = DockerPatternMatcher::new(&patterns).expect("valid patterns");
assert!(!m.matches("foo").unwrap());
assert!(!m.matches("server/share/build").unwrap());
assert!(!m.matches("logs").unwrap());
}
#[test]
fn windows_read_patterns_drive_docker_windows_semantics() {
let patterns = read_patterns_for(
b"build\\output\n!build\\output\\keep.txt\n",
true,
)
.unwrap();
let m = DockerPatternMatcher::new(&patterns).expect("valid patterns");
assert!(m.matches("build/output/app.bin").unwrap());
assert!(m.matches("build/output").unwrap());
assert!(!m.matches("build/output/keep.txt").unwrap());
assert!(!m.matches("buildoutput").unwrap());
}
#[test]
fn character_classes_are_regex_classes_not_gitignore() {
let m = matcher(&["[!a].txt"]);
assert!(m.matches("a.txt").unwrap());
assert!(m.matches("!.txt").unwrap());
assert!(!m.matches("b.txt").unwrap());
let m = matcher(&["[^a].txt"]);
assert!(!m.matches("a.txt").unwrap());
assert!(m.matches("b.txt").unwrap());
}
#[test]
fn single_star_is_anchored_to_one_segment() {
let m = matcher(&["*.log"]);
assert!(m.matches("app.log").unwrap());
assert!(!m.matches("sub/app.log").unwrap());
let m = matcher(&["**/*.log"]);
assert!(m.matches("app.log").unwrap());
assert!(m.matches("sub/app.log").unwrap());
assert!(m.matches("a/b/app.log").unwrap());
}
#[test]
fn question_mark_matches_one_non_separator_character() {
let m = matcher(&["?.txt"]);
assert!(m.matches("a.txt").unwrap());
assert!(!m.matches("ab.txt").unwrap());
assert!(!m.matches("/.txt").unwrap());
}
#[test]
fn a_matching_parent_directory_excludes_the_file() {
let m = matcher(&["logs"]);
assert!(m.matches("logs").unwrap());
assert!(m.matches("logs/app.log").unwrap());
assert!(m.matches("logs/deep/nested.log").unwrap());
assert!(!m.matches("logstash").unwrap());
let m = matcher(&["a/b"]);
assert!(m.matches("a/b/c/d").unwrap());
assert!(!m.matches("a/c").unwrap());
}
#[test]
fn every_ancestor_directory_is_checked_for_a_match() {
let m = matcher(&["**/dropdir", "!**/dropdir/keep.txt"]);
assert!(m.matches("a/b/dropdir/drop.txt").unwrap());
assert!(!m.matches("a/b/dropdir/keep.txt").unwrap());
assert!(m.matches("dropdir/drop.txt").unwrap());
assert!(!m.matches("dropdir/keep.txt").unwrap());
}
#[test]
fn docker_escaped_metacharacters_are_literal() {
let m = matcher(&["file+name"]);
assert!(m.matches("file+name").unwrap());
assert!(!m.matches("filename").unwrap());
assert!(!m.matches("fileename").unwrap());
let m = matcher(&["a|b"]);
assert!(m.matches("a|b").unwrap());
assert!(!m.matches("a").unwrap());
assert!(!m.matches("b").unwrap());
let m = matcher(&["a(b)"]);
assert!(m.matches("a(b)").unwrap());
assert!(!m.matches("ab").unwrap());
let m = matcher(&["x{2}"]);
assert!(m.matches("x{2}").unwrap());
assert!(!m.matches("xx").unwrap());
let m = matcher(&["file+name*"]);
assert!(m.matches("file+names").unwrap());
assert!(!m.matches("filename").unwrap());
let m = matcher(&["a|b*"]);
assert!(m.matches("a|bc").unwrap());
assert!(m.matches("a").unwrap());
assert!(m.matches("bx").unwrap());
assert!(!m.matches("c").unwrap());
let m = matcher(&["a(b)*"]);
assert!(m.matches("a(b)c").unwrap());
assert!(!m.matches("ab").unwrap());
let m = matcher(&["x{2}*"]);
assert!(m.matches("xx").unwrap());
assert!(m.matches("xxy").unwrap());
assert!(!m.matches("x{2}y").unwrap());
let m = matcher(&["a$b*"]);
assert!(m.matches("a$b").unwrap());
assert!(m.matches("a$bc").unwrap());
}
#[test]
fn non_trailing_double_star_stops_at_a_segment_boundary() {
let m = matcher(&["**/foo"]);
assert!(m.matches("foo").unwrap());
assert!(m.matches("a/foo").unwrap());
assert!(m.matches("a/b/foo").unwrap());
assert!(!m.matches("xfoo").unwrap());
assert!(!m.matches("a/xfoo").unwrap());
let m = matcher(&["a/**/b"]);
assert!(m.matches("a/b").unwrap());
assert!(m.matches("a/x/b").unwrap());
assert!(m.matches("a/x/y/b").unwrap());
assert!(!m.matches("a/xb").unwrap());
}
#[test]
fn leading_double_star_without_separator_is_a_suffix_match() {
let m = matcher(&["**foo"]);
assert!(m.matches("foo").unwrap());
assert!(m.matches("xfoo").unwrap());
assert!(m.matches("a/xfoo").unwrap());
let m = matcher(&["**foo*"]);
assert!(m.matches("foo").unwrap());
assert!(m.matches("a/foobar").unwrap());
assert!(!m.matches("xfoo").unwrap());
}
#[test]
fn exclusion_reincludes_under_an_excluded_directory() {
let m = matcher(&["logs", "!logs/keep.log"]);
assert!(m.matches("logs/drop.log").unwrap());
assert!(!m.matches("logs/keep.log").unwrap());
assert!(m.has_exclusions());
assert!(!matcher(&["logs"]).has_exclusions());
}
#[test]
fn last_matching_pattern_decides() {
let m = matcher(&["*.log", "!keep.log", "keep.*"]);
assert!(m.matches("keep.log").unwrap());
let m = matcher(&["*.log", "keep.*", "!keep.log"]);
assert!(!m.matches("keep.log").unwrap());
}
#[test]
fn trailing_double_star_accepts_everything_below() {
let m = matcher(&["foo/**"]);
assert!(m.matches("foo/a").unwrap());
assert!(m.matches("foo/a/b/c").unwrap());
assert!(!m.matches("foo").unwrap());
assert!(!m.matches("bar/a").unwrap());
}
#[test]
fn dot_is_literal() {
let m = matcher(&["a.txt"]);
assert!(m.matches("a.txt").unwrap());
assert!(!m.matches("axtxt").unwrap());
let m = matcher(&["a.txt*"]);
assert!(m.matches("a.txt").unwrap());
assert!(!m.matches("axtxt").unwrap());
}
#[test]
fn caret_is_literal_in_string_compared_patterns() {
let m = matcher(&["foo^bar"]);
assert!(m.matches("foo^bar").unwrap());
assert!(m.matches("foo^bar/sub.txt").unwrap());
assert!(!m.matches("foobar").unwrap());
let m = matcher(&["foo^bar/**"]);
assert!(m.matches("foo^bar/x").unwrap());
assert!(!m.matches("foo^bar").unwrap());
let m = matcher(&["**foo^bar"]);
assert!(m.matches("xfoo^bar").unwrap());
assert!(m.matches("a/xfoo^bar").unwrap());
let m = matcher(&["foo^*"]);
assert!(!m.matches("foo^").unwrap());
assert!(!m.matches("foo^x").unwrap());
assert!(!m.matches("foox").unwrap());
}
#[test]
fn backslash_escapes_the_next_character() {
let m = matcher(&["a\\*b"]);
assert!(m.matches("a*b").unwrap());
assert!(!m.matches("axb").unwrap());
}
#[test]
fn bare_exclusion_is_an_error() {
let err = DockerPatternMatcher::new(&["!".to_string()])
.map(|_| ())
.unwrap_err();
assert!(err.contains("illegal exclusion pattern"), "{err}");
}
#[test]
fn invalid_patterns_are_errors() {
let err = DockerPatternMatcher::new(&["a[".to_string()])
.map(|_| ())
.unwrap_err();
assert!(err.contains("invalid ignore pattern"), "{err}");
}
#[test]
fn pattern_screening_matches_docker() {
for bad in [
"foo\\", "*foo\\", "*\\", "[ab\\", "[", "[a", "a[", "[]", "[a-]", "[-a]", "[a-b",
"[^]", ".b\\", "?x\\", ".*b\\", "*b\\", "**\\", "[a][", "?[",
] {
let err = DockerPatternMatcher::new(&[bad.to_string()])
.map(|_| ())
.unwrap_err();
assert!(err.contains("syntax error in pattern"), "{bad}: {err}");
}
for good in [
"[!a]", "[^a]", "[a-z]", "[\\]]", "[\\-a]", "a\\*b", "a-b", "a]b", "[a]", "a**\\",
"[a]*x\\", "a*[a-]", "x*[-a]",
] {
assert!(
DockerPatternMatcher::new(&[good.to_string()]).is_ok(),
"{good}"
);
}
for late in ["a*[", "[a-z]*[", "x*[]"] {
let err = matcher(&[late]).matches("anything").unwrap_err();
assert!(!err.contains("syntax error in pattern"), "{late}: {err}");
assert!(err.contains("invalid ignore pattern"), "{late}: {err}");
}
let m = matcher(&["a*b\\"]);
assert!(m.matches("axb$").unwrap());
assert!(!m.matches("axb\\").unwrap());
assert!(!m.matches("axb").unwrap());
}
#[test]
fn brace_and_alternation_follow_go_regexp() {
let m = matcher(&["a{b*"]);
assert!(m.matches("a{bc").unwrap());
assert!(!m.matches("ab").unwrap());
let m = matcher(&["a{,2}b*"]);
assert!(m.matches("a{,2}bc").unwrap());
let m = matcher(&["x{01}*"]);
assert!(m.matches("x{01}").unwrap());
assert!(!m.matches("x").unwrap());
let m = matcher(&["{a}*"]);
assert!(m.matches("{a}").unwrap());
let m = matcher(&["x{1,3}*"]);
assert!(m.matches("xxx").unwrap());
assert!(!m.matches("y").unwrap());
let m = matcher(&["x{0}y*"]);
assert!(m.matches("y").unwrap());
assert!(!m.matches("xy").unwrap());
let m = matcher(&["x{1000}*"]);
assert!(m.matches(&"x".repeat(1000)).unwrap());
let err = matcher(&["x{1001}*"]).matches("x").unwrap_err();
assert!(err.contains("invalid repeat count"), "{err}");
let err = matcher(&["x*{2}"]).matches("x").unwrap_err();
assert!(err.contains("nested repetition"), "{err}");
let m = matcher(&["x{2}{2}"]);
assert!(m.matches("x{2}{2}").unwrap());
assert!(!m.matches("xxxx").unwrap());
let m = matcher(&["\\p{L}*"]);
assert!(m.matches("a").unwrap());
assert!(!m.matches("1").unwrap());
}
#[test]
fn perl_class_shorthands_are_ascii_like_go() {
let m = matcher(&["\\d*"]);
assert!(m.matches("7").unwrap());
assert!(!m.matches("١").unwrap());
let m = matcher(&["\\D*"]);
assert!(m.matches("١").unwrap());
assert!(!m.matches("7").unwrap());
let m = matcher(&["\\s*"]);
assert!(m.matches(" ").unwrap());
assert!(!m.matches("\u{0B}").unwrap());
assert!(!m.matches("\u{A0}").unwrap());
let m = matcher(&["\\S*"]);
assert!(m.matches("\u{0B}").unwrap());
assert!(m.matches("\u{A0}").unwrap());
assert!(!m.matches(" ").unwrap());
let m = matcher(&["\\w*"]);
assert!(m.matches("a").unwrap());
assert!(!m.matches("é").unwrap());
let m = matcher(&["\\W*"]);
assert!(m.matches("é").unwrap());
assert!(!m.matches("a").unwrap());
}
#[test]
fn perl_class_shorthands_inside_brackets_are_ascii_like_go() {
let m = matcher(&["[\\d]*"]);
assert!(m.matches("7").unwrap());
assert!(!m.matches("١").unwrap());
let m = matcher(&["[\\D]*"]);
assert!(m.matches("x").unwrap());
assert!(m.matches("١").unwrap());
assert!(!m.matches("4").unwrap());
let m = matcher(&["a[\\s]b"]);
assert!(m.matches("a b").unwrap());
assert!(!m.matches("a\u{A0}b").unwrap());
let m = matcher(&["[x\\w]*"]);
assert!(m.matches("x").unwrap());
assert!(m.matches("a").unwrap());
assert!(!m.matches("é").unwrap());
}
#[test]
fn octal_escapes_read_like_go() {
let m = matcher(&["\\141*"]);
assert!(m.matches("abc").unwrap());
assert!(!m.matches("bcd").unwrap());
let m = matcher(&["\\14*"]);
assert!(m.matches("\u{c}x").unwrap());
assert!(!m.matches("14").unwrap());
}
#[test]
fn bracket_members_go_reads_literally_stay_literal() {
let m = matcher(&["[a&&b]"]);
assert!(m.matches("a").unwrap());
assert!(m.matches("&").unwrap());
assert!(m.matches("b").unwrap());
assert!(!m.matches("c").unwrap());
let m = matcher(&["[a~~b]"]);
assert!(m.matches("~").unwrap());
assert!(m.matches("a").unwrap());
assert!(!m.matches("c").unwrap());
let m = matcher(&["[a[b]"]);
assert!(m.matches("[").unwrap());
assert!(m.matches("a").unwrap());
assert!(!m.matches("c").unwrap());
let m = matcher(&["[\\?]"]);
assert!(m.matches("?").unwrap());
assert!(!m.matches("x").unwrap());
let m = matcher(&["[\\*]"]);
assert!(m.matches("*").unwrap());
}
#[test]
fn bracket_wildcards_read_as_their_translations() {
let m = matcher(&["[?]"]);
assert!(!m.matches("?").unwrap());
assert!(m.matches("^]").unwrap());
assert!(m.matches("[]").unwrap());
let m = matcher(&["[*]"]);
assert!(!m.matches("*").unwrap());
assert!(m.matches("]").unwrap());
assert!(m.matches("[[]").unwrap());
let m = matcher(&["[a?b]"]);
assert!(!m.matches("a").unwrap());
assert!(!m.matches("?").unwrap());
assert!(m.matches("ab]").unwrap());
let m = matcher(&["[a*b]"]);
assert!(m.matches("ab]").unwrap());
assert!(m.matches("aab]").unwrap());
assert!(m.matches("b]").unwrap());
assert!(!m.matches("ab").unwrap());
assert!(!m.matches("*").unwrap());
let m = matcher(&["x[?]y"]);
assert!(m.matches("x[]y").unwrap());
assert!(!m.matches("x?y").unwrap());
let m = matcher(&["[^?]"]);
assert!(m.matches("a]").unwrap());
assert!(!m.matches("a").unwrap());
let m = matcher(&["[??]"]);
assert!(m.matches("[a]").unwrap());
}
#[test]
fn bracket_double_star_and_ranges_read_as_their_translations() {
let m = matcher(&["[**]"]);
assert!(m.matches("(").unwrap());
assert!(m.matches("/").unwrap());
assert!(!m.matches("x").unwrap());
let m = matcher(&["[**/x]"]);
assert!(m.matches("x").unwrap());
assert!(m.matches("(").unwrap());
let m = matcher(&["[**-z]"]);
assert!(m.matches("A").unwrap());
let m = matcher(&["[?-z]"]);
assert!(m.matches("[-z]").unwrap());
assert!(!m.matches("a").unwrap());
let m = matcher(&["[A-?]"]);
assert!(m.matches("B]").unwrap());
let m = matcher(&["[A-*]"]);
assert!(m.matches("]").unwrap());
let err = matcher(&["[a-?]"]).matches("x").unwrap_err();
assert!(err.contains("invalid ignore pattern"), "{err}");
let m = matcher(&["[*]{2}"]);
assert!(m.matches("]]").unwrap());
let err = matcher(&["[*{2}]"]).matches("x").unwrap_err();
assert!(err.contains("invalid nested repetition"), "{err}");
let m = matcher(&["[A-[:alpha:]]"]);
assert!(m.matches("B]").unwrap());
assert!(m.matches(":]").unwrap());
}
#[test]
fn whole_set_range_endpoints_are_errors() {
let err = matcher(&["z*[--\\d]"]).matches("za").unwrap_err();
assert!(err.contains("invalid escape sequence"), "{err}");
let err = matcher(&["z*[a-\\p{L}]"]).matches("za").unwrap_err();
assert!(err.contains("invalid escape sequence"), "{err}");
let m = matcher(&["*", "z*[--\\d]"]);
assert!(m.matches("za").unwrap());
let m = matcher(&["[\\d-x]"]);
assert!(m.matches("-").unwrap());
let m = matcher(&["[\\pL-z]"]);
assert!(m.matches("a").unwrap());
}
#[test]
fn bracket_dashes_and_first_bracket_follow_go_positions() {
let m = matcher(&["z*[--b]"]);
assert!(m.matches("z0").unwrap());
assert!(m.matches("z-").unwrap());
assert!(m.matches("zb").unwrap());
assert!(!m.matches("zc").unwrap());
let err = matcher(&["z*[a--b]"]).matches("z").unwrap_err();
assert!(err.contains("invalid ignore pattern"), "{err}");
let m = matcher(&["z*[a-]"]);
assert!(m.matches("za").unwrap());
assert!(m.matches("z-").unwrap());
assert!(!m.matches("zb").unwrap());
let m = matcher(&["z*[]a]"]);
assert!(m.matches("z]").unwrap());
assert!(m.matches("za").unwrap());
assert!(!m.matches("zb").unwrap());
let m = matcher(&["z*[]-a]"]);
assert!(m.matches("z^").unwrap());
assert!(m.matches("z]").unwrap());
assert!(!m.matches("zb").unwrap());
}
#[test]
fn escapes_go_rejects_are_errors() {
for pattern in ["\\1", "z*[\\1]", "\\8", "a[\\b]"] {
let err = matcher(&[pattern]).matches("anything").unwrap_err();
assert!(err.contains("invalid escape sequence"), "{pattern}: {err}");
}
}
#[test]
fn quoted_spans_quote_the_translated_text() {
let m = matcher(&["\\Qfoo.bar\\E*"]);
assert!(m.matches("foo\\.bar").unwrap());
assert!(m.matches("foo\\.barX").unwrap());
assert!(!m.matches("foo.bar").unwrap());
let m = matcher(&["\\Qa*b\\E"]);
assert!(m.matches("a[^/]*b").unwrap());
assert!(!m.matches("axb").unwrap());
assert!(!m.matches("a*b").unwrap());
let m = matcher(&["\\Q**\\Ex"]);
assert!(m.matches("(.*/)?x").unwrap());
assert!(!m.matches("x").unwrap());
assert!(!m.matches("**x").unwrap());
let m = matcher(&["\\Q+\\E"]);
assert!(m.matches("\\+").unwrap());
assert!(!m.matches("+").unwrap());
let m = matcher(&["\\Qa\\\\Eb"]);
assert!(m.matches("a\\b").unwrap());
assert!(!m.matches("ab").unwrap());
let m = matcher(&["\\Qa\\E*\\Qb\\E"]);
assert!(m.matches("ab").unwrap());
assert!(m.matches("axb").unwrap());
assert!(!m.matches("ax/b").unwrap());
let m = matcher(&["\\Qa\\E{2}"]);
assert!(m.matches("aa").unwrap());
assert!(!m.matches("a").unwrap());
let m = matcher(&["x\\Q\\Ey"]);
assert!(m.matches("xy").unwrap());
assert!(!m.matches("xay").unwrap());
let m = matcher(&["\\Qα β\\E"]);
assert!(m.matches("α β").unwrap());
}
#[test]
fn unterminated_quoted_spans_swallow_the_anchor() {
let m = matcher(&["\\Qfoo"]);
assert!(m.matches("foo$").unwrap());
assert!(m.matches("foo$x").unwrap());
assert!(!m.matches("foo").unwrap());
assert!(!m.matches("xfoo$").unwrap());
let m = matcher(&["\\Q**"]);
assert!(m.matches(".*$").unwrap());
assert!(m.matches(".*$zz").unwrap());
assert!(!m.matches("ab").unwrap());
}
#[test]
fn unicode_escapes_are_go_errors() {
for pattern in ["\\u0041*", "z*[\\u0041]", "\\U00000041*"] {
let err = matcher(&[pattern]).matches("A").unwrap_err();
assert!(err.contains("invalid escape sequence"), "{pattern}: {err}");
}
let err = matcher(&["a[\\Qx\\E]"]).matches("ax").unwrap_err();
assert!(err.contains("invalid ignore pattern"), "{err}");
}
#[test]
fn posix_classes_carry_through_brackets() {
let m = matcher(&["[[:digit:]]*"]);
assert!(m.matches("4").unwrap());
assert!(!m.matches("x").unwrap());
let m = matcher(&["a[[:alpha:]7]"]);
assert!(m.matches("ax").unwrap());
assert!(m.matches("a7").unwrap());
assert!(!m.matches("a8").unwrap());
}
#[test]
fn defective_patterns_error_on_first_consult_not_construction() {
let m = matcher(&["*", "[z-a]"]);
assert!(m.matches("a").unwrap());
let m = matcher(&["nomatch", "![z-a]"]);
assert!(!m.matches("a").unwrap());
let err = matcher(&["[z-a]"]).matches("a").unwrap_err();
assert!(err.contains("invalid ignore pattern \"[z-a]\""), "{err}");
}
#[test]
fn repetition_inside_a_class_is_literal() {
let m = matcher(&["[[:alpha:]{1001}]*"]);
assert!(m.matches("a").unwrap());
assert!(m.matches("{x").unwrap());
assert!(m.matches("1").unwrap());
assert!(!m.matches("2").unwrap());
}
#[test]
fn unicode_class_names_follow_go_not_rust() {
let m = matcher(&["\\p{Greek}*"]);
assert!(m.matches("αβ").unwrap());
assert!(!m.matches("ab").unwrap());
let m = matcher(&["\\p{GREEK}*"]);
assert!(m.matches("α").unwrap());
let m = matcher(&["\\p{Lowercase_Letter}*"]);
assert!(m.matches("a").unwrap());
assert!(!m.matches("A").unwrap());
let m = matcher(&["\\p{digit}*"]);
assert!(m.matches("7").unwrap());
let m = matcher(&["\\pl*"]);
assert!(m.matches("a").unwrap());
assert!(!m.matches("7").unwrap());
let m = matcher(&["\\p{Assigned}*"]);
assert!(m.matches("a").unwrap());
assert!(!m.matches("\u{0378}").unwrap());
let m = matcher(&["\\P{^Greek}*"]);
assert!(m.matches("α").unwrap());
assert!(!m.matches("a").unwrap());
let m = matcher(&["\\p{Cs}*"]);
assert!(!m.matches("a").unwrap());
let m = matcher(&["\\P{Cs}*"]);
assert!(m.matches("a").unwrap());
for bad in [
"\\p{Emoji}*",
"\\p{ASCII_Hex_Digit}*",
"\\p{Old_Italic}*",
"\\p{word}*",
"\\pE*",
"\\p{}*",
"\\p{Greek",
] {
let err = matcher(&[bad]).matches("a").unwrap_err();
assert!(
err.contains("invalid character class range"),
"{bad}: {err}"
);
}
}
#[test]
fn unicode_classes_inside_brackets_are_whole_members() {
let m = matcher(&["[\\p{Greek}-x]*"]);
assert!(m.matches("α").unwrap());
assert!(m.matches("-").unwrap());
assert!(m.matches("x").unwrap());
assert!(!m.matches("z").unwrap());
let m = matcher(&["[\\p{Greek}x]*"]);
assert!(m.matches("α").unwrap());
assert!(m.matches("x").unwrap());
assert!(!m.matches("z").unwrap());
let err = matcher(&["[\\p{Emoji}]"]).matches("a").unwrap_err();
assert!(err.contains("invalid character class range"), "{err}");
}
#[test]
fn posix_class_names_follow_go() {
let m = matcher(&["[[:^alpha:]]*"]);
assert!(m.matches("7").unwrap());
assert!(!m.matches("x").unwrap());
for bad in ["[[:bogus:]]*", "[[:Alpha:]]*", "[[:^bogus:]]*"] {
let err = matcher(&[bad]).matches("a").unwrap_err();
assert!(
err.contains("invalid character class range"),
"{bad}: {err}"
);
}
}
#[test]
fn overlong_lines_are_refused_like_docker() {
let max = "a".repeat(64 * 1024 - 1);
let content = format!("{max}\n");
assert_eq!(read_patterns(content.as_bytes()).unwrap(), [max.as_str()]);
let content = format!("a{max}\n");
let err = read_patterns(content.as_bytes()).unwrap_err();
assert!(err.contains("line 1"), "{err}");
}
#[test]
fn clean_resolves_dots_and_separators() {
assert_eq!(clean("a/./b//c"), "a/b/c");
assert_eq!(clean("a/b/../c"), "a/c");
assert_eq!(clean("dir/"), "dir");
assert_eq!(clean("./a"), "a");
assert_eq!(clean(".."), "..");
assert_eq!(clean(""), ".");
assert_eq!(clean("/a/b"), "/a/b");
}
}