Skip to main content

quarto_source_map/
utils.rs

1//! Utility functions for working with source positions
2
3use crate::types::{Location, Range};
4
5/// Convert a byte offset to a Location with line and column info
6///
7/// Returns None if the offset is out of bounds.
8pub fn offset_to_location(source: &str, offset: usize) -> Option<Location> {
9    if offset > source.len() {
10        return None;
11    }
12
13    let mut row = 0;
14    let mut column = 0;
15    let mut current_offset = 0;
16    // Floors to the start of the char containing `offset` when `offset`
17    // lands mid-character; equal to `offset` for boundary offsets. Kept in
18    // sync with FileInformation::offset_to_location's floor.
19    let mut safe_offset = offset;
20
21    for ch in source.chars() {
22        if current_offset >= offset {
23            break;
24        }
25
26        let char_len = ch.len_utf8();
27        if offset < current_offset + char_len {
28            // `offset` lands inside this character. Floor to its start and
29            // stop the column loop before counting it — otherwise the
30            // column would overcount by one relative to FileInformation,
31            // which floors before counting.
32            safe_offset = current_offset;
33            break;
34        }
35
36        if ch == '\n' {
37            row += 1;
38            column = 0;
39        } else {
40            column += 1;
41        }
42
43        current_offset += char_len;
44    }
45
46    Some(Location {
47        offset: safe_offset,
48        row,
49        column,
50    })
51}
52
53/// Convert line and column numbers to a byte offset
54///
55/// Line and column are 0-indexed. Returns None if out of bounds.
56pub fn line_col_to_offset(source: &str, line: usize, col: usize) -> Option<usize> {
57    let mut current_line = 0;
58    let mut current_col = 0;
59    let mut offset = 0;
60
61    for ch in source.chars() {
62        if current_line == line && current_col == col {
63            return Some(offset);
64        }
65
66        if ch == '\n' {
67            current_line += 1;
68            current_col = 0;
69        } else {
70            current_col += 1;
71        }
72
73        offset += ch.len_utf8();
74    }
75
76    // Check if we're at the end position
77    if current_line == line && current_col == col {
78        return Some(offset);
79    }
80
81    None
82}
83
84/// Create a Range from start and end byte offsets
85///
86/// This is a helper that creates a Range with Location structs
87/// that only have offsets filled in (row and column are 0).
88/// Use `offset_to_location` to get full Location info.
89pub fn range_from_offsets(start: usize, end: usize) -> Range {
90    Range {
91        start: Location {
92            offset: start,
93            row: 0,
94            column: 0,
95        },
96        end: Location {
97            offset: end,
98            row: 0,
99            column: 0,
100        },
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn test_offset_to_location_simple() {
110        let source = "hello\nworld";
111
112        // Beginning
113        let loc = offset_to_location(source, 0).unwrap();
114        assert_eq!(loc.offset, 0);
115        assert_eq!(loc.row, 0);
116        assert_eq!(loc.column, 0);
117
118        // Middle of first line
119        let loc = offset_to_location(source, 3).unwrap();
120        assert_eq!(loc.offset, 3);
121        assert_eq!(loc.row, 0);
122        assert_eq!(loc.column, 3);
123
124        // After newline (beginning of second line)
125        let loc = offset_to_location(source, 6).unwrap();
126        assert_eq!(loc.offset, 6);
127        assert_eq!(loc.row, 1);
128        assert_eq!(loc.column, 0);
129
130        // Middle of second line
131        let loc = offset_to_location(source, 9).unwrap();
132        assert_eq!(loc.offset, 9);
133        assert_eq!(loc.row, 1);
134        assert_eq!(loc.column, 3);
135    }
136
137    #[test]
138    fn test_offset_to_location_out_of_bounds() {
139        let source = "hello";
140        assert!(offset_to_location(source, 100).is_none());
141    }
142
143    #[test]
144    fn test_offset_to_location_end() {
145        let source = "hello";
146        let loc = offset_to_location(source, 5).unwrap();
147        assert_eq!(loc.offset, 5);
148        assert_eq!(loc.row, 0);
149        assert_eq!(loc.column, 5);
150    }
151
152    #[test]
153    fn test_line_col_to_offset_simple() {
154        let source = "hello\nworld";
155
156        // Beginning
157        let offset = line_col_to_offset(source, 0, 0).unwrap();
158        assert_eq!(offset, 0);
159
160        // Middle of first line
161        let offset = line_col_to_offset(source, 0, 3).unwrap();
162        assert_eq!(offset, 3);
163
164        // Beginning of second line
165        let offset = line_col_to_offset(source, 1, 0).unwrap();
166        assert_eq!(offset, 6);
167
168        // Middle of second line
169        let offset = line_col_to_offset(source, 1, 3).unwrap();
170        assert_eq!(offset, 9);
171    }
172
173    #[test]
174    fn test_line_col_to_offset_out_of_bounds() {
175        let source = "hello\nworld";
176        assert!(line_col_to_offset(source, 10, 0).is_none());
177        assert!(line_col_to_offset(source, 0, 100).is_none());
178    }
179
180    #[test]
181    fn test_line_col_to_offset_end() {
182        let source = "hello";
183        let offset = line_col_to_offset(source, 0, 5).unwrap();
184        assert_eq!(offset, 5);
185    }
186
187    #[test]
188    fn test_roundtrip() {
189        let source = "hello\nworld\ntest";
190
191        // Test various positions
192        for test_offset in [0, 3, 6, 10, 16] {
193            let loc = offset_to_location(source, test_offset).unwrap();
194            let back_to_offset = line_col_to_offset(source, loc.row, loc.column).unwrap();
195            assert_eq!(test_offset, back_to_offset);
196        }
197    }
198
199    #[test]
200    fn test_range_from_offsets() {
201        let range = range_from_offsets(10, 20);
202        assert_eq!(range.start.offset, 10);
203        assert_eq!(range.end.offset, 20);
204        assert_eq!(range.start.row, 0);
205        assert_eq!(range.start.column, 0);
206    }
207
208    #[test]
209    fn test_offset_to_location_multiline() {
210        let source = "line1\nline2\nline3";
211
212        // Test each line start
213        let loc = offset_to_location(source, 0).unwrap();
214        assert_eq!(loc.row, 0);
215        assert_eq!(loc.column, 0);
216
217        let loc = offset_to_location(source, 6).unwrap();
218        assert_eq!(loc.row, 1);
219        assert_eq!(loc.column, 0);
220
221        let loc = offset_to_location(source, 12).unwrap();
222        assert_eq!(loc.row, 2);
223        assert_eq!(loc.column, 0);
224    }
225
226    #[test]
227    fn test_offset_to_location_agrees_with_file_information_on_mid_char_offset() {
228        // Same fixture as FileInformation::offset_to_location's regression
229        // test: "x = 'A✨B'", where ✨ (U+2728) occupies bytes 6..9. A
230        // mid-character offset (7) must floor to the char boundary (6) —
231        // both the returned offset and the column — so this free function
232        // agrees with FileInformation's, instead of overcounting the column
233        // by one because it only breaks the loop once current_offset >=
234        // offset, by which point the containing character has already been
235        // counted.
236        let source = "x = 'A✨B'";
237        let loc = offset_to_location(source, 7).unwrap();
238        assert_eq!(loc.offset, 6);
239        assert_eq!(loc.row, 0);
240        assert_eq!(loc.column, 6);
241    }
242}