use tower_lsp::lsp_types::{FoldingRange, FoldingRangeKind};
#[must_use]
pub fn folding_ranges(text: &str) -> Vec<FoldingRange> {
let lines: Vec<&str> = text.lines().collect();
if lines.is_empty() {
return Vec::new();
}
let mut ranges = Vec::new();
collect_indentation_folds(&lines, &mut ranges);
collect_document_section_folds(&lines, &mut ranges);
collect_comment_block_folds(&lines, &mut ranges);
ranges
}
struct OpenRegion {
start_line: usize,
indent: usize,
}
fn collect_indentation_folds(lines: &[&str], ranges: &mut Vec<FoldingRange>) {
let mut stack: Vec<OpenRegion> = Vec::new();
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed == "---" || trimmed == "..." {
continue;
}
if trimmed.starts_with('#') {
let indent = line.len() - line.trim_start().len();
close_regions_at_or_above(&mut stack, indent, i, lines, ranges);
continue;
}
let indent = line.len() - line.trim_start().len();
close_regions_at_or_above(&mut stack, indent, i, lines, ranges);
if starts_fold_region(trimmed) {
stack.push(OpenRegion {
start_line: i,
indent,
});
}
}
let total = lines.len();
stack.into_iter().rev().for_each(|region| {
let end = find_last_content_line(lines, region.start_line, total);
if end > region.start_line {
push_fold(ranges, region.start_line, end, None);
}
});
}
fn close_regions_at_or_above(
stack: &mut Vec<OpenRegion>,
indent: usize,
current_line: usize,
lines: &[&str],
ranges: &mut Vec<FoldingRange>,
) {
while let Some(top) = stack.last() {
if top.indent >= indent {
let Some(region) = stack.pop() else { break };
let end = find_last_content_line(lines, region.start_line, current_line);
if end > region.start_line {
push_fold(ranges, region.start_line, end, None);
}
} else {
break;
}
}
}
fn starts_fold_region(trimmed: &str) -> bool {
if trimmed.ends_with(':') {
return true;
}
if let Some(colon_pos) = find_mapping_colon(trimmed) {
let after_colon = trimmed[colon_pos + 1..].trim();
if after_colon.is_empty() {
return true;
}
if is_block_scalar_indicator(after_colon) {
return true;
}
}
false
}
fn is_block_scalar_indicator(value: &str) -> bool {
let mut chars = value.chars();
let Some(first) = chars.next() else {
return false;
};
if first != '|' && first != '>' {
return false;
}
for ch in chars {
match ch {
'+' | '-' | '0'..='9' => {}
' ' | '\t' | '#' => return true, _ => return false, }
}
true
}
fn find_mapping_colon(line: &str) -> Option<usize> {
let mut in_single_quote = false;
let mut in_double_quote = false;
for (i, ch) in line.char_indices() {
match ch {
'\'' if !in_double_quote => in_single_quote = !in_single_quote,
'"' if !in_single_quote => in_double_quote = !in_double_quote,
':' if !in_single_quote && !in_double_quote => {
let rest = &line[i + 1..];
if rest.is_empty() || rest.starts_with(' ') || rest.starts_with('\t') {
return Some(i);
}
}
_ => {}
}
}
None
}
fn find_last_content_line(lines: &[&str], start: usize, before: usize) -> usize {
((start + 1)..before)
.rev()
.find(|&i| {
lines
.get(i)
.is_some_and(|l| !l.trim().is_empty() && l.trim() != "---" && l.trim() != "...")
})
.unwrap_or(start)
}
fn collect_document_section_folds(lines: &[&str], ranges: &mut Vec<FoldingRange>) {
let separator_positions: Vec<usize> = lines
.iter()
.enumerate()
.filter(|(_, l)| l.trim() == "---")
.map(|(i, _)| i)
.collect();
if separator_positions.is_empty() {
return;
}
if let Some(&first_sep) = separator_positions.first()
&& first_sep > 0
{
let end = find_last_content_line_in_range(lines, 0, first_sep);
if let Some(end) = end
&& end > 0
{
push_fold(ranges, 0, end, Some(FoldingRangeKind::Region));
}
}
for window in separator_positions.windows(2) {
if let [start_sep, before] = window {
let start = start_sep + 1;
if start < *before {
let end = find_last_content_line_in_range(lines, start, *before);
if let Some(end) = end
&& end > start
{
push_fold(ranges, start, end, Some(FoldingRangeKind::Region));
}
}
}
}
let last_sep = *separator_positions
.last()
.expect("separator_positions is non-empty");
let start = last_sep + 1;
if start < lines.len() {
let end = find_last_content_line_in_range(lines, start, lines.len());
if let Some(end) = end
&& end > start
{
push_fold(ranges, start, end, Some(FoldingRangeKind::Region));
}
}
}
fn find_last_content_line_in_range(lines: &[&str], from: usize, before: usize) -> Option<usize> {
(from..before).rev().find(|&i| {
lines
.get(i)
.is_some_and(|l| !l.trim().is_empty() && l.trim() != "---" && l.trim() != "...")
})
}
fn collect_comment_block_folds(lines: &[&str], ranges: &mut Vec<FoldingRange>) {
let mut comment_start: Option<usize> = None;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with('#') {
if comment_start.is_none() {
comment_start = Some(i);
}
} else {
if let Some(start) = comment_start
&& i - 1 > start
{
push_fold(ranges, start, i - 1, Some(FoldingRangeKind::Comment));
}
comment_start = None;
}
}
if let Some(start) = comment_start {
let end = lines.len() - 1;
if end > start {
push_fold(ranges, start, end, Some(FoldingRangeKind::Comment));
}
}
}
fn push_fold(
ranges: &mut Vec<FoldingRange>,
start: usize,
end: usize,
kind: Option<FoldingRangeKind>,
) {
#[allow(clippy::cast_possible_truncation)]
ranges.push(FoldingRange {
start_line: start as u32,
start_character: None,
end_line: end as u32,
end_character: None,
kind,
collapsed_text: None,
});
}
#[cfg(test)]
mod tests {
use super::*;
use tower_lsp::lsp_types::FoldingRangeKind;
fn ranges_as_tuples(ranges: &[FoldingRange]) -> Vec<(u32, u32)> {
ranges.iter().map(|r| (r.start_line, r.end_line)).collect()
}
#[test]
fn should_fold_mapping_with_nested_content() {
let text = "server:\n host: localhost\n port: 8080\n";
let result = folding_ranges(text);
let tuples = ranges_as_tuples(&result);
assert!(
tuples.contains(&(0, 2)),
"should fold server mapping from line 0 to 2, got: {tuples:?}"
);
}
#[test]
fn should_not_fold_single_line_mapping() {
let text = "key: value\n";
let result = folding_ranges(text);
assert!(result.is_empty(), "should not fold single-line mapping");
}
#[test]
fn should_fold_multiple_top_level_mappings() {
let text =
"server:\n host: localhost\n port: 8080\ndatabase:\n name: mydb\n port: 5432\n";
let result = folding_ranges(text);
let tuples = ranges_as_tuples(&result);
assert!(
tuples.contains(&(0, 2)),
"should fold server mapping (lines 0-2), got: {tuples:?}"
);
assert!(
tuples.contains(&(3, 5)),
"should fold database mapping (lines 3-5), got: {tuples:?}"
);
}
#[test]
fn should_fold_deeply_nested_mappings() {
let text = "a:\n b:\n c:\n d: value\n";
let result = folding_ranges(text);
let tuples = ranges_as_tuples(&result);
assert!(
tuples.contains(&(0, 3)),
"should fold 'a' (lines 0-3), got: {tuples:?}"
);
assert!(
tuples.contains(&(1, 3)),
"should fold 'b' (lines 1-3), got: {tuples:?}"
);
assert!(
tuples.contains(&(2, 3)),
"should fold 'c' (lines 2-3), got: {tuples:?}"
);
}
#[test]
fn should_not_fold_mapping_with_inline_value_only() {
let text = "name: Alice\nage: 30\n";
let result = folding_ranges(text);
assert!(
result.is_empty(),
"should not fold flat key-value pairs with no nesting"
);
}
#[test]
fn should_fold_sequence() {
let text = "items:\n - one\n - two\n - three\n";
let result = folding_ranges(text);
let tuples = ranges_as_tuples(&result);
assert!(
tuples.contains(&(0, 3)),
"should fold items sequence (lines 0-3), got: {tuples:?}"
);
}
#[test]
fn should_fold_sequence_of_mappings() {
let text = "users:\n - name: Alice\n age: 30\n - name: Bob\n age: 25\n";
let result = folding_ranges(text);
let tuples = ranges_as_tuples(&result);
assert!(
tuples.contains(&(0, 4)),
"should fold users sequence (lines 0-4), got: {tuples:?}"
);
}
#[test]
fn should_fold_literal_block_scalar() {
let text = "description: |\n This is a\n multi-line\n description\n";
let result = folding_ranges(text);
let tuples = ranges_as_tuples(&result);
assert!(
tuples.contains(&(0, 3)),
"should fold literal block scalar (lines 0-3), got: {tuples:?}"
);
}
#[test]
fn should_fold_folded_block_scalar() {
let text = "summary: >\n This is a\n folded\n paragraph\n";
let result = folding_ranges(text);
let tuples = ranges_as_tuples(&result);
assert!(
tuples.contains(&(0, 3)),
"should fold folded block scalar (lines 0-3), got: {tuples:?}"
);
}
#[test]
fn should_not_treat_gt_or_pipe_in_value_as_block_scalar() {
let text = "condition: a > b\nresult: true\n";
let result = folding_ranges(text);
assert!(
result.is_empty(),
"should not fold -- '>' in 'a > b' is not a block scalar indicator"
);
}
#[test]
fn should_fold_document_sections() {
let text = "key1: val1\nkey2: val2\n---\nkey3: val3\nkey4: val4\n";
let result = folding_ranges(text);
assert!(
result.len() >= 2,
"should have at least 2 folding ranges for 2 document sections, got: {}",
result.len()
);
}
#[test]
fn should_fold_document_sections_with_nested_content() {
let text = "doc1:\n key: val\n---\ndoc2:\n key: val\n";
let result = folding_ranges(text);
let tuples = ranges_as_tuples(&result);
assert!(
tuples.contains(&(0, 1)),
"should fold doc1 mapping (lines 0-1), got: {tuples:?}"
);
assert!(
tuples.contains(&(3, 4)),
"should fold doc2 mapping (lines 3-4), got: {tuples:?}"
);
}
#[test]
fn should_not_break_fold_region_for_comment_lines() {
let text = "server:\n # This is a comment\n host: localhost\n port: 8080\n";
let result = folding_ranges(text);
let tuples = ranges_as_tuples(&result);
assert!(
tuples.contains(&(0, 3)),
"should fold server mapping (lines 0-3) including comment, got: {tuples:?}"
);
}
#[test]
fn should_fold_consecutive_comment_block() {
let text = "# Header comment\n# continues here\n# and here\nkey: value\n";
let result = folding_ranges(text);
let comment_folds: Vec<&FoldingRange> = result
.iter()
.filter(|r| r.kind == Some(FoldingRangeKind::Comment))
.collect();
let region_folds: Vec<&FoldingRange> = result
.iter()
.filter(|r| r.kind == Some(FoldingRangeKind::Region) || r.kind.is_none())
.collect();
for fold in ®ion_folds {
assert!(
fold.start_line > 2,
"comment block should not produce a Region fold, got region fold starting at line {}",
fold.start_line
);
}
if !comment_folds.is_empty() {
let tuples: Vec<(u32, u32)> = comment_folds
.iter()
.map(|r| (r.start_line, r.end_line))
.collect();
assert!(
tuples.contains(&(0, 2)),
"comment fold should span lines 0-2, got: {tuples:?}"
);
}
}
#[test]
fn should_return_empty_for_empty_document() {
let text = "";
let result = folding_ranges(text);
assert!(result.is_empty(), "should return empty for empty document");
}
#[test]
fn should_return_empty_for_single_line_document() {
let text = "key: value";
let result = folding_ranges(text);
assert!(
result.is_empty(),
"should return empty for single-line document"
);
}
#[test]
fn should_return_empty_for_comment_only_document() {
let text = "# just a comment\n";
let result = folding_ranges(text);
for fold in &result {
assert!(
fold.kind == Some(FoldingRangeKind::Comment) || fold.kind.is_none(),
"comment-only document should not produce Region folds"
);
}
}
#[test]
fn should_handle_blank_lines_within_fold_region() {
let text = "server:\n host: localhost\n\n port: 8080\n";
let result = folding_ranges(text);
let tuples = ranges_as_tuples(&result);
assert!(
tuples.contains(&(0, 3)),
"should fold server mapping (lines 0-3) across blank line, got: {tuples:?}"
);
}
#[test]
fn should_handle_mixed_content_types() {
let text = "config:\n name: app\n ports:\n - 80\n - 443\n description: |\n A multi-line\n description\n";
let result = folding_ranges(text);
let tuples = ranges_as_tuples(&result);
assert!(
tuples.contains(&(0, 7)),
"should fold config mapping (lines 0-7), got: {tuples:?}"
);
}
}