use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum FoldKind {
Heading(u8),
CodeBlock,
List,
Blockquote,
Indentation,
Custom,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FoldRegion {
pub id: u64,
pub start_line: usize,
pub end_line: usize,
pub kind: FoldKind,
pub preview: Option<String>,
pub is_folded: bool,
}
impl FoldRegion {
#[must_use]
pub fn new(id: u64, start_line: usize, end_line: usize, kind: FoldKind) -> Self {
Self {
id,
start_line,
end_line,
kind,
preview: None,
is_folded: false,
}
}
#[must_use]
pub fn with_preview(
id: u64,
start_line: usize,
end_line: usize,
kind: FoldKind,
preview: impl Into<String>,
) -> Self {
Self {
id,
start_line,
end_line,
kind,
preview: Some(preview.into()),
is_folded: false,
}
}
#[must_use]
pub fn line_count(&self) -> usize {
self.end_line.saturating_sub(self.start_line) + 1
}
#[must_use]
pub fn contains_line(&self, line: usize) -> bool {
line > self.start_line && line <= self.end_line
}
pub fn toggle(&mut self) {
self.is_folded = !self.is_folded;
}
}
#[derive(Debug, Clone, Default)]
pub struct FoldState {
regions: HashMap<u64, FoldRegion>,
next_id: u64,
is_dirty: bool,
}
impl FoldState {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_region(&mut self, start_line: usize, end_line: usize, kind: FoldKind) -> u64 {
let id = self.next_id;
self.next_id += 1;
let region = FoldRegion::new(id, start_line, end_line, kind);
self.regions.insert(id, region);
id
}
pub fn add_region_with_preview(
&mut self,
start_line: usize,
end_line: usize,
kind: FoldKind,
preview: impl Into<String>,
) -> u64 {
let id = self.next_id;
self.next_id += 1;
let region = FoldRegion::with_preview(id, start_line, end_line, kind, preview);
self.regions.insert(id, region);
id
}
#[must_use]
pub fn get_region(&self, id: u64) -> Option<&FoldRegion> {
self.regions.get(&id)
}
pub fn get_region_mut(&mut self, id: u64) -> Option<&mut FoldRegion> {
self.regions.get_mut(&id)
}
#[must_use]
pub fn region_at_line(&self, line: usize) -> Option<&FoldRegion> {
self.regions.values().find(|r| r.start_line == line)
}
pub fn toggle_at_line(&mut self, line: usize) -> bool {
if let Some(region) = self.regions.values_mut().find(|r| r.start_line == line) {
region.toggle();
true
} else {
false
}
}
#[must_use]
pub fn is_line_hidden(&self, line: usize) -> bool {
self.regions
.values()
.any(|r| r.is_folded && r.contains_line(line))
}
#[must_use]
pub fn fold_indicators(&self) -> Vec<(usize, bool)> {
let mut indicators: Vec<_> = self
.regions
.values()
.map(|r| (r.start_line, r.is_folded))
.collect();
indicators.sort_by_key(|(line, _)| *line);
indicators
}
pub fn fold_all(&mut self) {
for region in self.regions.values_mut() {
region.is_folded = true;
}
}
pub fn unfold_all(&mut self) {
for region in self.regions.values_mut() {
region.is_folded = false;
}
}
pub fn fold_kind(&mut self, kind: FoldKind) {
for region in self.regions.values_mut() {
if region.kind == kind {
region.is_folded = true;
}
}
}
pub fn unfold_kind(&mut self, kind: FoldKind) {
for region in self.regions.values_mut() {
if region.kind == kind {
region.is_folded = false;
}
}
}
pub fn clear(&mut self) {
self.regions.clear();
}
#[must_use]
pub fn next_id(&mut self) -> u64 {
let id = self.next_id;
self.next_id += 1;
id
}
pub fn mark_clean(&mut self) {
self.is_dirty = false;
}
pub fn mark_dirty(&mut self) {
self.is_dirty = true;
}
#[must_use]
pub fn is_dirty(&self) -> bool {
self.is_dirty
}
#[must_use]
pub fn region_count(&self) -> usize {
self.regions.len()
}
pub fn iter(&self) -> impl Iterator<Item = &FoldRegion> {
self.regions.values()
}
}
#[must_use]
pub fn detect_heading_level(line: &str) -> Option<u8> {
let trimmed = line.trim_start();
if !trimmed.starts_with('#') {
return None;
}
let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
if hash_count > 6 {
return None;
}
let after_hashes = &trimmed[hash_count..];
if after_hashes.is_empty() || after_hashes.starts_with(' ') {
return Some(hash_count as u8);
}
None
}
#[must_use]
pub fn detect_markdown_folds(content: &str) -> FoldState {
let mut state = FoldState::new();
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() {
return state;
}
let mut headings: Vec<(usize, u8, String)> = Vec::new();
for (line_num, line) in lines.iter().enumerate() {
if let Some(level) = detect_heading_level(line) {
let text = line
.trim_start_matches('#')
.trim()
.chars()
.take(50)
.collect::<String>();
headings.push((line_num, level, text));
}
}
for (i, (start_line, level, preview_text)) in headings.iter().enumerate() {
let end_line = if i + 1 < headings.len() {
let mut found_end = None;
for j in (i + 1)..headings.len() {
let (next_line, next_level, _) = &headings[j];
if *next_level <= *level {
found_end = Some(next_line.saturating_sub(1));
break;
}
}
found_end.unwrap_or_else(|| {
if i + 1 < headings.len() {
headings[i + 1].0.saturating_sub(1)
} else {
lines.len().saturating_sub(1)
}
})
} else {
lines.len().saturating_sub(1)
};
if end_line > *start_line {
let mut actual_end = end_line;
while actual_end > *start_line
&& lines
.get(actual_end)
.map(|l| l.trim().is_empty())
.unwrap_or(true)
{
actual_end -= 1;
}
if actual_end > *start_line {
state.add_region_with_preview(
*start_line,
actual_end,
FoldKind::Heading(*level),
preview_text.clone(),
);
}
}
}
let mut in_code_block = false;
let mut code_block_start = 0;
for (line_num, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
if in_code_block {
if line_num > code_block_start {
state.add_region(code_block_start, line_num, FoldKind::CodeBlock);
}
in_code_block = false;
} else {
code_block_start = line_num;
in_code_block = true;
}
}
}
state.mark_clean();
state
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_detect_heading_level() {
assert_eq!(detect_heading_level("# Heading"), Some(1));
assert_eq!(detect_heading_level("## Heading"), Some(2));
assert_eq!(detect_heading_level("### Heading"), Some(3));
assert_eq!(detect_heading_level("Not a heading"), None);
assert_eq!(detect_heading_level("#NoSpace"), None);
}
#[test]
fn test_fold_region_contains_line() {
let region = FoldRegion::new(1, 5, 10, FoldKind::Heading(1));
assert!(!region.contains_line(5)); assert!(region.contains_line(6));
assert!(region.contains_line(10));
assert!(!region.contains_line(11));
}
#[test]
fn test_detect_markdown_folds() {
let content = "# Title\n\nSome content\n\n## Section\n\nMore content";
let state = detect_markdown_folds(content);
assert!(state.region_count() > 0);
}
}