Skip to main content

runex_core/
hook.rs

1//! Per-keystroke hook logic, centralising what each shell template used to
2//! re-implement in bash/zsh/pwsh/clink/nu. The goal is that shells provide a
3//! thin "buffer + cursor in, eval-able string out" adapter and all the real
4//! expansion logic — command-position detection, token extraction, cursor
5//! placeholder handling, shell escaping — lives here in Rust.
6
7use crate::expand::expand;
8use crate::model::{Config, ExpandResult, Shell};
9use crate::shell::{bash_quote_string, lua_quote_string, pwsh_quote_string};
10
11/// Outcome of a hook call — what the shell adapter should do to its buffer.
12///
13/// `line` is the full new buffer text and `cursor` is the new cursor position
14/// in **bytes** from the start of `line`. Shells are expected to replace their
15/// buffer and cursor with these values atomically.
16#[derive(Debug, Clone, PartialEq)]
17pub enum HookAction {
18    /// The token at the cursor expanded; shell should replace the buffer.
19    Replace { line: String, cursor: usize },
20    /// No expansion happened; shell should just insert a literal space at the
21    /// cursor (the conventional trigger fallback).
22    InsertSpace { line: String, cursor: usize },
23}
24
25/// Core hook entry point, shell-agnostic.
26///
27/// `line` is the shell's current buffer, `cursor` is the byte offset of the
28/// caret. If a known abbreviation ends at the cursor and the prefix is a
29/// command position, expand it; otherwise fall back to inserting a space.
30pub fn hook<F>(
31    config: &Config,
32    shell: Shell,
33    line: &str,
34    cursor: usize,
35    command_exists: F,
36) -> HookAction
37where
38    F: Fn(&str) -> bool,
39{
40    let cursor = cursor.min(line.len());
41
42    // If the cursor is not at a space boundary (mid-word), treat this like any
43    // other printable key: insert a space at the cursor and don't try to
44    // expand. This matches what each shell's template did historically.
45    if cursor < line.len() && !line[cursor..].starts_with(' ') {
46        return insert_space(line, cursor);
47    }
48
49    let left = &line[..cursor];
50    let Some(token_start) = token_start_of(left) else {
51        return insert_space(line, cursor);
52    };
53    let token = &left[token_start..];
54    if token.is_empty() {
55        return insert_space(line, cursor);
56    }
57
58    let prefix = &line[..token_start];
59    if !is_command_position(prefix) {
60        return insert_space(line, cursor);
61    }
62
63    if !is_known_token(config, token) {
64        return insert_space(line, cursor);
65    }
66
67    match expand(config, token, shell, command_exists) {
68        ExpandResult::Expanded { text, cursor_offset } => {
69            let right = &line[cursor..];
70            let mut new_line = String::with_capacity(prefix.len() + text.len() + right.len() + 1);
71            new_line.push_str(prefix);
72            new_line.push_str(&text);
73            let cursor_after_expand = match cursor_offset {
74                Some(off) => token_start + off,
75                None => token_start + text.len(),
76            };
77            // Append the trailing space that the trigger key would have
78            // produced, then everything that was to the right of the old cursor.
79            new_line.insert(cursor_after_expand, ' ');
80            new_line.push_str(right);
81            HookAction::Replace {
82                line: new_line,
83                cursor: cursor_after_expand + 1,
84            }
85        }
86        ExpandResult::PassThrough(_) => insert_space(line, cursor),
87    }
88}
89
90fn insert_space(line: &str, cursor: usize) -> HookAction {
91    let mut new_line = String::with_capacity(line.len() + 1);
92    new_line.push_str(&line[..cursor]);
93    new_line.push(' ');
94    new_line.push_str(&line[cursor..]);
95    HookAction::InsertSpace {
96        line: new_line,
97        cursor: cursor + 1,
98    }
99}
100
101/// Returns the byte index where the last whitespace-delimited token starts
102/// in `left` (i.e. the candidate abbreviation to expand). Returns `None` when
103/// `left` is empty.
104fn token_start_of(left: &str) -> Option<usize> {
105    if left.is_empty() {
106        return None;
107    }
108    Some(left.rfind(' ').map_or(0, |i| i + 1))
109}
110
111fn is_known_token(config: &Config, token: &str) -> bool {
112    config.abbr.iter().any(|abbr| abbr.key == token)
113}
114
115/// Render a `HookAction` into a shell-specific eval-able string. The shell
116/// integration script consumes this verbatim via `eval` (or equivalent).
117///
118/// ## Why a `match` instead of a trait
119///
120/// A textbook DIP refactor would introduce a `Renderer` trait with one
121/// impl per shell. We deliberately don't: there are five shells, the
122/// rendering logic for each is a single `format!`, and the shell list
123/// is closed (we wouldn't add a sixth at runtime). A trait would
124/// fragment five trivial format strings across five files without
125/// improving testability — the existing per-shell unit tests in this
126/// module already exercise each arm independently.
127pub fn render_action(shell: Shell, action: &HookAction) -> String {
128    let (line, cursor) = match action {
129        HookAction::Replace { line, cursor } | HookAction::InsertSpace { line, cursor } => {
130            (line, cursor)
131        }
132    };
133    match shell {
134        Shell::Bash => format!(
135            "READLINE_LINE={}; READLINE_POINT={}",
136            bash_quote_string(line),
137            cursor,
138        ),
139        Shell::Zsh => {
140            let (lb, rb) = line.split_at(*cursor);
141            format!("LBUFFER={}; RBUFFER={}", bash_quote_string(lb), bash_quote_string(rb))
142        }
143        Shell::Pwsh => format!(
144            "$__RUNEX_LINE = {}\n$__RUNEX_CURSOR = {}",
145            pwsh_quote_string(line),
146            cursor,
147        ),
148        Shell::Clink => format!(
149            "return {{ line = {}, cursor = {} }}",
150            lua_quote_string(line),
151            cursor,
152        ),
153        // nu has no safe `eval`; the bootstrap reads the emitted JSON object
154        // with `from json` and applies the replace/set-cursor itself. Two
155        // fields, machine-parsable, no string escaping inside nu.
156        Shell::Nu => format!(
157            "{{\"line\": {}, \"cursor\": {}}}",
158            serde_json::to_string(line).unwrap_or_else(|_| "\"\"".into()),
159            cursor,
160        ),
161    }
162}
163
164/// Returns `true` when the characters to the left of the token we're about to
165/// consider represent a position where a fresh command name is expected.
166///
167/// Command position is:
168/// - the very start of the line (after trimming trailing spaces), or
169/// - immediately after a pipeline / list operator (`|`, `||`, `&&`, `;`), or
170/// - immediately after `sudo ` that is itself in command position (e.g.
171///   `sudo gcm` should still expand `gcm`).
172///
173/// Anything else (mid-arguments, after `=`, inside a command's arguments) is
174/// treated as non-command-position and abbreviations should not expand.
175pub fn is_command_position(prefix: &str) -> bool {
176    let trimmed = trim_trailing_spaces(prefix);
177
178    if trimmed.is_empty() {
179        return true;
180    }
181
182    if ends_with_pipeline_operator(trimmed) {
183        return true;
184    }
185
186    if let Some(before_sudo) = strip_trailing_sudo(trimmed) {
187        let before_sudo = trim_trailing_spaces(before_sudo);
188        if before_sudo.is_empty() {
189            return true;
190        }
191        return ends_with_pipeline_operator(before_sudo);
192    }
193
194    false
195}
196
197fn trim_trailing_spaces(s: &str) -> &str {
198    s.trim_end_matches(' ')
199}
200
201fn ends_with_pipeline_operator(s: &str) -> bool {
202    // Order matters: check two-char operators before one-char `|`.
203    s.ends_with("&&")
204        || s.ends_with("||")
205        || s.ends_with('|')
206        || s.ends_with(';')
207}
208
209/// If `prefix` ends with the whitespace-separated word `sudo`, returns the
210/// slice before it (including any trailing whitespace that preceded `sudo`).
211/// Returns `None` otherwise.
212fn strip_trailing_sudo(prefix: &str) -> Option<&str> {
213    // Previous word is whatever follows the last space (or the whole prefix
214    // when there is no space).
215    let prev_word_start = prefix.rfind(' ').map_or(0, |i| i + 1);
216    let prev_word = &prefix[prev_word_start..];
217    if prev_word == "sudo" {
218        Some(&prefix[..prev_word_start])
219    } else {
220        None
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn command_position_empty_prefix_is_true() {
230        assert!(is_command_position(""));
231    }
232
233    #[test]
234    fn command_position_only_spaces_is_true() {
235        assert!(is_command_position("    "));
236    }
237
238    #[test]
239    fn command_position_after_pipe_is_true() {
240        assert!(is_command_position("ls | "));
241        assert!(is_command_position("ls |"));
242    }
243
244    #[test]
245    fn command_position_after_logical_or_is_true() {
246        assert!(is_command_position("foo || "));
247    }
248
249    #[test]
250    fn command_position_after_logical_and_is_true() {
251        assert!(is_command_position("foo && "));
252    }
253
254    #[test]
255    fn command_position_after_semicolon_is_true() {
256        assert!(is_command_position("foo; "));
257    }
258
259    #[test]
260    fn command_position_after_sudo_at_start_is_true() {
261        assert!(is_command_position("sudo "));
262    }
263
264    #[test]
265    fn command_position_after_sudo_following_pipe_is_true() {
266        assert!(is_command_position("ls | sudo "));
267    }
268
269    #[test]
270    fn command_position_after_sudo_mid_args_is_false() {
271        // `sudo` as an argument to another command is NOT command position.
272        assert!(!is_command_position("echo sudo "));
273    }
274
275    #[test]
276    fn command_position_middle_of_args_is_false() {
277        assert!(!is_command_position("ls -la "));
278        assert!(!is_command_position("git commit -m "));
279    }
280
281    #[test]
282    fn command_position_not_fooled_by_substring_sudo() {
283        // "pseudo" ends in "sudo" but isn't the word sudo.
284        assert!(!is_command_position("pseudo "));
285    }
286
287    #[test]
288    fn command_position_does_not_expand_after_assignment() {
289        // `VAR=value cmd` – after `VAR=` we're not in command position here
290        // (the whole `VAR=value` is a preceding assignment, but our simple
291        // model intentionally treats this as non-command-position; abbrevs
292        // shouldn't fire for the RHS of an assignment).
293        assert!(!is_command_position("VAR="));
294    }
295
296    // ---- hook() behaviour ----
297
298    use crate::config::parse_config;
299
300    fn sample_config() -> Config {
301        parse_config(
302            r#"
303            version = 1
304            [[abbr]]
305            key = "gcm"
306            expand = "git commit -m"
307
308            [[abbr]]
309            key = "gca"
310            expand = "git commit -am '{}'"
311
312            [[abbr]]
313            key = "ls"
314            expand = "lsd"
315            when_command_exists = ["lsd"]
316            "#,
317        )
318        .unwrap()
319    }
320
321    fn always_exists(_: &str) -> bool { true }
322    fn never_exists(_: &str) -> bool { false }
323
324    #[test]
325    fn hook_inserts_space_on_empty_line() {
326        let config = sample_config();
327        let action = hook(&config, Shell::Bash, "", 0, always_exists);
328        assert_eq!(action, HookAction::InsertSpace { line: " ".into(), cursor: 1 });
329    }
330
331    #[test]
332    fn hook_inserts_space_for_unknown_token() {
333        let config = sample_config();
334        let action = hook(&config, Shell::Bash, "nope", 4, always_exists);
335        assert_eq!(action, HookAction::InsertSpace { line: "nope ".into(), cursor: 5 });
336    }
337
338    #[test]
339    fn hook_inserts_space_when_not_in_command_position() {
340        let config = sample_config();
341        // `echo gcm` - gcm is an argument to echo, not a command name.
342        let action = hook(&config, Shell::Bash, "echo gcm", 8, always_exists);
343        assert_eq!(action, HookAction::InsertSpace { line: "echo gcm ".into(), cursor: 9 });
344    }
345
346    #[test]
347    fn hook_expands_known_token_at_command_position() {
348        let config = sample_config();
349        let action = hook(&config, Shell::Bash, "gcm", 3, always_exists);
350        // Expanded "git commit -m" (13 chars) then the triggering space.
351        assert_eq!(
352            action,
353            HookAction::Replace { line: "git commit -m ".into(), cursor: 14 }
354        );
355    }
356
357    #[test]
358    fn hook_expands_token_after_sudo() {
359        let config = sample_config();
360        let action = hook(&config, Shell::Bash, "sudo gcm", 8, always_exists);
361        assert_eq!(
362            action,
363            HookAction::Replace { line: "sudo git commit -m ".into(), cursor: 19 }
364        );
365    }
366
367    #[test]
368    fn hook_handles_cursor_placeholder() {
369        let config = sample_config();
370        // Rule: `expand = "git commit -am '{}'"`. After expansion the `{}` is
371        // removed, cursor lands at that position, and the trigger space is
372        // inserted **at the cursor** (so the user can keep typing inside the
373        // quotes). That yields `git commit -am ' '` with cursor just after the
374        // inserted space.
375        let action = hook(&config, Shell::Bash, "gca", 3, always_exists);
376        if let HookAction::Replace { line, cursor } = action {
377            assert_eq!(line, "git commit -am ' '");
378            assert_eq!(cursor, 17);
379        } else {
380            panic!("expected Replace, got {:?}", action);
381        }
382    }
383
384    #[test]
385    fn hook_respects_when_command_exists_failure() {
386        let config = sample_config();
387        // `ls` rule requires `lsd` to exist; with never_exists() it should skip.
388        let action = hook(&config, Shell::Bash, "ls", 2, never_exists);
389        assert_eq!(action, HookAction::InsertSpace { line: "ls ".into(), cursor: 3 });
390    }
391
392    #[test]
393    fn hook_preserves_text_right_of_cursor() {
394        let config = sample_config();
395        // Buffer: "gcm xyz", cursor at 3 (right after "gcm").
396        let action = hook(&config, Shell::Bash, "gcm xyz", 3, always_exists);
397        if let HookAction::Replace { line, cursor } = action {
398            assert_eq!(line, "git commit -m  xyz");
399            assert_eq!(cursor, 14);
400        } else {
401            panic!("expected Replace, got {:?}", action);
402        }
403    }
404
405    // ---- render_action() ----
406
407    #[test]
408    fn render_bash_quotes_single_quotes_in_expansion() {
409        let action = HookAction::Replace {
410            line: "git commit -am '' ".into(),
411            cursor: 17,
412        };
413        let out = render_action(Shell::Bash, &action);
414        // bash_quote_string wraps in single quotes and escapes embedded ones
415        // with the `'\''` pattern.
416        assert!(out.starts_with("READLINE_LINE="));
417        assert!(out.contains("'\\''"), "render output should escape quotes: {}", out);
418        assert!(out.ends_with("; READLINE_POINT=17"));
419    }
420
421    #[test]
422    fn render_zsh_splits_lbuffer_rbuffer() {
423        let action = HookAction::Replace {
424            line: "git commit -m  xyz".into(),
425            cursor: 14,
426        };
427        let out = render_action(Shell::Zsh, &action);
428        assert!(out.contains("LBUFFER="));
429        assert!(out.contains("RBUFFER="));
430    }
431}