Skip to main content

kinetik_diag/
lib.rs

1//! Shared source spans and diagnostics for Kinetik.
2
3use std::fmt::{self, Write as _};
4
5/// Identifier for a source file in a compilation session.
6#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
7pub struct SourceId(pub u32);
8
9/// One-based line and column location in a source file.
10#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub struct LineColumn {
12    /// One-based line number.
13    pub line: usize,
14    /// One-based column number measured in Unicode scalar values.
15    pub column: usize,
16}
17
18/// Byte span inside a source file.
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub struct Span {
21    /// Source file containing the span.
22    pub source: SourceId,
23    /// Start byte offset, inclusive.
24    pub start: usize,
25    /// End byte offset, exclusive.
26    pub end: usize,
27}
28
29impl Span {
30    /// Creates a new span.
31    #[must_use]
32    pub const fn new(source: SourceId, start: usize, end: usize) -> Self {
33        Self { source, start, end }
34    }
35
36    /// Returns a span with `start` and `end` ordered.
37    #[must_use]
38    pub const fn normalized(self) -> Self {
39        if self.start <= self.end {
40            self
41        } else {
42            Self {
43                source: self.source,
44                start: self.end,
45                end: self.start,
46            }
47        }
48    }
49
50    /// Returns whether the span contains no bytes.
51    #[must_use]
52    pub const fn is_empty(self) -> bool {
53        self.start == self.end
54    }
55}
56
57/// Source text plus precomputed line starts for fast location lookup.
58#[derive(Clone, Debug, Eq, PartialEq)]
59pub struct SourceFile {
60    id: SourceId,
61    name: String,
62    text: String,
63    line_starts: Vec<usize>,
64}
65
66impl SourceFile {
67    /// Creates a source file and indexes its line starts.
68    #[must_use]
69    pub fn new(id: SourceId, name: impl Into<String>, text: impl Into<String>) -> Self {
70        let text = text.into();
71        let line_starts = line_starts(&text);
72
73        Self {
74            id,
75            name: name.into(),
76            text,
77            line_starts,
78        }
79    }
80
81    /// Returns the source id.
82    #[must_use]
83    pub const fn id(&self) -> SourceId {
84        self.id
85    }
86
87    /// Returns the display name for this source.
88    #[must_use]
89    pub fn name(&self) -> &str {
90        &self.name
91    }
92
93    /// Returns the full source text.
94    #[must_use]
95    pub fn text(&self) -> &str {
96        &self.text
97    }
98
99    /// Returns the number of indexed lines.
100    #[must_use]
101    pub fn line_count(&self) -> usize {
102        self.line_starts.len()
103    }
104
105    /// Converts a byte offset into a one-based line and column location.
106    ///
107    /// Offsets past the end of the file are clamped to the end of the text.
108    #[must_use]
109    pub fn line_column(&self, offset: usize) -> LineColumn {
110        let offset = offset.min(self.text.len());
111        let line_index = self.line_index(offset);
112        let line_start = self.line_starts[line_index];
113        let column = self.text[line_start..offset].chars().count() + 1;
114
115        LineColumn {
116            line: line_index + 1,
117            column,
118        }
119    }
120
121    /// Returns the text for a one-based line number, without trailing newline bytes.
122    #[must_use]
123    pub fn line_text(&self, line: usize) -> Option<&str> {
124        if line == 0 || line > self.line_starts.len() {
125            return None;
126        }
127
128        let line_index = line - 1;
129        let start = self.line_starts[line_index];
130        let end = self
131            .line_starts
132            .get(line_index + 1)
133            .copied()
134            .unwrap_or(self.text.len());
135
136        Some(trim_line_ending(&self.text[start..end]))
137    }
138
139    fn line_index(&self, offset: usize) -> usize {
140        match self.line_starts.binary_search(&offset) {
141            Ok(index) => index,
142            Err(index) => index.saturating_sub(1),
143        }
144    }
145}
146
147/// Severity for a diagnostic.
148#[derive(Clone, Copy, Debug, Eq, PartialEq)]
149pub enum Severity {
150    /// Informational note.
151    Note,
152    /// Non-fatal warning.
153    Warning,
154    /// Fatal error.
155    Error,
156}
157
158impl fmt::Display for Severity {
159    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
160        match self {
161            Self::Note => f.write_str("note"),
162            Self::Warning => f.write_str("warning"),
163            Self::Error => f.write_str("error"),
164        }
165    }
166}
167
168/// A source span label attached to a diagnostic.
169#[derive(Clone, Debug, Eq, PartialEq)]
170pub struct Label {
171    /// Span this label points at.
172    pub span: Span,
173    /// Optional message displayed with the underline.
174    pub message: Option<String>,
175}
176
177impl Label {
178    /// Creates a label for a source span.
179    #[must_use]
180    pub fn new(span: Span, message: impl Into<String>) -> Self {
181        Self {
182            span,
183            message: Some(message.into()),
184        }
185    }
186
187    /// Creates an unlabeled pointer for a source span.
188    #[must_use]
189    pub const fn at(span: Span) -> Self {
190        Self {
191            span,
192            message: None,
193        }
194    }
195}
196
197/// Source-associated diagnostic.
198#[derive(Clone, Debug, Eq, PartialEq)]
199pub struct Diagnostic {
200    /// Severity.
201    pub severity: Severity,
202    /// Human-facing message.
203    pub message: String,
204    /// Source labels.
205    pub labels: Vec<Label>,
206}
207
208impl Diagnostic {
209    /// Creates a diagnostic with the provided severity.
210    #[must_use]
211    pub fn new(severity: Severity, message: impl Into<String>) -> Self {
212        Self {
213            severity,
214            message: message.into(),
215            labels: Vec::new(),
216        }
217    }
218
219    /// Creates an error diagnostic.
220    #[must_use]
221    pub fn error(message: impl Into<String>) -> Self {
222        Self::new(Severity::Error, message)
223    }
224
225    /// Creates a warning diagnostic.
226    #[must_use]
227    pub fn warning(message: impl Into<String>) -> Self {
228        Self::new(Severity::Warning, message)
229    }
230
231    /// Creates a note diagnostic.
232    #[must_use]
233    pub fn note(message: impl Into<String>) -> Self {
234        Self::new(Severity::Note, message)
235    }
236
237    /// Adds a labeled span.
238    #[must_use]
239    pub fn with_label(mut self, label: Label) -> Self {
240        self.labels.push(label);
241        self
242    }
243
244    /// Adds an unlabeled primary span.
245    #[must_use]
246    pub fn with_span(self, span: Span) -> Self {
247        self.with_label(Label::at(span))
248    }
249
250    /// Renders the diagnostic with a simple single-line source snippet.
251    ///
252    /// The first label that belongs to `source` is used as the snippet anchor.
253    #[must_use]
254    pub fn render(&self, source: &SourceFile) -> String {
255        let mut rendered = format!("{}: {}", self.severity, self.message);
256
257        let Some(label) = self
258            .labels
259            .iter()
260            .find(|label| label.span.source == source.id())
261        else {
262            return rendered;
263        };
264
265        let span = label.span.normalized();
266        let start = source.line_column(span.start);
267        let end = source.line_column(span.end);
268        let line_text = source.line_text(start.line).unwrap_or_default();
269        let gutter_width = start.line.to_string().len();
270
271        let _ = write!(
272            rendered,
273            "\n --> {}:{}:{}\n{:>width$} |\n{:>width$} | {}\n{:>width$} | ",
274            source.name(),
275            start.line,
276            start.column,
277            "",
278            start.line,
279            line_text,
280            "",
281            width = gutter_width,
282        );
283
284        let underline_start = start.column.saturating_sub(1);
285        let underline_len = if start.line == end.line {
286            end.column.saturating_sub(start.column).max(1)
287        } else {
288            line_text
289                .chars()
290                .count()
291                .saturating_sub(underline_start)
292                .max(1)
293        };
294
295        rendered.push_str(&" ".repeat(underline_start));
296        rendered.push_str(&"^".repeat(underline_len));
297
298        if let Some(message) = &label.message {
299            rendered.push(' ');
300            rendered.push_str(message);
301        }
302
303        rendered
304    }
305}
306
307fn line_starts(text: &str) -> Vec<usize> {
308    let mut starts = vec![0];
309
310    for (index, byte) in text.bytes().enumerate() {
311        if byte == b'\n' {
312            starts.push(index + 1);
313        }
314    }
315
316    starts
317}
318
319fn trim_line_ending(line: &str) -> &str {
320    line.strip_suffix("\r\n")
321        .or_else(|| line.strip_suffix('\n'))
322        .unwrap_or(line)
323}
324
325#[cfg(test)]
326mod tests {
327    use super::{Diagnostic, Label, LineColumn, Severity, SourceFile, SourceId, Span};
328
329    #[test]
330    fn maps_offsets_to_line_and_column() {
331        let source = SourceFile::new(SourceId(1), "player.kn", "let hp = 10\nprint(hp)\n");
332
333        assert_eq!(source.line_column(0), LineColumn { line: 1, column: 1 });
334        assert_eq!(source.line_column(4), LineColumn { line: 1, column: 5 });
335        assert_eq!(source.line_column(12), LineColumn { line: 2, column: 1 });
336        assert_eq!(
337            source.line_column(usize::MAX),
338            LineColumn { line: 3, column: 1 }
339        );
340    }
341
342    #[test]
343    fn maps_unicode_columns_by_character() {
344        let source = SourceFile::new(
345            SourceId(1),
346            "unicode.kn",
347            "let name = \"Ari\"\nprint(\"é\")",
348        );
349
350        assert_eq!(source.line_column(24), LineColumn { line: 2, column: 8 });
351    }
352
353    #[test]
354    fn returns_line_text_without_newline() {
355        let source = SourceFile::new(SourceId(1), "lines.kn", "one\r\ntwo\nthree");
356
357        assert_eq!(source.line_count(), 3);
358        assert_eq!(source.line_text(1), Some("one"));
359        assert_eq!(source.line_text(2), Some("two"));
360        assert_eq!(source.line_text(3), Some("three"));
361        assert_eq!(source.line_text(4), None);
362    }
363
364    #[test]
365    fn creates_labeled_diagnostic() {
366        let span = Span::new(SourceId(1), 2, 5);
367        let diagnostic =
368            Diagnostic::error("bad token").with_label(Label::new(span, "unexpected input"));
369
370        assert_eq!(diagnostic.severity, Severity::Error);
371        assert_eq!(diagnostic.message, "bad token");
372        assert_eq!(diagnostic.labels.len(), 1);
373        assert_eq!(diagnostic.labels[0].span, span);
374    }
375
376    #[test]
377    fn renders_simple_source_snippet() {
378        let source = SourceFile::new(SourceId(7), "example.kn", "let hp = 10\nprint(hp)");
379        let diagnostic = Diagnostic::error("expected expression").with_label(Label::new(
380            Span::new(SourceId(7), 4, 6),
381            "while parsing initializer",
382        ));
383
384        assert_eq!(
385            diagnostic.render(&source),
386            "error: expected expression\n --> example.kn:1:5\n  |\n1 | let hp = 10\n  |     ^^ while parsing initializer"
387        );
388    }
389
390    #[test]
391    fn renders_without_snippet_when_source_does_not_match() {
392        let source = SourceFile::new(SourceId(1), "example.kn", "let hp = 10");
393        let diagnostic =
394            Diagnostic::warning("unused value").with_span(Span::new(SourceId(2), 0, 3));
395
396        assert_eq!(diagnostic.render(&source), "warning: unused value");
397    }
398}