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