Skip to main content

quarto_source_map/
mapping.rs

1//! Position mapping through transformation chains
2
3use crate::types::{FileId, Location};
4use crate::{SourceContext, SourceInfo};
5
6/// Result of mapping a position back to an original file
7#[derive(Debug, Clone, PartialEq)]
8pub struct MappedLocation {
9    /// The original file
10    pub file_id: FileId,
11    /// Location in the original file
12    pub location: Location,
13}
14
15impl SourceInfo {
16    /// Map an offset in the current text back to original source
17    pub fn map_offset(&self, offset: usize, ctx: &SourceContext) -> Option<MappedLocation> {
18        match self {
19            SourceInfo::Original {
20                file_id,
21                start_offset,
22                ..
23            } => {
24                // Direct mapping to original file
25                let file = ctx.get_file(*file_id)?;
26                let file_info = file.file_info.as_ref()?;
27
28                // Compute the absolute offset in the file
29                let absolute_offset = start_offset + offset;
30
31                // Get file content: use stored content for ephemeral files, or read from disk
32                let content = match &file.content {
33                    Some(c) => c.clone(),
34                    None => std::fs::read_to_string(&file.path).ok()?,
35                };
36
37                // Convert offset to Location with row/column using efficient binary search
38                let location = file_info.offset_to_location(absolute_offset, &content)?;
39
40                Some(MappedLocation {
41                    file_id: *file_id,
42                    location,
43                })
44            }
45            SourceInfo::Substring {
46                parent,
47                start_offset,
48                ..
49            } => {
50                // Map to parent coordinates and recurse
51                let parent_offset = start_offset + offset;
52                parent.map_offset(parent_offset, ctx)
53            }
54            SourceInfo::Concat { pieces } => {
55                // Find which piece contains this offset
56                for piece in pieces {
57                    let piece_start = piece.offset_in_concat;
58                    let piece_end = piece_start + piece.length;
59
60                    if offset >= piece_start && offset < piece_end {
61                        // Offset is within this piece
62                        let offset_in_piece = offset - piece_start;
63                        return piece.source_info.map_offset(offset_in_piece, ctx);
64                    }
65                }
66                // Exclusive end: `offset == total` matches no piece above; map it to
67                // the end of the last piece (like Original/Substring's map_offset(length)).
68                if let Some(last) = pieces.last()
69                    && offset == last.offset_in_concat + last.length
70                {
71                    return last.source_info.map_offset(last.length, ctx);
72                }
73                None // Offset not found in any piece
74            }
75            SourceInfo::Generated { .. } => {
76                // Generated nodes have no offset-within-current-text;
77                // callers wanting source coordinates use resolve_byte_range.
78                None
79            }
80        }
81    }
82
83    /// Map a range in the current text back to original source
84    pub fn map_range(
85        &self,
86        start: usize,
87        end: usize,
88        ctx: &SourceContext,
89    ) -> Option<(MappedLocation, MappedLocation)> {
90        let start_mapped = self.map_offset(start, ctx)?;
91        let end_mapped = self.map_offset(end, ctx)?;
92        Some((start_mapped, end_mapped))
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use crate::types::{Location, Range};
99    use crate::{SourceContext, SourceInfo};
100
101    #[test]
102    fn test_map_offset_original() {
103        let mut ctx = SourceContext::new();
104        let file_id = ctx.add_file("test.qmd".to_string(), Some("hello\nworld".to_string()));
105
106        let info = SourceInfo::from_range(
107            file_id,
108            Range {
109                start: Location {
110                    offset: 0,
111                    row: 0,
112                    column: 0,
113                },
114                end: Location {
115                    offset: 11,
116                    row: 1,
117                    column: 5,
118                },
119            },
120        );
121
122        // Test mapping offset 0 (start of first line)
123        let mapped = info.map_offset(0, &ctx).unwrap();
124        assert_eq!(mapped.file_id, file_id);
125        assert_eq!(mapped.location.offset, 0);
126        assert_eq!(mapped.location.row, 0);
127        assert_eq!(mapped.location.column, 0);
128
129        // Test mapping offset 6 (start of second line)
130        let mapped = info.map_offset(6, &ctx).unwrap();
131        assert_eq!(mapped.file_id, file_id);
132        assert_eq!(mapped.location.offset, 6);
133        assert_eq!(mapped.location.row, 1);
134        assert_eq!(mapped.location.column, 0);
135    }
136
137    #[test]
138    fn test_map_offset_substring() {
139        let mut ctx = SourceContext::new();
140        let file_id = ctx.add_file("test.qmd".to_string(), Some("0123456789".to_string()));
141
142        let original = SourceInfo::from_range(
143            file_id,
144            Range {
145                start: Location {
146                    offset: 0,
147                    row: 0,
148                    column: 0,
149                },
150                end: Location {
151                    offset: 10,
152                    row: 0,
153                    column: 10,
154                },
155            },
156        );
157
158        // Extract substring from offset 3 to 7 ("3456")
159        let substring = SourceInfo::substring(original, 3, 7);
160
161        // Map offset 0 in substring (should be '3' at offset 3 in original)
162        let mapped = substring.map_offset(0, &ctx).unwrap();
163        assert_eq!(mapped.file_id, file_id);
164        assert_eq!(mapped.location.offset, 3);
165
166        // Map offset 2 in substring (should be '5' at offset 5 in original)
167        let mapped = substring.map_offset(2, &ctx).unwrap();
168        assert_eq!(mapped.file_id, file_id);
169        assert_eq!(mapped.location.offset, 5);
170    }
171
172    #[test]
173    fn test_map_offset_concat() {
174        let mut ctx = SourceContext::new();
175        let file_id1 = ctx.add_file("first.qmd".to_string(), Some("AAA".to_string()));
176        let file_id2 = ctx.add_file("second.qmd".to_string(), Some("BBB".to_string()));
177
178        let info1 = SourceInfo::from_range(
179            file_id1,
180            Range {
181                start: Location {
182                    offset: 0,
183                    row: 0,
184                    column: 0,
185                },
186                end: Location {
187                    offset: 3,
188                    row: 0,
189                    column: 3,
190                },
191            },
192        );
193
194        let info2 = SourceInfo::from_range(
195            file_id2,
196            Range {
197                start: Location {
198                    offset: 0,
199                    row: 0,
200                    column: 0,
201                },
202                end: Location {
203                    offset: 3,
204                    row: 0,
205                    column: 3,
206                },
207            },
208        );
209
210        // Concatenate: "AAABBB"
211        let concat = SourceInfo::concat(vec![(info1, 3), (info2, 3)]);
212
213        // Map offset 1 (should be in first piece, second 'A')
214        let mapped = concat.map_offset(1, &ctx).unwrap();
215        assert_eq!(mapped.file_id, file_id1);
216        assert_eq!(mapped.location.offset, 1);
217
218        // Map offset 4 (should be in second piece, second 'B')
219        let mapped = concat.map_offset(4, &ctx).unwrap();
220        assert_eq!(mapped.file_id, file_id2);
221        assert_eq!(mapped.location.offset, 1);
222
223        // Exclusive end (offset 6 == total): maps to end of last piece
224        let mapped = concat.map_offset(6, &ctx).unwrap();
225        assert_eq!(mapped.file_id, file_id2);
226        assert_eq!(mapped.location.offset, 3);
227
228        // map_range over the whole concat: exclusive end must resolve
229        let (start, end) = concat.map_range(0, 6, &ctx).unwrap();
230        assert_eq!(start.file_id, file_id1);
231        assert_eq!(start.location.offset, 0);
232        assert_eq!(end.file_id, file_id2);
233        assert_eq!(end.location.offset, 3);
234    }
235
236    #[test]
237    fn test_map_range() {
238        let mut ctx = SourceContext::new();
239        let file_id = ctx.add_file("test.qmd".to_string(), Some("hello\nworld".to_string()));
240
241        let info = SourceInfo::from_range(
242            file_id,
243            Range {
244                start: Location {
245                    offset: 0,
246                    row: 0,
247                    column: 0,
248                },
249                end: Location {
250                    offset: 11,
251                    row: 1,
252                    column: 5,
253                },
254            },
255        );
256
257        // Map range [0, 5) which is "hello"
258        let (start, end) = info.map_range(0, 5, &ctx).unwrap();
259        assert_eq!(start.file_id, file_id);
260        assert_eq!(start.location.offset, 0);
261        assert_eq!(end.file_id, file_id);
262        assert_eq!(end.location.offset, 5);
263    }
264}