escriba-memori 0.1.46

memori (目盛) — the positioning vocabulary: scale-tagged offsets, endpoint bounds, and freshness. A leaf crate with zero dependencies, so every layer that manipulates a position can depend on it.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
//! memori (目盛) — the graduation marks an offset was counted in.
//!
//! # The problem this vocabulary corners
//!
//! An offset into text is a `usize`, and a `usize` says nothing about **which
//! ruler it was measured on**. escriba measures on three, and they disagree on
//! every non-ASCII character:
//!
//! | scale | `"héllo"` end | who demands it |
//! |---|---|---|
//! | bytes | 6 | `regex`, every `&str` index |
//! | chars | 5 | escriba's own `Position`/match offsets |
//! | UTF-16 units | 5 | LSP, and anything speaking to an editor client |
//!
//! Passing one where another is expected compiles perfectly and is wrong only
//! for users with non-ASCII text — the worst possible failure profile, because
//! it survives every test written in English.
//!
//! Five distinct bugs in one session were this class. That is the signal a
//! primitive is announcing itself rather than five tasks:
//!
//! 1. a commit stepping from the wrong anchor,
//! 2. `saturating_sub(1)` making offset 0 unreachable,
//! 3. match offsets used against text that had since been edited,
//! 4. byte/char/UTF-16 conversion correct only because one function guarded it,
//! 5. an exclusive-vs-inclusive endpoint chosen by picking a function name.
//!
//! # The three axes, made orthogonal
//!
//! - **Scale** — [`Offset<S>`] is phantom-tagged, so `Offset<Bytes>` and
//!   `Offset<Chars>` are different types and mixing them is a compile error.
//!   Conversion is possible *only* through a [`Ruler`], which cannot exist
//!   without the text it measures.
//! - **Bound** — [`Bound`] makes "does the endpoint count itself?" a value the
//!   caller states, not a function name they must pick correctly, and not an
//!   arithmetic fudge at the call site.
//! - **Freshness** — [`Anchored`] carries the [`EditGen`] an offset was
//!   computed against, so using it after an edit is a `None` rather than a
//!   silently wrong column.
//!
//! # Tier honesty
//!
//! Scale-mixing is **truly unrepresentable** (a type error, `E0308`). Bound and
//! freshness are **parse-time-rejected** at this border — you can still build a
//! `Ruler` for the wrong text. The full ledger is in `docs/memori.md`.
//!
//! The `(defmemori …)` tatara-lisp surface is a NAMED FOLLOW-UP, not shipped.
//! This crate is the typed Rust border only.
//!
//! # Why a leaf crate
//!
//! It has **no dependencies, deliberately**. `escriba-core` imports
//! `escriba_search::Direction`, so core depends on SEARCH — and a positioning
//! primitive living in core would be invisible to the search engine, which is
//! where the `step`/`step_inclusive` twins [`Bound`] exists to replace live.
//! The vocabulary has to sit below both, so it does.

use core::marker::PhantomData;

// ── scales ───────────────────────────────────────────────────────────────

/// A unit an offset can be counted in.
///
/// Sealed by construction: the three implementors below are the only ones, and
/// the trait's only members are constants, so a consumer cannot invent a
/// fourth scale that conversion does not handle.
pub trait Scale: Copy + core::fmt::Debug {
    /// How this scale names itself in a diagnostic.
    const NAME: &'static str;
}

/// UTF-8 bytes — what `&str` indexing and the `regex` crate speak.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct Bytes;
/// Unicode scalar values — what escriba's own `Position` and match offsets use.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Default)]
pub struct Chars;
/// UTF-16 code units — what LSP specifies, and nothing else.
#[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";
}

/// An offset counted in a specific [`Scale`].
///
/// The phantom parameter is the whole point: `Offset<Bytes>` and
/// `Offset<Chars>` are distinct types, so the substitution that produced a
/// wrong column for every non-ASCII user is now `E0308`.
///
/// `raw()` is deliberately the ONLY way out. Reaching for it is the moment to
/// ask which scale the consumer wants, which is exactly the question the bare
/// `usize` let everyone skip.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Offset<S: Scale> {
    raw: usize,
    _scale: PhantomData<S>,
}

