use std::ops::Range;
use crate::folding::FoldRange;
use crate::{EditorBuffer, is_fenced_row};
pub(crate) fn heading_level(trimmed_start: &str) -> Option<u8> {
if trimmed_start.starts_with("# ") {
Some(1)
} else if trimmed_start.starts_with("## ") {
Some(2)
} else if trimmed_start.starts_with("### ") {
Some(3)
} else if trimmed_start.starts_with("#### ") {
Some(4)
} else if trimmed_start.starts_with("##### ") {
Some(5)
} else if trimmed_start.starts_with("###### ") {
Some(6)
} else {
None
}
}
fn indent_width(line: &str) -> usize {
let mut width = 0;
for ch in line.chars() {
match ch {
' ' => width += 1,
'\t' => width += 4 - width % 4,
_ => break,
}
}
width
}
fn is_list_item(trimmed_start: &str) -> bool {
let bytes = trimmed_start.as_bytes();
if bytes.len() == 1 && matches!(bytes[0], b'-' | b'*' | b'+') {
return true;
}
if trimmed_start.starts_with("- ")
|| trimmed_start.starts_with("* ")
|| trimmed_start.starts_with("+ ")
{
return true;
}
let digits = bytes
.iter()
.take_while(|byte| byte.is_ascii_digit())
.count();
if digits == 0 || digits >= bytes.len() {
return false;
}
(bytes[digits] == b'.' || bytes[digits] == b')')
&& bytes.get(digits + 1).is_none_or(|byte| *byte == b' ')
}
struct StructureRow {
blank: bool,
heading: Option<u8>,
list_indent: Option<usize>,
indent: usize,
}
pub(crate) fn markdown_fold_ranges(
buffer: &EditorBuffer,
fences: &[usize],
frontmatter: Option<&Range<usize>>,
) -> Vec<FoldRange> {
let total = buffer.len_lines();
let mut rows = Vec::with_capacity(total);
for row in 0..total {
let raw = buffer.line_to_string(row);
let text = raw.trim_end_matches(['\r', '\n']);
let trimmed = text.trim_start();
let opaque = is_fenced_row(fences, row)
|| frontmatter
.as_ref()
.is_some_and(|block| block.contains(&row));
rows.push(StructureRow {
blank: trimmed.is_empty(),
heading: (!opaque).then(|| heading_level(trimmed)).flatten(),
list_indent: (!opaque && is_list_item(trimmed))
.then(|| text.len() - trimmed.len())
.map(|bytes| indent_width(&text[..bytes])),
indent: indent_width(text),
});
}
let mut ranges = Vec::new();
let mut open_headings: Vec<(usize, u8)> = Vec::new();
let mut open_lists: Vec<(usize, usize)> = Vec::new();
for (row, info) in rows.iter().enumerate() {
if info.blank {
continue;
}
if let Some(level) = info.heading {
close_lists(&mut open_lists, &rows, row, 0, false, &mut ranges);
close_headings(&mut open_headings, &rows, row, level, &mut ranges);
open_headings.push((row, level));
} else if let Some(indent) = info.list_indent {
close_lists(&mut open_lists, &rows, row, indent, false, &mut ranges);
open_lists.push((row, indent));
} else {
close_lists(&mut open_lists, &rows, row, info.indent, true, &mut ranges);
}
}
let end = total;
close_lists(&mut open_lists, &rows, end, 0, false, &mut ranges);
close_headings(&mut open_headings, &rows, end, 1, &mut ranges);
ranges.sort_by_key(|range| range.start_row);
ranges
}
fn close_lists(
stack: &mut Vec<(usize, usize)>,
rows: &[StructureRow],
row: usize,
floor: usize,
strict: bool,
ranges: &mut Vec<FoldRange>,
) {
while stack.last().is_some_and(|&(_, indent)| {
if strict {
indent > floor
} else {
indent >= floor
}
}) {
let (start, _) = stack.pop().expect("stack not empty");
let mut end = row.saturating_sub(1);
while end > start && rows[end].blank {
end -= 1;
}
if end > start {
ranges.push(FoldRange {
start_row: start,
end_row: end,
});
}
}
}
fn close_headings(
stack: &mut Vec<(usize, u8)>,
rows: &[StructureRow],
row: usize,
floor: u8,
ranges: &mut Vec<FoldRange>,
) {
while stack.last().is_some_and(|&(_, level)| level >= floor) {
let (start, _) = stack.pop().expect("stack not empty");
let mut end = row.saturating_sub(1);
while end > start && rows[end].blank {
end -= 1;
}
if end > start {
ranges.push(FoldRange {
start_row: start,
end_row: end,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fold(buffer_text: &str) -> Vec<FoldRange> {
let buffer = EditorBuffer::new(buffer_text);
markdown_fold_ranges(&buffer, &[], None)
}
#[test]
fn heading_sections_nest_by_level() {
let ranges = fold("# H1\na\n## H2\nb\n# H1b\n");
assert!(ranges.contains(&FoldRange {
start_row: 0,
end_row: 3
}));
assert!(ranges.contains(&FoldRange {
start_row: 2,
end_row: 3
}));
assert!(!ranges.iter().any(|range| range.start_row == 4));
}
#[test]
fn single_row_sections_emit_nothing() {
assert!(fold("# Solo\n").is_empty());
assert!(fold("# A\n# B\n").is_empty());
}
#[test]
fn trailing_blanks_trim_off() {
let ranges = fold("# H\ntext\n\n\n");
assert_eq!(
ranges,
vec![FoldRange {
start_row: 0,
end_row: 1
}]
);
}
#[test]
fn list_subtrees_nest_by_indent() {
let ranges = fold("- a\n - b\n - c\n - d\n- e\n");
assert!(ranges.contains(&FoldRange {
start_row: 0,
end_row: 3
}));
assert!(ranges.contains(&FoldRange {
start_row: 1,
end_row: 2
}));
assert!(!ranges.iter().any(|range| range.start_row == 4));
}
#[test]
fn ordered_and_task_items_fold() {
let ranges = fold("1. one\n2. two\n continued\n3. three\n");
assert!(ranges.contains(&FoldRange {
start_row: 1,
end_row: 2
}));
assert!(!ranges.iter().any(|range| range.start_row == 0));
assert!(!ranges.iter().any(|range| range.start_row == 3));
let tasks = fold("- [ ] a\n detail\n- [ ] b\n");
assert!(ranges_contains(&tasks, 0, 1));
}
#[test]
fn lazy_continuation_absorbs() {
let ranges = fold("- a\ncontinued\n- b\n");
assert!(ranges.contains(&FoldRange {
start_row: 0,
end_row: 1
}));
}
#[test]
fn headings_terminate_lists() {
let ranges = fold("- a\n - b\n# H\ntext\n");
assert!(ranges.contains(&FoldRange {
start_row: 0,
end_row: 1
}));
assert!(ranges.contains(&FoldRange {
start_row: 2,
end_row: 3
}));
}
#[test]
fn fenced_rows_never_open_folds() {
let buffer = EditorBuffer::new("# H\n```\n# not a heading\n- not a list\n```\ntext\n");
let ranges = markdown_fold_ranges(&buffer, &[1, 4], None);
assert_eq!(
ranges,
vec![FoldRange {
start_row: 0,
end_row: 5
}]
);
}
#[test]
fn frontmatter_rows_never_open_folds() {
let buffer = EditorBuffer::new("---\n# not a heading\n---\n# Real\ntext\n");
let ranges = markdown_fold_ranges(&buffer, &[], Some(&(0..3)));
assert_eq!(
ranges,
vec![FoldRange {
start_row: 3,
end_row: 4
}]
);
}
#[test]
fn shared_heading_levels_match_highlighting() {
assert_eq!(heading_level("# H"), Some(1));
assert_eq!(heading_level("###### H"), Some(6));
assert_eq!(heading_level("#nospace"), None);
assert_eq!(heading_level("####### H"), None);
}
fn ranges_contains(ranges: &[FoldRange], start: usize, end: usize) -> bool {
ranges.contains(&FoldRange {
start_row: start,
end_row: end,
})
}
}