1use std::path::{Path, PathBuf};
31use std::process::Command;
32
33#[derive(Clone, Debug, PartialEq, Eq, Hash)]
35pub enum Launch {
36 Editor,
38 System,
40 Reveal,
42 Shell(String),
45 Extract,
48}
49
50#[derive(Clone, Debug, PartialEq, Eq, Hash)]
52pub struct Opener {
53 pub name: String,
55 pub desc: String,
57 pub launch: Launch,
58 pub block: bool,
61}
62
63impl Opener {
64 pub fn new(name: impl Into<String>, desc: impl Into<String>, launch: Launch) -> Opener {
66 Opener {
67 name: name.into(),
68 desc: desc.into(),
69 launch,
70 block: false,
71 }
72 }
73
74 #[must_use]
76 pub fn block(mut self, on: bool) -> Opener {
77 self.block = on;
78 self
79 }
80
81 pub fn commands(&self, paths: &[PathBuf]) -> Vec<Command> {
85 if paths.is_empty() {
86 return Vec::new();
87 }
88 match &self.launch {
89 Launch::Editor => {
90 let mut command = editor();
91 command.args(paths);
92 vec![command]
93 }
94 Launch::System => paths
95 .iter()
96 .map(|path| {
97 let mut command = system_opener();
98 command.arg(path);
99 command
100 })
101 .collect(),
102 Launch::Reveal => {
103 let mut dirs: Vec<PathBuf> = paths
104 .iter()
105 .map(|p| p.parent().map_or(p.clone(), Path::to_path_buf))
106 .collect();
107 dirs.dedup();
108 dirs.into_iter()
109 .map(|dir| {
110 let mut command = system_opener();
111 command.arg(dir);
112 command
113 })
114 .collect()
115 }
116 Launch::Shell(run) => vec![shell(run, paths)],
117 Launch::Extract => Vec::new(),
118 }
119 }
120}
121
122fn shell(run: &str, paths: &[PathBuf]) -> Command {
124 if cfg!(windows) {
125 let quoted: Vec<String> = paths
126 .iter()
127 .map(|p| format!("\"{}\"", p.display()))
128 .collect();
129 let mut command = Command::new("cmd");
130 command.arg("/C").arg(run.replace("%*", "ed.join(" ")));
131 command
132 } else {
133 let mut command = Command::new("sh");
134 command.arg("-c").arg(run).arg("sh").args(paths);
136 command
137 }
138}
139
140#[derive(Clone, Debug, PartialEq, Eq, Hash)]
146pub struct Rule {
147 pub mime: Option<String>,
148 pub name: Option<String>,
149 pub openers: Vec<String>,
150}
151
152impl Rule {
153 pub fn mime(glob: impl Into<String>, openers: &[&str]) -> Rule {
155 Rule {
156 mime: Some(glob.into()),
157 name: None,
158 openers: openers.iter().map(|s| s.to_string()).collect(),
159 }
160 }
161
162 pub fn name(glob: impl Into<String>, openers: &[&str]) -> Rule {
164 Rule {
165 mime: None,
166 name: Some(glob.into()),
167 openers: openers.iter().map(|s| s.to_string()).collect(),
168 }
169 }
170
171 pub fn matches(&self, name: &str, mime: &str) -> bool {
174 self.mime.as_deref().is_none_or(|g| glob_match(g, mime))
175 && self.name.as_deref().is_none_or(|g| glob_match(g, name))
176 }
177}
178
179#[derive(Clone, Debug, Default, PartialEq, Eq)]
183pub struct Rules {
184 pub openers: Vec<Opener>,
185 pub rules: Vec<Rule>,
186}
187
188const ARCHIVES: &str = "application/{zip,gzip,x-tar,x-bzip2,x-xz,zstd,x-7z-compressed,vnd.rar,x-lzip,vnd.ms-cab-compressed}";
190
191impl Rules {
192 pub fn new() -> Rules {
194 Rules::default()
195 }
196
197 pub fn yazi() -> Rules {
203 Rules::new()
204 .opener(Opener::new("edit", "$EDITOR", Launch::Editor).block(true))
205 .opener(Opener::new("open", "Open", Launch::System))
206 .opener(Opener::new("play", "Play", Launch::System))
207 .opener(Opener::new("reveal", "Reveal", Launch::Reveal))
208 .opener(Opener::new("extract", "Extract here", Launch::Extract))
209 .rule(Rule::name("*/", &["edit", "open", "reveal"]))
210 .rule(Rule::mime("text/*", &["edit", "reveal"]))
211 .rule(Rule::mime("image/*", &["open", "reveal"]))
212 .rule(Rule::mime("{audio,video}/*", &["play", "reveal"]))
213 .rule(Rule::mime(ARCHIVES, &["extract", "reveal"]))
214 .rule(Rule::mime(
215 "application/{json,x-ndjson,toml,yaml,xml}",
216 &["edit", "reveal"],
217 ))
218 .rule(Rule::mime("*/javascript", &["edit", "reveal"]))
219 .rule(Rule::mime("inode/x-empty", &["edit", "reveal"]))
220 .rule(Rule::name("*", &["open", "reveal"]))
221 }
222
223 #[must_use]
225 pub fn opener(mut self, opener: Opener) -> Rules {
226 self.openers.retain(|o| o.name != opener.name);
227 self.openers.push(opener);
228 self
229 }
230
231 #[must_use]
233 pub fn rule(mut self, rule: Rule) -> Rules {
234 self.rules.push(rule);
235 self
236 }
237
238 #[must_use]
240 pub fn prepend(mut self, rule: Rule) -> Rules {
241 self.rules.insert(0, rule);
242 self
243 }
244
245 pub fn get(&self, name: &str) -> Option<&Opener> {
247 self.openers.iter().find(|o| o.name == name)
248 }
249
250 pub fn matching(&self, path: &Path, mime: &str) -> Vec<&Opener> {
254 let mut name = path.file_name().map_or_else(
255 || path.display().to_string(),
256 |n| n.to_string_lossy().into_owned(),
257 );
258 if mime == "inode/directory" {
259 name.push('/');
260 }
261 self.rules
262 .iter()
263 .find(|rule| rule.matches(&name, mime))
264 .map(|rule| rule.openers.iter().filter_map(|n| self.get(n)).collect())
265 .unwrap_or_default()
266 }
267
268 pub fn shared(&self, files: &[(PathBuf, &str)]) -> Vec<&Opener> {
271 let mut shared: Option<Vec<&Opener>> = None;
272 for (path, mime) in files {
273 let these = self.matching(path, mime);
274 shared = Some(match shared {
275 None => these,
276 Some(before) => before.into_iter().filter(|o| these.contains(o)).collect(),
277 });
278 }
279 shared.unwrap_or_default()
280 }
281}
282
283pub fn glob_match(pattern: &str, text: &str) -> bool {
295 expand(pattern).iter().any(|p| {
296 wildcard(
297 &p.chars().collect::<Vec<_>>(),
298 &text.chars().collect::<Vec<_>>(),
299 )
300 })
301}
302
303fn expand(pattern: &str) -> Vec<String> {
305 let Some(open) = pattern.find('{') else {
306 return vec![pattern.to_string()];
307 };
308 let mut depth = 0;
310 let mut commas = Vec::new();
311 let mut close = None;
312 for (i, c) in pattern[open..].char_indices() {
313 match c {
314 '{' => depth += 1,
315 '}' => {
316 depth -= 1;
317 if depth == 0 {
318 close = Some(open + i);
319 break;
320 }
321 }
322 ',' if depth == 1 => commas.push(open + i),
323 _ => {}
324 }
325 }
326 let Some(close) = close else {
327 return vec![pattern.to_string()];
328 };
329 let (head, tail) = (&pattern[..open], &pattern[close + 1..]);
330 let mut bounds = vec![open];
331 bounds.extend(commas);
332 bounds.push(close);
333 bounds
334 .windows(2)
335 .flat_map(|w| expand(&format!("{head}{}{tail}", &pattern[w[0] + 1..w[1]])))
336 .collect()
337}
338
339fn wildcard(pattern: &[char], text: &[char]) -> bool {
340 let (mut p, mut t) = (0, 0);
342 let mut star: Option<(usize, usize)> = None;
343 while t < text.len() {
344 match pattern.get(p) {
345 Some('*') => {
346 star = Some((p, t));
347 p += 1;
348 }
349 Some('?') => {
350 p += 1;
351 t += 1;
352 }
353 Some(&c) if c == text[t] => {
354 p += 1;
355 t += 1;
356 }
357 _ => match star {
358 Some((sp, st)) => {
359 p = sp + 1;
360 t = st + 1;
361 star = Some((sp, st + 1));
362 }
363 None => return false,
364 },
365 }
366 }
367 pattern[p..].iter().all(|&c| c == '*')
368}
369
370fn command_from(var: &str, fallback: &str) -> Command {
372 let line = std::env::var(var)
373 .ok()
374 .filter(|v| !v.trim().is_empty())
375 .unwrap_or_else(|| fallback.to_string());
376 let mut words = line.split_whitespace();
377 let mut command = Command::new(words.next().unwrap_or(fallback));
378 command.args(words);
379 command
380}
381
382pub fn system_opener() -> Command {
385 if cfg!(windows) {
386 let mut command = command_from("OPENER", "cmd");
387 if std::env::var_os("OPENER").is_none() {
388 command.args(["/C", "start", ""]);
389 }
390 command
391 } else if cfg!(target_os = "macos") {
392 command_from("OPENER", "open")
393 } else {
394 command_from("OPENER", "xdg-open")
395 }
396}
397
398pub fn editor() -> Command {
401 command_from("EDITOR", if cfg!(windows) { "notepad" } else { "vi" })
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407
408 fn names(openers: &[&Opener]) -> Vec<String> {
409 openers.iter().map(|o| o.name.clone()).collect()
410 }
411
412 #[test]
413 fn yazis_rules_pick_by_type_and_name() {
414 let rules = Rules::yazi();
415 let pick = |name: &str, mime: &str| names(&rules.matching(Path::new(name), mime));
416 assert_eq!(pick("src", "inode/directory"), ["edit", "open", "reveal"]);
417 assert_eq!(pick("a.rs", "text/x-rust"), ["edit", "reveal"]);
418 assert_eq!(pick("a.mp3", "audio/mpeg"), ["play", "reveal"]);
419 assert_eq!(pick("a.tar", "application/x-tar"), ["extract", "reveal"]);
420 assert_eq!(pick("a.json", "application/json"), ["edit", "reveal"]);
421 assert_eq!(pick("empty", "inode/x-empty"), ["edit", "reveal"]);
422 assert_eq!(pick("a.pdf", "application/pdf"), ["open", "reveal"]);
423 }
424
425 #[test]
426 fn own_rules_come_first_and_selections_share() {
427 let rules = Rules::yazi()
428 .opener(Opener::new("view", "View", Launch::Shell("less \"$@\"".into())).block(true))
429 .prepend(Rule::name("*.log", &["view", "edit"]));
430 assert_eq!(
431 names(&rules.matching(Path::new("x.log"), "text/plain")),
432 ["view", "edit"]
433 );
434 let shared = rules.shared(&[
435 (PathBuf::from("a.txt"), "text/plain"),
436 (PathBuf::from("b.png"), "image/png"),
437 ]);
438 assert_eq!(names(&shared), ["reveal"]);
439 }
440
441 #[test]
442 fn commands_for_each_launch() {
443 let paths = [PathBuf::from("/tmp/a b.txt"), PathBuf::from("/tmp/c.txt")];
444 let rules = Rules::yazi();
445 assert_eq!(rules.get("edit").unwrap().commands(&paths).len(), 1);
446 assert_eq!(rules.get("open").unwrap().commands(&paths).len(), 2);
447 assert_eq!(rules.get("reveal").unwrap().commands(&paths).len(), 1);
449 assert!(rules.get("extract").unwrap().commands(&paths).is_empty());
450 #[cfg(unix)]
451 {
452 let shell = Opener::new("count", "Count", Launch::Shell("echo $# \"$@\"".into()));
453 let out = shell.commands(&paths)[0].output().unwrap();
454 assert_eq!(
455 String::from_utf8_lossy(&out.stdout).trim(),
456 "2 /tmp/a b.txt /tmp/c.txt"
457 );
458 }
459 }
460
461 #[test]
462 fn globs() {
463 assert!(glob_match("*", ""));
464 assert!(glob_match("*/", "dir/"));
465 assert!(!glob_match("*/", "file"));
466 assert!(glob_match("a{b,{c,d}}e", "ade"));
467 assert!(glob_match("*.{tar.gz,tgz}", "x.tgz"));
468 assert!(!glob_match("a?c", "ac"));
469 assert!(glob_match("*a*b", "xxaxxb"));
470 }
471}