Skip to main content

idet_core/
lineops.rs

1//! Moving the current line, paragraph or markdown section up or down.
2
3/// Direction in which [`move_segment`] shifts text.
4#[derive(Clone, Copy)]
5pub enum LineMove {
6    /// Move towards the start of the text.
7    Up,
8    /// Move towards the end of the text.
9    Down,
10}
11
12/// How much text [`move_segment`] treats as one unit.
13#[derive(Clone, Copy, PartialEq, Eq)]
14pub enum Segment {
15    /// The single line holding the cursor.
16    Line,
17    /// The run of non-blank lines around the cursor.
18    Block,
19    /// A markdown heading with everything below it, subsections included.
20    Section,
21}
22
23/// Swaps the line at `cursor` with its neighbour in `direction`.
24///
25/// `cursor` is a character index; the cursor stays on the moved line. Returns
26/// the new text and cursor, or `None` when there is no neighbouring line.
27#[must_use]
28pub fn move_line(text: &str, cursor: usize, direction: LineMove) -> Option<(String, usize)> {
29    move_segment(text, cursor, Segment::Line, direction)
30}
31
32/// Swaps the segment holding `cursor` with the neighbouring one in `direction`.
33///
34/// `cursor` is a character index and keeps its position inside the segment as
35/// it moves. Blank lines separating two blocks stay where they are. Returns
36/// the new text and cursor, or `None` when the cursor is outside any segment
37/// or has no neighbour to swap with.
38#[must_use]
39pub fn move_segment(
40    text: &str,
41    cursor: usize,
42    segment: Segment,
43    direction: LineMove,
44) -> Option<(String, usize)> {
45    let lines: Vec<&str> = text.split('\n').collect();
46    let (line, column) = line_column(text, cursor);
47    let (start, end) = segment_range(&lines, line, segment)?;
48    let (other_start, other_end) = neighbour_range(&lines, start, end, segment, direction)?;
49    let moving_first = start < other_start;
50    let (first, second) = if moving_first {
51        ((start, end), (other_start, other_end))
52    } else {
53        ((other_start, other_end), (start, end))
54    };
55    let mut swapped: Vec<&str> = Vec::with_capacity(lines.len());
56    swapped.extend_from_slice(lines.get(..first.0)?);
57    swapped.extend_from_slice(lines.get(second.0..second.1)?);
58    swapped.extend_from_slice(lines.get(first.1..second.0)?);
59    swapped.extend_from_slice(lines.get(first.0..first.1)?);
60    swapped.extend_from_slice(lines.get(second.1..)?);
61    let moved_start = if moving_first {
62        first.0 + (second.1 - second.0) + (second.0 - first.1)
63    } else {
64        first.0
65    };
66    let target_line = moved_start + (line - start);
67    let target_column = column.min(swapped.get(target_line)?.chars().count());
68    let moved_cursor = cursor_at(&swapped, target_line, target_column);
69    Some((swapped.join("\n"), moved_cursor))
70}
71
72fn segment_range(lines: &[&str], line: usize, segment: Segment) -> Option<(usize, usize)> {
73    match segment {
74        Segment::Line => Some((line, line + 1)),
75        Segment::Block => {
76            if lines.get(line)?.trim().is_empty() {
77                return None;
78            }
79            let start = line - filled_run(lines.iter().take(line).rev());
80            let end = line + 1 + filled_run(lines.iter().skip(line + 1));
81            Some((start, end))
82        }
83        Segment::Section => {
84            let start = lines
85                .iter()
86                .take(line + 1)
87                .rposition(|text| heading_level(text).is_some())?;
88            let level = heading_level(lines.get(start)?)?;
89            Some((start, section_end(lines, start, level)))
90        }
91    }
92}
93
94fn neighbour_range(
95    lines: &[&str],
96    start: usize,
97    end: usize,
98    segment: Segment,
99    direction: LineMove,
100) -> Option<(usize, usize)> {
101    match (segment, direction) {
102        (Segment::Line, LineMove::Up) => Some((start.checked_sub(1)?, start)),
103        (Segment::Line, LineMove::Down) => (end < lines.len()).then_some((end, end + 1)),
104        (Segment::Block, LineMove::Up) => {
105            let other_end = start - blank_run(lines.iter().take(start).rev());
106            let last = other_end.checked_sub(1)?;
107            let other_start = last - filled_run(lines.iter().take(last).rev());
108            Some((other_start, other_end))
109        }
110        (Segment::Block, LineMove::Down) => {
111            let other_start = end + blank_run(lines.iter().skip(end));
112            if other_start >= lines.len() {
113                return None;
114            }
115            let other_end = other_start + 1 + filled_run(lines.iter().skip(other_start + 1));
116            Some((other_start, other_end))
117        }
118        (Segment::Section, LineMove::Up) => {
119            let level = heading_level(lines.get(start)?)?;
120            let other_start = lines
121                .iter()
122                .take(start)
123                .rposition(|text| heading_level(text).is_some())?;
124            (heading_level(lines.get(other_start)?) == Some(level)).then_some((other_start, start))
125        }
126        (Segment::Section, LineMove::Down) => {
127            let level = heading_level(lines.get(start)?)?;
128            (heading_level(lines.get(end)?) == Some(level))
129                .then(|| (end, section_end(lines, end, level)))
130        }
131    }
132}
133
134fn blank_run<'a>(lines: impl Iterator<Item = &'a &'a str>) -> usize {
135    lines.take_while(|text| text.trim().is_empty()).count()
136}
137
138fn filled_run<'a>(lines: impl Iterator<Item = &'a &'a str>) -> usize {
139    lines.take_while(|text| !text.trim().is_empty()).count()
140}
141
142fn section_end(lines: &[&str], start: usize, level: usize) -> usize {
143    lines
144        .iter()
145        .enumerate()
146        .skip(start + 1)
147        .find(|(_, text)| heading_level(text).is_some_and(|found| found <= level))
148        .map_or(lines.len(), |(index, _)| index)
149}
150
151fn heading_level(line: &str) -> Option<usize> {
152    let hashes = line.chars().take_while(|&c| c == '#').count();
153    (hashes > 0 && line.chars().nth(hashes) == Some(' ')).then_some(hashes)
154}
155
156/// Locates the zero-indexed `(line, column)` of the character at `cursor`.
157#[must_use]
158pub fn line_column(text: &str, cursor: usize) -> (usize, usize) {
159    let mut line = 0;
160    let mut column = 0;
161    for (index, character) in text.chars().enumerate() {
162        if index == cursor {
163            break;
164        }
165        if character == '\n' {
166            line += 1;
167            column = 0;
168        } else {
169            column += 1;
170        }
171    }
172    (line, column)
173}
174
175/// Converts a `(line, column)` position back into a character index, given
176/// `text` already split on `\n`.
177#[must_use]
178pub fn cursor_at(lines: &[&str], line: usize, column: usize) -> usize {
179    let preceding: usize = lines
180        .iter()
181        .take(line)
182        .map(|text| text.chars().count() + 1)
183        .sum();
184    preceding + column
185}