| 1 | |
|
| 2 | |
|
| 3 | |
|
| 4 | |
|
| 5 | |
|
| 6 | |
|
| 7 | |
|
| 8 | | use globset::{Glob, GlobMatcher}; |
| 9 | | use std::collections::BTreeMap; |
| 10 | |
|
| 11 | | #[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 12 | | pub enum Eol { |
| 13 | | Lf, |
| 14 | | Crlf, |
| 15 | | } |
| 16 | |
|
| 17 | |
|
| 18 | | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] |
| 19 | | pub struct Attrs { |
| 20 | | |
| 21 | | pub binary: Option<bool>, |
| 22 | | pub eol: Option<Eol>, |
| 23 | | pub nocompress: bool, |
| 24 | | pub lockable: bool, |
| 25 | | } |
| 26 | |
|
| 27 | | struct Rule { |
| 28 | | matcher: GlobMatcher, |
| 29 | | attrs: Vec<(String, Option<String>)>, |
| 30 | | } |
| 31 | |
|
| 32 | | #[derive(Default)] |
| 33 | | pub struct Attributes { |
| 34 | | rules: Vec<Rule>, |
| 35 | | } |
| 36 | |
|
| 37 | | impl Attributes { |
| 38 | | |
| 39 | 34× | pub fn add_file(&mut self, dir: &str, text: &str) { |
| 40 | 68× | for line in text.lines() { |
| 41 | 68× | let line = line.trim(); |
| 42 | 68× | if line.is_empty() || line.starts_with('#') { |
| 43 | 0× | continue; |
| 44 | 68× | } |
| 45 | 68× | let mut parts = line.split_whitespace(); |
| 46 | 68× | let Some(pattern) = parts.next() else { |
| 47 | 0× | continue; |
| 48 | | }; |
| 49 | 68× | let attrs: Vec<(String, Option<String>)> = parts |
| 50 | 84× | .map(|a| match a.split_once('=') { |
| 51 | 20× | Some((k, v)) => (k.to_string(), Some(v.to_string())), |
| 52 | 64× | None => (a.to_string(), None), |
| 53 | 84× | }) |
| 54 | 68× | .collect(); |
| 55 | 68× | let anchored = pattern.contains('/'); |
| 56 | 68× | let core = pattern.trim_start_matches('/'); |
| 57 | 68× | let full = match (dir.is_empty(), anchored) { |
| 58 | 17× | (true, true) => core.to_string(), |
| 59 | 49× | (true, false) => format!("**/{core}"), |
| 60 | 0× | (false, true) => format!("{dir}/{core}"), |
| 61 | 2× | (false, false) => format!("{dir}/**/{core}"), |
| 62 | | }; |
| 63 | 68× | if let Ok(g) = Glob::new(&full) { |
| 64 | 68× | self.rules.push(Rule { |
| 65 | 68× | matcher: g.compile_matcher(), |
| 66 | 68× | attrs, |
| 67 | 68× | }); |
| 68 | 68× | } |
| 69 | | } |
| 70 | 34× | } |
| 71 | |
|
| 72 | | |
| 73 | 3934× | pub fn from_files<'a>(files: impl IntoIterator<Item = (&'a str, &'a [u8])>) -> Attributes { |
| 74 | 3934× | let mut sorted: BTreeMap<&str, &[u8]> = BTreeMap::new(); |
| 75 | 3934× | for (p, c) in files { |
| 76 | 35× | if p == ".oxyattributes" || p.ends_with("/.oxyattributes") { |
| 77 | 34× | sorted.insert(p, c); |
| 78 | 34× | } |
| 79 | | } |
| 80 | 3934× | let mut out = Attributes::default(); |
| 81 | | |
| 82 | 3934× | let mut entries: Vec<(&str, &[u8])> = sorted.into_iter().collect(); |
| 83 | 3934× | entries.sort_by_key(|(p, _)| p.matches('/').count()); |
| 84 | 3934× | for (p, c) in entries { |
| 85 | 34× | let dir = p |
| 86 | 34× | .strip_suffix(".oxyattributes") |
| 87 | 34× | .unwrap_or("") |
| 88 | 34× | .trim_end_matches('/'); |
| 89 | 34× | out.add_file(dir, &String::from_utf8_lossy(c)); |
| 90 | 34× | } |
| 91 | 3934× | out |
| 92 | 3934× | } |
| 93 | |
|
| 94 | 84548× | pub fn lookup(&self, path: &str) -> Attrs { |
| 95 | 84548× | let mut a = Attrs::default(); |
| 96 | 84548× | for r in &self.rules { |
| 97 | 486× | if !r.matcher.is_match(path) { |
| 98 | 390× | continue; |
| 99 | 96× | } |
| 100 | 124× | for (k, v) in &r.attrs { |
| 101 | 124× | match (k.as_str(), v.as_deref()) { |
| 102 | 124× | ("binary", None) => a.binary = Some(true), |
| 103 | 95× | ("-binary", None) | ("text", None) => a.binary = Some(false), |
| 104 | 95× | ("eol", Some("lf")) => a.eol = Some(Eol::Lf), |
| 105 | 26× | ("eol", Some("crlf")) => a.eol = Some(Eol::Crlf), |
| 106 | 57× | ("-eol", None) => a.eol = None, |
| 107 | 56× | ("nocompress", None) => a.nocompress = true, |
| 108 | 28× | ("-nocompress", None) => a.nocompress = false, |
| 109 | 28× | ("lockable", None) => a.lockable = true, |
| 110 | 0× | ("-lockable", None) => a.lockable = false, |
| 111 | 0× | _ => {} |
| 112 | | } |
| 113 | | } |
| 114 | | } |
| 115 | 84548× | a |
| 116 | 84548× | } |
| 117 | |
|
| 118 | 1× | pub fn is_empty(&self) -> bool { |
| 119 | 1× | self.rules.is_empty() |
| 120 | 1× | } |
| 121 | | } |
| 122 | |
|
| 123 | |
|
| 124 | 9× | pub fn normalize_eol(bytes: &[u8]) -> Vec<u8> { |
| 125 | 9× | let mut out = Vec::with_capacity(bytes.len()); |
| 126 | 9× | let mut i = 0; |
| 127 | 85× | while i < bytes.len() { |
| 128 | 76× | if bytes[i] == b'\r' && bytes.get(i + 1) == Some(&b'\n') { |
| 129 | 16× | i += 1; |
| 130 | 16× | continue; |
| 131 | 60× | } |
| 132 | 60× | out.push(bytes[i]); |
| 133 | 60× | i += 1; |
| 134 | | } |
| 135 | 9× | out |
| 136 | 9× | } |
| 137 | |
|
| 138 | |
|
| 139 | 4× | pub fn to_crlf(bytes: &[u8]) -> Vec<u8> { |
| 140 | 4× | let mut out = Vec::with_capacity(bytes.len() + bytes.len() / 16); |
| 141 | 4× | let mut prev = 0u8; |
| 142 | 27× | for &b in bytes { |
| 143 | 27× | if b == b'\n' && prev != b'\r' { |
| 144 | 8× | out.push(b'\r'); |
| 145 | 19× | } |
| 146 | 27× | out.push(b); |
| 147 | 27× | prev = b; |
| 148 | | } |
| 149 | 4× | out |
| 150 | 4× | } |
| 151 | |
|
| 152 | | #[cfg(test)] |
| 153 | | mod tests { |
| 154 | | use super::*; |
| 155 | |
|
| 156 | | #[test] |
| 157 | 1× | fn parsing_and_precedence() { |
| 158 | 1× | let a = Attributes::from_files([ |
| 159 | 1× | (".oxyattributes", &b"*.png binary nocompress\n*.txt eol=lf\ndocs/*.txt eol=crlf\nmodels/** lockable\n"[..]), |
| 160 | 1× | ("src/.oxyattributes", &b"*.dat binary\ngen.txt -eol\n"[..]), |
| 161 | 1× | ("README.md", &b"not attributes"[..]), |
| 162 | 1× | ]); |
| 163 | 1× | assert_eq!( |
| 164 | 1× | a.lookup("a/b/logo.png"), |
| 165 | | Attrs { |
| 166 | | binary: Some(true), |
| 167 | | eol: None, |
| 168 | | nocompress: true, |
| 169 | | lockable: false |
| 170 | | } |
| 171 | | ); |
| 172 | 1× | assert_eq!(a.lookup("notes.txt").eol, Some(Eol::Lf)); |
| 173 | 1× | assert_eq!(a.lookup("docs/notes.txt").eol, Some(Eol::Crlf)); |
| 174 | 1× | assert!(a.lookup("models/x/y.bin").lockable); |
| 175 | 1× | assert_eq!(a.lookup("src/deep/x.dat").binary, Some(true)); |
| 176 | 1× | assert_eq!(a.lookup("other/x.dat").binary, None); |
| 177 | 1× | assert_eq!(a.lookup("src/gen.txt").eol, None, "deeper file overrides"); |
| 178 | 1× | assert_eq!(a.lookup("src/other.txt").eol, Some(Eol::Lf)); |
| 179 | 1× | assert_eq!(a.lookup("plain"), Attrs::default()); |
| 180 | 1× | } |
| 181 | |
|
| 182 | | #[test] |
| 183 | 1× | fn eol_conversions() { |
| 184 | 1× | assert_eq!(normalize_eol(b"a\r\nb\nc\r\n"), b"a\nb\nc\n"); |
| 185 | 1× | assert_eq!(to_crlf(b"a\nb\r\nc\n"), b"a\r\nb\r\nc\r\n"); |
| 186 | 1× | assert_eq!(normalize_eol(&to_crlf(b"x\ny\n")), b"x\ny\n"); |
| 187 | 1× | } |
| 188 | | } |