1use std::ops::Range;
2
3use crate::{
4 EditorError, EditorHook, HookContext, KeyCode, PromptAction, PromptPlacement, PromptSpec,
5 SearchQuery, SearchState, Selection, replace_all_query, replace_one_query,
6};
7
8pub const SEARCH_PROMPT_ID: &str = "search";
10pub const REPLACE_PROMPT_ID: &str = "replace";
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum SearchAction {
21 FocusSearch,
23 FocusReplace,
25 ToggleReplace,
27}
28
29fn search_spec() -> PromptSpec {
30 PromptSpec::new(
31 SEARCH_PROMPT_ID,
32 "/",
33 "Find in page",
34 PromptPlacement::BottomBar,
35 true,
36 )
37}
38
39fn replace_spec() -> PromptSpec {
40 PromptSpec::new(
41 REPLACE_PROMPT_ID,
42 "Replace",
43 "Replace with",
44 PromptPlacement::BottomBar,
45 false,
46 )
47}
48
49#[derive(Debug, Clone)]
68pub struct SearchHook {
69 state: SearchState,
70 query_text: String,
71 replacement: String,
72 replace_mode: bool,
73 prompt_is_replace: bool,
74 active: bool,
75 status_cache: String,
76 case_sensitive: bool,
77 whole_word: bool,
78 highlight_all: bool,
79}
80
81impl Default for SearchHook {
82 fn default() -> Self {
83 Self {
84 state: SearchState::new(),
85 query_text: String::new(),
86 replacement: String::new(),
87 replace_mode: false,
88 prompt_is_replace: false,
89 active: false,
90 status_cache: "SEARCH".to_string(),
91 case_sensitive: true,
92 whole_word: false,
93 highlight_all: true,
94 }
95 }
96}
97
98impl SearchHook {
99 pub fn new() -> Self {
102 Self::default()
103 }
104
105 pub fn is_active(&self) -> bool {
107 self.active
108 }
109
110 pub fn match_count(&self) -> usize {
112 self.state.match_count()
113 }
114
115 pub fn current_match(&self) -> Option<Range<usize>> {
117 self.state.current_match()
118 }
119
120 pub fn query_text(&self) -> &str {
122 &self.query_text
123 }
124
125 pub fn replacement(&self) -> &str {
127 &self.replacement
128 }
129
130 pub fn case_sensitive(&self) -> bool {
132 self.case_sensitive
133 }
134
135 pub fn whole_word(&self) -> bool {
137 self.whole_word
138 }
139
140 pub fn highlight_all(&self) -> bool {
142 self.highlight_all
143 }
144
145 pub fn is_replace_mode(&self) -> bool {
147 self.replace_mode
148 }
149
150 pub fn is_replace_prompt(&self) -> bool {
152 self.prompt_is_replace
153 }
154
155 pub fn build_query(&self) -> Option<SearchQuery> {
158 if self.query_text.is_empty() {
159 return None;
160 }
161 Some(SearchQuery::new(
162 &self.query_text,
163 self.case_sensitive,
164 self.whole_word,
165 false,
166 ))
167 }
168
169 pub fn toggle_case(&mut self, ctx: &mut HookContext) {
171 self.case_sensitive = !self.case_sensitive;
172 self.rescan(ctx);
173 }
174
175 pub fn toggle_word(&mut self, ctx: &mut HookContext) {
177 self.whole_word = !self.whole_word;
178 self.rescan(ctx);
179 }
180
181 pub fn toggle_highlight(&mut self) {
183 self.highlight_all = !self.highlight_all;
184 self.update_status();
185 }
186
187 pub fn open_search(&mut self, ctx: &mut HookContext, initial: &str) {
189 ctx.prompt.open(search_spec(), initial);
190 self.query_text = initial.to_string();
191 self.active = true;
192 self.prompt_is_replace = false;
193 self.refresh_from_input(ctx);
194 }
195
196 pub fn open_replace(&mut self, ctx: &mut HookContext) {
198 if ctx.prompt.spec().is_some_and(|s| s.id == SEARCH_PROMPT_ID) {
199 self.query_text = ctx.prompt.input().to_string();
200 }
201 let replacement = self.replacement.clone();
202 ctx.prompt.open(replace_spec(), &replacement);
203 self.active = true;
204 self.replace_mode = true;
205 self.prompt_is_replace = true;
206 }
207
208 pub fn close(&mut self, ctx: &mut HookContext) {
210 ctx.prompt.close();
211 self.active = false;
212 self.replace_mode = false;
213 self.prompt_is_replace = false;
214 self.update_status();
215 }
216
217 pub fn toggle_replace(&mut self, ctx: &mut HookContext) {
219 if self.replace_mode {
220 self.replace_mode = false;
221 if self.prompt_is_replace {
222 self.focus_search(ctx);
223 }
224 } else {
225 self.open_replace(ctx);
226 }
227 }
228
229 pub fn focus_search(&mut self, ctx: &mut HookContext) {
231 if self.prompt_is_replace {
232 self.replacement = ctx.prompt.input().to_string();
233 let query = self.query_text.clone();
234 ctx.prompt.open(search_spec(), &query);
235 self.prompt_is_replace = false;
236 self.refresh_from_input(ctx);
237 }
238 }
239
240 pub fn focus_replace(&mut self, ctx: &mut HookContext) {
242 if !self.prompt_is_replace {
243 self.open_replace(ctx);
244 }
245 }
246
247 fn refresh_from_input(&mut self, ctx: &mut HookContext) {
249 self.query_text = ctx.prompt.input().to_string();
250 self.rescan(ctx);
251 }
252
253 fn rescan(&mut self, ctx: &mut HookContext) {
255 if self.query_text.is_empty() {
256 self.state = SearchState::new();
257 } else {
258 self.state.set_query(SearchQuery::new(
259 &self.query_text,
260 self.case_sensitive,
261 self.whole_word,
262 false,
263 ));
264 let _ = self.state.refresh(ctx.buffer);
266 }
267 self.update_status();
268 }
269
270 fn update_status(&mut self) {
271 let scope = if self.replace_mode {
272 "REPLACE"
273 } else {
274 "SEARCH"
275 };
276 let flags = format!(
277 "[{}] [{}] [{}]",
278 if self.case_sensitive { "Aa" } else { "aa" },
279 if self.whole_word { "W" } else { "w" },
280 if self.highlight_all { "H" } else { "h" },
281 );
282 let count = self.state.match_count();
283 self.status_cache = if count == 0 {
284 format!("{scope} — no matches {flags}")
285 } else {
286 match self.state.current_index() {
287 Some(i) => format!("{scope} {}/{} {flags}", i + 1, count),
288 None => format!("{scope} — {count} matches {flags}"),
289 }
290 };
291 }
292
293 pub fn navigate_next(&mut self, ctx: &mut HookContext, wrap: bool) -> bool {
298 self.navigate_next_from(ctx, ctx.buffer.cursor_offset(), wrap)
299 }
300
301 pub fn navigate_next_from(&mut self, ctx: &mut HookContext, from: usize, wrap: bool) -> bool {
303 let from = match self.state.current_match() {
304 Some(m) if m.start == from => from + 1,
305 _ => from,
306 };
307 let found = self
308 .state
309 .next(ctx.buffer, from, wrap)
310 .ok()
311 .flatten()
312 .map(|m| {
313 ctx.buffer.set_cursor_offset(m.start);
314 *ctx.selection = Some(Selection::range(m.start, m.end));
315 })
316 .is_some();
317 self.update_status();
318 found
319 }
320
321 pub fn navigate_prev(&mut self, ctx: &mut HookContext, wrap: bool) -> bool {
323 self.navigate_prev_from(ctx, ctx.buffer.cursor_offset(), wrap)
324 }
325
326 pub fn navigate_prev_from(&mut self, ctx: &mut HookContext, from: usize, wrap: bool) -> bool {
328 let found = self
329 .state
330 .prev(ctx.buffer, from, wrap)
331 .ok()
332 .flatten()
333 .map(|m| {
334 ctx.buffer.set_cursor_offset(m.start);
335 *ctx.selection = Some(Selection::range(m.start, m.end));
336 })
337 .is_some();
338 self.update_status();
339 found
340 }
341
342 pub fn replace_current(&mut self, ctx: &mut HookContext) -> Result<bool, EditorError> {
346 if ctx.prompt.spec().is_some_and(|s| s.id == REPLACE_PROMPT_ID) {
347 self.replacement = ctx.prompt.input().to_string();
348 }
349 if self.query_text.is_empty() {
350 return Ok(false);
351 }
352 let _ = self.state.refresh(ctx.buffer);
353 let from = ctx.buffer.cursor_offset();
354 let target = self
355 .state
356 .matches()
357 .iter()
358 .find(|m| m.start <= from && from <= m.end)
359 .cloned()
360 .or_else(|| {
361 self.state
362 .matches()
363 .iter()
364 .find(|m| m.start >= from)
365 .cloned()
366 })
367 .or_else(|| self.state.matches().first().cloned());
368 let Some(target) = target else {
369 return Ok(false);
370 };
371 let Some(query) = self.build_query() else {
372 return Ok(false);
373 };
374 let replacement = self.replacement.clone();
375 if !replace_one_query(ctx.buffer, &query, target, &replacement)? {
376 return Ok(false);
377 }
378 let _ = self.state.refresh(ctx.buffer);
379 self.navigate_next(ctx, true);
380 Ok(true)
381 }
382
383 pub fn replace_all(&mut self, ctx: &mut HookContext) -> Result<usize, EditorError> {
386 if ctx.prompt.spec().is_some_and(|s| s.id == REPLACE_PROMPT_ID) {
387 self.replacement = ctx.prompt.input().to_string();
388 }
389 let Some(query) = self.build_query() else {
390 return Ok(0);
391 };
392 let replacement = self.replacement.clone();
393 let n = replace_all_query(ctx.buffer, &query, &replacement)?;
394 let _ = self.state.refresh(ctx.buffer);
395 *ctx.selection = None;
396 ctx.prompt.set_message(&format!(
397 "Replaced {n} match{}",
398 if n == 1 { "" } else { "es" }
399 ));
400 self.update_status();
401 Ok(n)
402 }
403
404 fn ctrl_f_initial(&self, ctx: &HookContext) -> String {
406 match ctx.selection.map(|s| s.byte_range()) {
407 Some(range) if !range.is_empty() => {
408 let end = range.end.min(ctx.buffer.len_bytes());
409 let start = range.start.min(end);
410 ctx.buffer.text().byte_slice(start..end).to_string()
411 }
412 _ => self.query_text.clone(),
413 }
414 }
415}
416
417impl SearchHook {
418 fn owns_prompt(&self, ctx: &HookContext) -> bool {
420 ctx.prompt.is_open()
421 && ctx
422 .prompt
423 .spec()
424 .is_some_and(|s| s.id == SEARCH_PROMPT_ID || s.id == REPLACE_PROMPT_ID)
425 }
426}
427
428impl EditorHook for SearchHook {
429 fn on_search_action(
430 &mut self,
431 ctx: &mut HookContext,
432 action: SearchAction,
433 ) -> crate::HookOutcome {
434 if !self.owns_prompt(ctx) {
435 return crate::HookOutcome::PassThrough;
436 }
437 match action {
438 SearchAction::FocusSearch => self.focus_search(ctx),
439 SearchAction::FocusReplace => self.focus_replace(ctx),
440 SearchAction::ToggleReplace => self.toggle_replace(ctx),
441 }
442 crate::HookOutcome::Consumed
443 }
444
445 fn on_key(&mut self, ctx: &mut HookContext, event: &crate::KeyEvent) -> crate::HookOutcome {
446 use crate::HookOutcome;
447
448 let lowered: Option<char> = match event.code {
449 KeyCode::Char(c) => Some(c.to_ascii_lowercase()),
450 _ => None,
451 };
452 let mods = &event.modifiers;
453 let owns_prompt = self.owns_prompt(ctx);
454
455 if owns_prompt {
456 if lowered == Some('f') && mods.ctrl && !mods.alt && !mods.meta {
457 self.focus_search(ctx);
458 return HookOutcome::Consumed;
459 }
460 if event.code == KeyCode::Tab
461 && !mods.ctrl
462 && !mods.alt
463 && !mods.meta
464 && self.replace_mode
465 {
466 if self.prompt_is_replace {
467 self.focus_search(ctx);
468 } else {
469 self.focus_replace(ctx);
470 }
471 return HookOutcome::Consumed;
472 }
473 if event.code == KeyCode::Enter && mods.ctrl && !mods.alt && !mods.meta {
474 let _ = self.replace_current(ctx);
475 return HookOutcome::Consumed;
476 }
477 if lowered == Some('a') && mods.alt && !mods.ctrl && !mods.meta {
478 let _ = self.replace_all(ctx);
479 return HookOutcome::Consumed;
480 }
481 if lowered == Some('c') && mods.alt && !mods.ctrl && !mods.meta {
482 self.toggle_case(ctx);
483 return HookOutcome::Consumed;
484 }
485 if lowered == Some('w') && mods.alt && !mods.ctrl && !mods.meta {
486 self.toggle_word(ctx);
487 return HookOutcome::Consumed;
488 }
489 if lowered == Some('h') && mods.alt && !mods.ctrl && !mods.meta {
490 self.toggle_highlight();
491 return HookOutcome::Consumed;
492 }
493 if event.code == KeyCode::Up && !mods.ctrl && !mods.meta {
494 if mods.alt {
495 ctx.prompt.history_prev();
496 } else {
497 self.navigate_prev(ctx, true);
498 }
499 return HookOutcome::Consumed;
500 }
501 if event.code == KeyCode::Down && !mods.ctrl && !mods.meta {
502 if mods.alt {
503 ctx.prompt.history_next();
504 } else {
505 self.navigate_next(ctx, true);
506 }
507 return HookOutcome::Consumed;
508 }
509 if lowered == Some('h') && mods.ctrl && !mods.alt && !mods.meta {
510 if !self.replace_mode {
511 self.open_replace(ctx);
512 } else if !self.prompt_is_replace {
513 self.focus_replace(ctx);
514 } else {
515 self.toggle_replace(ctx);
516 }
517 return HookOutcome::Consumed;
518 }
519 if event.code == KeyCode::F(3) && !mods.ctrl && !mods.alt && !mods.meta {
520 if mods.shift {
521 self.navigate_prev(ctx, true);
522 } else {
523 self.navigate_next(ctx, true);
524 }
525 return HookOutcome::Consumed;
526 }
527 let is_replace = ctx.prompt.spec().is_some_and(|s| s.id == REPLACE_PROMPT_ID);
528 match ctx.prompt.handle_key(event) {
529 PromptAction::Editing => {
530 if is_replace {
531 self.replacement = ctx.prompt.input().to_string();
532 } else {
533 self.refresh_from_input(ctx);
534 }
535 HookOutcome::Consumed
536 }
537 PromptAction::Submitted(input) => {
538 if is_replace {
539 self.replacement = input;
540 let _ = self.replace_current(ctx);
541 } else {
542 self.query_text = input;
543 self.navigate_next(ctx, true);
544 }
545 HookOutcome::Consumed
546 }
547 PromptAction::Cancelled => {
548 self.active = false;
549 self.replace_mode = false;
550 self.prompt_is_replace = false;
551 self.update_status();
552 HookOutcome::Consumed
553 }
554 PromptAction::Ignored => HookOutcome::Consumed,
555 }
556 } else if !ctx.prompt.is_open() {
557 let lowered: Option<char> = match event.code {
558 KeyCode::Char(c) => Some(c.to_ascii_lowercase()),
559 _ => None,
560 };
561 if lowered == Some('f') && mods.ctrl && !mods.alt && !mods.meta {
562 let initial = self.ctrl_f_initial(ctx);
563 self.replace_mode = false;
564 self.open_search(ctx, &initial);
565 return HookOutcome::Consumed;
566 }
567 if lowered == Some('h') && mods.ctrl && !mods.alt && !mods.meta {
568 if !self.active {
569 let initial = self.ctrl_f_initial(ctx);
570 self.open_search(ctx, &initial);
571 }
572 self.open_replace(ctx);
573 return HookOutcome::Consumed;
574 }
575 if lowered == Some('c') && mods.alt && !mods.ctrl && !mods.meta && self.active {
576 self.toggle_case(ctx);
577 return HookOutcome::Consumed;
578 }
579 if lowered == Some('w') && mods.alt && !mods.ctrl && !mods.meta && self.active {
580 self.toggle_word(ctx);
581 return HookOutcome::Consumed;
582 }
583 if lowered == Some('h') && mods.alt && !mods.ctrl && !mods.meta && self.active {
584 self.toggle_highlight();
585 return HookOutcome::Consumed;
586 }
587 if event.code == KeyCode::F(3)
588 && !mods.ctrl
589 && !mods.alt
590 && !mods.meta
591 && self.active
592 && !self.query_text.is_empty()
593 {
594 if mods.shift {
595 self.navigate_prev(ctx, true);
596 } else {
597 self.navigate_next(ctx, true);
598 }
599 return HookOutcome::Consumed;
600 }
601 HookOutcome::PassThrough
602 } else {
603 HookOutcome::PassThrough
604 }
605 }
606
607 fn status_text(&self) -> Option<&str> {
608 if self.active {
609 Some(&self.status_cache)
610 } else {
611 None
612 }
613 }
614
615 fn search_snapshot(&self) -> Option<crate::SearchSnapshot> {
616 if !self.active {
617 return None;
618 }
619 Some(crate::SearchSnapshot {
620 active: true,
621 case_sensitive: self.case_sensitive,
622 whole_word: self.whole_word,
623 highlight_all: self.highlight_all,
624 matches: self.state.matches().to_vec(),
625 current: self.state.current_index(),
626 replace_mode: self.replace_mode,
627 is_replace_prompt: self.prompt_is_replace,
628 query: self.query_text.clone(),
629 replacement: self.replacement.clone(),
630 })
631 }
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637 use crate::{
638 CursorStyle, EditorBuffer, HookContext, HookOutcome, KeyCode, KeyEvent, PromptState,
639 };
640
641 struct Harness {
642 buffer: EditorBuffer,
643 selection: Option<Selection>,
644 cursor_style: CursorStyle,
645 prompt: PromptState,
646 effects: Vec<crate::HookEffect>,
647 hook: SearchHook,
648 }
649
650 impl Harness {
651 fn new(text: &str) -> Self {
652 Self {
653 buffer: EditorBuffer::new(text),
654 selection: None,
655 cursor_style: CursorStyle::Bar,
656 prompt: PromptState::new(),
657 effects: Vec::new(),
658 hook: SearchHook::new(),
659 }
660 }
661
662 fn key(&mut self, code: KeyCode) -> HookOutcome {
663 let event = KeyEvent::plain(code);
664 let Harness {
665 buffer,
666 selection,
667 cursor_style,
668 prompt,
669 effects,
670 hook,
671 } = self;
672 let mut ctx = HookContext::new(buffer, selection, cursor_style, prompt, effects);
673 hook.on_key(&mut ctx, &event)
674 }
675
676 fn key_mod(&mut self, code: KeyCode, ctrl: bool, alt: bool, shift: bool) -> HookOutcome {
677 let event = KeyEvent {
678 code,
679 modifiers: crate::Modifiers {
680 ctrl,
681 alt,
682 shift,
683 meta: false,
684 },
685 };
686 let Harness {
687 buffer,
688 selection,
689 cursor_style,
690 prompt,
691 effects,
692 hook,
693 } = self;
694 let mut ctx = HookContext::new(buffer, selection, cursor_style, prompt, effects);
695 hook.on_key(&mut ctx, &event)
696 }
697
698 fn ctx_text(&self) -> String {
699 self.buffer.text().to_string()
700 }
701
702 fn type_into_prompt(&mut self, text: &str) {
703 for ch in text.chars() {
704 self.key(KeyCode::Char(ch));
705 }
706 }
707
708 fn with_ctx<R>(&mut self, f: impl FnOnce(&mut HookContext<'_>, &mut SearchHook) -> R) -> R {
709 let Harness {
710 buffer,
711 selection,
712 cursor_style,
713 prompt,
714 effects,
715 hook,
716 } = self;
717 let mut ctx = HookContext::new(buffer, selection, cursor_style, prompt, effects);
718 f(&mut ctx, hook)
719 }
720 }
721
722 #[test]
723 fn idle_hook_passes_everything_through() {
724 let mut h = Harness::new("hello");
725 assert_eq!(h.key(KeyCode::Char('a')), HookOutcome::PassThrough);
726 assert_eq!(h.key(KeyCode::Enter), HookOutcome::PassThrough);
727 assert!(h.hook.status_text().is_none());
728 }
729
730 #[test]
731 fn ctrl_f_opens_search_with_selection_as_initial() {
732 let mut h = Harness::new("hello world");
733 h.selection = Some(Selection::range(0, 5));
734 assert_eq!(
735 h.key_mod(KeyCode::Char('f'), true, false, false),
736 HookOutcome::Consumed
737 );
738 assert!(h.prompt.is_open());
739 assert_eq!(h.prompt.input(), "hello");
740 assert_eq!(h.hook.match_count(), 1);
741 assert!(h.hook.status_text().is_some());
742 }
743
744 #[test]
745 fn typing_live_refreshes_and_enter_navigates() {
746 let mut h = Harness::new("foo bar foo");
747 assert_eq!(
748 h.key_mod(KeyCode::Char('f'), true, false, false),
749 HookOutcome::Consumed
750 );
751
752 for k in [KeyCode::Char('f'), KeyCode::Char('o'), KeyCode::Char('o')] {
753 assert_eq!(h.key(k), HookOutcome::Consumed);
754 }
755 assert_eq!(h.hook.match_count(), 2);
756
757 assert_eq!(h.key(KeyCode::Enter), HookOutcome::Consumed);
758 assert_eq!(h.selection.unwrap().byte_range(), 0..3);
759 assert_eq!(h.hook.status_text(), Some("SEARCH 1/2 [Aa] [w] [H]"));
760
761 assert_eq!(h.key(KeyCode::Enter), HookOutcome::Consumed);
762 assert_eq!(h.selection.unwrap().byte_range(), 8..11);
763 assert_eq!(h.hook.status_text(), Some("SEARCH 2/2 [Aa] [w] [H]"));
764 }
765
766 #[test]
767 fn toggle_case_rescans_case_insensitively() {
768 let mut h = Harness::new("Foo foo FOO");
769 h.key_mod(KeyCode::Char('f'), true, false, false);
770 for k in [KeyCode::Char('f'), KeyCode::Char('o'), KeyCode::Char('o')] {
771 h.key(k);
772 }
773 assert_eq!(h.hook.match_count(), 1);
774 assert!(h.hook.case_sensitive());
775
776 h.with_ctx(|ctx, hook| hook.toggle_case(ctx));
777 assert!(!h.hook.case_sensitive());
778 assert_eq!(h.hook.match_count(), 3);
779 assert_eq!(
780 h.hook.status_text(),
781 Some("SEARCH — 3 matches [aa] [w] [H]")
782 );
783
784 h.with_ctx(|ctx, hook| hook.toggle_case(ctx));
785 assert_eq!(h.hook.match_count(), 1);
786 }
787
788 #[test]
789 fn toggle_word_filters_substring_matches() {
790 let mut h = Harness::new("foo foobar foo");
791 h.key_mod(KeyCode::Char('f'), true, false, false);
792 for k in [KeyCode::Char('f'), KeyCode::Char('o'), KeyCode::Char('o')] {
793 h.key(k);
794 }
795 assert_eq!(h.hook.match_count(), 3);
796
797 h.with_ctx(|ctx, hook| hook.toggle_word(ctx));
798 assert!(h.hook.whole_word());
799 assert_eq!(h.hook.match_count(), 2);
800 assert_eq!(
801 h.hook.status_text(),
802 Some("SEARCH — 2 matches [Aa] [W] [H]")
803 );
804 }
805
806 #[test]
807 fn toggle_highlight_flips_without_rescanning() {
808 let mut h = Harness::new("foo foo");
809 h.key_mod(KeyCode::Char('f'), true, false, false);
810 h.key(KeyCode::Char('f'));
811 assert!(h.hook.highlight_all());
812
813 h.with_ctx(|ctx, hook| {
814 hook.toggle_highlight();
815 let _ = ctx;
816 });
817 assert!(!h.hook.highlight_all());
818 assert_eq!(h.hook.match_count(), 2);
819 assert!(h.hook.status_text().unwrap().ends_with("[Aa] [w] [h]"));
820 }
821
822 #[test]
823 fn replace_honors_toggle_flags() {
824 let mut h = Harness::new("Foo foo");
825 h.key_mod(KeyCode::Char('f'), true, false, false);
826 for k in [KeyCode::Char('f'), KeyCode::Char('o'), KeyCode::Char('o')] {
827 h.key(k);
828 }
829 h.with_ctx(|ctx, hook| hook.toggle_case(ctx));
830 h.key_mod(KeyCode::Char('h'), true, false, false);
831 for k in [KeyCode::Char('b'), KeyCode::Char('a'), KeyCode::Char('r')] {
832 h.key(k);
833 }
834 h.with_ctx(|ctx, hook| {
835 assert_eq!(hook.replace_all(ctx).unwrap(), 2);
836 });
837 assert_eq!(h.ctx_text(), "bar bar");
838 }
839
840 #[test]
841 fn build_query_is_none_when_empty() {
842 let hook = SearchHook::new();
843 assert!(hook.build_query().is_none());
844 }
845
846 #[test]
847 fn alt_h_toggles_highlight_via_key() {
848 let mut h = Harness::new("foo foo");
849 h.key_mod(KeyCode::Char('f'), true, false, false);
850 h.key(KeyCode::Char('f'));
851 assert!(h.hook.highlight_all());
852 assert_eq!(
853 h.key_mod(KeyCode::Char('h'), false, true, false),
854 HookOutcome::Consumed
855 );
856 assert!(!h.hook.highlight_all());
857 assert!(h.prompt.is_open());
859 assert_eq!(h.hook.match_count(), 2);
860 }
861
862 #[test]
863 fn snapshot_reports_state_while_active() {
864 let mut h = Harness::new("foo bar foo");
865 assert!(h.hook.search_snapshot().is_none());
866 h.key_mod(KeyCode::Char('f'), true, false, false);
867 for k in [KeyCode::Char('f'), KeyCode::Char('o'), KeyCode::Char('o')] {
868 h.key(k);
869 }
870 h.key(KeyCode::Enter);
871 let snap = h.hook.search_snapshot().expect("snapshot while active");
872 assert!(snap.active);
873 assert!(snap.case_sensitive);
874 assert!(!snap.whole_word);
875 assert!(snap.highlight_all);
876 assert_eq!(snap.matches, vec![0..3, 8..11]);
877 assert_eq!(snap.current, Some(0));
878 h.key(KeyCode::Escape);
879 assert!(h.hook.search_snapshot().is_none());
880 }
881
882 #[test]
883 fn alt_c_and_alt_w_toggle_via_keys() {
884 let mut h = Harness::new("Foo foo foobar");
885 h.key_mod(KeyCode::Char('f'), true, false, false);
886 for k in [KeyCode::Char('f'), KeyCode::Char('o'), KeyCode::Char('o')] {
887 h.key(k);
888 }
889 assert_eq!(h.hook.match_count(), 2);
890
891 assert_eq!(
892 h.key_mod(KeyCode::Char('c'), false, true, false),
893 HookOutcome::Consumed
894 );
895 assert_eq!(h.hook.match_count(), 3);
896
897 assert_eq!(
898 h.key_mod(KeyCode::Char('w'), false, true, false),
899 HookOutcome::Consumed
900 );
901 assert_eq!(h.hook.match_count(), 2);
902 assert_eq!(
903 h.hook.status_text(),
904 Some("SEARCH — 2 matches [aa] [W] [H]")
905 );
906 }
907
908 #[test]
909 fn up_down_navigate_matches_and_alt_reaches_history() {
910 let mut h = Harness::new("foo bar foo");
911 h.key_mod(KeyCode::Char('f'), true, false, false);
912 for k in [KeyCode::Char('f'), KeyCode::Char('o'), KeyCode::Char('o')] {
913 h.key(k);
914 }
915 h.key(KeyCode::Enter);
916 assert_eq!(h.selection.unwrap().byte_range(), 0..3);
917
918 assert_eq!(h.key(KeyCode::Down), HookOutcome::Consumed);
920 assert_eq!(h.selection.unwrap().byte_range(), 8..11);
921 assert_eq!(h.key(KeyCode::Up), HookOutcome::Consumed);
922 assert_eq!(h.selection.unwrap().byte_range(), 0..3);
923
924 h.key(KeyCode::Enter);
926 h.type_into_prompt("zzz");
927 assert_eq!(
928 h.key_mod(KeyCode::Up, false, true, false),
929 HookOutcome::Consumed
930 );
931 assert_eq!(h.prompt.input(), "foo");
932 }
933
934 #[test]
935 fn f3_keys_navigate_without_typing() {
936 let mut h = Harness::new("aa aa");
937 h.key_mod(KeyCode::Char('f'), true, false, false);
938 h.key(KeyCode::Char('a'));
939 h.key(KeyCode::Char('a'));
940 h.key(KeyCode::Enter);
941 assert_eq!(h.selection.unwrap().byte_range(), 0..2);
942
943 assert_eq!(h.key(KeyCode::F(3)), HookOutcome::Consumed);
944 assert_eq!(h.selection.unwrap().byte_range(), 3..5);
945
946 assert_eq!(
947 h.key_mod(KeyCode::F(3), false, false, true),
948 HookOutcome::Consumed
949 );
950 assert_eq!(h.selection.unwrap().byte_range(), 0..2);
951 }
952
953 #[test]
954 fn escape_closes_and_resets_mode() {
955 let mut h = Harness::new("foo foo");
956 h.key_mod(KeyCode::Char('f'), true, false, false);
957 assert!(h.prompt.is_open());
958 assert_eq!(h.key(KeyCode::Escape), HookOutcome::Consumed);
959 assert!(!h.prompt.is_open());
960 assert!(h.hook.status_text().is_none());
961 assert_eq!(h.key(KeyCode::Char('a')), HookOutcome::PassThrough);
962 }
963
964 #[test]
965 fn ctrl_h_replace_flow_replaces_current_and_all() {
966 let mut h = Harness::new("foo bar foo");
967 h.key_mod(KeyCode::Char('f'), true, false, false);
968 for k in [KeyCode::Char('f'), KeyCode::Char('o'), KeyCode::Char('o')] {
969 h.key(k);
970 }
971
972 assert_eq!(
973 h.key_mod(KeyCode::Char('h'), true, false, false),
974 HookOutcome::Consumed
975 );
976 assert_eq!(h.prompt.spec().unwrap().id, REPLACE_PROMPT_ID);
977 for k in [KeyCode::Char('b'), KeyCode::Char('a'), KeyCode::Char('z')] {
978 h.key(k);
979 }
980 assert_eq!(h.key(KeyCode::Enter), HookOutcome::Consumed);
981 assert_eq!(h.prompt.spec().unwrap().id, REPLACE_PROMPT_ID);
982 assert_eq!(h.hook.replacement(), "baz");
983 assert_eq!(h.ctx_text(), "baz bar foo");
984 assert_eq!(h.selection.unwrap().byte_range(), 8..11);
985
986 assert_eq!(
987 h.key_mod(KeyCode::Char('a'), false, true, false),
988 HookOutcome::Consumed
989 );
990 assert_eq!(h.ctx_text(), "baz bar baz");
991 assert_eq!(h.prompt.message(), Some("Replaced 1 match"));
992 h.buffer.undo();
993 assert_eq!(h.ctx_text(), "baz bar foo");
994 }
995
996 #[test]
997 fn public_api_drives_vim_style_flows() {
998 let mut h = Harness::new("foo bar foo");
999 let Harness {
1000 buffer,
1001 selection,
1002 cursor_style,
1003 prompt,
1004 effects,
1005 hook,
1006 } = &mut h;
1007 let mut ctx = HookContext::new(buffer, selection, cursor_style, prompt, effects);
1008
1009 hook.open_search(&mut ctx, "foo");
1010 assert_eq!(hook.match_count(), 2);
1011 assert!(hook.navigate_next(&mut ctx, true));
1012 assert_eq!(ctx.selection.unwrap().byte_range(), 0..3);
1013 assert!(hook.navigate_prev(&mut ctx, true));
1014 assert_eq!(ctx.selection.unwrap().byte_range(), 8..11);
1015
1016 hook.close(&mut ctx);
1017 assert!(!ctx.prompt.is_open());
1018 assert!(!hook.is_active());
1019 }
1020
1021 #[test]
1022 fn replace_while_typing_in_replace_prompt_with_ctrl_enter_and_alt_a() {
1023 let mut h = Harness::new("alpha beta alpha");
1024 h.key_mod(KeyCode::Char('f'), true, false, false);
1025 for k in [
1026 KeyCode::Char('a'),
1027 KeyCode::Char('l'),
1028 KeyCode::Char('p'),
1029 KeyCode::Char('h'),
1030 KeyCode::Char('a'),
1031 ] {
1032 h.key(k);
1033 }
1034 assert_eq!(h.hook.match_count(), 2);
1035
1036 assert_eq!(
1037 h.key_mod(KeyCode::Char('h'), true, false, false),
1038 HookOutcome::Consumed
1039 );
1040 assert_eq!(h.prompt.spec().unwrap().id, REPLACE_PROMPT_ID);
1041
1042 for k in [
1043 KeyCode::Char('o'),
1044 KeyCode::Char('m'),
1045 KeyCode::Char('e'),
1046 KeyCode::Char('g'),
1047 KeyCode::Char('a'),
1048 ] {
1049 h.key(k);
1050 }
1051 assert_eq!(h.hook.replacement(), "omega");
1052
1053 assert_eq!(
1054 h.key_mod(KeyCode::Enter, true, false, false),
1055 HookOutcome::Consumed
1056 );
1057 assert_eq!(h.ctx_text(), "omega beta alpha");
1058
1059 assert_eq!(
1060 h.key_mod(KeyCode::Char('a'), false, true, false),
1061 HookOutcome::Consumed
1062 );
1063 assert_eq!(h.ctx_text(), "omega beta omega");
1064 }
1065
1066 #[test]
1067 fn tab_toggles_between_search_and_replace_in_replace_mode() {
1068 let mut h = Harness::new("one two one");
1069 h.key_mod(KeyCode::Char('f'), true, false, false);
1070 for k in [KeyCode::Char('o'), KeyCode::Char('n'), KeyCode::Char('e')] {
1071 h.key(k);
1072 }
1073 h.key_mod(KeyCode::Char('h'), true, false, false);
1074 assert!(h.hook.is_replace_mode());
1075 assert!(h.hook.is_replace_prompt());
1076
1077 h.key(KeyCode::Tab);
1078 assert!(!h.hook.is_replace_prompt());
1079 assert_eq!(h.prompt.spec().unwrap().id, SEARCH_PROMPT_ID);
1080 assert_eq!(h.prompt.input(), "one");
1081
1082 h.key(KeyCode::Tab);
1083 assert!(h.hook.is_replace_prompt());
1084 assert_eq!(h.prompt.spec().unwrap().id, REPLACE_PROMPT_ID);
1085 }
1086
1087 #[test]
1088 fn search_snapshot_reflects_replace_state() {
1089 let mut h = Harness::new("test test");
1090 h.key_mod(KeyCode::Char('f'), true, false, false);
1091 h.type_into_prompt("test");
1092 let snap = h.hook.search_snapshot().unwrap();
1093 assert_eq!(snap.query, "test");
1094 assert!(!snap.replace_mode);
1095 assert!(!snap.is_replace_prompt);
1096
1097 h.key_mod(KeyCode::Char('h'), true, false, false);
1098 h.type_into_prompt("passed");
1099 let snap = h.hook.search_snapshot().unwrap();
1100 assert_eq!(snap.query, "test");
1101 assert_eq!(snap.replacement, "passed");
1102 assert!(snap.replace_mode);
1103 assert!(snap.is_replace_prompt);
1104 }
1105}