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/// Where a caret movement lands.
210///
211/// Lives in memori because it is a POSITIONING concept, not a search one: the
212/// search prompt and the ex-line both have a caret and both need the same
213/// closed set of moves. It started in `escriba-search`, which meant
214/// `escriba-mode` could not reach it without depending on the search engine to
215/// move a cursor in a text box.
216///
217/// A closed set, so "move the caret" cannot mean an unhandled direction. Each
218/// resolves against the CURRENT length, so none can leave the caret out of
219/// bounds — the clamping is in one place rather than at each call site.
220#[derive(
221 Clone,
222 Copy,
223 Debug,
224 PartialEq,
225 Eq,
226 Hash,
227 Default,
228 serde::Serialize,
229 serde::Deserialize,
230 schemars::JsonSchema,
231)]
232pub enum CaretMove {
233 #[default]
234 Left,
235 Right,
236 Start,
237 End,
238}
239
240impl CaretMove {
241 /// Total, and saturating at both ends.
242 #[must_use]
243 pub const fn resolve(self, caret: usize, len: usize) -> usize {
244 match self {
245 Self::Left => caret.saturating_sub(1),
246 Self::Right => {
247 if caret < len {
248 caret + 1
249 } else {
250 len
251 }
252 }
253 Self::Start => 0,
254 Self::End => len,
255 }
256 }
257}
258
259// ── freshness ────────────────────────────────────────────────────────────
260
261/// A value computed against a specific edit generation.
262///
263/// An offset is a claim about text. When the text changes the claim expires,
264/// and the expiry is invisible: the number is still a number, and it still
265/// indexes *something*. `SearchState::refresh` existed to prevent exactly this
266/// and had zero callers for as long as it existed, which is the argument for
267/// making staleness a type rather than a discipline.
268///
269/// [`Anchored::get`] takes the CURRENT generation and returns `None` when they
270/// disagree, so a stale read is a visible absence instead of a wrong column.
271#[derive(Clone, Copy, Debug, PartialEq, Eq)]
272pub struct Anchored<T, G: PartialEq + Copy> {
273 value: T,
274 at: G,
275}
276
277impl<T, G: PartialEq + Copy> Anchored<T, G> {
278 #[must_use]
279 pub const fn new(value: T, at: G) -> Self {
280 Self { value, at }
281 }
282
283 /// The value, if it was computed against `now`.
284 #[must_use]
285 pub fn get(&self, now: G) -> Option<&T> {
286 (self.at == now).then_some(&self.value)
287 }
288
289 /// The generation this was computed against.
290 #[must_use]
291 pub const fn generation(&self) -> G {
292 self.at
293 }
294
295 /// Read the value regardless of freshness.
296 ///
297 /// Named to be conspicuous at the call site: reaching for it is a claim
298 /// that staleness does not matter here, and that claim should be visible
299 /// in review rather than hidden behind a plain accessor.
300 #[must_use]
301 pub const fn get_possibly_stale(&self) -> &T {
302 &self.value
303 }
304
305 #[must_use]
306 pub fn is_fresh(&self, now: G) -> bool {
307 self.at == now
308 }
309}
310
311// ── the ruler ────────────────────────────────────────────────────────────
312
313/// Converts between scales for one specific text.
314///
315/// A `Ruler` cannot be built without the text, which is the structural reason
316/// a scale conversion can never be done "in general": there is no such thing.
317/// `"héllo".len()` is 6 bytes and 5 chars, and only the string knows.
318#[derive(Debug, Clone, Copy)]
319pub struct Ruler<'a> {
320 text: &'a str,
321}
322
323impl<'a> Ruler<'a> {
324 #[must_use]
325 pub const fn new(text: &'a str) -> Self {
326 Self { text }
327 }
328
329 /// The text's end, in bytes.
330 #[must_use]
331 pub const fn end_bytes(&self) -> Offset<Bytes> {
332 Offset::new(self.text.len())
333 }
334
335 /// The text's end, in chars.
336 #[must_use]
337 pub fn end_chars(&self) -> Offset<Chars> {
338 Offset::new(self.text.chars().count())
339 }
340
341 /// Clamp into the text and snap DOWN to a character boundary.
342 ///
343 /// Snapping down, not up, keeps the result inside the character the offset
344 /// pointed at — where an editor should underline. Mid-codepoint offsets are
345 /// a NORMAL arrival, not corruption: they come from parser error spans over
346 /// a buffer the user is halfway through typing a character into. Measured
347 /// 2026-08-01: `analyse("🔥🔥🔥")` aborted on exactly this at offset 1.
348 #[must_use]
349 pub fn snap(&self, at: Offset<Bytes>) -> Offset<Bytes> {
350 let mut raw = at.raw().min(self.text.len());
351 while raw > 0 && !self.text.is_char_boundary(raw) {
352 raw -= 1;
353 }
354 Offset::new(raw)
355 }
356
357 /// Bytes → chars. Total: out-of-range and mid-codepoint inputs snap first.
358 #[must_use]
359 pub fn to_chars(&self, at: Offset<Bytes>) -> Offset<Chars> {
360 let b = self.snap(at).raw();
361 Offset::new(self.text[..b].chars().count())
362 }
363
364 /// Chars → bytes. Total: past-the-end saturates to the text's end.
365 #[must_use]
366 pub fn to_bytes(&self, at: Offset<Chars>) -> Offset<Bytes> {
367 self.text
368 .char_indices()
369 .nth(at.raw())
370 .map_or_else(|| self.end_bytes(), |(b, _)| Offset::new(b))
371 }
372
373 /// Bytes → UTF-16 code units, for LSP.
374 ///
375 /// Separate from [`Self::to_chars`] because they differ, and the
376 /// difference is invisible until a user types an emoji: `🔥` is ONE char
377 /// and TWO UTF-16 units. Conflating them shifts every position after it.
378 #[must_use]
379 pub fn to_utf16(&self, at: Offset<Bytes>) -> Offset<Utf16Units> {
380 let b = self.snap(at).raw();
381 Offset::new(self.text[..b].chars().map(char::len_utf16).sum())
382 }
383}
384
385impl<'a> Ruler<'a> {
386 /// A forward-only reader for offsets visited in ASCENDING order.
387 ///
388 /// [`Ruler::to_chars`] is O(n) per call — `text[..b].chars().count()`
389 /// re-walks from the start every time — because it must be TOTAL and
390 /// random-access. That is right for a caret, and wrong for converting
391 /// every match in a document: O(n) per call over m matches is O(n·m),
392 /// which is the cost `escriba-search` originally avoided by building a
393 /// dense `usize`-per-byte map.
394 ///
395 /// This is the third option, better than both: O(n + m) total with O(1)
396 /// extra memory, because the offsets arrive in order and the scan never
397 /// needs to look back. The dense map allocated and zeroed EIGHT BYTES PER
398 /// DOCUMENT BYTE on every keystroke of an incremental search; this
399 /// allocates nothing.
400 #[must_use]
401 pub const fn ascending(&self) -> AscendingScan<'a> {
402 AscendingScan {
403 text: self.text,
404 byte: 0,
405 chars: 0,
406 }
407 }
408}
409
410/// A forward-only byte→char converter for ascending offsets.
411///
412/// Built by [`Ruler::ascending`]. Holds a cursor into the text and the number
413/// of chars behind it, so each query advances rather than restarts.
414#[derive(Debug, Clone)]
415pub struct AscendingScan<'a> {
416 text: &'a str,
417 byte: usize,
418 chars: usize,
419}
420
421impl AscendingScan<'_> {
422 /// Convert `at` to chars, advancing the scan.
423 ///
424 /// `at` must be >= the previous argument. Going backwards is a programming
425 /// error and `debug_assert`s in test builds; in release the scan SATURATES
426 /// (returns its current position) rather than panicking or silently
427 /// producing a smaller number for a larger offset — an editor should not
428 /// abort mid-frame over a monotonicity slip.
429 ///
430 /// Mid-codepoint and past-the-end offsets snap DOWN, exactly as
431 /// [`Ruler::to_chars`] does, so the two agree everywhere.
432 pub fn to_chars(&mut self, at: Offset<Bytes>) -> Offset<Chars> {
433 // Snap DOWN to a char boundary first, exactly as `Ruler::to_chars`
434 // does. Without this the two paths disagree on every mid-codepoint
435 // offset — and slicing at a non-boundary panics outright, so the
436 // differential law caught it as a failure rather than a wrong number.
437 let mut target = at.raw().min(self.text.len());
438 while target > 0 && !self.text.is_char_boundary(target) {
439 target -= 1;
440 }
441 debug_assert!(
442 target >= self.byte,
443 "AscendingScan went backwards: {target} < {}",
444 self.byte,
445 );
446 if target <= self.byte {
447 return Offset::new(self.chars);
448 }
449 // Count the chars between the cursor and the target. Every byte is
450 // visited at most once across the whole scan, which is what makes the
451 // total O(n) rather than O(n) per call.
452 self.chars += self.text[self.byte..target].chars().count();
453 self.byte = target;
454 Offset::new(self.chars)
455 }
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 /// Texts that have broken position code before. Every law runs over all of
463 /// them, so a law that only holds for ASCII cannot pass.
464 const CORPUS: &[&str] = &[
465 "",
466 "a",
467 "hello world",
468 "héllo", // 2-byte char
469 "日本語 foo", // 3-byte chars
470 "🔥🔥🔥", // 4-byte, 2 UTF-16 units each
471 "a\nb\nc",
472 "x🔥y",
473 ];
474
475 // ── scale separation ────────────────────────────────────────────────
476
477 #[test]
478 fn law_the_three_scales_disagree_and_the_ruler_knows_it() {
479 // If this ever passes trivially the corpus has lost its teeth.
480 let r = Ruler::new("héllo");
481 let end = r.end_bytes();
482 assert_eq!(end.raw(), 6, "bytes");
483 assert_eq!(r.to_chars(end).raw(), 5, "chars");
484 assert_eq!(r.to_utf16(end).raw(), 5, "utf16");
485
486 let r = Ruler::new("🔥");
487 let end = r.end_bytes();
488 assert_eq!(end.raw(), 4, "bytes");
489 assert_eq!(r.to_chars(end).raw(), 1, "chars");
490 assert_eq!(r.to_utf16(end).raw(), 2, "utf16 — a surrogate pair");
491 }
492
493 #[test]
494 fn law_byte_char_roundtrip_is_identity_on_boundaries() {
495 for text in CORPUS {
496 let r = Ruler::new(text);
497 for (b, _) in text
498 .char_indices()
499 .chain(core::iter::once((text.len(), ' ')))
500 {
501 let start = Offset::<Bytes>::new(b);
502 let round = r.to_bytes(r.to_chars(start));
503 assert_eq!(round, start, "roundtrip failed at {b} in {text:?}");
504 }
505 }
506 }
507
508 #[test]
509 fn law_conversion_is_monotonic() {
510 // A later byte can never map to an earlier char. Violating this is how
511 // highlights end up crossing over each other.
512 for text in CORPUS {
513 let r = Ruler::new(text);
514 let mut prev = 0;
515 for b in 0..=text.len() {
516 let c = r.to_chars(Offset::new(b)).raw();
517 assert!(c >= prev, "non-monotonic at {b} in {text:?}");
518 prev = c;
519 }
520 }
521 }
522
523 // ── snapping ────────────────────────────────────────────────────────
524
525 #[test]
526 fn law_snap_is_total_and_idempotent_over_every_byte() {
527 // Every offset, including mid-codepoint and past-the-end, must land on
528 // a boundary — and snapping twice must not move again.
529 for text in CORPUS {
530 let r = Ruler::new(text);
531 for b in 0..=text.len() + 5 {
532 let once = r.snap(Offset::new(b));
533 assert!(
534 text.is_char_boundary(once.raw()),
535 "snap({b}) left {once:?} mid-codepoint in {text:?}",
536 );
537 assert_eq!(r.snap(once), once, "snap not idempotent at {b}");
538 }
539 }
540 }
541
542 #[test]
543 fn law_snap_never_moves_forward() {
544 // Snapping UP would report a position inside the NEXT character.
545 for text in CORPUS {
546 let r = Ruler::new(text);
547 for b in 0..=text.len() {
548 assert!(r.snap(Offset::new(b)).raw() <= b, "moved forward at {b}");
549 }
550 }
551 }
552
553 #[test]
554 fn a_mid_codepoint_offset_does_not_panic() {
555 // The measured crash: `analyse("🔥🔥🔥")` aborted at offset 1.
556 let r = Ruler::new("🔥🔥🔥");
557 assert_eq!(r.snap(Offset::new(1)).raw(), 0);
558 assert_eq!(r.to_chars(Offset::new(1)).raw(), 0);
559 assert_eq!(r.to_utf16(Offset::new(1)).raw(), 0);
560 }
561
562 // ── bounds: the seal on the saturating_sub bug ──────────────────────
563
564 #[test]
565 fn law_an_inclusive_forward_search_finds_a_match_at_zero() {
566 // THE regression. The old spelling was `step(from - 1)`, and `0 - 1`
567 // saturates to `0`, so a match at the start of the file was
568 // unreachable. There is no subtraction here to saturate.
569 let starts = [0_usize, 10, 20];
570 assert_eq!(
571 Bound::Inclusive.first_matching(&starts, 0, true),
572 Some(0),
573 "an inclusive search from 0 must find the match AT 0",
574 );
575 assert_eq!(
576 Bound::Exclusive.first_matching(&starts, 0, true),
577 Some(1),
578 "an exclusive search from 0 must skip it",
579 );
580 }
581
582 #[test]
583 fn law_the_two_bounds_differ_only_at_the_anchor() {
584 let starts = [0_usize, 5, 9];
585 for anchor in 0..12 {
586 let inc = Bound::Inclusive.first_matching(&starts, anchor, true);
587 let exc = Bound::Exclusive.first_matching(&starts, anchor, true);
588 if starts.contains(&anchor) {
589 assert_ne!(inc, exc, "must differ when the anchor IS a match");
590 } else {
591 assert_eq!(inc, exc, "must agree when the anchor is not a match");
592 }
593 }
594 }
595
596 #[test]
597 fn law_backward_is_the_mirror_of_forward() {
598 let starts = [0_usize, 5, 9];
599 assert_eq!(Bound::Inclusive.first_matching(&starts, 5, false), Some(1));
600 assert_eq!(Bound::Exclusive.first_matching(&starts, 5, false), Some(0));
601 assert_eq!(
602 Bound::Exclusive.first_matching(&starts, 0, false),
603 None,
604 "nothing lies strictly before the first match",
605 );
606 }
607
608 #[test]
609 fn law_no_bound_ever_underflows() {
610 // Anchor 0 in both directions, on an empty and a full list.
611 for b in [Bound::Inclusive, Bound::Exclusive] {
612 assert_eq!(b.first_matching(&[], 0, true), None);
613 assert_eq!(b.first_matching(&[], 0, false), None);
614 let _ = b.first_matching(&[0], 0, true);
615 let _ = b.first_matching(&[0], 0, false);
616 }
617 }
618
619 // ── freshness ───────────────────────────────────────────────────────
620
621 #[test]
622 fn law_a_value_from_an_older_generation_reads_as_absent() {
623 // Any `PartialEq + Copy` works as a generation — memori does not care
624 // WHICH counter, only that two of them can disagree.
625 let (g0, g1) = (0_u64, 1_u64);
626
627 let a = Anchored::new(Offset::<Chars>::new(7), g0);
628 assert_eq!(a.get(g0), Some(&Offset::new(7)), "fresh");
629 assert_eq!(
630 a.get(g1),
631 None,
632 "stale reads as absent, not as a wrong number"
633 );
634 assert!(!a.is_fresh(g1));
635 // The escape hatch still works, and is named so review can see it.
636 assert_eq!(a.get_possibly_stale().raw(), 7);
637 }
638
639 #[test]
640 fn law_freshness_is_not_ordering() {
641 // A value from a LATER generation is just as unusable as an older one —
642 // it is a mismatch, not a comparison. Treating it as "newer, so fine"
643 // is how a stale read sneaks back in.
644 let a = Anchored::new(1_u8, 1_u64);
645 assert_eq!(a.get(0_u64), None);
646 }
647
648 // ── the compile-time seal, demonstrated ─────────────────────────────
649
650 /// Scale mixing is a TYPE error, which a runtime test cannot observe. This
651 /// documents the seal and keeps the constructors honest; the proof is in
652 /// `docs/memori.md`, which records the exact `E0308` from a deliberate
653 /// violation.
654 #[test]
655 fn offsets_of_different_scales_are_different_types() {
656 let b = Offset::<Bytes>::new(6);
657 let c = Offset::<Chars>::new(5);
658 assert_eq!(Offset::<Bytes>::scale_name(), "bytes");
659 assert_eq!(Offset::<Chars>::scale_name(), "chars");
660 // `assert_eq!(b, c)` does not compile: expected `Offset<Bytes>`,
661 // found `Offset<Chars>`.
662 assert_eq!(b.raw(), 6);
663 assert_eq!(c.raw(), 5);
664 }
665
666 // ── the ascending scan ──────────────────────────────────────────────
667
668 #[test]
669 fn law_an_ascending_scan_agrees_with_to_chars_at_every_offset() {
670 // The differential law: the bulk path and the scalar path must give
671 // the same answer for every byte of every corpus entry. If they can
672 // disagree anywhere, the retrofit is a silent corruption.
673 for text in CORPUS {
674 let r = Ruler::new(text);
675 let mut scan = r.ascending();
676 for b in 0..=text.len() {
677 let bulk = scan.to_chars(Offset::new(b));
678 let scalar = r.to_chars(Offset::new(b));
679 assert_eq!(bulk, scalar, "disagreed at byte {b} of {text:?}");
680 }
681 }
682 }
683
684 #[test]
685 fn law_an_ascending_scan_snaps_down_like_to_chars_mid_codepoint() {
686 // Offsets inside a multi-byte char are a normal arrival; both paths
687 // must land on the same boundary.
688 let r = Ruler::new("🔥🔥🔥");
689 for b in 0..=r.end_bytes().raw() {
690 let mut scan = r.ascending();
691 assert_eq!(scan.to_chars(Offset::new(b)), r.to_chars(Offset::new(b)));
692 }
693 }
694
695 #[test]
696 fn an_ascending_scan_visits_each_byte_once() {
697 // The property that makes it O(n + m): querying every offset in order
698 // must not re-walk. Asserted structurally — the cursor only advances.
699 let text = "日本語 foo bar";
700 let r = Ruler::new(text);
701 let mut scan = r.ascending();
702 let mut last = 0;
703 for b in 0..=text.len() {
704 let got = scan.to_chars(Offset::new(b)).raw();
705 assert!(got >= last, "chars went backwards at {b}");
706 last = got;
707 }
708 assert_eq!(last, text.chars().count(), "ends at the full char count");
709 }
710
711 #[test]
712 fn an_ascending_scan_saturates_rather_than_lying_when_asked_to_go_back() {
713 // Release behaviour: a monotonicity slip must not produce a SMALLER
714 // char offset for a LARGER byte offset, and must not abort a frame.
715 // (Debug builds assert first, so this documents the release contract.)
716 let r = Ruler::new("abcdef");
717 let mut scan = r.ascending();
718 let forward = scan.to_chars(Offset::new(4));
719 assert_eq!(forward.raw(), 4);
720 }
721
722 #[test]
723 fn an_ascending_scan_past_the_end_clamps() {
724 let r = Ruler::new("abc");
725 let mut scan = r.ascending();
726 assert_eq!(scan.to_chars(Offset::new(99)).raw(), 3);
727 }
728}
729
730/// A line of text plus the caret editing it, as ONE value.
731///
732/// They are one value because the invariant lives *between* them —
733/// `caret <= text.chars().count()` — and a struct with private fields is the
734/// only place such an invariant can be maintained once rather than at every
735/// mutation site.
736///
737/// # Why this is in memori rather than in either editor crate
738///
739/// It was written twice. `escriba-search`'s `Prompt` held `text` + `caret` as
740/// sibling fields and paired them correctly at six mutation sites by
741/// convention; `escriba-mode`'s ex-line held the same two fields and got it
742/// wrong at the seventh — `clear()` emptied the text and stranded the caret
743/// past the end, reported by nothing louder than `warning: unused variable:
744/// caret`. Neither crate can see the other, so the shared primitive has
745/// exactly one legal home: beneath both, next to the offsets and the ruler it
746/// is made of.
747///
748/// Deliberately carries no serde: a wire name like `minibuffer` is a
749/// consumer's business, and a positioning primitive should not know it.
750#[derive(Debug, Clone, Default, PartialEq, Eq)]
751pub struct CaretLine {
752 text: String,
753 /// Where the next typed character goes, in CHARS from the start.
754 ///
755 /// Chars, never bytes — this is text a human edits, and a byte caret lands
756 /// mid-codepoint the first time someone types `héllo`.
757 caret: usize,
758}
759
760impl CaretLine {
761 /// Build from parts, clamping the caret into range.
762 ///
763 /// The one constructor that takes a caret from outside, so a deserializer
764 /// or a test cannot introduce a value the methods would then preserve.
765 #[must_use]
766 pub fn new(text: String, caret: usize) -> Self {
767 let caret = caret.min(text.chars().count());
768 Self { text, caret }
769 }
770
771 /// The text typed so far, without the leading `:`.
772 #[must_use]
773 pub fn text(&self) -> &str {
774 &self.text
775 }
776
777 /// The caret, in chars from the start.
778 #[must_use]
779 pub const fn caret(&self) -> usize {
780 self.caret
781 }
782
783 /// Length in chars — the caret's upper bound.
784 #[must_use]
785 pub fn len_chars(&self) -> usize {
786 self.text.chars().count()
787 }
788
789 /// The caret as a BYTE index, for string surgery.
790 ///
791 /// The one place the ex-line turns chars into bytes, and it delegates to
792 /// `Ruler` rather than a local `char_indices().nth()` — which is what it
793 /// was for exactly one commit. That local version was the FOURTH
794 /// hand-rolled copy of this conversion in the workspace, written inside
795 /// the crate that had just taken a dependency on the vocabulary built to
796 /// hold it.
797 fn byte_of_caret(&self) -> usize {
798 Ruler::new(&self.text)
799 .to_bytes(Offset::<Chars>::new(self.caret))
800 .raw()
801 }
802
803 /// Insert a char AT the caret and step past it.
804 pub fn insert(&mut self, ch: char) {
805 let at = self.byte_of_caret();
806 self.text.insert(at, ch);
807 self.caret += 1;
808 }
809
810 /// Append a raw fragment and park the caret at the end.
811 ///
812 /// Appends rather than inserting on purpose: its caller is the command
813 /// registry's `__quit__` sentinel handshake, which is writing a fragment
814 /// the user did not type.
815 pub fn push_str(&mut self, s: &str) {
816 self.text.push_str(s);
817 self.caret = self.len_chars();
818 }
819
820 /// Move the caret.
821 pub fn move_caret(&mut self, to: CaretMove) {
822 self.caret = to.resolve(self.caret, self.len_chars());
823 }
824
825 /// Delete the char AT the caret (`<Del>`). No-op at the end of the line.
826 pub fn delete(&mut self) {
827 let at = self.byte_of_caret();
828 if at < self.text.len() {
829 self.text.remove(at);
830 }
831 }
832
833 /// Delete the char BEFORE the caret (`<BS>`), returning it.
834 ///
835 /// Deleting before the caret and deleting the tail are the same operation
836 /// only while the caret sits at the end — exactly the assumption that made
837 /// the search prompt's shadow diverge from the typed prompt.
838 pub fn backspace(&mut self) -> Option<char> {
839 if self.caret == 0 {
840 return None;
841 }
842 let at = self.byte_of_caret();
843 let prev = self.text[..at]
844 .char_indices()
845 .next_back()
846 .map_or(0, |(i, _)| i);
847 let ch = self.text.remove(prev);
848 self.caret -= 1;
849 Some(ch)
850 }
851
852 /// Empty the line AND return the caret home.
853 ///
854 /// Both halves, because they are one value. The version of this that
855 /// cleared only the text is what motivated the type.
856 pub fn clear(&mut self) {
857 self.text.clear();
858 self.caret = 0;
859 }
860
861 /// Replace the whole line, parking the caret at its end.
862 ///
863 /// For text that arrives whole rather than a character at a time — a
864 /// recalled history entry, a restored stash. The caret goes to the end
865 /// because that is where a user continues typing.
866 pub fn set_text(&mut self, text: String) {
867 self.text = text;
868 self.caret = self.len_chars();
869 }
870
871 /// `<C-w>` — delete the word before the caret.
872 ///
873 /// Trailing whitespace goes first, then the run of non-whitespace, which
874 /// is what makes a second `<C-w>` eat a whole second word rather than
875 /// only the gap between them.
876 pub fn delete_word_before(&mut self) {
877 let chars: Vec<char> = self.text.chars().collect();
878 let mut i = self.caret;
879 while i > 0 && chars[i - 1].is_whitespace() {
880 i -= 1;
881 }
882 while i > 0 && !chars[i - 1].is_whitespace() {
883 i -= 1;
884 }
885 self.text = chars[..i]
886 .iter()
887 .chain(chars[self.caret..].iter())
888 .collect();
889 self.caret = i;
890 }
891
892 /// `<C-u>` — delete from the caret back to the start of the line.
893 pub fn clear_before_caret(&mut self) {
894 let at = self.byte_of_caret();
895 self.text.drain(..at);
896 self.caret = 0;
897 }
898}
899
900#[cfg(test)]
901mod caret_line_tests {
902 use super::*;
903
904 #[test]
905 fn law_the_caret_byte_offset_agrees_with_the_hand_rolled_conversion() {
906 // `CaretLine::byte_of_caret` routes through `Ruler` rather than its own
907 // `char_indices().nth()`. This differential test pins the two as equal
908 // over multibyte text — the only place a byte/char confusion shows up.
909 //
910 // It lives here rather than in `escriba-search` because the conversion
911 // does: it was duplicated in the search prompt and (briefly) in the
912 // ex-line, and the test followed the code down.
913 for text in ["", "abc", "héllo", "日本語 foo", "🔥x🔥"] {
914 for caret in 0..=text.chars().count() {
915 let line = CaretLine::new(text.to_owned(), caret);
916 let by_hand = text
917 .char_indices()
918 .nth(caret)
919 .map_or(text.len(), |(b, _)| b);
920 assert_eq!(
921 line.byte_of_caret(),
922 by_hand,
923 "caret {caret} in {text:?}: Ruler disagreed with the hand-rolled map",
924 );
925 }
926 }
927 }
928
929 #[test]
930 fn law_every_mutation_preserves_the_caret_bound() {
931 // The invariant the type exists for, across the whole surface, on text
932 // where a byte caret would land mid-codepoint.
933 let mut line = CaretLine::new("héllo 日本語".to_owned(), 3);
934 let check = |l: &CaretLine| assert!(l.caret() <= l.len_chars(), "{l:?}");
935
936 line.insert('x');
937 check(&line);
938 line.move_caret(CaretMove::Start);
939 check(&line);
940 line.delete();
941 check(&line);
942 line.move_caret(CaretMove::End);
943 check(&line);
944 line.backspace();
945 check(&line);
946 line.delete_word_before();
947 check(&line);
948 line.clear_before_caret();
949 check(&line);
950 line.set_text("🔥🔥🔥".to_owned());
951 check(&line);
952 assert_eq!(line.caret(), 3, "set_text parks the caret at the end");
953 line.clear();
954 check(&line);
955 assert_eq!(line.caret(), 0, "and clear brings it home");
956 }
957
958 #[test]
959 fn law_a_caret_past_the_end_is_clamped_by_the_constructor() {
960 // The one door a caret enters through from outside.
961 assert_eq!(CaretLine::new("ab".to_owned(), 99).caret(), 2);
962 assert_eq!(CaretLine::new(String::new(), 7).caret(), 0);
963 }
964
965 #[test]
966 fn law_a_second_word_delete_eats_a_whole_word_not_just_the_gap() {
967 // The behaviour that makes `<C-w>` usable, and why the whitespace run
968 // is consumed before the word run.
969 let mut line = CaretLine::new("foo bar baz".to_owned(), 11);
970 line.delete_word_before();
971 assert_eq!(line.text(), "foo bar ");
972 line.delete_word_before();
973 assert_eq!(line.text(), "foo ");
974 }
975}