1use crate::{
6 Spanned,
7 grammar::{CoreSchema, FmFormat, FrontmatterConstraint, Only, RuleCommon, States, squash},
8 text::{self, is_slug, is_url},
9};
10
11pub trait Host {
15 fn scalar_format(&self, name: &str, value: &str) -> Option<Result<(), String>>;
19
20 fn reserved_keys(&self) -> &[&str];
22}
23#[derive(Debug, Clone, PartialEq)]
28pub enum Event {
29 Rule {
32 rule: RuleCommon,
34 key: String,
36 line: Option<usize>,
38 criterion: &'static str,
40 message: String,
42 },
43 Error {
45 line: Option<usize>,
47 criterion: &'static str,
49 message: String,
51 },
52 Gate {
54 only: Only,
56 line: usize,
58 rule: usize,
60 },
61}
62struct Fm<'a> {
66 states: &'a States,
67 host: &'a dyn Host,
68 key: String,
70 events: Vec<Event>,
71}
72
73impl Fm<'_> {
75 fn emit(
77 &mut self,
78 rule: &RuleCommon,
79 line: Option<usize>,
80 criterion: &'static str,
81 message: impl Into<String>,
82 ) {
83 self.events.push(Event::Rule {
84 rule: rule.clone(),
85 key: self.key.clone(),
86 line,
87 criterion,
88 message: message.into(),
89 });
90 }
91
92 fn error(&mut self, line: Option<usize>, criterion: &'static str, message: impl Into<String>) {
94 self.events.push(Event::Error {
95 line,
96 criterion,
97 message: message.into(),
98 });
99 }
100
101 fn gate(&mut self, rule: &RuleCommon, line: usize) {
103 if rule.only.is_gated() {
104 self.events.push(Event::Gate {
105 only: rule.only.clone(),
106 line,
107 rule: rule.line,
108 });
109 }
110 }
111}
112
113pub fn check(
127 frontmatter: Option<&Spanned>,
128 schema: &CoreSchema,
129 states: &States,
130 host: &dyn Host,
131) -> Vec<Event> {
132 let mut c = Fm {
134 states,
135 host,
136 key: String::new(),
137 events: Vec::new(),
138 };
139 let Some(frontmatter) = frontmatter.filter(|f| f.as_map().is_some()) else {
140 return c.events;
141 };
142 let first_line = Some(frontmatter.line);
144 for (key, constraint) in &schema.frontmatter {
145 if !constraint.common.only.admits(c.states) {
147 continue;
148 }
149 let value = frontmatter.get(key);
150 c.key.clone_from(key);
154 match value {
155 None => {
156 if !constraint.optional {
157 c.emit(
158 &constraint.common,
159 first_line,
160 "TL_54",
161 format!("frontmatter key `{key}` is missing"),
162 );
163 }
164 }
165 Some(v) => {
166 c.gate(&constraint.common, v.line);
167 check_value(&mut c, key, constraint, v);
168 }
169 }
170 }
171 if !schema.frontmatter_open {
172 let reserved = c.host.reserved_keys();
174 let written = frontmatter.as_map().unwrap_or(&[]);
175 for (key, value) in written {
176 let key = &key.value;
177 let declared = schema.frontmatter.iter().any(|(k, _)| k == key)
178 || schema.lifecycle.iter().any(|d| &d.key == key)
179 || reserved.contains(&key.as_str());
180 if !declared {
181 c.error(
182 Some(value.line),
183 "TL_55",
184 format!("frontmatter key `{key}` is not declared"),
185 );
186 }
187 }
188 }
189 for dim in &schema.lifecycle {
190 match frontmatter.get(&dim.key) {
191 None => c.error(
192 first_line,
193 "TL_62",
194 format!("lifecycle key `{}` is missing", dim.key),
195 ),
196 Some(v) => {
197 let text = v.scalar_text().unwrap_or_default();
198 if !dim.states.contains(&text) {
200 c.error(
201 Some(v.line),
202 "TL_108",
203 format!(
204 "`{}` must be one of {}; `{text}` is not",
205 dim.key,
206 text::code_list(&dim.states, ", ")
207 ),
208 );
209 }
210 }
211 }
212 }
213 c.events
214}
215
216fn check_value(c: &mut Fm<'_>, key: &str, constraint: &FrontmatterConstraint, value: &Spanned) {
217 let common = &constraint.common;
218 if constraint.list {
219 let Some(items) = value.as_seq() else {
220 c.emit(
221 common,
222 Some(value.line),
223 "TL_57",
224 format!("`{key}` must be a list"),
225 );
226 return;
227 };
228 if let Some(min) = constraint.min
229 && items.len() < min
230 {
231 c.emit(
233 common,
234 Some(value.line),
235 "TL_106",
236 format!(
237 "`{key}` needs at least {}; it has {}",
238 text::count(min, "entry", "entries"),
239 items.len()
240 ),
241 );
242 }
243 if let Some(max) = constraint.max
244 && items.len() > max
245 {
246 c.emit(
248 common,
249 Some(value.line),
250 "TL_107",
251 format!(
252 "`{key}` allows at most {}; it has {}",
253 text::count(max, "entry", "entries"),
254 items.len()
255 ),
256 );
257 }
258 for item in items {
259 check_scalar(c, key, constraint, item);
260 }
261 } else {
262 if value.as_seq().is_some() {
263 c.emit(
264 common,
265 Some(value.line),
266 "TL_57",
267 format!("`{key}` must be a scalar, not a list"),
268 );
269 return;
270 }
271 check_scalar(c, key, constraint, value);
272 }
273}
274
275fn check_scalar(c: &mut Fm<'_>, key: &str, constraint: &FrontmatterConstraint, value: &Spanned) {
276 let common = &constraint.common;
277 let line = Some(value.line);
278 let Some(text) = value.scalar_text() else {
279 c.emit(common, line, "TL_57", format!("`{key}` must be a scalar"));
280 return;
281 };
282 if let Some(expected) = &constraint.const_
283 && &text != expected
284 {
285 c.emit(
286 common,
287 line,
288 "TL_56",
289 format!("`{key}` must be `{expected}`; it is `{text}`"),
290 );
291 }
292 let squashed = squash(&text);
295 if let Some(shape) = &constraint.shape
296 && constraint.id_template.is_none()
297 && !shape.matches(&squashed, |_| None)
298 {
299 c.emit(
300 common,
301 line,
302 "TL_56",
303 format!(
304 "`{key}` `{text}` {}{}",
305 shape.failure(&squashed),
306 constraint
307 .example
308 .as_deref()
309 .map_or_else(String::new, |e| format!("; a value of this kind reads {e}"))
310 ),
311 );
312 }
313 if let Some(allowed) = &constraint.enum_
314 && !allowed.contains(&text)
315 {
316 c.emit(
317 common,
318 line,
319 "TL_56",
320 format!(
321 "`{key}` must be one of {}; `{text}` is not",
322 text::code_list(allowed, ", ")
323 ),
324 );
325 }
326 match &constraint.format {
327 Some(FmFormat::Host(name)) => {
330 if let Some(Err(failure)) = c.host.scalar_format(name, &text) {
333 c.emit(common, line, "TL_56", format!("`{key}` {failure}"));
334 }
335 }
336 Some(FmFormat::Url) if !is_url(&text) => c.emit(
337 common,
338 line,
339 "TL_56",
340 format!("`{key}` must be an absolute URL; `{text}` is not"),
341 ),
342 Some(FmFormat::Slug) if !is_slug(&text) => c.emit(
343 common,
344 line,
345 "TL_56",
346 format!("`{key}` must be a slug; `{text}` is not"),
347 ),
348 Some(FmFormat::Url | FmFormat::Slug) | None => {}
349 }
350 if let Some(template) = &constraint.id_template
352 && constraint.const_.is_none()
353 {
354 if !template.matches(&text) {
355 c.emit(
356 common,
357 line,
358 "TL_56",
359 format!(
360 "id `{text}` does not match the template `{}`",
361 template.source
362 ),
363 );
364 }
365 for (name, held) in template.keys_of(&text) {
368 let Some(state) = c.states.0.get(&name).and_then(|s| s.first()) else {
369 continue;
370 };
371 if &held != state {
372 c.emit(
373 common,
374 line,
375 "TL_59",
376 format!("id `{text}` holds `{held}` for `{name}`, but `{name}` is `{state}`"),
377 );
378 }
379 }
380 }
381}