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};
5use std::borrow::Cow;
6
7/// Result of mapping a position back to an original file
8#[derive(Debug, Clone, PartialEq)]
9pub struct MappedLocation {
10    /// The original file
11    pub file_id: FileId,
12    /// Location in the original file
13    pub location: Location,
14}
15
16impl SourceInfo {
17    /// Map an offset in the current text back to original source
18    pub fn map_offset(&self, offset: usize, ctx: &SourceContext) -> Option<MappedLocation> {
19        match self {
20            SourceInfo::Original {
21                file_id,
22                start_offset,
23                ..
24            } => {
25                // Direct mapping to original file
26                let file = ctx.get_file(*file_id)?;
27                let file_info = file.file_info.as_ref()?;
28
29                // Compute the absolute offset in the file
30                let absolute_offset = start_offset + offset;
31
32                // Get file content: borrow the stored content for ephemeral
33                // files, or read from disk. `offset_to_location` only needs a
34                // `&str`, so the in-memory case must not clone — callers map
35                // one offset per AST node, and a clone here made that
36                // O(nodes × file size) (quarto-dev/q2 bd-jn7r22g8).
37                let content: Cow<'_, str> = match &file.content {
38                    Some(c) => Cow::Borrowed(c.as_str()),
39                    None => Cow::Owned(std::fs::read_to_string(&file.path).ok()?),
40                };
41
42                // Convert offset to Location with row/column using efficient binary search
43                let location = file_info.offset_to_location(absolute_offset, &content)?;
44
45                Some(MappedLocation {
46                    file_id: *file_id,
47                    location,
48                })
49            }
50            SourceInfo::Substring {
51                parent,
52                start_offset,
53                ..
54            } => {
55                // Map to parent coordinates and recurse
56                let parent_offset = start_offset + offset;
57                parent.map_offset(parent_offset, ctx)
58            }
59            SourceInfo::Concat { pieces } => {
60                // Find which piece contains this offset
61                for piece in pieces {
62                    let piece_start = piece.offset_in_concat;
63                    let piece_end = piece_start + piece.length;
64
65                    if offset >= piece_start && offset < piece_end {
66                        // Offset is within this piece
67                        let offset_in_piece = offset - piece_start;
68                        return piece.source_info.map_offset(offset_in_piece, ctx);
69                    }
70                }
71                // Exclusive end: `offset == total` matches no piece above; map it to
72                // the end of the last piece (like Original/Substring's map_offset(length)).
73                if let Some(last) = pieces.last()
74                    && offset == last.offset_in_concat + last.length
75                {
76                    return last.source_info.map_offset(last.source_info.length(), ctx);
77                }
78                None // Offset not found in any piece
79            }
80            SourceInfo::Generated { .. } => {
81                // Generated nodes have no offset-within-current-text;
82                // callers wanting source coordinates use resolve_byte_range.
83                None
84            }
85        }
86    }
87
88    /// Map a range in the current text back to original source
89    pub fn map_range(
90        &self,
91        start: usize,
92        end: usize,
93        ctx: &SourceContext,
94    ) -> Option<(MappedLocation, MappedLocation)> {
95        let start_mapped = self.map_offset(start, ctx)?;
96        let end_mapped = self.map_offset(end, ctx)?;
97        Some((start_mapped, end_mapped))
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use crate::types::{Location, Range};
104    use crate::{SourceContext, SourceInfo};
105
106    #[test]
107    fn test_map_offset_original() {
108        let mut ctx = SourceContext::new();
109        let file_id = ctx.add_file("test.qmd".to_string(), Some("hello\nworld".to_string()));
110
111        let info = SourceInfo::from_range(
112            file_id,
113            Range {
114                start: Location {
115                    offset: 0,
116                    row: 0,
117                    column: 0,
118                },
119                end: Location {
120                    offset: 11,
121                    row: 1,
122                    column: 5,
123                },
124            },
125        );
126
127        // Test mapping offset 0 (start of first line)
128        let mapped = info.map_offset(0, &ctx).unwrap();
129        assert_eq!(mapped.file_id, file_id);
130        assert_eq!(mapped.location.offset, 0);
131        assert_eq!(mapped.location.row, 0);
132        assert_eq!(mapped.location.column, 0);
133
134        // Test mapping offset 6 (start of second line)
135        let mapped = info.map_offset(6, &ctx).unwrap();
136        assert_eq!(mapped.file_id, file_id);
137        assert_eq!(mapped.location.offset, 6);
138        assert_eq!(mapped.location.row, 1);
139        assert_eq!(mapped.location.column, 0);
140    }
141
142    #[test]
143    fn test_map_offset_disk_backed_file() {
144        // `content: None` files are read from disk on demand (the `Owned`
145        // arm); they must resolve exactly like in-memory files.
146        let dir = std::env::temp_dir().join(format!(
147            "quarto-source-map-map-offset-{}",
148            std::process::id()
149        ));
150        std::fs::create_dir_all(&dir).unwrap();
151        let path = dir.join("disk.qmd");
152        std::fs::write(&path, "hello\nwörld\n").unwrap();
153
154        let mut ctx = SourceContext::new();
155        let file_id = ctx.add_file(path.to_string_lossy().into_owned(), None);
156        assert!(ctx.get_file(file_id).unwrap().content.is_none());
157
158        let info = SourceInfo::original(file_id, 0, 13);
159        // offset 9 is the 'r' after the two-byte 'ö': row 1, column 2 (chars)
160        let mapped = info.map_offset(9, &ctx).unwrap();
161        assert_eq!(mapped.file_id, file_id);
162        assert_eq!(mapped.location.offset, 9);
163        assert_eq!(mapped.location.row, 1);
164        assert_eq!(mapped.location.column, 2);
165
166        std::fs::remove_dir_all(&dir).unwrap();
167    }
168
169    #[test]
170    fn test_map_offset_substring() {
171        let mut ctx = SourceContext::new();
172        let file_id = ctx.add_file("test.qmd".to_string(), Some("0123456789".to_string()));
173
174        let original = SourceInfo::from_range(
175            file_id,
176            Range {
177                start: Location {
178                    offset: 0,
179                    row: 0,
180                    column: 0,
181                },
182                end: Location {
183                    offset: 10,
184                    row: 0,
185                    column: 10,
186                },
187            },
188        );
189
190        // Extract substring from offset 3 to 7 ("3456")
191        let substring = SourceInfo::substring(original, 3, 7);
192
193        // Map offset 0 in substring (should be '3' at offset 3 in original)
194        let mapped = substring.map_offset(0, &ctx).unwrap();
195        assert_eq!(mapped.file_id, file_id);
196        assert_eq!(mapped.location.offset, 3);
197
198        // Map offset 2 in substring (should be '5' at offset 5 in original)
199        let mapped = substring.map_offset(2, &ctx).unwrap();
200        assert_eq!(mapped.file_id, file_id);
201        assert_eq!(mapped.location.offset, 5);
202    }
203
204    #[test]
205    fn test_map_offset_concat() {
206        let mut ctx = SourceContext::new();
207        let file_id1 = ctx.add_file("first.qmd".to_string(), Some("AAA".to_string()));
208        let file_id2 = ctx.add_file("second.qmd".to_string(), Some("BBB".to_string()));
209
210        let info1 = SourceInfo::from_range(
211            file_id1,
212            Range {
213                start: Location {
214                    offset: 0,
215                    row: 0,
216                    column: 0,
217                },
218                end: Location {
219                    offset: 3,
220                    row: 0,
221                    column: 3,
222                },
223            },
224        );
225
226        let info2 = SourceInfo::from_range(
227            file_id2,
228            Range {
229                start: Location {
230                    offset: 0,
231                    row: 0,
232                    column: 0,
233                },
234                end: Location {
235                    offset: 3,
236                    row: 0,
237                    column: 3,
238                },
239            },
240        );
241
242        // Concatenate: "AAABBB"
243        let concat = SourceInfo::concat(vec![(info1, 3), (info2, 3)]);
244
245        // Map offset 1 (should be in first piece, second 'A')
246        let mapped = concat.map_offset(1, &ctx).unwrap();
247        assert_eq!(mapped.file_id, file_id1);
248        assert_eq!(mapped.location.offset, 1);
249
250        // Map offset 4 (should be in second piece, second 'B')
251        let mapped = concat.map_offset(4, &ctx).unwrap();
252        assert_eq!(mapped.file_id, file_id2);
253        assert_eq!(mapped.location.offset, 1);
254
255        // Exclusive end (offset 6 == total): maps to end of last piece
256        let mapped = concat.map_offset(6, &ctx).unwrap();
257        assert_eq!(mapped.file_id, file_id2);
258        assert_eq!(mapped.location.offset, 3);
259
260        // map_range over the whole concat: exclusive end must resolve
261        let (start, end) = concat.map_range(0, 6, &ctx).unwrap();
262        assert_eq!(start.file_id, file_id1);
263        assert_eq!(start.location.offset, 0);
264        assert_eq!(end.file_id, file_id2);
265        assert_eq!(end.location.offset, 3);
266    }
267
268    // -------------------------------------------------------------------------
269    // Concat's exclusive-end branch: last piece's *source* length, not its
270    // *content* length. Three measured terminal shapes (see
271    // extract-design-concat-preimage.md, "SourceInfo::Concat is already the
272    // right shape").
273    // -------------------------------------------------------------------------
274
275    #[test]
276    fn test_map_offset_concat_exclusive_end_all_verbatim_is_gating() {
277        // GATING: this assertion is unchanged by the fix (Some(9) both
278        // before and after) — for a verbatim last piece, content length
279        // equals source length, so `last.length` and
280        // `last.source_info.length()` agree. Keep for shape; do not cite
281        // as coverage for the mapping.rs:64-70 fix.
282        let mut ctx = SourceContext::new();
283        let file_id = ctx.add_file("test.qmd".to_string(), Some("hello world".to_string()));
284
285        let first = SourceInfo::from_range(
286            file_id,
287            Range {
288                start: Location {
289                    offset: 0,
290                    row: 0,
291                    column: 0,
292                },
293                end: Location {
294                    offset: 4,
295                    row: 0,
296                    column: 4,
297                },
298            },
299        );
300        let last = SourceInfo::from_range(
301            file_id,
302            Range {
303                start: Location {
304                    offset: 4,
305                    row: 0,
306                    column: 4,
307                },
308                end: Location {
309                    offset: 9,
310                    row: 0,
311                    column: 9,
312                },
313            },
314        );
315        let concat = SourceInfo::concat(vec![(first, 4), (last, 5)]);
316
317        // offset 9 == total content length -> exclusive-end branch
318        let mapped = concat.map_offset(9, &ctx).unwrap();
319        assert_eq!(mapped.location.offset, 9);
320    }
321
322    #[test]
323    fn test_map_offset_concat_exclusive_end_replacement_terminated() {
324        // A last piece whose content is a decoded replacement (`''` -> `'`):
325        // source span 7..9 (2 bytes) collapses to 1 content byte. Before the
326        // fix, `last.length` (content length 1) reaches only source offset
327        // 8; after the fix, `last.source_info.length()` (source length 2)
328        // reaches the true source end, 9.
329        let mut ctx = SourceContext::new();
330        let file_id = ctx.add_file("test.qmd".to_string(), Some("012345678".to_string()));
331
332        let first = SourceInfo::from_range(
333            file_id,
334            Range {
335                start: Location {
336                    offset: 0,
337                    row: 0,
338                    column: 0,
339                },
340                end: Location {
341                    offset: 7,
342                    row: 0,
343                    column: 7,
344                },
345            },
346        );
347        let replacement = SourceInfo::from_range(
348            file_id,
349            Range {
350                start: Location {
351                    offset: 7,
352                    row: 0,
353                    column: 7,
354                },
355                end: Location {
356                    offset: 9,
357                    row: 0,
358                    column: 9,
359                },
360            },
361        );
362        // first piece: 7 content bytes over 7 source bytes (verbatim);
363        // replacement piece: 1 content byte over 2 source bytes.
364        let concat = SourceInfo::concat(vec![(first, 7), (replacement, 1)]);
365
366        // offset 8 == total content length (7 + 1) -> exclusive-end branch
367        let mapped = concat.map_offset(8, &ctx).unwrap();
368        assert_eq!(mapped.location.offset, 9);
369    }
370
371    #[test]
372    fn test_map_offset_concat_exclusive_end_synthesis_terminated() {
373        // A last piece synthesized at EOF: `Original{eof, eof}` (zero-width
374        // source span) with a 1-byte content length. Before the fix,
375        // `last.length` (1) pushes the absolute offset to eof + 1, which
376        // exceeds the file's total length and returns None — a
377        // clip-chomped block scalar at EOF loses its caret's right edge
378        // entirely. After the fix, `last.source_info.length()` (0) maps to
379        // exactly eof, which is in bounds.
380        let mut ctx = SourceContext::new();
381        let file_id = ctx.add_file("test.qmd".to_string(), Some("hello world".to_string())); // 11 bytes
382
383        let synthesis = SourceInfo::from_range(
384            file_id,
385            Range {
386                start: Location {
387                    offset: 11,
388                    row: 0,
389                    column: 11,
390                },
391                end: Location {
392                    offset: 11,
393                    row: 0,
394                    column: 11,
395                },
396            },
397        );
398        let concat = SourceInfo::concat(vec![(synthesis, 1)]);
399
400        // offset 1 == total content length -> exclusive-end branch
401        let mapped = concat.map_offset(1, &ctx).unwrap();
402        assert_eq!(mapped.location.offset, 11);
403    }
404
405    #[test]
406    fn test_map_range() {
407        let mut ctx = SourceContext::new();
408        let file_id = ctx.add_file("test.qmd".to_string(), Some("hello\nworld".to_string()));
409
410        let info = SourceInfo::from_range(
411            file_id,
412            Range {
413                start: Location {
414                    offset: 0,
415                    row: 0,
416                    column: 0,
417                },
418                end: Location {
419                    offset: 11,
420                    row: 1,
421                    column: 5,
422                },
423            },
424        );
425
426        // Map range [0, 5) which is "hello"
427        let (start, end) = info.map_range(0, 5, &ctx).unwrap();
428        assert_eq!(start.file_id, file_id);
429        assert_eq!(start.location.offset, 0);
430        assert_eq!(end.file_id, file_id);
431        assert_eq!(end.location.offset, 5);
432    }
433}