Skip to main content

tuwunel_core/utils/string/
chunk.rs

1//! Budget-driven text segmentation. The size budget is injected as a `fits`
2//! predicate, so the caller owns measurement; markdown mode keeps code fences
3//! self-contained across segment boundaries.
4
5use std::iter::once;
6
7/// Byte offset past a line, paired with the fence info open after it.
8type Line<'a> = (usize, Option<&'a str>);
9
10struct Chunker<'a, F> {
11	rest: &'a str,
12	markdown: bool,
13	fits: F,
14
15	/// Info string of a code fence left open by the previous segment, reopened
16	/// at the head of the next.
17	fence: Option<&'a str>,
18
19	emitted: bool,
20}
21
22/// Where the next segment ends.
23enum Cut<'a> {
24	/// The whole remainder fits: consume it as the final segment.
25	All,
26
27	/// Cut before this byte offset, carrying this fence state to the next
28	/// segment (a closing fence is injected when it is open).
29	At {
30		offset: usize,
31		fence: Option<&'a str>,
32	},
33
34	/// No whole line fits; the first line must be split at a character
35	/// boundary.
36	Line,
37}
38
39/// Lazily splits `text` using a monotonic `fits` predicate.
40///
41/// For candidates with the same starting position, `fits` must remain false as
42/// their length increases. The iterator chooses fitting prefixes when possible;
43/// if even the first character fails, it emits that character to guarantee
44/// progress. Empty input yields one empty segment without consulting `fits`.
45///
46/// Whole lines are packed greedily. When no complete line fits, the first line
47/// is split at a character boundary.
48///
49/// In markdown mode, a whole-line cut that leaves a triple-backtick fence open
50/// appends a closing fence and reopens it with the same info string in the next
51/// segment. Partial-line cuts do not append a closing fence, so an oversized
52/// fenced line can produce segments that do not render independently.
53///
54/// A bounded `take` can stop the iterator before it scans the entire input.
55pub 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	/// Finds the greatest whole-line prefix of `self.rest` that fits, by an
116	/// exponential then binary search over line count. Detects when the whole
117	/// remainder fits, and reports when not even one line does.
118	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; // greatest line index known to fit as a proper split
129		let mut hi; // least line index known not to fit
130		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	/// Splits the first line at the greatest character boundary that fits,
176	/// advancing at least one character so progress is guaranteed. The fence
177	/// state is unchanged: a partial line toggles nothing.
178	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
204/// Extends `lines` to cover at least `upto` lines of `rest`, or until `rest`
205/// is exhausted. Each entry pairs the byte offset past a line with the fence
206/// info open after it (markdown mode only).
207fn 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
231/// Toggles `fence` when `line` is a code-fence delimiter: an outside delimiter
232/// opens a fence carrying its info string, an inside delimiter closes it.
233fn 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
241/// The info string of a triple-backtick fence line, or `None` when the line is
242/// not a fence delimiter.
243fn 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
251/// Joins the reopened head, body, and an optional closing fence into a segment.
252fn assemble(head: &str, body: &str, close: bool) -> String {
253	match close {
254		| true => format!("{head}{body}```\n"),
255		| false => format!("{head}{body}"),
256	}
257}