use std::fs;
use std::io;
use std::path::{Component, Path, PathBuf};
use crate::error::Error;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Pattern {
Anywhere(String),
Anchored(PathBuf),
}
pub fn shepignore_patterns(checkout: &Path) -> Result<Vec<Pattern>, Error> {
let path = checkout.join(".shepignore");
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(source) if source.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(source) => return Err(Error::Io { path, source }),
};
text.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(parse_pattern)
.collect()
}
fn parse_pattern(line: &str) -> Result<Pattern, Error> {
let refuse = |why: &str| {
Error::Config(format!(
".shepignore pattern {line:?} {why} - a .shepignore pattern is a bare name \
(matches at any depth) or a path containing `/` (anchored to the checkout root), \
nothing else"
))
};
if line.contains(['*', '?', '[']) {
return Err(refuse(
"uses glob syntax (`*`, `?`, `[`), which is not supported",
));
}
if line.starts_with('!') {
return Err(refuse(
"is a negation, which is not supported: everything is shared unless named here. A \
name that really begins with `!` is written `\\!name`, as in .gitignore",
));
}
let unescaped;
let line = match line.strip_prefix("\\!") {
Some(rest) => {
unescaped = format!("!{rest}");
unescaped.as_str()
}
None => line,
};
if line.contains('\\') {
return Err(refuse(
"carries a backslash; the only escape this file reads is a leading `\\!`",
));
}
let anchored_by_slash = line.starts_with('/');
let mut body = line;
loop {
let stripped = body.trim_start_matches('/').trim_start_matches("./");
if stripped == body {
break;
}
body = stripped;
}
let body = body.trim_end_matches('/');
if body.is_empty()
|| Path::new(body)
.components()
.any(|component| component == Component::CurDir)
{
return Err(refuse("names nothing"));
}
if Path::new(body)
.components()
.any(|component| component == Component::ParentDir)
{
return Err(refuse(
"climbs out of the checkout with `..`, so it cannot name anything in it",
));
}
if anchored_by_slash || body.contains('/') {
Ok(Pattern::Anchored(PathBuf::from(body)))
} else {
Ok(Pattern::Anywhere(body.to_owned()))
}
}
pub(super) fn pattern_matches(path: &Path, pattern: &Pattern) -> bool {
match pattern {
Pattern::Anywhere(name) => path
.components()
.any(|component| component.as_os_str() == name.as_str()),
Pattern::Anchored(prefix) => path.starts_with(prefix),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use crate::fixtures;
fn fixture_repo(entries: &[(&str, &str)]) -> TempDir {
fixtures::checkout(entries)
}
#[test]
fn shepignore_patterns_skips_blank_lines_and_comments() {
let repo = fixture_repo(&[
(".gitignore", "dist/\n"),
(
".shepignore",
"# build output, never share this\n\ndist\n\n",
),
("dist/app.js", "//"),
]);
let patterns = shepignore_patterns(repo.path()).expect("reads");
assert_eq!(patterns, vec![Pattern::Anywhere("dist".to_owned())]);
}
#[test]
fn an_escaped_bang_names_the_file_and_a_lone_dot_is_refused() {
assert_eq!(
parse_pattern("\\!weird").expect("the escape is honoured"),
Pattern::Anywhere("!weird".to_owned())
);
for pattern in [".", "./.", "./", ".//", "/./"] {
let err = parse_pattern(pattern).expect_err(pattern);
assert!(
err.to_string().contains("names nothing"),
"{pattern}: {err}"
);
}
assert_eq!(
parse_pattern("./dist").expect("a leading ./ is dropped"),
Pattern::Anywhere("dist".to_owned())
);
assert_eq!(
parse_pattern("/./x").expect("a leading /./ is an anchor"),
Pattern::Anchored(PathBuf::from("x"))
);
assert_eq!(
parse_pattern(".//x").expect("stripped to a fixed point"),
Pattern::Anywhere("x".to_owned())
);
let err = parse_pattern("\\#name").expect_err("an escape this file does not read");
assert!(err.to_string().contains("backslash"), "{err}");
}
#[test]
fn a_negation_a_parent_component_and_a_bare_slash_are_refused_by_name() {
for pattern in ["!dist", "../sibling", "/", "./"] {
let repo = fixture_repo(&[(".gitignore", "dist/\n"), (".shepignore", pattern)]);
let err = shepignore_patterns(repo.path())
.expect_err(&format!("{pattern:?} must be refused"));
assert!(matches!(err, Error::Config(_)), "{pattern:?}: {err}");
assert!(
err.to_string().contains(pattern),
"the refusal must name the pattern: {err}"
);
}
}
#[test]
fn shepignore_refuses_a_pattern_with_an_asterisk() {
let repo = fixture_repo(&[
(".gitignore", "dist/\n"),
(".shepignore", "*.log\n"),
("dist/app.js", "//"),
]);
let err = shepignore_patterns(repo.path()).expect_err("must refuse a glob pattern");
assert!(matches!(err, Error::Config(_)));
assert!(err.to_string().contains("*.log"));
}
#[test]
fn shepignore_refuses_a_pattern_with_a_question_mark() {
let repo = fixture_repo(&[
(".gitignore", "dist/\n"),
(".shepignore", "cache?.tmp\n"),
("dist/app.js", "//"),
]);
let err = shepignore_patterns(repo.path()).expect_err("must refuse a glob pattern");
assert!(matches!(err, Error::Config(_)));
assert!(err.to_string().contains("cache?.tmp"));
}
#[test]
fn shepignore_refuses_a_pattern_with_a_bracket_class() {
let repo = fixture_repo(&[
(".gitignore", "dist/\n"),
(".shepignore", "cache[0-9].tmp\n"),
("dist/app.js", "//"),
]);
let err = shepignore_patterns(repo.path()).expect_err("must refuse a glob pattern");
assert!(matches!(err, Error::Config(_)));
assert!(err.to_string().contains("cache[0-9].tmp"));
}
}