1use std::fmt::{self, Write as _};
4
5#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
7pub struct SourceId(pub u32);
8
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
11pub struct LineColumn {
12 pub line: usize,
14 pub column: usize,
16}
17
18#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub struct Span {
21 pub source: SourceId,
23 pub start: usize,
25 pub end: usize,
27}
28
29impl Span {
30 #[must_use]
32 pub const fn new(source: SourceId, start: usize, end: usize) -> Self {
33 Self { source, start, end }
34 }
35
36 #[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 #[must_use]
52 pub const fn is_empty(self) -> bool {
53 self.start == self.end
54 }
55}
56
57#[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 #[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 #[must_use]
83 pub const fn id(&self) -> SourceId {
84 self.id
85 }
86
87 #[must_use]
89 pub fn name(&self) -> &str {
90 &self.name
91 }
92
93 #[must_use]
95 pub fn text(&self) -> &str {
96 &self.text
97 }
98
99 #[must_use]
101 pub fn line_count(&self) -> usize {
102 self.line_starts.len()
103 }
104
105 #[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 #[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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
149pub enum Severity {
150 Note,
152 Warning,
154 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#[derive(Clone, Debug, Eq, PartialEq)]
170pub struct Label {
171 pub span: Span,
173 pub message: Option<String>,
175}
176
177impl Label {
178 #[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 #[must_use]
189 pub const fn at(span: Span) -> Self {
190 Self {
191 span,
192 message: None,
193 }
194 }
195}
196
197#[derive(Clone, Debug, Eq, PartialEq)]
199pub struct Diagnostic {
200 pub severity: Severity,
202 pub message: String,
204 pub labels: Vec<Label>,
206}
207
208impl Diagnostic {
209 #[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 #[must_use]
221 pub fn error(message: impl Into<String>) -> Self {
222 Self::new(Severity::Error, message)
223 }
224
225 #[must_use]
227 pub fn warning(message: impl Into<String>) -> Self {
228 Self::new(Severity::Warning, message)
229 }
230
231 #[must_use]
233 pub fn note(message: impl Into<String>) -> Self {
234 Self::new(Severity::Note, message)
235 }
236
237 #[must_use]
239 pub fn with_label(mut self, label: Label) -> Self {
240 self.labels.push(label);
241 self
242 }
243
244 #[must_use]
246 pub fn with_span(self, span: Span) -> Self {
247 self.with_label(Label::at(span))
248 }
249
250 #[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}