Skip to main content

pfx_editor_doc/
comment.rs

1use std::ops::Range;
2use std::path::PathBuf;
3
4use crate::splice::TextSplice;
5
6pub const MARK: &str = "#";
7pub const PREFIX: &str = "# ";
8pub const COMMENT: &str = "comment";
9pub const UNCOMMENT: &str = "uncomment";
10
11struct Line {
12    old: usize,
13    new: usize,
14    at: usize,
15    added: usize,
16    removed: usize,
17}
18
19fn line_start(text: &str, byte: usize) -> usize {
20    text[..byte].rfind('\n').map_or(0, |at| at + 1)
21}
22
23fn line_end(text: &str, byte: usize) -> usize {
24    text[byte..].find('\n').map_or(text.len(), |at| byte + at)
25}
26
27fn indent(line: &str) -> usize {
28    line.len() - line.trim_start_matches([' ', '\t']).len()
29}
30
31fn blank(line: &str) -> bool {
32    line.trim().is_empty()
33}
34
35fn floor(text: &str, byte: usize) -> usize {
36    let mut byte = byte.min(text.len());
37    while !text.is_char_boundary(byte) {
38        byte -= 1;
39    }
40    byte
41}
42
43pub fn toggle_comment(
44    file: impl Into<PathBuf>,
45    text: &str,
46    selection: Range<usize>,
47) -> Option<TextSplice> {
48    let low = floor(text, selection.start.min(selection.end));
49    let high = floor(text, selection.start.max(selection.end));
50    let start = line_start(text, low);
51    let mut last = high;
52    if high > low && line_start(text, high) == high {
53        last = high - 1;
54    }
55    let end = line_end(text, last.max(start));
56    let region = &text[start..end];
57    let lines: Vec<&str> = region.split('\n').collect();
58    let filled: Vec<&&str> = lines.iter().filter(|line| !blank(line)).collect();
59    if filled.is_empty() {
60        return None;
61    }
62    let remove = filled
63        .iter()
64        .all(|line| line[indent(line)..].starts_with(MARK));
65    let column = filled.iter().map(|line| indent(line)).min().unwrap_or(0);
66    let mut new = String::with_capacity(region.len() + lines.len() * PREFIX.len());
67    let mut map = Vec::with_capacity(lines.len());
68    let mut old_at = start;
69    for (n, line) in lines.iter().enumerate() {
70        if n > 0 {
71            new.push('\n');
72        }
73        let new_at = start + new.len();
74        let mut entry = Line {
75            old: old_at,
76            new: new_at,
77            at: 0,
78            added: 0,
79            removed: 0,
80        };
81        if blank(line) {
82            new.push_str(line);
83        } else if remove {
84            let at = indent(line);
85            let cut = if line[at..].starts_with(PREFIX) {
86                PREFIX.len()
87            } else {
88                MARK.len()
89            };
90            new.push_str(&line[..at]);
91            new.push_str(&line[at + cut..]);
92            entry.at = at;
93            entry.removed = cut;
94        } else {
95            new.push_str(&line[..column]);
96            new.push_str(PREFIX);
97            new.push_str(&line[column..]);
98            entry.at = column;
99            entry.added = PREFIX.len();
100        }
101        map.push(entry);
102        old_at += line.len() + 1;
103    }
104    let shift = new.len() as isize - region.len() as isize;
105    let moved = |byte: usize, left: bool| -> usize {
106        if byte < start {
107            return byte;
108        }
109        if byte > end {
110            return (byte as isize + shift) as usize;
111        }
112        let line = map
113            .iter()
114            .rev()
115            .find(|line| line.old <= byte)
116            .expect("the region starts a line");
117        let column = byte - line.old;
118        let column = if line.added > 0 && (column > line.at || (column == line.at && !left)) {
119            column + line.added
120        } else if line.removed > 0 && column >= line.at + line.removed {
121            column - line.removed
122        } else if line.removed > 0 && column > line.at {
123            line.at
124        } else {
125            column
126        };
127        line.new + column
128    };
129    let wide = selection.start != selection.end;
130    let caret = moved(selection.start, wide && selection.start < selection.end)
131        ..moved(selection.end, wide && selection.end < selection.start);
132    Some(
133        TextSplice::new(file, start..end, region, new)
134            .labelled(if remove { UNCOMMENT } else { COMMENT })
135            .with_carets(selection, caret),
136    )
137}
138
139#[cfg(test)]
140mod tests {
141    use std::path::Path;
142
143    use super::*;
144    use crate::edit::Edit;
145    use crate::files::Files;
146
147    fn toggled(text: &str, selection: Range<usize>) -> (String, Range<usize>, String) {
148        let splice = toggle_comment("a.toml", text, selection).expect("a splice");
149        let mut files = Files::new();
150        files.open("a.toml", text);
151        let mut edit = splice.clone();
152        edit.apply(&mut files).unwrap();
153        (
154            files.text(Path::new("a.toml")).unwrap().to_string(),
155            splice.caret_after.clone(),
156            splice.label.clone(),
157        )
158    }
159
160    #[test]
161    fn mixed_indented_and_blank_lines_comment_at_the_least_indent() {
162        let text = "[a]\n  x = 1\n\n    y = 2\n  # z = 3\nw = 4\n";
163        let (out, _, label) = toggled(text, 4..25);
164        assert_eq!(out, "[a]\n  # x = 1\n\n  #   y = 2\n  # # z = 3\nw = 4\n");
165        assert_eq!(label, COMMENT);
166    }
167
168    #[test]
169    fn lines_all_commented_are_uncommented_keeping_their_indent() {
170        let text = "  # x = 1\n\n    #y = 2\n  #   z = 3\n";
171        let (out, _, label) = toggled(text, 0..text.len());
172        assert_eq!(out, "  x = 1\n\n    y = 2\n    z = 3\n");
173        assert_eq!(label, UNCOMMENT);
174    }
175
176    #[test]
177    fn toggling_twice_restores_the_text() {
178        let text = "a = 1\n\tb = 2\n\n  c = \"รค\"\n";
179        let (once, caret, _) = toggled(text, 0..text.len() - 1);
180        let (twice, _, _) = toggled(&once, caret);
181        assert_eq!(twice, text);
182    }
183
184    #[test]
185    fn a_caret_toggles_its_own_line_and_moves_with_the_text() {
186        let text = "a = 1\n  b = 2\nc = 3\n";
187        let (out, caret, _) = toggled(text, 9..9);
188        assert_eq!(out, "a = 1\n  # b = 2\nc = 3\n");
189        assert_eq!(caret, 11..11);
190        let (back, caret, _) = toggled(&out, 11..11);
191        assert_eq!(back, text);
192        assert_eq!(caret, 9..9);
193    }
194
195    #[test]
196    fn a_selection_ending_at_a_line_start_leaves_that_line() {
197        let text = "a = 1\nb = 2\n";
198        let (out, caret, _) = toggled(text, 0..6);
199        assert_eq!(out, "# a = 1\nb = 2\n");
200        assert_eq!(caret, 0..8);
201    }
202
203    #[test]
204    fn blank_lines_alone_toggle_nothing() {
205        assert!(toggle_comment("a.toml", "\n  \n\n", 0..4).is_none());
206    }
207}