Skip to main content

hyprcorrect_core/
buffer.rs

1//! The keystroke buffer: a bounded, in-memory record of recently typed
2//! text in the focused element. It lets hyprcorrect answer "what was the
3//! last word?" without reading back from the focused application — which
4//! is what makes correction work in terminals.
5//!
6//! See the "keystroke buffer" section of `DESIGN.md`.
7
8/// Default cap on buffered characters — comfortably larger than any one
9/// word or sentence. Older characters are dropped from the front.
10const DEFAULT_CAPACITY: usize = 1024;
11
12/// One unit of input fed to the [`Buffer`] by the platform capture layer.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum Key {
15    /// A printable character was typed.
16    Char(char),
17    /// The Backspace key — delete the character before the caret.
18    Backspace,
19    /// Left arrow — move the caret left by one character within the
20    /// buffer. Buffer contents are unchanged.
21    MoveLeft,
22    /// Right arrow — move the caret right by one character within the
23    /// buffer.
24    MoveRight,
25    /// `Ctrl+Left` (word-jump) — move the caret to the start of the
26    /// previous word (or the start of the current word if the caret
27    /// sits inside one).
28    WordLeft,
29    /// `Ctrl+Right` — move the caret past the end of the next word
30    /// (or the end of the current word if the caret sits inside one).
31    WordRight,
32    /// `Home` — move the caret to the start of the buffer. (The
33    /// daemon's buffer holds at most one line — `Enter` still
34    /// resets — so "line start" and "buffer start" coincide.)
35    LineStart,
36    /// `End` — move the caret to the end of the buffer.
37    LineEnd,
38    /// Anything we can't track precisely: Up/Down/Tab/Enter/Esc,
39    /// Page Up/Down, focus change, mouse click, or any Ctrl/Alt/
40    /// Super shortcut we haven't taught the buffer about. After one
41    /// of these the buffer's contents and caret are no longer
42    /// trustworthy, so the buffer clears itself.
43    Reset,
44}
45
46/// The word at (or immediately to the left of) the caret, with the
47/// metadata an emit-side replace needs to delete the right characters
48/// before retyping. Returned by [`Buffer::word_at_caret`].
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct WordAtCaret {
51    /// The full word — both halves around the caret if the caret
52    /// sits inside the word.
53    pub word: String,
54    /// Whitespace between the right edge of the word and the caret
55    /// when the caret is in trailing whitespace, otherwise empty.
56    pub trailing: String,
57    /// How many characters of `word` sit BEFORE the caret. Emit
58    /// uses this as the BackSpace count.
59    pub chars_before_caret: usize,
60    /// How many characters of `word` sit AFTER the caret. Emit
61    /// uses this as the Delete-key count.
62    pub chars_after_caret: usize,
63}
64
65/// A sentence in the buffer, located by [`Buffer::sentence_containing`].
66/// Carries enough information for a caller holding an in-buffer byte
67/// offset (e.g. a [`NearbyWord`]'s `byte_start`/`byte_end`) to map it
68/// into sentence-relative bytes via subtraction.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct Sentence {
71    /// The sentence's text, with surrounding whitespace trimmed.
72    pub sentence: String,
73    /// Byte offset of `sentence`'s first byte within the buffer's text.
74    pub buffer_byte_start: usize,
75    /// Byte offset one past `sentence`'s last byte within the buffer's
76    /// text. Half-open range: `[buffer_byte_start, buffer_byte_end)`.
77    pub buffer_byte_end: usize,
78}
79
80/// The sentence containing (or immediately to the left of) the caret,
81/// with metadata for the emit-side replace. Returned by
82/// [`Buffer::sentence_at_caret`].
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct SentenceAtCaret {
85    /// The full sentence — both halves around the caret if the
86    /// caret sits inside the sentence.
87    pub sentence: String,
88    /// Whitespace between the right edge of the sentence and the
89    /// caret when the caret is in trailing whitespace.
90    pub trailing: String,
91    /// How many characters of `sentence` sit BEFORE the caret.
92    pub chars_before_caret: usize,
93    /// How many characters of `sentence` sit AFTER the caret.
94    pub chars_after_caret: usize,
95}
96
97/// A word the buffer holds *near* (but not necessarily at) the
98/// caret, with the offset metadata needed to navigate there and
99/// replace it. Returned by [`Buffer::words_near_caret`]; the
100/// corrector uses these to recover when the buffer caret has
101/// drifted a few chars from the visible cursor (held auto-repeat,
102/// mouse click, etc.) and the picked word_at_caret turns out to
103/// be fine.
104#[derive(Debug, Clone, PartialEq, Eq)]
105pub struct NearbyWord {
106    /// The word itself.
107    pub word: String,
108    /// Byte position of the word's START in the buffer's text.
109    pub byte_start: usize,
110    /// Byte position one past the word's END in the buffer's text.
111    pub byte_end: usize,
112    /// Signed chars from the buffer caret to the END of this word
113    /// (`byte_end`). Positive when the word ends to the RIGHT of
114    /// the caret, negative to the LEFT, zero when the caret sits
115    /// exactly at the word's end. Emit uses this to compute
116    /// Right/Left arrows before deleting the word.
117    pub caret_offset_chars: i32,
118}
119
120/// A bounded record of recently typed text in the focused element.
121///
122/// Carries a `caret` byte offset into `text`. Char/Backspace operate
123/// at the caret; MoveLeft/MoveRight slide the caret without changing
124/// the text. `last_word` / `last_sentence` extract from the text
125/// *behind* the caret, so navigating left into already-typed text and
126/// hitting the correction chord still operates on the right region.
127#[derive(Debug)]
128pub struct Buffer {
129    text: String,
130    /// Byte offset into `text`. Invariant: always at a UTF-8 char
131    /// boundary, `0 <= caret <= text.len()`.
132    caret: usize,
133    capacity: usize,
134}
135
136impl Default for Buffer {
137    fn default() -> Self {
138        Self::with_capacity(DEFAULT_CAPACITY)
139    }
140}
141
142impl Buffer {
143    /// Create a buffer holding at most `capacity` characters (at least 1).
144    pub fn with_capacity(capacity: usize) -> Self {
145        Self {
146            text: String::new(),
147            caret: 0,
148            capacity: capacity.max(1),
149        }
150    }
151
152    /// Feed one unit of input to the buffer.
153    pub fn push(&mut self, key: Key) {
154        match key {
155            Key::Char(c) => {
156                self.text.insert(self.caret, c);
157                self.caret += c.len_utf8();
158                self.trim_to_capacity();
159            }
160            Key::Backspace => {
161                if self.caret == 0 {
162                    return;
163                }
164                let prev = prev_char_boundary(&self.text, self.caret);
165                self.text.drain(prev..self.caret);
166                self.caret = prev;
167            }
168            Key::MoveLeft => {
169                if self.caret == 0 {
170                    return;
171                }
172                self.caret = prev_char_boundary(&self.text, self.caret);
173            }
174            Key::MoveRight => {
175                if self.caret >= self.text.len() {
176                    return;
177                }
178                self.caret = next_char_boundary(&self.text, self.caret);
179            }
180            Key::WordLeft => {
181                self.caret = prev_word_boundary(&self.text, self.caret);
182            }
183            Key::WordRight => {
184                self.caret = next_word_boundary(&self.text, self.caret);
185            }
186            Key::LineStart => {
187                self.caret = 0;
188            }
189            Key::LineEnd => {
190                self.caret = self.text.len();
191            }
192            Key::Reset => {
193                self.text.clear();
194                self.caret = 0;
195            }
196        }
197    }
198
199    /// Clear the buffer.
200    pub fn clear(&mut self) {
201        self.text.clear();
202        self.caret = 0;
203    }
204
205    /// `true` when the buffer holds no text.
206    pub fn is_empty(&self) -> bool {
207        self.text.is_empty()
208    }
209
210    /// The full buffered text, oldest character first.
211    pub fn text(&self) -> &str {
212        &self.text
213    }
214
215    /// The buffered text up to the caret — the part the daemon
216    /// treats as "what sits behind the cursor right now."
217    pub fn text_before_caret(&self) -> &str {
218        &self.text[..self.caret]
219    }
220
221    /// The last sentence in the buffer with the whitespace that
222    /// follows it, or `None` when the buffer holds no sentence
223    /// (empty / only whitespace).
224    ///
225    /// "Sentence" means the run of text bounded by sentence-enders
226    /// (`.`/`!`/`?`). The buffer's final sentence-ender, if any, is
227    /// included — so pressing the chord right after typing
228    /// `"The quick brown fox."` operates on `"The quick brown fox."`
229    /// rather than no-opping. If the buffer doesn't end with an
230    /// ender the sentence is the in-progress text after the previous
231    /// one.
232    pub fn sentence_at_caret(&self) -> Option<SentenceAtCaret> {
233        let caret = self.caret;
234        let s = self.sentence_containing(caret)?;
235        let caret_in_range = caret.clamp(s.buffer_byte_start, s.buffer_byte_end);
236        let chars_before = self.text[s.buffer_byte_start..caret_in_range]
237            .chars()
238            .count();
239        let chars_after = self.text[caret_in_range..s.buffer_byte_end].chars().count();
240        // Trailing whitespace between the sentence's right edge and
241        // the caret. Present only when the caret has walked past the
242        // sentence into trailing space.
243        let trailing = if caret > s.buffer_byte_end {
244            self.text[s.buffer_byte_end..caret].to_string()
245        } else {
246            String::new()
247        };
248        Some(SentenceAtCaret {
249            sentence: s.sentence,
250            trailing,
251            chars_before_caret: chars_before,
252            chars_after_caret: chars_after,
253        })
254    }
255
256    /// The sentence in the buffer that contains `byte_offset`, plus
257    /// the sentence's byte range within `self.text` — caller can
258    /// subtract `buffer_byte_start` from any in-buffer byte position
259    /// to land in sentence-relative bytes.
260    ///
261    /// Same "split on `.!?`, trim whitespace, prefer the later range
262    /// at a boundary" semantics as [`Self::sentence_at_caret`]; that
263    /// method projects this result onto the caret.
264    pub fn sentence_containing(&self, byte_offset: usize) -> Option<Sentence> {
265        let text = &self.text;
266        if text.is_empty() {
267            return None;
268        }
269        // Build the buffer's sentence ranges as `[start, end)` byte
270        // offsets, where `end` is the position AFTER the closing
271        // ender (or text.len() for an in-progress trailing sentence).
272        let mut ranges: Vec<(usize, usize)> = Vec::new();
273        let mut start = 0;
274        for (i, c) in text.char_indices() {
275            if matches!(c, '.' | '!' | '?') {
276                ranges.push((start, i + c.len_utf8()));
277                start = i + c.len_utf8();
278            }
279        }
280        if start < text.len() {
281            ranges.push((start, text.len()));
282        }
283        if ranges.is_empty() {
284            return None;
285        }
286        // Pick the latest range that contains `byte_offset`. When
287        // it sits exactly on a boundary (`offset == end`) the later
288        // range wins; if that later range is whitespace-only the
289        // offset is really in the previous sentence's trailing
290        // whitespace, so step back one.
291        let mut idx = ranges
292            .iter()
293            .rposition(|&(s, e)| s <= byte_offset && byte_offset <= e)?;
294        if text[ranges[idx].0..ranges[idx].1].trim().is_empty() && idx > 0 {
295            idx -= 1;
296        }
297        let (range_start, range_end) = ranges[idx];
298        let raw = &text[range_start..range_end];
299        let leading_ws = raw.len() - raw.trim_start().len();
300        let sentence_start = range_start + leading_ws;
301        let sentence_end = range_start + raw.trim_end().len();
302        if sentence_start >= sentence_end {
303            return None;
304        }
305        Some(Sentence {
306            sentence: text[sentence_start..sentence_end].to_string(),
307            buffer_byte_start: sentence_start,
308            buffer_byte_end: sentence_end,
309        })
310    }
311
312    /// The word at (or immediately left of) the caret. Decides by
313    /// looking at the char immediately BEFORE the caret:
314    /// - If it's a word char, the caret is in / at the end of a
315    ///   word; expand both directions.
316    /// - If it's whitespace (or the caret is at position 0), pick
317    ///   the previous word — matches the "fix the word your cursor
318    ///   just passed" mental model.
319    ///
320    /// Returns `None` when there's no word to operate on.
321    pub fn word_at_caret(&self) -> Option<WordAtCaret> {
322        let caret = self.caret;
323        let text = &self.text;
324
325        let prev_is_word = text[..caret].chars().next_back().is_some_and(is_word_char);
326        if prev_is_word {
327            // Caret sits in or at the end of a word — expand both ways.
328            let right_span: usize = text[caret..]
329                .chars()
330                .take_while(|&c| is_word_char(c))
331                .map(char::len_utf8)
332                .sum();
333            let left_span: usize = text[..caret]
334                .chars()
335                .rev()
336                .take_while(|&c| is_word_char(c))
337                .map(char::len_utf8)
338                .sum();
339            let word_start = caret - left_span;
340            let word_end = caret + right_span;
341            if word_start == word_end {
342                return None;
343            }
344            return Some(WordAtCaret {
345                word: text[word_start..word_end].to_string(),
346                trailing: String::new(),
347                chars_before_caret: text[word_start..caret].chars().count(),
348                chars_after_caret: text[caret..word_end].chars().count(),
349            });
350        }
351        // Caret follows whitespace or punctuation, or sits at
352        // position 0. Look LEFT for the previous word, skipping
353        // anything that isn't a word char (commas, periods,
354        // whitespace, …) so the captured "trailing" carries the
355        // punctuation back through the replacement intact.
356        let before = &text[..caret];
357        let trimmed_right = before.trim_end_matches(|c: char| !is_word_char(c));
358        if trimmed_right.is_empty() {
359            return None;
360        }
361        let word_chars: usize = trimmed_right
362            .chars()
363            .rev()
364            .take_while(|&c| is_word_char(c))
365            .map(char::len_utf8)
366            .sum();
367        if word_chars == 0 {
368            return None;
369        }
370        let word_end = trimmed_right.len();
371        let word_start = word_end - word_chars;
372        Some(WordAtCaret {
373            word: text[word_start..word_end].to_string(),
374            trailing: text[word_end..caret].to_string(),
375            chars_before_caret: text[word_start..word_end].chars().count(),
376            chars_after_caret: 0,
377        })
378    }
379
380    /// Mirror an external edit that happens AROUND the caret: delete
381    /// `backspaces` characters going LEFT and `deletes` characters
382    /// going RIGHT, then insert at the caret. Called after the
383    /// emulation layer fires `BackSpace × N` + `Delete × M` + the
384    /// replacement text.
385    pub fn apply_around_caret(&mut self, backspaces: usize, deletes: usize, insert: &str) {
386        for _ in 0..backspaces {
387            if self.caret == 0 {
388                break;
389            }
390            let prev = prev_char_boundary(&self.text, self.caret);
391            self.text.drain(prev..self.caret);
392            self.caret = prev;
393        }
394        for _ in 0..deletes {
395            if self.caret >= self.text.len() {
396                break;
397            }
398            let next = next_char_boundary(&self.text, self.caret);
399            self.text.drain(self.caret..next);
400        }
401        self.text.insert_str(self.caret, insert);
402        self.caret += insert.len();
403        self.trim_to_capacity();
404    }
405
406    /// Mirror an end-of-caret edit (no right-side deletes). Shim
407    /// over [`apply_around_caret`] so end-of-text call-sites stay
408    /// readable.
409    pub fn apply(&mut self, backspaces: usize, insert: &str) {
410        self.apply_around_caret(backspaces, 0, insert);
411    }
412
413    /// Current caret position as a byte offset into [`Self::text`].
414    /// Used by the nearby-word fallback to compute the signed
415    /// distance from the caret to a candidate replacement target.
416    pub fn caret(&self) -> usize {
417        self.caret
418    }
419
420    /// Mirror an edit that happens at an arbitrary byte range —
421    /// `byte_start..byte_end` is replaced with `insert`, and the
422    /// caret lands at `byte_start + insert.len()`. Used by the
423    /// nearby-word fallback when the picked word_at_caret turns
424    /// out to be fine but a typo a few words away is what the
425    /// user actually wanted to fix.
426    pub fn apply_at_word(&mut self, byte_start: usize, byte_end: usize, insert: &str) {
427        debug_assert!(byte_start <= byte_end && byte_end <= self.text.len());
428        self.text.replace_range(byte_start..byte_end, insert);
429        self.caret = byte_start + insert.len();
430        self.trim_to_capacity();
431    }
432
433    /// Enumerate every word in the buffer, ordered by char distance
434    /// from the caret (nearest first). The corrector uses this as
435    /// a fallback when `word_at_caret`'s primary pick comes back
436    /// "fine" — buffer caret can drift a few chars from the
437    /// visible cursor on long auto-repeats or mouse clicks, so
438    /// scanning a small window around the caret recovers the
439    /// real typo the user meant to fix.
440    pub fn words_near_caret(&self) -> Vec<NearbyWord> {
441        let text = &self.text;
442        let caret = self.caret;
443        let mut out: Vec<NearbyWord> = Vec::new();
444        let mut current_start: Option<usize> = None;
445        for (i, c) in text.char_indices() {
446            if is_word_char(c) {
447                if current_start.is_none() {
448                    current_start = Some(i);
449                }
450            } else if let Some(start) = current_start.take() {
451                out.push(make_nearby_word(text, caret, start, i));
452            }
453        }
454        if let Some(start) = current_start {
455            out.push(make_nearby_word(text, caret, start, text.len()));
456        }
457        out.sort_by_key(|nw| nw.caret_offset_chars.abs());
458        out
459    }
460
461    /// Drop characters from the front until the buffer fits `capacity`.
462    /// Shifts the caret back by the same number of bytes so the
463    /// before/after caret split stays consistent.
464    fn trim_to_capacity(&mut self) {
465        while self.text.chars().count() > self.capacity {
466            let first = self.text.chars().next().map_or(0, char::len_utf8);
467            self.text.drain(..first);
468            self.caret = self.caret.saturating_sub(first);
469        }
470    }
471}
472
473/// Return the byte offset of the char that ENDS at `pos` in `s`.
474/// `pos` must be > 0 and a char boundary.
475fn prev_char_boundary(s: &str, pos: usize) -> usize {
476    s[..pos].char_indices().next_back().map_or(0, |(i, _)| i)
477}
478
479/// Return the byte offset that ENDS the char STARTING at `pos` in `s`.
480/// `pos` must be < `s.len()` and a char boundary.
481fn next_char_boundary(s: &str, pos: usize) -> usize {
482    s[pos..].chars().next().map_or(pos, |c| pos + c.len_utf8())
483}
484
485/// Build a `NearbyWord` for the word at `[start, end)` in `text`,
486/// computing the signed char distance from `caret` to `end`.
487fn make_nearby_word(text: &str, caret: usize, start: usize, end: usize) -> NearbyWord {
488    let caret_offset_chars = if end >= caret {
489        text[caret..end].chars().count() as i32
490    } else {
491        -(text[end..caret].chars().count() as i32)
492    };
493    NearbyWord {
494        word: text[start..end].to_string(),
495        byte_start: start,
496        byte_end: end,
497        caret_offset_chars,
498    }
499}
500
501/// The "word char" rule for `word_at_caret`. Alphanumerics plus
502/// apostrophe — so contractions like `don't` stay one word, but
503/// commas, periods, quotes, and brackets are word boundaries. This
504/// rule is intentionally stricter than the navigation rule below:
505/// when we pick a word to send to the spell-checker/LLM we want it
506/// clean of punctuation, even if the user's editor would consider
507/// the punctuation part of the same nav-word.
508fn is_word_char(c: char) -> bool {
509    c.is_alphanumeric() || c == '\''
510}
511
512/// The "word char" rule for `Ctrl+Left` / `Ctrl+Right` caret
513/// tracking. Looser than [`is_word_char`]: also includes `.`,
514/// `-`, `_` so runs like `deal...do`, `well-known`, and
515/// `snake_case_var` count as a single nav-word — that matches
516/// what most shells and terminals do for `Ctrl+arrow` (zsh's
517/// default `WORDCHARS` includes all three, and bash's
518/// readline-bound `forward-word` does similar). Keeping nav and
519/// lookup separate lets the buffer's caret stay in step with the
520/// editor without dragging punctuation into the word we hand to
521/// the corrector.
522fn is_nav_word_char(c: char) -> bool {
523    is_word_char(c) || matches!(c, '.' | '-' | '_')
524}
525
526/// Where the caret lands on `Ctrl+Left`. Walk past any non-word
527/// chars immediately to the caret's left, then to the start of
528/// the next word over.
529fn prev_word_boundary(s: &str, from: usize) -> usize {
530    let left = &s[..from];
531    let trim: usize = left
532        .chars()
533        .rev()
534        .take_while(|&c| !is_nav_word_char(c))
535        .map(char::len_utf8)
536        .sum();
537    let trimmed_end = left.len() - trim;
538    let word_chars: usize = left[..trimmed_end]
539        .chars()
540        .rev()
541        .take_while(|&c| is_nav_word_char(c))
542        .map(char::len_utf8)
543        .sum();
544    trimmed_end - word_chars
545}
546
547/// Where the caret lands on `Ctrl+Right`. Walk past any non-word
548/// chars to the caret's right, then past the end of that word.
549fn next_word_boundary(s: &str, from: usize) -> usize {
550    let right = &s[from..];
551    let skip: usize = right
552        .chars()
553        .take_while(|&c| !is_nav_word_char(c))
554        .map(char::len_utf8)
555        .sum();
556    let word_chars: usize = right[skip..]
557        .chars()
558        .take_while(|&c| is_nav_word_char(c))
559        .map(char::len_utf8)
560        .sum();
561    from + skip + word_chars
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567
568    /// Feed each character of `s` to the buffer as a `Char` key.
569    fn type_str(buf: &mut Buffer, s: &str) {
570        for c in s.chars() {
571            buf.push(Key::Char(c));
572        }
573    }
574
575    #[test]
576    fn empty_buffer_has_no_word() {
577        let buf = Buffer::default();
578        assert!(buf.is_empty());
579        assert_eq!(buf.word_at_caret(), None);
580    }
581
582    #[test]
583    fn word_at_caret_at_end_of_word() {
584        let mut buf = Buffer::default();
585        type_str(&mut buf, "vernuer");
586        let at = buf.word_at_caret().unwrap();
587        assert_eq!(at.word, "vernuer");
588        assert_eq!(at.trailing, "");
589        assert_eq!(at.chars_before_caret, 7);
590        assert_eq!(at.chars_after_caret, 0);
591    }
592
593    #[test]
594    fn word_at_caret_in_trailing_whitespace_picks_the_left_word() {
595        let mut buf = Buffer::default();
596        type_str(&mut buf, "vernuer ");
597        let at = buf.word_at_caret().unwrap();
598        assert_eq!(at.word, "vernuer");
599        assert_eq!(at.trailing, " ");
600        assert_eq!(at.chars_before_caret, 7);
601        assert_eq!(at.chars_after_caret, 0);
602    }
603
604    #[test]
605    fn word_at_caret_inside_word_expands_both_directions() {
606        let mut buf = Buffer::default();
607        type_str(&mut buf, "vernuer");
608        // Land the caret between "ver" and "nuer".
609        for _ in 0..4 {
610            buf.push(Key::MoveLeft);
611        }
612        let at = buf.word_at_caret().unwrap();
613        assert_eq!(at.word, "vernuer");
614        assert_eq!(at.chars_before_caret, 3);
615        assert_eq!(at.chars_after_caret, 4);
616    }
617
618    #[test]
619    fn word_at_caret_picks_the_final_word() {
620        let mut buf = Buffer::default();
621        type_str(&mut buf, "the quick vernuer ");
622        let at = buf.word_at_caret().unwrap();
623        assert_eq!(at.word, "vernuer");
624    }
625
626    #[test]
627    fn all_whitespace_has_no_word_at_caret() {
628        let mut buf = Buffer::default();
629        type_str(&mut buf, "   ");
630        assert_eq!(buf.word_at_caret(), None);
631    }
632
633    #[test]
634    fn word_at_caret_handles_multibyte_chars() {
635        let mut buf = Buffer::default();
636        type_str(&mut buf, "café ");
637        let at = buf.word_at_caret().unwrap();
638        assert_eq!(at.word, "café");
639        assert_eq!(at.chars_before_caret, 4);
640    }
641
642    #[test]
643    fn backspace_removes_the_last_character() {
644        let mut buf = Buffer::default();
645        type_str(&mut buf, "vernuer");
646        buf.push(Key::Backspace);
647        assert_eq!(buf.text(), "vernue");
648    }
649
650    #[test]
651    fn backspace_on_empty_buffer_is_a_no_op() {
652        let mut buf = Buffer::default();
653        buf.push(Key::Backspace);
654        assert!(buf.is_empty());
655    }
656
657    #[test]
658    fn reset_clears_the_buffer() {
659        let mut buf = Buffer::default();
660        type_str(&mut buf, "vernuer ");
661        buf.push(Key::Reset);
662        assert!(buf.is_empty());
663        assert_eq!(buf.word_at_caret(), None);
664    }
665
666    #[test]
667    fn buffer_is_bounded_by_capacity() {
668        let mut buf = Buffer::with_capacity(5);
669        type_str(&mut buf, "abcdefgh");
670        assert_eq!(buf.text(), "defgh");
671    }
672
673    #[test]
674    fn sentence_at_caret_after_an_ender() {
675        let mut buf = Buffer::default();
676        type_str(&mut buf, "Hello there. how are you ");
677        let at = buf.sentence_at_caret().unwrap();
678        assert_eq!(at.sentence, "how are you");
679        assert_eq!(at.trailing, " ");
680        assert_eq!(at.chars_after_caret, 0);
681    }
682
683    #[test]
684    fn sentence_at_caret_when_no_ender_yet() {
685        let mut buf = Buffer::default();
686        type_str(&mut buf, "the quick brown fox");
687        let at = buf.sentence_at_caret().unwrap();
688        assert_eq!(at.sentence, "the quick brown fox");
689        assert_eq!(at.trailing, "");
690    }
691
692    #[test]
693    fn sentence_at_caret_with_multiple_enders_picks_current() {
694        let mut buf = Buffer::default();
695        type_str(&mut buf, "Hi! Hello there. How are yu");
696        let at = buf.sentence_at_caret().unwrap();
697        assert_eq!(at.sentence, "How are yu");
698    }
699
700    #[test]
701    fn sentence_at_caret_includes_the_trailing_ender() {
702        let mut buf = Buffer::default();
703        type_str(&mut buf, "Hello there.");
704        let at = buf.sentence_at_caret().unwrap();
705        assert_eq!(at.sentence, "Hello there.");
706    }
707
708    #[test]
709    fn sentence_at_caret_picks_the_final_of_multiple_complete_sentences() {
710        let mut buf = Buffer::default();
711        type_str(&mut buf, "First sentence. Second sentence!");
712        let at = buf.sentence_at_caret().unwrap();
713        assert_eq!(at.sentence, "Second sentence!");
714    }
715
716    #[test]
717    fn sentence_at_caret_after_complete_one_then_trailing_ws() {
718        let mut buf = Buffer::default();
719        type_str(&mut buf, "Hello there.   ");
720        let at = buf.sentence_at_caret().unwrap();
721        assert_eq!(at.sentence, "Hello there.");
722        assert_eq!(at.trailing, "   ");
723    }
724
725    #[test]
726    fn sentence_at_caret_returns_none_for_whitespace_only() {
727        let mut buf = Buffer::default();
728        type_str(&mut buf, "   ");
729        assert!(buf.sentence_at_caret().is_none());
730    }
731
732    #[test]
733    fn sentence_at_caret_in_middle_spans_both_sides() {
734        let mut buf = Buffer::default();
735        type_str(&mut buf, "the quick brown fox jumps");
736        // Land caret after "brown".
737        for _ in 0..10 {
738            buf.push(Key::MoveLeft);
739        }
740        let at = buf.sentence_at_caret().unwrap();
741        assert_eq!(at.sentence, "the quick brown fox jumps");
742        assert_eq!(at.chars_before_caret, 15);
743        assert_eq!(at.chars_after_caret, 10);
744    }
745
746    #[test]
747    fn sentence_containing_returns_the_active_sentence_with_buffer_offsets() {
748        let mut buf = Buffer::default();
749        type_str(&mut buf, "Hello world. The quick brown fox.");
750        // Find "fox" via the buffer's own text rather than a
751        // hand-counted offset — the contract under test is the
752        // "subtract buffer_byte_start to land in sentence-relative
753        // bytes" mapping, not arithmetic.
754        let fox_buf_start = buf.text().find("fox").unwrap();
755        let fox_buf_end = fox_buf_start + "fox".len();
756        let s = buf.sentence_containing(fox_buf_start).unwrap();
757        assert_eq!(s.sentence, "The quick brown fox.");
758        assert_eq!(s.buffer_byte_start, 13);
759        assert_eq!(s.buffer_byte_end, 33);
760        let start_in_sentence = fox_buf_start - s.buffer_byte_start;
761        let end_in_sentence = fox_buf_end - s.buffer_byte_start;
762        assert_eq!(&s.sentence[start_in_sentence..end_in_sentence], "fox");
763    }
764
765    #[test]
766    fn sentence_containing_picks_first_sentence_for_offset_in_it() {
767        let mut buf = Buffer::default();
768        type_str(&mut buf, "Hello world. The quick brown fox.");
769        let s = buf.sentence_containing(3).unwrap();
770        assert_eq!(s.sentence, "Hello world.");
771        assert_eq!(s.buffer_byte_start, 0);
772        assert_eq!(s.buffer_byte_end, 12);
773    }
774
775    #[test]
776    fn sentence_containing_returns_none_for_whitespace_only_buffer() {
777        let mut buf = Buffer::default();
778        type_str(&mut buf, "   ");
779        assert!(buf.sentence_containing(1).is_none());
780    }
781
782    #[test]
783    fn sentence_containing_steps_back_from_inter_sentence_whitespace() {
784        let mut buf = Buffer::default();
785        type_str(&mut buf, "Hello world. ");
786        // Position the offset just after the trailing space — the
787        // boundary-stepping logic should keep us in the only real
788        // sentence rather than returning whitespace.
789        let s = buf.sentence_containing(13).unwrap();
790        assert_eq!(s.sentence, "Hello world.");
791    }
792
793    #[test]
794    fn apply_mirrors_a_correction_at_end() {
795        let mut buf = Buffer::default();
796        type_str(&mut buf, "vernuer ");
797        buf.apply(8, "veneer ");
798        assert_eq!(buf.text(), "veneer ");
799        assert_eq!(buf.word_at_caret().unwrap().word, "veneer");
800    }
801
802    #[test]
803    fn apply_around_caret_mirrors_a_mid_word_fix() {
804        let mut buf = Buffer::default();
805        type_str(&mut buf, "vernuer trailing");
806        // Park caret right after "vernuer".
807        for _ in 0..9 {
808            buf.push(Key::MoveLeft);
809        }
810        // Mid-word edit emitting BackSpace × 3 + Delete × 4 + "veneer":
811        // pretend caret was between "ver" and "nuer" instead of after
812        // "vernuer". Functionally checks the both-sides drain.
813        for _ in 0..4 {
814            buf.push(Key::MoveLeft);
815        }
816        buf.apply_around_caret(3, 4, "veneer");
817        assert_eq!(buf.text(), "veneer trailing");
818    }
819
820    #[test]
821    fn move_left_walks_caret_back_without_clearing_text() {
822        let mut buf = Buffer::default();
823        type_str(&mut buf, "hello world");
824        for _ in 0..6 {
825            buf.push(Key::MoveLeft);
826        }
827        assert_eq!(buf.text(), "hello world");
828        assert_eq!(buf.text_before_caret(), "hello");
829    }
830
831    #[test]
832    fn typing_after_move_left_inserts_at_caret() {
833        let mut buf = Buffer::default();
834        type_str(&mut buf, "helloworld");
835        for _ in 0..5 {
836            buf.push(Key::MoveLeft);
837        }
838        type_str(&mut buf, " ");
839        assert_eq!(buf.text(), "hello world");
840    }
841
842    #[test]
843    fn backspace_after_move_left_removes_the_char_before_caret() {
844        let mut buf = Buffer::default();
845        type_str(&mut buf, "hello world");
846        for _ in 0..6 {
847            buf.push(Key::MoveLeft);
848        }
849        buf.push(Key::Backspace);
850        assert_eq!(buf.text(), "hell world");
851    }
852
853    #[test]
854    fn move_right_at_end_is_a_no_op() {
855        let mut buf = Buffer::default();
856        type_str(&mut buf, "abc");
857        buf.push(Key::MoveRight);
858        assert_eq!(buf.text_before_caret(), "abc");
859    }
860
861    #[test]
862    fn move_left_at_start_is_a_no_op() {
863        let mut buf = Buffer::default();
864        type_str(&mut buf, "abc");
865        for _ in 0..10 {
866            buf.push(Key::MoveLeft);
867        }
868        assert_eq!(buf.text_before_caret(), "");
869        assert_eq!(buf.text(), "abc");
870    }
871
872    #[test]
873    fn line_start_and_line_end_jump_to_the_edges() {
874        let mut buf = Buffer::default();
875        type_str(&mut buf, "hello world");
876        buf.push(Key::LineStart);
877        assert_eq!(buf.text_before_caret(), "");
878        buf.push(Key::LineEnd);
879        assert_eq!(buf.text_before_caret(), "hello world");
880    }
881
882    #[test]
883    fn word_left_jumps_to_previous_word_start() {
884        let mut buf = Buffer::default();
885        type_str(&mut buf, "the quick brown fox");
886        buf.push(Key::WordLeft);
887        assert_eq!(buf.text_before_caret(), "the quick brown ");
888        buf.push(Key::WordLeft);
889        assert_eq!(buf.text_before_caret(), "the quick ");
890        buf.push(Key::WordLeft);
891        assert_eq!(buf.text_before_caret(), "the ");
892        buf.push(Key::WordLeft);
893        assert_eq!(buf.text_before_caret(), "");
894    }
895
896    #[test]
897    fn word_right_jumps_to_next_word_end() {
898        let mut buf = Buffer::default();
899        type_str(&mut buf, "the quick brown fox");
900        buf.push(Key::LineStart);
901        buf.push(Key::WordRight);
902        assert_eq!(buf.text_before_caret(), "the");
903        buf.push(Key::WordRight);
904        assert_eq!(buf.text_before_caret(), "the quick");
905    }
906
907    #[test]
908    fn word_left_from_mid_word_lands_at_word_start() {
909        let mut buf = Buffer::default();
910        type_str(&mut buf, "hello world");
911        for _ in 0..3 {
912            buf.push(Key::MoveLeft);
913        }
914        // caret is between "wo" and "rld"
915        assert_eq!(buf.text_before_caret(), "hello wo");
916        buf.push(Key::WordLeft);
917        assert_eq!(buf.text_before_caret(), "hello ");
918    }
919
920    #[test]
921    fn ctrl_left_skips_commas_like_a_typical_editor() {
922        let mut buf = Buffer::default();
923        type_str(&mut buf, "hello, world");
924        buf.push(Key::WordLeft);
925        assert_eq!(buf.text_before_caret(), "hello, ");
926        buf.push(Key::WordLeft);
927        // Should land at start of "hello", not start of "hello,"
928        // — punctuation isn't part of the word.
929        assert_eq!(buf.text_before_caret(), "");
930    }
931
932    #[test]
933    fn word_at_caret_excludes_trailing_punctuation() {
934        let mut buf = Buffer::default();
935        type_str(&mut buf, "recieve,");
936        let at = buf.word_at_caret().expect("word at caret");
937        assert_eq!(at.word, "recieve");
938        assert_eq!(at.trailing, ",");
939    }
940
941    #[test]
942    fn word_at_caret_keeps_apostrophes_for_contractions() {
943        let mut buf = Buffer::default();
944        type_str(&mut buf, "don't");
945        let at = buf.word_at_caret().expect("word at caret");
946        assert_eq!(at.word, "don't");
947    }
948
949    #[test]
950    fn ctrl_right_skips_dot_runs_as_one_nav_word() {
951        // "deal...do you" — zsh and most shells treat "deal...do"
952        // as one Ctrl+Right hop because `.` is in WORDCHARS.
953        // Our nav-word definition has to match or the buffer's
954        // caret drifts on every dot-joined run.
955        let mut buf = Buffer::default();
956        type_str(&mut buf, "deal...do you");
957        buf.push(Key::LineStart);
958        buf.push(Key::WordRight);
959        assert_eq!(buf.text_before_caret(), "deal...do");
960        buf.push(Key::WordRight);
961        assert_eq!(buf.text_before_caret(), "deal...do you");
962    }
963
964    #[test]
965    fn word_at_caret_does_not_pull_dot_neighbors_in() {
966        // Even though Ctrl+arrow nav treats `deal...do` as one
967        // nav-word, `word_at_caret` must still return just `do`
968        // so the corrector doesn't try to fix `deal...do`.
969        let mut buf = Buffer::default();
970        type_str(&mut buf, "deal...do");
971        let at = buf.word_at_caret().expect("word at caret");
972        assert_eq!(at.word, "do");
973    }
974
975    #[test]
976    fn words_near_caret_orders_by_char_distance() {
977        let mut buf = Buffer::default();
978        type_str(&mut buf, "the quick brown fox");
979        // caret is at end. Nearest word is "fox" (distance 0).
980        let nearby = buf.words_near_caret();
981        let just_words: Vec<&str> = nearby.iter().map(|nw| nw.word.as_str()).collect();
982        assert_eq!(just_words, vec!["fox", "brown", "quick", "the"]);
983        assert_eq!(nearby[0].caret_offset_chars, 0);
984        // "brown" ends 4 chars left of "fox"'s end (one space + "fox").
985        assert_eq!(nearby[1].caret_offset_chars, -4);
986    }
987
988    #[test]
989    fn words_near_caret_walks_outward_from_mid_buffer_caret() {
990        let mut buf = Buffer::default();
991        type_str(&mut buf, "the quick brown fox");
992        // Move caret back to between "quick" and "brown".
993        for _ in 0..("brown fox".len()) {
994            buf.push(Key::MoveLeft);
995        }
996        assert_eq!(buf.text_before_caret(), "the quick ");
997        let nearby = buf.words_near_caret();
998        // "quick" ends 1 char left, "brown" ends 5 chars right.
999        assert_eq!(nearby[0].word, "quick");
1000        assert_eq!(nearby[0].caret_offset_chars, -1);
1001        assert_eq!(nearby[1].word, "brown");
1002        assert_eq!(nearby[1].caret_offset_chars, 5);
1003    }
1004
1005    #[test]
1006    fn apply_at_word_replaces_and_sets_caret() {
1007        let mut buf = Buffer::default();
1008        type_str(&mut buf, "the quick brown fox");
1009        // Replace "brown" (bytes 10..15) with "red". Caret should
1010        // land at 10 + 3 = 13.
1011        buf.apply_at_word(10, 15, "red");
1012        assert_eq!(buf.text(), "the quick red fox");
1013        assert_eq!(buf.caret(), 13);
1014    }
1015}