Skip to main content

syster/base/
span.rs

1//! Source text positions and ranges.
2
3use std::fmt;
4
5// Re-export from text-size for compatibility
6pub use text_size::TextRange;
7pub use text_size::TextSize;
8
9/// A line and column position in source text.
10///
11/// Both line and column are 0-indexed internally, but displayed as 1-indexed.
12#[derive(Copy, Clone, Eq, PartialEq, Hash, Default)]
13pub struct LineCol {
14    /// 0-indexed line number
15    pub line: u32,
16    /// 0-indexed column (in UTF-8 bytes, not characters)
17    pub col: u32,
18}
19
20impl LineCol {
21    /// Create a new LineCol position.
22    #[inline]
23    pub const fn new(line: u32, col: u32) -> Self {
24        Self { line, col }
25    }
26
27    /// Create from 1-indexed line and column (as displayed to users).
28    #[inline]
29    pub const fn from_one_indexed(line: u32, col: u32) -> Self {
30        Self {
31            line: line.saturating_sub(1),
32            col: col.saturating_sub(1),
33        }
34    }
35
36    /// Get 1-indexed line number (for display).
37    #[inline]
38    pub const fn line_one_indexed(self) -> u32 {
39        self.line + 1
40    }
41
42    /// Get 1-indexed column number (for display).
43    #[inline]
44    pub const fn col_one_indexed(self) -> u32 {
45        self.col + 1
46    }
47}
48
49impl fmt::Debug for LineCol {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        write!(f, "{}:{}", self.line_one_indexed(), self.col_one_indexed())
52    }
53}
54
55impl fmt::Display for LineCol {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        write!(f, "{}:{}", self.line_one_indexed(), self.col_one_indexed())
58    }
59}
60
61/// Index for converting between byte offsets and line/column positions.
62#[derive(Clone, Debug)]
63pub struct LineIndex {
64    /// Byte offset of the start of each line
65    line_starts: Vec<TextSize>,
66}
67
68impl LineIndex {
69    /// Build a line index from source text.
70    pub fn new(text: &str) -> Self {
71        let mut line_starts = vec![TextSize::from(0)];
72
73        for (offset, c) in text.char_indices() {
74            if c == '\n' {
75                line_starts.push(TextSize::from((offset + 1) as u32));
76            }
77        }
78
79        Self { line_starts }
80    }
81
82    /// Convert a byte offset to a line/column position.
83    pub fn line_col(&self, offset: TextSize) -> LineCol {
84        let line = self
85            .line_starts
86            .partition_point(|&start| start <= offset)
87            .saturating_sub(1);
88
89        let line_start = self.line_starts[line];
90        let col = offset - line_start;
91
92        LineCol {
93            line: line as u32,
94            col: col.into(),
95        }
96    }
97
98    /// Convert a line/column position to a byte offset.
99    pub fn offset(&self, line_col: LineCol) -> Option<TextSize> {
100        let line_start = self.line_starts.get(line_col.line as usize)?;
101        Some(*line_start + TextSize::from(line_col.col))
102    }
103
104    /// Get the number of lines.
105    pub fn len(&self) -> usize {
106        self.line_starts.len()
107    }
108
109    /// Check if there are no lines (empty file).
110    pub fn is_empty(&self) -> bool {
111        self.line_starts.is_empty()
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn test_line_col_display() {
121        let pos = LineCol::new(0, 0);
122        assert_eq!(format!("{}", pos), "1:1");
123
124        let pos = LineCol::new(5, 10);
125        assert_eq!(format!("{}", pos), "6:11");
126    }
127
128    #[test]
129    fn test_line_col_from_one_indexed() {
130        let pos = LineCol::from_one_indexed(1, 1);
131        assert_eq!(pos.line, 0);
132        assert_eq!(pos.col, 0);
133    }
134
135    #[test]
136    fn test_line_index_single_line() {
137        let index = LineIndex::new("hello world");
138
139        assert_eq!(index.line_col(TextSize::from(0)), LineCol::new(0, 0));
140        assert_eq!(index.line_col(TextSize::from(5)), LineCol::new(0, 5));
141    }
142
143    #[test]
144    fn test_line_index_multi_line() {
145        let index = LineIndex::new("hello\nworld\n!");
146
147        assert_eq!(index.line_col(TextSize::from(0)), LineCol::new(0, 0));
148        assert_eq!(index.line_col(TextSize::from(5)), LineCol::new(0, 5));
149        assert_eq!(index.line_col(TextSize::from(6)), LineCol::new(1, 0));
150        assert_eq!(index.line_col(TextSize::from(11)), LineCol::new(1, 5));
151        assert_eq!(index.line_col(TextSize::from(12)), LineCol::new(2, 0));
152    }
153
154    #[test]
155    fn test_line_index_offset() {
156        let index = LineIndex::new("hello\nworld");
157
158        assert_eq!(index.offset(LineCol::new(0, 0)), Some(TextSize::from(0)));
159        assert_eq!(index.offset(LineCol::new(1, 0)), Some(TextSize::from(6)));
160        assert_eq!(index.offset(LineCol::new(1, 3)), Some(TextSize::from(9)));
161    }
162}