// Derived `Clone`/`Copy` would demand `S: Clone`, which is noise on a phantom.
impl<S: Scale> Clone for Offset<S> {
    fn clone(&self) -> Self {
        *self
    }
}
impl<S: Scale> Copy for Offset<S> {}

impl<S: Scale> Offset<S> {
    /// The start of the text, in any scale. Always valid.
    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
    }

    /// Which ruler this was counted on — for diagnostics.
    #[must_use]
    pub const fn scale_name() -> &'static str {
        S::NAME
    }

    /// Move forward within the SAME scale. Saturating, so it cannot wrap.
    #[must_use]
    pub const fn advance(self, by: usize) -> Self {
        Self::new(self.raw.saturating_add(by))
    }
}

// ── bounds ───────────────────────────────────────────────────────────────

/// Does a search from an anchor consider the anchor itself?
///
/// This existed as a choice between two function names (`step` vs
/// `step_inclusive`) plus, at one call site, a `saturating_sub(1)` that tried
/// to convert one into the other by arithmetic. Both mistakes are the same
/// mistake, and both are removed by making the bound a value:
///
/// - naming it forces the caller to state intent instead of remembering which
///   function is which;
/// - [`Bound::first_matching`] never subtracts, so the "back up one to include
///   the anchor" trick — which cannot back up past 0, and therefore made a
///   match at offset 0 unreachable — has nowhere to live.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum Bound {
    /// The anchor counts. `/foo` sitting ON a `foo` finds that one.
    #[default]
    Inclusive,
    /// The anchor does not count. `n` advances off the current match.
    Exclusive,
}

impl Bound {
    /// Does `candidate` lie at-or-after `anchor` under this bound?
    #[must_use]
    pub const fn admits_forward(self, candidate: usize, anchor: usize) -> bool {
        match self {
            Self::Inclusive => candidate >= anchor,
            Self::Exclusive => candidate > anchor,
        }
    }

    /// Does `candidate` lie at-or-before `anchor` under this bound?
    #[must_use]
    pub const fn admits_backward(self, candidate: usize, anchor: usize) -> bool {
        match self {
            Self::Inclusive => candidate <= anchor,
            Self::Exclusive => candidate < anchor,
        }
    }

    /// The index of the first ascending `starts` entry this bound admits,
    /// searching forward from `anchor`.
    ///
    /// **Total, and correct at 0 by construction** — there is no subtraction
    /// to saturate. That is the seal on the measured bug: an inclusive search
    /// used to be spelled `step(from - 1)`, and `0 - 1` saturates back to `0`,
    /// so a match at the very start of the file could never be found.
    #[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))
        }
    }
}

/// Whether a step ran off the end and resumed at the other one.
///
/// Reported, never silent. Wrapping without saying so is how a reader loses
/// track of where they are in a long file — vim prints "search hit BOTTOM,
/// continuing at TOP" for exactly this reason.
///
/// Lifted here from `escriba-search` when result-list navigation became the
/// second consumer: the wrap and the way it is ANNOUNCED are one behaviour,
/// and two copies would be two chances to stop announcing it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Wrapped {
    #[default]
    No,
    /// Ran past the end, resumed at the top.
    AtBottom,
    /// Ran past the start, resumed at the bottom.
    AtTop,
}

impl Wrapped {
    #[must_use]
    pub const fn happened(self) -> bool {
        !matches!(self, Self::No)
    }
}

/// Where a wrapping step landed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Landing {
    /// Index into the sorted starts that were stepped over.
    pub index: usize,
    pub wrapped: Wrapped,
}

