Skip to main content

ochrepad_core/
transform.rs

1//! Text transforms: case conversion, line sorting, and line operations
2//! (trim, duplicate, move, join). Each resolves to one transaction.
3
4use std::cmp::Ordering;
5use std::collections::BTreeSet;
6use std::ops::Range;
7
8use serde::{Deserialize, Serialize};
9
10use crate::changeset::{Bias, ChangeSet, Edit};
11use crate::history::EditKind;
12use crate::position::{ByteOffset, visual_col_to_byte, visual_col_to_byte_ceil};
13use crate::resolve::Resolved;
14use crate::selection::{Selection, SelectionSet};
15use crate::text::Document;
16
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
18pub enum CaseMode {
19    Upper,
20    Lower,
21    /// First letter of each word upper case, the rest lower case.
22    Title,
23    /// Swap the case of every letter.
24    Invert,
25}
26
27#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
28pub struct SortOptions {
29    pub descending: bool,
30    pub case_insensitive: bool,
31    /// Compare by the number at the start of each line (integer or
32    /// decimal); lines without one sort first.
33    pub numeric: bool,
34    /// Drop lines equal to the line before them after sorting.
35    pub remove_duplicates: bool,
36}
37
38/// Converts `text` to `mode`. The result may be longer or shorter (`ß` →
39/// `SS`).
40pub fn convert_case(text: &str, mode: CaseMode) -> String {
41    match mode {
42        CaseMode::Upper => text.to_uppercase(),
43        CaseMode::Lower => text.to_lowercase(),
44        CaseMode::Invert => text
45            .chars()
46            .flat_map(|c| -> Box<dyn Iterator<Item = char>> {
47                if c.is_uppercase() {
48                    Box::new(c.to_lowercase())
49                } else if c.is_lowercase() {
50                    Box::new(c.to_uppercase())
51                } else {
52                    Box::new(std::iter::once(c))
53                }
54            })
55            .collect(),
56        CaseMode::Title => {
57            let mut out = String::with_capacity(text.len());
58            let mut in_word = false;
59            for c in text.chars() {
60                if c.is_alphanumeric() {
61                    if in_word {
62                        out.extend(c.to_lowercase());
63                    } else {
64                        out.extend(c.to_uppercase());
65                    }
66                    in_word = true;
67                } else {
68                    out.push(c);
69                    // "don't" stays one word.
70                    in_word = in_word && (c == '\'' || c == '\u{2019}');
71                }
72            }
73            out
74        }
75    }
76}
77
78fn line_range(doc: &Document, line: usize) -> Range<ByteOffset> {
79    doc.line_start(line)..doc.line_end(line)
80}
81
82/// Lines a selection covers. A non-empty selection ending at the very start
83/// of a line does not include that line (as in Notepad++).
84fn lines_of(doc: &Document, s: &Selection) -> (usize, usize) {
85    let first = doc.line_of(s.start());
86    let mut last = doc.line_of(s.end());
87    if !s.is_empty() && last > first && s.end() == doc.line_start(last) {
88        last -= 1;
89    }
90    (first, last)
91}
92
93/// Sorts and merges overlapping or touching ranges.
94fn merge(mut ranges: Vec<Range<ByteOffset>>) -> Vec<Range<ByteOffset>> {
95    ranges.sort_by_key(|r| (r.start, r.end));
96    let mut out: Vec<Range<ByteOffset>> = Vec::with_capacity(ranges.len());
97    for r in ranges {
98        match out.last_mut() {
99            Some(last) if r.start <= last.end => last.end = last.end.max(r.end),
100            _ => out.push(r),
101        }
102    }
103    out
104}
105
106fn edit_result(changes: ChangeSet, after: SelectionSet, sels: &SelectionSet) -> Resolved {
107    if changes.is_empty() {
108        Resolved::Selections(sels.clone())
109    } else {
110        Resolved::Edit {
111            changes,
112            after,
113            kind: EditKind::Other,
114        }
115    }
116}
117
118/// Maps every selection through `changes` (anchor and head both `Before`,
119/// so insertions at a caret land after it).
120fn map_before(sels: &SelectionSet, changes: &ChangeSet) -> SelectionSet {
121    sels.map(|s| Selection {
122        anchor: changes.map_position(s.anchor, Bias::Before),
123        head: changes.map_position(s.head, Bias::Before),
124        goal: None,
125    })
126}
127
128/// Converts each selection, or the caret's line for an empty selection.
129pub(crate) fn case(doc: &Document, sels: &SelectionSet, mode: CaseMode) -> Resolved {
130    let targets = sels
131        .ranges()
132        .iter()
133        .map(|s| {
134            if s.is_empty() {
135                line_range(doc, doc.line_of(s.head))
136            } else {
137                s.range()
138            }
139        })
140        .collect();
141    let pieces: Vec<(Range<ByteOffset>, String, String)> = merge(targets)
142        .into_iter()
143        .map(|r| {
144            let old = doc.slice(r.clone());
145            let new = convert_case(&old, mode);
146            (r, old, new)
147        })
148        .collect();
149    let changes = ChangeSet::new(
150        doc.len(),
151        pieces
152            .iter()
153            .filter(|(_, old, new)| old != new)
154            .map(|(r, _, new)| Edit::new(r.start, r.end, new.as_str()))
155            .collect(),
156    );
157    // Range ends map to the ends of the converted text, so selections cover
158    // it even when its length changed; a caret inside keeps its char index.
159    let remap = |pos: ByteOffset| {
160        let Some((r, old, new)) = pieces.iter().find(|(r, ..)| r.start <= pos && pos <= r.end)
161        else {
162            return changes.map_position(pos, Bias::Before);
163        };
164        let new_start = changes.map_position(r.start, Bias::Before);
165        if pos == r.end {
166            return ByteOffset(new_start.0 + new.len());
167        }
168        let chars = old[..pos.0 - r.start.0].chars().count();
169        let off = new.char_indices().nth(chars).map_or(new.len(), |(i, _)| i);
170        ByteOffset(new_start.0 + off)
171    };
172    let after = sels
173        .map(|s| Selection::new(remap(s.anchor), remap(s.head)))
174        .with_rect(sels.rect().cloned());
175    edit_result(changes, after, sels)
176}
177
178/// The number at the start of `s` (after leading whitespace).
179fn leading_number(s: &str) -> Option<f64> {
180    let t = s.trim_start();
181    let b = t.as_bytes();
182    let mut i = 0;
183    if i < b.len() && (b[i] == b'-' || b[i] == b'+') {
184        i += 1;
185    }
186    let digits_start = i;
187    while i < b.len() && b[i].is_ascii_digit() {
188        i += 1;
189    }
190    let mut digits = i - digits_start;
191    if i < b.len() && b[i] == b'.' {
192        let frac_start = i + 1;
193        let mut j = frac_start;
194        while j < b.len() && b[j].is_ascii_digit() {
195            j += 1;
196        }
197        if j > frac_start {
198            digits += j - frac_start;
199            i = j;
200        }
201    }
202    if digits == 0 {
203        return None;
204    }
205    t[..i].parse().ok()
206}
207
208/// Sorts the selected lines, the rectangle's lines keyed by the text inside
209/// it, or the whole document when nothing is selected.
210pub(crate) fn sort(
211    doc: &Document,
212    sels: &SelectionSet,
213    o: SortOptions,
214    tab_width: usize,
215) -> Resolved {
216    let rect = sels.rect().cloned();
217    let any_selected = sels.ranges().iter().any(|s| !s.is_empty());
218    let (first, mut last) = if let Some(r) = &rect {
219        (r.top(), r.bottom())
220    } else if any_selected {
221        let spans: Vec<_> = sels.ranges().iter().map(|s| lines_of(doc, s)).collect();
222        let first = spans.iter().map(|s| s.0).min().unwrap_or(0);
223        let last = spans.iter().map(|s| s.1).max().unwrap_or(0);
224        (first, last)
225    } else {
226        (0, doc.line_count() - 1)
227    };
228    // A trailing newline's empty last line is not a line to sort.
229    if last > first && last + 1 == doc.line_count() && doc.line_text(last).is_empty() {
230        last -= 1;
231    }
232    if last <= first {
233        return Resolved::Selections(sels.clone());
234    }
235    let lines: Vec<String> = (first..=last).map(|l| doc.line_text(l)).collect();
236    let keys: Vec<String> = (first..=last)
237        .zip(&lines)
238        .map(|(line, text)| {
239            let key = match &rect {
240                Some(r) => {
241                    let start = doc.line_start(line).0;
242                    let right = visual_col_to_byte(doc, line, r.right(), tab_width).0 - start;
243                    let left = (visual_col_to_byte_ceil(doc, line, r.left(), tab_width).0 - start)
244                        .min(right);
245                    text[left..right].to_owned()
246                }
247                None => text.clone(),
248            };
249            if o.case_insensitive {
250                key.to_lowercase()
251            } else {
252                key
253            }
254        })
255        .collect();
256    let compare = |a: usize, b: usize| -> Ordering {
257        let ord = if o.numeric {
258            match (leading_number(&keys[a]), leading_number(&keys[b])) {
259                (Some(x), Some(y)) => x.total_cmp(&y),
260                (None, Some(_)) => Ordering::Less,
261                (Some(_), None) => Ordering::Greater,
262                (None, None) => keys[a].cmp(&keys[b]),
263            }
264        } else {
265            keys[a].cmp(&keys[b])
266        };
267        if o.descending { ord.reverse() } else { ord }
268    };
269    let mut order: Vec<usize> = (0..lines.len()).collect();
270    order.sort_by(|&a, &b| compare(a, b)); // stable
271    if o.remove_duplicates {
272        let same = |a: &str, b: &str| {
273            if o.case_insensitive {
274                a.to_lowercase() == b.to_lowercase()
275            } else {
276                a == b
277            }
278        };
279        order.dedup_by(|b, a| same(&lines[*a], &lines[*b]));
280    }
281    let new: Vec<&str> = order.iter().map(|&i| lines[i].as_str()).collect();
282    let new = new.join("\n");
283    let start = doc.line_start(first);
284    let end = doc.line_end(last);
285    let changes = ChangeSet::new(doc.len(), vec![Edit::new(start, end, new.as_str())]);
286    let after = if any_selected || rect.is_some() {
287        SelectionSet::single(Selection::new(start, ByteOffset(start.0 + new.len())))
288    } else {
289        SelectionSet::caret(start)
290    };
291    edit_result(changes, after, sels)
292}
293
294/// The lines touched by non-empty selections, or every line if none.
295fn selected_lines(doc: &Document, sels: &SelectionSet) -> BTreeSet<usize> {
296    let selected: BTreeSet<usize> = sels
297        .ranges()
298        .iter()
299        .filter(|s| !s.is_empty())
300        .flat_map(|s| {
301            let (a, b) = lines_of(doc, s);
302            a..=b
303        })
304        .collect();
305    if selected.is_empty() {
306        (0..doc.line_count()).collect()
307    } else {
308        selected
309    }
310}
311
312/// Removes spaces and tabs at the ends of the selected lines (all lines
313/// when nothing is selected).
314pub(crate) fn trim_trailing(doc: &Document, sels: &SelectionSet) -> Resolved {
315    let edits = selected_lines(doc, sels)
316        .into_iter()
317        .filter_map(|line| {
318            let text = doc.line_text(line);
319            let keep = text.trim_end_matches([' ', '\t']).len();
320            (keep < text.len()).then(|| {
321                let start = doc.line_start(line).0;
322                Edit::new(ByteOffset(start + keep), doc.line_end(line), "")
323            })
324        })
325        .collect();
326    let changes = ChangeSet::new(doc.len(), edits);
327    let after = map_before(sels, &changes);
328    edit_result(changes, after, sels)
329}
330
331/// Duplicates the caret's line below itself, or a selection right after it.
332/// The carets and selections stay on the originals.
333pub(crate) fn duplicate(doc: &Document, sels: &SelectionSet) -> Resolved {
334    let mut lines = BTreeSet::new();
335    let mut edits = Vec::new();
336    for s in sels.ranges() {
337        if s.is_empty() {
338            lines.insert(doc.line_of(s.head));
339        } else {
340            edits.push(Edit::new(s.end(), s.end(), doc.slice(s.range())));
341        }
342    }
343    for line in lines {
344        let end = doc.line_end(line);
345        edits.push(Edit::new(end, end, format!("\n{}", doc.line_text(line))));
346    }
347    let changes = ChangeSet::new(doc.len(), edits);
348    let after = map_before(sels, &changes);
349    edit_result(changes, after, sels)
350}
351
352/// Moves the block of lines covered by the selections up or down one line;
353/// the selections move with it.
354pub(crate) fn move_lines(doc: &Document, sels: &SelectionSet, up: bool) -> Resolved {
355    let spans: Vec<_> = sels.ranges().iter().map(|s| lines_of(doc, s)).collect();
356    let first = spans.iter().map(|s| s.0).min().unwrap_or(0);
357    let last = spans.iter().map(|s| s.1).max().unwrap_or(0);
358    let block = doc.slice(doc.line_start(first)..doc.line_end(last));
359    let (edit, shift): (Edit, isize) = if up {
360        if first == 0 {
361            return Resolved::Selections(sels.clone());
362        }
363        let prev = doc.line_text(first - 1);
364        (
365            Edit::new(
366                doc.line_start(first - 1),
367                doc.line_end(last),
368                format!("{block}\n{prev}"),
369            ),
370            -(prev.len() as isize + 1),
371        )
372    } else {
373        if last + 1 >= doc.line_count() {
374            return Resolved::Selections(sels.clone());
375        }
376        let next = doc.line_text(last + 1);
377        (
378            Edit::new(
379                doc.line_start(first),
380                doc.line_end(last + 1),
381                format!("{next}\n{block}"),
382            ),
383            next.len() as isize + 1,
384        )
385    };
386    let changes = ChangeSet::new(doc.len(), vec![edit]);
387    // Every selection endpoint lies in the moved block (or at the start of
388    // the line after it), so all of them shift by the same amount. Moving
389    // down past the last line, "the start of the line after the block" no
390    // longer exists (the block now ends the document): clamp to the end.
391    let new_len = changes.new_len().0 as isize;
392    let shift_pos = |p: ByteOffset| ByteOffset((p.0 as isize + shift).clamp(0, new_len) as usize);
393    let after = sels.map(|s| Selection::new(shift_pos(s.anchor), shift_pos(s.head)));
394    edit_result(changes, after, sels)
395}
396
397/// Joins the selected lines (or the caret's line with the next) into one,
398/// replacing each line break and the whitespace around it with one space.
399pub(crate) fn join(doc: &Document, sels: &SelectionSet) -> Resolved {
400    let last_line = doc.line_count() - 1;
401    let mut breaks = BTreeSet::new();
402    for s in sels.ranges() {
403        let (a, b) = lines_of(doc, s);
404        let b = if a == b { (a + 1).min(last_line) } else { b };
405        breaks.extend(a..b);
406    }
407    let edits = breaks
408        .into_iter()
409        .map(|line| {
410            let text = doc.line_text(line);
411            let next = doc.line_text(line + 1);
412            let kept = text.trim_end_matches([' ', '\t']);
413            let rest = next.trim_start_matches([' ', '\t']);
414            let start = ByteOffset(doc.line_start(line).0 + kept.len());
415            let end = ByteOffset(doc.line_start(line + 1).0 + (next.len() - rest.len()));
416            let sep = if kept.is_empty() || rest.is_empty() {
417                ""
418            } else {
419                " "
420            };
421            Edit::new(start, end, sep)
422        })
423        .collect();
424    let changes = ChangeSet::new(doc.len(), edits);
425    let after = map_before(sels, &changes);
426    edit_result(changes, after, sels)
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    #[test]
434    fn case_modes() {
435        let s = "hello WORLD, don't straße";
436        assert_eq!(
437            convert_case(s, CaseMode::Upper),
438            "HELLO WORLD, DON'T STRASSE"
439        );
440        assert_eq!(
441            convert_case(s, CaseMode::Lower),
442            "hello world, don't straße"
443        );
444        assert_eq!(
445            convert_case(s, CaseMode::Title),
446            "Hello World, Don't Straße"
447        );
448        assert_eq!(
449            convert_case(s, CaseMode::Invert),
450            "HELLO world, DON'T STRASSE"
451        );
452    }
453
454    #[test]
455    fn numbers_at_line_starts() {
456        assert_eq!(leading_number("  42 apples"), Some(42.0));
457        assert_eq!(leading_number("-3.5x"), Some(-3.5));
458        assert_eq!(leading_number("7."), Some(7.0));
459        assert_eq!(leading_number("x1"), None);
460        assert_eq!(leading_number("-"), None);
461    }
462}