Skip to main content

escriba_memori/
lib.rs

1//! memori (目盛) — the graduation marks an offset was counted in.
2//!
3//! # The problem this vocabulary corners
4//!
5//! An offset into text is a `usize`, and a `usize` says nothing about **which
6//! ruler it was measured on**. escriba measures on three, and they disagree on
7//! every non-ASCII character:
8//!
9//! | scale | `"héllo"` end | who demands it |
10//! |---|---|---|
11//! | bytes | 6 | `regex`, every `&str` index |
12//! | chars | 5 | escriba's own `Position`/match offsets |
13//! | UTF-16 units | 5 | LSP, and anything speaking to an editor client |
14//!
15//! Passing one where another is expected compiles perfectly and is wrong only
16//! for users with non-ASCII text — the worst possible failure profile, because
17//! it survives every test written in English.
18//!
19//! Five distinct bugs in one session were this class. That is the signal a
20//! primitive is announcing itself rather than five tasks:
21//!
22//! 1. a commit stepping from the wrong anchor,
23//! 2. `saturating_sub(1)` making offset 0 unreachable,
24//! 3. match offsets used against text that had since been edited,
25//! 4. byte/char/UTF-16 conversion correct only because one function guarded it,
26//! 5. an exclusive-vs-inclusive endpoint chosen by picking a function name.
27//!
28//! # The three axes, made orthogonal
29//!
30//! - **Scale** — [`Offset<S>`] is phantom-tagged, so `Offset<Bytes>` and
31//!   `Offset<Chars>` are different types and mixing them is a compile error.
32//!   Conversion is possible *only* through a [`Ruler`], which cannot exist
33//!   without the text it measures.
34//! - **Bound** — [`Bound`] makes "does the endpoint count itself?" a value the
35//!   caller states, not a function name they must pick correctly, and not an
36//!   arithmetic fudge at the call site.
37//! - **Freshness** — [`Anchored`] carries the [`EditGen`] an offset was
38//!   computed against, so using it after an edit is a `None` rather than a
39//!   silently wrong column.
40//!
41//! # Tier honesty
42//!
43//! Scale-mixing is **truly unrepresentable** (a type error, `E0308`). Bound and
44//! freshness are **parse-time-rejected** at this border — you can still build a
45//! `Ruler` for the wrong text. The full ledger is in `docs/memori.md`.
46//!
47//! The `(defmemori …)` tatara-lisp surface is a NAMED FOLLOW-UP, not shipped.
48//! This crate is the typed Rust border only.
49//!
50//! # Why a leaf crate
51//!
52//! It has **no dependencies, deliberately**. `escriba-core` imports
53//! `escriba_search::Direction`, so core depends on SEARCH — and a positioning
54//! primitive living in core would be invisible to the search engine, which is
55//! where the `step`/`step_inclusive` twins [`Bound`] exists to replace live.
56//! The vocabulary has to sit below both, so it does.
57
58use core::marker::PhantomData;
59
60// ── scales ───────────────────────────────────────────────────────────────
61
62/// A unit an offset can be counted in.
63///
64/// Sealed by construction: the three implementors below are the only ones, and
65/// the trait's only members are constants, so a consumer cannot invent a
66/// fourth scale that conversion does not handle.
67pub trait Scale: Copy + core::fmt::Debug {
68    /// How this scale names itself in a diagnostic.
69    const NAME: &'static str;
70}
71
72/// UTF-8 bytes — what `&str` indexing and the `regex` crate speak.
73#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
74pub struct Bytes;
75/// Unicode scalar values — what escriba's own `Position` and match offsets use.
76#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
77pub struct Chars;
78/// UTF-16 code units — what LSP specifies, and nothing else.
79#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
80pub struct Utf16Units;
81
82impl Scale for Bytes {
83    const NAME: &'static str = "bytes";
84}
85impl Scale for Chars {
86    const NAME: &'static str = "chars";
87}
88impl Scale for Utf16Units {
89    const NAME: &'static str = "utf16";
90}
91
92/// An offset counted in a specific [`Scale`].
93///
94/// The phantom parameter is the whole point: `Offset<Bytes>` and
95/// `Offset<Chars>` are distinct types, so the substitution that produced a
96/// wrong column for every non-ASCII user is now `E0308`.
97///
98/// `raw()` is deliberately the ONLY way out. Reaching for it is the moment to
99/// ask which scale the consumer wants, which is exactly the question the bare
100/// `usize` let everyone skip.
101#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
102pub struct Offset<S: Scale> {
103    raw: usize,
104    _scale: PhantomData<S>,
105}
106
107// Derived `Clone`/`Copy` would demand `S: Clone`, which is noise on a phantom.
108impl<S: Scale> Clone for Offset<S> {
109    fn clone(&self) -> Self {
110        *self
111    }
112}
113impl<S: Scale> Copy for Offset<S> {}
114
115impl<S: Scale> Offset<S> {
116    /// The start of the text, in any scale. Always valid.
117    pub const ZERO: Self = Self {
118        raw: 0,
119        _scale: PhantomData,
120    };
121
122    #[must_use]
123    pub const fn new(raw: usize) -> Self {
124        Self {
125            raw,
126            _scale: PhantomData,
127        }
128    }
129
130    #[must_use]
131    pub const fn raw(self) -> usize {
132        self.raw
133    }
134
135    /// Which ruler this was counted on — for diagnostics.
136    #[must_use]
137    pub const fn scale_name() -> &'static str {
138        S::NAME
139    }
140
141    /// Move forward within the SAME scale. Saturating, so it cannot wrap.
142    #[must_use]
143    pub const fn advance(self, by: usize) -> Self {
144        Self::new(self.raw.saturating_add(by))
145    }
146}
147
148// ── bounds ───────────────────────────────────────────────────────────────
149
150/// Does a search from an anchor consider the anchor itself?
151///
152/// This existed as a choice between two function names (`step` vs
153/// `step_inclusive`) plus, at one call site, a `saturating_sub(1)` that tried
154/// to convert one into the other by arithmetic. Both mistakes are the same
155/// mistake, and both are removed by making the bound a value:
156///
157/// - naming it forces the caller to state intent instead of remembering which
158///   function is which;
159/// - [`Bound::first_matching`] never subtracts, so the "back up one to include
160///   the anchor" trick — which cannot back up past 0, and therefore made a
161///   match at offset 0 unreachable — has nowhere to live.
162#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
163pub enum Bound {
164    /// The anchor counts. `/foo` sitting ON a `foo` finds that one.
165    #[default]
166    Inclusive,
167    /// The anchor does not count. `n` advances off the current match.
168    Exclusive,
169}
170
171impl Bound {
172    /// Does `candidate` lie at-or-after `anchor` under this bound?
173    #[must_use]
174    pub const fn admits_forward(self, candidate: usize, anchor: usize) -> bool {
175        match self {
176            Self::Inclusive => candidate >= anchor,
177            Self::Exclusive => candidate > anchor,
178        }
179    }
180
181    /// Does `candidate` lie at-or-before `anchor` under this bound?
182    #[must_use]
183    pub const fn admits_backward(self, candidate: usize, anchor: usize) -> bool {
184        match self {
185            Self::Inclusive => candidate <= anchor,
186            Self::Exclusive => candidate < anchor,
187        }
188    }
189
190    /// The index of the first ascending `starts` entry this bound admits,
191    /// searching forward from `anchor`.
192    ///
193    /// **Total, and correct at 0 by construction** — there is no subtraction
194    /// to saturate. That is the seal on the measured bug: an inclusive search
195    /// used to be spelled `step(from - 1)`, and `0 - 1` saturates back to `0`,
196    /// so a match at the very start of the file could never be found.
197    #[must_use]
198    pub fn first_matching(self, starts: &[usize], anchor: usize, forward: bool) -> Option<usize> {
199        if forward {
200            starts.iter().position(|&s| self.admits_forward(s, anchor))
201        } else {
202            starts
203                .iter()
204                .rposition(|&s| self.admits_backward(s, anchor))
205        }
206    }
207}
208
209/// Whether a step ran off the end and resumed at the other one.
210///
211/// Reported, never silent. Wrapping without saying so is how a reader loses
212/// track of where they are in a long file — vim prints "search hit BOTTOM,
213/// continuing at TOP" for exactly this reason.
214///
215/// Lifted here from `escriba-search` when result-list navigation became the
216/// second consumer: the wrap and the way it is ANNOUNCED are one behaviour,
217/// and two copies would be two chances to stop announcing it.
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
219pub enum Wrapped {
220    #[default]
221    No,
222    /// Ran past the end, resumed at the top.
223    AtBottom,
224    /// Ran past the start, resumed at the bottom.
225    AtTop,
226}
227
228impl Wrapped {
229    #[must_use]
230    pub const fn happened(self) -> bool {
231        !matches!(self, Self::No)
232    }
233}
234
235/// Where a wrapping step landed.
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
237pub struct Landing {
238    /// Index into the sorted starts that were stepped over.
239    pub index: usize,
240    pub wrapped: Wrapped,
241}
242
243impl Bound {
244    /// [`first_matching`](Self::first_matching), wrapping at the ends.
245    ///
246    /// `first_matching` answers "what is next" and returns `None` at the end.
247    /// Both callers want "what is next, wrapping" — and both also want to
248    /// TELL the reader that it wrapped. Doing it here means search and result
249    /// navigation cannot drift into wrapping differently, or into one of them
250    /// going quiet about it.
251    ///
252    /// `None` only when `starts` is empty: with anything to land on, a
253    /// wrapping step always lands.
254    #[must_use]
255    pub fn step_wrapping(self, starts: &[usize], anchor: usize, forward: bool) -> Option<Landing> {
256        if starts.is_empty() {
257            return None;
258        }
259        match self.first_matching(starts, anchor, forward) {
260            Some(index) => Some(Landing {
261                index,
262                wrapped: Wrapped::No,
263            }),
264            None if forward => Some(Landing {
265                index: 0,
266                wrapped: Wrapped::AtBottom,
267            }),
268            None => Some(Landing {
269                index: starts.len() - 1,
270                wrapped: Wrapped::AtTop,
271            }),
272        }
273    }
274}
275
276/// Where a caret movement lands.
277///
278/// Lives in memori because it is a POSITIONING concept, not a search one: the
279/// search prompt and the ex-line both have a caret and both need the same
280/// closed set of moves. It started in `escriba-search`, which meant
281/// `escriba-mode` could not reach it without depending on the search engine to
282/// move a cursor in a text box.
283///
284/// A closed set, so "move the caret" cannot mean an unhandled direction. Each
285/// resolves against the CURRENT length, so none can leave the caret out of
286/// bounds — the clamping is in one place rather than at each call site.
287#[derive(
288    Clone,
289    Copy,
290    Debug,
291    PartialEq,
292    Eq,
293    Hash,
294    Default,
295    serde::Serialize,
296    serde::Deserialize,
297    schemars::JsonSchema,
298)]
299pub enum CaretMove {
300    #[default]
301    Left,
302    Right,
303    Start,
304    End,
305}
306
307impl CaretMove {
308    /// Total, and saturating at both ends.
309    #[must_use]
310    pub const fn resolve(self, caret: usize, len: usize) -> usize {
311        match self {
312            Self::Left => caret.saturating_sub(1),
313            Self::Right => {
314                if caret < len {
315                    caret + 1
316                } else {
317                    len
318                }
319            }
320            Self::Start => 0,
321            Self::End => len,
322        }
323    }
324}
325
326// ── freshness ────────────────────────────────────────────────────────────
327
328/// A value computed against a specific edit generation.
329///
330/// An offset is a claim about text. When the text changes the claim expires,
331/// and the expiry is invisible: the number is still a number, and it still
332/// indexes *something*. `SearchState::refresh` existed to prevent exactly this
333/// and had zero callers for as long as it existed, which is the argument for
334/// making staleness a type rather than a discipline.
335///
336/// [`Anchored::get`] takes the CURRENT generation and returns `None` when they
337/// disagree, so a stale read is a visible absence instead of a wrong column.
338#[derive(Clone, Copy, Debug, PartialEq, Eq)]
339pub struct Anchored<T, G: PartialEq + Copy> {
340    value: T,
341    at: G,
342}
343
344impl<T, G: PartialEq + Copy> Anchored<T, G> {
345    #[must_use]
346    pub const fn new(value: T, at: G) -> Self {
347        Self { value, at }
348    }
349
350    /// The value, if it was computed against `now`.
351    #[must_use]
352    pub fn get(&self, now: G) -> Option<&T> {
353        (self.at == now).then_some(&self.value)
354    }
355
356    /// The generation this was computed against.
357    #[must_use]
358    pub const fn generation(&self) -> G {
359        self.at
360    }
361
362    /// Read the value regardless of freshness.
363    ///
364    /// Named to be conspicuous at the call site: reaching for it is a claim
365    /// that staleness does not matter here, and that claim should be visible
366    /// in review rather than hidden behind a plain accessor.
367    #[must_use]
368    pub const fn get_possibly_stale(&self) -> &T {
369        &self.value
370    }
371
372    #[must_use]
373    pub fn is_fresh(&self, now: G) -> bool {
374        self.at == now
375    }
376}
377
378// ── the ruler ────────────────────────────────────────────────────────────
379
380/// Converts between scales for one specific text.
381///
382/// A `Ruler` cannot be built without the text, which is the structural reason
383/// a scale conversion can never be done "in general": there is no such thing.
384/// `"héllo".len()` is 6 bytes and 5 chars, and only the string knows.
385#[derive(Debug, Clone, Copy)]
386pub struct Ruler<'a> {
387    text: &'a str,
388}
389
390impl<'a> Ruler<'a> {
391    #[must_use]
392    pub const fn new(text: &'a str) -> Self {
393        Self { text }
394    }
395
396    /// The text's end, in bytes.
397    #[must_use]
398    pub const fn end_bytes(&self) -> Offset<Bytes> {
399        Offset::new(self.text.len())
400    }
401
402    /// The text's end, in chars.
403    #[must_use]
404    pub fn end_chars(&self) -> Offset<Chars> {
405        Offset::new(self.text.chars().count())
406    }
407
408    /// Clamp into the text and snap DOWN to a character boundary.
409    ///
410    /// Snapping down, not up, keeps the result inside the character the offset
411    /// pointed at — where an editor should underline. Mid-codepoint offsets are
412    /// a NORMAL arrival, not corruption: they come from parser error spans over
413    /// a buffer the user is halfway through typing a character into. Measured
414    /// 2026-08-01: `analyse("🔥🔥🔥")` aborted on exactly this at offset 1.
415    #[must_use]
416    pub fn snap(&self, at: Offset<Bytes>) -> Offset<Bytes> {
417        let mut raw = at.raw().min(self.text.len());
418        while raw > 0 && !self.text.is_char_boundary(raw) {
419            raw -= 1;
420        }
421        Offset::new(raw)
422    }
423
424    /// Bytes → chars. Total: out-of-range and mid-codepoint inputs snap first.
425    #[must_use]
426    pub fn to_chars(&self, at: Offset<Bytes>) -> Offset<Chars> {
427        let b = self.snap(at).raw();
428        Offset::new(self.text[..b].chars().count())
429    }
430
431    /// Chars → bytes. Total: past-the-end saturates to the text's end.
432    #[must_use]
433    pub fn to_bytes(&self, at: Offset<Chars>) -> Offset<Bytes> {
434        self.text
435            .char_indices()
436            .nth(at.raw())
437            .map_or_else(|| self.end_bytes(), |(b, _)| Offset::new(b))
438    }
439
440    /// Bytes → UTF-16 code units, for LSP.
441    ///
442    /// Separate from [`Self::to_chars`] because they differ, and the
443    /// difference is invisible until a user types an emoji: `🔥` is ONE char
444    /// and TWO UTF-16 units. Conflating them shifts every position after it.
445    #[must_use]
446    pub fn to_utf16(&self, at: Offset<Bytes>) -> Offset<Utf16Units> {
447        let b = self.snap(at).raw();
448        Offset::new(self.text[..b].chars().map(char::len_utf16).sum())
449    }
450}
451
452impl<'a> Ruler<'a> {
453    /// A forward-only reader for offsets visited in ASCENDING order.
454    ///
455    /// [`Ruler::to_chars`] is O(n) per call — `text[..b].chars().count()`
456    /// re-walks from the start every time — because it must be TOTAL and
457    /// random-access. That is right for a caret, and wrong for converting
458    /// every match in a document: O(n) per call over m matches is O(n·m),
459    /// which is the cost `escriba-search` originally avoided by building a
460    /// dense `usize`-per-byte map.
461    ///
462    /// This is the third option, better than both: O(n + m) total with O(1)
463    /// extra memory, because the offsets arrive in order and the scan never
464    /// needs to look back. The dense map allocated and zeroed EIGHT BYTES PER
465    /// DOCUMENT BYTE on every keystroke of an incremental search; this
466    /// allocates nothing.
467    #[must_use]
468    pub const fn ascending(&self) -> AscendingScan<'a> {
469        AscendingScan {
470            text: self.text,
471            byte: 0,
472            chars: 0,
473        }
474    }
475}
476
477/// A forward-only byte→char converter for ascending offsets.
478///
479/// Built by [`Ruler::ascending`]. Holds a cursor into the text and the number
480/// of chars behind it, so each query advances rather than restarts.
481#[derive(Debug, Clone)]
482pub struct AscendingScan<'a> {
483    text: &'a str,
484    byte: usize,
485    chars: usize,
486}
487
488impl AscendingScan<'_> {
489    /// Convert `at` to chars, advancing the scan.
490    ///
491    /// `at` must be >= the previous argument. Going backwards is a programming
492    /// error and `debug_assert`s in test builds; in release the scan SATURATES
493    /// (returns its current position) rather than panicking or silently
494    /// producing a smaller number for a larger offset — an editor should not
495    /// abort mid-frame over a monotonicity slip.
496    ///
497    /// Mid-codepoint and past-the-end offsets snap DOWN, exactly as
498    /// [`Ruler::to_chars`] does, so the two agree everywhere.
499    pub fn to_chars(&mut self, at: Offset<Bytes>) -> Offset<Chars> {
500        // Snap DOWN to a char boundary first, exactly as `Ruler::to_chars`
501        // does. Without this the two paths disagree on every mid-codepoint
502        // offset — and slicing at a non-boundary panics outright, so the
503        // differential law caught it as a failure rather than a wrong number.
504        let mut target = at.raw().min(self.text.len());
505        while target > 0 && !self.text.is_char_boundary(target) {
506            target -= 1;
507        }
508        debug_assert!(
509            target >= self.byte,
510            "AscendingScan went backwards: {target} < {}",
511            self.byte,
512        );
513        if target <= self.byte {
514            return Offset::new(self.chars);
515        }
516        // Count the chars between the cursor and the target. Every byte is
517        // visited at most once across the whole scan, which is what makes the
518        // total O(n) rather than O(n) per call.
519        self.chars += self.text[self.byte..target].chars().count();
520        self.byte = target;
521        Offset::new(self.chars)
522    }
523}
524
525#[cfg(test)]
526mod tests {
527    use super::*;
528
529    /// Texts that have broken position code before. Every law runs over all of
530    /// them, so a law that only holds for ASCII cannot pass.
531    const CORPUS: &[&str] = &[
532        "",
533        "a",
534        "hello world",
535        "héllo",      // 2-byte char
536        "日本語 foo", // 3-byte chars
537        "🔥🔥🔥",     // 4-byte, 2 UTF-16 units each
538        "a\nb\nc",
539        "x🔥y",
540    ];
541
542    // ── scale separation ────────────────────────────────────────────────
543
544    #[test]
545    fn law_the_three_scales_disagree_and_the_ruler_knows_it() {
546        // If this ever passes trivially the corpus has lost its teeth.
547        let r = Ruler::new("héllo");
548        let end = r.end_bytes();
549        assert_eq!(end.raw(), 6, "bytes");
550        assert_eq!(r.to_chars(end).raw(), 5, "chars");
551        assert_eq!(r.to_utf16(end).raw(), 5, "utf16");
552
553        let r = Ruler::new("🔥");
554        let end = r.end_bytes();
555        assert_eq!(end.raw(), 4, "bytes");
556        assert_eq!(r.to_chars(end).raw(), 1, "chars");
557        assert_eq!(r.to_utf16(end).raw(), 2, "utf16 — a surrogate pair");
558    }
559
560    #[test]
561    fn law_byte_char_roundtrip_is_identity_on_boundaries() {
562        for text in CORPUS {
563            let r = Ruler::new(text);
564            for (b, _) in text
565                .char_indices()
566                .chain(core::iter::once((text.len(), ' ')))
567            {
568                let start = Offset::<Bytes>::new(b);
569                let round = r.to_bytes(r.to_chars(start));
570                assert_eq!(round, start, "roundtrip failed at {b} in {text:?}");
571            }
572        }
573    }
574
575    #[test]
576    fn law_conversion_is_monotonic() {
577        // A later byte can never map to an earlier char. Violating this is how
578        // highlights end up crossing over each other.
579        for text in CORPUS {
580            let r = Ruler::new(text);
581            let mut prev = 0;
582            for b in 0..=text.len() {
583                let c = r.to_chars(Offset::new(b)).raw();
584                assert!(c >= prev, "non-monotonic at {b} in {text:?}");
585                prev = c;
586            }
587        }
588    }
589
590    // ── snapping ────────────────────────────────────────────────────────
591
592    #[test]
593    fn law_snap_is_total_and_idempotent_over_every_byte() {
594        // Every offset, including mid-codepoint and past-the-end, must land on
595        // a boundary — and snapping twice must not move again.
596        for text in CORPUS {
597            let r = Ruler::new(text);
598            for b in 0..=text.len() + 5 {
599                let once = r.snap(Offset::new(b));
600                assert!(
601                    text.is_char_boundary(once.raw()),
602                    "snap({b}) left {once:?} mid-codepoint in {text:?}",
603                );
604                assert_eq!(r.snap(once), once, "snap not idempotent at {b}");
605            }
606        }
607    }
608
609    #[test]
610    fn law_snap_never_moves_forward() {
611        // Snapping UP would report a position inside the NEXT character.
612        for text in CORPUS {
613            let r = Ruler::new(text);
614            for b in 0..=text.len() {
615                assert!(r.snap(Offset::new(b)).raw() <= b, "moved forward at {b}");
616            }
617        }
618    }
619
620    #[test]
621    fn a_mid_codepoint_offset_does_not_panic() {
622        // The measured crash: `analyse("🔥🔥🔥")` aborted at offset 1.
623        let r = Ruler::new("🔥🔥🔥");
624        assert_eq!(r.snap(Offset::new(1)).raw(), 0);
625        assert_eq!(r.to_chars(Offset::new(1)).raw(), 0);
626        assert_eq!(r.to_utf16(Offset::new(1)).raw(), 0);
627    }
628
629    // ── bounds: the seal on the saturating_sub bug ──────────────────────
630
631    #[test]
632    fn law_an_inclusive_forward_search_finds_a_match_at_zero() {
633        // THE regression. The old spelling was `step(from - 1)`, and `0 - 1`
634        // saturates to `0`, so a match at the start of the file was
635        // unreachable. There is no subtraction here to saturate.
636        let starts = [0_usize, 10, 20];
637        assert_eq!(
638            Bound::Inclusive.first_matching(&starts, 0, true),
639            Some(0),
640            "an inclusive search from 0 must find the match AT 0",
641        );
642        assert_eq!(
643            Bound::Exclusive.first_matching(&starts, 0, true),
644            Some(1),
645            "an exclusive search from 0 must skip it",
646        );
647    }
648
649    #[test]
650    fn law_the_two_bounds_differ_only_at_the_anchor() {
651        let starts = [0_usize, 5, 9];
652        for anchor in 0..12 {
653            let inc = Bound::Inclusive.first_matching(&starts, anchor, true);
654            let exc = Bound::Exclusive.first_matching(&starts, anchor, true);
655            if starts.contains(&anchor) {
656                assert_ne!(inc, exc, "must differ when the anchor IS a match");
657            } else {
658                assert_eq!(inc, exc, "must agree when the anchor is not a match");
659            }
660        }
661    }
662
663    #[test]
664    fn law_backward_is_the_mirror_of_forward() {
665        let starts = [0_usize, 5, 9];
666        assert_eq!(Bound::Inclusive.first_matching(&starts, 5, false), Some(1));
667        assert_eq!(Bound::Exclusive.first_matching(&starts, 5, false), Some(0));
668        assert_eq!(
669            Bound::Exclusive.first_matching(&starts, 0, false),
670            None,
671            "nothing lies strictly before the first match",
672        );
673    }
674
675    #[test]
676    fn law_no_bound_ever_underflows() {
677        // Anchor 0 in both directions, on an empty and a full list.
678        for b in [Bound::Inclusive, Bound::Exclusive] {
679            assert_eq!(b.first_matching(&[], 0, true), None);
680            assert_eq!(b.first_matching(&[], 0, false), None);
681            let _ = b.first_matching(&[0], 0, true);
682            let _ = b.first_matching(&[0], 0, false);
683        }
684    }
685
686    // ── freshness ───────────────────────────────────────────────────────
687
688    #[test]
689    fn law_a_value_from_an_older_generation_reads_as_absent() {
690        // Any `PartialEq + Copy` works as a generation — memori does not care
691        // WHICH counter, only that two of them can disagree.
692        let (g0, g1) = (0_u64, 1_u64);
693
694        let a = Anchored::new(Offset::<Chars>::new(7), g0);
695        assert_eq!(a.get(g0), Some(&Offset::new(7)), "fresh");
696        assert_eq!(
697            a.get(g1),
698            None,
699            "stale reads as absent, not as a wrong number"
700        );
701        assert!(!a.is_fresh(g1));
702        // The escape hatch still works, and is named so review can see it.
703        assert_eq!(a.get_possibly_stale().raw(), 7);
704    }
705
706    #[test]
707    fn law_freshness_is_not_ordering() {
708        // A value from a LATER generation is just as unusable as an older one —
709        // it is a mismatch, not a comparison. Treating it as "newer, so fine"
710        // is how a stale read sneaks back in.
711        let a = Anchored::new(1_u8, 1_u64);
712        assert_eq!(a.get(0_u64), None);
713    }
714
715    // ── the compile-time seal, demonstrated ─────────────────────────────
716
717    /// Scale mixing is a TYPE error, which a runtime test cannot observe. This
718    /// documents the seal and keeps the constructors honest; the proof is in
719    /// `docs/memori.md`, which records the exact `E0308` from a deliberate
720    /// violation.
721    #[test]
722    fn offsets_of_different_scales_are_different_types() {
723        let b = Offset::<Bytes>::new(6);
724        let c = Offset::<Chars>::new(5);
725        assert_eq!(Offset::<Bytes>::scale_name(), "bytes");
726        assert_eq!(Offset::<Chars>::scale_name(), "chars");
727        // `assert_eq!(b, c)` does not compile: expected `Offset<Bytes>`,
728        // found `Offset<Chars>`.
729        assert_eq!(b.raw(), 6);
730        assert_eq!(c.raw(), 5);
731    }
732
733    // ── the ascending scan ──────────────────────────────────────────────
734
735    #[test]
736    fn law_an_ascending_scan_agrees_with_to_chars_at_every_offset() {
737        // The differential law: the bulk path and the scalar path must give
738        // the same answer for every byte of every corpus entry. If they can
739        // disagree anywhere, the retrofit is a silent corruption.
740        for text in CORPUS {
741            let r = Ruler::new(text);
742            let mut scan = r.ascending();
743            for b in 0..=text.len() {
744                let bulk = scan.to_chars(Offset::new(b));
745                let scalar = r.to_chars(Offset::new(b));
746                assert_eq!(bulk, scalar, "disagreed at byte {b} of {text:?}");
747            }
748        }
749    }
750
751    #[test]
752    fn law_an_ascending_scan_snaps_down_like_to_chars_mid_codepoint() {
753        // Offsets inside a multi-byte char are a normal arrival; both paths
754        // must land on the same boundary.
755        let r = Ruler::new("🔥🔥🔥");
756        for b in 0..=r.end_bytes().raw() {
757            let mut scan = r.ascending();
758            assert_eq!(scan.to_chars(Offset::new(b)), r.to_chars(Offset::new(b)));
759        }
760    }
761
762    #[test]
763    fn an_ascending_scan_visits_each_byte_once() {
764        // The property that makes it O(n + m): querying every offset in order
765        // must not re-walk. Asserted structurally — the cursor only advances.
766        let text = "日本語 foo bar";
767        let r = Ruler::new(text);
768        let mut scan = r.ascending();
769        let mut last = 0;
770        for b in 0..=text.len() {
771            let got = scan.to_chars(Offset::new(b)).raw();
772            assert!(got >= last, "chars went backwards at {b}");
773            last = got;
774        }
775        assert_eq!(last, text.chars().count(), "ends at the full char count");
776    }
777
778    #[test]
779    fn an_ascending_scan_saturates_rather_than_lying_when_asked_to_go_back() {
780        // Release behaviour: a monotonicity slip must not produce a SMALLER
781        // char offset for a LARGER byte offset, and must not abort a frame.
782        // (Debug builds assert first, so this documents the release contract.)
783        let r = Ruler::new("abcdef");
784        let mut scan = r.ascending();
785        let forward = scan.to_chars(Offset::new(4));
786        assert_eq!(forward.raw(), 4);
787    }
788
789    #[test]
790    fn an_ascending_scan_past_the_end_clamps() {
791        let r = Ruler::new("abc");
792        let mut scan = r.ascending();
793        assert_eq!(scan.to_chars(Offset::new(99)).raw(), 3);
794    }
795}
796
797/// A line of text plus the caret editing it, as ONE value.
798///
799/// They are one value because the invariant lives *between* them —
800/// `caret <= text.chars().count()` — and a struct with private fields is the
801/// only place such an invariant can be maintained once rather than at every
802/// mutation site.
803///
804/// # Why this is in memori rather than in either editor crate
805///
806/// It was written twice. `escriba-search`'s `Prompt` held `text` + `caret` as
807/// sibling fields and paired them correctly at six mutation sites by
808/// convention; `escriba-mode`'s ex-line held the same two fields and got it
809/// wrong at the seventh — `clear()` emptied the text and stranded the caret
810/// past the end, reported by nothing louder than `warning: unused variable:
811/// caret`. Neither crate can see the other, so the shared primitive has
812/// exactly one legal home: beneath both, next to the offsets and the ruler it
813/// is made of.
814///
815/// Deliberately carries no serde: a wire name like `minibuffer` is a
816/// consumer's business, and a positioning primitive should not know it.
817#[derive(Debug, Clone, Default, PartialEq, Eq)]
818pub struct CaretLine {
819    text: String,
820    /// Where the next typed character goes, in CHARS from the start.
821    ///
822    /// Chars, never bytes — this is text a human edits, and a byte caret lands
823    /// mid-codepoint the first time someone types `héllo`.
824    caret: usize,
825}
826
827impl CaretLine {
828    /// Build from parts, clamping the caret into range.
829    ///
830    /// The one constructor that takes a caret from outside, so a deserializer
831    /// or a test cannot introduce a value the methods would then preserve.
832    #[must_use]
833    pub fn new(text: String, caret: usize) -> Self {
834        let caret = caret.min(text.chars().count());
835        Self { text, caret }
836    }
837
838    /// The text typed so far, without the leading `:`.
839    #[must_use]
840    pub fn text(&self) -> &str {
841        &self.text
842    }
843
844    /// The caret, in chars from the start.
845    #[must_use]
846    pub const fn caret(&self) -> usize {
847        self.caret
848    }
849
850    /// Length in chars — the caret's upper bound.
851    #[must_use]
852    pub fn len_chars(&self) -> usize {
853        self.text.chars().count()
854    }
855
856    /// The caret as a BYTE index, for string surgery.
857    ///
858    /// The one place the ex-line turns chars into bytes, and it delegates to
859    /// `Ruler` rather than a local `char_indices().nth()` — which is what it
860    /// was for exactly one commit. That local version was the FOURTH
861    /// hand-rolled copy of this conversion in the workspace, written inside
862    /// the crate that had just taken a dependency on the vocabulary built to
863    /// hold it.
864    fn byte_of_caret(&self) -> usize {
865        Ruler::new(&self.text)
866            .to_bytes(Offset::<Chars>::new(self.caret))
867            .raw()
868    }
869
870    /// Insert a char AT the caret and step past it.
871    pub fn insert(&mut self, ch: char) {
872        let at = self.byte_of_caret();
873        self.text.insert(at, ch);
874        self.caret += 1;
875    }
876
877    /// Append a raw fragment and park the caret at the end.
878    ///
879    /// Appends rather than inserting on purpose: its caller is the command
880    /// registry's `__quit__` sentinel handshake, which is writing a fragment
881    /// the user did not type.
882    pub fn push_str(&mut self, s: &str) {
883        self.text.push_str(s);
884        self.caret = self.len_chars();
885    }
886
887    /// Move the caret.
888    pub fn move_caret(&mut self, to: CaretMove) {
889        self.caret = to.resolve(self.caret, self.len_chars());
890    }
891
892    /// Delete the char AT the caret (`<Del>`). No-op at the end of the line.
893    pub fn delete(&mut self) {
894        let at = self.byte_of_caret();
895        if at < self.text.len() {
896            self.text.remove(at);
897        }
898    }
899
900    /// Delete the char BEFORE the caret (`<BS>`), returning it.
901    ///
902    /// Deleting before the caret and deleting the tail are the same operation
903    /// only while the caret sits at the end — exactly the assumption that made
904    /// the search prompt's shadow diverge from the typed prompt.
905    pub fn backspace(&mut self) -> Option<char> {
906        if self.caret == 0 {
907            return None;
908        }
909        let at = self.byte_of_caret();
910        let prev = self.text[..at]
911            .char_indices()
912            .next_back()
913            .map_or(0, |(i, _)| i);
914        let ch = self.text.remove(prev);
915        self.caret -= 1;
916        Some(ch)
917    }
918
919    /// Empty the line AND return the caret home.
920    ///
921    /// Both halves, because they are one value. The version of this that
922    /// cleared only the text is what motivated the type.
923    pub fn clear(&mut self) {
924        self.text.clear();
925        self.caret = 0;
926    }
927
928    /// Replace the whole line, parking the caret at its end.
929    ///
930    /// For text that arrives whole rather than a character at a time — a
931    /// recalled history entry, a restored stash. The caret goes to the end
932    /// because that is where a user continues typing.
933    pub fn set_text(&mut self, text: String) {
934        self.text = text;
935        self.caret = self.len_chars();
936    }
937
938    /// `<C-w>` — delete the word before the caret.
939    ///
940    /// Trailing whitespace goes first, then the run of non-whitespace, which
941    /// is what makes a second `<C-w>` eat a whole second word rather than
942    /// only the gap between them.
943    pub fn delete_word_before(&mut self) {
944        let chars: Vec<char> = self.text.chars().collect();
945        let mut i = self.caret;
946        while i > 0 && chars[i - 1].is_whitespace() {
947            i -= 1;
948        }
949        while i > 0 && !chars[i - 1].is_whitespace() {
950            i -= 1;
951        }
952        self.text = chars[..i]
953            .iter()
954            .chain(chars[self.caret..].iter())
955            .collect();
956        self.caret = i;
957    }
958
959    /// `<C-u>` — delete from the caret back to the start of the line.
960    pub fn clear_before_caret(&mut self) {
961        let at = self.byte_of_caret();
962        self.text.drain(..at);
963        self.caret = 0;
964    }
965}
966
967#[cfg(test)]
968mod caret_line_tests {
969    use super::*;
970
971    #[test]
972    fn law_the_caret_byte_offset_agrees_with_the_hand_rolled_conversion() {
973        // `CaretLine::byte_of_caret` routes through `Ruler` rather than its own
974        // `char_indices().nth()`. This differential test pins the two as equal
975        // over multibyte text — the only place a byte/char confusion shows up.
976        //
977        // It lives here rather than in `escriba-search` because the conversion
978        // does: it was duplicated in the search prompt and (briefly) in the
979        // ex-line, and the test followed the code down.
980        for text in ["", "abc", "héllo", "日本語 foo", "🔥x🔥"] {
981            for caret in 0..=text.chars().count() {
982                let line = CaretLine::new(text.to_owned(), caret);
983                let by_hand = text
984                    .char_indices()
985                    .nth(caret)
986                    .map_or(text.len(), |(b, _)| b);
987                assert_eq!(
988                    line.byte_of_caret(),
989                    by_hand,
990                    "caret {caret} in {text:?}: Ruler disagreed with the hand-rolled map",
991                );
992            }
993        }
994    }
995
996    #[test]
997    fn law_every_mutation_preserves_the_caret_bound() {
998        // The invariant the type exists for, across the whole surface, on text
999        // where a byte caret would land mid-codepoint.
1000        let mut line = CaretLine::new("héllo 日本語".to_owned(), 3);
1001        let check = |l: &CaretLine| assert!(l.caret() <= l.len_chars(), "{l:?}");
1002
1003        line.insert('x');
1004        check(&line);
1005        line.move_caret(CaretMove::Start);
1006        check(&line);
1007        line.delete();
1008        check(&line);
1009        line.move_caret(CaretMove::End);
1010        check(&line);
1011        line.backspace();
1012        check(&line);
1013        line.delete_word_before();
1014        check(&line);
1015        line.clear_before_caret();
1016        check(&line);
1017        line.set_text("🔥🔥🔥".to_owned());
1018        check(&line);
1019        assert_eq!(line.caret(), 3, "set_text parks the caret at the end");
1020        line.clear();
1021        check(&line);
1022        assert_eq!(line.caret(), 0, "and clear brings it home");
1023    }
1024
1025    #[test]
1026    fn law_a_caret_past_the_end_is_clamped_by_the_constructor() {
1027        // The one door a caret enters through from outside.
1028        assert_eq!(CaretLine::new("ab".to_owned(), 99).caret(), 2);
1029        assert_eq!(CaretLine::new(String::new(), 7).caret(), 0);
1030    }
1031
1032    #[test]
1033    fn law_a_second_word_delete_eats_a_whole_word_not_just_the_gap() {
1034        // The behaviour that makes `<C-w>` usable, and why the whitespace run
1035        // is consumed before the word run.
1036        let mut line = CaretLine::new("foo bar baz".to_owned(), 11);
1037        line.delete_word_before();
1038        assert_eq!(line.text(), "foo bar ");
1039        line.delete_word_before();
1040        assert_eq!(line.text(), "foo ");
1041    }
1042}