tuwunel_core/utils/string/
chunk.rs1use std::iter::once;
6
7type Line<'a> = (usize, Option<&'a str>);
9
10struct Chunker<'a, F> {
11 rest: &'a str,
12 markdown: bool,
13 fits: F,
14
15 fence: Option<&'a str>,
18
19 emitted: bool,
20}
21
22enum Cut<'a> {
24 All,
26
27 At {
30 offset: usize,
31 fence: Option<&'a str>,
32 },
33
34 Line,
37}
38
39pub fn chunk<F>(text: &str, markdown: bool, fits: F) -> impl Iterator<Item = String>
56where
57 F: Fn(&str) -> bool,
58{
59 Chunker {
60 rest: text,
61 markdown,
62 fits,
63 fence: None,
64 emitted: false,
65 }
66}
67
68impl<F> Iterator for Chunker<'_, F>
69where
70 F: Fn(&str) -> bool,
71{
72 type Item = String;
73
74 fn next(&mut self) -> Option<Self::Item> {
75 if self.rest.is_empty() {
76 return (!self.emitted).then(|| {
77 self.emitted = true;
78 String::new()
79 });
80 }
81
82 self.emitted = true;
83
84 Some(self.segment())
85 }
86}
87
88impl<'a, F> Chunker<'a, F>
89where
90 F: Fn(&str) -> bool,
91{
92 fn segment(&mut self) -> String {
93 let head = self.fence.map_or_else(String::new, reopen);
94
95 match self.cut(&head) {
96 | Cut::All => {
97 let segment = assemble(&head, self.rest, false);
98 self.rest = "";
99 self.fence = None;
100
101 segment
102 },
103 | Cut::At { offset, fence } => {
104 let (body, rest) = self.rest.split_at(offset);
105 let segment = assemble(&head, body, fence.is_some());
106 self.rest = rest;
107 self.fence = fence;
108
109 segment
110 },
111 | Cut::Line => self.split_line(&head),
112 }
113 }
114
115 fn cut(&self, head: &str) -> Cut<'a> {
119 let rest = self.rest;
120 let mut lines: Vec<Line<'a>> = Vec::new();
121
122 let split_fits = |(end, fence): Line<'a>| {
123 let (body, _) = rest.split_at(end);
124
125 (self.fits)(&assemble(head, body, fence.is_some()))
126 };
127
128 let mut lo = None; let mut hi; let mut probe: usize = 0;
131
132 loop {
133 discover(rest, self.fence, self.markdown, &mut lines, probe.saturating_add(1));
134
135 if lines
136 .get(probe)
137 .is_none_or(|&(end, _)| end == rest.len())
138 {
139 if (self.fits)(&assemble(head, rest, false)) {
140 return Cut::All;
141 }
142
143 hi = lines.len().saturating_sub(1);
144 break;
145 }
146
147 if split_fits(lines[probe]) {
148 lo = Some(probe);
149 probe = probe.saturating_mul(2).saturating_add(1);
150 } else {
151 hi = probe;
152 break;
153 }
154 }
155
156 let Some(mut lo) = lo else {
157 return Cut::Line;
158 };
159
160 while hi.abs_diff(lo) > 1 {
161 let mid = lo.midpoint(hi);
162
163 if split_fits(lines[mid]) {
164 lo = mid;
165 } else {
166 hi = mid;
167 }
168 }
169
170 let (offset, fence) = lines[lo];
171
172 Cut::At { offset, fence }
173 }
174
175 fn split_line(&mut self, head: &str) -> String {
179 let rest = self.rest;
180 let line = rest.split_inclusive('\n').next().unwrap_or(rest);
181
182 let bounds: Vec<usize> = line
183 .char_indices()
184 .skip(1)
185 .map(|(i, _)| i)
186 .chain(once(line.len()))
187 .collect();
188
189 let prefix_fits =
190 |&bound: &usize| (self.fits)(&assemble(head, line.split_at(bound).0, false));
191
192 let split = bounds
193 .partition_point(prefix_fits)
194 .checked_sub(1)
195 .map_or(bounds[0], |i| bounds[i]);
196
197 let (line, rest) = rest.split_at(split);
198 self.rest = rest;
199
200 assemble(head, line, false)
201 }
202}
203
204fn discover<'a>(
208 rest: &'a str,
209 start_fence: Option<&'a str>,
210 markdown: bool,
211 lines: &mut Vec<Line<'a>>,
212 upto: usize,
213) {
214 let (mut end, mut fence) = lines.last().copied().unwrap_or((0, start_fence));
215 let (_, tail) = rest.split_at(end);
216
217 for line in tail
218 .split_inclusive('\n')
219 .take(upto.saturating_sub(lines.len()))
220 {
221 end = end.saturating_add(line.len());
222
223 if markdown {
224 toggle_fence(&mut fence, line);
225 }
226
227 lines.push((end, fence));
228 }
229}
230
231fn toggle_fence<'a>(fence: &mut Option<&'a str>, line: &'a str) {
234 let Some(info) = fence_line_info(line) else {
235 return;
236 };
237
238 *fence = fence.is_none().then_some(info);
239}
240
241fn fence_line_info(line: &str) -> Option<&str> {
244 line.trim_start()
245 .strip_prefix("```")
246 .map(|rest| rest.trim_start_matches('`').trim())
247}
248
249fn reopen(info: &str) -> String { format!("```{info}\n") }
250
251fn assemble(head: &str, body: &str, close: bool) -> String {
253 match close {
254 | true => format!("{head}{body}```\n"),
255 | false => format!("{head}{body}"),
256 }
257}