oxur_smap/
span.rs

1/// A span representing a range in source code
2///
3/// Unlike `SourcePos` which represents a point with a length, `Span`
4/// explicitly represents a range with start and end positions. This
5/// is particularly useful for multi-line constructs like functions,
6/// blocks, and expressions that span multiple lines.
7///
8/// # Examples
9///
10/// ```
11/// use oxur_smap::Span;
12///
13/// // Single-line span
14/// let span = Span::new(
15///     "test.oxur".to_string(),
16///     1, 5,   // start: line 1, column 5
17///     1, 15,  // end: line 1, column 15
18/// );
19/// assert_eq!(span.num_lines(), 1);
20/// assert_eq!(span.length_on_start_line(), 10);
21///
22/// // Multi-line span
23/// let span = Span::new(
24///     "test.oxur".to_string(),
25///     1, 5,   // start: line 1, column 5
26///     3, 10,  // end: line 3, column 10
27/// );
28/// assert_eq!(span.num_lines(), 3);
29/// assert!(span.is_multi_line());
30/// ```
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct Span {
33    /// Source file path (or `<repl>` for REPL input)
34    pub file: String,
35
36    /// Start line number (1-indexed)
37    pub start_line: u32,
38
39    /// Start column number (1-indexed)
40    pub start_column: u32,
41
42    /// End line number (1-indexed)
43    pub end_line: u32,
44
45    /// End column number (1-indexed, exclusive)
46    ///
47    /// Note: This is the column *after* the last character in the span.
48    /// For example, if the span covers "hello", end_column points to
49    /// the position after 'o'.
50    pub end_column: u32,
51}
52
53impl Span {
54    /// Create a new span
55    ///
56    /// # Panics
57    ///
58    /// Panics if:
59    /// - `start_line` or `start_column` is 0 (must be 1-indexed)
60    /// - `end_line` or `end_column` is 0 (must be 1-indexed)
61    /// - `end_line` < `start_line` (invalid range)
62    /// - `end_line` == `start_line` and `end_column` <= `start_column` (invalid range)
63    ///
64    /// # Examples
65    ///
66    /// ```
67    /// use oxur_smap::Span;
68    ///
69    /// let span = Span::new("file.oxur".to_string(), 1, 5, 1, 15);
70    /// assert_eq!(span.start_line, 1);
71    /// assert_eq!(span.end_column, 15);
72    /// ```
73    pub fn new(
74        file: String,
75        start_line: u32,
76        start_column: u32,
77        end_line: u32,
78        end_column: u32,
79    ) -> Self {
80        assert!(start_line > 0, "Line numbers are 1-indexed");
81        assert!(start_column > 0, "Column numbers are 1-indexed");
82        assert!(end_line > 0, "Line numbers are 1-indexed");
83        assert!(end_column > 0, "Column numbers are 1-indexed");
84        assert!(end_line >= start_line, "End line must be >= start line");
85        if end_line == start_line {
86            assert!(end_column > start_column, "End column must be > start column on same line");
87        }
88
89        Self { file, start_line, start_column, end_line, end_column }
90    }
91
92    /// Create a span for REPL input
93    ///
94    /// # Examples
95    ///
96    /// ```
97    /// use oxur_smap::Span;
98    ///
99    /// let span = Span::repl(1, 1, 1, 10);
100    /// assert_eq!(span.file, "<repl>");
101    /// ```
102    pub fn repl(start_line: u32, start_column: u32, end_line: u32, end_column: u32) -> Self {
103        Self::new("<repl>".to_string(), start_line, start_column, end_line, end_column)
104    }
105
106    /// Check if this span is on a single line
107    ///
108    /// # Examples
109    ///
110    /// ```
111    /// use oxur_smap::Span;
112    ///
113    /// let single = Span::new("file.oxur".to_string(), 1, 5, 1, 15);
114    /// assert!(single.is_single_line());
115    ///
116    /// let multi = Span::new("file.oxur".to_string(), 1, 5, 3, 10);
117    /// assert!(!multi.is_single_line());
118    /// ```
119    pub fn is_single_line(&self) -> bool {
120        self.start_line == self.end_line
121    }
122
123    /// Check if this span spans multiple lines
124    pub fn is_multi_line(&self) -> bool {
125        !self.is_single_line()
126    }
127
128    /// Get the number of lines this span covers
129    ///
130    /// # Examples
131    ///
132    /// ```
133    /// use oxur_smap::Span;
134    ///
135    /// let span = Span::new("file.oxur".to_string(), 1, 5, 3, 10);
136    /// assert_eq!(span.num_lines(), 3);
137    /// ```
138    pub fn num_lines(&self) -> u32 {
139        self.end_line - self.start_line + 1
140    }
141
142    /// Get the length on the start line (for single-line spans)
143    ///
144    /// For multi-line spans, this returns the length from start_column
145    /// to the end of the start line (which is undefined without source text).
146    ///
147    /// # Examples
148    ///
149    /// ```
150    /// use oxur_smap::Span;
151    ///
152    /// let span = Span::new("file.oxur".to_string(), 1, 5, 1, 15);
153    /// assert_eq!(span.length_on_start_line(), 10);
154    /// ```
155    pub fn length_on_start_line(&self) -> u32 {
156        if self.is_single_line() {
157            self.end_column - self.start_column
158        } else {
159            // For multi-line, we don't know the line length without source text
160            // This is a limitation - caller should check is_single_line() first
161            0
162        }
163    }
164
165    /// Check if this span contains another span
166    ///
167    /// # Examples
168    ///
169    /// ```
170    /// use oxur_smap::Span;
171    ///
172    /// let outer = Span::new("file.oxur".to_string(), 1, 5, 3, 20);
173    /// let inner = Span::new("file.oxur".to_string(), 2, 1, 2, 10);
174    ///
175    /// assert!(outer.contains(&inner));
176    /// assert!(!inner.contains(&outer));
177    /// ```
178    pub fn contains(&self, other: &Span) -> bool {
179        if self.file != other.file {
180            return false;
181        }
182
183        // Check start is before or equal
184        let start_ok = other.start_line > self.start_line
185            || (other.start_line == self.start_line && other.start_column >= self.start_column);
186
187        // Check end is after or equal
188        let end_ok = other.end_line < self.end_line
189            || (other.end_line == self.end_line && other.end_column <= self.end_column);
190
191        start_ok && end_ok
192    }
193
194    /// Merge two spans into a single span covering both
195    ///
196    /// # Panics
197    ///
198    /// Panics if the spans are from different files.
199    ///
200    /// # Examples
201    ///
202    /// ```
203    /// use oxur_smap::Span;
204    ///
205    /// let span1 = Span::new("file.oxur".to_string(), 1, 5, 1, 10);
206    /// let span2 = Span::new("file.oxur".to_string(), 1, 15, 2, 5);
207    /// let merged = span1.merge(&span2);
208    ///
209    /// assert_eq!(merged.start_line, 1);
210    /// assert_eq!(merged.start_column, 5);
211    /// assert_eq!(merged.end_line, 2);
212    /// assert_eq!(merged.end_column, 5);
213    /// ```
214    pub fn merge(&self, other: &Span) -> Span {
215        assert_eq!(self.file, other.file, "Cannot merge spans from different files");
216
217        let (start_line, start_column) = if self.start_line < other.start_line
218            || (self.start_line == other.start_line && self.start_column < other.start_column)
219        {
220            (self.start_line, self.start_column)
221        } else {
222            (other.start_line, other.start_column)
223        };
224
225        let (end_line, end_column) = if self.end_line > other.end_line
226            || (self.end_line == other.end_line && self.end_column > other.end_column)
227        {
228            (self.end_line, self.end_column)
229        } else {
230            (other.end_line, other.end_column)
231        };
232
233        Span::new(self.file.clone(), start_line, start_column, end_line, end_column)
234    }
235}
236
237impl std::fmt::Display for Span {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        if self.is_single_line() {
240            write!(f, "{}:{}:{}-{}", self.file, self.start_line, self.start_column, self.end_column)
241        } else {
242            write!(
243                f,
244                "{}:{}:{}-{}:{}",
245                self.file, self.start_line, self.start_column, self.end_line, self.end_column
246            )
247        }
248    }
249}
250
251/// Convert a single-line Span to SourcePos
252///
253/// # Panics
254///
255/// Panics if the span is multi-line.
256impl From<&Span> for crate::SourcePos {
257    fn from(span: &Span) -> Self {
258        assert!(span.is_single_line(), "Can only convert single-line Span to SourcePos");
259        crate::SourcePos::new(
260            span.file.clone(),
261            span.start_line,
262            span.start_column,
263            span.length_on_start_line(),
264        )
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    #[test]
273    fn test_span_basic() {
274        let span = Span::new("test.oxur".to_string(), 1, 5, 1, 15);
275        assert_eq!(span.file, "test.oxur");
276        assert_eq!(span.start_line, 1);
277        assert_eq!(span.start_column, 5);
278        assert_eq!(span.end_line, 1);
279        assert_eq!(span.end_column, 15);
280    }
281
282    #[test]
283    fn test_span_display_single_line() {
284        let span = Span::new("test.oxur".to_string(), 10, 5, 10, 15);
285        assert_eq!(format!("{}", span), "test.oxur:10:5-15");
286    }
287
288    #[test]
289    fn test_span_display_multi_line() {
290        let span = Span::new("test.oxur".to_string(), 1, 5, 3, 10);
291        assert_eq!(format!("{}", span), "test.oxur:1:5-3:10");
292    }
293
294    #[test]
295    fn test_span_repl() {
296        let span = Span::repl(1, 1, 1, 20);
297        assert_eq!(span.file, "<repl>");
298        assert_eq!(span.start_line, 1);
299    }
300
301    #[test]
302    fn test_span_is_single_line() {
303        let single = Span::new("test.oxur".to_string(), 1, 5, 1, 15);
304        assert!(single.is_single_line());
305        assert!(!single.is_multi_line());
306
307        let multi = Span::new("test.oxur".to_string(), 1, 5, 3, 10);
308        assert!(!multi.is_single_line());
309        assert!(multi.is_multi_line());
310    }
311
312    #[test]
313    fn test_span_num_lines() {
314        let span1 = Span::new("test.oxur".to_string(), 1, 5, 1, 15);
315        assert_eq!(span1.num_lines(), 1);
316
317        let span2 = Span::new("test.oxur".to_string(), 1, 5, 3, 10);
318        assert_eq!(span2.num_lines(), 3);
319
320        let span3 = Span::new("test.oxur".to_string(), 5, 1, 10, 1);
321        assert_eq!(span3.num_lines(), 6);
322    }
323
324    #[test]
325    fn test_span_length_on_start_line() {
326        let single = Span::new("test.oxur".to_string(), 1, 5, 1, 15);
327        assert_eq!(single.length_on_start_line(), 10);
328
329        let multi = Span::new("test.oxur".to_string(), 1, 5, 3, 10);
330        // For multi-line, returns 0 (undefined without source text)
331        assert_eq!(multi.length_on_start_line(), 0);
332    }
333
334    #[test]
335    fn test_span_contains_same_line() {
336        let outer = Span::new("test.oxur".to_string(), 1, 5, 1, 20);
337        let inner = Span::new("test.oxur".to_string(), 1, 10, 1, 15);
338        let before = Span::new("test.oxur".to_string(), 1, 1, 1, 4);
339        let after = Span::new("test.oxur".to_string(), 1, 21, 1, 25);
340
341        assert!(outer.contains(&inner));
342        assert!(!outer.contains(&before));
343        assert!(!outer.contains(&after));
344        assert!(!inner.contains(&outer));
345    }
346
347    #[test]
348    fn test_span_contains_multi_line() {
349        let outer = Span::new("test.oxur".to_string(), 1, 5, 5, 20);
350        let inner = Span::new("test.oxur".to_string(), 2, 1, 3, 10);
351        let overlapping = Span::new("test.oxur".to_string(), 1, 1, 2, 10);
352
353        assert!(outer.contains(&inner));
354        assert!(!outer.contains(&overlapping)); // Starts before outer
355        assert!(!inner.contains(&outer));
356    }
357
358    #[test]
359    fn test_span_contains_different_files() {
360        let span1 = Span::new("file1.oxur".to_string(), 1, 5, 1, 20);
361        let span2 = Span::new("file2.oxur".to_string(), 1, 10, 1, 15);
362
363        assert!(!span1.contains(&span2));
364    }
365
366    #[test]
367    fn test_span_merge_same_line() {
368        let span1 = Span::new("test.oxur".to_string(), 1, 5, 1, 10);
369        let span2 = Span::new("test.oxur".to_string(), 1, 15, 1, 20);
370        let merged = span1.merge(&span2);
371
372        assert_eq!(merged.start_line, 1);
373        assert_eq!(merged.start_column, 5);
374        assert_eq!(merged.end_line, 1);
375        assert_eq!(merged.end_column, 20);
376    }
377
378    #[test]
379    fn test_span_merge_multi_line() {
380        let span1 = Span::new("test.oxur".to_string(), 1, 5, 2, 10);
381        let span2 = Span::new("test.oxur".to_string(), 3, 1, 4, 5);
382        let merged = span1.merge(&span2);
383
384        assert_eq!(merged.start_line, 1);
385        assert_eq!(merged.start_column, 5);
386        assert_eq!(merged.end_line, 4);
387        assert_eq!(merged.end_column, 5);
388    }
389
390    #[test]
391    fn test_span_merge_overlapping() {
392        let span1 = Span::new("test.oxur".to_string(), 1, 5, 2, 10);
393        let span2 = Span::new("test.oxur".to_string(), 2, 1, 3, 5);
394        let merged = span1.merge(&span2);
395
396        assert_eq!(merged.start_line, 1);
397        assert_eq!(merged.start_column, 5);
398        assert_eq!(merged.end_line, 3);
399        assert_eq!(merged.end_column, 5);
400    }
401
402    #[test]
403    fn test_span_merge_reversed() {
404        let span1 = Span::new("test.oxur".to_string(), 3, 1, 4, 5);
405        let span2 = Span::new("test.oxur".to_string(), 1, 5, 2, 10);
406        let merged = span1.merge(&span2);
407
408        // Should produce same result regardless of order
409        assert_eq!(merged.start_line, 1);
410        assert_eq!(merged.start_column, 5);
411        assert_eq!(merged.end_line, 4);
412        assert_eq!(merged.end_column, 5);
413    }
414
415    #[test]
416    #[should_panic(expected = "Cannot merge spans from different files")]
417    fn test_span_merge_different_files() {
418        let span1 = Span::new("file1.oxur".to_string(), 1, 5, 1, 10);
419        let span2 = Span::new("file2.oxur".to_string(), 1, 15, 1, 20);
420        span1.merge(&span2);
421    }
422
423    #[test]
424    #[should_panic(expected = "Line numbers are 1-indexed")]
425    fn test_span_zero_start_line() {
426        Span::new("test.oxur".to_string(), 0, 1, 1, 10);
427    }
428
429    #[test]
430    #[should_panic(expected = "Column numbers are 1-indexed")]
431    fn test_span_zero_start_column() {
432        Span::new("test.oxur".to_string(), 1, 0, 1, 10);
433    }
434
435    #[test]
436    #[should_panic(expected = "Line numbers are 1-indexed")]
437    fn test_span_zero_end_line() {
438        Span::new("test.oxur".to_string(), 1, 1, 0, 10);
439    }
440
441    #[test]
442    #[should_panic(expected = "Column numbers are 1-indexed")]
443    fn test_span_zero_end_column() {
444        Span::new("test.oxur".to_string(), 1, 1, 1, 0);
445    }
446
447    #[test]
448    #[should_panic(expected = "End line must be >= start line")]
449    fn test_span_end_before_start_line() {
450        Span::new("test.oxur".to_string(), 5, 1, 3, 10);
451    }
452
453    #[test]
454    #[should_panic(expected = "End column must be > start column on same line")]
455    fn test_span_end_before_start_column() {
456        Span::new("test.oxur".to_string(), 1, 10, 1, 5);
457    }
458
459    #[test]
460    #[should_panic(expected = "End column must be > start column on same line")]
461    fn test_span_equal_positions() {
462        Span::new("test.oxur".to_string(), 1, 10, 1, 10);
463    }
464
465    #[test]
466    fn test_span_to_source_pos() {
467        let span = Span::new("test.oxur".to_string(), 1, 5, 1, 15);
468        let pos: crate::SourcePos = (&span).into();
469
470        assert_eq!(pos.file, "test.oxur");
471        assert_eq!(pos.line, 1);
472        assert_eq!(pos.column, 5);
473        assert_eq!(pos.length, 10);
474    }
475
476    #[test]
477    #[should_panic(expected = "Can only convert single-line Span to SourcePos")]
478    fn test_span_to_source_pos_multi_line() {
479        let span = Span::new("test.oxur".to_string(), 1, 5, 3, 10);
480        let _pos: crate::SourcePos = (&span).into();
481    }
482}