Skip to main content

xei_core/
hooks.rs

1//! Limited plugin hooks — run shell commands on editor events.
2//!
3//! Config: `~/.xei/hooks.toml`
4//!
5//! ```toml
6//! # Placeholders: {file} {path} {dir} {ext} {event}  (shell-quoted automatically)
7//! on_save = "echo saved {file}"
8//! on_open = ""
9//! on_quit = ""
10//! enabled = true
11//! ```
12//!
13//! Multiple commands: separate with `;;` (each runs sequentially).
14//! Hooks run on a background thread (the editor never blocks); each command is
15//! killed after 10s. `on_quit` is fire-and-forget so quitting stays instant.
16
17use std::io::Read;
18use std::path::{Path, PathBuf};
19use std::process::{Command, Stdio};
20use std::time::{Duration, Instant};
21
22/// Per-command wall-clock budget before the hook is killed.
23const HOOK_TIMEOUT: Duration = Duration::from_secs(10);
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum HookEvent {
27    Save,
28    Open,
29    Quit,
30}
31
32impl HookEvent {
33    pub fn as_str(self) -> &'static str {
34        match self {
35            HookEvent::Save => "save",
36            HookEvent::Open => "open",
37            HookEvent::Quit => "quit",
38        }
39    }
40
41    pub fn config_key(self) -> &'static str {
42        match self {
43            HookEvent::Save => "on_save",
44            HookEvent::Open => "on_open",
45            HookEvent::Quit => "on_quit",
46        }
47    }
48}
49
50#[derive(Debug, Clone)]
51pub struct HooksConfig {
52    pub enabled: bool,
53    pub on_save: String,
54    pub on_open: String,
55    pub on_quit: String,
56}
57
58impl Default for HooksConfig {
59    fn default() -> Self {
60        Self {
61            enabled: true,
62            on_save: String::new(),
63            on_open: String::new(),
64            on_quit: String::new(),
65        }
66    }
67}
68
69impl HooksConfig {
70    pub fn config_path() -> PathBuf {
71        dirs_fallback().join("hooks.toml")
72    }
73
74    pub fn load() -> Self {
75        let path = Self::config_path();
76        let Ok(text) = std::fs::read_to_string(&path) else {
77            return Self::default();
78        };
79        parse_hooks_toml(&text)
80    }
81
82    pub fn for_event(&self, ev: HookEvent) -> &str {
83        match ev {
84            HookEvent::Save => &self.on_save,
85            HookEvent::Open => &self.on_open,
86            HookEvent::Quit => &self.on_quit,
87        }
88    }
89
90    /// True when this event would actually run something.
91    pub fn has_hook(&self, ev: HookEvent) -> bool {
92        self.enabled && !self.for_event(ev).trim().is_empty()
93    }
94}
95
96fn dirs_fallback() -> PathBuf {
97    if let Some(h) =
98        std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))
99    {
100        return PathBuf::from(h).join(".xei");
101    }
102    PathBuf::from(".xei")
103}
104
105fn parse_hooks_toml(text: &str) -> HooksConfig {
106    let mut cfg = HooksConfig::default();
107    for line in text.lines() {
108        let line = line.trim();
109        if line.is_empty() || line.starts_with('#') || line.starts_with('[') {
110            continue;
111        }
112        let Some((k, v)) = line.split_once('=') else {
113            continue;
114        };
115        let k = k.trim();
116        let v = parse_toml_value(v);
117        match k {
118            "enabled" => {
119                cfg.enabled = matches!(v.to_ascii_lowercase().as_str(), "true" | "1" | "yes" | "on");
120            }
121            "on_save" => cfg.on_save = v,
122            "on_open" => cfg.on_open = v,
123            "on_quit" => cfg.on_quit = v,
124            _ => {}
125        }
126    }
127    cfg
128}
129
130/// Quoted value up to the closing quote (inline `# comment` after it ignored);
131/// bare value up to the first `#`.
132fn parse_toml_value(raw: &str) -> String {
133    let v = raw.trim();
134    for quote in ['"', '\''] {
135        if let Some(rest) = v.strip_prefix(quote) {
136            if let Some(end) = rest.find(quote) {
137                return rest[..end].to_string();
138            }
139            return rest.to_string();
140        }
141    }
142    v.split('#').next().unwrap_or("").trim().to_string()
143}
144
145/// Single-quote `s` for `sh -c` (`'` → `'\''`), so paths with spaces or quotes
146/// survive placeholder substitution.
147fn sh_quote(s: &str) -> String {
148    format!("'{}'", s.replace('\'', r"'\''"))
149}
150
151/// Expanded commands for `event`: (command line, cwd).
152fn expand_commands(
153    cfg: &HooksConfig,
154    event: HookEvent,
155    file: Option<&Path>,
156) -> Vec<(String, PathBuf)> {
157    if !cfg.has_hook(event) {
158        return Vec::new();
159    }
160    let path = file.map(|p| p.display().to_string()).unwrap_or_default();
161    let dir = file
162        .and_then(|p| p.parent())
163        .map(|p| p.display().to_string())
164        .unwrap_or_else(|| {
165            std::env::current_dir()
166                .map(|p| p.display().to_string())
167                .unwrap_or_else(|_| ".".into())
168        });
169    let name = file
170        .and_then(|p| p.file_name())
171        .and_then(|n| n.to_str())
172        .unwrap_or("")
173        .to_string();
174    let ext = file
175        .and_then(|p| p.extension())
176        .and_then(|e| e.to_str())
177        .unwrap_or("")
178        .to_string();
179    let cwd = file
180        .and_then(|p| p.parent())
181        .map(Path::to_path_buf)
182        .unwrap_or_else(|| PathBuf::from("."));
183
184    cfg.for_event(event)
185        .split(";;")
186        .filter_map(|raw| {
187            let cmd = raw
188                .trim()
189                .replace("{file}", &sh_quote(&name))
190                .replace("{path}", &sh_quote(&path))
191                .replace("{dir}", &sh_quote(&dir))
192                .replace("{ext}", &sh_quote(&ext))
193                .replace("{event}", event.as_str());
194            if cmd.is_empty() {
195                None
196            } else {
197                Some((cmd, cwd.clone()))
198            }
199        })
200        .collect()
201}
202
203/// Run hook commands, waiting up to [`HOOK_TIMEOUT`] each. Returns the last
204/// status line. Blocking — call from a background thread.
205pub fn run_hooks(
206    cfg: &HooksConfig,
207    event: HookEvent,
208    file: Option<&Path>,
209) -> Option<String> {
210    let mut last_msg = None;
211    for (cmd, cwd) in expand_commands(cfg, event, file) {
212        match run_with_timeout(&cmd, &cwd) {
213            HookOutcome::Ok(line) => {
214                if !line.is_empty() {
215                    last_msg = Some(line);
216                }
217            }
218            HookOutcome::Failed(err) => {
219                last_msg = Some(format!("hook({}): {err}", event.as_str()));
220            }
221            HookOutcome::TimedOut => {
222                last_msg = Some(format!(
223                    "hook({}): timed out ({}s), killed",
224                    event.as_str(),
225                    HOOK_TIMEOUT.as_secs()
226                ));
227            }
228        }
229    }
230    last_msg
231}
232
233/// Platform shell: `sh -c` on unix, `cmd /C` on Windows (hooks are written
234/// for the platform they run on — placeholders stay POSIX-quoted).
235fn shell_command(cmd: &str) -> Command {
236    if cfg!(windows) {
237        let mut c = Command::new("cmd");
238        c.arg("/C").arg(cmd);
239        c
240    } else {
241        let mut c = Command::new("sh");
242        c.arg("-c").arg(cmd);
243        c
244    }
245}
246
247/// Fire-and-forget: spawn commands without waiting, so quit stays instant.
248/// The children keep running after the editor exits.
249pub fn run_hooks_detached(cfg: &HooksConfig, event: HookEvent, file: Option<&Path>) {
250    for (cmd, cwd) in expand_commands(cfg, event, file) {
251        let _ = shell_command(&cmd)
252            .current_dir(&cwd)
253            .stdin(Stdio::null())
254            .stdout(Stdio::null())
255            .stderr(Stdio::null())
256            .spawn();
257    }
258}
259
260enum HookOutcome {
261    /// First non-empty stdout line ("" when silent)
262    Ok(String),
263    Failed(String),
264    TimedOut,
265}
266
267fn run_with_timeout(cmd: &str, cwd: &Path) -> HookOutcome {
268    let mut child = match shell_command(cmd)
269        .current_dir(cwd)
270        .stdin(Stdio::null())
271        .stdout(Stdio::piped())
272        .stderr(Stdio::piped())
273        .spawn()
274    {
275        Ok(c) => c,
276        Err(e) => return HookOutcome::Failed(format!("spawn: {e}")),
277    };
278    // Drain pipes on side threads so a chatty hook can't deadlock on a full pipe.
279    let stdout = child.stdout.take().map(drain_to_string);
280    let stderr = child.stderr.take().map(drain_to_string);
281
282    let deadline = Instant::now() + HOOK_TIMEOUT;
283    let status = loop {
284        match child.try_wait() {
285            Ok(Some(status)) => break Some(status),
286            Ok(None) => {
287                if Instant::now() >= deadline {
288                    let _ = child.kill();
289                    let _ = child.wait();
290                    break None;
291                }
292                std::thread::sleep(Duration::from_millis(50));
293            }
294            Err(_) => break None,
295        }
296    };
297    let out = stdout.map(|h| h.join().unwrap_or_default()).unwrap_or_default();
298    let err = stderr.map(|h| h.join().unwrap_or_default()).unwrap_or_default();
299
300    match status {
301        None => HookOutcome::TimedOut,
302        Some(s) if s.success() => HookOutcome::Ok(
303            out.lines()
304                .map(str::trim)
305                .find(|l| !l.is_empty())
306                .unwrap_or("")
307                .to_string(),
308        ),
309        Some(_) => HookOutcome::Failed(
310            err.lines()
311                .chain(out.lines())
312                .map(str::trim)
313                .find(|l| !l.is_empty())
314                .unwrap_or("hook failed")
315                .to_string(),
316        ),
317    }
318}
319
320fn drain_to_string<R: Read + Send + 'static>(mut r: R) -> std::thread::JoinHandle<String> {
321    std::thread::spawn(move || {
322        let mut s = String::new();
323        let mut buf = [0u8; 4096];
324        loop {
325            match r.read(&mut buf) {
326                Ok(0) | Err(_) => break,
327                Ok(n) => s.push_str(&String::from_utf8_lossy(&buf[..n])),
328            }
329        }
330        s
331    })
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn parse_basic() {
340        let t = r#"
341enabled = true
342on_save = "echo {file}"
343on_open = ""
344"#;
345        let c = parse_hooks_toml(t);
346        assert!(c.enabled);
347        assert_eq!(c.on_save, "echo {file}");
348        assert!(c.on_open.is_empty());
349    }
350
351    #[test]
352    fn parse_inline_comments_and_quotes() {
353        let t = r#"
354enabled = true # yes
355on_save = "echo hi" # runs on save
356on_open = 'echo # not a comment'
357on_quit = echo bare # trailing
358"#;
359        let c = parse_hooks_toml(t);
360        assert!(c.enabled);
361        assert_eq!(c.on_save, "echo hi");
362        assert_eq!(c.on_open, "echo # not a comment");
363        assert_eq!(c.on_quit, "echo bare");
364    }
365
366    #[test]
367    fn placeholders_are_shell_quoted() {
368        let cfg = HooksConfig {
369            enabled: true,
370            on_save: "cat {path}".into(),
371            ..Default::default()
372        };
373        let cmds = expand_commands(&cfg, HookEvent::Save, Some(Path::new("/tmp/a b'c.rs")));
374        assert_eq!(cmds.len(), 1);
375        assert_eq!(cmds[0].0, r#"cat '/tmp/a b'\''c.rs'"#);
376    }
377
378    #[test]
379    fn sh_quote_roundtrip() {
380        assert_eq!(sh_quote("plain"), "'plain'");
381        assert_eq!(sh_quote("a'b"), r"'a'\''b'");
382    }
383
384    #[test]
385    fn hook_runs_and_captures_stdout() {
386        let cfg = HooksConfig {
387            enabled: true,
388            on_save: "echo ok {event}".into(),
389            ..Default::default()
390        };
391        let msg = run_hooks(&cfg, HookEvent::Save, Some(Path::new("/tmp/x.rs")));
392        assert_eq!(msg.as_deref(), Some("ok save"));
393    }
394
395    #[test]
396    fn hook_failure_reports_stderr() {
397        let cfg = HooksConfig {
398            enabled: true,
399            on_save: "echo boom >&2; false".into(),
400            ..Default::default()
401        };
402        let msg = run_hooks(&cfg, HookEvent::Save, None);
403        assert_eq!(msg.as_deref(), Some("hook(save): boom"));
404    }
405}