use std::path::Path;
#[derive(Debug, Clone, PartialEq, Eq)]
struct Glob {
weight: u32,
mime: String,
pattern: String,
chars: Vec<char>,
case_sensitive: bool,
}
impl Glob {
fn new(weight: u32, mime: &str, pattern: &str, case_sensitive: bool) -> Glob {
Glob {
weight,
mime: mime.to_string(),
chars: pattern.chars().collect(),
pattern: pattern.to_string(),
case_sensitive,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Globs {
globs: Vec<Glob>,
}
impl Globs {
pub fn parse(text: &str) -> Globs {
let mut globs = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let mut fields = line.splitn(4, ':');
let (Some(weight), Some(mime), Some(pattern)) =
(fields.next(), fields.next(), fields.next())
else {
continue;
};
let Ok(weight) = weight.parse::<u32>() else { continue };
if pattern.is_empty() || pattern == "__NOGLOBS__" {
continue;
}
let case_sensitive =
fields.next().is_some_and(|flags| flags.split(':').any(|flag| flag == "cs"));
globs.push(Glob::new(weight, mime, pattern, case_sensitive));
}
Globs { globs }
}
pub fn parse_legacy(text: &str) -> Globs {
let mut globs = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((mime, pattern)) = line.split_once(':') else { continue };
if pattern.is_empty() {
continue;
}
globs.push(Glob::new(50, mime, pattern, false));
}
Globs { globs }
}
pub fn load_from(dirs: &[std::path::PathBuf]) -> Globs {
let mut globs = Vec::new();
for dir in dirs {
let mime = dir.join("mime");
if let Ok(text) = std::fs::read_to_string(mime.join("globs2")) {
globs.extend(Globs::parse(&text).globs);
} else if let Ok(text) = std::fs::read_to_string(mime.join("globs")) {
globs.extend(Globs::parse_legacy(&text).globs);
}
}
Globs { globs }
}
pub fn type_of(&self, path: &Path) -> Option<&str> {
let name = path.file_name()?.to_str()?;
let chars: Vec<char> = name.chars().collect();
self.best_match(&chars).or_else(|| {
let lowered: Vec<char> = name.to_lowercase().chars().collect();
(lowered != chars).then(|| self.best_match(&lowered)).flatten()
})
}
fn best_match(&self, name: &[char]) -> Option<&str> {
self.globs
.iter()
.filter(|glob| matches_at(&glob.chars, name))
.fold(None, |best: Option<&Glob>, glob| match best {
Some(best) if rank(best) >= rank(glob) => Some(best),
_ => Some(glob),
})
.map(|glob| glob.mime.as_str())
}
pub fn all_matches(&self, path: &Path) -> Vec<&str> {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else { return Vec::new() };
let exact: Vec<char> = name.chars().collect();
let lowered: Vec<char> = name.to_lowercase().chars().collect();
let mut matched: Vec<&Glob> = self
.globs
.iter()
.filter(|glob| {
matches_at(&glob.chars, &exact)
|| (!glob.case_sensitive && matches_at(&glob.chars, &lowered))
})
.collect();
matched.sort_by_key(|glob| std::cmp::Reverse((glob.weight, glob.pattern.len())));
let mut out: Vec<&str> = Vec::new();
for glob in matched {
if !out.contains(&glob.mime.as_str()) {
out.push(&glob.mime);
}
}
out
}
pub fn mimes(&self) -> impl Iterator<Item = &str> {
self.globs.iter().map(|glob| glob.mime.as_str())
}
pub fn is_empty(&self) -> bool {
self.globs.is_empty()
}
}
fn rank(glob: &Glob) -> (u32, usize, bool) {
let registered = !glob.mime.split('/').nth(1).is_some_and(|sub| sub.starts_with("x-"));
(glob.weight, glob.pattern.len(), registered)
}
fn matches_at(pattern: &[char], name: &[char]) -> bool {
match pattern.first() {
None => name.is_empty(),
Some('*') => {
(0..=name.len()).any(|skip| matches_at(&pattern[1..], &name[skip..]))
}
Some('?') => !name.is_empty() && matches_at(&pattern[1..], &name[1..]),
Some('[') => {
let Some(close) = pattern.iter().position(|c| *c == ']') else {
return name.first() == Some(&'[') && matches_at(&pattern[1..], &name[1..]);
};
let Some(c) = name.first() else { return false };
let (negated, set) = match pattern.get(1) {
Some('!') => (true, &pattern[2..close]),
_ => (false, &pattern[1..close]),
};
if in_class(set, *c) == negated {
return false;
}
matches_at(&pattern[close + 1..], &name[1..])
}
Some(literal) => name.first() == Some(literal) && matches_at(&pattern[1..], &name[1..]),
}
}
fn in_class(set: &[char], c: char) -> bool {
let mut i = 0;
while i < set.len() {
if set.get(i + 1) == Some(&'-') {
if let Some(end) = set.get(i + 2) {
if set[i] <= c && c <= *end {
return true;
}
i += 3;
continue;
}
}
if set[i] == c {
return true;
}
i += 1;
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn db() -> Globs {
Globs::parse(
"# generated, do not edit\n\
50:model/stl:*.stl\n\
50:model/3mf:*.3mf\n\
50:application/zip:*.zip\n\
50:image/png:*.png\n\
40:image/apng:*.png\n\
50:application/gzip:*.gz\n\
50:application/x-compressed-tar:*.tar.gz\n\
50:text/x-makefile:Makefile:cs\n\
60:application/x-sharedlib:*.so.[0-9]*\n\
0:application/x-modrinth-modpack+zip:__NOGLOBS__\n",
)
}
#[test]
fn a_model_stored_as_a_zip_is_still_a_model() {
assert_eq!(db().type_of(&PathBuf::from("/d/part.3mf")), Some("model/3mf"));
assert_eq!(db().type_of(&PathBuf::from("/d/thing.stl")), Some("model/stl"));
assert_eq!(db().type_of(&PathBuf::from("/d/archive.zip")), Some("application/zip"));
}
#[test]
fn the_first_of_two_equal_rules_wins() {
let globs = Globs::parse("50:application/json:*.json\n50:application/schema+json:*.json\n");
assert_eq!(globs.type_of(&PathBuf::from("package.json")), Some("application/json"));
let reversed = Globs::parse("50:application/schema+json:*.json\n50:application/json:*.json\n");
assert_eq!(reversed.type_of(&PathBuf::from("package.json")), Some("application/schema+json"));
}
#[test]
fn the_heavier_rule_wins_and_then_the_longer_one() {
assert_eq!(db().type_of(&PathBuf::from("a.png")), Some("image/png"), "50 beats 40");
assert_eq!(
db().type_of(&PathBuf::from("backup.tar.gz")),
Some("application/x-compressed-tar"),
"*.tar.gz is longer than *.gz"
);
}
#[test]
fn an_unknown_name_is_unknown_rather_than_a_stream_of_bytes() {
assert_eq!(db().type_of(&PathBuf::from("/d/mystery.qqq")), None);
assert_eq!(db().type_of(&PathBuf::from("/d/no-extension")), None);
}
#[test]
fn every_matching_rule_can_be_listed() {
assert_eq!(
db().all_matches(&PathBuf::from("backup.tar.gz")),
["application/x-compressed-tar", "application/gzip"]
);
assert_eq!(db().all_matches(&PathBuf::from("a.png")), ["image/png", "image/apng"]);
assert!(db().all_matches(&PathBuf::from("mystery.qqq")).is_empty());
}
#[test]
fn matching_ignores_case_unless_the_rule_asks_otherwise() {
assert_eq!(db().type_of(&PathBuf::from("SHOUTING.STL")), Some("model/stl"));
assert_eq!(db().type_of(&PathBuf::from("Makefile")), Some("text/x-makefile"));
assert_eq!(db().type_of(&PathBuf::from("makefile")), None, "cs means exactly that");
}
#[test]
fn an_exact_case_match_is_preferred_to_a_folded_one() {
let globs = Globs::parse("50:text/x-c++src:*.C\n50:text/x-csrc:*.c\n");
assert_eq!(globs.type_of(&PathBuf::from("main.c")), Some("text/x-csrc"));
assert_eq!(globs.type_of(&PathBuf::from("main.C")), Some("text/x-c++src"));
let upper = Globs::parse("50:text/plain:*.txt\n");
assert_eq!(upper.type_of(&PathBuf::from("README.TXT")), Some("text/plain"));
}
#[test]
fn a_registered_type_beats_an_unregistered_one_on_a_tie() {
let globs = Globs::parse(
"50:application/x-coff:*.obj\n50:application/x-tgif:*.obj\n50:model/obj:*.obj\n",
);
assert_eq!(globs.type_of(&PathBuf::from("bracket.obj")), Some("model/obj"));
}
#[test]
fn a_character_class_matches_a_range() {
assert_eq!(db().type_of(&PathBuf::from("libc.so.6")), Some("application/x-sharedlib"));
assert_eq!(db().type_of(&PathBuf::from("libc.so.x")), None);
}
#[test]
fn an_unreadable_line_costs_that_line_alone() {
let globs = Globs::parse("nonsense\n50:text/plain:*.txt\nalso:nonsense\n\n");
assert_eq!(globs.type_of(&PathBuf::from("a.txt")), Some("text/plain"));
assert!(!globs.is_empty());
}
#[test]
fn the_older_globs_format_is_read_when_that_is_all_there_is() {
let globs = Globs::parse_legacy(
"# a test file\napplication/x-perl:*.pl\napplication/x-compressed-tar:*.tar.gz\n\
application/x-gzip:*.gz\ntext/x-makefile:[Mm]akefile\n",
);
assert_eq!(globs.type_of(&PathBuf::from("script.pl")), Some("application/x-perl"));
assert_eq!(
globs.type_of(&PathBuf::from("script.tar.gz")),
Some("application/x-compressed-tar"),
"the longer pattern still wins at equal weight"
);
assert_eq!(globs.type_of(&PathBuf::from("Makefile")), Some("text/x-makefile"));
}
#[test]
fn a_machine_with_no_database_is_empty_rather_than_wrong() {
let globs = Globs::load_from(&[PathBuf::from("/nonexistent-xyz")]);
assert!(globs.is_empty());
assert_eq!(globs.type_of(&PathBuf::from("a.png")), None);
}
}