1use crate::form::Form;
37use crate::messages::{raise, Refusal};
38use crate::reader::read_forms;
39
40#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct Book {
44 pub name: String,
45 pub path: String,
46}
47
48#[derive(Clone, Debug, PartialEq, Eq)]
50pub struct Distro {
51 pub name: String,
52 pub title: String,
53 pub tagline: String,
54 pub opens_with: String,
55 pub palette: Vec<(String, String)>,
58 pub prelude: String,
59 pub base: Book,
60 pub overlay: Option<Book>,
61 pub dialects: Vec<Book>,
62 pub libraries: Vec<Book>,
63 pub examples: Option<String>,
64 pub addin: Option<String>,
65 pub readme: Option<String>,
66}
67
68impl Distro {
69 pub fn chain(&self) -> Vec<&Book> {
73 let mut chain = vec![&self.base];
74 if let Some(o) = &self.overlay {
75 chain.push(o);
76 }
77 chain
78 }
79
80 pub fn shade(&self, name: &str) -> Option<&str> {
82 self.palette
83 .iter()
84 .find(|(n, _)| n == name)
85 .map(|(_, v)| v.as_str())
86 }
87}
88
89pub const SHADES: [&str; 3] = ["lavender", "whisper", "deep"];
91
92fn invalid(label: &str, why: &str) -> Refusal {
93 raise("distro-manifest-invalid", &[("path", label), ("why", why)])
94}
95
96fn strings<'a>(
99 items: &'a [Form],
100 head: &str,
101 want: usize,
102 label: &str,
103) -> Result<Vec<&'a str>, Refusal> {
104 let mut out = Vec::with_capacity(want);
105 for f in items {
106 match f {
107 Form::Str(s) => out.push(s.as_str()),
108 _ => {
109 return Err(invalid(
110 label,
111 &format!("({head} ...) holds something that is not a quoted string"),
112 ))
113 }
114 }
115 }
116 if out.len() != want {
117 return Err(invalid(
118 label,
119 &format!(
120 "({head} ...) wants {want} quoted string{}, not {}",
121 if want == 1 { "" } else { "s" },
122 out.len()
123 ),
124 ));
125 }
126 Ok(out)
127}
128
129fn checked_path(path: &str, head: &str, label: &str) -> Result<String, Refusal> {
132 if path.is_empty() {
133 return Err(invalid(label, &format!("({head} ...) names an empty path")));
134 }
135 if path.contains('\\') {
136 return Err(invalid(
137 label,
138 &format!("({head} ...) writes a path with a backslash; a manifest's paths use forward slashes"),
139 ));
140 }
141 if path.starts_with('/') || path.chars().nth(1) == Some(':') {
142 return Err(invalid(
143 label,
144 &format!("({head} ...) names an absolute path; a manifest's paths are relative to its folder"),
145 ));
146 }
147 Ok(path.to_string())
148}
149
150fn is_name(s: &str) -> bool {
151 !s.is_empty()
152 && s.chars()
153 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
154}
155
156fn is_shade(s: &str) -> bool {
157 s.len() == 7 && s.starts_with('#') && s[1..].chars().all(|c| c.is_ascii_hexdigit())
158}
159
160pub fn parse(text: &str, label: &str) -> Result<Distro, Refusal> {
163 let forms = read_forms(text).map_err(|r| invalid(label, &r.text))?;
164 let mut tops = forms.iter().filter(|f| matches!(f, Form::List(_)));
165 let (Some(Form::List(top)), None) = (tops.next(), tops.next()) else {
166 return Err(invalid(
167 label,
168 "it must hold one (distro \"name\" ...) form and nothing else at the top level",
169 ));
170 };
171 if !top.head_is("distro") {
172 return Err(invalid(
173 label,
174 "its top-level form does not begin with distro",
175 ));
176 }
177 let Some(Form::Str(name)) = top.items.get(1) else {
178 return Err(invalid(
179 label,
180 "(distro ...) names no distro: a quoted name comes first",
181 ));
182 };
183 if !is_name(name) {
184 return Err(invalid(
185 label,
186 "a distro's name is lowercase letters, digits and hyphens, the folder's own name",
187 ));
188 }
189 let mut title = None;
190 let mut tagline = None;
191 let mut opens_with = None;
192 let mut palette: Option<Vec<(String, String)>> = None;
193 let mut prelude = None;
194 let mut base = None;
195 let mut overlay = None;
196 let mut dialects: Vec<Book> = Vec::new();
197 let mut libraries: Vec<Book> = Vec::new();
198 let mut examples = None;
199 let mut addin = None;
200 let mut readme = None;
201 let once = |given: bool, head: &str| -> Result<(), Refusal> {
202 if given {
203 return Err(invalid(label, &format!("({head} ...) is given twice")));
204 }
205 Ok(())
206 };
207 for item in &top.items[2..] {
208 let Form::List(l) = item else {
209 return Err(invalid(
210 label,
211 "every directive inside (distro ...) is a list",
212 ));
213 };
214 let Ok(head) = l.head_sym() else {
215 return Err(invalid(label, "a directive has no head"));
216 };
217 let rest = &l.items[1..];
218 match head.as_str() {
219 "title" => {
220 once(title.is_some(), "title")?;
221 title = Some(strings(rest, "title", 1, label)?[0].to_string());
222 }
223 "tagline" => {
224 once(tagline.is_some(), "tagline")?;
225 tagline = Some(strings(rest, "tagline", 1, label)?[0].to_string());
226 }
227 "opens-with" => {
228 once(opens_with.is_some(), "opens-with")?;
229 opens_with = Some(strings(rest, "opens-with", 1, label)?[0].to_string());
230 }
231 "palette" => {
232 once(palette.is_some(), "palette")?;
233 let mut shades = Vec::with_capacity(SHADES.len());
234 for (i, want) in SHADES.iter().enumerate() {
235 let Some(Form::List(p)) = rest.get(i) else {
236 return Err(invalid(
237 label,
238 &format!(
239 "(palette ...) wants ({want} \"#rrggbb\") in position {}",
240 i + 1
241 ),
242 ));
243 };
244 let Ok(n) = p.head_sym() else {
245 return Err(invalid(label, "a shade of the palette has no name"));
246 };
247 if n != *want {
248 return Err(invalid(
249 label,
250 &format!("(palette ...) names {n} where {want} belongs; the shades are lavender, whisper and deep, in that order"),
251 ));
252 }
253 let v = strings(&p.items[1..], want, 1, label)?[0];
254 if !is_shade(v) {
255 return Err(invalid(
256 label,
257 &format!("the shade {want} is {v}, not # and six hex digits"),
258 ));
259 }
260 shades.push((n, v.to_string()));
261 }
262 if rest.len() != SHADES.len() {
263 return Err(invalid(
264 label,
265 "(palette ...) holds more than its three shades",
266 ));
267 }
268 palette = Some(shades);
269 }
270 "prelude" => {
271 once(prelude.is_some(), "prelude")?;
272 let p = strings(rest, "prelude", 1, label)?[0];
273 prelude = Some(checked_path(p, "prelude", label)?);
274 }
275 "phrasebook" | "overlay" | "dialect" | "library" => {
276 let s = strings(rest, &head, 2, label)?;
277 if !is_name(s[0]) {
278 return Err(invalid(
279 label,
280 &format!("({head} ...) names {}, and a book's name is lowercase letters, digits and hyphens", s[0]),
281 ));
282 }
283 let book = Book {
284 name: s[0].to_string(),
285 path: checked_path(s[1], &head, label)?,
286 };
287 match head.as_str() {
288 "phrasebook" => {
289 once(base.is_some(), "phrasebook")?;
290 base = Some(book);
291 }
292 "overlay" => {
293 once(overlay.is_some(), "overlay")?;
294 overlay = Some(book);
295 }
296 "dialect" => {
297 if dialects.iter().any(|d| d.name == book.name) {
298 return Err(invalid(
299 label,
300 &format!("(dialect \"{}\" ...) is given twice", book.name),
301 ));
302 }
303 dialects.push(book);
304 }
305 _ => {
306 if libraries.iter().any(|d| d.name == book.name) {
307 return Err(invalid(
308 label,
309 &format!("(library \"{}\" ...) is given twice", book.name),
310 ));
311 }
312 libraries.push(book);
313 }
314 }
315 }
316 "examples" => {
317 once(examples.is_some(), "examples")?;
318 let p = strings(rest, "examples", 1, label)?[0];
319 examples = Some(checked_path(p, "examples", label)?);
320 }
321 "addin" => {
322 once(addin.is_some(), "addin")?;
323 let a = strings(rest, "addin", 1, label)?[0];
324 if a.is_empty() || a.contains('/') || a.contains('\\') {
325 return Err(invalid(
326 label,
327 "(addin ...) is a bare file name, the add-in's own",
328 ));
329 }
330 addin = Some(a.to_string());
331 }
332 "readme" => {
333 once(readme.is_some(), "readme")?;
334 let p = strings(rest, "readme", 1, label)?[0];
335 readme = Some(checked_path(p, "readme", label)?);
336 }
337 other => {
338 return Err(invalid(
339 label,
340 &format!("({other} ...) is not a directive this version knows"),
341 ));
342 }
343 }
344 }
345 let need = |what: &str| invalid(label, &format!("it names no ({what} ...)"));
346 let Some(title) = title else {
347 return Err(need("title"));
348 };
349 let Some(tagline) = tagline else {
350 return Err(need("tagline"));
351 };
352 let Some(opens_with) = opens_with else {
353 return Err(need("opens-with"));
354 };
355 let Some(palette) = palette else {
356 return Err(need("palette"));
357 };
358 let Some(prelude) = prelude else {
359 return Err(need("prelude"));
360 };
361 let Some(base) = base else {
362 return Err(need("phrasebook"));
363 };
364 Ok(Distro {
365 name: name.clone(),
366 title,
367 tagline,
368 opens_with,
369 palette,
370 prelude,
371 base,
372 overlay,
373 dialects,
374 libraries,
375 examples,
376 addin,
377 readme,
378 })
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 const ENGLISH: &str = include_str!("../../distros/english/distro.vla");
386 const ESPANOL: &str = include_str!("../../distros/espanol/distro.vla");
387
388 #[test]
389 fn the_english_distro_reads_as_the_doors_ship_it() {
390 let d = parse(ENGLISH, "distros/english/distro.vla").unwrap();
391 assert_eq!(d.name, "english");
392 assert_eq!(d.title, "Frazaro");
393 assert_eq!(d.tagline, "a phrasebook for your workbook");
394 assert_eq!(d.opens_with, "Set total to 5.");
395 assert_eq!(d.shade("lavender"), Some("#e2d8f6"));
396 assert_eq!(d.shade("whisper"), Some("#f7f4fc"));
397 assert_eq!(d.shade("deep"), Some("#6b4fc8"));
398 assert_eq!(d.shade("mauve"), None);
399 assert_eq!(d.prelude, "../../scripts/prelude.vla");
400 assert_eq!(d.base.name, "english");
401 assert_eq!(d.base.path, "../../scripts/polyglotta/english.vla");
402 assert!(d.overlay.is_none());
403 let names: Vec<&str> = d.dialects.iter().map(|b| b.name.as_str()).collect();
404 assert_eq!(
405 names,
406 [
407 "dansk",
408 "deutsche",
409 "espanol",
410 "esperanto",
411 "francais",
412 "latin",
413 "pirate"
414 ]
415 );
416 assert!(d
417 .dialects
418 .iter()
419 .all(|b| b.path == format!("../../scripts/polyglotta/{}.vla", b.name)));
420 assert_eq!(d.libraries.len(), 1);
421 assert_eq!(d.libraries[0].name, "alien");
422 assert_eq!(d.examples.as_deref(), Some("../../examples"));
423 assert_eq!(d.addin.as_deref(), Some("Frazaro_English.xlam"));
424 assert_eq!(d.readme.as_deref(), Some("README.md"));
425 assert_eq!(d.chain().len(), 1);
426 }
427
428 #[test]
429 fn the_espanol_distro_is_the_add_in_s_chain_english_then_the_overlay() {
430 let d = parse(ESPANOL, "distros/espanol/distro.vla").unwrap();
431 assert_eq!(d.name, "espanol");
432 let chain: Vec<&str> = d.chain().iter().map(|b| b.path.as_str()).collect();
433 assert_eq!(
434 chain,
435 [
436 "../../scripts/polyglotta/english.vla",
437 "../../scripts/polyglotta/espanol.vla"
438 ]
439 );
440 assert_eq!(d.addin.as_deref(), Some("Frazaro_Espanol.xlam"));
441 assert_eq!(d.dialects.len(), 7);
442 }
443
444 fn why(text: &str) -> String {
445 let r = parse(text, "x/distro.vla").unwrap_err();
446 assert_eq!(r.id, "distro-manifest-invalid");
447 assert_eq!(r.source, "VLA-Distro");
448 assert!(r.text.starts_with("x/distro.vla "), "{}", r.text);
449 r.text
450 }
451
452 const MINIMAL: &str = "(distro \"mine\"\n (title \"T\")\n (tagline \"t\")\n (opens-with \"Say hi.\")\n (palette (lavender \"#e2d8f6\") (whisper \"#f7f4fc\") (deep \"#6b4fc8\"))\n (prelude \"prelude.vla\")\n (phrasebook \"mine\" \"mine.vla\"))\n";
453
454 #[test]
455 fn a_minimal_manifest_parses_and_each_defect_is_named() {
456 let d = parse(MINIMAL, "x/distro.vla").unwrap();
457 assert_eq!(d.name, "mine");
458 assert!(d.dialects.is_empty() && d.libraries.is_empty());
459 assert!(d.examples.is_none() && d.addin.is_none() && d.readme.is_none());
460 assert!(parse(&format!("; a note\n{MINIMAL}"), "x/distro.vla").is_ok());
462 for (text, fragment) in [
463 ("", "one (distro \"name\" ...) form"),
464 (
465 &format!("{MINIMAL}(distro \"two\")"),
466 "one (distro \"name\" ...) form",
467 ),
468 ("(edition \"mine\")", "does not begin with distro"),
469 ("(distro mine)", "a quoted name comes first"),
470 ("(distro \"Mine\")", "lowercase letters, digits and hyphens"),
471 (
472 &MINIMAL.replace("(title \"T\")", "(title \"T\")\n (title \"U\")"),
473 "(title ...) is given twice",
474 ),
475 (
476 &MINIMAL.replace("(title \"T\")", "(title T)"),
477 "not a quoted string",
478 ),
479 (
480 &MINIMAL.replace("(tagline \"t\")", "(tagline \"t\" \"u\")"),
481 "(tagline ...) wants 1 quoted string, not 2",
482 ),
483 (
484 &MINIMAL.replace("(title \"T\")\n", ""),
485 "it names no (title ...)",
486 ),
487 (
488 &MINIMAL.replace("(prelude \"prelude.vla\")\n", ""),
489 "it names no (prelude ...)",
490 ),
491 (
492 &MINIMAL.replace(" (phrasebook \"mine\" \"mine.vla\")", ""),
493 "it names no (phrasebook ...)",
494 ),
495 (
496 &MINIMAL.replace("\"mine.vla\"", "\"..\\\\mine.vla\""),
497 "forward slashes",
498 ),
499 (
500 &MINIMAL.replace("\"mine.vla\"", "\"/srv/mine.vla\""),
501 "absolute path",
502 ),
503 (
504 &MINIMAL.replace("\"mine.vla\"", "\"C:/mine.vla\""),
505 "absolute path",
506 ),
507 (&MINIMAL.replace("\"mine.vla\"", "\"\""), "empty path"),
508 (
509 &MINIMAL.replace("(phrasebook \"mine\"", "(phrasebook \"Mine\""),
510 "a book's name is lowercase",
511 ),
512 (
513 &MINIMAL.replace("(deep \"#6b4fc8\")", "(deep \"6b4fc8\")"),
514 "not # and six hex digits",
515 ),
516 (
517 &MINIMAL.replace("(whisper \"#f7f4fc\")", "(pale \"#f7f4fc\")"),
518 "names pale where whisper belongs",
519 ),
520 (
521 &MINIMAL.replace(" (deep \"#6b4fc8\")", ""),
522 "(palette ...) wants (deep \"#rrggbb\") in position 3",
523 ),
524 (
525 &MINIMAL.replace("(deep \"#6b4fc8\")", "(deep \"#6b4fc8\") (ink \"#000000\")"),
526 "more than its three shades",
527 ),
528 (
529 &MINIMAL.replace("(prelude \"prelude.vla\")", "(prelude \"prelude.vla\")\n (dialect \"a\" \"a.vla\")\n (dialect \"a\" \"b.vla\")"),
530 "(dialect \"a\" ...) is given twice",
531 ),
532 (
533 &MINIMAL.replace("(prelude \"prelude.vla\")", "(prelude \"prelude.vla\")\n (addin \"out/x.xlam\")"),
534 "a bare file name",
535 ),
536 (
537 &MINIMAL.replace("(prelude \"prelude.vla\")", "(prelude \"prelude.vla\")\n (colour \"red\")"),
538 "(colour ...) is not a directive this version knows",
539 ),
540 (
541 &MINIMAL.replace("(prelude \"prelude.vla\")", "(prelude \"prelude.vla\")\n \"loose\""),
542 "every directive inside (distro ...) is a list",
543 ),
544 ("(distro \"mine\" (title \"T\"", "x/distro.vla "),
545 ] {
546 let text = why(text);
547 assert!(text.contains(fragment), "{text:?} should name {fragment:?}");
548 }
549 }
550}