fabrica

hanna/fabrica

19226 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! A `.gitattributes` resolver over tree blobs.
6//!
7//! libgit2's attribute lookup is oriented at a working tree; against a bare
8//! repository, and for per-ref accuracy, it is unreliable. This module resolves
9//! attributes directly from the object database instead: for a given tree it
10//! collects every `.gitattributes` blob with its directory prefix, parses each
11//! into ordered rules, and answers "what attributes apply to this path?" with git's
12//! precedence — deeper directories win over shallower, and within one file a later
13//! line wins over an earlier one.
14//!
15//! Pattern matching follows gitattributes/gitignore semantics: a pattern with no
16//! slash matches the basename at any depth below its file; a pattern containing a
17//! slash is anchored to its file's directory; `**` spans directories; `!key`,
18//! `-key`, `key`, and `key=value` are the four assignment forms.
19//!
20//! Consumers (later phases): the highlight language override
21//! (`fabrica-language` / `linguist-language`), the "binary, not shown" decision
22//! (`binary` / `-text`), collapsing generated diffs (`linguist-generated` /
23//! `fabrica-generated`), and excluding vendored paths from search
24//! (`linguist-vendored`).
25
26use std::collections::HashMap;
27use std::sync::Arc;
28
29use globset::{GlobBuilder, GlobMatcher};
30
31use crate::GitError;
32use crate::repo::Repo;
33use crate::types::Oid;
34
35/// The value an attribute is assigned by a matching rule.
36///
37/// Mirrors git's four forms. Resolution keeps the last (deepest, latest) value
38/// per attribute name; [`AttrValue::Unspecified`] deliberately clears a value set
39/// by a shallower rule.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum AttrValue {
42 /// `attr` — set to the implicit boolean true.
43 Set,
44 /// `-attr` — explicitly unset (boolean false).
45 Unset,
46 /// `!attr` — unspecified; removes any value a less specific rule assigned.
47 Unspecified,
48 /// `attr=value` — set to a string value.
49 Value(String),
50}
51
52/// The attributes resolved for one concrete path.
53///
54/// A thin wrapper over the final name→value map, with typed accessors for the
55/// attributes fabrica actually consults.
56#[derive(Debug, Clone, Default, PartialEq, Eq)]
57pub struct Attributes {
58 map: HashMap<String, AttrValue>,
59}
60
61impl Attributes {
62 /// The raw value assigned to `name`, if any rule set one.
63 #[must_use]
64 pub fn get(&self, name: &str) -> Option<&AttrValue> {
65 self.map.get(name)
66 }
67
68 /// Whether `name` resolves to a truthy state ([`AttrValue::Set`] or a
69 /// non-`false` [`AttrValue::Value`]).
70 #[must_use]
71 pub fn is_set(&self, name: &str) -> bool {
72 match self.map.get(name) {
73 Some(AttrValue::Set) => true,
74 Some(AttrValue::Value(v)) => v != "false",
75 _ => false,
76 }
77 }
78
79 /// Whether `name` was explicitly unset (`-name`).
80 #[must_use]
81 pub fn is_unset(&self, name: &str) -> bool {
82 matches!(self.map.get(name), Some(AttrValue::Unset))
83 }
84
85 /// The highlighting language override, honouring fabrica's own attribute over
86 /// GitHub's `linguist-language` (§8.1 precedence).
87 #[must_use]
88 pub fn language(&self) -> Option<&str> {
89 for key in ["fabrica-language", "linguist-language"] {
90 if let Some(AttrValue::Value(v)) = self.map.get(key) {
91 return Some(v.as_str());
92 }
93 }
94 None
95 }
96
97 /// Whether the path should be treated as binary (rendered as "binary file, not
98 /// shown"): `binary` set, or text explicitly disabled with `-text`.
99 #[must_use]
100 pub fn is_binary(&self) -> bool {
101 self.is_set("binary") || self.is_unset("text")
102 }
103
104 /// Whether diff rendering is suppressed for this path: `-diff`, or `binary`
105 /// (git's `binary` macro implies `-diff`).
106 #[must_use]
107 pub fn no_diff(&self) -> bool {
108 self.is_unset("diff") || self.is_set("binary")
109 }
110
111 /// Whether the path is machine-generated and should collapse by default.
112 #[must_use]
113 pub fn is_generated(&self) -> bool {
114 self.is_set("linguist-generated") || self.is_set("fabrica-generated")
115 }
116
117 /// Whether the path is vendored and should be excluded from search.
118 #[must_use]
119 pub fn is_vendored(&self) -> bool {
120 self.is_set("linguist-vendored")
121 }
122
123 /// Whether the path is managed by git-LFS (`filter=lfs` in `.gitattributes`).
124 #[must_use]
125 pub fn is_lfs(&self) -> bool {
126 matches!(self.map.get("filter"), Some(AttrValue::Value(v)) if v == "lfs")
127 }
128}
129
130/// One `.gitattributes` file: its directory prefix (`""` at the tree root) and its
131/// parsed rules in file order.
132#[derive(Debug)]
133struct AttrFile {
134 /// Directory the file lives in, without a trailing slash (`""` at the root).
135 dir: String,
136 /// The file's rules, in the order they appear.
137 rules: Vec<Rule>,
138}
139
140/// One parsed line of a `.gitattributes` file.
141#[derive(Debug)]
142struct Rule {
143 /// The compiled pattern.
144 matcher: GlobMatcher,
145 /// Whether the pattern floats (no slash → match the basename at any depth)
146 /// rather than being anchored to the file's directory.
147 floating: bool,
148 /// The attribute assignments this line makes, in order.
149 assigns: Vec<(String, AttrValue)>,
150}
151
152/// The resolved rule set for a whole tree: every `.gitattributes` file it
153/// contains, ordered shallowest-first so application order yields git's
154/// "deeper wins" precedence.
155#[derive(Debug, Default)]
156pub struct AttributesSet {
157 files: Vec<AttrFile>,
158}
159
160impl AttributesSet {
161 /// Resolve the attributes that apply to `path` (a repo-relative, `/`-separated
162 /// path with no leading slash).
163 #[must_use]
164 pub fn attributes(&self, path: &str) -> Attributes {
165 let path = path.trim_start_matches('/');
166 let mut map: HashMap<String, AttrValue> = HashMap::new();
167
168 // `files` is shallowest-first; applying in that order lets deeper files
169 // (and later lines within a file) overwrite, which is exactly git's rule.
170 for file in &self.files {
171 let Some(rel) = relative_to(path, &file.dir) else {
172 continue;
173 };
174 let rel_base = rel.rsplit('/').next().unwrap_or(rel);
175 for rule in &file.rules {
176 let hit = if rule.floating {
177 rule.matcher.is_match(rel_base)
178 } else {
179 rule.matcher.is_match(rel)
180 };
181 if hit {
182 for (name, value) in &rule.assigns {
183 match value {
184 AttrValue::Unspecified => {
185 map.remove(name);
186 }
187 other => {
188 map.insert(name.clone(), other.clone());
189 }
190 }
191 }
192 }
193 }
194 }
195 Attributes { map }
196 }
197}
198
199/// Return `path` relative to directory `dir`, or `None` if `path` is not under
200/// `dir`. `dir` is `""` for the tree root (everything is under it).
201fn relative_to<'a>(path: &'a str, dir: &str) -> Option<&'a str> {
202 if dir.is_empty() {
203 return Some(path);
204 }
205 path.strip_prefix(dir)?.strip_prefix('/')
206}
207
208/// Parse the text of one `.gitattributes` file at directory `dir` into rules.
209/// Unparseable patterns are skipped (logged by the caller's fall-through), never
210/// fatal.
211fn parse_attr_file(dir: &str, text: &str) -> AttrFile {
212 let mut rules = Vec::new();
213 for raw in text.lines() {
214 let line = raw.trim();
215 if line.is_empty() || line.starts_with('#') {
216 continue;
217 }
218 let mut fields = line.split_whitespace();
219 let Some(pattern) = fields.next() else {
220 continue;
221 };
222 let assigns: Vec<(String, AttrValue)> = fields.map(parse_assignment).collect();
223 if assigns.is_empty() {
224 continue;
225 }
226 if let Some((matcher, floating)) = compile_pattern(pattern) {
227 rules.push(Rule {
228 matcher,
229 floating,
230 assigns,
231 });
232 }
233 }
234 AttrFile {
235 dir: dir.to_string(),
236 rules,
237 }
238}
239
240/// Parse one whitespace-delimited attribute token into a name and value.
241fn parse_assignment(token: &str) -> (String, AttrValue) {
242 if let Some(name) = token.strip_prefix('!') {
243 (name.to_string(), AttrValue::Unspecified)
244 } else if let Some(name) = token.strip_prefix('-') {
245 (name.to_string(), AttrValue::Unset)
246 } else if let Some((name, value)) = token.split_once('=') {
247 (name.to_string(), AttrValue::Value(value.to_string()))
248 } else {
249 (token.to_string(), AttrValue::Set)
250 }
251}
252
253/// Compile a gitattributes pattern into a matcher plus whether it floats. Returns
254/// `None` for a pattern globset cannot compile.
255///
256/// Semantics: a pattern with no `/` (a trailing one aside) floats and is matched
257/// against the basename; a pattern with a slash is anchored to the file's
258/// directory. `literal_separator(true)` makes `*` stop at `/` while `**` spans
259/// directories, matching git.
260fn compile_pattern(pattern: &str) -> Option<(GlobMatcher, bool)> {
261 let trimmed = pattern.trim_end_matches('/');
262 let floating = !trimmed.contains('/');
263 let glob_src = trimmed.strip_prefix('/').unwrap_or(trimmed);
264 let glob = GlobBuilder::new(glob_src)
265 .literal_separator(!floating)
266 .build()
267 .ok()?;
268 Some((glob.compile_matcher(), floating))
269}
270
271impl Repo {
272 /// Collect and parse every `.gitattributes` file reachable in the tree of
273 /// commit `oid`, yielding a queryable [`AttributesSet`].
274 ///
275 /// # Errors
276 ///
277 /// Returns [`GitError::RevNotFound`] if `oid` is not a commit, or
278 /// [`GitError::Libgit2`] on a walk failure.
279 pub fn attributes_set(&self, oid: &Oid) -> Result<AttributesSet, GitError> {
280 let commit = self.commit_at(oid)?;
281 let tree = commit.tree()?;
282
283 // Collect (dir, blob_oid) for every `.gitattributes`, then read the blobs.
284 let mut found: Vec<(String, git2::Oid)> = Vec::new();
285 tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| {
286 if entry.name() == Some(".gitattributes")
287 && entry.filemode() != i32::from(git2::FileMode::Tree)
288 {
289 // `root` is the directory prefix, e.g. "" or "src/".
290 found.push((root.trim_end_matches('/').to_string(), entry.id()));
291 }
292 git2::TreeWalkResult::Ok
293 })?;
294
295 let mut files: Vec<AttrFile> = Vec::with_capacity(found.len());
296 for (dir, blob_oid) in found {
297 let blob = self.raw().find_blob(blob_oid)?;
298 let text = String::from_utf8_lossy(blob.content());
299 files.push(parse_attr_file(&dir, &text));
300 }
301 // Shallowest directory first so that applying rules in order lets deeper
302 // files override.
303 files.sort_by(|a, b| {
304 depth(&a.dir)
305 .cmp(&depth(&b.dir))
306 .then_with(|| a.dir.cmp(&b.dir))
307 });
308 Ok(AttributesSet { files })
309 }
310}
311
312/// The nesting depth of a directory prefix (`""` → 0, `"src"` → 1, `"a/b"` → 2).
313fn depth(dir: &str) -> usize {
314 if dir.is_empty() {
315 0
316 } else {
317 dir.split('/').count()
318 }
319}
320
321/// A bounded cache of resolved [`AttributesSet`]s keyed by tree/commit oid.
322///
323/// [`Repo`] is opened per operation, so the cache lives here — shared across
324/// requests by the web layer — rather than on the repository handle. Resolving a
325/// tree's attributes parses every `.gitattributes` blob it contains, which is
326/// wasteful to repeat per blob view within one commit.
327#[derive(Clone)]
328pub struct AttributesCache {
329 inner: moka::sync::Cache<String, Arc<AttributesSet>>,
330}
331
332impl AttributesCache {
333 /// A cache holding up to `capacity` resolved sets.
334 #[must_use]
335 pub fn new(capacity: u64) -> Self {
336 Self {
337 inner: moka::sync::Cache::new(capacity),
338 }
339 }
340
341 /// Fetch the resolved set for `oid` in `repo`, resolving and caching on a miss.
342 ///
343 /// # Errors
344 ///
345 /// Propagates any [`GitError`] from resolution on a cache miss.
346 pub fn get(&self, repo: &Repo, oid: &Oid) -> Result<Arc<AttributesSet>, GitError> {
347 if let Some(hit) = self.inner.get(oid.as_str()) {
348 return Ok(hit);
349 }
350 let set = Arc::new(repo.attributes_set(oid)?);
351 self.inner.insert(oid.to_string(), Arc::clone(&set));
352 Ok(set)
353 }
354}
355
356impl Default for AttributesCache {
357 fn default() -> Self {
358 Self::new(256)
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 #![allow(clippy::unwrap_used, clippy::expect_used)]
365
366 use super::*;
367
368 /// Build an [`AttributesSet`] from `(dir, text)` pairs, mimicking what a tree
369 /// walk would collect, so the resolution logic can be tested without a repo.
370 fn set_from(files: &[(&str, &str)]) -> AttributesSet {
371 let mut parsed: Vec<AttrFile> = files
372 .iter()
373 .map(|(dir, text)| parse_attr_file(dir, text))
374 .collect();
375 parsed.sort_by(|a, b| {
376 depth(&a.dir)
377 .cmp(&depth(&b.dir))
378 .then_with(|| a.dir.cmp(&b.dir))
379 });
380 AttributesSet { files: parsed }
381 }
382
383 #[test]
384 fn floating_pattern_matches_basename_at_any_depth() {
385 let set = set_from(&[("", "*.rs linguist-language=Rust\n")]);
386 assert_eq!(set.attributes("src/deep/main.rs").language(), Some("Rust"));
387 assert_eq!(set.attributes("main.rs").language(), Some("Rust"));
388 assert_eq!(set.attributes("main.py").language(), None);
389 }
390
391 #[test]
392 fn anchored_pattern_only_matches_its_directory_level() {
393 // A leading slash anchors to the file's directory; `*` does not cross `/`.
394 let set = set_from(&[("", "/*.rs linguist-language=Rust\n")]);
395 assert_eq!(set.attributes("main.rs").language(), Some("Rust"));
396 assert_eq!(set.attributes("src/main.rs").language(), None);
397 }
398
399 #[test]
400 fn double_star_spans_directories() {
401 let set = set_from(&[("", "src/**/*.rs linguist-language=Rust\n")]);
402 assert_eq!(set.attributes("src/a/b/x.rs").language(), Some("Rust"));
403 assert_eq!(set.attributes("other/x.rs").language(), None);
404 }
405
406 #[test]
407 fn deeper_file_wins_over_shallower() {
408 // Root marks everything generated; a nested file un-marks its subtree.
409 let set = set_from(&[
410 ("", "* linguist-generated\n"),
411 ("src", "* -linguist-generated\n"),
412 ]);
413 assert!(set.attributes("README.md").is_generated());
414 assert!(!set.attributes("src/main.rs").is_generated());
415 }
416
417 #[test]
418 fn later_line_wins_within_a_file() {
419 let set = set_from(&[(
420 "",
421 "*.rs linguist-language=Rust\nmain.rs linguist-language=Ruby\n",
422 )]);
423 assert_eq!(set.attributes("main.rs").language(), Some("Ruby"));
424 assert_eq!(set.attributes("lib.rs").language(), Some("Rust"));
425 }
426
427 #[test]
428 fn negation_clears_a_value_from_a_less_specific_rule() {
429 // `!key` unspecifies, distinct from `-key` which sets false.
430 let set = set_from(&[("", "* linguist-language=Text\n*.rs !linguist-language\n")]);
431 assert_eq!(set.attributes("notes.md").language(), Some("Text"));
432 assert_eq!(set.attributes("main.rs").language(), None);
433 }
434
435 #[test]
436 fn binary_and_minus_text_are_binary() {
437 let set = set_from(&[("", "*.bin binary\n*.dat -text\n")]);
438 assert!(set.attributes("x.bin").is_binary());
439 assert!(set.attributes("x.dat").is_binary());
440 assert!(!set.attributes("x.txt").is_binary());
441 }
442
443 #[test]
444 fn minus_diff_suppresses_diff_rendering() {
445 let set = set_from(&[("", "*.lock -diff\n")]);
446 assert!(set.attributes("Cargo.lock").no_diff());
447 assert!(!set.attributes("src/main.rs").no_diff());
448 // `binary` also implies no diff.
449 let bin = set_from(&[("", "*.png binary\n")]);
450 assert!(bin.attributes("logo.png").no_diff());
451 }
452
453 #[test]
454 fn comments_and_blanks_are_ignored() {
455 let set = set_from(&[("", "# a comment\n\n*.rs linguist-language=Rust\n")]);
456 assert_eq!(set.attributes("main.rs").language(), Some("Rust"));
457 }
458
459 #[test]
460 fn fabrica_language_beats_linguist_language() {
461 let set = set_from(&[(
462 "",
463 "*.rs linguist-language=Rust\n*.rs fabrica-language=RustCustom\n",
464 )]);
465 assert_eq!(set.attributes("main.rs").language(), Some("RustCustom"));
466 }
467
468 #[test]
469 fn attributes_set_reads_gitattributes_from_a_real_tree() {
470 use std::path::Path;
471
472 use git2::{Repository, Signature, Time};
473
474 use crate::{create_bare, repo_path};
475
476 let dir = tempfile::tempdir().unwrap();
477 let id = "01hzxk9m2n8p7q6r5s4t3v2w1x";
478 create_bare(dir.path(), id, "main", Path::new("/usr/bin/fabrica")).unwrap();
479 let path = repo_path(dir.path(), id).unwrap();
480 let raw = Repository::open_bare(&path).unwrap();
481
482 // Root `.gitattributes` marks vendored; a nested one overrides in `src`.
483 let root_attr = raw
484 .blob(b"vendor/** linguist-vendored\n*.rs linguist-language=Rust\n")
485 .unwrap();
486 let src_attr = raw.blob(b"*.rs -linguist-vendored\n").unwrap();
487 let code = raw.blob(b"fn main() {}\n").unwrap();
488
489 let mut src = raw.treebuilder(None).unwrap();
490 src.insert(".gitattributes", src_attr, 0o100_644).unwrap();
491 src.insert("main.rs", code, 0o100_644).unwrap();
492 let src_tree = src.write().unwrap();
493
494 let mut vendor = raw.treebuilder(None).unwrap();
495 vendor.insert("lib.rs", code, 0o100_644).unwrap();
496 let vendor_tree = vendor.write().unwrap();
497
498 let mut root = raw.treebuilder(None).unwrap();
499 root.insert(".gitattributes", root_attr, 0o100_644).unwrap();
500 root.insert("src", src_tree, 0o040_000).unwrap();
501 root.insert("vendor", vendor_tree, 0o040_000).unwrap();
502 let root_tree = raw.find_tree(root.write().unwrap()).unwrap();
503
504 let sig = Signature::new("Ada", "ada@example.com", &Time::new(1, 0)).unwrap();
505 let commit = raw
506 .commit(Some("refs/heads/main"), &sig, &sig, "init", &root_tree, &[])
507 .unwrap();
508
509 let repo = Repo::open_path(&path).unwrap();
510 let set = repo.attributes_set(&Oid::new(commit.to_string())).unwrap();
511
512 // `vendor/lib.rs` is vendored; `src/main.rs` is un-vendored by the nested
513 // file but still resolves its language from the root rule.
514 assert!(set.attributes("vendor/lib.rs").is_vendored());
515 assert!(!set.attributes("src/main.rs").is_vendored());
516 assert_eq!(set.attributes("src/main.rs").language(), Some("Rust"));
517 }
518}