OXYGENHUB

Coverage / crates/oxy-core/src/attributes.rs 95.7%

112 of 117 lines run, 21 of 40 functions · run #376 on main · run missed

1
//! `.oxyattributes` (spec §19): per-path attributes in gitattributes syntax.
2
//! Each line is `pattern attr...` where an attribute is `binary`, `text`
3
//! (or `-binary`), `eol=lf`, `eol=crlf`, `nocompress`, or `lockable`. A
4
//! pattern without a slash matches a basename anywhere below the file's
5
//! directory; one with a slash is anchored to that directory. Later rules
6
//! override earlier ones per attribute.
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
/// The attributes in force for one path.
18
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
19
pub struct Attrs {
20
    /// `Some(true)` forces opaque, `Some(false)` forces text (absent NUL bytes).
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
    /// Add the rules of one `.oxyattributes` file living in `dir` (`""` for the root).
3934×
    pub fn add_file(&mut self, dir: &str, text: &str) {
4068×
        for line in text.lines() {
4168×
            let line = line.trim();
4268×
            if line.is_empty() || line.starts_with('#') {
430×
                continue;
4468×
            }
4568×
            let mut parts = line.split_whitespace();
4668×
            let Some(pattern) = parts.next() else {
470×
                continue;
48
            };
4968×
            let attrs: Vec<(String, Option<String>)> = parts
5084×
                .map(|a| match a.split_once('=') {
5120×
                    Some((k, v)) => (k.to_string(), Some(v.to_string())),
5264×
                    None => (a.to_string(), None),
5384×
                })
5468×
                .collect();
5568×
            let anchored = pattern.contains('/');
5668×
            let core = pattern.trim_start_matches('/');
5768×
            let full = match (dir.is_empty(), anchored) {
5817×
                (true, true) => core.to_string(),
5949×
                (true, false) => format!("**/{core}"),
600×
                (false, true) => format!("{dir}/{core}"),
612×
                (false, false) => format!("{dir}/**/{core}"),
62
            };
6368×
            if let Ok(g) = Glob::new(&full) {
6468×
                self.rules.push(Rule {
6568×
                    matcher: g.compile_matcher(),
6668×
                    attrs,
6768×
                });
6868×
            }
69
        }
7034×
    }
71
72
    /// Build from every `.oxyattributes` in `files` (path → content).
733936×
    pub fn from_files<'a>(files: impl IntoIterator<Item = (&'a str, &'a [u8])>) -> Attributes {
743936×
        let mut sorted: BTreeMap<&str, &[u8]> = BTreeMap::new();
753936×
        for (p, c) in files {
7635×
            if p == ".oxyattributes" || p.ends_with("/.oxyattributes") {
7734×
                sorted.insert(p, c);
7834×
            }
79
        }
803936×
        let mut out = Attributes::default();
81
        // Shallower files first so deeper ones override.
823936×
        let mut entries: Vec<(&str, &[u8])> = sorted.into_iter().collect();
833936×
        entries.sort_by_key(|(p, _)| p.matches('/').count());
843936×
        for (p, c) in entries {
8534×
            let dir = p
8634×
                .strip_suffix(".oxyattributes")
8734×
                .unwrap_or("")
8834×
                .trim_end_matches('/');
8934×
            out.add_file(dir, &String::from_utf8_lossy(c));
9034×
        }
913936×
        out
923936×
    }
