Skip to main content

qframe/
diagnostics.rs

1//! Problems found while loading theme, icon, locale and keymap files.
2//!
3//! Loading never panics and never aborts on the first mistake: every problem becomes a
4//! [`Diagnostic`] that points at the file, line and column, the broken entry is skipped,
5//! and the rest of the file is still used.
6
7use std::fmt;
8
9/// A position inside a loaded file. Lines and columns start at 1.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct Location {
12    /// Display name of the file, e.g. `nordic.toml`.
13    pub file: String,
14    /// Line number, starting at 1.
15    pub line: usize,
16    /// Column number in characters, starting at 1.
17    pub column: usize,
18}
19
20impl Location {
21    /// Converts a byte offset in `text` into a line and column.
22    ///
23    /// Offsets past the end of `text` point at the end of the file, and offsets inside a
24    /// character at that character.
25    #[must_use]
26    pub fn from_offset(file: &str, text: &str, offset: usize) -> Self {
27        let before = &text[..text.floor_char_boundary(offset)];
28        let line = before.matches('\n').count() + 1;
29        let line_start = before.rfind('\n').map_or(0, |i| i + 1);
30        let column = before[line_start..].chars().count() + 1;
31        Self { file: file.to_owned(), line, column }
32    }
33}
34
35/// Where every line of a text starts, for turning many byte offsets of one file into locations
36/// without counting its lines again each time: a theme file has a location for every property.
37#[derive(Debug, Clone)]
38pub(crate) struct LineStarts(Vec<usize>);
39
40impl LineStarts {
41    pub(crate) fn new(text: &str) -> Self {
42        Self(std::iter::once(0).chain(text.match_indices('\n').map(|(index, _)| index + 1)).collect())
43    }
44
45    /// The same location as [`Location::from_offset`] gives for `text`, the text these line
46    /// starts were made from.
47    pub(crate) fn locate(&self, file: &str, text: &str, offset: usize) -> Location {
48        let offset = text.floor_char_boundary(offset);
49        // Line starts are sorted and the first is 0, so at least one is at or before `offset`.
50        let line = self.0.partition_point(|start| *start <= offset);
51        let line_start = self.0[line.saturating_sub(1)];
52        let column = text[line_start..offset].chars().count() + 1;
53        Location { file: file.to_owned(), line, column }
54    }
55}
56
57impl fmt::Display for Location {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        write!(f, "{}:{}:{}", self.file, self.line, self.column)
60    }
61}
62
63/// How serious a [`Diagnostic`] is.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum Severity {
66    /// The entry was ignored; the value it tried to set comes from elsewhere.
67    Error,
68    /// The entry was used, but it is probably not what the author wants.
69    Warning,
70}
71
72/// One problem found while loading a file.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct Diagnostic {
75    /// Error or warning.
76    pub severity: Severity,
77    /// Where the problem is, when it can be pinned to a place in a file.
78    pub location: Option<Location>,
79    /// What is wrong and, where possible, how to fix it.
80    pub message: String,
81}
82
83impl Diagnostic {
84    /// Creates an error.
85    #[must_use]
86    pub fn error(location: Option<Location>, message: impl Into<String>) -> Self {
87        Self { severity: Severity::Error, location, message: message.into() }
88    }
89
90    /// Creates a warning.
91    #[must_use]
92    pub fn warning(location: Option<Location>, message: impl Into<String>) -> Self {
93        Self { severity: Severity::Warning, location, message: message.into() }
94    }
95}
96
97impl fmt::Display for Diagnostic {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        let kind = match self.severity {
100            Severity::Error => "error",
101            Severity::Warning => "warning",
102        };
103        match &self.location {
104            Some(location) => write!(f, "{location}: {kind}: {}", self.message),
105            None => write!(f, "{kind}: {}", self.message),
106        }
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113
114    #[test]
115    fn offset_maps_to_line_and_column() {
116        let text = "a = 1\nbé = 2\n";
117        let at = Location::from_offset("x.toml", text, text.find('=').unwrap_or(0));
118        assert_eq!((at.line, at.column), (1, 3));
119        let second = text.rfind('=').unwrap_or(0);
120        let at = Location::from_offset("x.toml", text, second);
121        assert_eq!((at.line, at.column), (2, 4));
122        assert_eq!(at.to_string(), "x.toml:2:4");
123    }
124
125    #[test]
126    fn offset_inside_a_character_points_at_that_character() {
127        let at = Location::from_offset("x.toml", "a = 1\nbé = 2\n", 8);
128        assert_eq!((at.line, at.column), (2, 2));
129    }
130
131    #[test]
132    fn line_starts_locate_every_offset_like_from_offset() {
133        for text in
134            ["", "ab", "a = 1\nbé = 2\n", "\n\nx", "k = \"界\"\r\n\r\nv = 'é😀'\r\n# ü\n", "no newline at end é"]
135        {
136            let starts = LineStarts::new(text);
137            for offset in 0..=text.len() + 2 {
138                assert_eq!(
139                    starts.locate("x.toml", text, offset),
140                    Location::from_offset("x.toml", text, offset),
141                    "{text:?} at {offset}"
142                );
143            }
144        }
145    }
146
147    #[test]
148    fn offset_past_end_is_clamped() {
149        let at = Location::from_offset("x.toml", "ab", 99);
150        assert_eq!((at.line, at.column), (1, 3));
151    }
152
153    #[test]
154    fn display_includes_severity_and_location() {
155        let d = Diagnostic::error(Some(Location::from_offset("t.toml", "x", 0)), "bad");
156        assert_eq!(d.to_string(), "t.toml:1:1: error: bad");
157        assert_eq!(Diagnostic::warning(None, "hm").to_string(), "warning: hm");
158    }
159}