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}