marked_text.rs

  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}