use bevy::prelude::Resource;
use std::num::NonZeroUsize;
use crate::StatusInfoText;
use super::{
config::VimOptions,
error::VimError,
motion::{clamp_to_boundary, clamp_to_cursor_position},
};
const MAX_SEARCH_QUERY_CHARS: usize = 256;
const DEFAULT_SEARCH_HIGHLIGHT_SCAN_BYTES: usize = 64 * 1024;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SearchDirection {
Forward,
Backward,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SearchRepeatDirection {
Next,
Previous,
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub struct SearchQuery {
text: String,
}
impl SearchQuery {
#[must_use]
pub const fn new() -> Self {
Self {
text: String::new(),
}
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.text
}
#[must_use]
pub fn status_text(&self) -> StatusInfoText {
StatusInfoText::escaped_display(&self.text)
}
#[must_use]
pub const fn byte_len(&self) -> usize {
self.text.len()
}
#[must_use]
pub fn case_sensitivity(&self, options: &VimOptions) -> SearchCaseSensitivity {
if !options.ignore_case || (options.smart_case && self.text.chars().any(char::is_uppercase))
{
SearchCaseSensitivity::Sensitive
} else {
SearchCaseSensitivity::Insensitive
}
}
#[must_use]
pub const fn is_empty(&self) -> bool {
self.text.is_empty()
}
pub fn push_str(&mut self, text: &str) {
let remaining = MAX_SEARCH_QUERY_CHARS.saturating_sub(self.text.chars().count());
self.text.extend(text.chars().take(remaining));
}
pub fn pop_char(&mut self) {
let _removed = self.text.pop();
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SearchCaseSensitivity {
Sensitive,
Insensitive,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SearchOutcome {
Match {
byte_index: usize,
},
Error(VimError),
}
#[derive(Clone, Debug, Default, Eq, PartialEq, Resource)]
pub struct SearchState {
active_query: Option<SearchQuery>,
active_direction: Option<SearchDirection>,
last_query: Option<SearchQuery>,
last_direction: Option<SearchDirection>,
last_outcome: Option<SearchOutcome>,
}
pub type VimSearchState = SearchState;
impl SearchState {
pub fn start(&mut self, direction: SearchDirection) {
self.active_query = Some(SearchQuery::new());
self.active_direction = Some(direction);
self.last_outcome = None;
}
pub fn cancel(&mut self) {
self.active_query = None;
self.active_direction = None;
}
#[must_use]
pub const fn is_active(&self) -> bool {
self.active_query.is_some()
}
#[must_use]
pub const fn active_query(&self) -> Option<&SearchQuery> {
self.active_query.as_ref()
}
#[must_use]
pub const fn active_direction(&self) -> Option<SearchDirection> {
self.active_direction
}
#[must_use]
pub const fn last_outcome(&self) -> Option<SearchOutcome> {
self.last_outcome
}
#[must_use]
pub const fn last_query(&self) -> Option<&SearchQuery> {
self.last_query.as_ref()
}
#[must_use]
pub const fn last_direction(&self) -> Option<SearchDirection> {
self.last_direction
}
pub fn push_text(&mut self, text: &str) {
if let Some(query) = &mut self.active_query {
query.push_str(text);
}
}
pub fn backspace(&mut self) {
if let Some(query) = &mut self.active_query {
query.pop_char();
}
}
pub fn submit(
&mut self,
text: &str,
cursor_byte_index: usize,
options: &VimOptions,
) -> SearchOutcome {
let Some(query) = self.active_query.take() else {
return self.fail(VimError::NoPreviousRegularExpression);
};
let direction = self
.active_direction
.take()
.unwrap_or(SearchDirection::Forward);
let submitted_query = if query.is_empty() {
let Some(last_query) = self.last_query.clone() else {
return self.fail(VimError::NoPreviousRegularExpression);
};
last_query
} else {
self.last_query = Some(query.clone());
query
};
self.last_direction = Some(direction);
self.search_for_query(
text,
cursor_byte_index,
&submitted_query,
direction,
options,
)
}
pub fn repeat(
&mut self,
text: &str,
cursor_byte_index: usize,
direction: SearchDirection,
options: &VimOptions,
) -> SearchOutcome {
let Some(query) = &self.last_query else {
return self.fail(VimError::NoPreviousRegularExpression);
};
let query = query.clone();
self.last_direction = Some(direction);
self.search_for_query(text, cursor_byte_index, &query, direction, options)
}
pub fn repeat_forward(
&mut self,
text: &str,
cursor_byte_index: usize,
options: &VimOptions,
) -> SearchOutcome {
self.repeat(text, cursor_byte_index, SearchDirection::Forward, options)
}
pub fn repeat_backward(
&mut self,
text: &str,
cursor_byte_index: usize,
options: &VimOptions,
) -> SearchOutcome {
self.repeat(text, cursor_byte_index, SearchDirection::Backward, options)
}
pub fn repeat_relative(
&mut self,
text: &str,
cursor_byte_index: usize,
repeat_direction: SearchRepeatDirection,
options: &VimOptions,
) -> SearchOutcome {
let Some(last_direction) = self.last_direction else {
return self.fail(VimError::NoPreviousRegularExpression);
};
let direction = match repeat_direction {
SearchRepeatDirection::Next => last_direction,
SearchRepeatDirection::Previous => last_direction.reversed(),
};
self.repeat(text, cursor_byte_index, direction, options)
}
fn search_for_query(
&mut self,
text: &str,
cursor_byte_index: usize,
query: &SearchQuery,
direction: SearchDirection,
options: &VimOptions,
) -> SearchOutcome {
let outcome = find_literal(text, cursor_byte_index, query, direction, options).map_or(
SearchOutcome::Error(VimError::PatternNotFound),
|byte_index| SearchOutcome::Match { byte_index },
);
self.last_outcome = Some(outcome);
outcome
}
const fn fail(&mut self, error: VimError) -> SearchOutcome {
let outcome = SearchOutcome::Error(error);
self.last_outcome = Some(outcome);
outcome
}
}
impl SearchDirection {
#[must_use]
pub const fn reversed(self) -> Self {
match self {
Self::Forward => Self::Backward,
Self::Backward => Self::Forward,
}
}
}
#[must_use]
pub fn find_literal(
text: &str,
cursor_byte_index: usize,
query: &SearchQuery,
direction: SearchDirection,
options: &VimOptions,
) -> Option<usize> {
if text.is_empty() || query.is_empty() {
return None;
}
match direction {
SearchDirection::Forward => find_forward(text, cursor_byte_index, query, options),
SearchDirection::Backward => find_backward(text, cursor_byte_index, query, options),
}
}
#[must_use]
pub fn literal_match_ranges_in_range(
text: &str,
query: &SearchQuery,
visible_range: std::ops::Range<usize>,
options: &VimOptions,
) -> Vec<std::ops::Range<usize>> {
let Some(matcher) = LiteralMatcher::new(query, options) else {
return Vec::new();
};
let search_range = SearchHighlightRange::new(
text,
query,
visible_range,
SearchHighlightScanBudget::default(),
);
let mut ranges = Vec::new();
for byte_index in text[search_range.scan.clone()]
.char_indices()
.map(|(offset, _character)| search_range.scan.start + offset)
{
if let Some(match_end) = matcher.match_end(text, byte_index)
&& match_end > search_range.visible.start
&& byte_index < search_range.visible.end
{
ranges.push(byte_index..match_end);
}
}
ranges
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct SearchHighlightScanBudget {
max_bytes: NonZeroUsize,
}
impl SearchHighlightScanBudget {
const fn new(max_bytes: NonZeroUsize) -> Self {
Self { max_bytes }
}
const fn get(self) -> usize {
self.max_bytes.get()
}
}
impl Default for SearchHighlightScanBudget {
fn default() -> Self {
Self::new(
NonZeroUsize::new(DEFAULT_SEARCH_HIGHLIGHT_SCAN_BYTES)
.expect("default search highlight scan budget must be non-zero"),
)
}
}
struct SearchHighlightRange {
visible: std::ops::Range<usize>,
scan: std::ops::Range<usize>,
}
impl SearchHighlightRange {
fn new(
text: &str,
query: &SearchQuery,
visible_range: std::ops::Range<usize>,
budget: SearchHighlightScanBudget,
) -> Self {
let visible_start = clamp_to_boundary(text, visible_range.start.min(text.len()));
let scan_start = clamp_to_boundary(text, visible_start.saturating_sub(query.byte_len()));
let budgeted_scan_end = scan_start.saturating_add(budget.get()).min(text.len());
let visible_end = clamp_to_boundary(
text,
visible_range
.end
.min(text.len())
.min(budgeted_scan_end)
.max(visible_start),
);
Self {
visible: visible_start..visible_end,
scan: scan_start..visible_end,
}
}
}
fn find_forward(
text: &str,
cursor_byte_index: usize,
query: &SearchQuery,
options: &VimOptions,
) -> Option<usize> {
let start = next_search_start(text, cursor_byte_index);
let matcher = LiteralMatcher::new(query, options)?;
find_first_match_at_or_after(text, start, &matcher)
.or_else(|| {
options
.wrap_scan
.then(|| find_first_match_before(text, start, &matcher))
.flatten()
})
.map(|byte_index| clamp_to_cursor_position(text, byte_index))
}
fn find_backward(
text: &str,
cursor_byte_index: usize,
query: &SearchQuery,
options: &VimOptions,
) -> Option<usize> {
let start = clamp_to_boundary(text, cursor_byte_index);
let matcher = LiteralMatcher::new(query, options)?;
find_last_match_before(text, start, &matcher)
.or_else(|| {
options
.wrap_scan
.then(|| find_last_match_at_or_after(text, start, &matcher))
.flatten()
})
.map(|byte_index| clamp_to_cursor_position(text, byte_index))
}
struct LiteralMatcher {
query_chars: Vec<char>,
case_sensitivity: SearchCaseSensitivity,
}
impl LiteralMatcher {
fn new(query: &SearchQuery, options: &VimOptions) -> Option<Self> {
(!query.is_empty()).then(|| Self {
query_chars: query.as_str().chars().collect(),
case_sensitivity: query.case_sensitivity(options),
})
}
fn matches_at(&self, text: &str, byte_index: usize) -> bool {
self.match_end(text, byte_index).is_some()
}
fn match_end(&self, text: &str, byte_index: usize) -> Option<usize> {
if !text.is_char_boundary(byte_index) {
return None;
}
let mut text_characters = text[byte_index..].chars();
let mut end = byte_index;
for query_character in self.query_chars.iter().copied() {
let text_character = text_characters.next()?;
if !characters_match(text_character, query_character, self.case_sensitivity) {
return None;
}
end += text_character.len_utf8();
}
Some(end)
}
}
fn find_first_match_at_or_after(
text: &str,
start: usize,
matcher: &LiteralMatcher,
) -> Option<usize> {
text[start..]
.char_indices()
.map(|(offset, _character)| start + offset)
.find(|byte_index| matcher.matches_at(text, *byte_index))
}
fn find_first_match_before(text: &str, start: usize, matcher: &LiteralMatcher) -> Option<usize> {
text.char_indices()
.map(|(byte_index, _character)| byte_index)
.take_while(|byte_index| *byte_index < start)
.find(|byte_index| matcher.matches_at(text, *byte_index))
}
fn find_last_match_before(text: &str, start: usize, matcher: &LiteralMatcher) -> Option<usize> {
text.char_indices()
.map(|(byte_index, _character)| byte_index)
.take_while(|byte_index| *byte_index < start)
.filter(|byte_index| matcher.matches_at(text, *byte_index))
.last()
}
fn find_last_match_at_or_after(
text: &str,
start: usize,
matcher: &LiteralMatcher,
) -> Option<usize> {
text[start..]
.char_indices()
.map(|(offset, _character)| start + offset)
.rfind(|byte_index| matcher.matches_at(text, *byte_index))
}
fn characters_match(
text_character: char,
query_character: char,
case_sensitivity: SearchCaseSensitivity,
) -> bool {
match case_sensitivity {
SearchCaseSensitivity::Sensitive => text_character == query_character,
SearchCaseSensitivity::Insensitive => text_character
.to_lowercase()
.eq(query_character.to_lowercase()),
}
}
fn next_search_start(text: &str, cursor_byte_index: usize) -> usize {
let cursor = clamp_to_cursor_position(text, cursor_byte_index);
text[cursor..]
.chars()
.next()
.map_or(cursor, |character| cursor + character.len_utf8())
.min(text.len())
}
#[cfg(test)]
mod tests {
use super::{
SearchCaseSensitivity, SearchDirection, SearchOutcome, SearchQuery, SearchRepeatDirection,
SearchState, find_literal,
};
use crate::vim::{VimError, VimOptions};
fn options() -> VimOptions {
VimOptions::default()
}
#[test]
fn forward_search_finds_match_after_cursor() {
let mut query = SearchQuery::new();
query.push_str("two");
assert_eq!(
find_literal(
"one two one two",
0,
&query,
SearchDirection::Forward,
&options()
),
Some("one ".len())
);
}
#[test]
fn forward_search_wraps_to_first_match() {
let mut query = SearchQuery::new();
query.push_str("one");
assert_eq!(
find_literal(
"one two",
"one two".len(),
&query,
SearchDirection::Forward,
&options(),
),
Some(0)
);
}
#[test]
fn forward_search_wrap_can_find_match_starting_at_cursor() {
let mut query = SearchQuery::new();
query.push_str("ALMA");
assert_eq!(
find_literal("ALMA", 0, &query, SearchDirection::Forward, &options()),
Some(0)
);
}
#[test]
fn backward_search_finds_previous_match_and_wraps() {
let mut query = SearchQuery::new();
query.push_str("two");
assert_eq!(
find_literal(
"one two one two",
"one two one".len(),
&query,
SearchDirection::Backward,
&options(),
),
Some("one ".len())
);
assert_eq!(
find_literal("one two", 0, &query, SearchDirection::Backward, &options()),
Some("one ".len())
);
}
#[test]
fn empty_or_missing_query_does_not_move() {
let query = SearchQuery::new();
let mut state = SearchState::default();
assert_eq!(
find_literal("abc", 0, &query, SearchDirection::Forward, &options()),
None
);
assert_eq!(
state.repeat_forward("abc", 0, &options()),
SearchOutcome::Error(VimError::NoPreviousRegularExpression)
);
}
#[test]
fn lowercase_queries_match_case_insensitively() {
let mut query = SearchQuery::new();
query.push_str("alma");
assert_eq!(
query.case_sensitivity(&options()),
SearchCaseSensitivity::Insensitive
);
assert_eq!(
find_literal("ALMA alma", 0, &query, SearchDirection::Forward, &options()),
Some("ALMA ".len())
);
assert_eq!(
find_literal("ALMA", 0, &query, SearchDirection::Forward, &options()),
Some(0)
);
}
#[test]
fn uppercase_queries_match_case_sensitively() {
let mut query = SearchQuery::new();
query.push_str("Alma");
assert_eq!(
query.case_sensitivity(&options()),
SearchCaseSensitivity::Sensitive
);
assert_eq!(
find_literal("ALMA alva", 0, &query, SearchDirection::Forward, &options()),
None
);
}
#[test]
fn unicode_search_stays_boundary_safe() {
let mut query = SearchQuery::new();
query.push_str("λ");
assert_eq!(
find_literal("Aλ Bλ", 0, &query, SearchDirection::Forward, &options()),
Some("A".len())
);
}
#[test]
fn repeat_keys_reuse_last_submitted_query() {
let mut state = SearchState::default();
state.start(SearchDirection::Forward);
state.push_text("two");
assert_eq!(
state.submit("one two one two", 0, &options()),
SearchOutcome::Match {
byte_index: "one ".len()
}
);
assert_eq!(
state.repeat_forward("one two one two", "one two".len(), &options()),
SearchOutcome::Match {
byte_index: "one two one ".len()
}
);
assert_eq!(
state.repeat_backward("one two one two", "one two one ".len(), &options()),
SearchOutcome::Match {
byte_index: "one ".len()
}
);
}
#[test]
fn relative_repeat_keys_follow_last_search_direction() {
let text = "one two one two";
let mut state = SearchState::default();
state.start(SearchDirection::Backward);
state.push_text("two");
assert_eq!(
state.submit(text, text.len(), &options()),
SearchOutcome::Match {
byte_index: "one two one ".len()
}
);
assert_eq!(
state.repeat_relative(
text,
"one two one ".len(),
SearchRepeatDirection::Next,
&options()
),
SearchOutcome::Match {
byte_index: "one ".len()
}
);
assert_eq!(
state.repeat_relative(
text,
"one ".len(),
SearchRepeatDirection::Previous,
&options()
),
SearchOutcome::Match {
byte_index: "one two one ".len()
}
);
}
#[test]
fn empty_submit_repeats_previous_search_or_reports_e35() {
let mut state = SearchState::default();
state.start(SearchDirection::Forward);
assert_eq!(
state.submit("one two", 0, &options()),
SearchOutcome::Error(VimError::NoPreviousRegularExpression)
);
state.start(SearchDirection::Forward);
state.push_text("two");
assert_eq!(
state.submit("one two one two", 0, &options()),
SearchOutcome::Match {
byte_index: "one ".len()
}
);
state.start(SearchDirection::Forward);
assert_eq!(
state.submit("one two one two", "one ".len(), &options()),
SearchOutcome::Match {
byte_index: "one two one ".len()
}
);
}
#[test]
fn literal_match_ranges_cover_visible_occurrences() {
let mut query = SearchQuery::new();
query.push_str("alma");
assert_eq!(
super::literal_match_ranges_in_range(
"ALMA alma λ",
&query,
0.."ALMA alma λ".len(),
&options()
),
vec![0..4, 5..9]
);
}
#[test]
fn literal_match_ranges_include_matches_starting_before_visible_range() {
let mut query = SearchQuery::new();
query.push_str("a\nb");
let text = "a\nbc";
assert_eq!(
super::literal_match_ranges_in_range(text, &query, 1..text.len(), &options()),
vec![0..3]
);
}
#[test]
fn literal_match_ranges_are_bounded_for_long_visible_lines() {
let mut query = SearchQuery::new();
query.push_str("zz");
let text = format!(
"{}zz",
"a".repeat(super::DEFAULT_SEARCH_HIGHLIGHT_SCAN_BYTES + 128)
);
assert_eq!(
super::literal_match_ranges_in_range(text.as_str(), &query, 0..text.len(), &options()),
Vec::<std::ops::Range<usize>>::new()
);
}
#[test]
fn search_options_control_case_and_wrapping() {
let mut query = SearchQuery::new();
query.push_str("alma");
let sensitive = VimOptions {
ignore_case: false,
..VimOptions::default()
};
let no_smart_case = VimOptions {
smart_case: false,
..VimOptions::default()
};
let no_wrap = VimOptions {
wrap_scan: false,
..VimOptions::default()
};
assert_eq!(
find_literal("ALMA alma", 0, &query, SearchDirection::Forward, &sensitive),
Some("ALMA ".len())
);
query = SearchQuery::new();
query.push_str("Alma");
assert_eq!(
find_literal(
"ALMA Alma",
"ALMA Alma".len(),
&query,
SearchDirection::Forward,
&no_smart_case
),
Some(0)
);
query = SearchQuery::new();
query.push_str("alma");
assert_eq!(
find_literal(
"alma",
"alma".len(),
&query,
SearchDirection::Forward,
&no_wrap
),
None
);
}
}