Skip to main content

rich_ext/
open.rs

1//! Which program opens a file: [Yazi](https://github.com/sxyazi/yazi)'s
2//! `[opener]` and `[open]` tables as data. An [`Opener`] says how to open
3//! (the editor, the system's opener, a folder view, a shell command, or
4//! extracting an archive) and whether it takes the terminal; a [`Rule`]
5//! picks openers by MIME type or name glob; [`Rules::matching`] gives the
6//! openers for a file, the first one what Enter uses.
7//!
8//! Not a port of anything upstream: an rs-rich addition, with no
9//! dependencies. It builds [`Command`]s and runs nothing itself, so it
10//! needs no TUI; intuiTUIve's `files` components run a blocking opener
11//! through a handoff of the terminal and the others in the background.
12//!
13//! ```
14//! use std::path::Path;
15//! use rich_ext::open::{Launch, Rules};
16//!
17//! let rules = Rules::yazi();
18//! let openers = rules.matching(Path::new("notes.md"), "text/markdown");
19//! assert_eq!(openers[0].launch, Launch::Editor);
20//! assert!(openers[0].block);
21//! let openers = rules.matching(Path::new("photo.jpg"), "image/jpeg");
22//! assert_eq!(openers[0].launch, Launch::System);
23//! ```
24//!
25//! The commands: [`editor`] is `$EDITOR` (else `vi`, Notepad on Windows),
26//! [`system_opener`] `$OPENER` (else `xdg-open`, `open` on macOS, `cmd /C
27//! start` on Windows). A [`Launch::Shell`] command runs as Yazi's do, in
28//! `sh -c` with the files as `"$@"` (`cmd /C` with `%*` on Windows).
29
30use std::path::{Path, PathBuf};
31use std::process::Command;
32
33/// How an [`Opener`] opens files.
34#[derive(Clone, Debug, PartialEq, Eq, Hash)]
35pub enum Launch {
36    /// `$EDITOR` with the files.
37    Editor,
38    /// The system's opener, once for each file.
39    System,
40    /// The system's opener on each file's directory.
41    Reveal,
42    /// A shell command, the files as `"$@"` (`%*` on Windows): Yazi's
43    /// `run`, such as `mpv --force-window "$@"`.
44    Shell(String),
45    /// Extract each archive beside itself. No program: the caller does it
46    /// (rs-rich-ext's `archive` module, behind its `archive` feature).
47    Extract,
48}
49
50/// A way to open files: Yazi's `[opener]` entries.
51#[derive(Clone, Debug, PartialEq, Eq, Hash)]
52pub struct Opener {
53    /// The name rules use for it (`edit`, `open`, `reveal`, …).
54    pub name: String,
55    /// What a picker shows (`$EDITOR`, `Open`, `Reveal`).
56    pub desc: String,
57    pub launch: Launch,
58    /// It takes the terminal until it ends (an editor, a pager), rather
59    /// than running in the background.
60    pub block: bool,
61}
62
63impl Opener {
64    /// An opener called `name`, shown as `desc`, in the background.
65    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    /// Taking the terminal (or not).
75    #[must_use]
76    pub fn block(mut self, on: bool) -> Opener {
77        self.block = on;
78        self
79    }
80
81    /// The commands that open `paths` this way: one for the editor or a
82    /// shell command, one a file for the system opener and reveal, none
83    /// for [`Launch::Extract`]. None either when `paths` is empty.
84    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
122/// `run` in the platform's shell with `paths` as its arguments.
123fn 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("%*", &quoted.join(" ")));
131        command
132    } else {
133        let mut command = Command::new("sh");
134        // `$0` is the command's name; the files are `$1…`, `"$@"`.
135        command.arg("-c").arg(run).arg("sh").args(paths);
136        command
137    }
138}
139
140/// A rule of Yazi's `[open]` table: files whose MIME type matches `mime`
141/// (a glob: `text/*`, `{audio,video}/*`) and whose name matches `name`
142/// (a glob; a directory's name ends in `/`, so `*/` is any directory) are
143/// opened with the openers called `openers`, in order. A rule without a
144/// `mime` or `name` matches any.
145#[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    /// Files whose MIME type matches `glob`.
154    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    /// Files whose name matches `glob`.
163    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    /// Whether the rule takes a file called `name` (ending in `/` for a
172    /// directory) of type `mime`.
173    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/// Openers and the rules that pick them. The first rule that matches a
180/// file decides; [`Rules::prepend`] puts a rule of your own before the
181/// defaults, as Yazi's `prepend_rules`.
182#[derive(Clone, Debug, Default, PartialEq, Eq)]
183pub struct Rules {
184    pub openers: Vec<Opener>,
185    pub rules: Vec<Rule>,
186}
187
188/// The MIME types of archives Yazi's `extract` opener takes.
189const 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    /// No openers and no rules.
193    pub fn new() -> Rules {
194        Rules::default()
195    }
196
197    /// Yazi's defaults: `edit` (`$EDITOR`, blocking), `open` and `play`
198    /// (the system's opener), `reveal` (the file's folder) and `extract`;
199    /// directories, text, JSON, JavaScript and empty files go to the
200    /// editor first, media to `play`, archives to `extract`, anything else
201    /// to `open`, each with `reveal` after it.
202    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    /// Add an opener (replacing one of the same name).
224    #[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    /// Add a rule after the others.
232    #[must_use]
233    pub fn rule(mut self, rule: Rule) -> Rules {
234        self.rules.push(rule);
235        self
236    }
237
238    /// Add a rule before the others.
239    #[must_use]
240    pub fn prepend(mut self, rule: Rule) -> Rules {
241        self.rules.insert(0, rule);
242        self
243    }
244
245    /// The opener called `name`.
246    pub fn get(&self, name: &str) -> Option<&Opener> {
247        self.openers.iter().find(|o| o.name == name)
248    }
249
250    /// The openers for the file at `path` of type `mime` (pass
251    /// `inode/directory` for a directory): the first matching rule's, in
252    /// its order, the names it uses that are not openers left out.
253    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    /// The openers every one of `files` (path and MIME type) can use, in
269    /// the first one's order: what "Open with…" offers for a selection.
270    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
283/// Whether `text` matches the glob `pattern`: `*` any run of characters,
284/// `?` one, `{a,b}` either (nesting allowed), anything else itself.
285///
286/// ```
287/// use rich_ext::open::glob_match;
288///
289/// assert!(glob_match("text/*", "text/x-rust"));
290/// assert!(glob_match("{audio,video}/*", "video/mp4"));
291/// assert!(glob_match("*.tar.?z", "a.tar.gz"));
292/// assert!(!glob_match("image/*", "text/plain"));
293/// ```
294pub 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
303/// The patterns `{a,b}` alternatives stand for.
304fn expand(pattern: &str) -> Vec<String> {
305    let Some(open) = pattern.find('{') else {
306        return vec![pattern.to_string()];
307    };
308    // The matching close brace, and the commas at this level.
309    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    // Greedy with backtracking to the last `*`.
341    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
370/// The program and arguments in `$VAR`, split at spaces, else `fallback`.
371fn 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
382/// The system's opener: `$OPENER` (a program and its arguments), else
383/// `xdg-open`, `open` on macOS, `cmd /C start ""` on Windows. Add the file.
384pub 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
398/// `$EDITOR` (a program and its arguments), else `vi` (Notepad on
399/// Windows). Add the files.
400pub 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        // Both files are in /tmp: one folder to show.
448        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}