use std::collections::HashSet;
#[derive(Default, Clone)]
pub struct FoldState {
collapsed: HashSet<usize>,
}
impl FoldState {
pub fn toggle(&mut self, header: usize) {
if !self.collapsed.remove(&header) {
self.collapsed.insert(header);
}
}
pub fn close(&mut self, header: usize) {
self.collapsed.insert(header);
}
pub fn open(&mut self, header: usize) {
self.collapsed.remove(&header);
}
pub fn is_collapsed(&self, header: usize) -> bool {
self.collapsed.contains(&header)
}
pub fn clear(&mut self) {
self.collapsed.clear();
}
pub fn close_all(&mut self, regions: &[(usize, usize)]) {
self.collapsed.extend(regions.iter().map(|&(h, _)| h));
}
pub fn is_empty(&self) -> bool {
self.collapsed.is_empty()
}
pub fn retain_below(&mut self, line_count: usize) {
self.collapsed.retain(|&h| h < line_count);
}
}
fn indent_width(line: &str, tab_width: usize) -> Option<usize> {
let mut width = 0;
for ch in line.chars() {
match ch {
' ' => width += 1,
'\t' => width += tab_width - (width % tab_width),
_ => return Some(width),
}
}
None
}
pub fn indent_fold_regions(lines: &[String], tab_width: usize) -> Vec<(usize, usize)> {
let tab_width = tab_width.max(1);
let mut regions = Vec::new();
let mut stack: Vec<(usize, usize)> = Vec::new();
let mut prev: Option<(usize, usize)> = None;
for (row, line) in lines.iter().enumerate() {
let Some(indent) = indent_width(line, tab_width) else {
continue; };
while let Some(&(header, header_indent)) = stack.last() {
if indent <= header_indent {
stack.pop();
let end = prev.map(|(r, _)| r).unwrap_or(header);
if end > header {
regions.push((header, end));
}
} else {
break;
}
}
if let Some((prev_row, prev_indent)) = prev
&& indent > prev_indent
{
stack.push((prev_row, prev_indent));
}
prev = Some((row, indent));
}
if let Some((last_row, _)) = prev {
for (header, _) in stack {
if last_row > header {
regions.push((header, last_row));
}
}
}
regions.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)));
regions.dedup_by_key(|(start, _)| *start);
regions
}
pub fn buffer_fold_regions(buf: &super::Buffer, tab_width: usize) -> Vec<(usize, usize)> {
let mut regions = match buf.highlighter.as_ref().filter(|e| e.has_fold_query()) {
Some(engine) => engine.fold_regions(),
None => indent_fold_regions(&buf.lines, tab_width),
};
regions.extend(import_fold_regions(&buf.lines));
regions.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)));
regions.dedup_by_key(|(start, _)| *start);
regions
}
fn is_import_line(trimmed: &str) -> bool {
const PREFIXES: &[&str] = &[
"import ",
"from ",
"use ",
"pub use ",
"using ",
"require ",
"require(",
"require_relative ",
"#include ",
"#include<",
"#include\"",
];
trimmed == "import" || PREFIXES.iter().any(|p| trimmed.starts_with(p))
}
fn bracket_delta(line: &str) -> i32 {
line.chars().fold(0, |d, ch| match ch {
'{' | '[' | '(' => d + 1,
'}' | ']' | ')' => d - 1,
_ => d,
})
}
pub fn import_fold_regions(lines: &[String]) -> Vec<(usize, usize)> {
let mut regions = Vec::new();
let mut run_start: Option<usize> = None;
let mut last_import = 0usize;
let mut depth = 0i32;
for (i, line) in lines.iter().enumerate() {
if depth > 0 {
if !line.trim().is_empty() {
last_import = i;
}
depth = (depth + bracket_delta(line)).max(0);
continue;
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if is_import_line(line.trim_start()) {
run_start.get_or_insert(i);
last_import = i;
depth = (depth + bracket_delta(line)).max(0);
} else if let Some(start) = run_start.take()
&& last_import > start
{
regions.push((start, last_import));
}
}
if let Some(start) = run_start
&& last_import > start
{
regions.push((start, last_import));
}
regions
}
#[cfg(test)]
mod tests {
use super::*;
fn lines(src: &[&str]) -> Vec<String> {
src.iter().map(|s| s.to_string()).collect()
}
#[test]
fn folds_a_simple_block() {
let src = lines(&["fn main() {", " let x = 1;", " let y = 2;", "}"]);
assert_eq!(indent_fold_regions(&src, 4), vec![(0, 2)]);
}
#[test]
fn trims_trailing_blank_lines() {
let src = lines(&["if a:", " b", "", "c"]);
assert_eq!(indent_fold_regions(&src, 4), vec![(0, 1)]);
}
#[test]
fn nested_regions_both_reported() {
let src = lines(&[
"a:", " b:", " c", " d", " e", ]);
let got = indent_fold_regions(&src, 2);
assert_eq!(got, vec![(0, 4), (1, 3)]);
}
#[test]
fn single_lines_dont_fold() {
let src = lines(&["a", "b", "c"]);
assert!(indent_fold_regions(&src, 4).is_empty());
}
#[test]
fn staircase_indent_nests_correctly() {
let src = lines(&["a", " b", " c", " d", "e"]);
assert_eq!(indent_fold_regions(&src, 2), vec![(0, 3), (1, 3), (2, 3)]);
}
#[test]
fn buffer_fold_regions_merges_indent_and_imports() {
let buf = crate::editor::Buffer {
lines: lines(&[
"use a;", "use b;", "", "fn f() {", " body;", "}", ]),
..Default::default()
};
assert_eq!(buffer_fold_regions(&buf, 4), vec![(0, 1), (3, 4)]);
}
#[test]
fn fold_state_toggle() {
let mut s = FoldState::default();
s.toggle(5);
assert!(s.is_collapsed(5));
s.toggle(5);
assert!(!s.is_collapsed(5));
}
#[test]
fn folds_a_run_of_imports() {
let src = lines(&["use a;", "use b;", "use c;", "", "fn main() {}"]);
assert_eq!(import_fold_regions(&src), vec![(0, 2)]);
}
#[test]
fn import_run_spans_internal_blank_but_trims_trailing() {
let src = lines(&[
"import os", "import sys", "", "from a import b", "", "x = 1", ]);
assert_eq!(import_fold_regions(&src), vec![(0, 3)]);
}
#[test]
fn single_import_does_not_fold() {
let src = lines(&["import os", "x = 1"]);
assert!(import_fold_regions(&src).is_empty());
}
#[test]
fn multiline_use_keeps_the_run_intact() {
let src = lines(&[
"use std::io::{self, Stdout, Write};", "use std::sync::mpsc;", "use std::thread;", "", "use anyhow::Result;", "use crossterm::event::{", " self as crossterm_event, Event,", " PushKeyboardEnhancementFlags,", "};", "use crossterm::execute;", "use crossterm::terminal::{", " EnterAlternateScreen, enable_raw_mode,", "};", "use ratatui::Terminal;", ]);
assert_eq!(import_fold_regions(&src), vec![(0, 13)]);
}
#[test]
fn multiline_python_from_import_parens() {
let src = lines(&[
"from foo import (", " a,", " b,", ")", "x = 1", ]);
assert_eq!(import_fold_regions(&src), vec![(0, 3)]);
}
#[test]
fn identifiers_starting_like_keywords_are_ignored() {
let src = lines(&["use_count = 1", "importing = true", "from_date = x"]);
assert!(import_fold_regions(&src).is_empty());
}
}