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