#![allow(non_snake_case)]
use crate::ported::params::getsparam;
use crate::zle_file_tester::OperationContext;
use std::ops::Range;
use std::sync::Mutex;
#[derive(Default, Clone, Debug)]
pub struct Autosuggestion {
pub text: String,
pub search_string_range: Range<usize>,
pub icase_matched_codepoints: Option<usize>,
pub is_whole_item_from_history: bool,
}
impl Autosuggestion {
pub fn clear(&mut self) {
self.text.clear();
}
pub fn is_empty(&self) -> bool {
self.text.is_empty()
}
pub fn suffix(&self) -> &str {
let typed = self.search_string_range.len();
match self.text.char_indices().nth(typed) {
Some((byte, _)) => &self.text[byte..],
None => "",
}
}
}
#[derive(Default)]
pub struct AutosuggestionResult {
pub autosuggestion: Autosuggestion,
pub command_line: String,
}
impl std::ops::Deref for AutosuggestionResult {
type Target = Autosuggestion;
fn deref(&self) -> &Self::Target {
&self.autosuggestion
}
}
impl AutosuggestionResult {
fn new(
command_line: String,
search_string_range: Range<usize>,
text: String,
icase_matched_codepoints: Option<usize>,
is_whole_item_from_history: bool,
) -> Self {
Self {
autosuggestion: Autosuggestion {
text,
search_string_range,
icase_matched_codepoints,
is_whole_item_from_history,
},
command_line,
}
}
fn search_string(&self) -> String {
self.command_line
.chars()
.skip(self.search_string_range.start)
.take(self.search_string_range.len())
.collect()
}
}
pub enum AutosuggestionPortion {
Count(usize),
Line,
Word,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Strategy {
History,
MatchPrevCmd,
}
#[derive(Default)]
pub struct AutosuggestState {
pub autosuggestion: Autosuggestion,
pub saved_autosuggestion: Option<Autosuggestion>,
pub suppress_autosuggestion: bool,
pub last_request_line: String,
pub history_search_active: bool,
}
pub static AUTOSUGGEST_STATE: Mutex<Option<AutosuggestState>> = Mutex::new(None);
pub fn with_state<R>(f: impl FnOnce(&mut AutosuggestState) -> R) -> R {
let mut guard = AUTOSUGGEST_STATE.lock().unwrap();
f(guard.get_or_insert_with(Default::default))
}
fn string_prefixes_string_maybe_case_insensitive(icase: bool, prefix: &str, value: &str) -> bool {
if icase {
let mut vc = value.chars();
prefix.chars().all(|pc| match vc.next() {
Some(c) => c.to_lowercase().eq(pc.to_lowercase()),
None => false,
})
} else {
value.starts_with(prefix)
}
}
pub type HistorySource<'a> = dyn Fn(&str, usize) -> Vec<String> + 'a;
pub fn history_commands_newest_first(prefix: &str, limit: usize) -> Vec<String> {
let from_db = crate::history::with_session_engine(|eng| {
eng.search_prefix_cs(prefix, limit)
.map(|entries| entries.into_iter().map(|e| e.command).collect::<Vec<_>>())
.unwrap_or_default()
})
.unwrap_or_default();
if !from_db.is_empty() {
return from_db;
}
let ring = crate::ported::hist::hist_ring.lock().unwrap();
ring.iter()
.filter(|e| !e.node.nam.is_empty() && e.node.nam.starts_with(prefix))
.take(limit)
.map(|e| e.node.nam.clone())
.collect()
}
fn previous_command() -> Option<String> {
let ring = crate::ported::hist::hist_ring.lock().unwrap();
ring.first().map(|e| e.node.nam.clone())
}
pub fn configured_strategies() -> Vec<Strategy> {
let raw = crate::ported::params::getaparam("ZSH_AUTOSUGGEST_STRATEGY")
.map(|v| v.join(" "))
.or_else(|| getsparam("ZSH_AUTOSUGGEST_STRATEGY"))
.unwrap_or_default();
let mut out: Vec<Strategy> = raw
.split_whitespace()
.filter_map(|s| match s {
"history" | "completion" => Some(Strategy::History),
"match_prev_cmd" => Some(Strategy::MatchPrevCmd),
_ => None,
})
.collect();
if out.is_empty() {
out.push(Strategy::History);
}
out
}
pub fn compute_autosuggestion(
command_line: &str,
cursor_pos: usize,
source: &HistorySource<'_>,
ctx: &OperationContext,
) -> AutosuggestionResult {
let nothing = AutosuggestionResult::default();
if ctx.check_cancel() {
return nothing; }
let line_len = command_line.chars().count();
let range = 0..line_len;
if range.is_empty() {
return nothing; }
let search_string = command_line;
let mut icase_history_result: Option<AutosuggestionResult> = None;
let strategies = configured_strategies();
let prev_cmd = if strategies.contains(&Strategy::MatchPrevCmd) {
previous_command()
} else {
None
};
let working_directory = getsparam("PWD").unwrap_or_else(|| ".".to_owned());
let mut candidates = source(search_string, 64);
if let Some(prev) = &prev_cmd {
let preferred: Option<String> = {
let ring = crate::ported::hist::hist_ring.lock().unwrap();
ring.windows(2)
.find(|w| &w[1].node.nam == prev && w[0].node.nam.starts_with(search_string))
.map(|w| w[0].node.nam.clone())
};
if let Some(preferred) = preferred {
candidates.retain(|c| c != &preferred);
candidates.insert(0, preferred);
}
}
let mut unvalidated_fallback: Option<AutosuggestionResult> = None;
let single_line_buffer = !search_string.contains('\n');
for full_item in &candidates {
let full: &str = if single_line_buffer {
match full_item.split_once('\n') {
Some((first, _)) => first,
None => full_item.as_str(),
}
} else {
full_item.as_str()
};
let full = &full.to_string();
let (matches, icase) = if full.starts_with(search_string) {
(true, false)
} else if icase_history_result.is_none()
&& string_prefixes_string_maybe_case_insensitive(true, search_string, full)
{
(true, true)
} else {
(false, false)
};
if !matches || full.chars().count() <= line_len {
continue;
}
let is_whole = full == full_item; let make_result = || {
AutosuggestionResult::new(
command_line.to_owned(),
range.clone(),
full.clone(),
icase.then(|| search_string.chars().count()),
is_whole,
)
};
if !icase && unvalidated_fallback.is_none() {
unvalidated_fallback = Some(make_result());
}
if ctx.check_cancel() {
break;
}
if crate::syntax_highlight::autosuggest_validate_from_history(
full,
&[],
&working_directory,
ctx,
) {
let result = make_result();
if icase {
icase_history_result = Some(result);
} else {
return result;
}
}
}
if let Some(result) = icase_history_result {
return result;
}
if ctx.check_cancel() {
if let Some(result) = unvalidated_fallback {
return result;
}
}
let _ = cursor_pos;
nothing
}
pub fn can_autosuggest(state: &AutosuggestState, line: &str) -> bool {
let max_size = getsparam("ZSH_AUTOSUGGEST_BUFFER_MAX_SIZE")
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(usize::MAX);
!state.suppress_autosuggestion
&& !state.history_search_active
&& line.chars().count() <= max_size
&& line
.chars()
.any(|c| !matches!(c, ' ' | '\t' | '\r' | '\n' | '\x0B'))
}
pub fn autosuggest_completed(state: &mut AutosuggestState, line: &str, result: AutosuggestionResult) {
if result.command_line != line {
return;
}
if !result.is_empty()
&& string_prefixes_string_maybe_case_insensitive(
result.icase_matched_codepoints.is_some(),
&result.search_string(),
&result.text,
)
{
state.autosuggestion = result.autosuggestion;
}
}
pub fn update_autosuggestion(
line: &str,
cursor: usize,
source: &HistorySource<'_>,
ctx: &OperationContext,
) -> bool {
with_state(|state| {
let before = state.autosuggestion.text.clone();
if !can_autosuggest(state, line) {
state.last_request_line.clear();
state.autosuggestion.clear();
return state.autosuggestion.text != before;
}
if is_at_line_with_autosuggestion(state, line, cursor) {
return false;
}
if line == state.last_request_line {
if !state.autosuggestion.is_empty() {
state.autosuggestion.clear();
}
return state.autosuggestion.text != before;
}
state.last_request_line = line.to_owned();
state.autosuggestion.clear();
let result = compute_autosuggestion(line, cursor, source, ctx);
autosuggest_completed(state, line, result);
state.autosuggestion.text != before
})
}
pub fn is_at_autosuggestion(state: &AutosuggestState, cursor: usize) -> bool {
if state.autosuggestion.is_empty() {
return false;
}
cursor == state.autosuggestion.search_string_range.end
}
pub fn is_at_line_with_autosuggestion(state: &AutosuggestState, line: &str, cursor: usize) -> bool {
if state.autosuggestion.is_empty() {
return false;
}
let range = &state.autosuggestion.search_string_range;
let line_len = line.chars().count();
(range.start == 0 && range.end == line_len && cursor <= range.end)
&& string_prefixes_string_maybe_case_insensitive(
state.autosuggestion.icase_matched_codepoints.is_some(),
line,
&state.autosuggestion.text,
)
}
pub fn accept_autosuggestion(
state: &mut AutosuggestState,
amount: AutosuggestionPortion,
) -> Option<(Range<usize>, String)> {
let autosuggestion = &state.autosuggestion;
if autosuggestion.is_empty() {
return None;
}
let autosuggestion_text: Vec<char> = autosuggestion.text.chars().collect();
let search_string_range = autosuggestion.search_string_range.clone();
let (range, replacement): (Range<usize>, String) = match amount {
AutosuggestionPortion::Count(count) => {
if count == usize::MAX {
(search_string_range, autosuggestion_text.iter().collect())
} else {
let pos = search_string_range.end;
let available = autosuggestion_text.len() - search_string_range.len();
let count = count.min(available);
if count == 0 {
return None;
}
let start = autosuggestion_text.len() - available;
(
pos..pos,
autosuggestion_text[start..start + count].iter().collect(),
)
}
}
AutosuggestionPortion::Line => {
let suggested = &autosuggestion_text[search_string_range.len()..];
let line_end = suggested
.iter()
.position(|&c| c == '\n')
.unwrap_or(suggested.len());
if line_end == 0 {
return None;
}
(
search_string_range.end..search_string_range.end,
suggested[..line_end].iter().collect(),
)
}
AutosuggestionPortion::Word => {
let wordchars =
getsparam("WORDCHARS").unwrap_or_else(|| "*?_-.[]~=/&;!#$%^(){}<>".to_owned());
let is_word = |c: char| c.is_alphanumeric() || wordchars.contains(c);
let have = search_string_range.len();
let mut want = have;
while want < autosuggestion_text.len() && !is_word(autosuggestion_text[want]) {
want += 1;
}
while want < autosuggestion_text.len() && is_word(autosuggestion_text[want]) {
want += 1;
}
if want == have {
return None;
}
(
search_string_range.end..search_string_range.end,
autosuggestion_text[have..want].iter().collect(),
)
}
};
if range == (0..0) && replacement.is_empty() {
return None;
}
if matches!(amount, AutosuggestionPortion::Count(usize::MAX)) {
state.autosuggestion.clear();
state.last_request_line.clear();
}
Some((range, replacement))
}
pub fn on_delete(state: &mut AutosuggestState) {
if !state.autosuggestion.is_empty() {
state.saved_autosuggestion = Some(state.autosuggestion.clone());
}
state.suppress_autosuggestion = true;
state.autosuggestion.clear();
state.last_request_line.clear();
}
pub fn on_insert(state: &mut AutosuggestState, line: &str) {
state.suppress_autosuggestion = false;
if let Some(saved) = state.saved_autosuggestion.take() {
let line_len = line.chars().count();
if string_prefixes_string_maybe_case_insensitive(
saved.icase_matched_codepoints.is_some(),
line,
&saved.text,
) && line_len < saved.text.chars().count()
{
let mut restored = saved;
restored.search_string_range = 0..line_len;
state.autosuggestion = restored;
state.last_request_line = line.to_owned();
}
}
}
pub fn on_line_finish(state: &mut AutosuggestState) {
state.autosuggestion.clear();
state.saved_autosuggestion = None;
state.suppress_autosuggestion = false;
state.last_request_line.clear();
}
#[cfg(test)]
mod tests {
use super::*;
fn lock() -> std::sync::MutexGuard<'static, ()> {
crate::test_util::global_state_lock()
}
fn src(items: &[&str]) -> impl Fn(&str, usize) -> Vec<String> {
let owned: Vec<String> = items.iter().map(|s| s.to_string()).collect();
move |prefix: &str, limit: usize| {
owned
.iter()
.filter(|c| {
c.to_lowercase().starts_with(&prefix.to_lowercase())
})
.take(limit)
.cloned()
.collect()
}
}
#[test]
fn compute_prefers_case_sensitive() {
let _g = lock();
let ctx = OperationContext::empty();
let source = src(&["Echo one", "echo two"]);
let r = compute_autosuggestion("echo", 4, &source, &ctx);
assert_eq!(r.text, "echo two");
assert!(r.icase_matched_codepoints.is_none());
assert!(r.is_whole_item_from_history);
}
#[test]
fn compute_falls_back_to_icase() {
let _g = lock();
let ctx = OperationContext::empty();
let source = src(&["Echo one"]);
let r = compute_autosuggestion("echo", 4, &source, &ctx);
assert_eq!(r.text, "Echo one");
assert_eq!(r.icase_matched_codepoints, Some(4));
}
#[test]
fn compute_rejects_invalid_command() {
let _g = lock();
let ctx = OperationContext::empty();
let source = src(&["nonexistent_zshrs_cmd_xyz --flag"]);
let r = compute_autosuggestion("nonex", 5, &source, &ctx);
assert!(r.is_empty(), "invalid command must not be suggested: {:?}", r.text);
}
#[test]
fn compute_empty_line_suggests_nothing() {
let _g = lock();
let ctx = OperationContext::empty();
let source = src(&["echo hi"]);
let r = compute_autosuggestion("", 0, &source, &ctx);
assert!(r.is_empty()); }
fn state_with_suggestion(line: &str, text: &str) -> AutosuggestState {
AutosuggestState {
autosuggestion: Autosuggestion {
text: text.to_owned(),
search_string_range: 0..line.chars().count(),
icase_matched_codepoints: None,
is_whole_item_from_history: true,
},
..Default::default()
}
}
#[test]
fn accept_full_replaces_line() {
let mut st = state_with_suggestion("git s", "git status --short");
let (range, repl) =
accept_autosuggestion(&mut st, AutosuggestionPortion::Count(usize::MAX)).unwrap();
assert_eq!(range, 0..5);
assert_eq!(repl, "git status --short");
assert!(st.autosuggestion.is_empty(), "full accept clears suggestion");
}
#[test]
fn accept_count_appends_chars() {
let mut st = state_with_suggestion("git s", "git status --short");
let (range, repl) = accept_autosuggestion(&mut st, AutosuggestionPortion::Count(3)).unwrap();
assert_eq!(range, 5..5);
assert_eq!(repl, "tat");
}
#[test]
fn accept_word_stops_at_boundary() {
let mut st = state_with_suggestion("git s", "git status --short");
let (range, repl) = accept_autosuggestion(&mut st, AutosuggestionPortion::Word).unwrap();
assert_eq!(range, 5..5);
assert!(repl.starts_with("tatus"), "got {repl:?}");
assert!(!repl.contains("short"), "must stop at word boundary: {repl:?}");
}
#[test]
fn accept_on_empty_suggestion_is_none() {
let mut st = AutosuggestState::default();
assert!(accept_autosuggestion(&mut st, AutosuggestionPortion::Count(usize::MAX)).is_none());
}
#[test]
fn delete_suppresses_and_insert_restores() {
let _g = lock();
let mut st = state_with_suggestion("git s", "git status");
on_delete(&mut st);
assert!(st.suppress_autosuggestion);
assert!(st.autosuggestion.is_empty());
assert!(!can_autosuggest(&st, "git "));
on_insert(&mut st, "git st");
assert!(!st.suppress_autosuggestion);
assert_eq!(st.autosuggestion.text, "git status");
assert_eq!(st.autosuggestion.search_string_range, 0..6);
}
#[test]
fn insert_discards_saved_when_no_longer_prefix() {
let _g = lock();
let mut st = state_with_suggestion("git s", "git status");
on_delete(&mut st);
on_insert(&mut st, "ls -");
assert!(st.autosuggestion.is_empty());
}
#[test]
fn stale_result_is_dropped() {
let mut st = AutosuggestState::default();
let result = AutosuggestionResult::new(
"old line".to_owned(),
0..8,
"old line more".to_owned(),
None,
true,
);
autosuggest_completed(&mut st, "new line", result);
assert!(st.autosuggestion.is_empty());
}
#[test]
fn update_clears_when_line_no_longer_matches() {
let _g = lock();
let ctx = OperationContext::empty();
let source = src(&["echo hello"]);
let changed = update_autosuggestion("echo h", 6, &source, &ctx);
assert!(changed);
with_state(|st| {
assert_eq!(st.autosuggestion.text, "echo hello");
});
let changed2 = update_autosuggestion("zzz_nothing", 11, &source, &ctx);
assert!(changed2);
with_state(|st| {
assert!(st.autosuggestion.is_empty());
st.autosuggestion.clear();
st.last_request_line.clear();
st.saved_autosuggestion = None;
});
}
#[test]
fn suggestion_suffix() {
let s = Autosuggestion {
text: "git status".to_owned(),
search_string_range: 0..5,
icase_matched_codepoints: None,
is_whole_item_from_history: true,
};
assert_eq!(s.suffix(), "tatus");
}
}