use crate::expand::expand;
use crate::model::{Config, ExpandResult, Shell};
use crate::shell::{bash_quote_string, lua_quote_string, pwsh_quote_string};
#[derive(Debug, Clone, PartialEq)]
pub enum HookAction {
Replace { line: String, cursor: usize },
InsertSpace { line: String, cursor: usize },
}
pub fn hook<F>(
config: &Config,
shell: Shell,
line: &str,
cursor: usize,
command_exists: F,
) -> HookAction
where
F: Fn(&str) -> bool,
{
let cursor = cursor.min(line.len());
if cursor < line.len() && !line[cursor..].starts_with(' ') {
return insert_space(line, cursor);
}
let left = &line[..cursor];
let Some(token_start) = token_start_of(left) else {
return insert_space(line, cursor);
};
let token = &left[token_start..];
if token.is_empty() {
return insert_space(line, cursor);
}
let prefix = &line[..token_start];
if !is_command_position(prefix) {
return insert_space(line, cursor);
}
if !is_known_token(config, token) {
return insert_space(line, cursor);
}
match expand(config, token, shell, command_exists) {
ExpandResult::Expanded { text, cursor_offset } => {
let right = &line[cursor..];
let mut new_line = String::with_capacity(prefix.len() + text.len() + right.len() + 1);
new_line.push_str(prefix);
new_line.push_str(&text);
let cursor_after_expand = match cursor_offset {
Some(off) => token_start + off,
None => token_start + text.len(),
};
new_line.insert(cursor_after_expand, ' ');
new_line.push_str(right);
HookAction::Replace {
line: new_line,
cursor: cursor_after_expand + 1,
}
}
ExpandResult::PassThrough(_) => insert_space(line, cursor),
}
}
fn insert_space(line: &str, cursor: usize) -> HookAction {
let mut new_line = String::with_capacity(line.len() + 1);
new_line.push_str(&line[..cursor]);
new_line.push(' ');
new_line.push_str(&line[cursor..]);
HookAction::InsertSpace {
line: new_line,
cursor: cursor + 1,
}
}
fn token_start_of(left: &str) -> Option<usize> {
if left.is_empty() {
return None;
}
Some(left.rfind(' ').map_or(0, |i| i + 1))
}
fn is_known_token(config: &Config, token: &str) -> bool {
config.abbr.iter().any(|abbr| abbr.key == token)
}
pub fn render_action(shell: Shell, action: &HookAction) -> String {
let (line, cursor) = match action {
HookAction::Replace { line, cursor } | HookAction::InsertSpace { line, cursor } => {
(line, cursor)
}
};
match shell {
Shell::Bash => format!(
"READLINE_LINE={}; READLINE_POINT={}",
bash_quote_string(line),
cursor,
),
Shell::Zsh => {
let (lb, rb) = line.split_at(*cursor);
format!("LBUFFER={}; RBUFFER={}", bash_quote_string(lb), bash_quote_string(rb))
}
Shell::Pwsh => format!(
"$__RUNEX_LINE = {}\n$__RUNEX_CURSOR = {}",
pwsh_quote_string(line),
cursor,
),
Shell::Clink => format!(
"return {{ line = {}, cursor = {} }}",
lua_quote_string(line),
cursor,
),
Shell::Nu => format!(
"{{\"line\": {}, \"cursor\": {}}}",
serde_json::to_string(line).unwrap_or_else(|_| "\"\"".into()),
cursor,
),
}
}
pub fn is_command_position(prefix: &str) -> bool {
let trimmed = trim_trailing_spaces(prefix);
if trimmed.is_empty() {
return true;
}
if ends_with_pipeline_operator(trimmed) {
return true;
}
if let Some(before_sudo) = strip_trailing_sudo(trimmed) {
let before_sudo = trim_trailing_spaces(before_sudo);
if before_sudo.is_empty() {
return true;
}
return ends_with_pipeline_operator(before_sudo);
}
false
}
fn trim_trailing_spaces(s: &str) -> &str {
s.trim_end_matches(' ')
}
fn ends_with_pipeline_operator(s: &str) -> bool {
s.ends_with("&&")
|| s.ends_with("||")
|| s.ends_with('|')
|| s.ends_with(';')
}
fn strip_trailing_sudo(prefix: &str) -> Option<&str> {
let prev_word_start = prefix.rfind(' ').map_or(0, |i| i + 1);
let prev_word = &prefix[prev_word_start..];
if prev_word == "sudo" {
Some(&prefix[..prev_word_start])
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_position_empty_prefix_is_true() {
assert!(is_command_position(""));
}
#[test]
fn command_position_only_spaces_is_true() {
assert!(is_command_position(" "));
}
#[test]
fn command_position_after_pipe_is_true() {
assert!(is_command_position("ls | "));
assert!(is_command_position("ls |"));
}
#[test]
fn command_position_after_logical_or_is_true() {
assert!(is_command_position("foo || "));
}
#[test]
fn command_position_after_logical_and_is_true() {
assert!(is_command_position("foo && "));
}
#[test]
fn command_position_after_semicolon_is_true() {
assert!(is_command_position("foo; "));
}
#[test]
fn command_position_after_sudo_at_start_is_true() {
assert!(is_command_position("sudo "));
}
#[test]
fn command_position_after_sudo_following_pipe_is_true() {
assert!(is_command_position("ls | sudo "));
}
#[test]
fn command_position_after_sudo_mid_args_is_false() {
assert!(!is_command_position("echo sudo "));
}
#[test]
fn command_position_middle_of_args_is_false() {
assert!(!is_command_position("ls -la "));
assert!(!is_command_position("git commit -m "));
}
#[test]
fn command_position_not_fooled_by_substring_sudo() {
assert!(!is_command_position("pseudo "));
}
#[test]
fn command_position_does_not_expand_after_assignment() {
assert!(!is_command_position("VAR="));
}
use crate::config::parse_config;
fn sample_config() -> Config {
parse_config(
r#"
version = 1
[[abbr]]
key = "gcm"
expand = "git commit -m"
[[abbr]]
key = "gca"
expand = "git commit -am '{}'"
[[abbr]]
key = "ls"
expand = "lsd"
when_command_exists = ["lsd"]
"#,
)
.unwrap()
}
fn always_exists(_: &str) -> bool { true }
fn never_exists(_: &str) -> bool { false }
#[test]
fn hook_inserts_space_on_empty_line() {
let config = sample_config();
let action = hook(&config, Shell::Bash, "", 0, always_exists);
assert_eq!(action, HookAction::InsertSpace { line: " ".into(), cursor: 1 });
}
#[test]
fn hook_inserts_space_for_unknown_token() {
let config = sample_config();
let action = hook(&config, Shell::Bash, "nope", 4, always_exists);
assert_eq!(action, HookAction::InsertSpace { line: "nope ".into(), cursor: 5 });
}
#[test]
fn hook_inserts_space_when_not_in_command_position() {
let config = sample_config();
let action = hook(&config, Shell::Bash, "echo gcm", 8, always_exists);
assert_eq!(action, HookAction::InsertSpace { line: "echo gcm ".into(), cursor: 9 });
}
#[test]
fn hook_expands_known_token_at_command_position() {
let config = sample_config();
let action = hook(&config, Shell::Bash, "gcm", 3, always_exists);
assert_eq!(
action,
HookAction::Replace { line: "git commit -m ".into(), cursor: 14 }
);
}
#[test]
fn hook_expands_token_after_sudo() {
let config = sample_config();
let action = hook(&config, Shell::Bash, "sudo gcm", 8, always_exists);
assert_eq!(
action,
HookAction::Replace { line: "sudo git commit -m ".into(), cursor: 19 }
);
}
#[test]
fn hook_handles_cursor_placeholder() {
let config = sample_config();
let action = hook(&config, Shell::Bash, "gca", 3, always_exists);
if let HookAction::Replace { line, cursor } = action {
assert_eq!(line, "git commit -am ' '");
assert_eq!(cursor, 17);
} else {
panic!("expected Replace, got {:?}", action);
}
}
#[test]
fn hook_respects_when_command_exists_failure() {
let config = sample_config();
let action = hook(&config, Shell::Bash, "ls", 2, never_exists);
assert_eq!(action, HookAction::InsertSpace { line: "ls ".into(), cursor: 3 });
}
#[test]
fn hook_preserves_text_right_of_cursor() {
let config = sample_config();
let action = hook(&config, Shell::Bash, "gcm xyz", 3, always_exists);
if let HookAction::Replace { line, cursor } = action {
assert_eq!(line, "git commit -m xyz");
assert_eq!(cursor, 14);
} else {
panic!("expected Replace, got {:?}", action);
}
}
#[test]
fn render_bash_quotes_single_quotes_in_expansion() {
let action = HookAction::Replace {
line: "git commit -am '' ".into(),
cursor: 17,
};
let out = render_action(Shell::Bash, &action);
assert!(out.starts_with("READLINE_LINE="));
assert!(out.contains("'\\''"), "render output should escape quotes: {}", out);
assert!(out.ends_with("; READLINE_POINT=17"));
}
#[test]
fn render_zsh_splits_lbuffer_rbuffer() {
let action = HookAction::Replace {
line: "git commit -m xyz".into(),
cursor: 14,
};
let out = render_action(Shell::Zsh, &action);
assert!(out.contains("LBUFFER="));
assert!(out.contains("RBUFFER="));
}
}