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(&region.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(&regions, 2), Some(1));
184/// assert_eq!(fold_region_at_line(&regions, 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(&regions, 3), Some(regions[0]));
204/// assert_eq!(collapsed_region_hiding_line(&regions, 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}