impl Bound {
    /// [`first_matching`](Self::first_matching), wrapping at the ends.
    ///
    /// `first_matching` answers "what is next" and returns `None` at the end.
    /// Both callers want "what is next, wrapping" — and both also want to
    /// TELL the reader that it wrapped. Doing it here means search and result
    /// navigation cannot drift into wrapping differently, or into one of them
    /// going quiet about it.
    ///
    /// `None` only when `starts` is empty: with anything to land on, a
    /// wrapping step always lands.
    #[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,
            }),
        }
    }
}

/// Where a caret movement lands.
///
/// Lives in memori because it is a POSITIONING concept, not a search one: the
/// search prompt and the ex-line both have a caret and both need the same
/// closed set of moves. It started in `escriba-search`, which meant
/// `escriba-mode` could not reach it without depending on the search engine to
/// move a cursor in a text box.
///
/// A closed set, so "move the caret" cannot mean an unhandled direction. Each
/// resolves against the CURRENT length, so none can leave the caret out of
/// bounds — the clamping is in one place rather than at each call site.
#[derive(
    Clone,
    Copy,
    Debug,
    PartialEq,
    Eq,
    Hash,
    Default,
    serde::Serialize,
    serde::Deserialize,
    schemars::JsonSchema,
)]
pub enum CaretMove {
    #[default]
    Left,
    Right,
    Start,
    End,
}

impl CaretMove {
    /// Total, and saturating at both ends.
    #[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,
        }
    }
}

// ── freshness ────────────────────────────────────────────────────────────

/// A value computed against a specific edit generation.
///
/// An offset is a claim about text. When the text changes the claim expires,
/// and the expiry is invisible: the number is still a number, and it still
/// indexes *something*. `SearchState::refresh` existed to prevent exactly this
/// and had zero callers for as long as it existed, which is the argument for
/// making staleness a type rather than a discipline.
///
/// [`Anchored::get`] takes the CURRENT generation and returns `None` when they
/// disagree, so a stale read is a visible absence instead of a wrong column.
#[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 }
    }

    /// The value, if it was computed against `now`.
    #[must_use]
    pub fn get(&self, now: G) -> Option<&T> {
        (self.at == now).then_some(&self.value)
    }

    /// The generation this was computed against.
    #[must_use]
    pub const fn generation(&self) -> G {
        self.at
    }

    /// Read the value regardless of freshness.
    ///
    /// Named to be conspicuous at the call site: reaching for it is a claim
    /// that staleness does not matter here, and that claim should be visible
    /// in review rather than hidden behind a plain accessor.
    #[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
    }
}

// ── the ruler ────────────────────────────────────────────────────────────

/// Converts between scales for one specific text.
///
/// A `Ruler` cannot be built without the text, which is the structural reason
/// a scale conversion can never be done "in general": there is no such thing.
/// `"héllo".len()` is 6 bytes and 5 chars, and only the string knows.
#[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 }
    }

    /// The text's end, in bytes.
    #[must_use]
    pub const fn end_bytes(&self) -> Offset<Bytes> {
        Offset::new(self.text.len())
    }

    /// The text's end, in chars.
    #[must_use]
    pub fn end_chars(&self) -> Offset<Chars> {
        Offset::new(self.text.chars().count())
    }

    /// Clamp into the text and snap DOWN to a character boundary.
    ///
    /// Snapping down, not up, keeps the result inside the character the offset
    /// pointed at — where an editor should underline. Mid-codepoint offsets are
    /// a NORMAL arrival, not corruption: they come from parser error spans over
    /// a buffer the user is halfway through typing a character into. Measured
    /// 2026-08-01: `analyse("🔥🔥🔥")` aborted on exactly this at offset 1.
    #[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)
    }

    /// Bytes → chars. Total: out-of-range and mid-codepoint inputs snap first.
    #[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())
    }

    /// Chars → bytes. Total: past-the-end saturates to the text's end.
    #[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))
    }

    /// Bytes → UTF-16 code units, for LSP.
    ///
    /// Separate from [`Self::to_chars`] because they differ, and the
    /// difference is invisible until a user types an emoji: `🔥` is ONE char
    /// and TWO UTF-16 units. Conflating them shifts every position after it.
    #[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> {
    /// A forward-only reader for offsets visited in ASCENDING order.
    ///
    /// [`Ruler::to_chars`] is O(n) per call — `text[..b].chars().count()`
    /// re-walks from the start every time — because it must be TOTAL and
    /// random-access. That is right for a caret, and wrong for converting
    /// every match in a document: O(n) per call over m matches is O(n·m),
    /// which is the cost `escriba-search` originally avoided by building a
    /// dense `usize`-per-byte map.
    ///
    /// This is the third option, better than both: O(n + m) total with O(1)
    /// extra memory, because the offsets arrive in order and the scan never
    /// needs to look back. The dense map allocated and zeroed EIGHT BYTES PER
    /// DOCUMENT BYTE on every keystroke of an incremental search; this
    /// allocates nothing.
    #[must_use]
    pub const fn ascending(&self) -> AscendingScan<'a> {
        AscendingScan {
            text: self.text,
            byte: 0,
            chars: 0,
        }
    }
}

