Skip to main content

kimun_notes/ropetext/
text.rs

1//! The text value: a rope, a revision, and the only way to make a [`Position`].
2
3use std::borrow::Cow;
4use std::fmt;
5
6use ropey::{Rope, RopeSlice};
7use unicode_segmentation::{GraphemeCursor, GraphemeIncomplete, UnicodeSegmentation};
8
9use crate::ropetext::position::{Column, Position, Revision, Span};
10
11/// A text, as a value.
12///
13/// `Clone` is cheap: the rope shares its structure, so a clone is not a copy of
14/// the text but *the same text*, held elsewhere. That is what lets a background
15/// task, a history entry or a preview hold one without anybody duplicating a
16/// buffer, and it is why a clone keeps the same [`Revision`] — only a change
17/// mints a new one.
18///
19/// The text never contains a carriage return. A `\r\n` or a lone `\r` in the
20/// input becomes `\n` on construction, so every layer above measures, wraps and
21/// addresses exactly one kind of line break. Restoring a file's original line
22/// endings on save is the caller's business; it has the file, this does not.
23#[derive(Debug, Clone)]
24pub struct Text {
25    rope: Rope,
26    revision: Revision,
27}
28
29impl Default for Text {
30    fn default() -> Self {
31        Self::new()
32    }
33}
34
35impl fmt::Display for Text {
36    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37        for chunk in self.rope.chunks() {
38            f.write_str(chunk)?;
39        }
40        Ok(())
41    }
42}
43
44impl From<&str> for Text {
45    /// Takes `s` as the text's content, normalising `\r\n` and lone `\r` to `\n`.
46    fn from(s: &str) -> Self {
47        Self {
48            rope: Rope::from_str(&normalise_breaks(s)),
49            revision: Revision::fresh(),
50        }
51    }
52}
53
54impl Text {
55    /// An empty text: one row, zero characters.
56    pub fn new() -> Self {
57        Self {
58            rope: Rope::new(),
59            revision: Revision::fresh(),
60        }
61    }
62
63    /// Which state this text is. Every [`Position`] carries the revision it was
64    /// built against, so one made against an earlier state is refused rather
65    /// than read as some other place.
66    pub fn revision(&self) -> Revision {
67        self.revision
68    }
69
70    pub fn len_bytes(&self) -> usize {
71        self.rope.len_bytes()
72    }
73
74    pub fn len_chars(&self) -> usize {
75        self.rope.len_chars()
76    }
77
78    /// Number of logical rows. A trailing newline opens a final empty row, so
79    /// `"a\n"` is two rows and the cursor can sit on the second.
80    pub fn line_count(&self) -> usize {
81        self.rope.len_lines()
82    }
83
84    /// Row `row` without its line break, or `None` if there is no such row.
85    ///
86    /// Borrowed when the row sits inside one of the rope's chunks, owned when it
87    /// straddles two. Nothing above holds a duplicate of the document, so a full
88    /// pass over the text costs what it costs and a partial pass costs less.
89    pub fn line(&self, row: usize) -> Option<Cow<'_, str>> {
90        self.line_slice(row).map(cow_of)
91    }
92
93    /// Length of row `row` in Unicode scalars, excluding its line break.
94    pub fn line_len_chars(&self, row: usize) -> Option<usize> {
95        self.line_slice(row).map(|l| l.len_chars())
96    }
97
98    /// Every row, without line breaks.
99    pub fn lines(&self) -> impl Iterator<Item = Cow<'_, str>> {
100        (0..self.line_count()).filter_map(|row| self.line(row))
101    }
102
103    /// The text within `span`.
104    ///
105    /// `None` when the span addresses another revision of the text.
106    pub fn slice(&self, span: Span) -> Option<Cow<'_, str>> {
107        if span.revision() != self.revision {
108            return None;
109        }
110        Some(cow_of(self.rope.byte_slice(span.byte_range())))
111    }
112
113    /// Whether `position` addresses a state this text has moved on from.
114    pub fn is_stale(&self, position: Position) -> bool {
115        position.revision() != self.revision
116    }
117
118    /// The position at `(row, column)`, or `None` if this text cannot address it.
119    ///
120    /// Refused rather than approximated: past the end of the row, past the last
121    /// row, or partway through a grapheme cluster all yield `None`. A caller
122    /// that gets `None` has asked for somewhere that does not exist, and a
123    /// keystroke that does nothing is recoverable in a way one that edits the
124    /// wrong place is not.
125    pub fn position(&self, row: usize, column: Column) -> Option<Position> {
126        let line = self.line_slice(row)?;
127        if column.get() > line.len_chars() {
128            return None;
129        }
130        let byte = self.rope.line_to_byte(row) + line.char_to_byte(column.get());
131        if !self.is_cluster_boundary(byte) {
132            return None;
133        }
134        Some(Position::new(byte, row, column, self.revision))
135    }
136
137    /// The position at byte offset `byte`, or `None` if that offset is past the
138    /// end, inside a character, or inside a grapheme cluster.
139    pub fn position_at_byte(&self, byte: usize) -> Option<Position> {
140        if byte > self.rope.len_bytes()
141            || !self.is_char_boundary(byte)
142            || !self.is_cluster_boundary(byte)
143        {
144            return None;
145        }
146        Some(self.position_at_addressable_byte(byte))
147    }
148
149    /// The position at the start of the grapheme cluster containing `byte`.
150    ///
151    /// This one **approximates**, which is why it says so in its name. It exists
152    /// for offsets that arrive from somewhere with a different idea of where a
153    /// character ends — an external editor reporting a cursor in bytes, say —
154    /// where refusing would drop the update entirely. Anything originating
155    /// inside this crate should use [`Self::position_at_byte`] and be told it was
156    /// wrong.
157    ///
158    /// `None` only when `byte` is past the end of the text.
159    pub fn position_at_byte_snapped(&self, byte: usize) -> Option<Position> {
160        if byte > self.rope.len_bytes() {
161            return None;
162        }
163        let mut at = byte;
164        while !self.is_char_boundary(at) {
165            at -= 1;
166        }
167        if !self.is_cluster_boundary(at) {
168            at = self.cluster_start_at_or_before(at);
169        }
170        Some(self.position_at_addressable_byte(at))
171    }
172
173    /// The first position in the text.
174    pub fn start(&self) -> Position {
175        Position::new(0, 0, Column::ZERO, self.revision)
176    }
177
178    /// The position just past the last character.
179    pub fn end(&self) -> Position {
180        self.position_at_addressable_byte(self.rope.len_bytes())
181    }
182
183    /// The whole text as one span.
184    pub fn full_span(&self) -> Span {
185        Span::new(self.start(), self.end())
186    }
187
188    /// An ordered span between two positions of *this* text.
189    ///
190    /// Order does not matter: the span comes back with its ends the right way
191    /// round. `None` when either position addresses another revision — which is
192    /// also the only place two positions are checked against each other, and the
193    /// reason [`Position`] is not `Ord`.
194    pub fn span(&self, a: Position, b: Position) -> Option<Span> {
195        if self.is_stale(a) || self.is_stale(b) {
196            return None;
197        }
198        Some(if a.byte() <= b.byte() {
199            Span::new(a, b)
200        } else {
201            Span::new(b, a)
202        })
203    }
204
205    // -- internals ----------------------------------------------------------
206
207    /// Replace `bytes` with `text`, becoming a new revision.
208    ///
209    /// `bytes` must be a byte range this text can address; the buffer only ever
210    /// derives it from [`Span`]s, which are checked. Inserted text is normalised
211    /// like any other, so a paste carrying `\r\n` cannot smuggle a carriage
212    /// return past the invariant.
213    /// Returns how many bytes were inserted, which is not `text.len()` when the
214    /// normalisation collapsed a `\r\n`.
215    ///
216    /// The precondition is asserted rather than trusted, because this is the one
217    /// place the text is mutated and the range reaches it as bare arithmetic —
218    /// `Txn` remaps its spans across earlier edits in the same transaction. An
219    /// inverted or out-of-range range would panic inside the rope anyway; a byte
220    /// *inside a character* would not. `byte_to_char` rounds it down, and the
221    /// edit would silently land somewhere the caller never asked for, in a note
222    /// that autosaves. Corrupting the text is worse than refusing to.
223    pub(crate) fn splice(&mut self, bytes: std::ops::Range<usize>, text: &str) -> usize {
224        assert!(
225            bytes.start <= bytes.end,
226            "splice range {bytes:?} is inverted"
227        );
228        assert!(
229            bytes.end <= self.rope.len_bytes(),
230            "splice range {bytes:?} runs past the text's {} bytes",
231            self.rope.len_bytes()
232        );
233        let start = self.char_boundary(bytes.start);
234        let end = self.char_boundary(bytes.end);
235        if start != end {
236            self.rope.remove(start..end);
237        }
238        let normalised = normalise_breaks(text);
239        if !normalised.is_empty() {
240            self.rope.insert(start, &normalised);
241        }
242        self.revision = Revision::fresh();
243        normalised.len()
244    }
245
246    /// Char index at `byte`, which must start a character.
247    ///
248    /// `Rope::byte_to_char` answers for a byte inside one by naming the
249    /// character that contains it, so the round trip back is what distinguishes
250    /// a boundary from an interior byte.
251    fn char_boundary(&self, byte: usize) -> usize {
252        let chars = self.rope.byte_to_char(byte);
253        assert_eq!(
254            self.rope.char_to_byte(chars),
255            byte,
256            "splice byte {byte} is inside a character"
257        );
258        chars
259    }
260
261    /// The same content as a new revision.
262    ///
263    /// Undo restores content, not identity: a revision names a point in the edit
264    /// timeline, and going back to earlier content is a *later* point. Keeping
265    /// revisions monotonic is what lets a cache and a background task compare
266    /// two of them without also asking which way history was walking.
267    pub(crate) fn reidentified(&self) -> Self {
268        Self {
269            rope: self.rope.clone(),
270            revision: Revision::fresh(),
271        }
272    }
273
274    /// Row containing `byte`, which must be addressable.
275    pub(crate) fn row_of_byte(&self, byte: usize) -> usize {
276        self.rope.byte_to_line(byte)
277    }
278
279    /// Byte offset where `row` starts.
280    pub(crate) fn row_start_byte(&self, row: usize) -> usize {
281        self.rope
282            .line_to_byte(row.min(self.line_count().saturating_sub(1)))
283    }
284
285    /// The position a cursor takes at `byte`, snapping **forward** if the byte
286    /// falls inside a cluster.
287    ///
288    /// Distinct from [`Self::position_at_derived_byte`] because the direction
289    /// matters and the two want opposite ones. An edit's own end offset is a
290    /// char boundary, but char boundaries are not cluster boundaries: typing `a`
291    /// in front of a lone combining acute produces one cluster, and the offset
292    /// between them addresses nothing. Snapping back — which is what
293    /// `position_at_byte_snapped` does, since it names the *enclosing* cluster —
294    /// would leave the cursor in front of the character just typed, so the next
295    /// keystroke would land in reverse order. Forward is the only direction that
296    /// keeps typing moving the way the typist is.
297    pub(crate) fn position_at_cursor_byte(&self, byte: usize) -> Position {
298        match self.position_at_byte(byte) {
299            Some(position) => position,
300            None => {
301                let forward = self.next_cluster_byte(byte.min(self.len_bytes()));
302                self.position_at_byte(forward).unwrap_or_else(|| self.end())
303            }
304        }
305    }
306
307    /// The position nearest `(row, column)` that this text has: the row and
308    /// column clamped to the text, and a column inside a grapheme cluster
309    /// moved on to the cluster's end, as the cursor would be.
310    ///
311    /// For a position remembered by row and column across a text swapped in
312    /// whole (an undo), where there is no edit to carry it through.
313    pub fn position_near(&self, row: usize, column: usize) -> Position {
314        let row = row.min(self.line_count().saturating_sub(1));
315        let len = self.line_len_chars(row).unwrap_or(0);
316        let column = column.min(len);
317        (column..=len)
318            .find_map(|c| self.position(row, Column::new(c)))
319            .or_else(|| self.position(row, Column::ZERO))
320            .unwrap_or_else(|| self.start())
321    }
322
323    /// The position at `byte`, snapping if the byte is somehow not addressable.
324    ///
325    /// For offsets this crate derived itself — a remapped cursor, a restored
326    /// history entry. They should always land on a cluster boundary; the assert
327    /// is what surfaces the case that does not, in development rather than in a
328    /// user's note, and the snap is what keeps it from being a panic if it ever
329    /// does.
330    pub(crate) fn position_at_derived_byte(&self, byte: usize) -> Position {
331        match self.position_at_byte(byte) {
332            Some(position) => position,
333            None => {
334                debug_assert!(false, "derived byte {byte} is not addressable");
335                self.position_at_byte_snapped(byte.min(self.len_bytes()))
336                    .unwrap_or_else(|| self.start())
337            }
338        }
339    }
340
341    /// Row `row` with any trailing `\n` trimmed off.
342    fn line_slice(&self, row: usize) -> Option<RopeSlice<'_>> {
343        let line = self.rope.get_line(row)?;
344        let chars = line.len_chars();
345        if chars > 0 && line.char(chars - 1) == '\n' {
346            Some(line.slice(..chars - 1))
347        } else {
348            Some(line)
349        }
350    }
351
352    /// `byte` is known to be addressable; derive the rest of the position.
353    fn position_at_addressable_byte(&self, byte: usize) -> Position {
354        let row = self.rope.byte_to_line(byte);
355        let line_start = self.rope.line_to_byte(row);
356        let column = Column::new(self.rope.byte_slice(line_start..byte).len_chars());
357        Position::new(byte, row, column, self.revision)
358    }
359
360    fn is_char_boundary(&self, byte: usize) -> bool {
361        let len = self.rope.len_bytes();
362        if byte == 0 || byte == len {
363            return true;
364        }
365        if byte > len {
366            return false;
367        }
368        let (chunk, chunk_start, _, _) = self.rope.chunk_at_byte(byte);
369        chunk.is_char_boundary(byte - chunk_start)
370    }
371
372    /// Whether `byte` sits between two grapheme clusters.
373    ///
374    /// Answered from the chunk containing `byte`, with preceding context fetched
375    /// only when the segmenter asks for it — so this is a chunk-local operation,
376    /// not a walk from the start of the row. That is what makes it affordable on
377    /// the paths that build a position per overlay per visible row.
378    fn is_cluster_boundary(&self, byte: usize) -> bool {
379        let len = self.rope.len_bytes();
380        if byte == 0 || byte == len {
381            return true;
382        }
383        if byte > len || !self.is_char_boundary(byte) {
384            return false;
385        }
386        let mut cursor = GraphemeCursor::new(byte, len, true);
387        let (chunk, chunk_start, _, _) = self.rope.chunk_at_byte(byte);
388        // Each PreContext asks for strictly earlier text, so this terminates;
389        // the bound is a backstop against a segmenter that disagrees.
390        for _ in 0..MAX_CONTEXT_REQUESTS {
391            match cursor.is_boundary(chunk, chunk_start) {
392                Ok(is) => return is,
393                Err(GraphemeIncomplete::PreContext(upto)) => {
394                    if upto == 0 {
395                        return true;
396                    }
397                    let (pre, pre_start, _, _) = self.rope.chunk_at_byte(upto - 1);
398                    cursor.provide_context(pre, pre_start);
399                }
400                Err(_) => return false,
401            }
402        }
403        debug_assert!(false, "grapheme cursor kept asking for context at {byte}");
404        false
405    }
406
407    /// Byte offset of the next cluster boundary after `byte`, or the end of the
408    /// text.
409    pub(crate) fn next_cluster_byte(&self, byte: usize) -> usize {
410        self.step_cluster(byte, true)
411    }
412
413    /// Byte offset of the previous cluster boundary before `byte`, or zero.
414    pub(crate) fn prev_cluster_byte(&self, byte: usize) -> usize {
415        self.step_cluster(byte, false)
416    }
417
418    /// The first scalar of the cluster starting at `byte`, or `None` at the end
419    /// of the text.
420    ///
421    /// A cluster's class — blank, word, punctuation — is its first scalar's, which
422    /// is what every editor's word motions use.
423    pub(crate) fn scalar_at(&self, byte: usize) -> Option<char> {
424        if byte >= self.rope.len_bytes() {
425            return None;
426        }
427        Some(self.rope.char(self.rope.byte_to_char(byte)))
428    }
429
430    /// One cluster boundary in either direction.
431    ///
432    /// Runs the segmenter over a window around `byte` rather than the whole row.
433    /// A cluster is a handful of bytes in practice, so the window almost always
434    /// suffices; when the segmenter says it needs more, it says which side, and
435    /// the window grows. Stepping a row's worth of text to move the cursor one
436    /// place would make an arrow key cost the length of its line.
437    fn step_cluster(&self, byte: usize, forward: bool) -> usize {
438        let len = self.rope.len_bytes();
439        let limit = if forward { len } else { 0 };
440        if byte == limit {
441            return limit;
442        }
443        let mut back = WINDOW_BYTES;
444        let mut ahead = WINDOW_BYTES;
445        loop {
446            let low = self.char_boundary_at_or_before(byte.saturating_sub(back));
447            let high = self.char_boundary_at_or_after((byte + ahead).min(len));
448            let window = cow_of(self.rope.byte_slice(low..high));
449            let mut cursor = GraphemeCursor::new(byte, len, true);
450            let step = if forward {
451                cursor.next_boundary(&window, low)
452            } else {
453                cursor.prev_boundary(&window, low)
454            };
455            match step {
456                Ok(Some(at)) => return at,
457                Ok(None) => return limit,
458                Err(GraphemeIncomplete::NextChunk) => ahead *= 4,
459                Err(GraphemeIncomplete::PreContext(_) | GraphemeIncomplete::PrevChunk) => back *= 4,
460                Err(_) => return limit,
461            }
462            if low == 0 && high == len {
463                // The window is the whole text and the segmenter still wants
464                // more, which it cannot get. Refuse to move rather than guess.
465                debug_assert!(false, "grapheme cursor wants context beyond the text");
466                return byte;
467            }
468        }
469    }
470
471    fn char_boundary_at_or_before(&self, byte: usize) -> usize {
472        let mut at = byte.min(self.rope.len_bytes());
473        while !self.is_char_boundary(at) {
474            at -= 1;
475        }
476        at
477    }
478
479    fn char_boundary_at_or_after(&self, byte: usize) -> usize {
480        let mut at = byte.min(self.rope.len_bytes());
481        while !self.is_char_boundary(at) {
482            at += 1;
483        }
484        at
485    }
486
487    /// Start of the grapheme cluster containing `byte`, which must be a char
488    /// boundary.
489    ///
490    /// Walks the row rather than the chunk. Unlike [`Self::is_cluster_boundary`]
491    /// this runs at one call site — an offset arriving from outside — so O(row)
492    /// is the right trade for an implementation that is obviously correct.
493    fn cluster_start_at_or_before(&self, byte: usize) -> usize {
494        let row = self.rope.byte_to_line(byte);
495        let row_start = self.rope.line_to_byte(row);
496        let line = cow_of(self.rope.line(row));
497        let offset = byte - row_start;
498        let mut start = 0;
499        for (at, _) in line.grapheme_indices(true) {
500            if at > offset {
501                break;
502            }
503            start = at;
504        }
505        row_start + start
506    }
507}
508
509/// Backstop on [`Text::is_cluster_boundary`]'s context loop.
510const MAX_CONTEXT_REQUESTS: usize = 64;
511
512/// Bytes either side of an offset handed to the segmenter to start with. Wide
513/// enough for any cluster that occurs in prose; grown on demand for ones that do
514/// not.
515const WINDOW_BYTES: usize = 64;
516
517fn cow_of(slice: RopeSlice<'_>) -> Cow<'_, str> {
518    match slice.as_str() {
519        Some(s) => Cow::Borrowed(s),
520        None => Cow::Owned(slice.to_string()),
521    }
522}
523
524/// `\r\n` and lone `\r` become `\n`. Borrows when there is nothing to do, which
525/// is every note not written on Windows.
526fn normalise_breaks(s: &str) -> Cow<'_, str> {
527    if !s.contains('\r') {
528        return Cow::Borrowed(s);
529    }
530    let mut out = String::with_capacity(s.len());
531    let mut chars = s.chars().peekable();
532    while let Some(c) = chars.next() {
533        if c == '\r' {
534            if chars.peek() == Some(&'\n') {
535                chars.next();
536            }
537            out.push('\n');
538        } else {
539            out.push(c);
540        }
541    }
542    Cow::Owned(out)
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    /// "e" plus a combining acute: two chars, one cluster.
550    const COMBINING: &str = "e\u{301}f";
551    /// Man-woman-girl family: three scalars joined by two ZWJs, one cluster.
552    const FAMILY: &str = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}";
553
554    fn col(n: usize) -> Column {
555        Column::new(n)
556    }
557
558    // -- splice preconditions -----------------------------------------------
559
560    #[test]
561    #[should_panic(expected = "is inside a character")]
562    fn splicing_inside_a_character_is_refused() {
563        // Without the check the rope rounds this down to the start of the `é`
564        // and deletes a character the caller never named.
565        let mut t = Text::from("héllo");
566        t.splice(2..3, "");
567    }
568
569    #[test]
570    #[should_panic(expected = "is inside a character")]
571    fn splicing_that_ends_inside_a_character_is_refused() {
572        let mut t = Text::from("héllo");
573        t.splice(1..2, "");
574    }
575
576    #[test]
577    #[should_panic(expected = "is inverted")]
578    fn splicing_an_inverted_range_is_refused() {
579        let mut t = Text::from("hello");
580        // Built from values: a literal `3..1` is a lint, and the point is that
581        // arithmetic can produce one where a literal never would.
582        let (start, end) = (3usize, 1usize);
583        t.splice(start..end, "");
584    }
585
586    #[test]
587    #[should_panic(expected = "runs past the text's")]
588    fn splicing_past_the_end_is_refused() {
589        let mut t = Text::from("hello");
590        t.splice(4..9, "");
591    }
592
593    #[test]
594    fn splicing_at_the_very_end_is_allowed() {
595        // The boundary the check must not exclude: an append is `len..len`.
596        let mut t = Text::from("hello");
597        t.splice(5..5, "!");
598        assert_eq!(t.line(0).expect("one row"), "hello!");
599    }
600
601    // -- shape --------------------------------------------------------------
602
603    #[test]
604    fn empty_text_has_one_empty_row() {
605        let t = Text::new();
606        assert_eq!(t.line_count(), 1);
607        assert_eq!(t.line(0).as_deref(), Some(""));
608        assert_eq!(t.len_bytes(), 0);
609    }
610
611    #[test]
612    fn trailing_newline_opens_a_final_empty_row() {
613        let t = Text::from("a\n");
614        assert_eq!(t.line_count(), 2);
615        assert_eq!(t.line(1).as_deref(), Some(""));
616    }
617
618    #[test]
619    fn no_trailing_newline_is_distinguishable_from_one() {
620        assert_eq!(Text::from("a").line_count(), 1);
621        assert_eq!(Text::from("a\n").line_count(), 2);
622        assert_eq!(Text::from("a").to_string(), "a");
623        assert_eq!(Text::from("a\n").to_string(), "a\n");
624    }
625
626    #[test]
627    fn lines_come_back_without_their_break() {
628        let t = Text::from("one\ntwo\nthree");
629        assert_eq!(
630            t.lines().map(|l| l.to_string()).collect::<Vec<_>>(),
631            ["one", "two", "three"]
632        );
633    }
634
635    #[test]
636    fn line_past_the_end_is_none() {
637        let t = Text::from("one\ntwo");
638        assert!(t.line(2).is_none());
639        assert!(t.position(2, col(0)).is_none());
640    }
641
642    // -- line endings -------------------------------------------------------
643
644    #[test]
645    fn crlf_normalises_and_leaves_no_carriage_return() {
646        let t = Text::from("a\r\nb\r\n");
647        assert_eq!(t.to_string(), "a\nb\n");
648        assert_eq!(t.line(0).as_deref(), Some("a"));
649        assert!(!t.to_string().contains('\r'));
650    }
651
652    #[test]
653    fn lone_carriage_return_is_a_line_break() {
654        let t = Text::from("a\rb");
655        assert_eq!(t.line_count(), 2);
656        assert_eq!(t.line(1).as_deref(), Some("b"));
657    }
658
659    #[test]
660    fn only_a_newline_breaks_a_row() {
661        // Ropey's default line-break set includes these; ours does not, because
662        // CommonMark's does not and neither does splitting on '\n'. A row here
663        // must be a row to the markdown parser as well.
664        for exotic in ["\u{b}", "\u{c}", "\u{85}", "\u{2028}", "\u{2029}"] {
665            let t = Text::from(format!("a{exotic}b").as_str());
666            assert_eq!(
667                t.line_count(),
668                1,
669                "{exotic:?} must be an ordinary character, not a break"
670            );
671        }
672    }
673
674    // -- positions ----------------------------------------------------------
675
676    #[test]
677    fn end_of_row_is_addressable_but_past_it_is_not() {
678        let t = Text::from("hello\nworld");
679        assert!(t.position(0, col(5)).is_some());
680        assert!(t.position(0, col(6)).is_none());
681    }
682
683    #[test]
684    fn column_is_chars_and_byte_is_bytes() {
685        let t = Text::from("w\u{f8}rld"); // "wørld": ø is two bytes
686        let p = t.position(0, col(2)).expect("char 2 is addressable");
687        assert_eq!(p.column().get(), 2);
688        assert_eq!(p.byte(), 3);
689    }
690
691    #[test]
692    fn row_and_column_survive_the_round_trip_through_byte() {
693        let t = Text::from("one\ntw\u{f8}\nthree");
694        let p = t.position(1, col(3)).expect("end of row 1");
695        let q = t.position_at_byte(p.byte()).expect("same place by byte");
696        assert_eq!((q.row(), q.column().get()), (1, 3));
697    }
698
699    #[test]
700    fn a_position_inside_a_character_is_refused() {
701        let t = Text::from("w\u{f8}rld");
702        assert!(t.position_at_byte(2).is_none(), "byte 2 splits ø");
703    }
704
705    #[test]
706    fn a_position_inside_a_cluster_is_refused() {
707        let t = Text::from(COMBINING);
708        // char 1 is the combining acute — a place the renderer cannot show a
709        // cursor, so the text refuses to name it.
710        assert!(t.position(0, col(1)).is_none());
711        assert!(t.position(0, col(0)).is_some());
712        assert!(t.position(0, col(2)).is_some());
713    }
714
715    #[test]
716    fn a_position_inside_a_zwj_sequence_is_refused() {
717        let t = Text::from(FAMILY);
718        assert!(t.position(0, col(0)).is_some());
719        for interior in 1..5 {
720            assert!(
721                t.position(0, col(interior)).is_none(),
722                "char {interior} is inside the family cluster"
723            );
724        }
725        assert!(t.position(0, col(5)).is_some(), "past the whole cluster");
726    }
727
728    #[test]
729    fn start_and_end_address_the_whole_text() {
730        let t = Text::from("one\ntwo");
731        assert_eq!(t.start().byte(), 0);
732        assert_eq!(t.end().byte(), 7);
733        assert_eq!((t.end().row(), t.end().column().get()), (1, 3));
734    }
735
736    #[test]
737    fn end_of_a_text_ending_in_a_newline_is_the_empty_row() {
738        let t = Text::from("a\n");
739        assert_eq!((t.end().row(), t.end().column().get()), (1, 0));
740    }
741
742    // -- snapping -----------------------------------------------------------
743
744    #[test]
745    fn snapping_lands_on_the_start_of_the_cluster() {
746        let t = Text::from(COMBINING);
747        let acute_start = 1; // byte offset of the combining mark
748        let p = t
749            .position_at_byte_snapped(acute_start)
750            .expect("inside the text");
751        assert_eq!(p.byte(), 0, "snapped back to the start of the cluster");
752    }
753
754    #[test]
755    fn snapping_a_valid_position_changes_nothing() {
756        let t = Text::from("hello");
757        let p = t.position_at_byte_snapped(3).expect("inside the text");
758        assert_eq!(p.byte(), 3);
759    }
760
761    #[test]
762    fn snapping_inside_a_character_lands_on_the_character() {
763        let t = Text::from("w\u{f8}rld");
764        let p = t.position_at_byte_snapped(2).expect("inside the text");
765        assert_eq!(p.byte(), 1);
766    }
767
768    #[test]
769    fn snapping_past_the_end_is_still_refused() {
770        let t = Text::from("hello");
771        assert!(t.position_at_byte_snapped(6).is_none());
772    }
773
774    // -- revisions ----------------------------------------------------------
775
776    #[test]
777    fn two_texts_never_share_a_revision() {
778        let a = Text::from("same");
779        let b = Text::from("same");
780        assert_ne!(a.revision(), b.revision());
781    }
782
783    #[test]
784    fn a_clone_is_the_same_text_and_keeps_its_revision() {
785        let a = Text::from("shared");
786        let b = a.clone();
787        assert_eq!(a.revision(), b.revision());
788        assert!(!b.is_stale(a.start()));
789    }
790
791    #[test]
792    fn a_position_from_another_text_is_stale() {
793        let a = Text::from("hello");
794        let b = Text::from("hello");
795        let p = a.position(0, col(2)).expect("addressable in a");
796        assert!(b.is_stale(p));
797        assert!(b.span(p, b.start()).is_none());
798        assert!(b.slice(a.full_span()).is_none());
799    }
800
801    // -- spans --------------------------------------------------------------
802
803    #[test]
804    fn a_span_comes_back_ordered() {
805        let t = Text::from("hello");
806        let a = t.position(0, col(1)).unwrap();
807        let b = t.position(0, col(4)).unwrap();
808        let forward = t.span(a, b).unwrap();
809        let backward = t.span(b, a).unwrap();
810        assert_eq!(forward, backward);
811        assert_eq!(forward.byte_range(), 1..4);
812    }
813
814    #[test]
815    fn slicing_a_span_reads_the_text_between_its_ends() {
816        let t = Text::from("one\ntwo\nthree");
817        let a = t.position(0, col(1)).unwrap();
818        let b = t.position(2, col(2)).unwrap();
819        let span = t.span(a, b).unwrap();
820        assert_eq!(t.slice(span).as_deref(), Some("ne\ntwo\nth"));
821    }
822
823    #[test]
824    fn an_empty_span_says_so() {
825        let t = Text::from("hello");
826        let p = t.position(0, col(2)).unwrap();
827        assert!(t.span(p, p).unwrap().is_empty());
828    }
829
830    #[test]
831    fn the_full_span_is_the_whole_text() {
832        let t = Text::from("one\ntwo");
833        assert_eq!(t.slice(t.full_span()).as_deref(), Some("one\ntwo"));
834    }
835
836    // -- properties ---------------------------------------------------------
837
838    mod properties {
839        use super::*;
840        use proptest::prelude::*;
841
842        /// Naive reference: every cluster boundary in the text, by byte offset.
843        fn cluster_boundaries(s: &str) -> Vec<usize> {
844            let mut out: Vec<usize> = s.grapheme_indices(true).map(|(i, _)| i).collect();
845            out.push(s.len());
846            out
847        }
848
849        proptest! {
850            /// A byte offset is addressable exactly when it is a cluster
851            /// boundary. No clamping, no rounding, in either direction.
852            #[test]
853            fn addressable_bytes_are_exactly_the_cluster_boundaries(s in ".{0,200}") {
854                let normalised = normalise_breaks(&s).into_owned();
855                let t = Text::from(normalised.as_str());
856                let expected = cluster_boundaries(&normalised);
857                for byte in 0..=normalised.len() {
858                    let got = t.position_at_byte(byte).is_some();
859                    prop_assert_eq!(
860                        got,
861                        expected.contains(&byte),
862                        "byte {} of {:?}", byte, normalised
863                    );
864                }
865            }
866
867            /// Snapping always lands on a cluster boundary at or before where it
868            /// was asked, and never moves a boundary that was already fine.
869            #[test]
870            fn snapping_lands_on_a_boundary_at_or_before(s in ".{0,200}") {
871                let normalised = normalise_breaks(&s).into_owned();
872                let t = Text::from(normalised.as_str());
873                let boundaries = cluster_boundaries(&normalised);
874                for byte in 0..=normalised.len() {
875                    let p = t.position_at_byte_snapped(byte).expect("inside the text");
876                    prop_assert!(p.byte() <= byte);
877                    prop_assert!(boundaries.contains(&p.byte()));
878                    if boundaries.contains(&byte) {
879                        prop_assert_eq!(p.byte(), byte);
880                    }
881                }
882            }
883
884            /// (row, column) and byte offset name the same places.
885            #[test]
886            fn row_column_and_byte_agree(s in ".{0,200}") {
887                let normalised = normalise_breaks(&s).into_owned();
888                let t = Text::from(normalised.as_str());
889                for byte in cluster_boundaries(&normalised) {
890                    let by_byte = t.position_at_byte(byte).expect("a boundary is addressable");
891                    let by_col = t
892                        .position(by_byte.row(), by_byte.column())
893                        .expect("its own row and column are addressable");
894                    prop_assert_eq!(by_col, by_byte);
895                }
896            }
897
898            /// Rows rejoin into the text, so nothing is lost or invented by the
899            /// break-trimming.
900            #[test]
901            fn rows_rejoin_into_the_text(s in ".{0,200}") {
902                let normalised = normalise_breaks(&s).into_owned();
903                let t = Text::from(normalised.as_str());
904                let rejoined = t.lines().collect::<Vec<_>>().join("\n");
905                prop_assert_eq!(rejoined, normalised);
906            }
907        }
908    }
909}