Skip to main content

ochrepad_core/
transform.rs

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