From d07074740bc44b71de83cf23dd692fa90c2854a9 Mon Sep 17 00:00:00 2001
From: Nathan Vegdahl <cessen@cessen.com>
Date: Sat, 26 Jun 2021 15:37:32 -0700
Subject: [PATCH] Add `Range` methods for various kinds of validation.

---
 helix-core/src/graphemes.rs |  14 ++++-
 helix-core/src/selection.rs | 113 +++++++++++++++++++++++++++++++++++-
 helix-term/src/ui/editor.rs |   6 +-
 3 files changed, 127 insertions(+), 6 deletions(-)

diff --git a/helix-core/src/graphemes.rs b/helix-core/src/graphemes.rs
index f71b6d5f..f7bf66c0 100644
--- a/helix-core/src/graphemes.rs
+++ b/helix-core/src/graphemes.rs
@@ -123,14 +123,24 @@ pub fn next_grapheme_boundary(slice: RopeSlice, char_idx: usize) -> usize {
 
 /// Returns the passed char index if it's already a grapheme boundary,
 /// or the next grapheme boundary char index if not.
-pub fn ensure_grapheme_boundary(slice: RopeSlice, char_idx: usize) -> usize {
+pub fn ensure_grapheme_boundary_next(slice: RopeSlice, char_idx: usize) -> usize {
     if char_idx == 0 {
-        0
+        char_idx
     } else {
         next_grapheme_boundary(slice, char_idx - 1)
     }
 }
 
+/// Returns the passed char index if it's already a grapheme boundary,
+/// or the prev grapheme boundary char index if not.
+pub fn ensure_grapheme_boundary_prev(slice: RopeSlice, char_idx: usize) -> usize {
+    if char_idx == slice.len_chars() {
+        char_idx
+    } else {
+        prev_grapheme_boundary(slice, char_idx + 1)
+    }
+}
+
 /// Returns whether the given char position is a grapheme boundary.
 pub fn is_grapheme_boundary(slice: RopeSlice, char_idx: usize) -> bool {
     // Bounds check
diff --git a/helix-core/src/selection.rs b/helix-core/src/selection.rs
index 35ad9845..906e2e53 100644
--- a/helix-core/src/selection.rs
+++ b/helix-core/src/selection.rs
@@ -2,7 +2,12 @@
 //! defined as a single empty or 1-wide selection range.
 //!
 //! All positioning is done via `char` offsets into the buffer.
-use crate::{Assoc, ChangeSet, Rope, RopeSlice};
+use crate::{
+    graphemes::{
+        ensure_grapheme_boundary_next, ensure_grapheme_boundary_prev, next_grapheme_boundary,
+    },
+    Assoc, ChangeSet, Rope, RopeSlice,
+};
 use smallvec::{smallvec, SmallVec};
 use std::borrow::Cow;
 
@@ -132,6 +137,61 @@ impl Range {
         }
     }
 
+    /// Compute the ends of the range, shifted (if needed) to align with
+    /// grapheme boundaries.
+    ///
+    /// This should generally be used for cursor validation.
+    ///
+    /// Always succeeds.
+    #[must_use]
+    pub fn aligned_range(&self, slice: RopeSlice) -> (usize, usize) {
+        if self.anchor == self.head {
+            let pos = ensure_grapheme_boundary_prev(slice, self.anchor);
+            (pos, pos)
+        } else {
+            (
+                ensure_grapheme_boundary_prev(slice, self.from()),
+                ensure_grapheme_boundary_next(slice, self.to()),
+            )
+        }
+    }
+
+    /// Same as `ensure_grapheme_validity()` + attempts to ensure a minimum
+    /// char width in the direction of the head.
+    ///
+    /// This should generally be used as a pre-pass for operations that
+    /// require a minimum selection width to achieve their intended behavior.
+    ///
+    /// This will fail at ensuring the minimum width only if the passed
+    /// `RopeSlice` is too short in the direction of the head, in which
+    /// case the range will fill the available length in that direction.
+    ///
+    /// Ensuring grapheme-boundary alignment always succeeds.
+    #[must_use]
+    pub fn min_width_range(&self, slice: RopeSlice, min_char_width: usize) -> (usize, usize) {
+        if min_char_width == 0 {
+            return self.aligned_range(slice);
+        }
+
+        if self.anchor <= self.head {
+            let anchor = ensure_grapheme_boundary_prev(slice, self.anchor);
+            let head = ensure_grapheme_boundary_next(
+                slice,
+                self.head
+                    .max(anchor + min_char_width)
+                    .min(slice.len_chars()),
+            );
+            (anchor, head)
+        } else {
+            let anchor = ensure_grapheme_boundary_next(slice, self.anchor);
+            let head = ensure_grapheme_boundary_prev(
+                slice,
+                self.head.min(anchor.saturating_sub(min_char_width)),
+            );
+            (head, anchor)
+        }
+    }
+
     // groupAt
 
     #[inline]
@@ -556,6 +616,54 @@ mod test {
         assert!(Range::new(1, 1).overlaps(&Range::new(1, 1)));
     }
 
+    #[test]
+    fn test_aligned_range() {
+        let r = Rope::from_str("\r\nHi\r\n");
+        let s = r.slice(..);
+
+        assert_eq!(Range::new(0, 0).aligned_range(s), (0, 0));
+        assert_eq!(Range::new(0, 1).aligned_range(s), (0, 2));
+        assert_eq!(Range::new(1, 1).aligned_range(s), (0, 0));
+        assert_eq!(Range::new(1, 2).aligned_range(s), (0, 2));
+        assert_eq!(Range::new(2, 2).aligned_range(s), (2, 2));
+        assert_eq!(Range::new(2, 3).aligned_range(s), (2, 3));
+        assert_eq!(Range::new(1, 3).aligned_range(s), (0, 3));
+        assert_eq!(Range::new(3, 5).aligned_range(s), (3, 6));
+        assert_eq!(Range::new(4, 5).aligned_range(s), (4, 6));
+        assert_eq!(Range::new(5, 5).aligned_range(s), (4, 4));
+        assert_eq!(Range::new(6, 6).aligned_range(s), (6, 6));
+    }
+
+    #[test]
+    fn test_min_width_range() {
+        let r = Rope::from_str("\r\nHi\r\n");
+        let s = r.slice(..);
+
+        assert_eq!(Range::new(0, 0).min_width_range(s, 1), (0, 2));
+        assert_eq!(Range::new(0, 1).min_width_range(s, 1), (0, 2));
+        assert_eq!(Range::new(1, 1).min_width_range(s, 1), (0, 2));
+        assert_eq!(Range::new(1, 2).min_width_range(s, 1), (0, 2));
+        assert_eq!(Range::new(2, 2).min_width_range(s, 1), (2, 3));
+        assert_eq!(Range::new(2, 3).min_width_range(s, 1), (2, 3));
+        assert_eq!(Range::new(1, 3).min_width_range(s, 1), (0, 3));
+        assert_eq!(Range::new(3, 5).min_width_range(s, 1), (3, 6));
+        assert_eq!(Range::new(4, 5).min_width_range(s, 1), (4, 6));
+        assert_eq!(Range::new(5, 5).min_width_range(s, 1), (4, 6));
+        assert_eq!(Range::new(6, 6).min_width_range(s, 1), (6, 6));
+
+        assert_eq!(Range::new(1, 0).min_width_range(s, 1), (0, 2));
+        assert_eq!(Range::new(2, 1).min_width_range(s, 1), (0, 2));
+        assert_eq!(Range::new(3, 2).min_width_range(s, 1), (2, 3));
+        assert_eq!(Range::new(3, 1).min_width_range(s, 1), (0, 3));
+        assert_eq!(Range::new(5, 3).min_width_range(s, 1), (3, 6));
+        assert_eq!(Range::new(5, 4).min_width_range(s, 1), (4, 6));
+
+        assert_eq!(Range::new(3, 4).min_width_range(s, 3), (3, 6));
+        assert_eq!(Range::new(4, 3).min_width_range(s, 3), (0, 4));
+        assert_eq!(Range::new(3, 4).min_width_range(s, 20), (3, 6));
+        assert_eq!(Range::new(4, 3).min_width_range(s, 20), (0, 4));
+    }
+
     #[test]
     fn test_split_on_matches() {
         use crate::regex::Regex;
@@ -569,6 +677,9 @@ mod test {
         assert_eq!(
             result.ranges(),
             &[
+                // TODO: rather than this behavior, maybe we want it
+                // to be based on which side is the anchor?
+                //
                 // We get a leading zero-width range when there's
                 // a leading match because ranges are inclusive on
                 // the left.  Imagine, for example, if the entire
diff --git a/helix-term/src/ui/editor.rs b/helix-term/src/ui/editor.rs
index b55a830e..d2925e35 100644
--- a/helix-term/src/ui/editor.rs
+++ b/helix-term/src/ui/editor.rs
@@ -8,7 +8,7 @@ use crate::{
 
 use helix_core::{
     coords_at_pos,
-    graphemes::ensure_grapheme_boundary,
+    graphemes::ensure_grapheme_boundary_next,
     syntax::{self, Highlight, HighlightEvent},
     LineEnding, Position, Range,
 };
@@ -144,8 +144,8 @@ impl EditorView {
         let highlights = highlights.into_iter().map(|event| match event.unwrap() {
             // convert byte offsets to char offset
             HighlightEvent::Source { start, end } => {
-                let start = ensure_grapheme_boundary(text, text.byte_to_char(start));
-                let end = ensure_grapheme_boundary(text, text.byte_to_char(end));
+                let start = ensure_grapheme_boundary_next(text, text.byte_to_char(start));
+                let end = ensure_grapheme_boundary_next(text, text.byte_to_char(end));
                 HighlightEvent::Source { start, end }
             }
             event => event,