mdbook_quiz_validate/
lib.rs1#![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)]
32pub 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
158pub 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}