ltk/files/
mime.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
// SPDX-License-Identifier: LGPL-2.1-only
// Copyright (C) 2026 Liberux Labs, S. L. <info@liberux.net>

//! A file's type from its name, by shared-mime-info's `globs2`: the resource directory's `mime/globs2` where an app bundles it, as a page does, else the system's.

use std::path::PathBuf;
use std::sync::OnceLock;

struct Glob
{
	weight:         u32,
	mime:           String,
	pattern:        String,
	case_sensitive: bool,
}

/// The MIME type shared-mime-info gives a file named `name`, from its globs alone.
pub fn mime_type( name: &str ) -> Option<&'static str>
{
	static GLOBS: OnceLock<Vec<Glob>> = OnceLock::new();
	best( GLOBS.get_or_init( load ), name )
}

fn load() -> Vec<Glob>
{
	let bundled = crate::resource_dir().map( |dir| dir.join( "mime" ) );
	let home    = crate::dirs::data_home();
	let system  = std::env::var( "XDG_DATA_DIRS" ).ok().filter( |dirs| !dirs.is_empty() ).unwrap_or_else( || "/usr/local/share:/usr/share".into() );
	let dirs    = bundled.into_iter()
		.chain( home.map( |dir| dir.join( "mime" ) ) )
		.chain( system.split( ':' ).map( |dir| PathBuf::from( dir ).join( "mime" ) ) );
	let mut globs = Vec::new();
	for dir in dirs
	{
		if let Ok( text ) = crate::vfs::read_to_string( dir.join( "globs2" ) ) { globs.extend( parse( &text ) ); }
	}
	globs
}

fn parse( text: &str ) -> impl Iterator<Item = Glob> + '_
{
	text.lines().filter( |line| !line.starts_with( '#' ) ).filter_map( |line|
	{
		let mut fields = line.splitn( 4, ':' );
		let weight     = fields.next()?.parse().ok()?;
		let mime       = fields.next()?.to_string();
		let pattern    = fields.next()?.to_string();
		let flags      = fields.next().unwrap_or_default();
		let case_sensitive = flags.split( ',' ).any( |flag| flag == "cs" );
		Some( Glob { weight, mime, pattern: if case_sensitive { pattern } else { pattern.to_lowercase() }, case_sensitive } )
	} )
}

/// The heaviest glob that matches, then a case-sensitive one, then the longest; the first directory's wins a tie.
fn best<'a>( globs: &'a [ Glob ], name: &str ) -> Option<&'a str>
{
	let lower = name.to_lowercase();
	let mut found: Option<&Glob> = None;
	for glob in globs
	{
		let subject = if glob.case_sensitive { name } else { &lower };
		if !matches( &glob.pattern, subject ) { continue; }
		let better = found.is_none_or( |f| ( glob.weight, glob.case_sensitive, glob.pattern.len() ) > ( f.weight, f.case_sensitive, f.pattern.len() ) );
		if better { found = Some( glob ); }
	}
	found.map( |glob| glob.mime.as_str() )
}

/// `fnmatch` without flags: `*`, `?` and bracket classes.
fn matches( pattern: &str, name: &str ) -> bool
{
	let pattern: Vec<char> = pattern.chars().collect();
	let name:    Vec<char> = name.chars().collect();
	matches_at( &pattern, &name )
}

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( ( &c, rest ) ) = name.split_first() else { return false };
			match class( &pattern[ 1.. ], c )
			{
				Some( ( true, after ) ) => matches_at( after, rest ),
				Some( ( false, _ ) )    => false,
				None                    => c == '[' && matches_at( &pattern[ 1.. ], rest ),
			}
		}
		Some( &p ) => name.first() == Some( &p ) && matches_at( &pattern[ 1.. ], &name[ 1.. ] ),
	}
}

/// Whether `c` is in the class that `pattern` opens, and what follows the class; `None` when it is never closed.
fn class( pattern: &[ char ], c: char ) -> Option<( bool, &[ char ] )>
{
	let ( negated, mut i ) = match pattern.first() { Some( '!' ) | Some( '^' ) => ( true, 1 ), _ => ( false, 0 ) };
	let mut hit   = false;
	let mut first = true;
	while i < pattern.len()
	{
		let p = pattern[ i ];
		if p == ']' && !first { return Some( ( hit != negated, &pattern[ i + 1.. ] ) ); }
		first = false;
		if pattern.get( i + 1 ) == Some( &'-' ) && pattern.get( i + 2 ).is_some_and( |&end| end != ']' )
		{
			hit |= ( p..=pattern[ i + 2 ] ).contains( &c );
			i += 3;
		} else {
			hit |= p == c;
			i += 1;
		}
	}
	None
}

#[ cfg( test ) ]
mod tests
{
	use super::*;

	const GLOBS2: &str = "\
# This file was automatically generated
50:text/x-csrc:*.c
50:text/x-c++src:*.C:cs
50:image/jpeg:*.jpg
50:image/jpeg:*.jpeg
50:application/x-compressed-tar:*.tar.gz
50:application/gzip:*.gz
10:text/x-readme:readme*
50:text/x-chdr:*.[ch]h
";

	fn globs() -> Vec<Glob>
	{
		parse( GLOBS2 ).collect()
	}

	#[ test ]
	fn extensions_ignore_case_unless_told()
	{
		let globs = globs();
		assert_eq!( best( &globs, "photo.JPG" ), Some( "image/jpeg" ) );
		assert_eq!( best( &globs, "main.c" ), Some( "text/x-csrc" ) );
		assert_eq!( best( &globs, "main.C" ), Some( "text/x-c++src" ) );
	}

	#[ test ]
	fn the_longest_pattern_of_a_weight_wins()
	{
		assert_eq!( best( &globs(), "src.tar.gz" ), Some( "application/x-compressed-tar" ) );
		assert_eq!( best( &globs(), "log.gz" ), Some( "application/gzip" ) );
	}

	#[ test ]
	fn wildcards_and_classes_match()
	{
		let globs = globs();
		assert_eq!( best( &globs, "README.md" ), Some( "text/x-readme" ) );
		assert_eq!( best( &globs, "util.hh" ), Some( "text/x-chdr" ) );
		assert_eq!( best( &globs, "util.ch" ), Some( "text/x-chdr" ) );
		assert_eq!( best( &globs, "util.xh" ), None );
		assert_eq!( best( &globs, "noextension" ), None );
	}

	#[ test ]
	fn classes_take_ranges_and_negation()
	{
		assert!( matches( "file[0-9]", "file7" ) );
		assert!( !matches( "file[!0-9]", "file7" ) );
		assert!( matches( "a[]]b", "a]b" ) );
		assert!( matches( "a[b", "a[b" ) );
	}
}