use std::path::Path;
use globset::{Glob, GlobSet, GlobSetBuilder};
use crate::RpoError;
const DEFAULT_EXCLUDES: &[&str] = &[
"**/Cargo.lock",
"**/package-lock.json",
"**/yarn.lock",
"**/pnpm-lock.yaml",
"**/poetry.lock",
"**/Pipfile.lock",
"**/uv.lock",
"**/Gemfile.lock",
"**/composer.lock",
"**/go.sum",
];
#[derive(Clone, Debug, Default)]
pub struct LinguistAttrs {
pub generated: Vec<String>, pub vendored: Vec<String>,
}
impl LinguistAttrs {
pub fn parse(bytes: &[u8]) -> Self {
let text = std::str::from_utf8(bytes).unwrap_or("");
let mut out = Self::default();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut parts = line.split_whitespace();
let Some(pattern) = parts.next() else {
continue;
};
for attr in parts {
match attr {
"linguist-generated=true" | "linguist-generated" => {
out.generated.push(pattern.to_string());
}
"linguist-vendored=true" | "linguist-vendored" => {
out.vendored.push(pattern.to_string());
}
_ => {}
}
}
}
out
}
}
pub struct FilterSet {
default_excludes: GlobSet,
user_includes: Option<GlobSet>,
user_excludes: Option<GlobSet>,
generated: GlobSet,
vendored: GlobSet,
respect_attrs: bool,
}
impl FilterSet {
pub fn build(
user_includes: &[String],
user_excludes: &[String],
attrs: Option<LinguistAttrs>,
respect_attrs: bool,
) -> Result<Self, RpoError> {
Ok(Self {
default_excludes: build_globs(DEFAULT_EXCLUDES.iter().map(|s| s.to_string()))?,
user_includes: if user_includes.is_empty() {
None
} else {
Some(build_globs(user_includes.iter().cloned())?)
},
user_excludes: if user_excludes.is_empty() {
None
} else {
Some(build_globs(user_excludes.iter().cloned())?)
},
generated: build_globs(
attrs
.as_ref()
.map(|a| a.generated.clone())
.unwrap_or_default(),
)?,
vendored: build_globs(
attrs
.as_ref()
.map(|a| a.vendored.clone())
.unwrap_or_default(),
)?,
respect_attrs,
})
}
pub fn changes_include(&self, path: &Path) -> bool {
if let Some(ex) = &self.user_excludes
&& ex.is_match(path)
{
return false;
}
if let Some(inc) = &self.user_includes
&& !inc.is_match(path)
{
return false;
}
true
}
pub fn classify(&self, path: &Path) -> PathClassification {
let is_generated = self.respect_attrs && self.generated.is_match(path);
let is_vendored = self.respect_attrs && self.vendored.is_match(path);
PathClassification {
is_generated,
is_vendored,
}
}
pub fn blame_includes(&self, path: &Path) -> bool {
if self.default_excludes.is_match(path) {
return false;
}
if self.respect_attrs && self.generated.is_match(path) {
return false;
}
if self.respect_attrs && self.vendored.is_match(path) {
return false;
}
if let Some(ex) = &self.user_excludes
&& ex.is_match(path)
{
return false;
}
if let Some(inc) = &self.user_includes
&& !inc.is_match(path)
{
return false;
}
true
}
}
#[derive(Clone, Copy, Debug)]
pub struct PathClassification {
pub is_generated: bool,
pub is_vendored: bool,
}
fn build_globs(patterns: impl IntoIterator<Item = String>) -> Result<GlobSet, RpoError> {
let mut b = GlobSetBuilder::new();
for p in patterns {
let glob = Glob::new(&p).map_err(|source| RpoError::InvalidGlob {
pattern: p.clone(),
source,
})?;
b.add(glob);
}
b.build().map_err(|source| RpoError::InvalidGlob {
pattern: "(set build)".into(),
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
#[test]
fn lockfiles_excluded_by_default() {
let f = FilterSet::build(&[], &[], None, true).unwrap();
assert!(!f.blame_includes(Path::new("Cargo.lock")));
assert!(!f.blame_includes(Path::new("app/package-lock.json")));
assert!(f.blame_includes(Path::new("src/main.rs")));
}
#[test]
fn user_include_restricts() {
let f = FilterSet::build(&["src/**/*.rs".into()], &[], None, true).unwrap();
assert!(f.blame_includes(Path::new("src/main.rs")));
assert!(!f.blame_includes(Path::new("docs/readme.md")));
}
#[test]
fn user_exclude_overrides_include() {
let f =
FilterSet::build(&["src/**".into()], &["src/generated/**".into()], None, true).unwrap();
assert!(f.blame_includes(Path::new("src/lib.rs")));
assert!(!f.blame_includes(Path::new("src/generated/model.rs")));
}
#[test]
fn linguist_generated_excluded_when_respected() {
let attrs = LinguistAttrs {
generated: vec!["**/*.pb.go".into()],
vendored: vec![],
};
let f = FilterSet::build(&[], &[], Some(attrs.clone()), true).unwrap();
assert!(!f.blame_includes(Path::new("api/foo.pb.go")));
let f_ignore_attrs = FilterSet::build(&[], &[], Some(attrs), false).unwrap();
assert!(f_ignore_attrs.blame_includes(Path::new("api/foo.pb.go")));
}
#[test]
fn classify_reports_flags() {
let attrs = LinguistAttrs {
generated: vec!["**/*.pb.go".into()],
vendored: vec!["vendor/**".into()],
};
let f = FilterSet::build(&[], &[], Some(attrs), true).unwrap();
let c = f.classify(Path::new("api/foo.pb.go"));
assert!(c.is_generated);
assert!(!c.is_vendored);
let c2 = f.classify(Path::new("vendor/lib.rs"));
assert!(!c2.is_generated);
assert!(c2.is_vendored);
}
#[test]
fn gitattributes_parser_handles_comments_and_blanks() {
let bytes =
b"# comment\n\n*.pb.go linguist-generated=true\nvendor/** linguist-vendored=true\n";
let attrs = LinguistAttrs::parse(bytes);
assert_eq!(attrs.generated, vec!["*.pb.go".to_string()]);
assert_eq!(attrs.vendored, vec!["vendor/**".to_string()]);
}
}