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// ── freshness ────────────────────────────────────────────────────────────
210
211/// A value computed against a specific edit generation.
212///
213/// An offset is a claim about text. When the text changes the claim expires,
214/// and the expiry is invisible: the number is still a number, and it still
215/// indexes *something*. `SearchState::refresh` existed to prevent exactly this
216/// and had zero callers for as long as it existed, which is the argument for
217/// making staleness a type rather than a discipline.
218///
219/// [`Anchored::get`] takes the CURRENT generation and returns `None` when they
220/// disagree, so a stale read is a visible absence instead of a wrong column.
221#[derive(Clone, Copy, Debug, PartialEq, Eq)]
222pub struct Anchored<T, G: PartialEq + Copy> {
223    value: T,
224    at: G,
225}
226
227impl<T, G: PartialEq + Copy> Anchored<T, G> {
228    #[must_use]
229    pub const fn new(value: T, at: G) -> Self {
230        Self { value, at }
231    }
232
233    /// The value, if it was computed against `now`.
234    #[must_use]
235    pub fn get(&self, now: G) -> Option<&T> {
236        (self.at == now).then_some(&self.value)
237    }
238
239    /// The generation this was computed against.
240    #[must_use]
241    pub const fn generation(&self) -> G {
242        self.at
243    }
244
245    /// Read the value regardless of freshness.
246    ///
247    /// Named to be conspicuous at the call site: reaching for it is a claim
248    /// that staleness does not matter here, and that claim should be visible
249    /// in review rather than hidden behind a plain accessor.
250    #[must_use]
251    pub const fn get_possibly_stale(&self) -> &T {
252        &self.value
253    }
254
255    #[must_use]
256    pub fn is_fresh(&self, now: G) -> bool {
257        self.at == now
258    }
259}
260
261// ── the ruler ────────────────────────────────────────────────────────────
262
263/// Converts between scales for one specific text.
264///
265/// A `Ruler` cannot be built without the text, which is the structural reason
266/// a scale conversion can never be done "in general": there is no such thing.
267/// `"héllo".len()` is 6 bytes and 5 chars, and only the string knows.
268#[derive(Debug, Clone, Copy)]
269pub struct Ruler<'a> {
270    text: &'a str,
271}
272
273impl<'a> Ruler<'a> {
274    #[must_use]
275    pub const fn new(text: &'a str) -> Self {
276        Self { text }
277    }
278
279    /// The text's end, in bytes.
280    #[must_use]
281    pub const fn end_bytes(&self) -> Offset<Bytes> {
282        Offset::new(self.text.len())
283    }
284
285    /// The text's end, in chars.
286    #[must_use]
287    pub fn end_chars(&self) -> Offset<Chars> {
288        Offset::new(self.text.chars().count())
289    }
290
291    /// Clamp into the text and snap DOWN to a character boundary.
292    ///
293    /// Snapping down, not up, keeps the result inside the character the offset
294    /// pointed at — where an editor should underline. Mid-codepoint offsets are
295    /// a NORMAL arrival, not corruption: they come from parser error spans over
296    /// a buffer the user is halfway through typing a character into. Measured
297    /// 2026-08-01: `analyse("🔥🔥🔥")` aborted on exactly this at offset 1.
298    #[must_use]
299    pub fn snap(&self, at: Offset<Bytes>) -> Offset<Bytes> {
300        let mut raw = at.raw().min(self.text.len());
301        while raw > 0 && !self.text.is_char_boundary(raw) {
302            raw -= 1;
303        }
304        Offset::new(raw)
305    }
306
307    /// Bytes → chars. Total: out-of-range and mid-codepoint inputs snap first.
308    #[must_use]
309    pub fn to_chars(&self, at: Offset<Bytes>) -> Offset<Chars> {
310        let b = self.snap(at).raw();
311        Offset::new(self.text[..b].chars().count())
312    }
313
314    /// Chars → bytes. Total: past-the-end saturates to the text's end.
315    #[must_use]
316    pub fn to_bytes(&self, at: Offset<Chars>) -> Offset<Bytes> {
317        self.text
318            .char_indices()
319            .nth(at.raw())
320            .map_or_else(|| self.end_bytes(), |(b, _)| Offset::new(b))
321    }
322
323    /// Bytes → UTF-16 code units, for LSP.
324    ///
325    /// Separate from [`Self::to_chars`] because they differ, and the
326    /// difference is invisible until a user types an emoji: `🔥` is ONE char
327    /// and TWO UTF-16 units. Conflating them shifts every position after it.
328    #[must_use]
329    pub fn to_utf16(&self, at: Offset<Bytes>) -> Offset<Utf16Units> {
330        let b = self.snap(at).raw();
331        Offset::new(self.text[..b].chars().map(char::len_utf16).sum())
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    /// Texts that have broken position code before. Every law runs over all of
340    /// them, so a law that only holds for ASCII cannot pass.
341    const CORPUS: &[&str] = &[
342        "",
343        "a",
344        "hello world",
345        "héllo",      // 2-byte char
346        "日本語 foo", // 3-byte chars
347        "🔥🔥🔥",     // 4-byte, 2 UTF-16 units each
348        "a\nb\nc",
349        "x🔥y",
350    ];
351
352    // ── scale separation ────────────────────────────────────────────────
353
354    #[test]
355    fn law_the_three_scales_disagree_and_the_ruler_knows_it() {
356        // If this ever passes trivially the corpus has lost its teeth.
357        let r = Ruler::new("héllo");
358        let end = r.end_bytes();
359        assert_eq!(end.raw(), 6, "bytes");
360        assert_eq!(r.to_chars(end).raw(), 5, "chars");
361        assert_eq!(r.to_utf16(end).raw(), 5, "utf16");
362
363        let r = Ruler::new("🔥");
364        let end = r.end_bytes();
365        assert_eq!(end.raw(), 4, "bytes");
366        assert_eq!(r.to_chars(end).raw(), 1, "chars");
367        assert_eq!(r.to_utf16(end).raw(), 2, "utf16 — a surrogate pair");
368    }
369
370    #[test]
371    fn law_byte_char_roundtrip_is_identity_on_boundaries() {
372        for text in CORPUS {
373            let r = Ruler::new(text);
374            for (b, _) in text
375                .char_indices()
376                .chain(core::iter::once((text.len(), ' ')))
377            {
378                let start = Offset::<Bytes>::new(b);
379                let round = r.to_bytes(r.to_chars(start));
380                assert_eq!(round, start, "roundtrip failed at {b} in {text:?}");
381            }
382        }
383    }
384
385    #[test]
386    fn law_conversion_is_monotonic() {
387        // A later byte can never map to an earlier char. Violating this is how
388        // highlights end up crossing over each other.
389        for text in CORPUS {
390            let r = Ruler::new(text);
391            let mut prev = 0;
392            for b in 0..=text.len() {
393                let c = r.to_chars(Offset::new(b)).raw();
394                assert!(c >= prev, "non-monotonic at {b} in {text:?}");
395                prev = c;
396            }
397        }
398    }
399
400    // ── snapping ────────────────────────────────────────────────────────
401
402    #[test]
403    fn law_snap_is_total_and_idempotent_over_every_byte() {
404        // Every offset, including mid-codepoint and past-the-end, must land on
405        // a boundary — and snapping twice must not move again.
406        for text in CORPUS {
407            let r = Ruler::new(text);
408            for b in 0..=text.len() + 5 {
409                let once = r.snap(Offset::new(b));
410                assert!(
411                    text.is_char_boundary(once.raw()),
412                    "snap({b}) left {once:?} mid-codepoint in {text:?}",
413                );
414                assert_eq!(r.snap(once), once, "snap not idempotent at {b}");
415            }
416        }
417    }
418
419    #[test]
420    fn law_snap_never_moves_forward() {
421        // Snapping UP would report a position inside the NEXT character.
422        for text in CORPUS {
423            let r = Ruler::new(text);
424            for b in 0..=text.len() {
425                assert!(r.snap(Offset::new(b)).raw() <= b, "moved forward at {b}");
426            }
427        }
428    }
429
430    #[test]
431    fn a_mid_codepoint_offset_does_not_panic() {
432        // The measured crash: `analyse("🔥🔥🔥")` aborted at offset 1.
433        let r = Ruler::new("🔥🔥🔥");
434        assert_eq!(r.snap(Offset::new(1)).raw(), 0);
435        assert_eq!(r.to_chars(Offset::new(1)).raw(), 0);
436        assert_eq!(r.to_utf16(Offset::new(1)).raw(), 0);
437    }
438
439    // ── bounds: the seal on the saturating_sub bug ──────────────────────
440
441    #[test]
442    fn law_an_inclusive_forward_search_finds_a_match_at_zero() {
443        // THE regression. The old spelling was `step(from - 1)`, and `0 - 1`
444        // saturates to `0`, so a match at the start of the file was
445        // unreachable. There is no subtraction here to saturate.
446        let starts = [0_usize, 10, 20];
447        assert_eq!(
448            Bound::Inclusive.first_matching(&starts, 0, true),
449            Some(0),
450            "an inclusive search from 0 must find the match AT 0",
451        );
452        assert_eq!(
453            Bound::Exclusive.first_matching(&starts, 0, true),
454            Some(1),
455            "an exclusive search from 0 must skip it",
456        );
457    }
458
459    #[test]
460    fn law_the_two_bounds_differ_only_at_the_anchor() {
461        let starts = [0_usize, 5, 9];
462        for anchor in 0..12 {
463            let inc = Bound::Inclusive.first_matching(&starts, anchor, true);
464            let exc = Bound::Exclusive.first_matching(&starts, anchor, true);
465            if starts.contains(&anchor) {
466                assert_ne!(inc, exc, "must differ when the anchor IS a match");
467            } else {
468                assert_eq!(inc, exc, "must agree when the anchor is not a match");
469            }
470        }
471    }
472
473    #[test]
474    fn law_backward_is_the_mirror_of_forward() {
475        let starts = [0_usize, 5, 9];
476        assert_eq!(Bound::Inclusive.first_matching(&starts, 5, false), Some(1));
477        assert_eq!(Bound::Exclusive.first_matching(&starts, 5, false), Some(0));
478        assert_eq!(
479            Bound::Exclusive.first_matching(&starts, 0, false),
480            None,
481            "nothing lies strictly before the first match",
482        );
483    }
484
485    #[test]
486    fn law_no_bound_ever_underflows() {
487        // Anchor 0 in both directions, on an empty and a full list.
488        for b in [Bound::Inclusive, Bound::Exclusive] {
489            assert_eq!(b.first_matching(&[], 0, true), None);
490            assert_eq!(b.first_matching(&[], 0, false), None);
491            let _ = b.first_matching(&[0], 0, true);
492            let _ = b.first_matching(&[0], 0, false);
493        }
494    }
495
496    // ── freshness ───────────────────────────────────────────────────────
497
498    #[test]
499    fn law_a_value_from_an_older_generation_reads_as_absent() {
500        // Any `PartialEq + Copy` works as a generation — memori does not care
501        // WHICH counter, only that two of them can disagree.
502        let (g0, g1) = (0_u64, 1_u64);
503
504        let a = Anchored::new(Offset::<Chars>::new(7), g0);
505        assert_eq!(a.get(g0), Some(&Offset::new(7)), "fresh");
506        assert_eq!(
507            a.get(g1),
508            None,
509            "stale reads as absent, not as a wrong number"
510        );
511        assert!(!a.is_fresh(g1));
512        // The escape hatch still works, and is named so review can see it.
513        assert_eq!(a.get_possibly_stale().raw(), 7);
514    }
515
516    #[test]
517    fn law_freshness_is_not_ordering() {
518        // A value from a LATER generation is just as unusable as an older one —
519        // it is a mismatch, not a comparison. Treating it as "newer, so fine"
520        // is how a stale read sneaks back in.
521        let a = Anchored::new(1_u8, 1_u64);
522        assert_eq!(a.get(0_u64), None);
523    }
524
525    // ── the compile-time seal, demonstrated ─────────────────────────────
526
527    /// Scale mixing is a TYPE error, which a runtime test cannot observe. This
528    /// documents the seal and keeps the constructors honest; the proof is in
529    /// `docs/memori.md`, which records the exact `E0308` from a deliberate
530    /// violation.
531    #[test]
532    fn offsets_of_different_scales_are_different_types() {
533        let b = Offset::<Bytes>::new(6);
534        let c = Offset::<Chars>::new(5);
535        assert_eq!(Offset::<Bytes>::scale_name(), "bytes");
536        assert_eq!(Offset::<Chars>::scale_name(), "chars");
537        // `assert_eq!(b, c)` does not compile: expected `Offset<Bytes>`,
538        // found `Offset<Chars>`.
539        assert_eq!(b.raw(), 6);
540        assert_eq!(c.raw(), 5);
541    }
542}