Skip to main content

pfx_editor_toml/
diag.rs

1use std::ops::Range;
2use std::path::{Path, PathBuf};
3
4use toml_edit::{DocumentMut, TomlError};
5
6pub const PARSE: &str = "parse";
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
9pub enum Severity {
10    Error,
11    Warning,
12}
13
14#[derive(Clone, Debug, PartialEq, Eq, Hash)]
15pub struct Location {
16    pub file: PathBuf,
17    pub line: u32,
18    pub column: u32,
19}
20
21#[derive(Clone, Debug, PartialEq, Eq, Hash)]
22pub struct Diagnostic {
23    pub file: PathBuf,
24    pub line: u32,
25    pub column: u32,
26    pub end_line: u32,
27    pub end_column: u32,
28    pub severity: Severity,
29    pub code: String,
30    pub key: String,
31    pub message: String,
32    pub related: Vec<Location>,
33}
34
35pub trait Check {
36    fn check(&self, file: &Path, text: &str, doc: &DocumentMut) -> Vec<Diagnostic>;
37}
38
39pub struct NoCheck;
40
41impl Check for NoCheck {
42    fn check(&self, _: &Path, _: &str, _: &DocumentMut) -> Vec<Diagnostic> {
43        Vec::new()
44    }
45}
46
47impl<F: Fn(&Path, &str, &DocumentMut) -> Vec<Diagnostic>> Check for F {
48    fn check(&self, file: &Path, text: &str, doc: &DocumentMut) -> Vec<Diagnostic> {
49        self(file, text, doc)
50    }
51}
52
53fn floor(text: &str, byte: usize) -> usize {
54    let mut byte = byte.min(text.len());
55    while !text.is_char_boundary(byte) {
56        byte -= 1;
57    }
58    byte
59}
60
61pub fn position(text: &str, byte: usize) -> (u32, u32) {
62    let byte = floor(text, byte);
63    let before = &text[..byte];
64    let line_start = before.rfind('\n').map_or(0, |at| at + 1);
65    let line = before.bytes().filter(|b| *b == b'\n').count() + 1;
66    let column = before[line_start..].chars().count() + 1;
67    (line as u32, column as u32)
68}
69
70pub fn offset(text: &str, line: u32, column: u32) -> usize {
71    let mut start = 0;
72    for _ in 1..line.max(1) {
73        match text[start..].find('\n') {
74            Some(at) => start += at + 1,
75            None => return text.len(),
76        }
77    }
78    let end = text[start..].find('\n').map_or(text.len(), |at| start + at);
79    text[start..end]
80        .char_indices()
81        .nth(column.max(1) as usize - 1)
82        .map_or(end, |(at, _)| start + at)
83}
84
85pub fn parse(file: &Path, text: &str) -> Result<DocumentMut, Box<Diagnostic>> {
86    text.parse::<DocumentMut>()
87        .map_err(|error| Box::new(Diagnostic::parse(file, text, &error)))
88}
89
90impl Diagnostic {
91    pub fn at(
92        file: impl Into<PathBuf>,
93        text: &str,
94        range: Range<usize>,
95        severity: Severity,
96        code: impl Into<String>,
97        message: impl Into<String>,
98    ) -> Diagnostic {
99        let (line, column) = position(text, range.start);
100        let (end_line, end_column) = position(text, range.end.max(range.start));
101        Diagnostic {
102            file: file.into(),
103            line,
104            column,
105            end_line,
106            end_column,
107            severity,
108            code: code.into(),
109            key: String::new(),
110            message: message.into(),
111            related: Vec::new(),
112        }
113    }
114
115    pub fn parse(file: &Path, text: &str, error: &TomlError) -> Diagnostic {
116        let range = error.span().unwrap_or(0..0);
117        Diagnostic::at(
118            file,
119            text,
120            range,
121            Severity::Error,
122            PARSE,
123            error.message().trim(),
124        )
125    }
126
127    pub fn start(&self, text: &str) -> usize {
128        offset(text, self.line, self.column)
129    }
130
131    pub fn span(&self, text: &str) -> Range<usize> {
132        let start = self.start(text);
133        let end = offset(text, self.end_line, self.end_column).max(start);
134        if start < end {
135            return start..end;
136        }
137        match text[start..].chars().next() {
138            Some(next) if next != '\n' => start..start + next.len_utf8(),
139            _ => match text[..start].chars().next_back() {
140                Some(previous) if previous != '\n' => start - previous.len_utf8()..start,
141                _ => start..start,
142            },
143        }
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    const FILE: &str = "scene.toml";
152
153    #[test]
154    fn positions_count_lines_and_characters_from_one() {
155        let text = "a = \"héllo wörld\"\nb = 'ünï' # ✓\n";
156        assert_eq!(position(text, 0), (1, 1));
157        let b = text.find('b').unwrap();
158        assert_eq!(position(text, b), (2, 1));
159        let tick = text.find('✓').unwrap();
160        assert_eq!(position(text, tick), (2, 13));
161        assert_eq!(position(text, tick + 1), (2, 13));
162        assert_eq!(position(text, text.len()), (3, 1));
163        for byte in 0..=text.len() {
164            if text.is_char_boundary(byte) {
165                let (line, column) = position(text, byte);
166                assert_eq!(offset(text, line, column), byte, "{byte}");
167            }
168        }
169        assert_eq!(offset(text, 1, 99), text.find('\n').unwrap());
170        assert_eq!(offset(text, 9, 1), text.len());
171    }
172
173    #[test]
174    fn a_parse_error_lands_on_its_line_and_character_column() {
175        let text = "name = \"héllo wörld\"\n[obj]\nbad = = 1\n";
176        let error = parse(Path::new(FILE), text).unwrap_err();
177        assert_eq!(error.severity, Severity::Error);
178        assert_eq!(error.code, PARSE);
179        assert_eq!(error.file, Path::new(FILE));
180        assert_eq!((error.line, error.column), (3, 7));
181        assert!(!error.message.is_empty());
182        let span = error.span(text);
183        assert_eq!(&text[span], "=");
184    }
185
186    #[test]
187    fn a_parse_error_after_wide_characters_counts_characters() {
188        let text = "ключ = \"значение\" junk\n";
189        let error = parse(Path::new(FILE), text).unwrap_err();
190        assert_eq!(error.line, 1);
191        assert_eq!(error.column, 19);
192        let span = error.span(text);
193        assert!(text[span.clone()].starts_with('j'), "{:?}", &text[span]);
194    }
195
196    #[test]
197    fn an_app_diagnostic_maps_back_to_its_bytes() {
198        let text = "[obj]\nnäme = \"ä\"\ncolor = [1, 2]\n";
199        let diagnostic = Diagnostic {
200            file: FILE.into(),
201            line: 3,
202            column: 9,
203            end_line: 3,
204            end_column: 15,
205            severity: Severity::Warning,
206            code: "short-color".into(),
207            key: "obj.color".into(),
208            message: "a colour has three channels".into(),
209            related: Vec::new(),
210        };
211        assert_eq!(&text[diagnostic.span(text)], "[1, 2]");
212        let wide = Diagnostic {
213            line: 2,
214            column: 2,
215            end_line: 2,
216            end_column: 3,
217            ..diagnostic.clone()
218        };
219        assert_eq!(&text[wide.span(text)], "ä");
220        let range = text.find("\"ä\"").unwrap()..text.find("\"ä\"").unwrap() + "\"ä\"".len();
221        let made = Diagnostic::at(FILE, text, range.clone(), Severity::Error, "x", "y");
222        assert_eq!((made.line, made.column, made.end_column), (2, 8, 11));
223        assert_eq!(made.span(text), range);
224    }
225
226    #[test]
227    fn an_empty_span_widens_to_one_character() {
228        let text = "a = \nb = 1";
229        let at = |line, column| Diagnostic {
230            line,
231            column,
232            end_line: line,
233            end_column: column,
234            ..Diagnostic::at(FILE, text, 0..0, Severity::Error, PARSE, "")
235        };
236        assert_eq!(&text[at(1, 1).span(text)], "a");
237        assert_eq!(&text[at(1, 5).span(text)], " ");
238        assert_eq!(&text[at(2, 6).span(text)], "1");
239        assert_eq!(at(1, 1).span(""), 0..0);
240    }
241
242    #[test]
243    fn untouched_text_round_trips_byte_for_byte() {
244        let text = "# a scene\n\n[object.lamp]   # the lamp\nname   =   \"lamp\"\nat = [ 0.0,1.5 ,  -2 ]\n\n[[light]] \n  color = { r = 1,g=0 }\ndoc = '''\nraw\n'''\n";
245        let doc = parse(Path::new(FILE), text).unwrap();
246        assert_eq!(doc.to_string(), text);
247    }
248}