use core::marker::PhantomData;
pub trait Scale: Copy + core::fmt::Debug {
const NAME: &'static str;
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct Bytes;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct Chars;
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct Utf16Units;
impl Scale for Bytes {
const NAME: &'static str = "bytes";
}
impl Scale for Chars {
const NAME: &'static str = "chars";
}
impl Scale for Utf16Units {
const NAME: &'static str = "utf16";
}
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Offset<S: Scale> {
raw: usize,
_scale: PhantomData<S>,
}
impl<S: Scale> Clone for Offset<S> {
fn clone(&self) -> Self {
*self
}
}
impl<S: Scale> Copy for Offset<S> {}
impl<S: Scale> Offset<S> {
pub const ZERO: Self = Self {
raw: 0,
_scale: PhantomData,
};
#[must_use]
pub const fn new(raw: usize) -> Self {
Self {
raw,
_scale: PhantomData,
}
}
#[must_use]
pub const fn raw(self) -> usize {
self.raw
}
#[must_use]
pub const fn scale_name() -> &'static str {
S::NAME
}
#[must_use]
pub const fn advance(self, by: usize) -> Self {
Self::new(self.raw.saturating_add(by))
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Bound {
#[default]
Inclusive,
Exclusive,
}
impl Bound {
#[must_use]
pub const fn admits_forward(self, candidate: usize, anchor: usize) -> bool {
match self {
Self::Inclusive => candidate >= anchor,
Self::Exclusive => candidate > anchor,
}
}
#[must_use]
pub const fn admits_backward(self, candidate: usize, anchor: usize) -> bool {
match self {
Self::Inclusive => candidate <= anchor,
Self::Exclusive => candidate < anchor,
}
}
#[must_use]
pub fn first_matching(self, starts: &[usize], anchor: usize, forward: bool) -> Option<usize> {
if forward {
starts.iter().position(|&s| self.admits_forward(s, anchor))
} else {
starts
.iter()
.rposition(|&s| self.admits_backward(s, anchor))
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Wrapped {
#[default]
No,
AtBottom,
AtTop,
}
impl Wrapped {
#[must_use]
pub const fn happened(self) -> bool {
!matches!(self, Self::No)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Landing {
pub index: usize,
pub wrapped: Wrapped,
}
impl Bound {
#[must_use]
pub fn step_wrapping(self, starts: &[usize], anchor: usize, forward: bool) -> Option<Landing> {
if starts.is_empty() {
return None;
}
match self.first_matching(starts, anchor, forward) {
Some(index) => Some(Landing {
index,
wrapped: Wrapped::No,
}),
None if forward => Some(Landing {
index: 0,
wrapped: Wrapped::AtBottom,
}),
None => Some(Landing {
index: starts.len() - 1,
wrapped: Wrapped::AtTop,
}),
}
}
}
#[derive(
Clone,
Copy,
Debug,
PartialEq,
Eq,
Hash,
Default,
serde::Serialize,
serde::Deserialize,
schemars::JsonSchema,
)]
pub enum CaretMove {
#[default]
Left,
Right,
Start,
End,
}
impl CaretMove {
#[must_use]
pub const fn resolve(self, caret: usize, len: usize) -> usize {
match self {
Self::Left => caret.saturating_sub(1),
Self::Right => {
if caret < len {
caret + 1
} else {
len
}
}
Self::Start => 0,
Self::End => len,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Anchored<T, G: PartialEq + Copy> {
value: T,
at: G,
}
impl<T, G: PartialEq + Copy> Anchored<T, G> {
#[must_use]
pub const fn new(value: T, at: G) -> Self {
Self { value, at }
}
#[must_use]
pub fn get(&self, now: G) -> Option<&T> {
(self.at == now).then_some(&self.value)
}
#[must_use]
pub const fn generation(&self) -> G {
self.at
}
#[must_use]
pub const fn get_possibly_stale(&self) -> &T {
&self.value
}
#[must_use]
pub fn is_fresh(&self, now: G) -> bool {
self.at == now
}
}
#[derive(Debug, Clone, Copy)]
pub struct Ruler<'a> {
text: &'a str,
}
impl<'a> Ruler<'a> {
#[must_use]
pub const fn new(text: &'a str) -> Self {
Self { text }
}
#[must_use]
pub const fn end_bytes(&self) -> Offset<Bytes> {
Offset::new(self.text.len())
}
#[must_use]
pub fn end_chars(&self) -> Offset<Chars> {
Offset::new(self.text.chars().count())
}
#[must_use]
pub fn snap(&self, at: Offset<Bytes>) -> Offset<Bytes> {
let mut raw = at.raw().min(self.text.len());
while raw > 0 && !self.text.is_char_boundary(raw) {
raw -= 1;
}
Offset::new(raw)
}
#[must_use]
pub fn to_chars(&self, at: Offset<Bytes>) -> Offset<Chars> {
let b = self.snap(at).raw();
Offset::new(self.text[..b].chars().count())
}
#[must_use]
pub fn to_bytes(&self, at: Offset<Chars>) -> Offset<Bytes> {
self.text
.char_indices()
.nth(at.raw())
.map_or_else(|| self.end_bytes(), |(b, _)| Offset::new(b))
}
#[must_use]
pub fn to_utf16(&self, at: Offset<Bytes>) -> Offset<Utf16Units> {
let b = self.snap(at).raw();
Offset::new(self.text[..b].chars().map(char::len_utf16).sum())
}
}
impl<'a> Ruler<'a> {
#[must_use]
pub const fn ascending(&self) -> AscendingScan<'a> {
AscendingScan {
text: self.text,
byte: 0,
chars: 0,
}
}
}
#[derive(Debug, Clone)]
pub struct AscendingScan<'a> {
text: &'a str,
byte: usize,
chars: usize,
}
impl AscendingScan<'_> {
pub fn to_chars(&mut self, at: Offset<Bytes>) -> Offset<Chars> {
let mut target = at.raw().min(self.text.len());
while target > 0 && !self.text.is_char_boundary(target) {
target -= 1;
}
debug_assert!(
target >= self.byte,
"AscendingScan went backwards: {target} < {}",
self.byte,
);
if target <= self.byte {
return Offset::new(self.chars);
}
self.chars += self.text[self.byte..target].chars().count();
self.byte = target;
Offset::new(self.chars)
}
}
#[cfg(test)]
mod tests {
use super::*;
const CORPUS: &[&str] = &[
"",
"a",
"hello world",
"héllo", "日本語 foo", "🔥🔥🔥", "a\nb\nc",
"x🔥y",
];
#[test]
fn law_the_three_scales_disagree_and_the_ruler_knows_it() {
let r = Ruler::new("héllo");
let end = r.end_bytes();
assert_eq!(end.raw(), 6, "bytes");
assert_eq!(r.to_chars(end).raw(), 5, "chars");
assert_eq!(r.to_utf16(end).raw(), 5, "utf16");
let r = Ruler::new("🔥");
let end = r.end_bytes();
assert_eq!(end.raw(), 4, "bytes");
assert_eq!(r.to_chars(end).raw(), 1, "chars");
assert_eq!(r.to_utf16(end).raw(), 2, "utf16 — a surrogate pair");
}
#[test]
fn law_byte_char_roundtrip_is_identity_on_boundaries() {
for text in CORPUS {
let r = Ruler::new(text);
for (b, _) in text
.char_indices()
.chain(core::iter::once((text.len(), ' ')))
{
let start = Offset::<Bytes>::new(b);
let round = r.to_bytes(r.to_chars(start));
assert_eq!(round, start, "roundtrip failed at {b} in {text:?}");
}
}
}
#[test]
fn law_conversion_is_monotonic() {
for text in CORPUS {
let r = Ruler::new(text);
let mut prev = 0;
for b in 0..=text.len() {
let c = r.to_chars(Offset::new(b)).raw();
assert!(c >= prev, "non-monotonic at {b} in {text:?}");
prev = c;
}
}
}
#[test]
fn law_snap_is_total_and_idempotent_over_every_byte() {
for text in CORPUS {
let r = Ruler::new(text);
for b in 0..=text.len() + 5 {
let once = r.snap(Offset::new(b));
assert!(
text.is_char_boundary(once.raw()),
"snap({b}) left {once:?} mid-codepoint in {text:?}",
);
assert_eq!(r.snap(once), once, "snap not idempotent at {b}");
}
}
}
#[test]
fn law_snap_never_moves_forward() {
for text in CORPUS {
let r = Ruler::new(text);
for b in 0..=text.len() {
assert!(r.snap(Offset::new(b)).raw() <= b, "moved forward at {b}");
}
}
}
#[test]
fn a_mid_codepoint_offset_does_not_panic() {
let r = Ruler::new("🔥🔥🔥");
assert_eq!(r.snap(Offset::new(1)).raw(), 0);
assert_eq!(r.to_chars(Offset::new(1)).raw(), 0);
assert_eq!(r.to_utf16(Offset::new(1)).raw(), 0);
}
#[test]
fn law_an_inclusive_forward_search_finds_a_match_at_zero() {
let starts = [0_usize, 10, 20];
assert_eq!(
Bound::Inclusive.first_matching(&starts, 0, true),
Some(0),
"an inclusive search from 0 must find the match AT 0",
);
assert_eq!(
Bound::Exclusive.first_matching(&starts, 0, true),
Some(1),
"an exclusive search from 0 must skip it",
);
}
#[test]
fn law_the_two_bounds_differ_only_at_the_anchor() {
let starts = [0_usize, 5, 9];
for anchor in 0..12 {
let inc = Bound::Inclusive.first_matching(&starts, anchor, true);
let exc = Bound::Exclusive.first_matching(&starts, anchor, true);
if starts.contains(&anchor) {
assert_ne!(inc, exc, "must differ when the anchor IS a match");
} else {
assert_eq!(inc, exc, "must agree when the anchor is not a match");
}
}
}
#[test]
fn law_backward_is_the_mirror_of_forward() {
let starts = [0_usize, 5, 9];
assert_eq!(Bound::Inclusive.first_matching(&starts, 5, false), Some(1));
assert_eq!(Bound::Exclusive.first_matching(&starts, 5, false), Some(0));
assert_eq!(
Bound::Exclusive.first_matching(&starts, 0, false),
None,
"nothing lies strictly before the first match",
);
}
#[test]
fn law_no_bound_ever_underflows() {
for b in [Bound::Inclusive, Bound::Exclusive] {
assert_eq!(b.first_matching(&[], 0, true), None);
assert_eq!(b.first_matching(&[], 0, false), None);
let _ = b.first_matching(&[0], 0, true);
let _ = b.first_matching(&[0], 0, false);
}
}
#[test]
fn law_a_value_from_an_older_generation_reads_as_absent() {
let (g0, g1) = (0_u64, 1_u64);
let a = Anchored::new(Offset::<Chars>::new(7), g0);
assert_eq!(a.get(g0), Some(&Offset::new(7)), "fresh");
assert_eq!(
a.get(g1),
None,
"stale reads as absent, not as a wrong number"
);
assert!(!a.is_fresh(g1));
assert_eq!(a.get_possibly_stale().raw(), 7);
}
#[test]
fn law_freshness_is_not_ordering() {
let a = Anchored::new(1_u8, 1_u64);
assert_eq!(a.get(0_u64), None);
}
#[test]
fn offsets_of_different_scales_are_different_types() {
let b = Offset::<Bytes>::new(6);
let c = Offset::<Chars>::new(5);
assert_eq!(Offset::<Bytes>::scale_name(), "bytes");
assert_eq!(Offset::<Chars>::scale_name(), "chars");
assert_eq!(b.raw(), 6);
assert_eq!(c.raw(), 5);
}
#[test]
fn law_an_ascending_scan_agrees_with_to_chars_at_every_offset() {
for text in CORPUS {
let r = Ruler::new(text);
let mut scan = r.ascending();
for b in 0..=text.len() {
let bulk = scan.to_chars(Offset::new(b));
let scalar = r.to_chars(Offset::new(b));
assert_eq!(bulk, scalar, "disagreed at byte {b} of {text:?}");
}
}
}
#[test]
fn law_an_ascending_scan_snaps_down_like_to_chars_mid_codepoint() {
let r = Ruler::new("🔥🔥🔥");
for b in 0..=r.end_bytes().raw() {
let mut scan = r.ascending();
assert_eq!(scan.to_chars(Offset::new(b)), r.to_chars(Offset::new(b)));
}
}
#[test]
fn an_ascending_scan_visits_each_byte_once() {
let text = "日本語 foo bar";
let r = Ruler::new(text);
let mut scan = r.ascending();
let mut last = 0;
for b in 0..=text.len() {
let got = scan.to_chars(Offset::new(b)).raw();
assert!(got >= last, "chars went backwards at {b}");
last = got;
}
assert_eq!(last, text.chars().count(), "ends at the full char count");
}
#[test]
fn an_ascending_scan_saturates_rather_than_lying_when_asked_to_go_back() {
let r = Ruler::new("abcdef");
let mut scan = r.ascending();
let forward = scan.to_chars(Offset::new(4));
assert_eq!(forward.raw(), 4);
}
#[test]
fn an_ascending_scan_past_the_end_clamps() {
let r = Ruler::new("abc");
let mut scan = r.ascending();
assert_eq!(scan.to_chars(Offset::new(99)).raw(), 3);
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CaretLine {
text: String,
caret: usize,
}
impl CaretLine {
#[must_use]
pub fn new(text: String, caret: usize) -> Self {
let caret = caret.min(text.chars().count());
Self { text, caret }
}
#[must_use]
pub fn text(&self) -> &str {
&self.text
}
#[must_use]
pub const fn caret(&self) -> usize {
self.caret
}
#[must_use]
pub fn len_chars(&self) -> usize {
self.text.chars().count()
}
fn byte_of_caret(&self) -> usize {
Ruler::new(&self.text)
.to_bytes(Offset::<Chars>::new(self.caret))
.raw()
}
pub fn insert(&mut self, ch: char) {
let at = self.byte_of_caret();
self.text.insert(at, ch);
self.caret += 1;
}
pub fn push_str(&mut self, s: &str) {
self.text.push_str(s);
self.caret = self.len_chars();
}
pub fn move_caret(&mut self, to: CaretMove) {
self.caret = to.resolve(self.caret, self.len_chars());
}
pub fn delete(&mut self) {
let at = self.byte_of_caret();
if at < self.text.len() {
self.text.remove(at);
}
}
pub fn backspace(&mut self) -> Option<char> {
if self.caret == 0 {
return None;
}
let at = self.byte_of_caret();
let prev = self.text[..at]
.char_indices()
.next_back()
.map_or(0, |(i, _)| i);
let ch = self.text.remove(prev);
self.caret -= 1;
Some(ch)
}
pub fn clear(&mut self) {
self.text.clear();
self.caret = 0;
}
pub fn set_text(&mut self, text: String) {
self.text = text;
self.caret = self.len_chars();
}
pub fn delete_word_before(&mut self) {
let chars: Vec<char> = self.text.chars().collect();
let mut i = self.caret;
while i > 0 && chars[i - 1].is_whitespace() {
i -= 1;
}
while i > 0 && !chars[i - 1].is_whitespace() {
i -= 1;
}
self.text = chars[..i]
.iter()
.chain(chars[self.caret..].iter())
.collect();
self.caret = i;
}
pub fn clear_before_caret(&mut self) {
let at = self.byte_of_caret();
self.text.drain(..at);
self.caret = 0;
}
}
#[cfg(test)]
mod caret_line_tests {
use super::*;
#[test]
fn law_the_caret_byte_offset_agrees_with_the_hand_rolled_conversion() {
for text in ["", "abc", "héllo", "日本語 foo", "🔥x🔥"] {
for caret in 0..=text.chars().count() {
let line = CaretLine::new(text.to_owned(), caret);
let by_hand = text
.char_indices()
.nth(caret)
.map_or(text.len(), |(b, _)| b);
assert_eq!(
line.byte_of_caret(),
by_hand,
"caret {caret} in {text:?}: Ruler disagreed with the hand-rolled map",
);
}
}
}
#[test]
fn law_every_mutation_preserves_the_caret_bound() {
let mut line = CaretLine::new("héllo 日本語".to_owned(), 3);
let check = |l: &CaretLine| assert!(l.caret() <= l.len_chars(), "{l:?}");
line.insert('x');
check(&line);
line.move_caret(CaretMove::Start);
check(&line);
line.delete();
check(&line);
line.move_caret(CaretMove::End);
check(&line);
line.backspace();
check(&line);
line.delete_word_before();
check(&line);
line.clear_before_caret();
check(&line);
line.set_text("🔥🔥🔥".to_owned());
check(&line);
assert_eq!(line.caret(), 3, "set_text parks the caret at the end");
line.clear();
check(&line);
assert_eq!(line.caret(), 0, "and clear brings it home");
}
#[test]
fn law_a_caret_past_the_end_is_clamped_by_the_constructor() {
assert_eq!(CaretLine::new("ab".to_owned(), 99).caret(), 2);
assert_eq!(CaretLine::new(String::new(), 7).caret(), 0);
}
#[test]
fn law_a_second_word_delete_eats_a_whole_word_not_just_the_gap() {
let mut line = CaretLine::new("foo bar baz".to_owned(), 11);
line.delete_word_before();
assert_eq!(line.text(), "foo bar ");
line.delete_word_before();
assert_eq!(line.text(), "foo ");
}
}