Skip to main content

weavatrix_rust_refactor/
coordinates.rs

1//! Graph positions to edit-plan positions.
2//!
3//! These are two different coordinate systems and nothing warns you when they are confused:
4//! the graph records a **1-based byte column**, the edit plan carries a **0-based UTF-16
5//! character offset**. They agree on every ASCII line, which is exactly why a mix-up survives
6//! testing and then corrupts the first file with an accented name or an emoji in a comment.
7//!
8//! Every conversion here refuses rather than clamps. A column past the end of its line means the
9//! graph no longer describes the file, and silently landing an edit at the nearest valid offset
10//! would write bytes nobody planned.
11
12/// A position that could not be converted, with the reason an agent can act on.
13#[derive(Debug, PartialEq, Eq)]
14pub enum PositionError {
15    /// The file has no such line.
16    LineOutOfRange { line: u32, lines: usize },
17    /// The line is shorter than the column claims.
18    ColumnOutOfRange {
19        line: u32,
20        column: u32,
21        bytes: usize,
22    },
23    /// The byte column falls inside a multi-byte character.
24    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
52/// The 0-based UTF-16 offset of a 1-based byte column on a 1-based line.
53///
54/// # Errors
55///
56/// Returns the position error describing how the graph and the file disagree.
57pub 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    // A trailing \r belongs to the line separator, not to the text a column can point into.
67    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
89/// The UTF-16 length of a whole line, for an edit that ends where the line does.
90///
91/// # Errors
92///
93/// Returns `LineOutOfRange` when the file has no such line.
94pub 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/// The exact text between two graph positions, or `None` when either does not convert.
111#[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
121/// Absolute byte offset of a 1-based line and 1-based byte column.
122fn 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        // "export function " is 16 bytes, so the identifier starts at byte column 17.
147        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        // "héllo " is 7 bytes but 6 UTF-16 units: the accented character is two bytes, one unit.
154        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 x = "` is 9 bytes, so the emoji occupies bytes 10..13 and the quote after it
161        // starts at byte column 14. Four bytes in, two UTF-16 units out.
162        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}