1use collections::HashMap;
2use std::{cmp::Ordering, ops::Range};
3
4/// Construct a string and a list of offsets within that string using a single
5/// string containing embedded position markers.
6pub fn marked_text_offsets_by(
7 marked_text: &str,
8 markers: Vec<char>,
9) -> (String, HashMap<char, Vec<usize>>) {
10 let mut extracted_markers: HashMap<char, Vec<usize>> = Default::default();
11 let mut unmarked_text = String::new();
12
13 for char in marked_text.chars() {
14 if markers.contains(&char) {
15 let char_offsets = extracted_markers.entry(char).or_default();
16 char_offsets.push(unmarked_text.len());
17 } else {
18 unmarked_text.push(char);
19 }
20 }
21
22 (unmarked_text, extracted_markers)
23}
24
25/// Construct a string and a list of ranges within that string using a single
26/// string containing embedded range markers, using arbitrary characters as
27/// range markers. By using multiple different range markers, you can construct
28/// ranges that overlap each other.
29///
30/// The returned ranges will be grouped by their range marking characters.
31pub fn marked_text_ranges_by(
32 marked_text: &str,
33 markers: Vec<TextRangeMarker>,
34) -> (String, HashMap<TextRangeMarker, Vec<Range<usize>>>) {
35 let all_markers = markers.iter().flat_map(|m| m.markers()).collect();
36
37 let (unmarked_text, mut marker_offsets) = marked_text_offsets_by(marked_text, all_markers);
38 let range_lookup = markers
39 .into_iter()
40 .map(|marker| {
41 (
42 marker.clone(),
43 match marker {
44 TextRangeMarker::Empty(empty_marker_char) => marker_offsets
45 .remove(&empty_marker_char)
46 .unwrap_or_default()
47 .into_iter()
48 .map(|empty_index| empty_index..empty_index)
49 .collect::<Vec<Range<usize>>>(),
50 TextRangeMarker::Range(start_marker, end_marker) => {
51 let starts = marker_offsets.remove(&start_marker).unwrap_or_default();
52 let ends = marker_offsets.remove(&end_marker).unwrap_or_default();
53 assert_eq!(starts.len(), ends.len(), "marked ranges are unbalanced");
54 starts
55 .into_iter()
56 .zip(ends)
57 .map(|(start, end)| {
58 assert!(end >= start, "marked ranges must be disjoint");
59 start..end
60 })
61 .collect::<Vec<Range<usize>>>()
62 }
63 TextRangeMarker::ReverseRange(start_marker, end_marker) => {
64 let starts = marker_offsets.remove(&start_marker).unwrap_or_default();
65 let ends = marker_offsets.remove(&end_marker).unwrap_or_default();
66 assert_eq!(starts.len(), ends.len(), "marked ranges are unbalanced");
67 starts
68 .into_iter()
69 .zip(ends)
70 .map(|(start, end)| {
71 assert!(end >= start, "marked ranges must be disjoint");
72 end..start
73 })
74 .collect::<Vec<Range<usize>>>()
75 }
76 },
77 )
78 })
79 .collect();
80
81 (unmarked_text, range_lookup)
82}
83
84/// Construct a string and a list of ranges within that string using a single
85/// string containing embedded range markers. The characters used to mark the
86/// ranges are as follows:
87///
88/// 1. To mark a range of text, surround it with the `«` and `»` angle brackets,
89/// which can be typed on a US keyboard with the `alt-|` and `alt-shift-|` keys.
90///
91/// ```text
92/// foo «selected text» bar
93/// ```
94///
95/// 2. To mark a single position in the text, use the `ˇ` caron,
96/// which can be typed on a US keyboard with the `alt-shift-t` key.
97///
98/// ```text
99/// the cursors are hereˇ and hereˇ.
100/// ```
101///
102/// 3. To mark a range whose direction is meaningful (like a selection),
103/// put a caron character beside one of its bounds, on the inside:
104///
105/// ```text
106/// one «ˇreversed» selection and one «forwardˇ» selection
107/// ```
108///
109/// Any • characters in the input string will be replaced with spaces. This makes
110/// it easier to test cases with trailing spaces, which tend to get trimmed from the
111/// source code.
112pub fn marked_text_ranges(
113 marked_text: &str,
114 ranges_are_directed: bool,
115) -> (String, Vec<Range<usize>>) {
116 let mut unmarked_text = String::with_capacity(marked_text.len());
117 let mut ranges = Vec::new();
118 let mut prev_marked_ix = 0;
119 let mut current_range_start = None;
120 let mut current_range_cursor = None;
121
122 let marked_text = marked_text.replace('•', " ");
123 for (marked_ix, marker) in marked_text.match_indices(&['«', '»', 'ˇ']) {
124 unmarked_text.push_str(&marked_text[prev_marked_ix..marked_ix]);
125 let unmarked_len = unmarked_text.len();
126 let len = marker.len();
127 prev_marked_ix = marked_ix + len;
128
129 match marker {
130 "ˇ" => {
131 if current_range_start.is_some() {
132 if current_range_cursor.is_some() {
133 panic!("duplicate point marker 'ˇ' at index {marked_ix}");
134 }
135
136 current_range_cursor = Some(unmarked_len);
137 } else {
138 ranges.push(unmarked_len..unmarked_len);
139 }
140 }
141 "«" => {
142 if current_range_start.is_some() {
143 panic!("unexpected range start marker '«' at index {marked_ix}");
144 }
145 current_range_start = Some(unmarked_len);
146 }
147 "»" => {
148 let current_range_start = if let Some(start) = current_range_start.take() {
149 start
150 } else {
151 panic!("unexpected range end marker '»' at index {marked_ix}");
152 };
153
154 let mut reversed = false;
155 if let Some(current_range_cursor) = current_range_cursor.take() {
156 if current_range_cursor == current_range_start {
157 reversed = true;
158 } else if current_range_cursor != unmarked_len {
159 panic!("unexpected 'ˇ' marker in the middle of a range");
160 }
161 } else if ranges_are_directed {
162 panic!("missing 'ˇ' marker to indicate range direction");
163 }
164
165 ranges.push(if reversed {
166 unmarked_len..current_range_start
167 } else {
168 current_range_start..unmarked_len
169 });
170 }
171 _ => unreachable!(),
172 }
173 }
174
175 unmarked_text.push_str(&marked_text[prev_marked_ix..]);
176 (unmarked_text, ranges)
177}
178
179pub fn marked_text_offsets(marked_text: &str) -> (String, Vec<usize>) {
180 let (text, ranges) = marked_text_ranges(marked_text, false);
181 (
182 text,
183 ranges
184 .into_iter()
185 .map(|range| {
186 assert_eq!(range.start, range.end);
187 range.start
188 })
189 .collect(),
190 )
191}
192
193pub fn generate_marked_text(
194 unmarked_text: &str,
195 ranges: &[Range<usize>],
196 indicate_cursors: bool,
197) -> String {
198 let mut marked_text = unmarked_text.to_string();
199 for range in ranges.iter().rev() {
200 if indicate_cursors {
201 match range.start.cmp(&range.end) {
202 Ordering::Less => {
203 marked_text.insert_str(range.end, "ˇ»");
204 marked_text.insert(range.start, '«');
205 }
206 Ordering::Equal => {
207 marked_text.insert(range.start, 'ˇ');
208 }
209 Ordering::Greater => {
210 marked_text.insert(range.start, '»');
211 marked_text.insert_str(range.end, "«ˇ");
212 }
213 }
214 } else {
215 marked_text.insert(range.end, '»');
216 marked_text.insert(range.start, '«');
217 }
218 }
219 marked_text
220}
221
222#[derive(Clone, Eq, PartialEq, Hash)]
223pub enum TextRangeMarker {
224 Empty(char),
225 Range(char, char),
226 ReverseRange(char, char),
227}
228
229impl TextRangeMarker {
230 fn markers(&self) -> Vec<char> {
231 match self {
232 Self::Empty(m) => vec![*m],
233 Self::Range(l, r) => vec![*l, *r],
234 Self::ReverseRange(l, r) => vec![*l, *r],
235 }
236 }
237}
238
239impl From<char> for TextRangeMarker {
240 fn from(marker: char) -> Self {
241 Self::Empty(marker)
242 }
243}
244
245impl From<(char, char)> for TextRangeMarker {
246 fn from((left_marker, right_marker): (char, char)) -> Self {
247 Self::Range(left_marker, right_marker)
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::{generate_marked_text, marked_text_ranges};
254
255 #[allow(clippy::reversed_empty_ranges)]
256 #[test]
257 fn test_marked_text() {
258 let (text, ranges) = marked_text_ranges("one «ˇtwo» «threeˇ» «ˇfour» fiveˇ six", true);
259
260 assert_eq!(text, "one two three four five six");
261 assert_eq!(ranges.len(), 4);
262 assert_eq!(ranges[0], 7..4);
263 assert_eq!(ranges[1], 8..13);
264 assert_eq!(ranges[2], 18..14);
265 assert_eq!(ranges[3], 23..23);
266
267 assert_eq!(
268 generate_marked_text(&text, &ranges, true),
269 "one «ˇtwo» «threeˇ» «ˇfour» fiveˇ six"
270 );
271 }
272}