Skip to main content

ochrepad_core/
transform.rs

1//! Text transforms: case conversion, line sorting, and line operations
2//! (trim, duplicate, move, join, delete, transpose, insert, reverse, remove
3//! empty or duplicate lines) and blank operations (trim, EOL to space, tabs
4//! and spaces). Each resolves to one transaction.
5
6use std::cmp::Ordering;
7use std::collections::{BTreeSet, HashSet};
8use std::ops::Range;
9
10use serde::{Deserialize, Serialize};
11
12use crate::changeset::{Bias, ChangeSet, Edit};
13use crate::history::EditKind;
14use unicode_segmentation::UnicodeSegmentation;
15
16use crate::position::{ByteOffset, grapheme_width, visual_col_to_byte, visual_col_to_byte_ceil};
17use crate::resolve::Resolved;
18use crate::selection::{Selection, SelectionSet};
19use crate::text::Document;
20
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
22pub enum CaseMode {
23    Upper,
24    Lower,
25    /// First letter of each word upper case, the rest lower case.
26    Title,
27    /// First letter of each word upper case, the rest as it was
28    /// (Notepad++'s Proper Case (blend)).
29    TitleBlend,
30    /// First letter of each sentence upper case, the rest lower case.
31    Sentence,
32    /// First letter of each sentence upper case, the rest as it was.
33    SentenceBlend,
34    /// Swap the case of every letter.
35    Invert,
36    /// Each letter upper or lower case at random (Notepad++'s ranDOm
37    /// CasE); the seed decides, so a macro replays it.
38    Random {
39        seed: u64,
40    },
41}
42
43#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
44pub struct SortOptions {
45    pub descending: bool,
46    pub case_insensitive: bool,
47    /// Compare by the number at the start of each line (integer or
48    /// decimal); lines without one sort first.
49    pub numeric: bool,
50    /// Drop lines equal to the line before them after sorting.
51    pub remove_duplicates: bool,
52    /// Compare by length in characters (then as text).
53    #[serde(default)]
54    pub by_length: bool,
55}
56
57/// Converts `text` to `mode`. The result may be longer or shorter (`ß` →
58/// `SS`).
59pub fn convert_case(text: &str, mode: CaseMode) -> String {
60    match mode {
61        CaseMode::Upper => text.to_uppercase(),
62        CaseMode::Lower => text.to_lowercase(),
63        CaseMode::Invert => text
64            .chars()
65            .flat_map(|c| -> Box<dyn Iterator<Item = char>> {
66                if c.is_uppercase() {
67                    Box::new(c.to_lowercase())
68                } else if c.is_lowercase() {
69                    Box::new(c.to_uppercase())
70                } else {
71                    Box::new(std::iter::once(c))
72                }
73            })
74            .collect(),
75        CaseMode::Title | CaseMode::TitleBlend => {
76            let blend = mode == CaseMode::TitleBlend;
77            let mut out = String::with_capacity(text.len());
78            let mut in_word = false;
79            for c in text.chars() {
80                if c.is_alphanumeric() {
81                    if !in_word {
82                        out.extend(c.to_uppercase());
83                    } else if blend {
84                        out.push(c);
85                    } else {
86                        out.extend(c.to_lowercase());
87                    }
88                    in_word = true;
89                } else {
90                    out.push(c);
91                    // "don't" stays one word.
92                    in_word = in_word && (c == '\'' || c == '\u{2019}');
93                }
94            }
95            out
96        }
97        CaseMode::Sentence | CaseMode::SentenceBlend => {
98            // As Notepad++: a sentence starts the text, follows a `.`, `!`
99            // or `?` with no letter right after it (the first dot of
100            // "e.g." does not end one), and follows a blank line.
101            let blend = mode == CaseMode::SentenceBlend;
102            let chars: Vec<char> = text.chars().collect();
103            let mut out = String::with_capacity(text.len());
104            let (mut starts, mut after_break) = (true, false);
105            for (i, &c) in chars.iter().enumerate() {
106                if c.is_alphabetic() {
107                    if starts {
108                        out.extend(c.to_uppercase());
109                    } else if blend {
110                        out.push(c);
111                    } else {
112                        out.extend(c.to_lowercase());
113                    }
114                    starts = false;
115                    after_break = false;
116                    continue;
117                }
118                out.push(c);
119                if c == '\n' {
120                    starts |= after_break;
121                    after_break = true;
122                } else if !c.is_whitespace() {
123                    // Not a blank line; and "2.50" has no sentence end.
124                    after_break = false;
125                    if matches!(c, '.' | '!' | '?') {
126                        starts |= !chars.get(i + 1).is_some_and(|n| n.is_alphanumeric());
127                    }
128                }
129            }
130            out
131        }
132        CaseMode::Random { seed } => {
133            // xorshift64*, as Randomize Line Order.
134            let mut state = seed | 1;
135            let mut upper = || {
136                state ^= state >> 12;
137                state ^= state << 25;
138                state ^= state >> 27;
139                state.wrapping_mul(0x2545_F491_4F6C_DD1D) >> 63 == 1
140            };
141            text.chars()
142                .flat_map(|c| -> Box<dyn Iterator<Item = char>> {
143                    if !c.is_alphabetic() {
144                        Box::new(std::iter::once(c))
145                    } else if upper() {
146                        Box::new(c.to_uppercase())
147                    } else {
148                        Box::new(c.to_lowercase())
149                    }
150                })
151                .collect()
152        }
153    }
154}
155
156fn line_range(doc: &Document, line: usize) -> Range<ByteOffset> {
157    doc.line_start(line)..doc.line_end(line)
158}
159
160/// Lines a selection covers. A non-empty selection ending at the very start
161/// of a line does not include that line (as in Notepad++).
162fn lines_of(doc: &Document, s: &Selection) -> (usize, usize) {
163    let first = doc.line_of(s.start());
164    let mut last = doc.line_of(s.end());
165    if !s.is_empty() && last > first && s.end() == doc.line_start(last) {
166        last -= 1;
167    }
168    (first, last)
169}
170
171/// Sorts and merges overlapping or touching ranges.
172fn merge(mut ranges: Vec<Range<ByteOffset>>) -> Vec<Range<ByteOffset>> {
173    ranges.sort_by_key(|r| (r.start, r.end));
174    let mut out: Vec<Range<ByteOffset>> = Vec::with_capacity(ranges.len());
175    for r in ranges {
176        match out.last_mut() {
177            Some(last) if r.start <= last.end => last.end = last.end.max(r.end),
178            _ => out.push(r),
179        }
180    }
181    out
182}
183
184fn edit_result(changes: ChangeSet, after: SelectionSet, sels: &SelectionSet) -> Resolved {
185    if changes.is_empty() {
186        Resolved::Selections(sels.clone())
187    } else {
188        Resolved::Edit {
189            changes,
190            after,
191            kind: EditKind::Other,
192        }
193    }
194}
195
196/// Maps every selection through `changes` (anchor and head both `Before`,
197/// so insertions at a caret land after it).
198fn map_before(sels: &SelectionSet, changes: &ChangeSet) -> SelectionSet {
199    sels.map(|s| Selection {
200        anchor: changes.map_position(s.anchor, Bias::Before),
201        head: changes.map_position(s.head, Bias::Before),
202        goal: None,
203    })
204}
205
206/// Converts each selection, or the caret's line for an empty selection.
207pub(crate) fn case(doc: &Document, sels: &SelectionSet, mode: CaseMode) -> Resolved {
208    let targets = sels
209        .ranges()
210        .iter()
211        .map(|s| {
212            if s.is_empty() {
213                line_range(doc, doc.line_of(s.head))
214            } else {
215                s.range()
216            }
217        })
218        .collect();
219    let pieces: Vec<(Range<ByteOffset>, String, String)> = merge(targets)
220        .into_iter()
221        .map(|r| {
222            let old = doc.slice(r.clone());
223            let new = convert_case(&old, mode);
224            (r, old, new)
225        })
226        .collect();
227    let changes = ChangeSet::new(
228        doc.len(),
229        pieces
230            .iter()
231            .filter(|(_, old, new)| old != new)
232            .map(|(r, _, new)| Edit::new(r.start, r.end, new.as_str()))
233            .collect(),
234    );
235    // Range ends map to the ends of the converted text, so selections cover
236    // it even when its length changed; a caret inside keeps its char index.
237    let remap = |pos: ByteOffset| {
238        let Some((r, old, new)) = pieces.iter().find(|(r, ..)| r.start <= pos && pos <= r.end)
239        else {
240            return changes.map_position(pos, Bias::Before);
241        };
242        let new_start = changes.map_position(r.start, Bias::Before);
243        if pos == r.end {
244            return ByteOffset(new_start.0 + new.len());
245        }
246        let chars = old[..pos.0 - r.start.0].chars().count();
247        let off = new.char_indices().nth(chars).map_or(new.len(), |(i, _)| i);
248        ByteOffset(new_start.0 + off)
249    };
250    let after = sels
251        .map(|s| Selection::new(remap(s.anchor), remap(s.head)))
252        .with_rect(sels.rect().cloned());
253    edit_result(changes, after, sels)
254}
255
256/// The number at the start of `s` (after leading whitespace).
257fn leading_number(s: &str) -> Option<f64> {
258    let t = s.trim_start();
259    let b = t.as_bytes();
260    let mut i = 0;
261    if i < b.len() && (b[i] == b'-' || b[i] == b'+') {
262        i += 1;
263    }
264    let digits_start = i;
265    while i < b.len() && b[i].is_ascii_digit() {
266        i += 1;
267    }
268    let mut digits = i - digits_start;
269    if i < b.len() && b[i] == b'.' {
270        let frac_start = i + 1;
271        let mut j = frac_start;
272        while j < b.len() && b[j].is_ascii_digit() {
273            j += 1;
274        }
275        if j > frac_start {
276            digits += j - frac_start;
277            i = j;
278        }
279    }
280    if digits == 0 {
281        return None;
282    }
283    t[..i].parse().ok()
284}
285
286/// Sorts the selected lines, the rectangle's lines keyed by the text inside
287/// it, or the whole document when nothing is selected.
288pub(crate) fn sort(
289    doc: &Document,
290    sels: &SelectionSet,
291    o: SortOptions,
292    tab_width: usize,
293) -> Resolved {
294    reorder(doc, sels, tab_width, o.case_insensitive, |lines, keys| {
295        let compare = |a: usize, b: usize| -> Ordering {
296            let ord = if o.numeric {
297                match (leading_number(&keys[a]), leading_number(&keys[b])) {
298                    (Some(x), Some(y)) => x.total_cmp(&y),
299                    (None, Some(_)) => Ordering::Less,
300                    (Some(_), None) => Ordering::Greater,
301                    (None, None) => keys[a].cmp(&keys[b]),
302                }
303            } else if o.by_length {
304                let len = |i: usize| keys[i].chars().count();
305                len(a).cmp(&len(b)).then_with(|| keys[a].cmp(&keys[b]))
306            } else {
307                keys[a].cmp(&keys[b])
308            };
309            if o.descending { ord.reverse() } else { ord }
310        };
311        let mut order: Vec<usize> = (0..lines.len()).collect();
312        order.sort_by(|&a, &b| compare(a, b)); // stable
313        if o.remove_duplicates {
314            let same = |a: &str, b: &str| {
315                if o.case_insensitive {
316                    a.to_lowercase() == b.to_lowercase()
317                } else {
318                    a == b
319                }
320            };
321            order.dedup_by(|b, a| same(&lines[*a], &lines[*b]));
322        }
323        order
324    })
325}
326
327/// Puts the lines Sort Lines would sort in a random order, from `seed`
328/// (Randomize Line Order).
329pub(crate) fn shuffle(
330    doc: &Document,
331    sels: &SelectionSet,
332    seed: u64,
333    tab_width: usize,
334) -> Resolved {
335    reorder(doc, sels, tab_width, false, |lines, _| {
336        // xorshift64*, then Fisher-Yates.
337        let mut state = seed | 1;
338        let mut next = || {
339            state ^= state >> 12;
340            state ^= state << 25;
341            state ^= state >> 27;
342            state.wrapping_mul(0x2545_F491_4F6C_DD1D)
343        };
344        let mut order: Vec<usize> = (0..lines.len()).collect();
345        for i in (1..order.len()).rev() {
346            let j = (next() % (i as u64 + 1)) as usize;
347            order.swap(i, j);
348        }
349        order
350    })
351}
352
353/// Rewrites the lines Sort Lines works on (the selected lines, a
354/// rectangle's lines keyed by the text inside it, or the whole document)
355/// in the order `order` gives from the lines and their keys.
356fn reorder(
357    doc: &Document,
358    sels: &SelectionSet,
359    tab_width: usize,
360    case_insensitive: bool,
361    order: impl FnOnce(&[String], &[String]) -> Vec<usize>,
362) -> Resolved {
363    let rect = sels.rect().cloned();
364    let any_selected = sels.ranges().iter().any(|s| !s.is_empty());
365    let (first, mut last) = if let Some(r) = &rect {
366        (r.top(), r.bottom())
367    } else if any_selected {
368        let spans: Vec<_> = sels.ranges().iter().map(|s| lines_of(doc, s)).collect();
369        let first = spans.iter().map(|s| s.0).min().unwrap_or(0);
370        let last = spans.iter().map(|s| s.1).max().unwrap_or(0);
371        (first, last)
372    } else {
373        (0, doc.line_count() - 1)
374    };
375    // A trailing newline's empty last line is not a line to sort.
376    if last > first && last + 1 == doc.line_count() && doc.line_text(last).is_empty() {
377        last -= 1;
378    }
379    if last <= first {
380        return Resolved::Selections(sels.clone());
381    }
382    let lines: Vec<String> = (first..=last).map(|l| doc.line_text(l)).collect();
383    let keys: Vec<String> = (first..=last)
384        .zip(&lines)
385        .map(|(line, text)| {
386            let key = match &rect {
387                Some(r) => {
388                    let start = doc.line_start(line).0;
389                    let right = visual_col_to_byte(doc, line, r.right(), tab_width).0 - start;
390                    let left = (visual_col_to_byte_ceil(doc, line, r.left(), tab_width).0 - start)
391                        .min(right);
392                    text[left..right].to_owned()
393                }
394                None => text.clone(),
395            };
396            if case_insensitive {
397                key.to_lowercase()
398            } else {
399                key
400            }
401        })
402        .collect();
403    let order = order(&lines, &keys);
404    let new: Vec<&str> = order.iter().map(|&i| lines[i].as_str()).collect();
405    let new = new.join("\n");
406    let start = doc.line_start(first);
407    let end = doc.line_end(last);
408    let changes = ChangeSet::new(doc.len(), vec![Edit::new(start, end, new.as_str())]);
409    let after = if any_selected || rect.is_some() {
410        SelectionSet::single(Selection::new(start, ByteOffset(start.0 + new.len())))
411    } else {
412        SelectionSet::caret(start)
413    };
414    edit_result(changes, after, sels)
415}
416
417/// The lines touched by non-empty selections, or every line if none.
418fn selected_lines(doc: &Document, sels: &SelectionSet) -> BTreeSet<usize> {
419    let selected: BTreeSet<usize> = sels
420        .ranges()
421        .iter()
422        .filter(|s| !s.is_empty())
423        .flat_map(|s| {
424            let (a, b) = lines_of(doc, s);
425            a..=b
426        })
427        .collect();
428    if selected.is_empty() {
429        (0..doc.line_count()).collect()
430    } else {
431        selected
432    }
433}
434
435/// Removes spaces and tabs at the ends of the selected lines (all lines
436/// when nothing is selected).
437pub(crate) fn trim_trailing(doc: &Document, sels: &SelectionSet) -> Resolved {
438    let edits = selected_lines(doc, sels)
439        .into_iter()
440        .filter_map(|line| {
441            let text = doc.line_text(line);
442            let keep = text.trim_end_matches([' ', '\t']).len();
443            (keep < text.len()).then(|| {
444                let start = doc.line_start(line).0;
445                Edit::new(ByteOffset(start + keep), doc.line_end(line), "")
446            })
447        })
448        .collect();
449    let changes = ChangeSet::new(doc.len(), edits);
450    let after = map_before(sels, &changes);
451    edit_result(changes, after, sels)
452}
453
454/// Duplicates the caret's line below itself, or a selection right after it.
455/// The carets and selections stay on the originals.
456pub(crate) fn duplicate(doc: &Document, sels: &SelectionSet) -> Resolved {
457    let mut lines = BTreeSet::new();
458    let mut edits = Vec::new();
459    for s in sels.ranges() {
460        if s.is_empty() {
461            lines.insert(doc.line_of(s.head));
462        } else {
463            edits.push(Edit::new(s.end(), s.end(), doc.slice(s.range())));
464        }
465    }
466    for line in lines {
467        let end = doc.line_end(line);
468        edits.push(Edit::new(end, end, format!("\n{}", doc.line_text(line))));
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/// Moves the block of lines covered by the selections up or down one line;
476/// the selections move with it.
477pub(crate) fn move_lines(doc: &Document, sels: &SelectionSet, up: bool) -> Resolved {
478    let spans: Vec<_> = sels.ranges().iter().map(|s| lines_of(doc, s)).collect();
479    let first = spans.iter().map(|s| s.0).min().unwrap_or(0);
480    let last = spans.iter().map(|s| s.1).max().unwrap_or(0);
481    let block = doc.slice(doc.line_start(first)..doc.line_end(last));
482    let (edit, shift): (Edit, isize) = if up {
483        if first == 0 {
484            return Resolved::Selections(sels.clone());
485        }
486        let prev = doc.line_text(first - 1);
487        (
488            Edit::new(
489                doc.line_start(first - 1),
490                doc.line_end(last),
491                format!("{block}\n{prev}"),
492            ),
493            -(prev.len() as isize + 1),
494        )
495    } else {
496        if last + 1 >= doc.line_count() {
497            return Resolved::Selections(sels.clone());
498        }
499        let next = doc.line_text(last + 1);
500        (
501            Edit::new(
502                doc.line_start(first),
503                doc.line_end(last + 1),
504                format!("{next}\n{block}"),
505            ),
506            next.len() as isize + 1,
507        )
508    };
509    let changes = ChangeSet::new(doc.len(), vec![edit]);
510    // Every selection endpoint lies in the moved block (or at the start of
511    // the line after it), so all of them shift by the same amount. Moving
512    // down past the last line, "the start of the line after the block" no
513    // longer exists (the block now ends the document): clamp to the end.
514    let new_len = changes.new_len().0 as isize;
515    let shift_pos = |p: ByteOffset| ByteOffset((p.0 as isize + shift).clamp(0, new_len) as usize);
516    let after = sels.map(|s| Selection::new(shift_pos(s.anchor), shift_pos(s.head)));
517    edit_result(changes, after, sels)
518}
519
520/// Joins the selected lines (or the caret's line with the next) into one,
521/// replacing each line break and the whitespace around it with one space.
522pub(crate) fn join(doc: &Document, sels: &SelectionSet) -> Resolved {
523    let last_line = doc.line_count() - 1;
524    let mut breaks = BTreeSet::new();
525    for s in sels.ranges() {
526        let (a, b) = lines_of(doc, s);
527        let b = if a == b { (a + 1).min(last_line) } else { b };
528        breaks.extend(a..b);
529    }
530    let edits = breaks
531        .into_iter()
532        .map(|line| {
533            let text = doc.line_text(line);
534            let next = doc.line_text(line + 1);
535            let kept = text.trim_end_matches([' ', '\t']);
536            let rest = next.trim_start_matches([' ', '\t']);
537            let start = ByteOffset(doc.line_start(line).0 + kept.len());
538            let end = ByteOffset(doc.line_start(line + 1).0 + (next.len() - rest.len()));
539            let sep = if kept.is_empty() || rest.is_empty() {
540                ""
541            } else {
542                " "
543            };
544            Edit::new(start, end, sep)
545        })
546        .collect();
547    let changes = ChangeSet::new(doc.len(), edits);
548    let after = map_before(sels, &changes);
549    edit_result(changes, after, sels)
550}
551
552/// The lines the selections touch (a selection ending at a line start does
553/// not take that line), or the carets' lines.
554/// Splits each touched line wider than `width` columns at the blanks
555/// between its words (Split Lines): each run of blanks where it breaks
556/// becomes a line break and the line's indentation, so the new lines line
557/// up with it. A word wider than the width stays whole on its own line,
558/// and blanks at the end of the line stay.
559pub(crate) fn split_lines(
560    doc: &Document,
561    sels: &SelectionSet,
562    width: usize,
563    tab_width: usize,
564) -> Resolved {
565    let mut edits = Vec::new();
566    for line in touched_lines(doc, sels) {
567        let text = doc.line_text(line);
568        let start = doc.line_start(line).0;
569        let lead = text.len() - text.trim_start_matches([' ', '\t']).len();
570        let indent = &text[..lead];
571        for blanks in split_points(&text, width.max(1), tab_width) {
572            edits.push(Edit::new(
573                ByteOffset(start + blanks.start),
574                ByteOffset(start + blanks.end),
575                format!("\n{indent}"),
576            ));
577        }
578    }
579    let changes = ChangeSet::new(doc.len(), edits);
580    let after = map_selections_around(sels, &changes);
581    edit_result(changes, after, sels)
582}
583
584/// The runs of blanks (byte ranges) at which `text`, one line, breaks so
585/// that its pieces fit `width` columns where they can.
586fn split_points(text: &str, width: usize, tab_width: usize) -> Vec<Range<usize>> {
587    let cols = |s: &str, from: usize| {
588        s.graphemes(true)
589            .fold(from, |col, g| col + grapheme_width(g, col, tab_width))
590            - from
591    };
592    let blank_end = |at: usize| text.len() - text[at..].trim_start_matches([' ', '\t']).len();
593    let lead = blank_end(0);
594    let indent = cols(&text[..lead], 0);
595    let mut breaks = Vec::new();
596    // The column the next character goes at, and whether a word is on the
597    // piece being filled already.
598    let (mut col, mut filled) = (indent, false);
599    let mut at = lead;
600    while at < text.len() {
601        let word = blank_end(at);
602        if word == text.len() {
603            break;
604        }
605        let end = text[word..]
606            .find([' ', '\t'])
607            .map_or(text.len(), |i| word + i);
608        let blanks = cols(&text[at..word], col);
609        let word_cols = cols(&text[word..end], col + blanks);
610        if filled && col + blanks + word_cols > width {
611            breaks.push(at..word);
612            col = indent + cols(&text[word..end], indent);
613        } else {
614            col += blanks + word_cols;
615        }
616        filled = true;
617        at = end;
618    }
619    breaks
620}
621
622fn touched_lines(doc: &Document, sels: &SelectionSet) -> BTreeSet<usize> {
623    sels.ranges()
624        .iter()
625        .flat_map(|s| {
626            let (a, b) = lines_of(doc, s);
627            a..=b
628        })
629        .collect()
630}
631
632/// Whether Tab should indent lines rather than insert: some selection spans
633/// more than one line.
634pub(crate) fn spans_lines(doc: &Document, sels: &SelectionSet) -> bool {
635    sels.ranges().iter().any(|s| {
636        let (a, b) = lines_of(doc, s);
637        !s.is_empty() && a != b
638    })
639}
640
641/// Maps selections through an edit that inserts or removes at line starts,
642/// keeping a selection that starts at a line start covering what was
643/// inserted there.
644fn map_selections_around(sels: &SelectionSet, changes: &ChangeSet) -> SelectionSet {
645    sels.map(|s| {
646        let (start_bias, end_bias) = (Bias::Before, Bias::After);
647        let map = |p: ByteOffset, bias| changes.map_position(p, bias);
648        if s.is_empty() {
649            Selection::caret(map(s.head, Bias::After))
650        } else if s.anchor <= s.head {
651            Selection::new(map(s.anchor, start_bias), map(s.head, end_bias))
652        } else {
653            Selection::new(map(s.anchor, end_bias), map(s.head, start_bias))
654        }
655    })
656}
657
658/// Indents each touched line (except empty ones) by `unit`.
659pub(crate) fn indent(doc: &Document, sels: &SelectionSet, unit: &str) -> Resolved {
660    let edits = touched_lines(doc, sels)
661        .into_iter()
662        .filter(|&l| doc.line_start(l) < doc.line_end(l))
663        .map(|l| Edit::new(doc.line_start(l), doc.line_start(l), unit))
664        .collect();
665    let changes = ChangeSet::new(doc.len(), edits);
666    let after = map_selections_around(sels, &changes);
667    edit_result(changes, after, sels)
668}
669
670/// Removes one level of indentation from each touched line: a leading tab,
671/// or up to `width` leading spaces.
672pub(crate) fn unindent(doc: &Document, sels: &SelectionSet, width: usize) -> Resolved {
673    let edits = touched_lines(doc, sels)
674        .into_iter()
675        .filter_map(|l| {
676            let text = doc.line_text(l);
677            let n = if text.starts_with('\t') {
678                1
679            } else {
680                text.bytes()
681                    .take(width.max(1))
682                    .take_while(|&b| b == b' ')
683                    .count()
684            };
685            let start = doc.line_start(l);
686            (n > 0).then(|| Edit::new(start, ByteOffset(start.0 + n), ""))
687        })
688        .collect();
689    let changes = ChangeSet::new(doc.len(), edits);
690    let after = sels.map(|s| Selection {
691        anchor: changes.map_position(s.anchor, Bias::Before),
692        head: changes.map_position(s.head, Bias::Before),
693        goal: None,
694    });
695    edit_result(changes, after, sels)
696}
697
698/// Bytes of leading spaces and tabs.
699fn indent_len(text: &str) -> usize {
700    text.len() - text.trim_start_matches([' ', '\t']).len()
701}
702
703/// What a line comment command does.
704#[derive(Clone, Copy, Debug, PartialEq, Eq)]
705pub(crate) enum CommentOp {
706    /// Comment the lines, or uncomment them if they all are.
707    Toggle,
708    /// Comment the lines, even those that already are.
709    Add,
710    /// Uncomment the lines that are.
711    Remove,
712}
713
714/// Comments the touched lines with `marker` at their common indent, or
715/// uncomments them (`op`; `Toggle` uncomments if every non-blank one is
716/// commented).
717pub(crate) fn line_comment(
718    doc: &Document,
719    sels: &SelectionSet,
720    marker: &str,
721    op: CommentOp,
722) -> Resolved {
723    let lines: Vec<(usize, String)> = touched_lines(doc, sels)
724        .into_iter()
725        .map(|l| (l, doc.line_text(l)))
726        .filter(|(_, t)| !t.trim().is_empty())
727        .collect();
728    if lines.is_empty() {
729        return Resolved::Selections(sels.clone());
730    }
731    let commented = |t: &str| t[indent_len(t)..].starts_with(marker);
732    let remove = match op {
733        CommentOp::Toggle => lines.iter().all(|(_, t)| commented(t)),
734        CommentOp::Add => false,
735        CommentOp::Remove => true,
736    };
737    let edits = if remove {
738        lines
739            .iter()
740            .filter(|(_, t)| commented(t))
741            .map(|(l, t)| {
742                let at = doc.line_start(*l).0 + indent_len(t);
743                let rest = &t[indent_len(t) + marker.len()..];
744                let len = marker.len() + usize::from(rest.starts_with(' '));
745                Edit::new(ByteOffset(at), ByteOffset(at + len), "")
746            })
747            .collect()
748    } else {
749        let col = lines.iter().map(|(_, t)| indent_len(t)).min().unwrap_or(0);
750        lines
751            .iter()
752            .map(|(l, _)| {
753                let at = ByteOffset(doc.line_start(*l).0 + col);
754                Edit::new(at, at, format!("{marker} "))
755            })
756            .collect()
757    };
758    let changes = ChangeSet::new(doc.len(), edits);
759    let after = map_selections_around(sels, &changes);
760    edit_result(changes, after, sels)
761}
762
763/// Wraps each selection (a caret: its line's text) in `open` … `close`, or
764/// unwraps it if it already is wrapped.
765pub(crate) fn toggle_block_comment(
766    doc: &Document,
767    sels: &SelectionSet,
768    open: &str,
769    close: &str,
770) -> Resolved {
771    let mut edits = Vec::new();
772    for s in sels.ranges() {
773        let range = if s.is_empty() {
774            let line = doc.line_of(s.head);
775            let text = doc.line_text(line);
776            let start = doc.line_start(line).0;
777            ByteOffset(start + indent_len(&text))..ByteOffset(start + text.trim_end().len())
778        } else {
779            s.range()
780        };
781        if range.start >= range.end {
782            continue;
783        }
784        let text = doc.slice(range.clone());
785        let lead = text.len() - text.trim_start().len();
786        let inner = text.trim();
787        if in_block_comment(&text, open, close) {
788            // Unwrap, with one space inside each marker if there is one.
789            let start = range.start.0 + lead;
790            let end = start + inner.len();
791            let body = &inner[open.len()..inner.len() - close.len()];
792            let a = open.len() + usize::from(body.starts_with(' '));
793            let b = close.len() + usize::from(body.len() > 1 && body.ends_with(' '));
794            edits.push(Edit::new(ByteOffset(start), ByteOffset(start + a), ""));
795            edits.push(Edit::new(ByteOffset(end - b), ByteOffset(end), ""));
796        } else {
797            edits.push(Edit::new(range.start, range.start, format!("{open} ")));
798            edits.push(Edit::new(range.end, range.end, format!(" {close}")));
799        }
800    }
801    let changes = ChangeSet::new(doc.len(), edits);
802    let after = map_selections_around(sels, &changes);
803    edit_result(changes, after, sels)
804}
805
806/// Whether `text`, blanks aside, is one block comment.
807fn in_block_comment(text: &str, open: &str, close: &str) -> bool {
808    let inner = text.trim();
809    // The first close after the open is the one at the end: "/* a */ b
810    // /* c */" is two comments, not one.
811    inner.len() >= open.len() + close.len()
812        && inner.starts_with(open)
813        && inner[open.len()..].find(close) == Some(inner.len() - open.len() - close.len())
814}
815
816/// Line comments with the line marker, or a block comment around the lines'
817/// text for a language with only block comments.
818pub(crate) fn comment_lines(
819    doc: &Document,
820    sels: &SelectionSet,
821    syntax: &crate::buffer::CommentSyntax,
822    op: CommentOp,
823) -> Option<Resolved> {
824    if let Some(marker) = &syntax.line {
825        return Some(line_comment(doc, sels, marker, op));
826    }
827    let (open, close) = syntax.block.as_ref()?;
828    // Each run of touched lines is one block: from its first non-blank
829    // line's text to its last one's end.
830    let text_start = |l: usize| ByteOffset(doc.line_start(l).0 + indent_len(&doc.line_text(l)));
831    let span = |a: usize, b: usize| Selection::new(text_start(a), doc.line_end(b));
832    let is_comment = |s: &Selection| in_block_comment(&doc.slice(s.range()), open, close);
833    let mut runs: Vec<(usize, usize)> = Vec::new();
834    for l in touched_lines(doc, sels) {
835        match runs.last_mut() {
836            Some((_, end)) if *end + 1 == l => *end = l,
837            _ => runs.push((l, l)),
838        }
839    }
840    // What to toggle: a run's block, or its lines that are comments each.
841    let mut targets = Vec::new();
842    for (a, b) in runs {
843        let filled: Vec<usize> = (a..=b)
844            .filter(|&l| !doc.line_text(l).trim().is_empty())
845            .collect();
846        let (Some(&first), Some(&last)) = (filled.first(), filled.last()) else {
847            continue;
848        };
849        let whole = span(first, last);
850        let each: Vec<Selection> = filled.iter().map(|&l| span(l, l)).collect();
851        let one = is_comment(&whole);
852        let every = each.iter().all(&is_comment);
853        match op {
854            CommentOp::Add if !one && !every => targets.push(whole),
855            CommentOp::Add => {}
856            _ if one => targets.push(whole),
857            CommentOp::Remove => targets.extend(each.into_iter().filter(&is_comment)),
858            CommentOp::Toggle if every => targets.extend(each),
859            CommentOp::Toggle => targets.push(whole),
860        }
861    }
862    if targets.is_empty() {
863        return Some(Resolved::Selections(sels.clone()));
864    }
865    // Each target is wrapped if it is not a comment and unwrapped if it is.
866    let targets = SelectionSet::new(targets, 0);
867    match toggle_block_comment(doc, &targets, open, close) {
868        Resolved::Edit { changes, kind, .. } => {
869            let after = map_selections_around(sels, &changes);
870            Some(Resolved::Edit {
871                changes,
872                after,
873                kind,
874            })
875        }
876        other => Some(other),
877    }
878}
879
880/// The lines a removing line operation looks at: the rectangle's lines, the
881/// lines touched by non-empty selections, or every line. A trailing
882/// newline's empty last line is not a line here (as in `sort`).
883fn target_lines(doc: &Document, sels: &SelectionSet) -> Vec<usize> {
884    let mut lines: Vec<usize> = match sels.rect() {
885        Some(r) => (r.top()..=r.bottom()).collect(),
886        None => selected_lines(doc, sels).into_iter().collect(),
887    };
888    let last = doc.line_count() - 1;
889    if last > 0 && lines.last() == Some(&last) && doc.line_text(last).is_empty() {
890        lines.pop();
891    }
892    lines
893}
894
895/// Deletes whole lines (sorted, distinct) with their line breaks. A run of
896/// lines that ends the document takes the line break before it instead, so
897/// no empty line is left behind.
898/// Deletes `lines` (sorted) with their line breaks; the last line takes
899/// the break before it.
900pub(crate) fn remove_lines(doc: &Document, sels: &SelectionSet, lines: &[usize]) -> Resolved {
901    let last = doc.line_count() - 1;
902    let mut edits = Vec::new();
903    let mut i = 0;
904    while i < lines.len() {
905        let first = lines[i];
906        while i + 1 < lines.len() && lines[i + 1] == lines[i] + 1 {
907            i += 1;
908        }
909        let end = lines[i];
910        i += 1;
911        let range = if end < last {
912            doc.line_start(first)..doc.line_start(end + 1)
913        } else if first > 0 {
914            doc.line_end(first - 1)..doc.len()
915        } else {
916            ByteOffset(0)..doc.len()
917        };
918        if range.start < range.end {
919            edits.push(Edit::new(range.start, range.end, ""));
920        }
921    }
922    let changes = ChangeSet::new(doc.len(), edits);
923    let after = map_before(sels, &changes);
924    edit_result(changes, after, sels)
925}
926
927/// Removes the empty lines among the target lines; with
928/// `include_whitespace`, also lines of only spaces and tabs.
929pub(crate) fn remove_empty(
930    doc: &Document,
931    sels: &SelectionSet,
932    include_whitespace: bool,
933) -> Resolved {
934    let lines: Vec<usize> = target_lines(doc, sels)
935        .into_iter()
936        .filter(|&line| {
937            let text = doc.line_text(line);
938            if include_whitespace {
939                text.trim_matches([' ', '\t']).is_empty()
940            } else {
941                text.is_empty()
942            }
943        })
944        .collect();
945    remove_lines(doc, sels, &lines)
946}
947
948/// Removes target lines equal to an earlier target line, keeping the first
949/// of each and the order; with `consecutive_only`, only lines equal to the
950/// line right before them.
951pub(crate) fn remove_duplicates(
952    doc: &Document,
953    sels: &SelectionSet,
954    consecutive_only: bool,
955) -> Resolved {
956    let mut seen = HashSet::new();
957    let mut prev: Option<(usize, String)> = None;
958    let mut lines = Vec::new();
959    for line in target_lines(doc, sels) {
960        let text = doc.line_text(line);
961        let duplicate = if consecutive_only {
962            prev.as_ref()
963                .is_some_and(|(l, t)| l + 1 == line && *t == text)
964        } else {
965            !seen.insert(text.clone())
966        };
967        if duplicate {
968            lines.push(line);
969        }
970        prev = Some((line, text));
971    }
972    remove_lines(doc, sels, &lines)
973}
974
975/// Deletes the lines the selections touch (the caret's line for a caret),
976/// with their line breaks: Notepad++'s Line Delete (Ctrl+Shift+L).
977pub(crate) fn delete_lines(doc: &Document, sels: &SelectionSet) -> Resolved {
978    let lines: Vec<usize> = touched_lines(doc, sels).into_iter().collect();
979    remove_lines(doc, sels, &lines)
980}
981
982/// Swaps each caret's line with the line above it (Ctrl+T). The caret goes
983/// to the start of its line, which now holds the text from above, as in
984/// Notepad++. A line next to one already swapped is left alone.
985pub(crate) fn transpose(doc: &Document, sels: &SelectionSet) -> Resolved {
986    let lines: BTreeSet<usize> = sels.ranges().iter().map(|s| doc.line_of(s.head)).collect();
987    let mut edits = Vec::new();
988    let mut swapped = BTreeSet::new();
989    for line in lines {
990        if line == 0 || swapped.contains(&(line - 1)) {
991            continue;
992        }
993        let above = doc.line_text(line - 1);
994        let this = doc.line_text(line);
995        edits.push(Edit::new(
996            doc.line_start(line - 1),
997            doc.line_end(line),
998            format!("{this}\n{above}"),
999        ));
1000        swapped.insert(line);
1001    }
1002    let changes = ChangeSet::new(doc.len(), edits);
1003    // A swap keeps the length of what it replaces, so positions outside the
1004    // swaps stay; inside one, a position follows its text to its new line.
1005    let follow = |pos: ByteOffset| {
1006        for &line in &swapped {
1007            let (above, this) = (doc.line_start(line - 1), doc.line_start(line));
1008            let this_len = doc.line_end(line).0 - this.0;
1009            if above <= pos && pos < this {
1010                return ByteOffset(pos.0 + this_len + 1);
1011            }
1012            if this <= pos && pos <= doc.line_end(line) {
1013                return ByteOffset(above.0 + pos.0 - this.0);
1014            }
1015        }
1016        pos
1017    };
1018    let after = sels.map(|s| {
1019        let line = doc.line_of(s.head);
1020        if swapped.contains(&line) {
1021            let this_len = doc.line_end(line).0 - doc.line_start(line).0;
1022            Selection::caret(ByteOffset(doc.line_start(line - 1).0 + this_len + 1))
1023        } else {
1024            Selection::new(follow(s.anchor), follow(s.head))
1025        }
1026    });
1027    edit_result(changes, after, sels)
1028}
1029
1030/// Inserts a line above (or below) each caret's line, or above a
1031/// selection's first line (below its last), indented like that line; the
1032/// carets go to the new lines.
1033pub(crate) fn insert_line(doc: &Document, sels: &SelectionSet, above: bool) -> Resolved {
1034    let line_for = |s: &Selection| {
1035        let (first, last) = lines_of(doc, s);
1036        if above { first } else { last }
1037    };
1038    let lines: BTreeSet<usize> = sels.ranges().iter().map(line_for).collect();
1039    // (line, where the text goes in, the caret's offset from there)
1040    let mut spots = Vec::new();
1041    let mut edits = Vec::new();
1042    for line in lines {
1043        let text = doc.line_text(line);
1044        let indent = &text[..indent_len(&text)];
1045        if above {
1046            let at = doc.line_start(line);
1047            edits.push(Edit::new(at, at, format!("{indent}\n")));
1048            spots.push((line, at, indent.len()));
1049        } else {
1050            let at = doc.line_end(line);
1051            edits.push(Edit::new(at, at, format!("\n{indent}")));
1052            spots.push((line, at, indent.len() + 1));
1053        }
1054    }
1055    let changes = ChangeSet::new(doc.len(), edits);
1056    let primary_line = line_for(sels.primary());
1057    let primary = spots
1058        .iter()
1059        .position(|(line, ..)| *line == primary_line)
1060        .unwrap_or(0);
1061    let carets = spots
1062        .iter()
1063        .map(|&(_, at, offset)| {
1064            Selection::caret(ByteOffset(
1065                changes.map_position(at, Bias::Before).0 + offset,
1066            ))
1067        })
1068        .collect();
1069    edit_result(changes, SelectionSet::new(carets, primary), sels)
1070}
1071
1072/// Reverses the order of the selected lines (from the first to the last),
1073/// the rectangle's lines, or every line when nothing is selected.
1074pub(crate) fn reverse(doc: &Document, sels: &SelectionSet) -> Resolved {
1075    let rect = sels.rect().cloned();
1076    let any_selected = sels.ranges().iter().any(|s| !s.is_empty());
1077    let (first, mut last) = if let Some(r) = &rect {
1078        (r.top(), r.bottom())
1079    } else if any_selected {
1080        let spans: Vec<_> = sels.ranges().iter().map(|s| lines_of(doc, s)).collect();
1081        let first = spans.iter().map(|s| s.0).min().unwrap_or(0);
1082        let last = spans.iter().map(|s| s.1).max().unwrap_or(0);
1083        (first, last)
1084    } else {
1085        (0, doc.line_count() - 1)
1086    };
1087    // A trailing newline's empty last line is not a line to move.
1088    if last > first && last + 1 == doc.line_count() && doc.line_text(last).is_empty() {
1089        last -= 1;
1090    }
1091    if last <= first {
1092        return Resolved::Selections(sels.clone());
1093    }
1094    let mut lines: Vec<String> = (first..=last).map(|l| doc.line_text(l)).collect();
1095    lines.reverse();
1096    let new = lines.join("\n");
1097    let (start, end) = (doc.line_start(first), doc.line_end(last));
1098    if doc.slice(start..end) == new {
1099        return Resolved::Selections(sels.clone());
1100    }
1101    let changes = ChangeSet::new(doc.len(), vec![Edit::new(start, end, new.as_str())]);
1102    let after = if any_selected || rect.is_some() {
1103        SelectionSet::single(Selection::new(start, ByteOffset(start.0 + new.len())))
1104    } else {
1105        SelectionSet::caret(start)
1106    };
1107    edit_result(changes, after, sels)
1108}
1109
1110/// Removes leading spaces and tabs (and with `trailing`, trailing ones too)
1111/// from the selected lines, or from every line when nothing is selected.
1112pub(crate) fn trim_lines(doc: &Document, sels: &SelectionSet, trailing: bool) -> Resolved {
1113    let mut edits = Vec::new();
1114    for line in selected_lines(doc, sels) {
1115        let text = doc.line_text(line);
1116        let start = doc.line_start(line).0;
1117        let lead = indent_len(&text);
1118        // A blank line is all lead; nothing is left to trim at its end.
1119        let keep_end = if trailing {
1120            text.trim_end_matches([' ', '\t']).len().max(lead)
1121        } else {
1122            text.len()
1123        };
1124        if lead > 0 {
1125            edits.push(Edit::new(ByteOffset(start), ByteOffset(start + lead), ""));
1126        }
1127        if keep_end < text.len() {
1128            edits.push(Edit::new(
1129                ByteOffset(start + keep_end),
1130                ByteOffset(start + text.len()),
1131                "",
1132            ));
1133        }
1134    }
1135    let changes = ChangeSet::new(doc.len(), edits);
1136    let after = map_before(sels, &changes);
1137    edit_result(changes, after, sels)
1138}
1139
1140/// Replaces each line break between the target lines with a space, joining
1141/// each run of them into one line (Notepad++'s EOL to Space). Unlike Join
1142/// Lines, the spaces and tabs around the breaks stay.
1143pub(crate) fn eol_to_space(doc: &Document, sels: &SelectionSet) -> Resolved {
1144    let lines = target_lines(doc, sels);
1145    let edits = lines
1146        .windows(2)
1147        .filter(|w| w[1] == w[0] + 1)
1148        .map(|w| Edit::new(doc.line_end(w[0]), doc.line_start(w[1]), " "))
1149        .collect();
1150    let changes = ChangeSet::new(doc.len(), edits);
1151    let after = map_before(sels, &changes);
1152    edit_result(changes, after, sels)
1153}
1154
1155/// Replaces every tab on the selected lines (every line when nothing is
1156/// selected) with the spaces that reach the same tab stop.
1157pub(crate) fn tabs_to_spaces(doc: &Document, sels: &SelectionSet, tab_width: usize) -> Resolved {
1158    let width = tab_width.max(1);
1159    let mut edits = Vec::new();
1160    for line in selected_lines(doc, sels) {
1161        let text = doc.line_text(line);
1162        if !text.contains('\t') {
1163            continue;
1164        }
1165        let start = doc.line_start(line).0;
1166        let mut col = 0;
1167        for (i, g) in text.grapheme_indices(true) {
1168            let w = grapheme_width(g, col, width);
1169            if g == "\t" {
1170                edits.push(Edit::new(
1171                    ByteOffset(start + i),
1172                    ByteOffset(start + i + 1),
1173                    " ".repeat(w),
1174                ));
1175            }
1176            col += w;
1177        }
1178    }
1179    let changes = ChangeSet::new(doc.len(), edits);
1180    let after = map_before(sels, &changes);
1181    edit_result(changes, after, sels)
1182}
1183
1184/// The tabs (and spaces) for `n` spaces starting at visual column `col`,
1185/// keeping every column: each stretch of two or more spaces that reaches a
1186/// tab stop becomes a tab. `None` when nothing would change.
1187fn spaces_as_tabs(col: usize, n: usize, width: usize) -> Option<String> {
1188    let end = col + n;
1189    let mut out = String::new();
1190    let mut c = col;
1191    let mut changed = false;
1192    while c < end {
1193        let stop = (c / width + 1) * width;
1194        if stop > end {
1195            out.push_str(&" ".repeat(end - c));
1196            break;
1197        }
1198        if stop - c >= 2 {
1199            out.push('\t');
1200            changed = true;
1201        } else {
1202            out.push(' ');
1203        }
1204        c = stop;
1205    }
1206    changed.then_some(out)
1207}
1208
1209/// Replaces spaces with tabs on the selected lines (every line when nothing
1210/// is selected) wherever that keeps every column: Notepad++'s Space to TAB
1211/// (All). Leading whitespace becomes tabs and then spaces narrower than a
1212/// tab, as Convert Indentation does; after it, each run of two or more
1213/// spaces reaching a tab stop becomes a tab.
1214pub(crate) fn spaces_to_tabs(doc: &Document, sels: &SelectionSet, tab_width: usize) -> Resolved {
1215    let width = tab_width.max(1);
1216    let mut edits = Vec::new();
1217    for line in selected_lines(doc, sels) {
1218        let text = doc.line_text(line);
1219        if !text.contains(' ') {
1220            continue;
1221        }
1222        let start = doc.line_start(line).0;
1223        let lead = indent_len(&text);
1224        let mut col = 0;
1225        for b in text[..lead].bytes() {
1226            col = if b == b'\t' {
1227                col + width - col % width
1228            } else {
1229                col + 1
1230            };
1231        }
1232        let indent = format!("{}{}", "\t".repeat(col / width), " ".repeat(col % width));
1233        if indent != text[..lead] {
1234            edits.push(Edit::new(
1235                ByteOffset(start),
1236                ByteOffset(start + lead),
1237                indent,
1238            ));
1239        }
1240        // Runs of spaces after the indentation: (byte, column) of the first.
1241        let mut run: Option<(usize, usize)> = None;
1242        let flush = |run: &mut Option<(usize, usize)>, col: usize, edits: &mut Vec<Edit>| {
1243            if let Some((byte, from)) = run.take()
1244                && let Some(tabs) = spaces_as_tabs(from, col - from, width)
1245            {
1246                edits.push(Edit::new(
1247                    ByteOffset(start + byte),
1248                    ByteOffset(start + byte + col - from),
1249                    tabs,
1250                ));
1251            }
1252        };
1253        for (i, g) in text[lead..].grapheme_indices(true) {
1254            if g == " " {
1255                run.get_or_insert((lead + i, col));
1256            } else {
1257                flush(&mut run, col, &mut edits);
1258            }
1259            col += grapheme_width(g, col, width);
1260        }
1261        flush(&mut run, col, &mut edits);
1262    }
1263    let changes = ChangeSet::new(doc.len(), edits);
1264    let after = map_before(sels, &changes);
1265    edit_result(changes, after, sels)
1266}
1267
1268#[cfg(test)]
1269mod tests {
1270    use super::*;
1271
1272    #[test]
1273    fn case_modes() {
1274        let s = "hello WORLD, don't straße";
1275        assert_eq!(
1276            convert_case(s, CaseMode::Upper),
1277            "HELLO WORLD, DON'T STRASSE"
1278        );
1279        assert_eq!(
1280            convert_case(s, CaseMode::Lower),
1281            "hello world, don't straße"
1282        );
1283        assert_eq!(
1284            convert_case(s, CaseMode::Title),
1285            "Hello World, Don't Straße"
1286        );
1287        assert_eq!(
1288            convert_case(s, CaseMode::Invert),
1289            "HELLO world, DON'T STRASSE"
1290        );
1291        assert_eq!(
1292            convert_case("hello WORLD, mcDonald", CaseMode::TitleBlend),
1293            "Hello WORLD, McDonald"
1294        );
1295    }
1296
1297    #[test]
1298    fn sentence_case_starts_after_an_end_mark_or_a_blank_line() {
1299        let s = "HELLO there. e.g. THIS one! and? yes\nno\n\nNEW para";
1300        // As in Notepad++, only the first dot of "e.g." is inside a
1301        // sentence.
1302        assert_eq!(
1303            convert_case(s, CaseMode::Sentence),
1304            "Hello there. E.g. This one! And? Yes\nno\n\nNew para"
1305        );
1306        assert_eq!(
1307            convert_case(s, CaseMode::SentenceBlend),
1308            "HELLO there. E.g. THIS one! And? Yes\nno\n\nNEW para"
1309        );
1310        assert_eq!(convert_case("", CaseMode::Sentence), "");
1311        assert_eq!(
1312            convert_case("3 apples. ok", CaseMode::Sentence),
1313            "3 Apples. Ok"
1314        );
1315        // A decimal point ends no sentence; a line of blanks is blank.
1316        assert_eq!(
1317            convert_case("it costs 2.50 DOLLARS", CaseMode::Sentence),
1318            "It costs 2.50 dollars"
1319        );
1320        assert_eq!(convert_case("a\n  \nb", CaseMode::Sentence), "A\n  \nB");
1321    }
1322
1323    #[test]
1324    fn random_case_changes_only_letters_and_follows_the_seed() {
1325        let s = "abcdefghijklmnop 123 straße!";
1326        let a = convert_case(s, CaseMode::Random { seed: 7 });
1327        assert_eq!(a, convert_case(s, CaseMode::Random { seed: 7 }));
1328        assert_ne!(a, convert_case(s, CaseMode::Random { seed: 8 }));
1329        assert_eq!(a.to_lowercase().replace("ss", "ß"), s);
1330        assert!(a.chars().any(char::is_uppercase) && a.chars().any(char::is_lowercase));
1331    }
1332
1333    #[test]
1334    fn spaces_become_tabs_only_where_columns_stay() {
1335        assert_eq!(spaces_as_tabs(1, 3, 4), Some("\t".to_owned()));
1336        assert_eq!(
1337            spaces_as_tabs(3, 1, 4),
1338            None,
1339            "one space before a stop stays"
1340        );
1341        assert_eq!(spaces_as_tabs(2, 8, 4), Some("\t\t  ".to_owned()));
1342        assert_eq!(spaces_as_tabs(0, 3, 4), None);
1343    }
1344
1345    #[test]
1346    fn numbers_at_line_starts() {
1347        assert_eq!(leading_number("  42 apples"), Some(42.0));
1348        assert_eq!(leading_number("-3.5x"), Some(-3.5));
1349        assert_eq!(leading_number("7."), Some(7.0));
1350        assert_eq!(leading_number("x1"), None);
1351        assert_eq!(leading_number("-"), None);
1352    }
1353}