OXYGENHUB

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

118 of 123 lines run, 22 of 42 functions · run #394 on proposal/151 · 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`, `lockable`, or
4
//! `merge=union`. A
5
//! pattern without a slash matches a basename anywhere below the file's
6
//! directory; one with a slash is anchored to that directory. Later rules
7
//! override earlier ones per attribute.
8
9
use globset::{Glob, GlobMatcher};
10
use std::collections::BTreeMap;
11
12
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13
pub enum Eol {
14
    Lf,
15
    Crlf,
16
}
17
18
/// The attributes in force for one path.
19
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20
pub struct Attrs {
21
    /// `Some(true)` forces opaque, `Some(false)` forces text (absent NUL bytes).
22
    pub binary: Option<bool>,
23
    pub eol: Option<Eol>,
24
    pub nocompress: bool,
25
    pub lockable: bool,
26
    /// `merge=union` (oxygen#145): two changes adding lines at the same
27
    /// place keep both, without asking (`oxy resolve --both`, spec §9.5).
28
    pub union: bool,
29
}
30
31
struct Rule {
32
    matcher: GlobMatcher,
33
    attrs: Vec<(String, Option<String>)>,
34
}
35
36
#[derive(Default)]
37
pub struct Attributes {
38
    rules: Vec<Rule>,
39
}
40
41
impl Attributes {
42
    /// Add the rules of one `.oxyattributes` file living in `dir` (`""` for the root).
4366×
    pub fn add_file(&mut self, dir: &str, text: &str) {
44100×
        for line in text.lines() {
45100×
            let line = line.trim();
46100×
            if line.is_empty() || line.starts_with('#') {
470×
                continue;
48100×
            }
49100×
            let mut parts = line.split_whitespace();
50100×
            let Some(pattern) = parts.next() else {
510×
                continue;
52
            };
53100×
            let attrs: Vec<(String, Option<String>)> = parts
54116×
                .map(|a| match a.split_once('=') {
5552×
                    Some((k, v)) => (k.to_string(), Some(v.to_string())),
5664×
                    None => (a.to_string(), None),
57116×
                })
58100×
                .collect();
59100×
            let anchored = pattern.contains('/');
60100×
            let core = pattern.trim_start_matches('/');
61100×
            let full = match (dir.is_empty(), anchored) {
6217×
                (true, true) => core.to_string(),
6381×
                (true, false) => format!("**/{core}"),
640×
                (false, true) => format!("{dir}/{core}"),
652×
                (false, false) => format!("{dir}/**/{core}"),
66
            };
67100×
            if let Ok(g) = Glob::new(&full) {
68100×
                self.rules.push(Rule {
69100×
                    matcher: g.compile_matcher(),
70100×
                    attrs,
71100×
                });
72100×
            }
73
        }
7466×
    }
75
76
    /// Build from every `.oxyattributes` in `files` (path → content).
774254×
    pub fn from_files<'a>(files: impl IntoIterator<Item = (&'a str, &'a [u8])>) -> Attributes {
784254×
        let mut sorted: BTreeMap<&str, &[u8]> = BTreeMap::new();
794254×
        for (p, c) in files {
8067×
            if p == ".oxyattributes" || p.ends_with("/.oxyattributes") {
8166×
                sorted.insert(p, c);
8266×
            }
83
        }
844254×
        let mut out = Attributes::default();
85
        // Shallower files first so deeper ones override.
864254×
        let mut entries: Vec<(&str, &[u8])> = sorted.into_iter().collect();
874254×
        entries.sort_by_key(|(p, _)| p.matches('/').count());
884254×
        for (p, c) in entries {
8966×
            let dir = p
9066×
                .strip_suffix(".oxyattributes")
9166×
                .unwrap_or("")
9266×
                .trim_end_matches('/');
9366×
            out.add_file(dir, &String::from_utf8_lossy(c));
9466×
        }
954254×
        out
964254×
    }