/// A forward-only byte→char converter for ascending offsets.
///
/// Built by [`Ruler::ascending`]. Holds a cursor into the text and the number
/// of chars behind it, so each query advances rather than restarts.
#[derive(Debug, Clone)]
pub struct AscendingScan<'a> {
    text: &'a str,
    byte: usize,
    chars: usize,
}

impl AscendingScan<'_> {
    /// Convert `at` to chars, advancing the scan.
    ///
    /// `at` must be >= the previous argument. Going backwards is a programming
    /// error and `debug_assert`s in test builds; in release the scan SATURATES
    /// (returns its current position) rather than panicking or silently
    /// producing a smaller number for a larger offset — an editor should not
    /// abort mid-frame over a monotonicity slip.
    ///
    /// Mid-codepoint and past-the-end offsets snap DOWN, exactly as
    /// [`Ruler::to_chars`] does, so the two agree everywhere.
    pub fn to_chars(&mut self, at: Offset<Bytes>) -> Offset<Chars> {
        // Snap DOWN to a char boundary first, exactly as `Ruler::to_chars`
        // does. Without this the two paths disagree on every mid-codepoint
        // offset — and slicing at a non-boundary panics outright, so the
        // differential law caught it as a failure rather than a wrong number.
        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);
        }
        // Count the chars between the cursor and the target. Every byte is
        // visited at most once across the whole scan, which is what makes the
        // total O(n) rather than O(n) per call.
        self.chars += self.text[self.byte..target].chars().count();
        self.byte = target;
        Offset::new(self.chars)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Texts that have broken position code before. Every law runs over all of
    /// them, so a law that only holds for ASCII cannot pass.
    const CORPUS: &[&str] = &[
        "",
        "a",
        "hello world",
        "héllo",      // 2-byte char
        "日本語 foo", // 3-byte chars
        "🔥🔥🔥",     // 4-byte, 2 UTF-16 units each
        "a\nb\nc",
        "x🔥y",
    ];

    // ── scale separation ────────────────────────────────────────────────

    #[test]
    fn law_the_three_scales_disagree_and_the_ruler_knows_it() {
        // If this ever passes trivially the corpus has lost its teeth.
        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() {
        // A later byte can never map to an earlier char. Violating this is how
        // highlights end up crossing over each other.
        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;
            }
        }
    }

    // ── snapping ────────────────────────────────────────────────────────

    #[test]
    fn law_snap_is_total_and_idempotent_over_every_byte() {
        // Every offset, including mid-codepoint and past-the-end, must land on
        // a boundary — and snapping twice must not move again.
        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() {
        // Snapping UP would report a position inside the NEXT character.
        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() {
        // The measured crash: `analyse("🔥🔥🔥")` aborted at offset 1.
        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);
    }

    // ── bounds: the seal on the saturating_sub bug ──────────────────────

    #[test]
    fn law_an_inclusive_forward_search_finds_a_match_at_zero() {
        // THE regression. The old spelling was `step(from - 1)`, and `0 - 1`
        // saturates to `0`, so a match at the start of the file was
        // unreachable. There is no subtraction here to saturate.
        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() {
        // Anchor 0 in both directions, on an empty and a full list.
        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);
        }
    }

    // ── freshness ───────────────────────────────────────────────────────

    #[test]
    fn law_a_value_from_an_older_generation_reads_as_absent() {
        // Any `PartialEq + Copy` works as a generation — memori does not care
        // WHICH counter, only that two of them can disagree.
        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));
        // The escape hatch still works, and is named so review can see it.
        assert_eq!(a.get_possibly_stale().raw(), 7);
    }

    #[test]
    fn law_freshness_is_not_ordering() {
        // A value from a LATER generation is just as unusable as an older one —
        // it is a mismatch, not a comparison. Treating it as "newer, so fine"
        // is how a stale read sneaks back in.
        let a = Anchored::new(1_u8, 1_u64);
        assert_eq!(a.get(0_u64), None);
    }

    // ── the compile-time seal, demonstrated ─────────────────────────────

    /// Scale mixing is a TYPE error, which a runtime test cannot observe. This
    /// documents the seal and keeps the constructors honest; the proof is in
    /// `docs/memori.md`, which records the exact `E0308` from a deliberate
    /// violation.
    #[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, c)` does not compile: expected `Offset<Bytes>`,
        // found `Offset<Chars>`.
        assert_eq!(b.raw(), 6);
        assert_eq!(c.raw(), 5);
    }

    // ── the ascending scan ──────────────────────────────────────────────

    #[test]
    fn law_an_ascending_scan_agrees_with_to_chars_at_every_offset() {
        // The differential law: the bulk path and the scalar path must give
        // the same answer for every byte of every corpus entry. If they can
        // disagree anywhere, the retrofit is a silent corruption.
        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() {
        // Offsets inside a multi-byte char are a normal arrival; both paths
        // must land on the same boundary.
        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() {
        // The property that makes it O(n + m): querying every offset in order
        // must not re-walk. Asserted structurally — the cursor only advances.
        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() {
        // Release behaviour: a monotonicity slip must not produce a SMALLER
        // char offset for a LARGER byte offset, and must not abort a frame.
        // (Debug builds assert first, so this documents the release contract.)
        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);
    }
}

/// A line of text plus the caret editing it, as ONE value.
///
/// They are one value because the invariant lives *between* them —
/// `caret <= text.chars().count()` — and a struct with private fields is the
/// only place such an invariant can be maintained once rather than at every
/// mutation site.
///
/// # Why this is in memori rather than in either editor crate
///
/// It was written twice. `escriba-search`'s `Prompt` held `text` + `caret` as
/// sibling fields and paired them correctly at six mutation sites by
/// convention; `escriba-mode`'s ex-line held the same two fields and got it
/// wrong at the seventh — `clear()` emptied the text and stranded the caret
/// past the end, reported by nothing louder than `warning: unused variable:
/// caret`. Neither crate can see the other, so the shared primitive has
/// exactly one legal home: beneath both, next to the offsets and the ruler it
/// is made of.
///
/// Deliberately carries no serde: a wire name like `minibuffer` is a
/// consumer's business, and a positioning primitive should not know it.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct CaretLine {
    text: String,
    /// Where the next typed character goes, in CHARS from the start.
    ///
    /// Chars, never bytes — this is text a human edits, and a byte caret lands
    /// mid-codepoint the first time someone types `héllo`.
    caret: usize,
}

