Skip to main content

tfparser_core/ir/
span.rs

1//! Source-position metadata attached to every IR node that originated in a
2//! parsed file.
3//!
4//! Per [10-data-model.md § 2.4]: `file` is an `Arc<Path>` (shared across all
5//! spans pointing into the same source); `byte_range` is `Range<u32>` to keep
6//! the on-disk footprint small at reference scale (4 GB per file is more than
7//! enough); `line` / `column` are 1-based.
8//!
9//! [10-data-model.md § 2.4]: ../../specs/10-data-model.md
10//!
11//! ## Invariants
12//!
13//! - `byte_range.start <= byte_range.end`. Enforced by [`Span::new`]; direct field access bypasses
14//!   the check (the field is private).
15//! - `line >= 1`, `column >= 1`. Zero values are nonsensical for 1-based positions and would mask
16//!   bugs downstream; rejected by the constructor.
17
18use std::{ops::Range, path::Path, sync::Arc};
19
20use serde::{Deserialize, Serialize};
21
22use crate::error::ValidationError;
23
24/// Byte-offset + line/column span into a source file.
25///
26/// Spans are cheap to clone (`Arc<Path>` is a single ref-count bump). Per
27/// [10-data-model.md § 2.5 (I-IR-1)], every `Span` in the workspace IR
28/// resolves to a path *underneath* the workspace root — discovery enforces
29/// this; `Span` itself does not validate the path (it accepts already-trusted
30/// values from the loader).
31#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
32#[non_exhaustive]
33#[serde(rename_all = "camelCase")]
34pub struct Span {
35    /// Path of the source file the span points into.
36    #[serde(with = "crate::ir::path_serde::arc_path")]
37    pub file: Arc<Path>,
38
39    /// Half-open byte range `[start, end)` into the file's contents.
40    pub byte_range: Range<u32>,
41
42    /// 1-based line number of `byte_range.start`.
43    pub line: u32,
44
45    /// 1-based column (in bytes) of `byte_range.start`.
46    pub column: u32,
47}
48
49impl Span {
50    /// Construct a span, validating that `byte_range` is well-ordered and
51    /// `line`/`column` are 1-based.
52    ///
53    /// # Errors
54    ///
55    /// Returns [`ValidationError::Shape`] if any invariant is violated.
56    pub fn new(
57        file: Arc<Path>,
58        byte_range: Range<u32>,
59        line: u32,
60        column: u32,
61    ) -> Result<Self, ValidationError> {
62        if byte_range.start > byte_range.end {
63            return Err(ValidationError::Shape {
64                field: "Span.byte_range",
65                rule: "start-after-end",
66            });
67        }
68        if line == 0 {
69            return Err(ValidationError::Shape {
70                field: "Span.line",
71                rule: "must-be-1-based",
72            });
73        }
74        if column == 0 {
75            return Err(ValidationError::Shape {
76                field: "Span.column",
77                rule: "must-be-1-based",
78            });
79        }
80        Ok(Self {
81            file,
82            byte_range,
83            line,
84            column,
85        })
86    }
87
88    /// A synthetic 1-byte span used for IR nodes that did not originate in a
89    /// file (e.g. defaults injected by the evaluator). Carries an empty path.
90    #[must_use]
91    pub fn synthetic() -> Self {
92        Self {
93            file: Arc::from(Path::new("")),
94            byte_range: 0..0,
95            line: 1,
96            column: 1,
97        }
98    }
99
100    /// Convenience accessor for the byte range as a `usize` slice into a
101    /// source buffer.
102    #[must_use]
103    pub fn byte_range_usize(&self) -> Range<usize> {
104        self.byte_range.start as usize..self.byte_range.end as usize
105    }
106}
107
108#[cfg(test)]
109#[allow(
110    clippy::unwrap_used,
111    clippy::expect_used,
112    clippy::panic,
113    clippy::indexing_slicing,
114    clippy::reversed_empty_ranges
115)]
116mod tests {
117    use super::*;
118
119    fn p(s: &str) -> Arc<Path> {
120        Arc::from(Path::new(s))
121    }
122
123    fn must_err(r: Result<Span, ValidationError>) -> ValidationError {
124        match r {
125            Ok(s) => panic!("expected Err, got Ok({s:?})"),
126            Err(e) => e,
127        }
128    }
129
130    #[test]
131    fn test_should_construct_span_with_valid_inputs() {
132        let s = Span::new(p("/tmp/x.tf"), 10..20, 3, 5).unwrap();
133        assert_eq!(s.byte_range, 10..20);
134        assert_eq!(s.line, 3);
135        assert_eq!(s.column, 5);
136        assert_eq!(s.byte_range_usize(), 10usize..20usize);
137    }
138
139    #[test]
140    fn test_should_reject_reversed_byte_range() {
141        // Constructed manually so clippy doesn't flag the literal as
142        // `reversed_empty_ranges` — the IR allows the field type to express
143        // any range; we want to assert the constructor rejects it.
144        let range = Range::<u32> { start: 30, end: 10 };
145        let err = must_err(Span::new(p("/tmp/x.tf"), range, 1, 1));
146        assert!(matches!(
147            err,
148            ValidationError::Shape {
149                rule: "start-after-end",
150                ..
151            }
152        ));
153    }
154
155    #[test]
156    fn test_should_reject_zero_line() {
157        let err = must_err(Span::new(p("/tmp/x.tf"), 0..1, 0, 1));
158        assert!(matches!(
159            err,
160            ValidationError::Shape {
161                field: "Span.line",
162                ..
163            }
164        ));
165    }
166
167    #[test]
168    fn test_should_reject_zero_column() {
169        let err = must_err(Span::new(p("/tmp/x.tf"), 0..1, 1, 0));
170        assert!(matches!(
171            err,
172            ValidationError::Shape {
173                field: "Span.column",
174                ..
175            }
176        ));
177    }
178
179    #[test]
180    fn test_should_serde_round_trip_span() {
181        let s = Span::new(p("foo/bar.tf"), 5..15, 2, 4).unwrap();
182        let json = serde_json::to_string(&s).unwrap();
183        let back: Span = serde_json::from_str(&json).unwrap();
184        assert_eq!(s, back);
185    }
186
187    #[test]
188    fn test_should_serialize_span_in_camel_case() {
189        let s = Span::new(p("a.tf"), 0..1, 1, 1).unwrap();
190        let json = serde_json::to_string(&s).unwrap();
191        assert!(json.contains("byteRange"), "got: {json}");
192    }
193
194    #[test]
195    fn test_should_serde_round_trip_synthetic_span() {
196        let s = Span::synthetic();
197        let json = serde_json::to_string(&s).unwrap();
198        assert!(
199            json.contains("\"file\":\"\""),
200            "expected empty file: {json}"
201        );
202        assert!(json.contains("\"line\":1"), "{json}");
203        let back: Span = serde_json::from_str(&json).unwrap();
204        assert_eq!(s, back);
205    }
206}