mdbook_quiz_validate/
lib.rs

1//! Validation logic for types in [`mdbook_quiz_schema`].
2
3#![warn(missing_docs)]
4
5use std::{
6  cell::RefCell,
7  collections::HashSet,
8  fmt,
9  path::{Path, PathBuf},
10  sync::{Arc, Mutex},
11};
12
13use mdbook_quiz_schema::*;
14use miette::{
15  Diagnostic, EyreContext, LabeledSpan, MietteHandler, NamedSource, Result, SourceSpan, miette,
16};
17use thiserror::Error;
18
19pub use spellcheck::register_more_words;
20pub use toml_spanned_value::SpannedValue;
21
22mod impls;
23mod spellcheck;
24
25#[derive(Default)]
26struct ValidatedInner {
27  ids: HashSet<String>,
28  paths: HashSet<PathBuf>,
29}
30
31#[derive(Default, Clone)]
32/// A thread-safe mutable set of already-validated identifiers and paths.
33pub struct Validated(Arc<Mutex<ValidatedInner>>);
34
35struct QuizDiagnostic {
36  error: miette::Error,
37  fatal: bool,
38}
39
40pub(crate) struct ValidationContext {
41  diagnostics: RefCell<Vec<QuizDiagnostic>>,
42  path: PathBuf,
43  contents: String,
44  validated: Validated,
45  spellcheck: bool,
46}
47
48impl ValidationContext {
49  pub fn new(path: &Path, contents: &str, validated: Validated, spellcheck: bool) -> Self {
50    ValidationContext {
51      diagnostics: Default::default(),
52      path: path.to_owned(),
53      contents: contents.to_owned(),
54      validated,
55      spellcheck,
56    }
57  }
58
59  pub fn add_diagnostic(&mut self, err: impl Into<miette::Error>, fatal: bool) {
60    self.diagnostics.borrow_mut().push(QuizDiagnostic {
61      error: err.into(),
62      fatal,
63    });
64  }
65
66  pub fn error(&mut self, err: impl Into<miette::Error>) {
67    self.add_diagnostic(err, true);
68  }
69
70  pub fn warning(&mut self, err: impl Into<miette::Error>) {
71    self.add_diagnostic(err, false);
72  }
73
74  pub fn check(&mut self, f: impl FnOnce() -> Result<()>) {
75    if let Err(res) = f() {
76      self.error(res);
77    }
78  }
79
80  pub fn check_id(&mut self, id: &str, value: &SpannedValue) {
81    let new_id = self.validated.0.lock().unwrap().ids.insert(id.to_string());
82    if !new_id {
83      self.error(miette!(
84        labels = vec![value.labeled_span()],
85        "Duplicate ID: {id}"
86      ));
87    }
88  }
89
90  pub fn contents(&self) -> &str {
91    &self.contents
92  }
93}
94
95impl fmt::Debug for ValidationContext {
96  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97    let handler = MietteHandler::default();
98    for diagnostic in self.diagnostics.borrow_mut().drain(..) {
99      let src = NamedSource::new(self.path.to_string_lossy(), self.contents.clone());
100      let report = diagnostic.error.with_source_code(src);
101      handler.debug(report.as_ref(), f)?;
102    }
103    Ok(())
104  }
105}
106
107macro_rules! cxensure {
108  ($cx:expr, $($rest:tt)*) => {{
109    $cx.check(|| {
110      miette::ensure!($($rest)*);
111      Ok(())
112    });
113  }};
114}
115
116macro_rules! tomlcast {
117  ($e:ident) => { $e };
118  ($e:ident .table $($rest:tt)*) => {{
119    let _t = $e.get_ref().as_table().unwrap();
120    tomlcast!(_t $($rest)*)
121  }};
122  ($e:ident .array $($rest:tt)*) => {{
123    let _t = $e.get_ref().as_array().unwrap();
124    tomlcast!(_t $($rest)*)
125  }};
126  ($e:ident [$s:literal] $($rest:tt)*) => {{
127    let _t = $e.get($s).unwrap();
128    tomlcast!(_t $($rest)*)
129  }}
130}
131
132pub(crate) use {cxensure, tomlcast};
133
134pub(crate) trait Validate {
135  fn validate(&self, cx: &mut ValidationContext, value: &SpannedValue);
136}
137
138pub(crate) trait SpannedValueExt {
139  fn labeled_span(&self) -> LabeledSpan;
140}
141
142impl SpannedValueExt for SpannedValue {
143  fn labeled_span(&self) -> LabeledSpan {
144    let span = self.start()..self.end();
145    LabeledSpan::new_with_span(None, span)
146  }
147}
148
149#[derive(Error, Diagnostic, Debug)]
150#[error("TOML parse error: {cause}")]
151struct ParseError {
152  cause: String,
153
154  #[label]
155  span: Option<SourceSpan>,
156}
157
158/// Runs validation on a quiz with TOML-format `contents` at `path` under the ID set `ids`.
159pub fn validate(
160  path: &Path,
161  contents: &str,
162  validated: &Validated,
163  spellcheck: bool,
164) -> anyhow::Result<()> {
165  let not_checked = validated.0.lock().unwrap().paths.insert(path.to_path_buf());
166  if !not_checked {
167    return Ok(());
168  }
169
170  let mut cx = ValidationContext::new(path, contents, validated.clone(), spellcheck);
171
172  let parse_result = toml::from_str::<Quiz>(contents);
173  match parse_result {
174    Ok(quiz) => {
175      let value: SpannedValue = toml::from_str(contents)?;
176      quiz.validate(&mut cx, &value)
177    }
178    Err(parse_err) => {
179      let error = ParseError {
180        cause: format!("{parse_err}"),
181        span: None,
182      };
183      cx.error(error);
184    }
185  }
186
187  let has_diagnostic = !cx.diagnostics.borrow().is_empty();
188  let is_fatal = cx.diagnostics.borrow().iter().any(|d| d.fatal);
189
190  if has_diagnostic {
191    eprintln!("{cx:?}");
192  }
193
194  anyhow::ensure!(!is_fatal, "Quiz failed to validate: {}", path.display());
195
196  Ok(())
197}
198
199#[cfg(test)]
200pub(crate) mod test {
201  use super::*;
202
203  pub(crate) fn harness(contents: &str) -> anyhow::Result<()> {
204    validate(Path::new("dummy.rs"), contents, &Validated::default(), true)
205  }
206
207  #[test]
208  fn validate_twice() -> anyhow::Result<()> {
209    let contents = r#"
210[[questions]]
211id = "foobar"
212type = "MultipleChoice"
213prompt.prompt = ""
214answer.answer = ""
215prompt.distractors = [""]
216"#;
217    let validated = Validated::default();
218    validate(Path::new("dummy.rs"), contents, &validated, true)?;
219    validate(Path::new("dummy.rs"), contents, &validated, true)?;
220    Ok(())
221  }
222
223  #[test]
224  fn validate_parse_error() {
225    let contents = r#"
226[[questions]]
227type = "MultipleChoice
228prompt.prompt = ""
229answer.answer = ""
230prompt.distractors = [""]
231    "#;
232    assert!(harness(contents).is_err());
233  }
234}