use std::collections::HashSet;
use crate::text_buffer::TextBuffer;
use super::TAB_WIDTH;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FoldRegion {
pub start_line: usize,
pub end_line: usize,
}
impl FoldRegion {
pub fn new(start_line: usize, end_line: usize) -> Self {
Self { start_line, end_line }
}
}
fn indent_width(line: &str) -> Option<usize> {
let mut width = 0;
for c in line.chars() {
match c {
'\t' => width += TAB_WIDTH,
' ' => width += 1,
_ if c.is_whitespace() => width += 1,
_ => return Some(width),
}
}
None
}
pub fn compute_foldable_regions(buffer: &TextBuffer) -> Vec<FoldRegion> {
let line_count = buffer.line_count();
let indents: Vec<Option<usize>> =
(0..line_count).map(|i| indent_width(buffer.line(i))).collect();
let mut regions: Vec<FoldRegion> = Vec::new();
let mut open_regions: Vec<(usize, usize)> = Vec::new();
let mut non_blank = indents
.iter()
.enumerate()
.filter_map(|(line, indent)| indent.map(|width| (line, width)))
.peekable();
let mut previous_non_blank = None;
while let Some((line, indent)) = non_blank.next() {
while open_regions
.last()
.is_some_and(|(header_indent, _)| *header_indent >= indent)
{
if let Some((_, region_index)) = open_regions.pop()
&& let Some(end_line) = previous_non_blank
&& let Some(region) = regions.get_mut(region_index)
{
region.end_line = end_line;
}
}
if non_blank
.peek()
.is_some_and(|(_, next_indent)| *next_indent > indent)
{
let region_index = regions.len();
regions.push(FoldRegion::new(line, line));
open_regions.push((indent, region_index));
}
previous_non_blank = Some(line);
}
if let Some(end_line) = previous_non_blank {
for (_, region_index) in open_regions {
if let Some(region) = regions.get_mut(region_index) {
region.end_line = end_line;
}
}
}
regions
}
pub fn is_fold_header(regions: &[FoldRegion], line: usize) -> bool {
regions.binary_search_by_key(&line, |region| region.start_line).is_ok()
}
pub fn is_line_fold_header(buffer: &TextBuffer, line: usize) -> bool {
let Some(header_indent) = indent_width(buffer.line(line)) else {
return false;
};
(line.saturating_add(1)..buffer.line_count())
.find_map(|next_line| indent_width(buffer.line(next_line)))
.is_some_and(|next_indent| next_indent > header_indent)
}
pub fn hidden_lines(
regions: &[FoldRegion],
collapsed: &HashSet<usize>,
) -> HashSet<usize> {
let mut hidden = HashSet::new();
for region in regions {
if collapsed.contains(®ion.start_line) {
hidden.extend((region.start_line + 1)..=region.end_line);
}
}
hidden
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_indent_width_blank_lines() {
assert_eq!(indent_width(""), None);
assert_eq!(indent_width(" "), None);
assert_eq!(indent_width("\t"), None);
}
#[test]
fn test_indent_width_expands_tabs() {
assert_eq!(indent_width("code"), Some(0));
assert_eq!(indent_width(" code"), Some(2));
assert_eq!(indent_width("\tcode"), Some(TAB_WIDTH));
assert_eq!(indent_width("\t code"), Some(TAB_WIDTH + 2));
}
#[test]
fn test_no_regions_for_flat_text() {
let buffer = TextBuffer::new("a\nb\nc");
assert!(compute_foldable_regions(&buffer).is_empty());
}
#[test]
fn test_simple_block() {
let buffer =
TextBuffer::new("fn main() {\n let x = 1;\n let y = 2;\n}");
let regions = compute_foldable_regions(&buffer);
assert_eq!(regions, vec![FoldRegion::new(0, 2)]);
}
#[test]
fn test_nested_blocks() {
let buffer = TextBuffer::new(
"outer:\n inner:\n deep\n deeper\n after_inner",
);
let regions = compute_foldable_regions(&buffer);
assert_eq!(regions, vec![FoldRegion::new(0, 4), FoldRegion::new(1, 3)]);
}
#[test]
fn test_blank_lines_inside_and_trailing() {
let buffer =
TextBuffer::new("def f():\n a = 1\n\n b = 2\n\ng = 3");
let regions = compute_foldable_regions(&buffer);
assert_eq!(regions, vec![FoldRegion::new(0, 3)]);
}
#[test]
fn test_hidden_lines_single_collapsed() {
let regions = vec![FoldRegion::new(0, 2)];
let collapsed: HashSet<usize> = [0].into_iter().collect();
let hidden = hidden_lines(®ions, &collapsed);
assert_eq!(hidden, [1, 2].into_iter().collect());
}
#[test]
fn test_hidden_lines_nested_union() {
let regions = vec![FoldRegion::new(0, 4), FoldRegion::new(1, 3)];
let collapsed: HashSet<usize> = [1].into_iter().collect();
assert_eq!(
hidden_lines(®ions, &collapsed),
[2, 3].into_iter().collect()
);
let collapsed: HashSet<usize> = [0, 1].into_iter().collect();
assert_eq!(
hidden_lines(®ions, &collapsed),
[1, 2, 3, 4].into_iter().collect()
);
}
#[test]
fn test_hidden_lines_ignores_unknown_collapsed() {
let regions = vec![FoldRegion::new(0, 2)];
let collapsed: HashSet<usize> = [99].into_iter().collect();
assert!(hidden_lines(®ions, &collapsed).is_empty());
}
#[test]
fn test_is_fold_header() {
let regions = vec![FoldRegion::new(0, 2), FoldRegion::new(5, 7)];
assert!(is_fold_header(®ions, 0));
assert!(is_fold_header(®ions, 5));
assert!(!is_fold_header(®ions, 1));
assert!(!is_fold_header(®ions, 3));
}
#[test]
fn test_is_line_fold_header_skips_blank_lines() {
let buffer = TextBuffer::new("header\n\n body\nsibling");
assert!(is_line_fold_header(&buffer, 0));
assert!(!is_line_fold_header(&buffer, 1));
assert!(!is_line_fold_header(&buffer, 2));
}
}