quarto-source-map 0.1.3

Source-location tracking with byte-range provenance, for parsers and diagnostics.
Documentation
//! Position mapping through transformation chains

use crate::types::{FileId, Location};
use crate::{SourceContext, SourceInfo};

/// Result of mapping a position back to an original file
#[derive(Debug, Clone, PartialEq)]
pub struct MappedLocation {
    /// The original file
    pub file_id: FileId,
    /// Location in the original file
    pub location: Location,
}

impl SourceInfo {
    /// Map an offset in the current text back to original source
    pub fn map_offset(&self, offset: usize, ctx: &SourceContext) -> Option<MappedLocation> {
        match self {
            SourceInfo::Original {
                file_id,
                start_offset,
                ..
            } => {
                // Direct mapping to original file
                let file = ctx.get_file(*file_id)?;
                let file_info = file.file_info.as_ref()?;

                // Compute the absolute offset in the file
                let absolute_offset = start_offset + offset;

                // Get file content: use stored content for ephemeral files, or read from disk
                let content = match &file.content {
                    Some(c) => c.clone(),
                    None => std::fs::read_to_string(&file.path).ok()?,
                };

                // Convert offset to Location with row/column using efficient binary search
                let location = file_info.offset_to_location(absolute_offset, &content)?;

                Some(MappedLocation {
                    file_id: *file_id,
                    location,
                })
            }
            SourceInfo::Substring {
                parent,
                start_offset,
                ..
            } => {
                // Map to parent coordinates and recurse
                let parent_offset = start_offset + offset;
                parent.map_offset(parent_offset, ctx)
            }
            SourceInfo::Concat { pieces } => {
                // Find which piece contains this offset
                for piece in pieces {
                    let piece_start = piece.offset_in_concat;
                    let piece_end = piece_start + piece.length;

                    if offset >= piece_start && offset < piece_end {
                        // Offset is within this piece
                        let offset_in_piece = offset - piece_start;
                        return piece.source_info.map_offset(offset_in_piece, ctx);
                    }
                }
                // Exclusive end: `offset == total` matches no piece above; map it to
                // the end of the last piece (like Original/Substring's map_offset(length)).
                if let Some(last) = pieces.last()
                    && offset == last.offset_in_concat + last.length
                {
                    return last.source_info.map_offset(last.source_info.length(), ctx);
                }
                None // Offset not found in any piece
            }
            SourceInfo::Generated { .. } => {
                // Generated nodes have no offset-within-current-text;
                // callers wanting source coordinates use resolve_byte_range.
                None
            }
        }
    }

    /// Map a range in the current text back to original source
    pub fn map_range(
        &self,
        start: usize,
        end: usize,
        ctx: &SourceContext,
    ) -> Option<(MappedLocation, MappedLocation)> {
        let start_mapped = self.map_offset(start, ctx)?;
        let end_mapped = self.map_offset(end, ctx)?;
        Some((start_mapped, end_mapped))
    }
}

#[cfg(test)]
mod tests {
    use crate::types::{Location, Range};
    use crate::{SourceContext, SourceInfo};

    #[test]
    fn test_map_offset_original() {
        let mut ctx = SourceContext::new();
        let file_id = ctx.add_file("test.qmd".to_string(), Some("hello\nworld".to_string()));

        let info = SourceInfo::from_range(
            file_id,
            Range {
                start: Location {
                    offset: 0,
                    row: 0,
                    column: 0,
                },
                end: Location {
                    offset: 11,
                    row: 1,
                    column: 5,
                },
            },
        );

        // Test mapping offset 0 (start of first line)
        let mapped = info.map_offset(0, &ctx).unwrap();
        assert_eq!(mapped.file_id, file_id);
        assert_eq!(mapped.location.offset, 0);
        assert_eq!(mapped.location.row, 0);
        assert_eq!(mapped.location.column, 0);

        // Test mapping offset 6 (start of second line)
        let mapped = info.map_offset(6, &ctx).unwrap();
        assert_eq!(mapped.file_id, file_id);
        assert_eq!(mapped.location.offset, 6);
        assert_eq!(mapped.location.row, 1);
        assert_eq!(mapped.location.column, 0);
    }

