oo-ide 0.0.4

∞ is a terminal IDE focused on low distraction, high usability.
Documentation
//! Code folding.
//!
//! A `FoldState` tracks which lines are currently folded and provides helpers
//! for the editor to convert between logical (buffer) line numbers and visual
//! (screen) row offsets.
//!
//! Two fold strategies are supported:
//! - **Brace folding** (C-family, Rust, JS, Go): a `{` line can be folded
//!   to hide everything up to its matching `}`.
//! - **Indent folding** (Python, YAML, Markdown): a dedented line ends a fold.
//!
//! `FoldState` is kept simple — it stores a sorted list of folded ranges
//! `(start, end)` where `start` is the line with the opening delimiter and
//! `end` is the last hidden line (the closing delimiter stays visible).

/// A half-open range `[start, end)` of **hidden** lines.
///
/// The line at `start` contains the opening delimiter (and is **visible**).
/// Lines `start+1 ..= end-1` are hidden.
/// The line at `end` (closing delimiter) is **visible**.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct FoldRange {
    /// The line that was folded (contains `{` or the header).
    pub header: usize,
    /// Last hidden line (inclusive).  The line after this is visible again.
    pub last_hidden: usize,
}

impl FoldRange {
    /// Number of lines hidden by this fold.
    pub fn hidden_count(&self) -> usize {
        self.last_hidden.saturating_sub(self.header)
    }

    /// Is `line` hidden by this fold?
    pub fn hides(&self, line: usize) -> bool {
        line > self.header && line <= self.last_hidden
    }
}

#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
pub struct FoldState {
    /// Sorted by `header`, non-overlapping.
    folds: Vec<FoldRange>,
}

impl FoldState {
    /// True if line `row` is completely hidden (not the fold header itself).
    pub fn is_hidden(&self, row: usize) -> bool {
        self.folds.iter().any(|f| f.hides(row))
    }

    /// Remove folds that reference lines beyond `line_count`.
    pub fn prune_invalid(&mut self, line_count: usize) {
        self.folds.retain(|f| f.header < line_count);
    }

    /// True if line `row` is the header of an active fold.
    pub fn is_folded_header(&self, row: usize) -> bool {
        self.folds.iter().any(|f| f.header == row)
    }

    /// Toggle a fold at `header`.  If already folded, remove; otherwise
    /// detect the range from `lines` and add.
    pub fn toggle(&mut self, header: usize, lines: &[String]) {
        // Remove if already folded at this header.
        if let Some(pos) = self.folds.iter().position(|f| f.header == header) {
            self.folds.remove(pos);
            return;
        }

        // Detect fold range.
        if let Some(range) = detect_range(header, lines) {
            // Insert maintaining sorted order and removing overlaps.
            self.folds
                .retain(|f| !(f.header > range.header && f.header <= range.last_hidden));
            let pos = self.folds.partition_point(|f| f.header < range.header);
            self.folds.insert(pos, range);
        }
    }

    /// Convert a logical buffer line number to a visual row index (0-based),
    /// accounting for all active folds above it.
    ///
    /// Returns `None` if the line itself is hidden.
    #[allow(dead_code)]
    pub fn logical_to_visual(&self, row: usize) -> Option<usize> {
        if self.is_hidden(row) {
            return None;
        }
        let mut visual = row;
        for f in &self.folds {
            if f.header >= row {
                break;
            }
            visual -= f.hidden_count();
        }
        Some(visual)
    }

    /// Convert a visual row back to a logical line number.
    #[allow(dead_code)]
    pub fn visual_to_logical(&self, visual: usize) -> usize {
        let mut logical = visual;
        for f in &self.folds {
            if f.header >= logical {
                break;
            }
            logical += f.hidden_count();
            // If we jumped past this fold's header, all its hidden lines count.
        }
        logical
    }

    /// Collect all visible line indices from `lines`, in order.
    pub fn visible_lines<'a>(&self, lines: &'a [String]) -> Vec<(usize, &'a str)> {
        lines
            .iter()
            .enumerate()
            .filter(|(i, _)| !self.is_hidden(*i))
            .map(|(i, s)| (i, s.as_str()))
            .collect()
    }

    /// All active fold ranges (for gutter rendering).
    pub fn folds(&self) -> &[FoldRange] {
        &self.folds
    }
}

