ailloli_ui_editor/code/folding.rs
1//! Logical-line fold regions, discovery, and collapsed-state transfer.
2
3use std::collections::HashMap;
4
5use crate::code::EditorLanguage;
6
7/// Collapsible range of zero-based logical lines.
8///
9/// The line at [`FoldRegion::start_line`] remains visible when the region is
10/// collapsed; lines through [`FoldRegion::end_line`] are hidden. Callers are
11/// responsible for constructing ranges whose end is not before their start.
12///
13/// # Examples
14///
15/// ```
16/// use ailloli_ui_editor::FoldRegion;
17///
18/// let region = FoldRegion::new(3, 6).collapsed(true);
19/// assert!(!region.hides_line(3));
20/// assert!(region.hides_line(4));
21/// assert!(region.hides_line(6));
22/// ```
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
24pub struct FoldRegion {
25 /// Stable identifier derived from the start and end lines.
26 pub id: FoldRegionId,
27 /// Zero-based header line that remains visible while collapsed.
28 pub start_line: usize,
29 /// Zero-based inclusive last line hidden while collapsed.
30 pub end_line: usize,
31 /// Whether the region currently hides its body lines.
32 pub collapsed: bool,
33}
34
35/// Packed identity for a fold region's line range.
36///
37/// The representation is collision-free when both line indices fit in 32 bits.
38/// Values outside that range are not rejected and can collide, so persistent
39/// callers should keep both inputs at or below [`u32::MAX`].
40///
41/// # Examples
42///
43/// ```
44/// use ailloli_ui_editor::FoldRegionId;
45///
46/// assert_eq!(FoldRegionId::from_lines(2, 9), FoldRegionId((2_u64 << 32) | 9));
47/// ```
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
49pub struct FoldRegionId(pub u64);
50
51/// Constructs and queries fold regions.
52impl FoldRegion {
53 /// Creates an expanded region spanning `start_line..=end_line`.
54 ///
55 /// The inputs are stored without ordering validation. Consequently, a
56 /// reversed range hides no lines and reports a hidden count of zero.
57 ///
58 /// # Examples
59 ///
60 /// ```
61 /// use ailloli_ui_editor::FoldRegion;
62 ///
63 /// let region = FoldRegion::new(4, 7);
64 /// assert_eq!((region.start_line, region.end_line), (4, 7));
65 /// assert!(!region.collapsed);
66 /// ```
67 pub fn new(start_line: usize, end_line: usize) -> Self {
68 Self {
69 id: FoldRegionId::from_lines(start_line, end_line),
70 start_line,
71 end_line,
72 collapsed: false,
73 }
74 }
75
76 /// Sets whether the region is collapsed and returns it.
77 ///
78 /// # Examples
79 ///
80 /// ```
81 /// use ailloli_ui_editor::FoldRegion;
82 ///
83 /// assert!(FoldRegion::new(0, 2).collapsed(true).collapsed);
84 /// ```
85 pub fn collapsed(mut self, collapsed: bool) -> Self {
86 self.collapsed = collapsed;
87 self
88 }
89
90 /// Returns whether a zero-based line is hidden by this region.
91 ///
92 /// The header line is never hidden and the end line is inclusive.
93 ///
94 /// # Examples
95 ///
96 /// ```
97 /// use ailloli_ui_editor::FoldRegion;
98 ///
99 /// let region = FoldRegion::new(5, 6).collapsed(true);
100 /// assert_eq!((region.hides_line(5), region.hides_line(6)), (false, true));
101 /// ```
102 pub fn hides_line(self, line: usize) -> bool {
103 self.collapsed && line > self.start_line && line <= self.end_line
104 }
105
106 /// Returns the number of body lines in the range, whether expanded or not.
107 ///
108 /// Reversed ranges return zero rather than underflowing.
109 ///
110 /// # Examples
111 ///
112 /// ```
113 /// use ailloli_ui_editor::FoldRegion;
114 ///
115 /// assert_eq!(FoldRegion::new(2, 5).hidden_line_count(), 3);
116 /// assert_eq!(FoldRegion::new(5, 2).hidden_line_count(), 0);
117 /// ```
118 pub fn hidden_line_count(self) -> usize {
119 self.end_line.saturating_sub(self.start_line)
120 }
121}
122
123/// Creates packed fold-region identifiers.
124impl FoldRegionId {
125 /// Packs the start line into the high 32 bits and ORs in the end line.
126 ///
127 /// Inputs are not range-checked. The identifier is uniquely reversible
128 /// only when both inputs fit in [`u32`].
129 ///
130 /// # Examples
131 ///
132 /// ```
133 /// use ailloli_ui_editor::FoldRegionId;
134 ///
135 /// let id = FoldRegionId::from_lines(12, 34);
136 /// assert_eq!(id.0 >> 32, 12);
137 /// assert_eq!(id.0 as u32, 34);
138 /// ```
139 pub fn from_lines(start_line: usize, end_line: usize) -> Self {
140 Self(((start_line as u64) << 32) | end_line as u64)
141 }
142}
143
144/// Transfers collapsed state from matching previous region identifiers.
145///
146/// Regions without a previous match keep their supplied state and the order of
147/// `next` is preserved. If `previous` contains a duplicate identifier, its last
148/// occurrence wins.
149///
150/// # Examples
151///
152/// ```
153/// use ailloli_ui_editor::{code::merge_fold_regions_with_previous, FoldRegion};
154///
155/// let previous = [FoldRegion::new(1, 3).collapsed(true)];
156/// let merged = merge_fold_regions_with_previous(vec![FoldRegion::new(1, 3)], &previous);
157/// assert!(merged[0].collapsed);
158/// ```
159pub fn merge_fold_regions_with_previous(
160 mut next: Vec<FoldRegion>,
161 previous: &[FoldRegion],
162) -> Vec<FoldRegion> {
163 let collapsed_by_id: HashMap<_, _> = previous
164 .iter()
165 .map(|region| (region.id, region.collapsed))
166 .collect();
167 for region in &mut next {
168 if let Some(collapsed) = collapsed_by_id.get(®ion.id) {
169 region.collapsed = *collapsed;
170 }
171 }
172 next
173}
174
175/// Returns the index of the first non-empty region starting on `line`.
176///
177/// # Examples
178///
179/// ```
180/// use ailloli_ui_editor::{code::fold_region_at_line, FoldRegion};
181///
182/// let regions = [FoldRegion::new(2, 2), FoldRegion::new(2, 5)];
183/// assert_eq!(fold_region_at_line(®ions, 2), Some(1));
184/// assert_eq!(fold_region_at_line(®ions, 5), None);
185/// ```
186pub fn fold_region_at_line(regions: &[FoldRegion], line: usize) -> Option<usize> {
187 regions
188 .iter()
189 .position(|region| region.start_line == line && region.end_line > region.start_line)
190}
191
192/// Returns the first collapsed region that hides `line`.
193///
194/// Overlapping regions are resolved by slice order rather than by smallest or
195/// outermost range.
196///
197/// # Examples
198///
199/// ```
200/// use ailloli_ui_editor::{code::collapsed_region_hiding_line, FoldRegion};
201///
202/// let regions = [FoldRegion::new(1, 4).collapsed(true)];
203/// assert_eq!(collapsed_region_hiding_line(®ions, 3), Some(regions[0]));
204/// assert_eq!(collapsed_region_hiding_line(®ions, 1), None);
205/// ```
206pub fn collapsed_region_hiding_line(regions: &[FoldRegion], line: usize) -> Option<FoldRegion> {
207 regions
208 .iter()
209 .copied()
210 .find(|region| region.hides_line(line))
211}
212
213/// Returns the UTF-8 byte offset at which a zero-based logical line starts.
214///
215/// Line zero starts at byte zero. A line beyond the available newline
216/// separators maps to `text.len()`, including in an empty string.
217///
218/// # Examples
219///
220/// ```
221/// use ailloli_ui_editor::code::line_start_byte;
222///
223/// assert_eq!(line_start_byte("é\nnext", 1), 3);
224/// assert_eq!(line_start_byte("one", 9), 3);
225/// ```
226pub fn line_start_byte(text: &str, line: usize) -> usize {
227 if line == 0 {
228 return 0;
229 }
230 let mut current = 0;
231 for (offset, ch) in text.char_indices() {
232 if ch == '\n' {
233 current += 1;
234 if current == line {
235 return offset + ch.len_utf8();
236 }
237 }
238 }
239 text.len()
240}
241
242/// Counts newline bytes before a UTF-8 byte offset.
243///
244/// Offsets beyond the text are clamped to `text.len()`.
245///
246/// # Panics
247///
248/// Panics when an in-bounds `byte` is not a UTF-8 character boundary.
249///
250/// # Examples
251///
252/// ```
253/// use ailloli_ui_editor::code::line_for_byte;
254///
255/// let text = "é\nnext";
256/// assert_eq!(line_for_byte(text, 2), 0);
257/// assert_eq!(line_for_byte(text, 3), 1);
258/// assert_eq!(line_for_byte(text, usize::MAX), 1);
259/// ```
260pub fn line_for_byte(text: &str, byte: usize) -> usize {
261 text[..byte.min(text.len())]
262 .bytes()
263 .filter(|byte| *byte == b'\n')
264 .count()
265}
266
267#[cfg(feature = "tree_sitter")]
268/// Discovers multi-line foldable constructs for a document.
269///
270/// With the `tree_sitter` feature, Rust documents are parsed and their regions
271/// are sorted and deduplicated; unsupported languages return an empty vector.
272/// Parser failures also produce an empty vector.
273///
274/// # Examples
275///
276/// ```
277/// use ailloli_ui_editor::{code::fold_regions_for_document, EditorLanguage};
278///
279/// let regions = fold_regions_for_document(EditorLanguage::Rust, "fn f() {\n 1;\n}\n");
280/// assert_eq!(regions.len(), 1);
281/// assert_eq!((regions[0].start_line, regions[0].end_line), (0, 2));
282/// assert!(fold_regions_for_document(EditorLanguage::PlainText, "a\nb").is_empty());
283/// ```
284pub fn fold_regions_for_document(language: EditorLanguage, text: &str) -> Vec<FoldRegion> {
285 match language {
286 EditorLanguage::Rust => rust_tree_sitter_fold_regions(text),
287 _ => Vec::new(),
288 }
289}
290
291#[cfg(not(feature = "tree_sitter"))]
292/// Returns no discovered regions when parser support is disabled.
293///
294/// Manually supplied [`FoldRegion`] values remain usable without the
295/// `tree_sitter` feature.
296///
297/// # Examples
298///
299/// ```
300/// use ailloli_ui_editor::{code::fold_regions_for_document, EditorLanguage};
301///
302/// assert!(fold_regions_for_document(EditorLanguage::Rust, "fn f() {\n}\n").is_empty());
303/// ```
304pub fn fold_regions_for_document(_language: EditorLanguage, _text: &str) -> Vec<FoldRegion> {
305 Vec::new()
306}
307
308#[cfg(feature = "tree_sitter")]
309/// Parses Rust text and returns its sorted, deduplicated fold regions.
310fn rust_tree_sitter_fold_regions(text: &str) -> Vec<FoldRegion> {
311 let mut parser = tree_sitter::Parser::new();
312 let language = tree_sitter_rust::LANGUAGE.into();
313 if parser.set_language(&language).is_err() {
314 return Vec::new();
315 }
316 let Some(tree) = parser.parse(text, None) else {
317 return Vec::new();
318 };
319 let mut regions = Vec::new();
320 collect_rust_fold_regions(tree.root_node(), &mut regions);
321 regions.sort_by_key(|region| (region.start_line, region.end_line));
322 regions.dedup_by_key(|region| (region.start_line, region.end_line));
323 regions
324}
325
326#[cfg(feature = "tree_sitter")]
327/// Recursively collects the foldable nodes below `node`.
328fn collect_rust_fold_regions(node: tree_sitter::Node<'_>, regions: &mut Vec<FoldRegion>) {
329 if is_foldable_rust_node(node) {
330 let start = node.start_position().row;
331 let end = node.end_position().row;
332 if end > start {
333 regions.push(FoldRegion::new(start, end));
334 }
335 }
336 let mut cursor = node.walk();
337 for child in node.children(&mut cursor) {
338 collect_rust_fold_regions(child, regions);
339 }
340}
341
342#[cfg(feature = "tree_sitter")]
343/// Reports whether a Rust syntax node defines a foldable construct.
344fn is_foldable_rust_node(node: tree_sitter::Node<'_>) -> bool {
345 matches!(
346 node.kind(),
347 "mod_item"
348 | "impl_item"
349 | "function_item"
350 | "trait_item"
351 | "struct_item"
352 | "enum_item"
353 | "block"
354 | "block_comment"
355 )
356}