#[cfg(feature = "lsp")]
use tower_lsp_server::ls_types::*;
#[cfg(feature = "lsp")]
pub fn compute_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();
detect_block_folds(&lines, &mut ranges);
detect_multiline_string_folds(&lines, &mut ranges);
detect_comment_folds(&lines, &mut ranges);
ranges
}
#[cfg(feature = "lsp")]
fn indent_level(line: &str) -> Option<usize> {
if line.trim().is_empty() {
return None;
}
Some(line.len() - line.trim_start().len())
}
#[cfg(feature = "lsp")]
fn has_inline_value(trimmed: &str) -> bool {
if let Some(colon_pos) = trimmed.find(':') {
let after = &trimmed[colon_pos + 1..];
let after_trimmed = after.trim();
if after_trimmed.is_empty() {
return false;
}
if after_trimmed == "|"
|| after_trimmed == ">"
|| after_trimmed.starts_with("| ")
|| after_trimmed.starts_with("> ")
|| after_trimmed.starts_with("|+")
|| after_trimmed.starts_with("|-")
|| after_trimmed.starts_with(">+")
|| after_trimmed.starts_with(">-")
{
return false;
}
return true;
}
true
}
#[cfg(feature = "lsp")]
const SECTION_KEYS: &[&str] = &[
"tasks:", "mcp:", "context:", "include:", "skills:", "servers:", "files:",
];
#[cfg(feature = "lsp")]
fn detect_block_folds(lines: &[&str], ranges: &mut Vec<FoldingRange>) {
let mut stack: Vec<(usize, usize)> = Vec::new();
for (i, line) in lines.iter().enumerate() {
let Some(indent) = indent_level(line) else {
continue; };
let trimmed = line.trim();
if trimmed.starts_with('#') {
continue;
}
while let Some(&(stack_indent, stack_start)) = stack.last() {
if indent <= stack_indent {
let end_line = find_last_nonempty_line(lines, stack_start + 1, i);
if let Some(end) = end_line {
if end > stack_start {
ranges.push(FoldingRange {
start_line: stack_start as u32,
start_character: None,
end_line: end as u32,
end_character: None,
kind: Some(FoldingRangeKind::Region),
collapsed_text: None,
});
}
}
stack.pop();
} else {
break;
}
}
let starts_block = if trimmed.starts_with("- id:") {
true
} else if SECTION_KEYS
.iter()
.any(|k| trimmed == *k || trimmed.starts_with(k))
{
!has_inline_value(trimmed)
} else if trimmed.contains(':') && !trimmed.starts_with('-') {
!has_inline_value(trimmed)
} else {
false
};
if starts_block {
stack.push((indent, i));
}
}
let total = lines.len();
while let Some((_, stack_start)) = stack.pop() {
let end_line = find_last_nonempty_line(lines, stack_start + 1, total);
if let Some(end) = end_line {
if end > stack_start {
ranges.push(FoldingRange {
start_line: stack_start as u32,
start_character: None,
end_line: end as u32,
end_character: None,
kind: Some(FoldingRangeKind::Region),
collapsed_text: None,
});
}
}
}
}
#[cfg(feature = "lsp")]
fn detect_multiline_string_folds(lines: &[&str], ranges: &mut Vec<FoldingRange>) {
let mut i = 0;
while i < lines.len() {
let trimmed = lines[i].trim();
let is_block_scalar = if let Some(colon_pos) = trimmed.find(':') {
let after = trimmed[colon_pos + 1..].trim();
after == "|"
|| after == ">"
|| after.starts_with("| ")
|| after.starts_with("> ")
|| after.starts_with("|+")
|| after.starts_with("|-")
|| after.starts_with(">+")
|| after.starts_with(">-")
} else {
false
};
if is_block_scalar {
let key_indent = indent_level(lines[i]).unwrap_or(0);
let start = i;
i += 1;
while i < lines.len() {
if lines[i].trim().is_empty() {
i += 1;
continue;
}
let line_indent = indent_level(lines[i]).unwrap_or(0);
if line_indent > key_indent {
i += 1;
} else {
break;
}
}
let end = find_last_nonempty_line(lines, start + 1, i);
if let Some(end_line) = end {
if end_line > start {
ranges.push(FoldingRange {
start_line: start as u32,
start_character: None,
end_line: end_line as u32,
end_character: None,
kind: Some(FoldingRangeKind::Region),
collapsed_text: None,
});
}
}
} else {
i += 1;
}
}
}
#[cfg(feature = "lsp")]
fn detect_comment_folds(lines: &[&str], ranges: &mut Vec<FoldingRange>) {
let mut i = 0;
while i < lines.len() {
if lines[i].trim().starts_with('#') {
let start = i;
while i < lines.len() && lines[i].trim().starts_with('#') {
i += 1;
}
if i - start >= 2 {
ranges.push(FoldingRange {
start_line: start as u32,
start_character: None,
end_line: (i - 1) as u32,
end_character: None,
kind: Some(FoldingRangeKind::Comment),
collapsed_text: None,
});
}
} else {
i += 1;
}
}
}
#[cfg(feature = "lsp")]
fn find_last_nonempty_line(lines: &[&str], from: usize, to: usize) -> Option<usize> {
let to = to.min(lines.len());
(from..to).rev().find(|&j| !lines[j].trim().is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(feature = "lsp")]
fn fold_lines(text: &str) -> Vec<(u32, u32)> {
compute_folding_ranges(text)
.into_iter()
.map(|r| (r.start_line, r.end_line))
.collect()
}
#[cfg(feature = "lsp")]
fn fold_kinds(text: &str) -> Vec<(u32, u32, Option<FoldingRangeKind>)> {
compute_folding_ranges(text)
.into_iter()
.map(|r| (r.start_line, r.end_line, r.kind))
.collect()
}
#[test]
#[cfg(feature = "lsp")]
fn single_task_fold() {
let text = "\
tasks:
- id: step1
infer: \"Generate content\"
timeout: 30";
let folds = fold_lines(text);
assert!(
folds.contains(&(0, 3)),
"tasks: block should fold: {:?}",
folds
);
assert!(
folds.contains(&(1, 3)),
"task block should fold: {:?}",
folds
);
}
#[test]
#[cfg(feature = "lsp")]
fn multiple_tasks_fold() {
let text = "\
tasks:
- id: step1
infer: \"Hello\"
- id: step2
exec: \"echo done\"";
let folds = fold_lines(text);
assert!(
folds.contains(&(0, 4)),
"tasks: block should span all tasks: {:?}",
folds
);
assert!(
folds.contains(&(1, 2)),
"first task should fold: {:?}",
folds
);
assert!(
folds.contains(&(3, 4)),
"second task should fold: {:?}",
folds
);
}
#[test]
#[cfg(feature = "lsp")]
fn nested_with_block_fold() {
let text = "\
tasks:
- id: step1
with:
data: $step0
label: \"test\"
infer: \"Use {{with.data}}\"";
let folds = fold_lines(text);
assert!(
folds.contains(&(2, 4)),
"with: block should fold: {:?}",
folds
);
}
#[test]
#[cfg(feature = "lsp")]
fn mcp_config_fold() {
let text = "\
mcp:
servers:
novanet:
command: node
args: [\"server.js\"]
perplexity:
command: npx";
let folds = fold_lines(text);
assert!(
folds.contains(&(0, 6)),
"mcp: should fold entire section: {:?}",
folds
);
assert!(folds.contains(&(1, 6)), "servers: should fold: {:?}", folds);
assert!(
folds.contains(&(2, 4)),
"novanet: server should fold: {:?}",
folds
);
assert!(
folds.contains(&(5, 6)),
"perplexity: server should fold: {:?}",
folds
);
}
#[test]
#[cfg(feature = "lsp")]
fn multiline_string_fold() {
let text = "\
tasks:
- id: step1
infer:
prompt: |
This is a long
multi-line prompt
that spans several lines";
let folds = fold_lines(text);
assert!(
folds.contains(&(3, 6)),
"multi-line string should fold: {:?}",
folds
);
}
#[test]
#[cfg(feature = "lsp")]
fn empty_document_returns_no_folds() {
assert!(compute_folding_ranges("").is_empty());
assert!(compute_folding_ranges(" \n \n").is_empty());
}
#[test]
#[cfg(feature = "lsp")]
fn content_array_fold() {
let text = "\
tasks:
- id: vision_task
infer:
content:
- type: image
source: \"hash123\"
- type: text
text: \"Describe this\"";
let folds = fold_lines(text);
assert!(
folds.contains(&(3, 7)),
"content: array should fold: {:?}",
folds
);
}
#[test]
#[cfg(feature = "lsp")]
fn top_level_sections_fold() {
let text = "\
schema: nika/workflow@0.12
workflow: my-workflow
context:
files:
brand: ./brand.md
data: ./data.json
tasks:
- id: step1
infer: \"Hello\"";
let folds = fold_lines(text);
assert!(folds.contains(&(3, 6)), "context: should fold: {:?}", folds);
assert!(folds.contains(&(4, 6)), "files: should fold: {:?}", folds);
assert!(folds.contains(&(8, 10)), "tasks: should fold: {:?}", folds);
}
#[test]
#[cfg(feature = "lsp")]
fn comment_block_fold() {
let text = "\
# This is a header comment
# that spans multiple lines
# explaining the workflow
schema: nika/workflow@0.12";
let kinds = fold_kinds(text);
assert!(
kinds.contains(&(0, 2, Some(FoldingRangeKind::Comment))),
"comment block should fold with Comment kind: {:?}",
kinds
);
}
#[test]
#[cfg(feature = "lsp")]
fn full_workflow_integration() {
let text = "\
schema: nika/workflow@0.12
workflow: integration-test
mcp:
servers:
novanet:
command: node
context:
files:
brand: ./brand.md
tasks:
- id: generate
with:
data: $import
infer:
prompt: |
Generate content
using brand guidelines
- id: publish
exec: \"echo done\"";
let folds = compute_folding_ranges(text);
assert!(
folds.len() >= 8,
"full workflow should have many folds, got {}: {:?}",
folds.len(),
folds
.iter()
.map(|r| (r.start_line, r.end_line))
.collect::<Vec<_>>()
);
}
}