1use std::ops::Range;
15
16use odox_core::Element;
17use odox_core::edit;
18
19use crate::flow_model::paragraph_paths;
20
21#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct Match {
24 pub scope: usize,
27 pub paragraph: Vec<usize>,
30 pub range: Range<usize>,
32}
33
34fn fold(c: char) -> char {
37 c.to_lowercase().next().unwrap_or(c)
38}
39
40fn folded(text: &str) -> Vec<char> {
41 text.chars().map(fold).collect()
42}
43
44pub fn ranges(text: &str, query: &str) -> Vec<Range<usize>> {
47 let needle = folded(query);
48 if needle.is_empty() {
49 return Vec::new();
50 }
51 let hay = folded(text);
52 let mut found = Vec::new();
53 let mut at = 0;
54 while at + needle.len() <= hay.len() {
55 if hay[at..at + needle.len()] == needle[..] {
56 found.push(at..at + needle.len());
57 at += needle.len();
58 } else {
59 at += 1;
60 }
61 }
62 found
63}
64
65pub fn in_paragraphs(root: &Element, scope: usize, query: &str) -> Vec<Match> {
68 let mut found = Vec::new();
69 if query.is_empty() {
70 return found;
71 }
72 for path in paragraph_paths(root) {
73 let Some(paragraph) = root.at(&path) else {
74 continue;
75 };
76 for range in ranges(&edit::text(paragraph), query) {
77 found.push(Match {
78 scope,
79 paragraph: path.clone(),
80 range,
81 });
82 }
83 }
84 found
85}
86
87#[derive(Default)]
90pub struct Found {
91 matches: Vec<Match>,
92 current: Option<usize>,
93 reveal: bool,
94}
95
96impl Found {
97 pub fn set(&mut self, matches: Vec<Match>) {
101 self.matches = matches;
102 self.current = self
103 .current
104 .filter(|&at| at < self.matches.len())
105 .or_else(|| (!self.matches.is_empty()).then_some(0));
106 }
107
108 pub fn clear(&mut self) {
110 *self = Self::default();
111 }
112
113 pub fn len(&self) -> usize {
115 self.matches.len()
116 }
117
118 pub fn is_empty(&self) -> bool {
120 self.matches.is_empty()
121 }
122
123 pub fn current(&self) -> Option<usize> {
125 self.current
126 }
127
128 pub fn show(&mut self, index: usize) -> Option<&Match> {
130 let found = self.matches.get(index)?;
131 self.current = Some(index);
132 self.reveal = true;
133 Some(found)
134 }
135
136 pub fn current_match(&self) -> Option<&Match> {
138 self.matches.get(self.current?)
139 }
140
141 pub fn highlights(&self, scope: usize) -> Option<Highlights<'_>> {
145 if self.matches.is_empty() {
146 return None;
147 }
148 Some(Highlights {
149 matches: &self.matches,
150 current: self.current,
151 reveal: self.reveal,
152 scope,
153 })
154 }
155
156 pub fn drawn(&mut self) {
159 self.reveal = false;
160 }
161}
162
163#[derive(Clone)]
165pub struct Highlights<'a> {
166 matches: &'a [Match],
167 current: Option<usize>,
168 reveal: bool,
169 scope: usize,
170}
171
172impl Highlights<'_> {
173 pub fn within(&self, paragraph: &[usize]) -> Vec<(Range<usize>, bool)> {
175 let first = self
176 .matches
177 .partition_point(|m| (m.scope, m.paragraph.as_slice()) < (self.scope, paragraph));
178 self.matches[first..]
179 .iter()
180 .enumerate()
181 .take_while(|(_, m)| m.scope == self.scope && m.paragraph == paragraph)
182 .map(|(offset, m)| (m.range.clone(), self.current == Some(first + offset)))
183 .collect()
184 }
185
186 pub fn reveal(&self) -> bool {
188 self.reveal
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn a_scan_finds_paragraphs_by_the_paths_a_flow_reports() {
198 let bytes = std::fs::read(
199 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
200 .join("../../corpus/libreoffice/text.odt"),
201 )
202 .expect("the corpus document");
203 let document = odox_core::doc::TextDocument::read(&bytes).expect("it reads");
204 let body = document.body().expect("it has a body");
205 let found = in_paragraphs(body, 0, "AND");
206 assert_eq!(found.len(), 6);
207 for hit in &found {
208 let paragraph = body.at(&hit.paragraph).expect("a path to a paragraph");
209 let text: Vec<char> = edit::text(paragraph).chars().collect();
210 let matched: String = text[hit.range.clone()].iter().collect();
211 assert_eq!(matched.to_lowercase(), "and");
212 }
213 }
214
215 #[test]
216 fn a_query_is_found_whatever_its_case() {
217 assert_eq!(ranges("Odox and ODOX", "odox"), vec![0..4, 9..13]);
218 }
219
220 #[test]
221 fn matches_do_not_overlap() {
222 assert_eq!(ranges("aaaa", "aa"), vec![0..2, 2..4]);
223 }
224
225 #[test]
226 fn an_empty_query_matches_nothing() {
227 assert_eq!(ranges("anything", ""), Vec::<Range<usize>>::new());
228 }
229
230 #[test]
231 fn ranges_count_characters_and_not_bytes() {
232 assert_eq!(ranges("ünï ünï", "ünï"), vec![0..3, 4..7]);
233 }
234
235 fn found_in(paragraph: &[usize], range: Range<usize>) -> Match {
236 Match {
237 scope: 0,
238 paragraph: paragraph.to_vec(),
239 range,
240 }
241 }
242
243 #[test]
244 fn a_paragraphs_highlights_are_the_matches_with_its_path() {
245 let mut found = Found::default();
246 found.set(vec![
247 found_in(&[0], 1..2),
248 found_in(&[1], 0..3),
249 found_in(&[1], 5..8),
250 found_in(&[2, 0], 0..1),
251 ]);
252 found.show(2);
253 let highlights = found.highlights(0).expect("there are matches");
254 assert_eq!(highlights.within(&[1]), vec![(0..3, false), (5..8, true)]);
255 assert_eq!(highlights.within(&[3]), Vec::<(Range<usize>, bool)>::new());
256 }
257
258 #[test]
259 fn the_current_match_survives_new_results_that_still_reach_it() {
260 let mut found = Found::default();
261 found.set(vec![found_in(&[0], 0..1), found_in(&[1], 0..1)]);
262 found.show(1);
263 found.set(vec![found_in(&[0], 0..1), found_in(&[1], 0..2)]);
264 assert_eq!(found.current(), Some(1));
265 found.set(vec![found_in(&[0], 0..1)]);
266 assert_eq!(found.current(), Some(0));
267 }
268
269 #[test]
270 fn a_scroll_is_owed_until_the_frame_is_drawn() {
271 let mut found = Found::default();
272 found.set(vec![found_in(&[0], 0..1)]);
273 found.show(0);
274 assert!(found.highlights(0).is_some_and(|h| h.reveal()));
275 assert!(found.highlights(0).is_some_and(|h| h.reveal()));
276 found.drawn();
277 assert!(found.highlights(0).is_some_and(|h| !h.reveal()));
278 }
279}