97
9886007×
    pub fn lookup(&self, path: &str) -> Attrs {
9986007×
        let mut a = Attrs::default();
10086007×
        for r in &self.rules {
101721×
            if !r.matcher.is_match(path) {
102505×
                continue;
103216×
            }
104244×
            for (k, v) in &r.attrs {
105244×
                match (k.as_str(), v.as_deref()) {
106244×
                    ("binary", None) => a.binary = Some(true),
107215×
                    ("-binary", None) | ("text", None) => a.binary = Some(false),
108215×
                    ("eol", Some("lf")) => a.eol = Some(Eol::Lf),
10926×
                    ("eol", Some("crlf")) => a.eol = Some(Eol::Crlf),
110177×
                    ("-eol", None) => a.eol = None,
111176×
                    ("nocompress", None) => a.nocompress = true,
112148×
                    ("-nocompress", None) => a.nocompress = false,
113148×
                    ("lockable", None) => a.lockable = true,
114120×
                    ("-lockable", None) => a.lockable = false,
115120×
                    ("merge", Some("union")) => a.union = true,
1160×
                    ("merge", Some(_)) | ("-merge", None) => a.union = false,
1170×
                    _ => {}
118
                }
119
            }
120
        }
12186007×
        a
12286007×
    }
123
1241×
    pub fn is_empty(&self) -> bool {
1251×
        self.rules.is_empty()
1261×
    }
127
}
128
129
/// `\r\n` → `\n`, as stored for `eol=` files.
1309×
pub fn normalize_eol(bytes: &[u8]) -> Vec<u8> {
1319×
    let mut out = Vec::with_capacity(bytes.len());
1329×
    let mut i = 0;
13385×
    while i < bytes.len() {
13476×
        if bytes[i] == b'\r' && bytes.get(i + 1) == Some(&b'\n') {
13516×
            i += 1;
13616×
            continue;
13760×
        }
13860×
        out.push(bytes[i]);
13960×
        i += 1;
140
    }
1419×
    out
1429×
}
143
144
/// `\n` → `\r\n` (lines already ending in `\r\n` are left alone).
1454×
pub fn to_crlf(bytes: &[u8]) -> Vec<u8> {
1464×
    let mut out = Vec::with_capacity(bytes.len() + bytes.len() / 16);
1474×
    let mut prev = 0u8;
14827×
    for &b in bytes {
14927×
        if b == b'\n' && prev != b'\r' {
1508×
            out.push(b'\r');
15119×
        }
15227×
        out.push(b);
15327×
        prev = b;
154
    }
1554×
    out
1564×
}
157
158
#[cfg(test)]
159
mod tests {
160
    use super::*;
161
162
    #[test]
1631×
    fn parsing_and_precedence() {
1641×
        let a = Attributes::from_files([
1651×
            (".oxyattributes", &b"*.png binary nocompress\n*.txt eol=lf\ndocs/*.txt eol=crlf\nmodels/** lockable\n"[..]),
1661×
            ("src/.oxyattributes", &b"*.dat binary\ngen.txt -eol\n"[..]),
1671×
            ("README.md", &b"not attributes"[..]),
1681×
        ]);
1691×
        assert_eq!(
1701×
            a.lookup("a/b/logo.png"),
171
            Attrs {
172
                binary: Some(true),
173
                eol: None,
174
                nocompress: true,
175
                lockable: false,
176
                union: false
177
            }
178
        );
1791×
        assert_eq!(a.lookup("notes.txt").eol, Some(Eol::Lf));
1801×
        assert_eq!(a.lookup("docs/notes.txt").eol, Some(Eol::Crlf));
1811×
        assert!(a.lookup("models/x/y.bin").lockable);
1821×
        let u = Attributes::from_files([(".oxyattributes", &b"CHANGELOG.md merge=union\n"[..])]);
1831×
        assert!(u.lookup("CHANGELOG.md").union);
1841×
        assert!(u.lookup("docs/CHANGELOG.md").union);
1851×
        assert!(!u.lookup("README.md").union);
1861×
        assert_eq!(a.lookup("src/deep/x.dat").binary, Some(true));
1871×
        assert_eq!(a.lookup("other/x.dat").binary, None);
1881×
        assert_eq!(a.lookup("src/gen.txt").eol, None, "deeper file overrides");
1891×
        assert_eq!(a.lookup("src/other.txt").eol, Some(Eol::Lf));
1901×
        assert_eq!(a.lookup("plain"), Attrs::default());
1911×
    }
192
193
    #[test]
1941×
    fn eol_conversions() {
1951×
        assert_eq!(normalize_eol(b"a\r\nb\nc\r\n"), b"a\nb\nc\n");
1961×
        assert_eq!(to_crlf(b"a\nb\r\nc\n"), b"a\r\nb\r\nc\r\n");
1971×
        assert_eq!(normalize_eol(&to_crlf(b"x\ny\n")), b"x\ny\n");
1981×
    }
199
}