Skip to main content

yaml_rt_core/
source.rs

1use crate::{Diagnostic, DiagnosticKind, YamlError};
2
3/// YAML version targeted by this workspace.
4pub const TARGET_YAML_VERSION: &str = "1.2.2";
5
6/// Identifier for a node stored inside a [`crate::YamlDoc`].
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
8pub struct NodeId(pub u32);
9
10impl NodeId {
11    /// Creates a node ID from a vector index.
12    ///
13    /// # Panics
14    ///
15    /// Panics when `index` cannot fit in the u32-backed node ID.
16    #[must_use]
17    pub fn from_usize(index: usize) -> Self {
18        Self(u32::try_from(index).expect("node arena is too large for u32-based node IDs"))
19    }
20
21    /// Returns this node ID as a vector index.
22    #[must_use]
23    pub const fn as_usize(self) -> usize {
24        self.0 as usize
25    }
26}
27
28/// A byte span inside a [`Source`].
29#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
30pub struct Span {
31    /// Inclusive start byte offset.
32    pub start: u32,
33    /// Exclusive end byte offset.
34    pub end: u32,
35}
36
37impl Span {
38    /// Creates a new byte span.
39    #[must_use]
40    pub const fn new(start: u32, end: u32) -> Self {
41        Self { start, end }
42    }
43
44    /// Creates a span from usize byte offsets.
45    ///
46    /// # Panics
47    ///
48    /// Panics when either offset cannot fit in the u32-backed span.
49    #[must_use]
50    pub fn from_usize(start: usize, end: usize) -> Self {
51        Self::try_from((start, end)).expect("YAML source is too large for u32-based spans")
52    }
53
54    /// Returns an empty span at `offset`.
55    #[must_use]
56    pub const fn empty(offset: u32) -> Self {
57        Self {
58            start: offset,
59            end: offset,
60        }
61    }
62
63    pub(crate) fn usize_to_u32(offset: usize) -> u32 {
64        u32::try_from(offset).expect("YAML source is too large for u32-based spans")
65    }
66
67    pub(crate) fn offset_from_usize(base: u32, offset: usize) -> u32 {
68        base.checked_add(Self::usize_to_u32(offset))
69            .expect("YAML source is too large for u32-based spans")
70    }
71
72    /// Returns an empty span at `offset`.
73    #[must_use]
74    pub fn empty_from_usize(offset: usize) -> Self {
75        Self::empty(Self::usize_to_u32(offset))
76    }
77
78    /// Returns the span length in bytes.
79    #[must_use]
80    pub const fn len(self) -> u32 {
81        self.end.saturating_sub(self.start)
82    }
83
84    /// Returns true when this span covers no bytes.
85    #[must_use]
86    pub const fn is_empty(self) -> bool {
87        self.start == self.end
88    }
89
90    /// Returns true when `offset` is inside this span.
91    #[must_use]
92    pub const fn contains(self, offset: u32) -> bool {
93        self.start <= offset && offset < self.end
94    }
95}
96
97impl TryFrom<(usize, usize)> for Span {
98    type Error = std::num::TryFromIntError;
99
100    fn try_from((start, end): (usize, usize)) -> Result<Self, Self::Error> {
101        Ok(Self {
102            start: Self::usize_to_u32(start),
103            end: Self::usize_to_u32(end),
104        })
105    }
106}
107/// One-based line and column location.
108#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct LineCol {
110    /// One-based line number.
111    pub line: usize,
112    /// One-based column number in bytes for the current bootstrap model.
113    pub column: usize,
114}
115
116/// Original YAML input plus line-start metadata.
117#[derive(Debug, Clone, PartialEq, Eq)]
118pub struct Source {
119    text: String,
120    line_starts: Vec<u32>,
121}
122
123impl Source {
124    /// Builds a source buffer, validates YAML 1.2.2 printable characters, and
125    /// records all line starts.
126    ///
127    /// # Errors
128    ///
129    /// Returns an error when `text` contains characters that are not valid in a
130    /// YAML 1.2.2 stream.
131    pub fn new(text: String) -> Result<Self, YamlError> {
132        let mut line_starts = Vec::with_capacity(text.len() / 32 + 1);
133        line_starts.push(0);
134        if text.is_ascii() {
135            for (offset, byte) in text.bytes().enumerate() {
136                if !matches!(byte, b'\t' | b'\n' | b'\r' | b' '..=b'~') {
137                    return Err(invalid_yaml_character(offset, char::from(byte)));
138                }
139                if byte == b'\n' {
140                    line_starts.push(Span::usize_to_u32(offset + 1));
141                }
142            }
143        } else {
144            for (offset, character) in text.char_indices() {
145                if !is_yaml_printable(character) {
146                    return Err(invalid_yaml_character(offset, character));
147                }
148                if character == '\n' {
149                    line_starts.push(Span::usize_to_u32(offset + 1));
150                }
151            }
152        }
153
154        Ok(Self { text, line_starts })
155    }
156
157    /// Returns the original input text.
158    #[must_use]
159    pub fn as_str(&self) -> &str {
160        &self.text
161    }
162
163    /// Returns the source length in bytes.
164    #[must_use]
165    pub fn len(&self) -> usize {
166        self.text.len()
167    }
168
169    /// Returns true when the source is empty.
170    #[must_use]
171    pub fn is_empty(&self) -> bool {
172        self.text.is_empty()
173    }
174
175    /// Returns the recorded line-start byte offsets.
176    #[must_use]
177    pub fn line_starts(&self) -> &[u32] {
178        &self.line_starts
179    }
180
181    /// Returns the source slice for `span`.
182    ///
183    /// # Panics
184    ///
185    /// Panics if `span` is outside the source or does not fall on UTF-8
186    /// boundaries. Use [`Source::try_slice`] when handling user-provided spans.
187    #[must_use]
188    pub fn slice(&self, span: Span) -> &str {
189        self.try_slice(span)
190            .expect("span must be in bounds and on UTF-8 boundaries")
191    }
192
193    /// Returns the source slice for `span`, or a span-rich error when invalid.
194    ///
195    /// # Errors
196    ///
197    /// Returns an error when `span` is outside the source text or does not fall
198    /// on UTF-8 boundaries.
199    pub fn try_slice(&self, span: Span) -> Result<&str, YamlError> {
200        let start = span.start as usize;
201        let end = span.end as usize;
202
203        if start > end || end > self.text.len() {
204            return Err(YamlError::new(Diagnostic::new(
205                DiagnosticKind::Source,
206                "span is outside the source text",
207                span,
208            )));
209        }
210
211        self.text.get(start..end).ok_or_else(|| {
212            YamlError::new(Diagnostic::new(
213                DiagnosticKind::Source,
214                "span does not align with UTF-8 character boundaries",
215                span,
216            ))
217        })
218    }
219
220    /// Converts a byte offset into a one-based line/column pair.
221    #[must_use]
222    pub fn line_col(&self, offset: usize) -> LineCol {
223        let offset = Span::usize_to_u32(offset.min(self.text.len()));
224        let line_index = match self.line_starts.binary_search(&offset) {
225            Ok(index) => index,
226            Err(index) => index.saturating_sub(1),
227        };
228        let line_start = self.line_starts[line_index];
229
230        LineCol {
231            line: line_index + 1,
232            column: (offset - line_start) as usize + 1,
233        }
234    }
235
236    /// Returns the line/column pair for a diagnostic's primary span.
237    #[must_use]
238    pub fn diagnostic_position(&self, diagnostic: &Diagnostic) -> LineCol {
239        self.line_col(diagnostic.span.start as usize)
240    }
241}
242
243fn invalid_yaml_character(offset: usize, character: char) -> YamlError {
244    let span = Span::from_usize(offset, offset + character.len_utf8());
245    YamlError::new(
246        Diagnostic::new(
247            DiagnosticKind::Source,
248            format!("invalid YAML 1.2.2 character U+{:04X}", character as u32),
249            span,
250        )
251        .with_note(
252            "YAML streams may contain tab, line feeds, carriage returns, printable Unicode characters, and non-breaking spaces",
253        ),
254    )
255}
256
257pub(crate) fn validate_yaml_chars(text: &str) -> Result<(), YamlError> {
258    for (offset, character) in text.char_indices() {
259        if !is_yaml_printable(character) {
260            let span = Span::from_usize(offset, offset + character.len_utf8());
261            return Err(YamlError::new(
262                Diagnostic::new(
263                    DiagnosticKind::Source,
264                    format!(
265                        "invalid YAML 1.2.2 character U+{:04X}",
266                        character as u32
267                    ),
268                    span,
269                )
270                .with_note(
271                    "YAML streams may contain tab, line feeds, carriage returns, printable Unicode characters, and non-breaking spaces",
272                ),
273            ));
274        }
275    }
276
277    Ok(())
278}
279
280const fn is_yaml_printable(character: char) -> bool {
281    matches!(
282        character as u32,
283        0x09 | 0x0A | 0x0D | 0x20..=0x7E | 0x85 | 0xA0..=0xD7FF | 0xE000..=0xFFFD | 0x001_0000..=0x0010_FFFF
284    )
285}