denise_forms/error.rs
1//! What went wrong, and where in the file.
2//!
3//! A form is something a person typed, so every failure here carries a line, a
4//! column, and — where there is a finite set of right answers — the whole set. A
5//! misspelled property names the property, the widget, and everything that widget
6//! *does* accept; there is no error in this crate that leaves somebody grepping.
7
8use std::fmt;
9
10use denise_ui::widgets::Property;
11
12/// Where in the source something is, counted from one as an editor counts.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub struct At {
15 /// Line, from 1.
16 pub line: usize,
17 /// Column, from 1.
18 pub column: usize,
19}
20
21impl At {
22 /// The position of a byte offset in `source`.
23 ///
24 /// Computed on demand rather than carried around: the parser hands back byte
25 /// spans, and a form that loads without complaint should not have paid for
26 /// counting newlines.
27 ///
28 /// ```
29 /// # use denise_forms::At;
30 /// let source = "form \"F\" version=1\n label \"æøå\" x=0\n";
31 ///
32 /// assert_eq!(At::of(source, 0), At { line: 1, column: 1 });
33 /// // Characters, not bytes, so a column points where a caret would be even
34 /// // on a line with an `æ` in it.
35 /// assert_eq!(At::of(source, source.len() - 1).line, 2);
36 /// ```
37 pub fn of(source: &str, offset: usize) -> Self {
38 let offset = offset.min(source.len());
39 let consumed = &source[..offset];
40 let line = consumed.bytes().filter(|&b| b == b'\n').count() + 1;
41 let column = consumed
42 .rfind('\n')
43 .map_or(offset, |nl| offset - nl - 1)
44 // Count characters rather than bytes, so a column in a line with an
45 // `æ` in it points where the editor's cursor would be.
46 .min(consumed.len());
47 let column = consumed[consumed.len() - column..].chars().count() + 1;
48 Self { line, column }
49 }
50
51 /// The start of the file, for a complaint about the file as a whole.
52 pub const START: Self = Self { line: 1, column: 1 };
53}
54
55impl fmt::Display for At {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 write!(f, "{}:{}", self.line, self.column)
58 }
59}
60
61/// Everything that can be wrong with a form file.
62#[derive(Clone, Debug, PartialEq)]
63#[non_exhaustive]
64pub enum Reason {
65 /// The file is not KDL at all.
66 Syntax(String),
67 /// The top level is not a single `form` node.
68 NotAForm {
69 /// What was found there instead.
70 found: String,
71 },
72 /// `version` is missing, or is not a number.
73 Version,
74 /// The file was written for a later version of this crate.
75 FromTheFuture {
76 /// What the file asks for.
77 wanted: u64,
78 /// The highest this crate understands.
79 understood: u64,
80 },
81 /// A required property is not there.
82 Missing {
83 /// The node's kind.
84 kind: String,
85 /// What was needed.
86 name: &'static str,
87 },
88 /// No widget goes by that name.
89 UnknownWidget {
90 /// What the file said.
91 found: String,
92 },
93 /// The widget has no such property.
94 UnknownProperty {
95 /// The node's kind.
96 kind: &'static str,
97 /// What the file said.
98 found: String,
99 /// Everything that widget does accept.
100 accepted: &'static [Property],
101 },
102 /// A property the `form` node does not carry — often one that belongs to a
103 /// different kind of form.
104 ///
105 /// Separate from [`Reason::UnknownProperty`] because what a form accepts
106 /// depends on its kind, so the list has to be built rather than pointed at.
107 UnknownFormProperty {
108 /// What kind of form it said it was.
109 kind: &'static str,
110 /// What the file said.
111 found: String,
112 /// Everything a form of that kind does accept.
113 accepted: Vec<&'static str>,
114 },
115 /// The property exists; the value was not the shape it takes.
116 WrongType {
117 /// The node's kind.
118 kind: &'static str,
119 /// The property.
120 name: String,
121 /// What it takes, in words.
122 wanted: &'static str,
123 },
124 /// A name that should have been one of a fixed set was not.
125 NotAName {
126 /// The property, or the thing being named.
127 name: String,
128 /// What the file said.
129 found: String,
130 /// Every name that would have worked.
131 accepted: &'static [&'static str],
132 },
133 /// A child node that the parent has no use for.
134 UnexpectedChild {
135 /// The parent's kind.
136 parent: String,
137 /// The child's kind.
138 found: String,
139 },
140 /// A designer's placeholder content written where the engine would load it.
141 ///
142 /// A `table`'s `row`s and a `timeline`'s `event`s belong in that node's
143 /// `design { … }` block, where every build but a designer's skips them. Left
144 /// outside, they would ship to a kiosk — so this is an error rather than a
145 /// thing quietly ignored, for the same reason an unknown property is.
146 PlaceholderOutside {
147 /// The widget's kind.
148 kind: String,
149 /// The child node's name.
150 found: String,
151 },
152 /// One message name is used with two different payload shapes, so no single
153 /// enum variant can serve both.
154 ///
155 /// Only [`codegen`](crate::codegen) raises this. The engine is happy to
156 /// resolve one name twice, because the application's `match` answers each
157 /// call separately; a generated enum cannot, because `Greet` is either a
158 /// variant or a `fn(bool) -> M` and not both.
159 PayloadClash {
160 /// The name used twice.
161 found: String,
162 /// What it was first seen as.
163 first: &'static str,
164 /// And then as.
165 then: &'static str,
166 },
167 /// A name in the file cannot be turned into a Rust identifier.
168 ///
169 /// Only [`codegen`](crate::codegen). A form loads perfectly well with a node
170 /// called `2`; a struct field cannot be called that.
171 NotAnIdentifier {
172 /// What the file called it.
173 found: String,
174 /// Why it will not do.
175 because: &'static str,
176 },
177 /// Two names in the file become one Rust identifier.
178 ///
179 /// Only [`codegen`](crate::codegen). `full-name` and `full_name` are two
180 /// nodes to a form file and one field to Rust.
181 Collides {
182 /// The second name to arrive.
183 found: String,
184 /// The one already there.
185 with: String,
186 /// What they both became.
187 spelled: String,
188 },
189 /// The application's resolver did not know a message name.
190 UnknownMessage {
191 /// The name in the file.
192 found: String,
193 },
194 /// The resolver knew the name but gave back the wrong shape of message.
195 WrongMessage {
196 /// The name in the file.
197 found: String,
198 /// What the widget needs.
199 wanted: &'static str,
200 },
201 /// A picture the application's loader would not load.
202 Asset {
203 /// The path in the file.
204 path: String,
205 },
206 /// Two nodes claim the same name.
207 DuplicateName {
208 /// The name.
209 name: String,
210 },
211 /// More than one node asked for the caret.
212 TwoFocuses,
213 /// The tree refused a node, which it does when its parent is gone.
214 TreeRefused,
215 /// The file nests deeper than this crate will follow.
216 TooDeep {
217 /// The limit.
218 limit: usize,
219 },
220 /// The file is larger than this crate will read.
221 TooLarge {
222 /// The limit, in bytes.
223 limit: usize,
224 },
225 /// A brace has no partner.
226 ///
227 /// Refused by the same byte scan that counts depth, and for the same
228 /// reason: a file whose braces do not balance cannot parse whatever the
229 /// parser does with it, and `kdl` can spend an unbounded amount of time
230 /// discovering that. Saying it up front costs one pass and gives a better
231 /// position than the recovery would.
232 Unbalanced {
233 /// `true` for a `{` that is never closed, `false` for a `}` that
234 /// closes nothing.
235 open: bool,
236 },
237 /// The file nests commented-out children blocks past what the parser can
238 /// be asked to read.
239 ///
240 /// `kdl` takes time that doubles with every commented-out block nested
241 /// inside another: a hundred bytes of them is twenty seconds, and three
242 /// hundred is longer than anyone will wait. So this is refused by a byte
243 /// scan before the parser is handed the file at all — the same treatment,
244 /// and for the same reason, as nesting past
245 /// [`MAX_DEPTH`](crate::MAX_DEPTH), which overflows its stack. See
246 /// [`MAX_COMMENTED_DEPTH`](crate::MAX_COMMENTED_DEPTH).
247 CommentedTooDeep {
248 /// The limit, in levels.
249 limit: usize,
250 },
251 /// The parse was still running when the caller's deadline passed, so it
252 /// was abandoned and the file was not read.
253 ///
254 /// Only [`Form::parse_within`](crate::Form::parse_within) raises this, and
255 /// only a caller who asked for a deadline can get it. It says nothing about
256 /// the file beyond how long it was taking: a hostile one that has found a
257 /// corner of `kdl` that takes exponential time looks exactly like a
258 /// legitimate one on a machine that is too slow for the number chosen. The
259 /// position is the top of the file, because nothing in it has been read.
260 TooSlow {
261 /// The time the caller allowed.
262 limit: core::time::Duration,
263 },
264 /// A bounded parse could not be started, so the file was not read at all.
265 ///
266 /// [`Form::parse_within`](crate::Form::parse_within) works on a thread it
267 /// can walk away from, and this says there was no such thread to be had:
268 /// either [`MAX_ABANDONED`](crate::MAX_ABANDONED) earlier parses are still
269 /// running past their deadlines — a wedged thread each, and the point of
270 /// the limit is that a machine does not fill up with them — or the system
271 /// refused a thread outright.
272 NoThread {
273 /// How many earlier parses are still running past their deadline.
274 abandoned: usize,
275 },
276 /// The parser could not keep the file byte-for-byte.
277 ///
278 /// Everything this crate does — undo, the designer's save, a text editor
279 /// alongside — stands on [`Form::text`](crate::Form::text) reproducing what
280 /// was opened, and a file that cannot be reproduced would silently lose
281 /// bytes on the first save. Refusing it is the honest alternative.
282 ///
283 /// The known causes are all one thing — kdl dropping the trivia between a
284 /// closing brace and the next node, whether that is trailing whitespace or
285 /// a comment written on the brace's line — and all of it is put back before
286 /// this is ever raised. So reaching this means a shape nobody has seen yet,
287 /// which is what the fuzz target `parse_form` is hunting for.
288 NotPreserved,
289 /// An edit named a node that is not there.
290 NoSuchNode {
291 /// The child path that went nowhere.
292 path: Vec<usize>,
293 },
294 /// A move would have put a node inside itself.
295 ///
296 /// Or would have moved the form, which is the document rather than a node in
297 /// it. Both are the same mistake — a tree cannot contain its own root — and
298 /// both come from a drag that ended where it should not have been allowed to.
299 IntoItself {
300 /// The node the move named.
301 path: Vec<usize>,
302 },
303 /// An edit set the positional argument of a node written without one.
304 ///
305 /// An argument comes before every property, and no edit puts something at
306 /// the front of a line without rewriting the line. Setting the property
307 /// that argument stands for is what to do instead.
308 NoArgument,
309 /// An edit would have put a number where a string lives, or the reverse.
310 ///
311 /// Not a matter of reversibility — an edit's inverse restores the text that
312 /// was there, so any value can be put back. It is a matter of what the file
313 /// would become: `placeholder=70` parses and then will not build, and an
314 /// editor holding the widget's own descriptor already knows better. So the
315 /// door refuses it rather than the loader, three steps later.
316 ///
317 /// A number replacing a number is not this, whichever way it is written:
318 /// `value=70` becoming `value=70.5` is an ordinary edit.
319 WrongKind {
320 /// The property.
321 name: String,
322 /// What it holds now.
323 holds: &'static str,
324 /// What the edit offered.
325 given: &'static str,
326 },
327}
328
329/// A failure, and where in the file it is.
330#[derive(Clone, Debug, PartialEq)]
331pub struct Error {
332 /// Where.
333 pub at: At,
334 /// What.
335 pub reason: Reason,
336}
337
338impl Error {
339 pub(crate) const fn new(at: At, reason: Reason) -> Self {
340 Self { at, reason }
341 }
342}
343
344/// Renders a list, Oxford-comma-free and bounded, for an "expected one of" line.
345fn listing(names: impl Iterator<Item = impl fmt::Display>) -> String {
346 let mut all: Vec<String> = names.map(|n| n.to_string()).collect();
347 all.sort_unstable();
348 // A widget with thirty properties should not print thirty of them at the top
349 // of a diagnostic; the point is to jog a memory, not to be documentation.
350 const SHOWN: usize = 12;
351 if all.len() > SHOWN {
352 let rest = all.len() - SHOWN;
353 all.truncate(SHOWN);
354 format!("{}, and {rest} more", all.join(", "))
355 } else {
356 all.join(", ")
357 }
358}
359
360impl fmt::Display for Error {
361 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
362 write!(f, "{}: ", self.at)?;
363 match &self.reason {
364 Reason::Syntax(message) => write!(f, "{message}"),
365 Reason::NotAForm { found } => write!(
366 f,
367 "a form file holds one `form` node and nothing else; found `{found}`"
368 ),
369 Reason::Version => write!(
370 f,
371 "`form` needs a `version`, as a whole number — this crate reads version {}",
372 crate::VERSION
373 ),
374 Reason::FromTheFuture { wanted, understood } => write!(
375 f,
376 "this file is version {wanted} and this crate reads version {understood}; \
377 a newer denise-forms will open it"
378 ),
379 Reason::Missing { kind, name } => {
380 write!(f, "`{kind}` needs a `{name}` and there is none")
381 }
382 Reason::UnknownWidget { found } => {
383 let kinds = listing(denise_ui::widgets::all().iter().map(|w| w.kind));
384 write!(f, "there is no widget called `{found}`; there is {kinds}")
385 }
386 Reason::UnknownProperty {
387 kind,
388 found,
389 accepted,
390 } => {
391 let names = listing(accepted.iter().map(|p| p.name));
392 write!(f, "`{kind}` has no property `{found}`; it accepts {names}")
393 }
394 Reason::UnknownFormProperty {
395 kind,
396 found,
397 accepted,
398 } => {
399 let names = listing(accepted.iter());
400 write!(
401 f,
402 "a `{kind}` form has no property `{found}`; it accepts {names}"
403 )
404 }
405 Reason::WrongType { kind, name, wanted } => {
406 write!(f, "`{name}` on `{kind}` takes {wanted}")
407 }
408 Reason::NotAName {
409 name,
410 found,
411 accepted,
412 } => {
413 let names = listing(accepted.iter());
414 write!(f, "`{found}` is not a {name}; try {names}")
415 }
416 Reason::UnexpectedChild { parent, found } => {
417 write!(f, "a `{parent}` has no use for a `{found}` inside it")
418 }
419 Reason::PayloadClash { found, first, then } => write!(
420 f,
421 "`{found}` is used as {first} and as {then}; one name cannot generate both"
422 ),
423 Reason::NotAnIdentifier { found, because } => {
424 write!(f, "`{found}` cannot name anything in Rust: {because}")
425 }
426 Reason::Collides {
427 found,
428 with,
429 spelled,
430 } => write!(f, "`{found}` and `{with}` are both `{spelled}` in Rust"),
431 Reason::UnknownMessage { found } => write!(
432 f,
433 "the application does not know a message called `{found}`"
434 ),
435 Reason::WrongMessage { found, wanted } => write!(
436 f,
437 "`{found}` resolved to the wrong kind of message; this one needs {wanted}"
438 ),
439 Reason::Asset { path } => {
440 write!(f, "the application could not load `{path}`")
441 }
442 Reason::DuplicateName { name } => {
443 write!(f, "two nodes are called `{name}`; a name identifies one")
444 }
445 Reason::TwoFocuses => write!(
446 f,
447 "two nodes ask for the caret with `focus=#true`; only one can have it"
448 ),
449 Reason::TreeRefused => write!(f, "the tree would not take this node"),
450 Reason::TooDeep { limit } => write!(
451 f,
452 "this form nests more than {limit} deep, which is past what a \
453 form is and into what a stack overflow is"
454 ),
455 Reason::PlaceholderOutside { kind, found } => write!(
456 f,
457 "a `{found}` is placeholder content, so it belongs in this \
458 {kind}'s `design {{ … }}` block; written here it would be \
459 loaded on a panel that has its own"
460 ),
461 Reason::Unbalanced { open } => {
462 if *open {
463 write!(f, "this `{{` is never closed")
464 } else {
465 write!(f, "this `}}` closes nothing")
466 }
467 }
468 Reason::CommentedTooDeep { limit } => write!(
469 f,
470 "this nests commented-out blocks more than {limit} deep, and \
471 every level of that doubles what reading the file costs"
472 ),
473 Reason::TooSlow { limit } => write!(
474 f,
475 "this form was taking longer than {limit:?} to parse, so it \
476 was abandoned unread"
477 ),
478 Reason::NoThread { abandoned } => {
479 if *abandoned >= crate::MAX_ABANDONED {
480 write!(
481 f,
482 "{abandoned} earlier parses are still running past \
483 their deadline and cannot be stopped, so this form \
484 was not started; restart to clear them"
485 )
486 } else {
487 write!(
488 f,
489 "this form needs a thread of its own to be parsed \
490 under a deadline, and the system would not give it one"
491 )
492 }
493 }
494 Reason::NotPreserved => write!(
495 f,
496 "the parser cannot keep this file byte-for-byte, so saving it \
497 would corrupt it; the difference starts here"
498 ),
499 Reason::TooLarge { limit } => {
500 write!(
501 f,
502 "a form file is at most {limit} bytes; this one is larger"
503 )
504 }
505 Reason::NoSuchNode { path } => write!(f, "there is no node at {path:?}"),
506 Reason::IntoItself { path } => {
507 write!(f, "a node cannot go inside itself; {path:?} was asked to")
508 }
509 Reason::NoArgument => write!(
510 f,
511 "this node is written without an argument; set the property instead"
512 ),
513 Reason::WrongKind { name, holds, given } => write!(
514 f,
515 "`{name}` holds {holds}; putting {given} there would make a form that will not load"
516 ),
517 }
518 }
519}
520
521impl std::error::Error for Error {}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526
527 #[test]
528 fn a_position_counts_from_one_the_way_an_editor_does() {
529 let source = "abc\ndef\n";
530 assert_eq!(At::of(source, 0), At { line: 1, column: 1 });
531 assert_eq!(At::of(source, 2), At { line: 1, column: 3 });
532 assert_eq!(At::of(source, 4), At { line: 2, column: 1 });
533 assert_eq!(At::of(source, 6), At { line: 2, column: 3 });
534 }
535
536 #[test]
537 fn a_column_counts_characters_rather_than_bytes() {
538 // Four bytes before the `x`, but two characters.
539 let source = "æø x";
540 assert_eq!(At::of(source, 5), At { line: 1, column: 4 });
541 }
542
543 #[test]
544 fn a_position_past_the_end_lands_at_the_end() {
545 let source = "ab";
546 assert_eq!(At::of(source, 99), At { line: 1, column: 3 });
547 }
548
549 #[test]
550 fn a_long_list_of_names_is_cut_short_rather_than_dumped() {
551 let many: Vec<String> = (0..40).map(|n| format!("name{n:02}")).collect();
552 let rendered = listing(many.iter());
553 assert!(rendered.contains("and 28 more"), "{rendered}");
554 assert!(rendered.len() < 200, "{rendered}");
555 }
556}