Skip to main content

frazaro_core/
distro.rs

1//! The distro (KERNEL.2, 2026-10-07): the folder the build tools take, read
2//! from its manifest. The design is `web/CALLOSUM.md` ยง14.1: a distro is a
3//! folder of phrasebooks, libraries, examples, a palette, an edition's
4//! chrome, a README and the lines a new program opens with, baked by the
5//! existing build tools into one page, one add-in and one command-line
6//! door; the registry is a repository the user clones, never fetched
7//! (`SD-13`), and the build stamp's hashes are the lockfile.
8//!
9//! The manifest is `distro.vla` in the folder, one `(distro "name" ...)`
10//! form of directives, each a path relative to the folder with forward
11//! slashes, so that the first distro, `distros/english`, names the corpus
12//! files where they are and nothing moves. This module reads the text into
13//! a [`Distro`] and refuses a malformed one by name
14//! (`distro-manifest-invalid`, what is wrong in the `why` slot); it reads no
15//! file and resolves no path, which is a door's business. The page builder
16//! (`tools/build_web.ps1`) and the add-in builder (`src/VLA_Build.bas`)
17//! read the same file one directive a line, which `tools/check_distro.ps1`
18//! holds every manifest to.
19//!
20//! ```text
21//! (distro "english"
22//!   (title "Frazaro")                       the page's title, the heading's first word
23//!   (tagline "a phrasebook for your workbook")
24//!   (opens-with "Set total to 5.")          the sentence a new program's first row offers
25//!   (palette (lavender "#e2d8f6") (whisper "#f7f4fc") (deep "#6b4fc8"))
26//!   (prelude "../../scripts/prelude.vla")
27//!   (phrasebook "english" "../../scripts/polyglotta/english.vla")   the base, loaded first
28//!   (overlay "espanol" "...")                what an edition loads after the base; at most one
29//!   (dialect "pirate" "...")                 the language picker's alternatives, each over the base
30//!   (library "alien" "...")                  a macro library a program includes; listed, not proved
31//!   (examples "../../examples")
32//!   (addin "Frazaro_English.xlam")           the add-in's file name
33//!   (readme "README.md"))
34//! ```
35
36use crate::form::Form;
37use crate::messages::{raise, Refusal};
38use crate::reader::read_forms;
39
40/// A phrasebook or library the manifest names: its name, and its path as
41/// written, relative to the folder.
42#[derive(Clone, Debug, PartialEq, Eq)]
43pub struct Book {
44    pub name: String,
45    pub path: String,
46}
47
48/// A distro, as its manifest describes it.
49#[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    /// The three shades, in the order the manifest gives them: `lavender`,
56    /// `whisper` and `deep`, each `#` and six hex digits.
57    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    /// The phrasebooks an edition loads, in order: the base, then the
70    /// overlay when there is one. What the add-in embeds and what a door
71    /// loads with no `--phrasebook`.
72    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    /// A shade of the palette by its name.
81    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
89/// The names the palette must give, in order.
90pub 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
96/// A directive's items after its head, each a string, or the refusal
97/// naming the directive and the count it wanted.
98fn 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
129/// A path as a manifest writes one: relative to the folder, forward
130/// slashes, not empty.
131fn 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
160/// The manifest's text as a [`Distro`], or the refusal that names what is
161/// wrong with it; `label` is what the refusal calls the file.
162pub 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        // Comments are skipped, as everywhere in the language.
461        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}