OXYGENHUB
# 89a1d0243544 oxygen / crates / oxy-core / src / attributes.rs
6.1 KiB · Rust · last changed .oxyattributes: binary, text, eol=lf|crlf, nocompress (raw chunks in packs), lockable with advisory local oxy lock/unlock (spec 0.6j) 1 month ago source blame history raw
cdb5beba Andy McCall ↶ 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).
39
    pub fn add_file(&mut self, dir: &str, text: &str) {
40
        for line in text.lines() {
41
            let line = line.trim();
42
            if line.is_empty() || line.starts_with('#') {
43
                continue;
44
            }
45
            let mut parts = line.split_whitespace();
46
            let Some(pattern) = parts.next() else { continue };
47
            let attrs: Vec<(String, Option<String>)> = parts
48
                .map(|a| match a.split_once('=') {
49
                    Some((k, v)) => (k.to_string(), Some(v.to_string())),
50
                    None => (a.to_string(), None),
51
                })
52
                .collect();
53
            let anchored = pattern.contains('/');
54
            let core = pattern.trim_start_matches('/');
55
            let full = match (dir.is_empty(), anchored) {
56
                (true, true) => core.to_string(),
57
                (true, false) => format!("**/{core}"),
58
                (false, true) => format!("{dir}/{core}"),
59
                (false, false) => format!("{dir}/**/{core}"),
60
            };
61
            if let Ok(g) = Glob::new(&full) {
62
                self.rules.push(Rule { matcher: g.compile_matcher(), attrs });
63
            }
64
        }
65
    }
66
67
    /// Build from every `.oxyattributes` in `files` (path → content).
68
    pub fn from_files<'a>(files: impl IntoIterator<Item = (&'a str, &'a [u8])>) -> Attributes {
69
        let mut sorted: BTreeMap<&str, &[u8]> = BTreeMap::new();
70
        for (p, c) in files {
71
            if p == ".oxyattributes" || p.ends_with("/.oxyattributes") {
72
                sorted.insert(p, c);
73
            }
74
        }
75
        let mut out = Attributes::default();
76
        // Shallower files first so deeper ones override.
77
        let mut entries: Vec<(&str, &[u8])> = sorted.into_iter().collect();
78
        entries.sort_by_key(|(p, _)| p.matches('/').count());
79
        for (p, c) in entries {
80
            let dir = p.strip_suffix(".oxyattributes").unwrap_or("").trim_end_matches('/');
81
            out.add_file(dir, &String::from_utf8_lossy(c));
82
        }
83
        out
84
    }
85
86
    pub fn lookup(&self, path: &str) -> Attrs {
87
        let mut a = Attrs::default();
88
        for r in &self.rules {
89
            if !r.matcher.is_match(path) {
90
                continue;
91
            }
92
            for (k, v) in &r.attrs {
93
                match (k.as_str(), v.as_deref()) {
94
                    ("binary", None) => a.binary = Some(true),
95
                    ("-binary", None) | ("text", None) => a.binary = Some(false),
96
                    ("eol", Some("lf")) => a.eol = Some(Eol::Lf),
97
                    ("eol", Some("crlf")) => a.eol = Some(Eol::Crlf),
98
                    ("-eol", None) => a.eol = None,
99
                    ("nocompress", None) => a.nocompress = true,
100
                    ("-nocompress", None) => a.nocompress = false,
101
                    ("lockable", None) => a.lockable = true,
102
                    ("-lockable", None) => a.lockable = false,
103
                    _ => {}
104
                }
105
            }
106
        }
107
        a
108
    }
109
110
    pub fn is_empty(&self) -> bool {
111
        self.rules.is_empty()
112
    }
113
}
114
115
/// `\r\n` → `\n`, as stored for `eol=` files.
116
pub fn normalize_eol(bytes: &[u8]) -> Vec<u8> {
117
    let mut out = Vec::with_capacity(bytes.len());
118
    let mut i = 0;
119
    while i < bytes.len() {
120
        if bytes[i] == b'\r' && bytes.get(i + 1) == Some(&b'\n') {
121
            i += 1;
122
            continue;
123
        }
124
        out.push(bytes[i]);
125
        i += 1;
126
    }
127
    out
128
}
129
130
/// `\n` → `\r\n` (lines already ending in `\r\n` are left alone).
131
pub fn to_crlf(bytes: &[u8]) -> Vec<u8> {
132
    let mut out = Vec::with_capacity(bytes.len() + bytes.len() / 16);
133
    let mut prev = 0u8;
134
    for &b in bytes {
135
        if b == b'\n' && prev != b'\r' {
136
            out.push(b'\r');
137
        }
138
        out.push(b);
139
        prev = b;
140
    }
141
    out
142
}
143
144
#[cfg(test)]
145
mod tests {
146
    use super::*;
147
148
    #[test]
149
    fn parsing_and_precedence() {
150
        let a = Attributes::from_files([
151
            (".oxyattributes", &b"*.png binary nocompress\n*.txt eol=lf\ndocs/*.txt eol=crlf\nmodels/** lockable\n"[..]),
152
            ("src/.oxyattributes", &b"*.dat binary\ngen.txt -eol\n"[..]),
153
            ("README.md", &b"not attributes"[..]),
154
        ]);
155
        assert_eq!(a.lookup("a/b/logo.png"), Attrs { binary: Some(true), eol: None, nocompress: true, lockable: false });
156
        assert_eq!(a.lookup("notes.txt").eol, Some(Eol::Lf));
157
        assert_eq!(a.lookup("docs/notes.txt").eol, Some(Eol::Crlf));
158
        assert!(a.lookup("models/x/y.bin").lockable);
159
        assert_eq!(a.lookup("src/deep/x.dat").binary, Some(true));
160
        assert_eq!(a.lookup("other/x.dat").binary, None);
161
        assert_eq!(a.lookup("src/gen.txt").eol, None, "deeper file overrides");
162
        assert_eq!(a.lookup("src/other.txt").eol, Some(Eol::Lf));
163
        assert_eq!(a.lookup("plain"), Attrs::default());
164
    }
165
166
    #[test]
167
    fn eol_conversions() {
168
        assert_eq!(normalize_eol(b"a\r\nb\nc\r\n"), b"a\nb\nc\n");
169
        assert_eq!(to_crlf(b"a\nb\r\nc\n"), b"a\r\nb\r\nc\r\n");
170
        assert_eq!(normalize_eol(&to_crlf(b"x\ny\n")), b"x\ny\n");
171
    }
172
}