//! `.oxyattributes` (spec ยง19): per-path attributes in gitattributes syntax. //! Each line is `pattern attr...` where an attribute is `binary`, `text` //! (or `-binary`), `eol=lf`, `eol=crlf`, `nocompress`, or `lockable`. A //! pattern without a slash matches a basename anywhere below the file's //! directory; one with a slash is anchored to that directory. Later rules //! override earlier ones per attribute. use globset::{Glob, GlobMatcher}; use std::collections::BTreeMap; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Eol { Lf, Crlf, } /// The attributes in force for one path. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct Attrs { /// `Some(true)` forces opaque, `Some(false)` forces text (absent NUL bytes). pub binary: Option, pub eol: Option, pub nocompress: bool, pub lockable: bool, } struct Rule { matcher: GlobMatcher, attrs: Vec<(String, Option)>, } #[derive(Default)] pub struct Attributes { rules: Vec, } impl Attributes { /// Add the rules of one `.oxyattributes` file living in `dir` (`""` for the root). pub fn add_file(&mut self, dir: &str, text: &str) { 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; }; let attrs: Vec<(String, Option)> = parts .map(|a| match a.split_once('=') { Some((k, v)) => (k.to_string(), Some(v.to_string())), None => (a.to_string(), None), }) .collect(); let anchored = pattern.contains('/'); let core = pattern.trim_start_matches('/'); let full = match (dir.is_empty(), anchored) { (true, true) => core.to_string(), (true, false) => format!("**/{core}"), (false, true) => format!("{dir}/{core}"), (false, false) => format!("{dir}/**/{core}"), }; if let Ok(g) = Glob::new(&full) { self.rules.push(Rule { matcher: g.compile_matcher(), attrs, }); } } } /// Build from every `.oxyattributes` in `files` (path โ†’ content). pub fn from_files<'a>(files: impl IntoIterator) -> Attributes { let mut sorted: BTreeMap<&str, &[u8]> = BTreeMap::new(); for (p, c) in files { if p == ".oxyattributes" || p.ends_with("/.oxyattributes") { sorted.insert(p, c); } } let mut out = Attributes::default(); // Shallower files first so deeper ones override. let mut entries: Vec<(&str, &[u8])> = sorted.into_iter().collect(); entries.sort_by_key(|(p, _)| p.matches('/').count()); for (p, c) in entries { let dir = p .strip_suffix(".oxyattributes") .unwrap_or("") .trim_end_matches('/'); out.add_file(dir, &String::from_utf8_lossy(c)); } out } pub fn lookup(&self, path: &str) -> Attrs { let mut a = Attrs::default(); for r in &self.rules { if !r.matcher.is_match(path) { continue; } for (k, v) in &r.attrs { match (k.as_str(), v.as_deref()) { ("binary", None) => a.binary = Some(true), ("-binary", None) | ("text", None) => a.binary = Some(false), ("eol", Some("lf")) => a.eol = Some(Eol::Lf), ("eol", Some("crlf")) => a.eol = Some(Eol::Crlf), ("-eol", None) => a.eol = None, ("nocompress", None) => a.nocompress = true, ("-nocompress", None) => a.nocompress = false, ("lockable", None) => a.lockable = true, ("-lockable", None) => a.lockable = false, _ => {} } } } a } pub fn is_empty(&self) -> bool { self.rules.is_empty() } } /// `\r\n` โ†’ `\n`, as stored for `eol=` files. pub fn normalize_eol(bytes: &[u8]) -> Vec { let mut out = Vec::with_capacity(bytes.len()); let mut i = 0; while i < bytes.len() { if bytes[i] == b'\r' && bytes.get(i + 1) == Some(&b'\n') { i += 1; continue; } out.push(bytes[i]); i += 1; } out } /// `\n` โ†’ `\r\n` (lines already ending in `\r\n` are left alone). pub fn to_crlf(bytes: &[u8]) -> Vec { let mut out = Vec::with_capacity(bytes.len() + bytes.len() / 16); let mut prev = 0u8; for &b in bytes { if b == b'\n' && prev != b'\r' { out.push(b'\r'); } out.push(b); prev = b; } out } #[cfg(test)] mod tests { use super::*; #[test] fn parsing_and_precedence() { let a = Attributes::from_files([ (".oxyattributes", &b"*.png binary nocompress\n*.txt eol=lf\ndocs/*.txt eol=crlf\nmodels/** lockable\n"[..]), ("src/.oxyattributes", &b"*.dat binary\ngen.txt -eol\n"[..]), ("README.md", &b"not attributes"[..]), ]); assert_eq!( a.lookup("a/b/logo.png"), Attrs { binary: Some(true), eol: None, nocompress: true, lockable: false } ); assert_eq!(a.lookup("notes.txt").eol, Some(Eol::Lf)); assert_eq!(a.lookup("docs/notes.txt").eol, Some(Eol::Crlf)); assert!(a.lookup("models/x/y.bin").lockable); assert_eq!(a.lookup("src/deep/x.dat").binary, Some(true)); assert_eq!(a.lookup("other/x.dat").binary, None); assert_eq!(a.lookup("src/gen.txt").eol, None, "deeper file overrides"); assert_eq!(a.lookup("src/other.txt").eol, Some(Eol::Lf)); assert_eq!(a.lookup("plain"), Attrs::default()); } #[test] fn eol_conversions() { assert_eq!(normalize_eol(b"a\r\nb\nc\r\n"), b"a\nb\nc\n"); assert_eq!(to_crlf(b"a\nb\r\nc\n"), b"a\r\nb\r\nc\r\n"); assert_eq!(normalize_eol(&to_crlf(b"x\ny\n")), b"x\ny\n"); } }