    #[test]
    fn test_map_offset_substring() {
        let mut ctx = SourceContext::new();
        let file_id = ctx.add_file("test.qmd".to_string(), Some("0123456789".to_string()));

        let original = SourceInfo::from_range(
            file_id,
            Range {
                start: Location {
                    offset: 0,
                    row: 0,
                    column: 0,
                },
                end: Location {
                    offset: 10,
                    row: 0,
                    column: 10,
                },
            },
        );

        // Extract substring from offset 3 to 7 ("3456")
        let substring = SourceInfo::substring(original, 3, 7);

        // Map offset 0 in substring (should be '3' at offset 3 in original)
        let mapped = substring.map_offset(0, &ctx).unwrap();
        assert_eq!(mapped.file_id, file_id);
        assert_eq!(mapped.location.offset, 3);

        // Map offset 2 in substring (should be '5' at offset 5 in original)
        let mapped = substring.map_offset(2, &ctx).unwrap();
        assert_eq!(mapped.file_id, file_id);
        assert_eq!(mapped.location.offset, 5);
    }

    #[test]
    fn test_map_offset_concat() {
        let mut ctx = SourceContext::new();
        let file_id1 = ctx.add_file("first.qmd".to_string(), Some("AAA".to_string()));
        let file_id2 = ctx.add_file("second.qmd".to_string(), Some("BBB".to_string()));

        let info1 = SourceInfo::from_range(
            file_id1,
            Range {
                start: Location {
                    offset: 0,
                    row: 0,
                    column: 0,
                },
                end: Location {
                    offset: 3,
                    row: 0,
                    column: 3,
                },
            },
        );

        let info2 = SourceInfo::from_range(
            file_id2,
            Range {
                start: Location {
                    offset: 0,
                    row: 0,
                    column: 0,
                },
                end: Location {
                    offset: 3,
                    row: 0,
                    column: 3,
                },
            },
        );

        // Concatenate: "AAABBB"
        let concat = SourceInfo::concat(vec![(info1, 3), (info2, 3)]);

        // Map offset 1 (should be in first piece, second 'A')
        let mapped = concat.map_offset(1, &ctx).unwrap();
        assert_eq!(mapped.file_id, file_id1);
        assert_eq!(mapped.location.offset, 1);

        // Map offset 4 (should be in second piece, second 'B')
        let mapped = concat.map_offset(4, &ctx).unwrap();
        assert_eq!(mapped.file_id, file_id2);
        assert_eq!(mapped.location.offset, 1);

        // Exclusive end (offset 6 == total): maps to end of last piece
        let mapped = concat.map_offset(6, &ctx).unwrap();
        assert_eq!(mapped.file_id, file_id2);
        assert_eq!(mapped.location.offset, 3);

        // map_range over the whole concat: exclusive end must resolve
        let (start, end) = concat.map_range(0, 6, &ctx).unwrap();
        assert_eq!(start.file_id, file_id1);
        assert_eq!(start.location.offset, 0);
        assert_eq!(end.file_id, file_id2);
        assert_eq!(end.location.offset, 3);
    }

    // -------------------------------------------------------------------------
    // Concat's exclusive-end branch: last piece's *source* length, not its
    // *content* length. Three measured terminal shapes (see
    // extract-design-concat-preimage.md, "SourceInfo::Concat is already the
    // right shape").
    // -------------------------------------------------------------------------

    #[test]
    fn test_map_offset_concat_exclusive_end_all_verbatim_is_gating() {
        // GATING: this assertion is unchanged by the fix (Some(9) both
        // before and after) — for a verbatim last piece, content length
        // equals source length, so `last.length` and
        // `last.source_info.length()` agree. Keep for shape; do not cite
        // as coverage for the mapping.rs:64-70 fix.
        let mut ctx = SourceContext::new();
        let file_id = ctx.add_file("test.qmd".to_string(), Some("hello world".to_string()));

        let first = SourceInfo::from_range(
            file_id,
            Range {
                start: Location {
                    offset: 0,
                    row: 0,
                    column: 0,
                },
                end: Location {
                    offset: 4,
                    row: 0,
                    column: 4,
                },
            },
        );
        let last = SourceInfo::from_range(
            file_id,
            Range {
                start: Location {
                    offset: 4,
                    row: 0,
                    column: 4,
                },
                end: Location {
                    offset: 9,
                    row: 0,
                    column: 9,
                },
            },
        );
        let concat = SourceInfo::concat(vec![(first, 4), (last, 5)]);

        // offset 9 == total content length -> exclusive-end branch
        let mapped = concat.map_offset(9, &ctx).unwrap();
        assert_eq!(mapped.location.offset, 9);
    }

