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.source_info.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    // -------------------------------------------------------------------------
237    // Concat's exclusive-end branch: last piece's *source* length, not its
238    // *content* length. Three measured terminal shapes (see
239    // extract-design-concat-preimage.md, "SourceInfo::Concat is already the
240    // right shape").
241    // -------------------------------------------------------------------------
242
243    #[test]
244    fn test_map_offset_concat_exclusive_end_all_verbatim_is_gating() {
245        // GATING: this assertion is unchanged by the fix (Some(9) both
246        // before and after) — for a verbatim last piece, content length
247        // equals source length, so `last.length` and
248        // `last.source_info.length()` agree. Keep for shape; do not cite
249        // as coverage for the mapping.rs:64-70 fix.
250        let mut ctx = SourceContext::new();
251        let file_id = ctx.add_file("test.qmd".to_string(), Some("hello world".to_string()));
252
253        let first = SourceInfo::from_range(
254            file_id,
255            Range {
256                start: Location {
257                    offset: 0,
258                    row: 0,
259                    column: 0,
260                },
261                end: Location {
262                    offset: 4,
263                    row: 0,
264                    column: 4,
265                },
266            },
267        );
268        let last = SourceInfo::from_range(
269            file_id,
270            Range {
271                start: Location {
272                    offset: 4,
273                    row: 0,
274                    column: 4,
275                },
276                end: Location {
277                    offset: 9,
278                    row: 0,
279                    column: 9,
280                },
281            },
282        );
283        let concat = SourceInfo::concat(vec![(first, 4), (last, 5)]);
284
285        // offset 9 == total content length -> exclusive-end branch
286        let mapped = concat.map_offset(9, &ctx).unwrap();
287        assert_eq!(mapped.location.offset, 9);
288    }
289
290    #[test]
291    fn test_map_offset_concat_exclusive_end_replacement_terminated() {
292        // A last piece whose content is a decoded replacement (`''` -> `'`):
293        // source span 7..9 (2 bytes) collapses to 1 content byte. Before the
294        // fix, `last.length` (content length 1) reaches only source offset
295        // 8; after the fix, `last.source_info.length()` (source length 2)
296        // reaches the true source end, 9.
297        let mut ctx = SourceContext::new();
298        let file_id = ctx.add_file("test.qmd".to_string(), Some("012345678".to_string()));
299
300        let first = SourceInfo::from_range(
301            file_id,
302            Range {
303                start: Location {
304                    offset: 0,
305                    row: 0,
306                    column: 0,
307                },
308                end: Location {
309                    offset: 7,
310                    row: 0,
311                    column: 7,
312                },
313            },
314        );
315        let replacement = SourceInfo::from_range(
316            file_id,
317            Range {
318                start: Location {
319                    offset: 7,
320                    row: 0,
321                    column: 7,
322                },
323                end: Location {
324                    offset: 9,
325                    row: 0,
326                    column: 9,
327                },
328            },
329        );
330        // first piece: 7 content bytes over 7 source bytes (verbatim);
331        // replacement piece: 1 content byte over 2 source bytes.
332        let concat = SourceInfo::concat(vec![(first, 7), (replacement, 1)]);
333
334        // offset 8 == total content length (7 + 1) -> exclusive-end branch
335        let mapped = concat.map_offset(8, &ctx).unwrap();
336        assert_eq!(mapped.location.offset, 9);
337    }
338
339    #[test]
340    fn test_map_offset_concat_exclusive_end_synthesis_terminated() {
341        // A last piece synthesized at EOF: `Original{eof, eof}` (zero-width
342        // source span) with a 1-byte content length. Before the fix,
343        // `last.length` (1) pushes the absolute offset to eof + 1, which
344        // exceeds the file's total length and returns None — a
345        // clip-chomped block scalar at EOF loses its caret's right edge
346        // entirely. After the fix, `last.source_info.length()` (0) maps to
347        // exactly eof, which is in bounds.
348        let mut ctx = SourceContext::new();
349        let file_id = ctx.add_file("test.qmd".to_string(), Some("hello world".to_string())); // 11 bytes
350
351        let synthesis = SourceInfo::from_range(
352            file_id,
353            Range {
354                start: Location {
355                    offset: 11,
356                    row: 0,
357                    column: 11,
358                },
359                end: Location {
360                    offset: 11,
361                    row: 0,
362                    column: 11,
363                },
364            },
365        );
366        let concat = SourceInfo::concat(vec![(synthesis, 1)]);
367
368        // offset 1 == total content length -> exclusive-end branch
369        let mapped = concat.map_offset(1, &ctx).unwrap();
370        assert_eq!(mapped.location.offset, 11);
371    }
372
373    #[test]
374    fn test_map_range() {
375        let mut ctx = SourceContext::new();
376        let file_id = ctx.add_file("test.qmd".to_string(), Some("hello\nworld".to_string()));
377
378        let info = SourceInfo::from_range(
379            file_id,
380            Range {
381                start: Location {
382                    offset: 0,
383                    row: 0,
384                    column: 0,
385                },
386                end: Location {
387                    offset: 11,
388                    row: 1,
389                    column: 5,
390                },
391            },
392        );
393
394        // Map range [0, 5) which is "hello"
395        let (start, end) = info.map_range(0, 5, &ctx).unwrap();
396        assert_eq!(start.file_id, file_id);
397        assert_eq!(start.location.offset, 0);
398        assert_eq!(end.file_id, file_id);
399        assert_eq!(end.location.offset, 5);
400    }
401}