impl CaretLine {
    /// Build from parts, clamping the caret into range.
    ///
    /// The one constructor that takes a caret from outside, so a deserializer
    /// or a test cannot introduce a value the methods would then preserve.
    #[must_use]
    pub fn new(text: String, caret: usize) -> Self {
        let caret = caret.min(text.chars().count());
        Self { text, caret }
    }

    /// The text typed so far, without the leading `:`.
    #[must_use]
    pub fn text(&self) -> &str {
        &self.text
    }

    /// The caret, in chars from the start.
    #[must_use]
    pub const fn caret(&self) -> usize {
        self.caret
    }

    /// Length in chars — the caret's upper bound.
    #[must_use]
    pub fn len_chars(&self) -> usize {
        self.text.chars().count()
    }

    /// The caret as a BYTE index, for string surgery.
    ///
    /// The one place the ex-line turns chars into bytes, and it delegates to
    /// `Ruler` rather than a local `char_indices().nth()` — which is what it
    /// was for exactly one commit. That local version was the FOURTH
    /// hand-rolled copy of this conversion in the workspace, written inside
    /// the crate that had just taken a dependency on the vocabulary built to
    /// hold it.
    fn byte_of_caret(&self) -> usize {
        Ruler::new(&self.text)
            .to_bytes(Offset::<Chars>::new(self.caret))
            .raw()
    }