    #[test]
    fn test_map_offset_concat_exclusive_end_replacement_terminated() {
        // A last piece whose content is a decoded replacement (`''` -> `'`):
        // source span 7..9 (2 bytes) collapses to 1 content byte. Before the
        // fix, `last.length` (content length 1) reaches only source offset
        // 8; after the fix, `last.source_info.length()` (source length 2)
        // reaches the true source end, 9.
        let mut ctx = SourceContext::new();
        let file_id = ctx.add_file("test.qmd".to_string(), Some("012345678".to_string()));

        let first = SourceInfo::from_range(
            file_id,
            Range {
                start: Location {
                    offset: 0,
                    row: 0,
                    column: 0,
                },
                end: Location {
                    offset: 7,
                    row: 0,
                    column: 7,
                },
            },
        );
        let replacement = SourceInfo::from_range(
            file_id,
            Range {
                start: Location {
                    offset: 7,
                    row: 0,
                    column: 7,
                },
                end: Location {
                    offset: 9,
                    row: 0,
                    column: 9,
                },
            },
        );
        // first piece: 7 content bytes over 7 source bytes (verbatim);
        // replacement piece: 1 content byte over 2 source bytes.
        let concat = SourceInfo::concat(vec![(first, 7), (replacement, 1)]);

        // offset 8 == total content length (7 + 1) -> exclusive-end branch
        let mapped = concat.map_offset(8, &ctx).unwrap();
        assert_eq!(mapped.location.offset, 9);
    }

    #[test]
    fn test_map_offset_concat_exclusive_end_synthesis_terminated() {
        // A last piece synthesized at EOF: `Original{eof, eof}` (zero-width
        // source span) with a 1-byte content length. Before the fix,
        // `last.length` (1) pushes the absolute offset to eof + 1, which
        // exceeds the file's total length and returns None — a
        // clip-chomped block scalar at EOF loses its caret's right edge
        // entirely. After the fix, `last.source_info.length()` (0) maps to
        // exactly eof, which is in bounds.
        let mut ctx = SourceContext::new();
        let file_id = ctx.add_file("test.qmd".to_string(), Some("hello world".to_string())); // 11 bytes

        let synthesis = SourceInfo::from_range(
            file_id,
            Range {
                start: Location {
                    offset: 11,
                    row: 0,
                    column: 11,
                },
                end: Location {
                    offset: 11,
                    row: 0,
                    column: 11,
                },
            },
        );
        let concat = SourceInfo::concat(vec![(synthesis, 1)]);

        // offset 1 == total content length -> exclusive-end branch
        let mapped = concat.map_offset(1, &ctx).unwrap();
        assert_eq!(mapped.location.offset, 11);
    }

    #[test]
    fn test_map_range() {
        let mut ctx = SourceContext::new();
        let file_id = ctx.add_file("test.qmd".to_string(), Some("hello\nworld".to_string()));

        let info = SourceInfo::from_range(
            file_id,
            Range {
                start: Location {
                    offset: 0,
                    row: 0,
                    column: 0,
                },
                end: Location {
                    offset: 11,
                    row: 1,
                    column: 5,
                },
            },
        );

        // Map range [0, 5) which is "hello"
        let (start, end) = info.map_range(0, 5, &ctx).unwrap();
        assert_eq!(start.file_id, file_id);
        assert_eq!(start.location.offset, 0);
        assert_eq!(end.file_id, file_id);
        assert_eq!(end.location.offset, 5);
    }
}