use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::engine::{Direction, SearchMatch, Step, find_all, step, step_inclusive};
use crate::pattern::{CaseMode, PatternError, SearchPattern};
pub const HISTORY_LIMIT: usize = 50;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Prompt {
pub direction: Direction,
pub text: String,
pub origin: usize,
caret: usize,
history_index: Option<usize>,
stashed: Option<String>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default, Serialize, Deserialize, JsonSchema)]
pub enum CaretMove {
#[default]
Left,
Right,
Start,
End,
}
impl CaretMove {
#[must_use]
pub const fn resolve(self, caret: usize, len: usize) -> usize {
match self {
Self::Left => caret.saturating_sub(1),
Self::Right => {
if caret < len {
caret + 1
} else {
len
}
}
Self::Start => 0,
Self::End => len,
}
}
}
impl Prompt {
#[must_use]
pub const fn caret(&self) -> usize {
self.caret
}
#[must_use]
fn byte_of_caret(&self) -> usize {
self.text
.char_indices()
.nth(self.caret)
.map_or(self.text.len(), |(b, _)| b)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Accepted {
Committed,
ReusedPrevious,
NothingToRepeat,
Invalid(PatternError),
}
#[derive(Debug, Clone, Default)]
pub struct SearchState {
prompt: Option<Prompt>,
pattern: Option<SearchPattern>,
direction: Direction,
matches: Vec<SearchMatch>,
highlight: bool,
history: Vec<String>,
case: CaseMode,
}
impl SearchState {
#[must_use]
pub fn new(case: CaseMode) -> Self {
Self {
case,
highlight: true,
..Self::default()
}
}
pub fn open(&mut self, direction: Direction, origin: usize) {
self.prompt = Some(Prompt {
direction,
text: String::new(),
origin,
caret: 0,
history_index: None,
stashed: None,
});
}
#[must_use]
pub const fn prompt(&self) -> Option<&Prompt> {
self.prompt.as_ref()
}
#[must_use]
pub const fn is_prompting(&self) -> bool {
self.prompt.is_some()
}
pub fn push(&mut self, ch: char) {
if let Some(p) = self.prompt.as_mut() {
let at = p.byte_of_caret();
p.text.insert(at, ch);
p.caret += 1;
p.history_index = None;
}
}
pub fn move_caret(&mut self, to: CaretMove) {
if let Some(p) = self.prompt.as_mut() {
p.caret = to.resolve(p.caret, p.text.chars().count());
}
}
pub fn delete_at_caret(&mut self) {
if let Some(p) = self.prompt.as_mut() {
let at = p.byte_of_caret();
if at < p.text.len() {
p.text.remove(at);
p.history_index = None;
}
}
}
pub fn delete_word_before_caret(&mut self) {
let Some(p) = self.prompt.as_mut() else {
return;
};
let chars: Vec<char> = p.text.chars().collect();
let mut i = p.caret;
while i > 0 && chars[i - 1].is_whitespace() {
i -= 1;
}
while i > 0 && !chars[i - 1].is_whitespace() {
i -= 1;
}
let kept: String = chars[..i].iter().chain(chars[p.caret..].iter()).collect();
p.text = kept;
p.caret = i;
p.history_index = None;
}
pub fn clear_before_caret(&mut self) {
if let Some(p) = self.prompt.as_mut() {
let at = p.byte_of_caret();
p.text.drain(..at);
p.caret = 0;
p.history_index = None;
}
}
pub fn backspace(&mut self) -> bool {
let Some(p) = self.prompt.as_mut() else {
return false;
};
p.history_index = None;
if p.caret == 0 {
if p.text.is_empty() {
self.prompt = None;
return true;
}
return false;
}
let at = p.byte_of_caret();
let prev = p.text[..at]
.char_indices()
.next_back()
.map_or(0, |(i, _)| i);
p.text.remove(prev);
p.caret -= 1;
false
}
pub fn cancel(&mut self) -> Option<usize> {
self.prompt.take().map(|p| p.origin)
}
pub fn accept(&mut self, text: &str) -> Accepted {
let Some(p) = self.prompt.take() else {
return Accepted::NothingToRepeat;
};
let direction = p.direction;
if p.text.is_empty() {
return if self.pattern.is_some() {
self.direction = direction;
self.refresh(text);
Accepted::ReusedPrevious
} else {
Accepted::NothingToRepeat
};
}
match SearchPattern::compile(&p.text, self.case) {
Ok(pattern) => {
self.remember(&p.text);
self.pattern = Some(pattern);
self.direction = direction;
self.highlight = true;
self.refresh(text);
Accepted::Committed
}
Err(e) => {
self.prompt = Some(p);
Accepted::Invalid(e)
}
}
}
fn remember(&mut self, raw: &str) {
self.history.retain(|h| h != raw);
self.history.push(raw.to_string());
if self.history.len() > HISTORY_LIMIT {
self.history.remove(0);
}
}
#[must_use]
pub fn history(&self) -> &[String] {
&self.history
}
pub fn history_step(&mut self, back: bool) {
if self.history.is_empty() {
return;
}
let len = self.history.len();
let Some(p) = self.prompt.as_mut() else {
return;
};
match (p.history_index, back) {
(None, true) => {
p.stashed = Some(p.text.clone());
p.history_index = Some(len - 1);
p.text.clone_from(&self.history[len - 1]);
}
(Some(i), true) if i > 0 => {
p.history_index = Some(i - 1);
p.text.clone_from(&self.history[i - 1]);
}
(Some(i), false) if i + 1 < len => {
p.history_index = Some(i + 1);
p.text.clone_from(&self.history[i + 1]);
}
(Some(_), false) => {
p.history_index = None;
p.text = p.stashed.take().unwrap_or_default();
}
_ => {}
}
p.caret = p.text.chars().count();
}
pub fn refresh(&mut self, text: &str) {
self.matches = self
.pattern
.as_ref()
.map_or_else(Vec::new, |p| find_all(text, p));
}
#[must_use]
pub fn preview(&self, text: &str) -> Option<Step> {
let p = self.prompt.as_ref()?;
if p.text.is_empty() {
return None;
}
let pattern = SearchPattern::compile(&p.text, self.case).ok()?;
let matches = find_all(text, &pattern);
step_inclusive(&matches, p.origin, p.direction)
}
#[must_use]
pub fn preview_total(&self, text: &str) -> usize {
let Some(p) = self.prompt.as_ref() else {
return 0;
};
if p.text.is_empty() {
return 0;
}
SearchPattern::compile(&p.text, self.case)
.ok()
.map_or(0, |pattern| find_all(text, &pattern).len())
}
#[must_use]
pub fn prompt_is_empty(&self) -> bool {
self.prompt.as_ref().is_none_or(|p| p.text.is_empty())
}
#[must_use]
pub fn commit_step(&self, origin: usize) -> Option<Step> {
step_inclusive(&self.matches, origin, self.direction)
}
#[must_use]
pub fn repeat(&self, from: usize, reverse: bool) -> Option<Step> {
let dir = if reverse {
self.direction.reversed()
} else {
self.direction
};
step(&self.matches, from, dir)
}
pub fn search_word(&mut self, text: &str, cursor: usize, direction: Direction) -> Option<Step> {
let word = crate::engine::word_at(text, cursor)?;
let pattern = SearchPattern::whole_word(&word, self.case).ok()?;
self.remember(pattern.raw());
self.pattern = Some(pattern);
self.direction = direction;
self.highlight = true;
self.refresh(text);
self.repeat(cursor, false)
}
#[must_use]
pub const fn pattern(&self) -> Option<&SearchPattern> {
self.pattern.as_ref()
}
#[must_use]
pub const fn direction(&self) -> Direction {
self.direction
}
#[must_use]
pub fn matches(&self) -> &[SearchMatch] {
&self.matches
}
#[must_use]
pub fn highlights(&self) -> &[SearchMatch] {
if self.highlight { &self.matches } else { &[] }
}
#[must_use]
pub const fn highlight_enabled(&self) -> bool {
self.highlight
}
pub fn relight(&mut self) {
self.highlight = true;
}
pub fn clear_highlight(&mut self) {
self.highlight = false;
}
pub fn set_case(&mut self, case: CaseMode) {
self.case = case;
}
#[must_use]
pub const fn case(&self) -> CaseMode {
self.case
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEXT: &str = "foo bar\nbaz foo\nqux foo end";
fn committed(pat: &str) -> SearchState {
let mut s = SearchState::new(CaseMode::Sensitive);
s.open(Direction::Forward, 0);
for c in pat.chars() {
s.push(c);
}
assert_eq!(s.accept(TEXT), Accepted::Committed);
s
}
#[test]
fn typing_then_accepting_commits_and_finds() {
let s = committed("foo");
assert_eq!(s.pattern().unwrap().raw(), "foo");
assert_eq!(s.matches().len(), 3);
assert!(!s.is_prompting(), "prompt closes on accept");
}
#[test]
fn cancel_keeps_the_previous_search_intact() {
let mut s = committed("foo");
s.open(Direction::Forward, 5);
s.push('z');
let origin = s.cancel();
assert_eq!(origin, Some(5), "cancel returns the cursor home");
assert_eq!(s.pattern().unwrap().raw(), "foo", "old pattern survives");
assert_eq!(s.matches().len(), 3, "old highlights survive");
assert!(!s.is_prompting());
}
#[test]
fn backspacing_past_the_slash_closes_the_prompt() {
let mut s = SearchState::new(CaseMode::Smart);
s.open(Direction::Forward, 0);
s.push('a');
assert!(!s.backspace(), "still has text");
assert!(s.backspace(), "empty -> closes");
assert!(!s.is_prompting());
}
#[test]
fn an_invalid_pattern_keeps_the_prompt_open_so_typing_is_not_lost() {
let mut s = SearchState::new(CaseMode::Smart);
s.open(Direction::Forward, 0);
for c in "a[b".chars() {
s.push(c);
}
assert!(matches!(s.accept(TEXT), Accepted::Invalid(_)));
assert!(s.is_prompting(), "prompt must stay open");
assert_eq!(s.prompt().unwrap().text, "a[b", "text must survive");
}
#[test]
fn bare_enter_reuses_the_previous_pattern() {
let mut s = committed("foo");
s.open(Direction::Backward, 0);
assert_eq!(s.accept(TEXT), Accepted::ReusedPrevious);
assert_eq!(s.pattern().unwrap().raw(), "foo");
assert_eq!(s.direction(), Direction::Backward, "direction updates");
}
#[test]
fn bare_enter_with_no_history_does_nothing() {
let mut s = SearchState::new(CaseMode::Smart);
s.open(Direction::Forward, 0);
assert_eq!(s.accept(TEXT), Accepted::NothingToRepeat);
}
#[test]
fn n_and_N_move_opposite_ways() {
let s = committed("foo");
let fwd = s.repeat(0, false).unwrap();
let back = s.repeat(20, true).unwrap();
assert!(fwd.target.start > 0);
assert!(back.target.start < 20);
}
#[test]
fn N_after_a_backward_search_goes_forward() {
let mut s = SearchState::new(CaseMode::Sensitive);
s.open(Direction::Backward, 0);
for c in "foo".chars() {
s.push(c);
}
s.accept(TEXT);
assert_eq!(s.direction(), Direction::Backward);
let n = s.repeat(0, true).unwrap();
assert_eq!(n.target.start, 12, "first match strictly after 0");
}
#[test]
fn noh_stops_highlighting_but_n_still_works() {
let mut s = committed("foo");
assert_eq!(s.highlights().len(), 3);
s.clear_highlight();
assert!(s.highlights().is_empty(), "nothing lit");
assert_eq!(s.matches().len(), 3, "but matches are remembered");
assert!(s.repeat(0, false).is_some(), "and n still moves");
}
#[test]
fn incremental_preview_does_not_commit() {
let mut s = SearchState::new(CaseMode::Sensitive);
s.open(Direction::Forward, 0);
for c in "baz".chars() {
s.push(c);
}
assert!(s.preview(TEXT).is_some(), "preview finds it");
assert!(s.pattern().is_none(), "but nothing is committed yet");
assert!(s.matches().is_empty());
}
#[test]
fn preview_finds_a_match_starting_at_the_cursor() {
let mut s = SearchState::new(CaseMode::Sensitive);
s.open(Direction::Forward, 0); for c in "foo".chars() {
s.push(c);
}
assert_eq!(
s.preview(TEXT).unwrap().target.start,
0,
"must light the one under the cursor"
);
}
#[test]
fn preview_of_an_invalid_pattern_is_none_not_a_panic() {
let mut s = SearchState::new(CaseMode::Smart);
s.open(Direction::Forward, 0);
for c in "a[b".chars() {
s.push(c);
}
assert!(s.preview(TEXT).is_none());
}
#[test]
fn history_records_accepted_searches_newest_last() {
let mut s = SearchState::new(CaseMode::Sensitive);
for p in ["foo", "bar", "baz"] {
s.open(Direction::Forward, 0);
for c in p.chars() {
s.push(c);
}
s.accept(TEXT);
}
assert_eq!(s.history(), ["foo", "bar", "baz"]);
}
#[test]
fn repeating_a_search_moves_it_to_the_front_without_duplicating() {
let mut s = SearchState::new(CaseMode::Sensitive);
for p in ["foo", "bar", "foo"] {
s.open(Direction::Forward, 0);
for c in p.chars() {
s.push(c);
}
s.accept(TEXT);
}
assert_eq!(s.history(), ["bar", "foo"], "no duplicate 'foo'");
}
#[test]
fn arrowing_up_walks_back_through_history() {
let mut s = SearchState::new(CaseMode::Sensitive);
for p in ["one", "two"] {
s.open(Direction::Forward, 0);
for c in p.chars() {
s.push(c);
}
s.accept(TEXT);
}
s.open(Direction::Forward, 0);
s.history_step(true);
assert_eq!(s.prompt().unwrap().text, "two");
s.history_step(true);
assert_eq!(s.prompt().unwrap().text, "one");
s.history_step(false);
assert_eq!(s.prompt().unwrap().text, "two");
}
#[test]
fn arrowing_back_down_restores_what_you_were_typing() {
let mut s = SearchState::new(CaseMode::Sensitive);
s.open(Direction::Forward, 0);
for c in "old".chars() {
s.push(c);
}
s.accept(TEXT);
s.open(Direction::Forward, 0);
for c in "typ".chars() {
s.push(c);
}
s.history_step(true);
assert_eq!(s.prompt().unwrap().text, "old");
s.history_step(false);
assert_eq!(s.prompt().unwrap().text, "typ", "the stash comes back");
}
#[test]
fn history_is_bounded() {
let mut s = SearchState::new(CaseMode::Sensitive);
for i in 0..(HISTORY_LIMIT + 10) {
s.open(Direction::Forward, 0);
for c in i.to_string().chars() {
s.push(c);
}
s.accept(TEXT);
}
assert_eq!(s.history().len(), HISTORY_LIMIT);
}
#[test]
fn star_searches_the_whole_word_under_the_cursor() {
let text = "foo foobar foo";
let mut s = SearchState::new(CaseMode::Sensitive);
let hit = s.search_word(text, 0, Direction::Forward).unwrap();
assert_eq!(hit.target.start, 11);
assert_eq!(s.matches().len(), 2, "two whole-word 'foo', not three");
}
#[test]
fn star_on_a_wordless_line_is_none_and_changes_nothing() {
let mut s = committed("foo");
let before = s.matches().len();
assert!(s.search_word(" \n", 0, Direction::Forward).is_none());
assert_eq!(s.matches().len(), before, "state untouched");
}
#[test]
fn typing_after_browsing_history_stops_browsing() {
let mut s = SearchState::new(CaseMode::Sensitive);
s.open(Direction::Forward, 0);
for c in "old".chars() {
s.push(c);
}
s.accept(TEXT);
s.open(Direction::Forward, 0);
s.history_step(true);
assert_eq!(s.prompt().unwrap().text, "old");
s.push('x');
assert_eq!(s.prompt().unwrap().text, "oldx");
s.history_step(false);
assert_eq!(s.prompt().unwrap().text, "oldx");
}
#[test]
fn refresh_tracks_an_edited_buffer() {
let mut s = committed("foo");
assert_eq!(s.matches().len(), 3);
s.refresh("foo");
assert_eq!(s.matches().len(), 1, "matches follow the new text");
}
#[test]
fn pushing_into_a_closed_prompt_is_a_no_op_not_a_panic() {
let mut s = SearchState::new(CaseMode::Smart);
s.push('x');
assert!(!s.is_prompting());
assert!(!s.backspace());
assert_eq!(s.cancel(), None);
}
fn prompting(text: &str) -> SearchState {
let mut st = SearchState::new(CaseMode::Smart);
st.open(Direction::Forward, 0);
for c in text.chars() {
st.push(c);
}
st
}
fn shown(st: &SearchState) -> (String, usize) {
let p = st.prompt().expect("prompting");
(p.text.clone(), p.caret())
}
#[test]
fn typing_appends_and_the_caret_follows() {
let st = prompting("foo");
assert_eq!(shown(&st), ("foo".to_string(), 3));
}
#[test]
fn a_character_typed_mid_pattern_lands_at_the_caret() {
let mut st = prompting("fo");
st.move_caret(CaretMove::Left);
st.push('X');
assert_eq!(shown(&st), ("fXo".to_string(), 2), "inserted AT the caret");
}
#[test]
fn backspace_deletes_before_the_caret_not_at_the_end() {
let mut st = prompting("abc");
st.move_caret(CaretMove::Left); assert!(!st.backspace());
assert_eq!(shown(&st), ("ac".to_string(), 1), "deleted `b`, not `c`");
}
#[test]
fn delete_at_caret_removes_the_character_ahead() {
let mut st = prompting("abc");
st.move_caret(CaretMove::Start);
st.delete_at_caret();
assert_eq!(shown(&st), ("bc".to_string(), 0));
}
#[test]
fn forward_delete_never_closes_the_prompt() {
let mut st = prompting("a");
st.move_caret(CaretMove::Start);
st.delete_at_caret();
assert!(st.is_prompting(), "prompt must stay open");
assert_eq!(shown(&st), (String::new(), 0));
}
#[test]
fn backspace_at_the_start_with_text_ahead_is_a_no_op_not_a_cancel() {
let mut st = prompting("abc");
st.move_caret(CaretMove::Start);
assert!(!st.backspace(), "must not report a close");
assert!(st.is_prompting(), "prompt survives");
assert_eq!(shown(&st), ("abc".to_string(), 0), "text untouched");
}
#[test]
fn backspace_on_an_empty_prompt_still_closes_it() {
let mut st = prompting("");
assert!(st.backspace(), "empty + backspace closes");
assert!(!st.is_prompting());
}
#[test]
fn caret_movement_saturates_at_both_ends() {
let mut st = prompting("ab");
for _ in 0..5 {
st.move_caret(CaretMove::Left);
}
assert_eq!(shown(&st).1, 0, "cannot go left of the start");
for _ in 0..5 {
st.move_caret(CaretMove::Right);
}
assert_eq!(shown(&st).1, 2, "cannot go right of the end");
}
#[test]
fn start_and_end_jump_the_caret() {
let mut st = prompting("hello");
st.move_caret(CaretMove::Start);
assert_eq!(shown(&st).1, 0);
st.move_caret(CaretMove::End);
assert_eq!(shown(&st).1, 5);
}
#[test]
fn the_caret_counts_CHARS_not_bytes() {
let mut st = prompting("héllo");
st.move_caret(CaretMove::Start);
st.move_caret(CaretMove::Right);
st.move_caret(CaretMove::Right); st.push('X');
assert_eq!(shown(&st), ("héXllo".to_string(), 3));
}
#[test]
fn editing_multibyte_text_backwards_does_not_panic() {
let mut st = prompting("🔥é日");
st.move_caret(CaretMove::End);
assert!(!st.backspace());
assert!(!st.backspace());
assert_eq!(shown(&st), ("🔥".to_string(), 1));
}
#[test]
fn ctrl_w_deletes_the_word_before_the_caret() {
let mut st = prompting("foo bar");
st.delete_word_before_caret();
assert_eq!(shown(&st), ("foo ".to_string(), 4));
}
#[test]
fn a_second_ctrl_w_eats_the_gap_and_the_next_word() {
let mut st = prompting("foo bar");
st.delete_word_before_caret();
st.delete_word_before_caret();
assert_eq!(shown(&st), (String::new(), 0));
}
#[test]
fn ctrl_w_keeps_what_is_ahead_of_the_caret() {
let mut st = prompting("foo bar");
st.move_caret(CaretMove::Start);
st.move_caret(CaretMove::Right);
st.move_caret(CaretMove::Right);
st.move_caret(CaretMove::Right); st.delete_word_before_caret();
assert_eq!(shown(&st), (" bar".to_string(), 0));
}
#[test]
fn ctrl_u_clears_back_to_the_start_only() {
let mut st = prompting("abcdef");
st.move_caret(CaretMove::Start);
for _ in 0..3 {
st.move_caret(CaretMove::Right);
}
st.clear_before_caret();
assert_eq!(shown(&st), ("def".to_string(), 0));
}
#[test]
fn history_recall_parks_the_caret_at_the_end() {
let mut st = SearchState::new(CaseMode::Smart);
st.open(Direction::Forward, 0);
for c in "alpha".chars() {
st.push(c);
}
let _ = st.accept("alpha beta");
st.open(Direction::Forward, 0);
st.history_step(true);
let (text, caret) = shown(&st);
assert_eq!(caret, text.chars().count(), "continue typing at the end");
}
#[test]
fn the_caret_never_exceeds_the_text_length() {
let mut st = prompting("hello");
let ops: &[CaretMove] = &[
CaretMove::End,
CaretMove::Left,
CaretMove::Start,
CaretMove::Right,
];
for op in ops {
st.move_caret(*op);
st.delete_at_caret();
let (t, c) = shown(&st);
assert!(c <= t.chars().count(), "caret {c} past {t:?}");
}
}
}