Skip to main content

bekoedit_markdown/
sections.rs

1//! Outline-based document section operations (RFC-029).
2//!
3//! A "section" is the range from one ATX heading to just before the next
4//! heading of the same or higher level (lower number), or the end of the
5//! document. Operations swap adjacent sibling sections; they never cross a
6//! parent-level boundary, preserving document hierarchy.
7
8use serde::{Deserialize, Serialize};
9
10use crate::index::MarkdownIndex;
11use crate::range::ByteRange;
12
13/// Result of a section move operation.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct SectionMoveResult {
16    /// New canonical text after the sections are swapped.
17    pub text: String,
18    /// Byte offset of the heading in the new text (for cursor repositioning).
19    pub new_heading_offset: usize,
20}
21
22/// Error conditions for section operations.
23#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error, Serialize, Deserialize)]
24pub enum SectionError {
25    #[error("heading index {0} out of range")]
26    HeadingIndexOutOfRange(usize),
27    #[error("no sibling section exists in that direction")]
28    NoSibling,
29    #[error("section boundaries could not be resolved")]
30    BoundaryError,
31}
32
33/// Computes the byte range of the section starting at `headings[idx]`.
34/// The section ends just before the next heading of equal or higher level
35/// (numerically ≤ the current heading level), or at end-of-document.
36pub fn section_range(text: &str, index: &MarkdownIndex, heading_idx: usize) -> Option<ByteRange> {
37    let headings = &index.headings;
38    if heading_idx >= headings.len() {
39        return None;
40    }
41    let start = headings[heading_idx].source_range.start;
42    let level = headings[heading_idx].level;
43
44    // Find the next heading at the same or higher level.
45    let end = headings[heading_idx + 1..]
46        .iter()
47        .find(|h| h.level <= level)
48        .map(|h| h.source_range.start)
49        .unwrap_or(text.len());
50
51    Some(ByteRange::new(start, end))
52}
53
54/// Moves the section at `heading_idx` one position earlier among its
55/// siblings (swaps with the preceding sibling section).
56pub fn move_section_up(
57    text: &str,
58    index: &MarkdownIndex,
59    heading_idx: usize,
60) -> Result<SectionMoveResult, SectionError> {
61    if heading_idx >= index.headings.len() {
62        return Err(SectionError::HeadingIndexOutOfRange(heading_idx));
63    }
64    let level = index.headings[heading_idx].level;
65
66    // Find the preceding sibling (same level, no intervening higher-level heading).
67    let prev_idx = (0..heading_idx)
68        .rev()
69        .find(|&i| index.headings[i].level == level)
70        .ok_or(SectionError::NoSibling)?;
71
72    // Verify no heading with a higher level (lower number) exists between them.
73    let has_parent_between = (prev_idx + 1..heading_idx).any(|i| index.headings[i].level < level);
74    if has_parent_between {
75        return Err(SectionError::NoSibling);
76    }
77
78    let range_prev = section_range(text, index, prev_idx).ok_or(SectionError::BoundaryError)?;
79    let range_curr = section_range(text, index, heading_idx).ok_or(SectionError::BoundaryError)?;
80
81    swap_sections(text, range_prev, range_curr)
82}
83
84/// Moves the section at `heading_idx` one position later among its siblings.
85pub fn move_section_down(
86    text: &str,
87    index: &MarkdownIndex,
88    heading_idx: usize,
89) -> Result<SectionMoveResult, SectionError> {
90    if heading_idx >= index.headings.len() {
91        return Err(SectionError::HeadingIndexOutOfRange(heading_idx));
92    }
93    let level = index.headings[heading_idx].level;
94
95    // Find the next sibling (same level, no intervening higher-level heading).
96    let next_idx = (heading_idx + 1..index.headings.len())
97        .find(|&i| index.headings[i].level == level)
98        .ok_or(SectionError::NoSibling)?;
99
100    let has_parent_between = (heading_idx + 1..next_idx).any(|i| index.headings[i].level < level);
101    if has_parent_between {
102        return Err(SectionError::NoSibling);
103    }
104
105    let range_curr = section_range(text, index, heading_idx).ok_or(SectionError::BoundaryError)?;
106    let range_next = section_range(text, index, next_idx).ok_or(SectionError::BoundaryError)?;
107
108    let result = swap_sections(text, range_curr, range_next)?;
109    // heading is now at range_next.start after the swap
110    Ok(result)
111}
112
113fn swap_sections(
114    text: &str,
115    first: ByteRange,
116    second: ByteRange,
117) -> Result<SectionMoveResult, SectionError> {
118    // Sections must be contiguous: first.end == second.start (possibly
119    // with intervening blank lines that belong to neither).
120    // We keep the text between sections (blank lines) attached to the
121    // second section header so spacing is preserved.
122    let first_text = &text[first.start..first.end];
123    let second_text = &text[second.start..second.end];
124    let gap = &text[first.end..second.start];
125
126    let mut result = String::with_capacity(text.len());
127    result.push_str(&text[..first.start]);
128    result.push_str(second_text);
129    result.push_str(gap);
130    result.push_str(first_text);
131    result.push_str(&text[second.end..]);
132
133    let new_heading_offset = first.start;
134    Ok(SectionMoveResult {
135        text: result,
136        new_heading_offset,
137    })
138}