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(Clone, Copy, Debug, Default, PartialEq, Eq)]
90pub struct Replaced {
91 pub replaced: usize,
93 pub skipped: usize,
96}
97
98pub fn replace_ranges(text: &str, ranges: &[Range<usize>], with: &str) -> String {
101 let mut out = String::new();
102 let mut from = 0;
103 for range in ranges {
104 out.extend(
105 text.chars()
106 .skip(from)
107 .take(range.start.saturating_sub(from)),
108 );
109 out.push_str(with);
110 from = range.end;
111 }
112 out.extend(text.chars().skip(from));
113 out
114}
115
116#[derive(Default)]
119pub struct Found {
120 matches: Vec<Match>,
121 current: Option<usize>,
122 reveal: bool,
123}
124
125impl Found {
126 pub fn set(&mut self, matches: Vec<Match>) {
130 self.matches = matches;
131 self.current = self
132 .current
133 .filter(|&at| at < self.matches.len())
134 .or_else(|| (!self.matches.is_empty()).then_some(0));
135 }
136
137 pub fn clear(&mut self) {
139 *self = Self::default();
140 }
141
142 pub fn len(&self) -> usize {
144 self.matches.len()
145 }
146
147 pub fn is_empty(&self) -> bool {
149 self.matches.is_empty()
150 }
151
152 pub fn all(&self) -> &[Match] {
154 &self.matches
155 }
156
157 pub fn current(&self) -> Option<usize> {
159 self.current
160 }
161
162 pub fn show(&mut self, index: usize) -> Option<&Match> {
164 let found = self.matches.get(index)?;
165 self.current = Some(index);
166 self.reveal = true;
167 Some(found)
168 }
169
170 pub fn current_match(&self) -> Option<&Match> {
172 self.matches.get(self.current?)
173 }
174
175 pub fn highlights(&self, scope: usize) -> Option<Highlights<'_>> {
179 if self.matches.is_empty() {
180 return None;
181 }
182 Some(Highlights {
183 matches: &self.matches,
184 current: self.current,
185 reveal: self.reveal,
186 scope,
187 })
188 }
189
190 pub fn drawn(&mut self) {
193 self.reveal = false;
194 }
195}
196
197#[derive(Clone)]
199pub struct Highlights<'a> {
200 matches: &'a [Match],
201 current: Option<usize>,
202 reveal: bool,
203 scope: usize,
204}
205
206impl Highlights<'_> {
207 pub fn within(&self, paragraph: &[usize]) -> Vec<(Range<usize>, bool)> {
209 let first = self
210 .matches
211 .partition_point(|m| (m.scope, m.paragraph.as_slice()) < (self.scope, paragraph));
212 self.matches[first..]
213 .iter()
214 .enumerate()
215 .take_while(|(_, m)| m.scope == self.scope && m.paragraph == paragraph)
216 .map(|(offset, m)| (m.range.clone(), self.current == Some(first + offset)))
217 .collect()
218 }
219
220 pub fn reveal(&self) -> bool {
222 self.reveal
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn a_scan_finds_paragraphs_by_the_paths_a_flow_reports() {
232 let bytes = std::fs::read(
233 std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
234 .join("../../corpus/libreoffice/text.odt"),
235 )
236 .expect("the corpus document");
237 let document = odox_core::doc::TextDocument::read(&bytes).expect("it reads");
238 let body = document.body().expect("it has a body");
239 let found = in_paragraphs(body, 0, "AND");
240 assert_eq!(found.len(), 6);
241 for hit in &found {
242 let paragraph = body.at(&hit.paragraph).expect("a path to a paragraph");
243 let text: Vec<char> = edit::text(paragraph).chars().collect();
244 let matched: String = text[hit.range.clone()].iter().collect();
245 assert_eq!(matched.to_lowercase(), "and");
246 }
247 }
248
249 #[test]
250 fn ranges_are_replaced_in_place_and_counted_in_characters() {
251 assert_eq!(replace_ranges("ünï ünï x", &[0..3, 4..7], "a"), "a a x");
252 assert_eq!(
253 replace_ranges("abc", std::slice::from_ref(&(1..2)), ""),
254 "ac"
255 );
256 assert_eq!(replace_ranges("abc", &[], "z"), "abc");
257 }
258
259 #[test]
260 fn a_query_is_found_whatever_its_case() {
261 assert_eq!(ranges("Odox and ODOX", "odox"), vec![0..4, 9..13]);
262 }
263
264 #[test]
265 fn matches_do_not_overlap() {
266 assert_eq!(ranges("aaaa", "aa"), vec![0..2, 2..4]);
267 }
268
269 #[test]
270 fn an_empty_query_matches_nothing() {
271 assert_eq!(ranges("anything", ""), Vec::<Range<usize>>::new());
272 }
273
274 #[test]
275 fn ranges_count_characters_and_not_bytes() {
276 assert_eq!(ranges("ünï ünï", "ünï"), vec![0..3, 4..7]);
277 }
278
279 fn found_in(paragraph: &[usize], range: Range<usize>) -> Match {
280 Match {
281 scope: 0,
282 paragraph: paragraph.to_vec(),
283 range,
284 }
285 }
286
287 #[test]
288 fn a_paragraphs_highlights_are_the_matches_with_its_path() {
289 let mut found = Found::default();
290 found.set(vec![
291 found_in(&[0], 1..2),
292 found_in(&[1], 0..3),
293 found_in(&[1], 5..8),
294 found_in(&[2, 0], 0..1),
295 ]);
296 found.show(2);
297 let highlights = found.highlights(0).expect("there are matches");
298 assert_eq!(highlights.within(&[1]), vec![(0..3, false), (5..8, true)]);
299 assert_eq!(highlights.within(&[3]), Vec::<(Range<usize>, bool)>::new());
300 }
301
302 #[test]
303 fn the_current_match_survives_new_results_that_still_reach_it() {
304 let mut found = Found::default();
305 found.set(vec![found_in(&[0], 0..1), found_in(&[1], 0..1)]);
306 found.show(1);
307 found.set(vec![found_in(&[0], 0..1), found_in(&[1], 0..2)]);
308 assert_eq!(found.current(), Some(1));
309 found.set(vec![found_in(&[0], 0..1)]);
310 assert_eq!(found.current(), Some(0));
311 }
312
313 #[test]
314 fn a_scroll_is_owed_until_the_frame_is_drawn() {
315 let mut found = Found::default();
316 found.set(vec![found_in(&[0], 0..1)]);
317 found.show(0);
318 assert!(found.highlights(0).is_some_and(|h| h.reveal()));
319 assert!(found.highlights(0).is_some_and(|h| h.reveal()));
320 found.drawn();
321 assert!(found.highlights(0).is_some_and(|h| !h.reveal()));
322 }
323}