Skip to main content

cove_diag/
lib.rs

1//! Source positions and compiler diagnostics shared by every Cove component.
2//!
3//! ADR 0001 treats diagnostics as part of the learning interface: an error
4//! states the Cove rule it enforces, points at the source, and shows a textual
5//! correction when one is unambiguous.
6
7use std::fmt;
8use std::path::{Path, PathBuf};
9
10/// Identifies one source file inside a [`SourceMap`].
11#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
12pub struct FileId(pub u32);
13
14/// A half-open byte range `[start, end)` inside a single file.
15#[derive(Clone, Copy, PartialEq, Eq, Debug)]
16pub struct Span {
17    pub file: FileId,
18    pub start: u32,
19    pub end: u32,
20}
21
22impl Span {
23    pub fn new(file: FileId, start: u32, end: u32) -> Self {
24        Span { file, start, end }
25    }
26
27    /// The smallest span covering both `self` and `other`.
28    pub fn to(self, other: Span) -> Span {
29        debug_assert_eq!(self.file, other.file);
30        Span {
31            file: self.file,
32            start: self.start.min(other.start),
33            end: self.end.max(other.end),
34        }
35    }
36}
37
38/// A value paired with the source range it came from.
39#[derive(Clone, Copy, PartialEq, Eq, Debug)]
40pub struct Spanned<T> {
41    pub node: T,
42    pub span: Span,
43}
44
45impl<T> Spanned<T> {
46    pub fn new(node: T, span: Span) -> Self {
47        Spanned { node, span }
48    }
49}
50
51/// One loaded source file.
52pub struct SourceFile {
53    pub id: FileId,
54    pub path: PathBuf,
55    pub text: String,
56    /// Byte offset of the start of each line.
57    line_starts: Vec<u32>,
58}
59
60impl SourceFile {
61    /// 1-based line and column (in characters) for a byte offset.
62    pub fn line_col(&self, offset: u32) -> (usize, usize) {
63        let line = match self.line_starts.binary_search(&offset) {
64            Ok(i) => i,
65            Err(i) => i - 1,
66        };
67        let line_start = self.line_starts[line] as usize;
68        let col = self.text[line_start..offset as usize].chars().count() + 1;
69        (line + 1, col)
70    }
71
72    /// Text of the 1-based `line`, without its terminator.
73    pub fn line_text(&self, line: usize) -> &str {
74        let start = self.line_starts[line - 1] as usize;
75        let end = self
76            .line_starts
77            .get(line)
78            .map(|&e| e as usize)
79            .unwrap_or(self.text.len());
80        self.text[start..end].trim_end_matches(['\n', '\r'])
81    }
82}
83
84/// Owns every source file the compiler has read.
85#[derive(Default)]
86pub struct SourceMap {
87    files: Vec<SourceFile>,
88}
89
90impl SourceMap {
91    pub fn new() -> Self {
92        SourceMap::default()
93    }
94
95    pub fn add(&mut self, path: impl Into<PathBuf>, text: impl Into<String>) -> FileId {
96        let id = FileId(self.files.len() as u32);
97        let text = text.into();
98        let mut line_starts = vec![0u32];
99        for (i, b) in text.bytes().enumerate() {
100            if b == b'\n' {
101                line_starts.push(i as u32 + 1);
102            }
103        }
104        self.files.push(SourceFile {
105            id,
106            path: path.into(),
107            text,
108            line_starts,
109        });
110        id
111    }
112
113    pub fn get(&self, id: FileId) -> &SourceFile {
114        &self.files[id.0 as usize]
115    }
116
117    pub fn path(&self, id: FileId) -> &Path {
118        &self.files[id.0 as usize].path
119    }
120
121    pub fn files(&self) -> impl Iterator<Item = &SourceFile> {
122        self.files.iter()
123    }
124}
125
126/// How seriously a diagnostic should be taken.
127///
128/// The three are ordered by what they ask of a reader. An error is a program
129/// the toolchain refuses. A warning is a program it accepts and doubts, so
130/// `cove check --deny-warnings` refuses it instead. A note is a program it
131/// accepts and does not doubt: the compiler is saying what it deliberately
132/// did not prove, which is a fact about the language rather than a suspicion
133/// about this program, so no strictness setting turns one into a failure.
134#[derive(Clone, Copy, PartialEq, Eq, Debug)]
135pub enum Severity {
136    Error,
137    Warning,
138    Note,
139}
140
141impl fmt::Display for Severity {
142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
143        match self {
144            Severity::Error => f.write_str("error"),
145            Severity::Warning => f.write_str("warning"),
146            Severity::Note => f.write_str("note"),
147        }
148    }
149}
150
151/// A secondary source location that explains the primary one.
152#[derive(Clone, Debug)]
153pub struct Label {
154    pub span: Span,
155    pub message: String,
156}
157
158/// A compiler or runtime message about a specific place in source.
159#[derive(Clone, Debug)]
160pub struct Diagnostic {
161    pub severity: Severity,
162    /// Stable identifier such as `cove::parse::unexpected_token`.
163    pub code: String,
164    pub message: String,
165    pub primary: Option<Span>,
166    pub labels: Vec<Label>,
167    /// The Cove rule this diagnostic enforces, in one sentence.
168    pub rule: Option<String>,
169    /// An unambiguous textual correction, when one exists.
170    pub help: Option<String>,
171}
172
173impl Diagnostic {
174    pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
175        Diagnostic {
176            severity: Severity::Error,
177            code: code.into(),
178            message: message.into(),
179            primary: None,
180            labels: Vec::new(),
181            rule: None,
182            help: None,
183        }
184    }
185
186    pub fn warning(code: impl Into<String>, message: impl Into<String>) -> Self {
187        Diagnostic {
188            severity: Severity::Warning,
189            ..Diagnostic::error(code, message)
190        }
191    }
192
193    /// Something the toolchain deliberately did not check, said out loud.
194    ///
195    /// A note is not a complaint. It reports a place where a rule of the
196    /// language, or a promise a schema made, leaves the compiler nothing to
197    /// prove, so that a run which succeeds still says which of its silences
198    /// were chosen ones.
199    pub fn note(code: impl Into<String>, message: impl Into<String>) -> Self {
200        Diagnostic {
201            severity: Severity::Note,
202            ..Diagnostic::error(code, message)
203        }
204    }
205
206    pub fn at(mut self, span: Span) -> Self {
207        self.primary = Some(span);
208        self
209    }
210
211    pub fn label(mut self, span: Span, message: impl Into<String>) -> Self {
212        self.labels.push(Label {
213            span,
214            message: message.into(),
215        });
216        self
217    }
218
219    pub fn rule(mut self, rule: impl Into<String>) -> Self {
220        self.rule = Some(rule.into());
221        self
222    }
223
224    pub fn help(mut self, help: impl Into<String>) -> Self {
225        self.help = Some(help.into());
226        self
227    }
228}
229
230/// Collects diagnostics produced by one compilation.
231#[derive(Default)]
232pub struct Diagnostics {
233    items: Vec<Diagnostic>,
234}
235
236impl Diagnostics {
237    pub fn new() -> Self {
238        Diagnostics::default()
239    }
240
241    pub fn push(&mut self, diagnostic: Diagnostic) {
242        self.items.push(diagnostic);
243    }
244
245    pub fn extend(&mut self, other: impl IntoIterator<Item = Diagnostic>) {
246        self.items.extend(other);
247    }
248
249    pub fn has_errors(&self) -> bool {
250        self.items.iter().any(|d| d.severity == Severity::Error)
251    }
252
253    pub fn is_empty(&self) -> bool {
254        self.items.is_empty()
255    }
256
257    pub fn iter(&self) -> impl Iterator<Item = &Diagnostic> {
258        self.items.iter()
259    }
260
261    pub fn into_vec(self) -> Vec<Diagnostic> {
262        self.items
263    }
264}
265
266/// Renders a diagnostic as plain text with a source excerpt.
267pub fn render(sources: &SourceMap, diagnostic: &Diagnostic) -> String {
268    let mut out = format!(
269        "{}[{}]: {}\n",
270        diagnostic.severity, diagnostic.code, diagnostic.message
271    );
272
273    if let Some(span) = diagnostic.primary {
274        out.push_str(&render_span(sources, span, None));
275    }
276    for label in &diagnostic.labels {
277        out.push_str(&render_span(sources, label.span, Some(&label.message)));
278    }
279    if let Some(rule) = &diagnostic.rule {
280        out.push_str(&format!("  rule: {rule}\n"));
281    }
282    if let Some(help) = &diagnostic.help {
283        for (i, line) in help.lines().enumerate() {
284            if i == 0 {
285                out.push_str(&format!("  help: {line}\n"));
286            } else {
287                out.push_str(&format!("        {line}\n"));
288            }
289        }
290    }
291    out
292}
293
294fn render_span(sources: &SourceMap, span: Span, message: Option<&str>) -> String {
295    let file = sources.get(span.file);
296    let (line, col) = file.line_col(span.start);
297    let text = file.line_text(line);
298    let gutter = line.to_string();
299    let pad = " ".repeat(gutter.len());
300    let width = {
301        let (end_line, end_col) = file.line_col(span.end);
302        if end_line == line {
303            (end_col - col).max(1)
304        } else {
305            text.chars().count().saturating_sub(col - 1).max(1)
306        }
307    };
308
309    let mut out = format!("{pad}--> {}:{line}:{col}\n", file.path.display());
310    out.push_str(&format!("{pad} |\n"));
311    out.push_str(&format!("{gutter} | {text}\n"));
312    out.push_str(&format!(
313        "{pad} | {}{}{}\n",
314        " ".repeat(col - 1),
315        "^".repeat(width),
316        match message {
317            Some(m) => format!(" {m}"),
318            None => String::new(),
319        }
320    ));
321    out
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    #[test]
329    fn line_col_counts_from_one() {
330        let mut map = SourceMap::new();
331        let id = map.add("a.cove", "let x = 1\nlet y = 2\n");
332        let file = map.get(id);
333        assert_eq!(file.line_col(0), (1, 1));
334        assert_eq!(file.line_col(10), (2, 1));
335        assert_eq!(file.line_text(2), "let y = 2");
336    }
337
338    #[test]
339    fn render_points_at_the_span() {
340        let mut map = SourceMap::new();
341        let id = map.add("a.cove", "let x = 1\n");
342        let d = Diagnostic::error("cove::test::demo", "example")
343            .at(Span::new(id, 4, 5))
344            .rule("`let` creates a read-only place.");
345        let text = render(&map, &d);
346        assert!(text.contains("error[cove::test::demo]: example"));
347        assert!(text.contains("a.cove:1:5"));
348        assert!(text.contains("^"));
349    }
350}