1use crate::expand::expand;
8use crate::model::{Config, ExpandResult, Shell};
9use crate::shell::{bash_quote_string, lua_quote_string, pwsh_quote_string};
10
11#[derive(Debug, Clone, PartialEq)]
17pub enum HookAction {
18 Replace { line: String, cursor: usize },
20 InsertSpace { line: String, cursor: usize },
23}
24
25pub 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 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 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
101fn 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
115pub 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 Shell::Nu => format!(
157 "{{\"line\": {}, \"cursor\": {}}}",
158 serde_json::to_string(line).unwrap_or_else(|_| "\"\"".into()),
159 cursor,
160 ),
161 }
162}
163
164pub 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 s.ends_with("&&")
204 || s.ends_with("||")
205 || s.ends_with('|')
206 || s.ends_with(';')
207}
208
209fn strip_trailing_sudo(prefix: &str) -> Option<&str> {
213 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 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 assert!(!is_command_position("pseudo "));
285 }
286
287 #[test]
288 fn command_position_does_not_expand_after_assignment() {
289 assert!(!is_command_position("VAR="));
294 }
295
296 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 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 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 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 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 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 #[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 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}