1#[derive(Debug, PartialEq, Eq)]
14pub enum PositionError {
15 LineOutOfRange { line: u32, lines: usize },
17 ColumnOutOfRange {
19 line: u32,
20 column: u32,
21 bytes: usize,
22 },
23 NotACharBoundary { line: u32, column: u32 },
25}
26
27impl std::fmt::Display for PositionError {
28 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 match self {
30 Self::LineOutOfRange { line, lines } => write!(
31 formatter,
32 "the graph points at line {line} but the file has {lines}; rebuild the graph"
33 ),
34 Self::ColumnOutOfRange {
35 line,
36 column,
37 bytes,
38 } => write!(
39 formatter,
40 "the graph points at byte column {column} on line {line}, which is {bytes} bytes \
41 long; rebuild the graph"
42 ),
43 Self::NotACharBoundary { line, column } => write!(
44 formatter,
45 "byte column {column} on line {line} falls inside a character; the graph and the \
46 file disagree"
47 ),
48 }
49 }
50}
51
52pub fn utf16_offset(text: &str, line: u32, byte_column: u32) -> Result<u32, PositionError> {
58 let lines = text.split('\n').collect::<Vec<_>>();
59 let index = usize::try_from(line.saturating_sub(1)).unwrap_or(usize::MAX);
60 let Some(source) = lines.get(index) else {
61 return Err(PositionError::LineOutOfRange {
62 line,
63 lines: lines.len(),
64 });
65 };
66 let source = source.strip_suffix('\r').unwrap_or(source);
68 let offset = usize::try_from(byte_column.saturating_sub(1)).unwrap_or(usize::MAX);
69 if offset > source.len() {
70 return Err(PositionError::ColumnOutOfRange {
71 line,
72 column: byte_column,
73 bytes: source.len(),
74 });
75 }
76 if !source.is_char_boundary(offset) {
77 return Err(PositionError::NotACharBoundary {
78 line,
79 column: byte_column,
80 });
81 }
82 let units = source[..offset]
83 .chars()
84 .map(|character| u32::try_from(character.len_utf16()).unwrap_or(1))
85 .sum();
86 Ok(units)
87}
88
89pub fn utf16_line_length(text: &str, line: u32) -> Result<u32, PositionError> {
95 let lines = text.split('\n').collect::<Vec<_>>();
96 let index = usize::try_from(line.saturating_sub(1)).unwrap_or(usize::MAX);
97 let Some(source) = lines.get(index) else {
98 return Err(PositionError::LineOutOfRange {
99 line,
100 lines: lines.len(),
101 });
102 };
103 let source = source.strip_suffix('\r').unwrap_or(source);
104 Ok(source
105 .chars()
106 .map(|character| u32::try_from(character.len_utf16()).unwrap_or(1))
107 .sum())
108}
109
110#[must_use]
112pub fn slice_between(text: &str, start: (u32, u32), end: (u32, u32)) -> Option<String> {
113 let start_offset = byte_offset(text, start.0, start.1)?;
114 let end_offset = byte_offset(text, end.0, end.1)?;
115 if start_offset > end_offset {
116 return None;
117 }
118 text.get(start_offset..end_offset).map(ToOwned::to_owned)
119}
120
121fn byte_offset(text: &str, line: u32, byte_column: u32) -> Option<usize> {
123 let mut consumed = 0_usize;
124 for (number, source) in text.split('\n').enumerate() {
125 let current = u32::try_from(number + 1).unwrap_or(u32::MAX);
126 if current == line {
127 let offset = usize::try_from(byte_column.saturating_sub(1)).ok()?;
128 let trimmed = source.strip_suffix('\r').unwrap_or(source);
129 if offset > trimmed.len() || !trimmed.is_char_boundary(offset) {
130 return None;
131 }
132 return Some(consumed + offset);
133 }
134 consumed += source.len() + 1;
135 }
136 None
137}
138
139#[cfg(test)]
140mod tests {
141 use super::{PositionError, slice_between, utf16_line_length, utf16_offset};
142
143 #[test]
144 fn ascii_columns_convert_to_the_offset_one_less() {
145 let text = "export function resolveTarget(input) {\n";
146 assert_eq!(utf16_offset(text, 1, 17), Ok(16));
148 assert_eq!(utf16_offset(text, 1, 1), Ok(0));
149 }
150
151 #[test]
152 fn a_multi_byte_prefix_shortens_the_utf16_offset() {
153 let text = "héllo world\n";
155 assert_eq!(utf16_offset(text, 1, 8), Ok(6));
156 }
157
158 #[test]
159 fn a_surrogate_pair_counts_as_two_utf16_units() {
160 let text = "let x = \"🎯\" // done\n";
163 let before_emoji = utf16_offset(text, 1, 10).expect("byte column before the emoji");
164 let after_emoji = utf16_offset(text, 1, 14).expect("byte column after the emoji");
165 assert_eq!(before_emoji, 9);
166 assert_eq!(after_emoji - before_emoji, 2);
167 }
168
169 #[test]
170 fn a_column_inside_a_character_is_refused_not_rounded() {
171 let text = "héllo\n";
172 assert_eq!(
173 utf16_offset(text, 1, 3),
174 Err(PositionError::NotACharBoundary { line: 1, column: 3 })
175 );
176 }
177
178 #[test]
179 fn a_column_past_the_line_is_refused() {
180 let text = "one\ntwo\n";
181 assert!(matches!(
182 utf16_offset(text, 1, 99),
183 Err(PositionError::ColumnOutOfRange { .. })
184 ));
185 }
186
187 #[test]
188 fn a_line_past_the_file_is_refused() {
189 let text = "one\n";
190 assert!(matches!(
191 utf16_offset(text, 9, 1),
192 Err(PositionError::LineOutOfRange { .. })
193 ));
194 }
195
196 #[test]
197 fn carriage_returns_belong_to_the_separator_not_the_line() {
198 let text = "one\r\ntwo\r\n";
199 assert_eq!(utf16_line_length(text, 1), Ok(3));
200 assert_eq!(utf16_offset(text, 1, 4), Ok(3));
201 }
202
203 #[test]
204 fn slicing_returns_the_exact_source_between_two_positions() {
205 let text = "pub fn one() -> u32 {\n 1\n}\n";
206 assert_eq!(slice_between(text, (1, 8), (1, 11)), Some("one".to_owned()));
207 assert_eq!(
208 slice_between(text, (1, 1), (3, 2)),
209 Some("pub fn one() -> u32 {\n 1\n}".to_owned())
210 );
211 }
212
213 #[test]
214 fn slicing_a_multi_byte_line_returns_characters_not_bytes() {
215 let text = "let café = 1\n";
216 assert_eq!(
217 slice_between(text, (1, 5), (1, 10)),
218 Some("café".to_owned())
219 );
220 }
221
222 #[test]
223 fn an_inverted_range_slices_to_nothing() {
224 let text = "one two\n";
225 assert_eq!(slice_between(text, (1, 5), (1, 2)), None);
226 }
227}