93
9484157×
    pub fn lookup(&self, path: &str) -> Attrs {
9584157×
        let mut a = Attrs::default();
9684157×
        for r in &self.rules {
97486×
            if !r.matcher.is_match(path) {
98390×
                continue;
9996×
            }
100124×
            for (k, v) in &r.attrs {
101124×
                match (k.as_str(), v.as_deref()) {
102124×
                    ("binary", None) => a.binary = Some(true),
10395×
                    ("-binary", None) | ("text", None) => a.binary = Some(false),
10495×
                    ("eol", Some("lf")) => a.eol = Some(Eol::Lf),
10526×
                    ("eol", Some("crlf")) => a.eol = Some(Eol::Crlf),
10657×
                    ("-eol", None) => a.eol = None,
10756×
                    ("nocompress", None) => a.nocompress = true,
10828×
                    ("-nocompress", None) => a.nocompress = false,
10928×
                    ("lockable", None) => a.lockable = true,
1100×
                    ("-lockable", None) => a.lockable = false,
1110×
                    _ => {}
112
                }
113
            }
114
        }
11584157×
        a
11684157×
    }
117
1181×
    pub fn is_empty(&self) -> bool {
1191×
        self.rules.is_empty()
1201×
    }
121
}
122
123
/// `\r\n` → `\n`, as stored for `eol=` files.
1249×
pub fn normalize_eol(bytes: &[u8]) -> Vec<u8> {
1259×
    let mut out = Vec::with_capacity(bytes.len());
1269×
    let mut i = 0;
12785×
    while i < bytes.len() {
12876×
        if bytes[i] == b'\r' && bytes.get(i + 1) == Some(&b'\n') {
12916×
            i += 1;
13016×
            continue;
13160×
        }
13260×
        out.push(bytes[i]);
13360×
        i += 1;
134
    }
1359×
    out
1369×
}
137
138
/// `\n` → `\r\n` (lines already ending in `\r\n` are left alone).
1394×
pub fn to_crlf(bytes: &[u8]) -> Vec<u8> {
1404×
    let mut out = Vec::with_capacity(bytes.len() + bytes.len() / 16);
1414×
    let mut prev = 0u8;
14227×
    for &b in bytes {
14327×
        if b == b'\n' && prev != b'\r' {
1448×
            out.push(b'\r');
14519×
        }
14627×
        out.push(b);
14727×
        prev = b;
148
    }
1494×
    out
1504×
}
151
152
#[cfg(test)]
153
mod tests {
154
    use super::*;
155
156
    #[test]
1571×
    fn parsing_and_precedence() {
1581×
        let a = Attributes::from_files([
1591×
            (".oxyattributes", &b"*.png binary nocompress\n*.txt eol=lf\ndocs/*.txt eol=crlf\nmodels/** lockable\n"[..]),
1601×
            ("src/.oxyattributes", &b"*.dat binary\ngen.txt -eol\n"[..]),
1611×
            ("README.md", &b"not attributes"[..]),
1621×
        ]);
1631×
        assert_eq!(
1641×
            a.lookup("a/b/logo.png"),
165
            Attrs {
166
                binary: Some(true),
167
                eol: None,
168
                nocompress: true,
169
                lockable: false
170
            }
171
        );
1721×
        assert_eq!(a.lookup("notes.txt").eol, Some(Eol::Lf));
1731×
        assert_eq!(a.lookup("docs/notes.txt").eol, Some(Eol::Crlf));
1741×
        assert!(a.lookup("models/x/y.bin").lockable);
1751×
        assert_eq!(a.lookup("src/deep/x.dat").binary, Some(true));
1761×
        assert_eq!(a.lookup("other/x.dat").binary, None);
1771×
        assert_eq!(a.lookup("src/gen.txt").eol, None, "deeper file overrides");
1781×
        assert_eq!(a.lookup("src/other.txt").eol, Some(Eol::Lf));
1791×
        assert_eq!(a.lookup("plain"), Attrs::default());
1801×
    }
181
182
    #[test]
1831×
    fn eol_conversions() {
1841×
        assert_eq!(normalize_eol(b"a\r\nb\nc\r\n"), b"a\nb\nc\n");
1851×
        assert_eq!(to_crlf(b"a\nb\r\nc\n"), b"a\r\nb\r\nc\r\n");
1861×
        assert_eq!(normalize_eol(&to_crlf(b"x\ny\n")), b"x\ny\n");
1871×
    }
188
}