    /// Insert a char AT the caret and step past it.
    pub fn insert(&mut self, ch: char) {
        let at = self.byte_of_caret();
        self.text.insert(at, ch);
        self.caret += 1;
    }

    /// Append a raw fragment and park the caret at the end.
    ///
    /// Appends rather than inserting on purpose: its caller is the command
    /// registry's `__quit__` sentinel handshake, which is writing a fragment
    /// the user did not type.
    pub fn push_str(&mut self, s: &str) {
        self.text.push_str(s);
        self.caret = self.len_chars();
    }

    /// Move the caret.
    pub fn move_caret(&mut self, to: CaretMove) {
        self.caret = to.resolve(self.caret, self.len_chars());
    }

    /// Delete the char AT the caret (`<Del>`). No-op at the end of the line.
    pub fn delete(&mut self) {
        let at = self.byte_of_caret();
        if at < self.text.len() {
            self.text.remove(at);
        }
    }

    /// Delete the char BEFORE the caret (`<BS>`), returning it.
    ///
    /// Deleting before the caret and deleting the tail are the same operation
    /// only while the caret sits at the end — exactly the assumption that made
    /// the search prompt's shadow diverge from the typed prompt.
    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)
    }

    /// Empty the line AND return the caret home.
    ///
    /// Both halves, because they are one value. The version of this that
    /// cleared only the text is what motivated the type.
    pub fn clear(&mut self) {
        self.text.clear();
        self.caret = 0;
    }

    /// Replace the whole line, parking the caret at its end.
    ///
    /// For text that arrives whole rather than a character at a time — a
    /// recalled history entry, a restored stash. The caret goes to the end
    /// because that is where a user continues typing.
    pub fn set_text(&mut self, text: String) {
        self.text = text;
        self.caret = self.len_chars();
    }

    /// `<C-w>` — delete the word before the caret.
    ///
    /// Trailing whitespace goes first, then the run of non-whitespace, which
    /// is what makes a second `<C-w>` eat a whole second word rather than
    /// only the gap between them.
    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;
    }

    /// `<C-u>` — delete from the caret back to the start of the line.
    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() {
        // `CaretLine::byte_of_caret` routes through `Ruler` rather than its own
        // `char_indices().nth()`. This differential test pins the two as equal
        // over multibyte text — the only place a byte/char confusion shows up.
        //
        // It lives here rather than in `escriba-search` because the conversion
        // does: it was duplicated in the search prompt and (briefly) in the
        // ex-line, and the test followed the code down.
        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() {
        // The invariant the type exists for, across the whole surface, on text
        // where a byte caret would land mid-codepoint.
        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() {
        // The one door a caret enters through from outside.
        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() {
        // The behaviour that makes `<C-w>` usable, and why the whitespace run
        // is consumed before the word run.
        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 ");
    }
}