Skip to main content

markdown/
quote.rs

1//! Finding a range again in a document edited somewhere this crate never saw.
2//!
3//! A saved [`Selection`] is offsets, and offsets only hold for the document
4//! they were taken from. A [`Quote`] is the text under the range with a little
5//! context either side — the W3C Web Annotation model's `TextQuoteSelector` —
6//! which is what survives the file being rewritten on disk.
7//!
8//! ```
9//! use markdown::{Cursor, Part, Quote, Selection, parse};
10//!
11//! let before = parse("alpha one\n\nbravo two");
12//! let range = Selection::new(Cursor::new(1, Part::Body, 6), Cursor::new(1, Part::Body, 9));
13//! let quote = Quote::of(&before, range).unwrap();
14//!
15//! let after = parse("new first\n\nalpha one\n\nbravo two");
16//! let found = quote.find(&after, Some(range)).unwrap();
17//! assert_eq!(found.ordered().0, Cursor::new(2, Part::Body, 6));
18//! ```
19
20use crate::{Cursor, Doc, Selection};
21
22/// How many characters of context a quote keeps on either side.
23const CONTEXT: usize = 32;
24
25/// The text a range covers, and what stands either side of it.
26#[derive(Debug, Clone, PartialEq, Eq, Hash)]
27pub struct Quote {
28    pub exact: String,
29    /// Up to 32 characters before `exact`, ending where it starts.
30    pub prefix: String,
31    /// Up to 32 characters after `exact`, starting where it ends.
32    pub suffix: String,
33}
34
35impl Quote {
36    /// The quote under `range`, or `None` for a collapsed range.
37    ///
38    /// Text parts are read in document order with a newline between each, so a
39    /// range across blocks quotes the break as `\n`.
40    pub fn of(doc: &Doc, range: Selection) -> Option<Self> {
41        let flat = Flat::new(doc);
42        let (start, end) = range.clamp(doc).ordered();
43        let (start, end) = (flat.index(start)?, flat.index(end)?);
44        if start >= end {
45            return None;
46        }
47        let text = flat.text.as_str();
48        Some(Self {
49            exact: text[start..end].to_string(),
50            prefix: tail(&text[..start], CONTEXT).to_string(),
51            suffix: head(&text[end..], CONTEXT).to_string(),
52        })
53    }
54
55    /// Where the quote is in `doc`, or `None` when its text is gone.
56    ///
57    /// `hint` is the range as last saved. Where it still covers the exact
58    /// text, it is the answer. Otherwise every occurrence is weighed by how
59    /// much of the prefix and suffix still stand beside it, and a tie goes to
60    /// the one nearest the hint.
61    pub fn find(&self, doc: &Doc, hint: Option<Selection>) -> Option<Selection> {
62        if self.exact.is_empty() {
63            return None;
64        }
65        let flat = Flat::new(doc);
66        let text = flat.text.as_str();
67        let near = hint.and_then(|hint| flat.index(hint.clamp(doc).ordered().0));
68        if let Some(at) = near
69            && text
70                .get(at..)
71                .is_some_and(|rest| rest.starts_with(&self.exact))
72        {
73            return flat.selection(at, at + self.exact.len());
74        }
75
76        let at = occurrences(text, &self.exact).max_by_key(|&at| {
77            let end = at + self.exact.len();
78            let context =
79                common_tail(&text[..at], &self.prefix) + common_head(&text[end..], &self.suffix);
80            let distance = near.map_or(0, |near| near.abs_diff(at));
81            (context, std::cmp::Reverse(distance))
82        })?;
83        flat.selection(at, at + self.exact.len())
84    }
85}
86
87/// Every text part of a document laid end to end.
88struct Flat {
89    text: String,
90    /// Where each part starts in `text`, and the cursor at its offset zero.
91    parts: Vec<(usize, Cursor)>,
92}
93
94impl Flat {
95    fn new(doc: &Doc) -> Self {
96        let mut text = String::new();
97        let mut parts = Vec::new();
98        for (ix, block) in doc.blocks.iter().enumerate() {
99            for part in block.parts() {
100                let Some(body) = block.text_at(part) else {
101                    continue;
102                };
103                if !parts.is_empty() {
104                    text.push('\n');
105                }
106                parts.push((text.len(), Cursor::new(ix, part, 0)));
107                text.push_str(&body.text);
108            }
109        }
110        Self { text, parts }
111    }
112
113    /// Where `cursor` falls in the flat text.
114    fn index(&self, cursor: Cursor) -> Option<usize> {
115        let at = self
116            .parts
117            .iter()
118            .position(|(_, start)| start.block == cursor.block && start.part == cursor.part)?;
119        let (start, _) = self.parts[at];
120        let end = self
121            .parts
122            .get(at + 1)
123            .map_or(self.text.len(), |(next, _)| next - 1);
124        Some((start + cursor.offset).min(end))
125    }
126
127    /// The cursor at flat index `at`: in the last part starting at or before it.
128    fn cursor(&self, at: usize) -> Option<Cursor> {
129        let (start, cursor) = self.parts.iter().rev().find(|(start, _)| *start <= at)?;
130        Some(Cursor {
131            offset: at - start,
132            ..*cursor
133        })
134    }
135
136    fn selection(&self, start: usize, end: usize) -> Option<Selection> {
137        Some(Selection::new(self.cursor(start)?, self.cursor(end)?))
138    }
139}
140
141/// Every place `exact` starts, overlapping ones included.
142fn occurrences<'a>(text: &'a str, exact: &'a str) -> impl Iterator<Item = usize> + 'a {
143    text.char_indices()
144        .map(|(at, _)| at)
145        .filter(move |&at| text[at..].starts_with(exact))
146}
147
148/// The last `count` characters of `text`.
149fn tail(text: &str, count: usize) -> &str {
150    let at = text
151        .char_indices()
152        .rev()
153        .nth(count.saturating_sub(1))
154        .map_or(0, |(at, _)| at);
155    &text[at..]
156}
157
158/// The first `count` characters of `text`.
159fn head(text: &str, count: usize) -> &str {
160    let at = text
161        .char_indices()
162        .nth(count)
163        .map_or(text.len(), |(at, _)| at);
164    &text[..at]
165}
166
167/// How many characters `a` and `b` share at their ends.
168fn common_tail(a: &str, b: &str) -> usize {
169    a.chars()
170        .rev()
171        .zip(b.chars().rev())
172        .take_while(|(a, b)| a == b)
173        .count()
174}
175
176/// How many characters `a` and `b` share at their starts.
177fn common_head(a: &str, b: &str) -> usize {
178    a.chars().zip(b.chars()).take_while(|(a, b)| a == b).count()
179}