pfx_editor_doc/
comment.rs1use 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}