/// Detect the foldable range starting at `header` line.
///
/// Strategy:
///   1. If the header line ends with `{`, scan forward for the matching `}`.
///   2. Otherwise fall back to indent folding: continue while the next
///      non-empty line has greater indentation.
fn detect_range(header: usize, lines: &[String]) -> Option<FoldRange> {
    let header_line = lines.get(header)?;
    let trimmed = header_line.trim_end();

    if trimmed.ends_with('{') || trimmed.ends_with('(') || trimmed.ends_with('[') {
        brace_range(header, lines)
    } else {
        indent_range(header, lines)
    }
}

fn brace_range(header: usize, lines: &[String]) -> Option<FoldRange> {
    let open = lines[header].trim_end().chars().last()?;
    let close = match open {
        '{' => '}',
        '(' => ')',
        '[' => ']',
        _ => return None,
    };

    let mut depth = 0i32;
    for (i, line) in lines.iter().enumerate().skip(header) {
        for ch in line.chars() {
            if ch == open {
                depth += 1;
            }
            if ch == close {
                depth -= 1;
                if depth == 0 && i > header {
                    // Hide everything between header and the closing line.
                    return Some(FoldRange {
                        header,
                        last_hidden: i.saturating_sub(1),
                    });
                }
            }
        }
    }
    None
}

fn indent_range(header: usize, lines: &[String]) -> Option<FoldRange> {
    let header_indent = indent_level(&lines[header]);
    let mut last = header;
    for (i, line) in lines.iter().enumerate().skip(header + 1) {
        if line.trim().is_empty() {
            continue;
        }
        if indent_level(line) > header_indent {
            last = i;
        } else {
            break;
        }
    }
    if last == header {
        return None;
    }
    Some(FoldRange {
        header,
        last_hidden: last,
    })
}

fn indent_level(line: &str) -> usize {
    line.chars().take_while(|c| *c == ' ').count()
        + line.chars().take_while(|c| *c == '\t').count() * 4
}

#[cfg(test)]
mod tests {
    use super::*;

    fn lines(text: &str) -> Vec<String> {
        text.lines().map(|l| l.to_string()).collect()
    }

    #[test]
    fn brace_fold_hides_inner_lines() {
        let src = lines("fn foo() {\n    let x = 1;\n    x\n}");
        let mut fs = FoldState::default();
        fs.toggle(0, &src);
        assert!(!fs.is_hidden(0)); // header visible
        assert!(fs.is_hidden(1));
        assert!(fs.is_hidden(2));
        assert!(!fs.is_hidden(3)); // closing `}` visible
    }

    #[test]
    fn toggle_twice_unfolds() {
        let src = lines("fn foo() {\n    x\n}");
        let mut fs = FoldState::default();
        fs.toggle(0, &src);
        assert!(fs.is_hidden(1));
        fs.toggle(0, &src);
        assert!(!fs.is_hidden(1));
    }

    #[test]
    fn logical_to_visual_skips_hidden() {
        let src = lines("fn foo() {\n    x\n    y\n}");
        let mut fs = FoldState::default();
        fs.toggle(0, &src);
        // Line 0 → visual 0, lines 1-2 hidden, line 3 → visual 1.
        assert_eq!(fs.logical_to_visual(0), Some(0));
        assert_eq!(fs.logical_to_visual(1), None);
        assert_eq!(fs.logical_to_visual(3), Some(1));
    }

    #[test]
    fn visual_to_logical_round_trips() {
        let src = lines("fn foo() {\n    x\n    y\n}\nlet z = 1;");
        let mut fs = FoldState::default();
        fs.toggle(0, &src);
        // visual 0 → logical 0, visual 1 → logical 3, visual 2 → logical 4
        assert_eq!(fs.visual_to_logical(0), 0);
        assert_eq!(fs.visual_to_logical(1), 3);
        assert_eq!(fs.visual_to_logical(2), 4);
    }

    #[test]
    fn indent_fold() {
        let src = lines("class Foo:\n    def bar(self):\n        pass\n\nclass Bar:");
        let mut fs = FoldState::default();
        fs.toggle(0, &src);
        assert!(fs.is_hidden(1));
        assert!(fs.is_hidden(2));
        assert!(!fs.is_hidden(4));
    }

    #[test]
    fn prune_invalid_removes_folds_beyond_line_count() {
        let src = lines("fn foo() {\n    x\n}\nfn bar() {\n    y\n}");
        let mut fs = FoldState::default();
        fs.toggle(0, &src);
        fs.toggle(3, &src);
        assert!(fs.is_hidden(1));
        assert!(fs.is_hidden(4));
        fs.prune_invalid(3);
        assert!(fs.is_hidden(1), "fold at 0 should still exist and hide line 1");
        assert!(!fs.is_hidden(4), "fold at 3 should be removed since line_count is 3");
    }
}