Skip to main content

vla_lang/
messages.rs

1//! The message catalogue and the refusal: INTRINSICS.md's sixth entry.
2//!
3//! Every refusal in the reference is `VLA_Messages.RaiseMsg "<id>", slot,
4//! value, ...` (SD-2): the id names a catalogue entry holding the VBA error
5//! number, the `Err.Source` tag and an English template whose `{slot}`s the
6//! call fills. The treaty's fifth oracle holds every implementation to the
7//! same id in the same situation, so the language refuses from the same
8//! catalogue, read as data. Since PORT.12 the catalogue is in two halves,
9//! both exported from `src/VLA_Messages.bas` by `tools/export_messages.ps1`
10//! and held to it by `tools/check_data_exports.ps1`: this crate embeds the
11//! language's own families (`data/messages.vla`: `vla`, `interp`, `lint`,
12//! `view`, since KERNEL.7 `calc`, and since KERNEL.22 `grid`, the
13//! machine's), and `frazaro-core` embeds the rest
14//! and asks this catalogue after its own. The VBA is the source of both.
15//!
16//! A refusal here is a value, not a raise: [`Refusal`], carried in a
17//! `Result`. [`raise`] builds one exactly as `RaiseMsg` builds its text,
18//! `SubstituteSlots` included. The two internal errors `RaiseMsg` can meet,
19//! an unknown id and a slot left unfilled, are refusals too, with the VBA's
20//! own words, so that nothing on this path can panic. A [`Catalogue`] is
21//! the mechanism over any such text, so that a crate above this one holds
22//! a catalogue of its own and falls through to the language's.
23
24use std::sync::OnceLock;
25
26use crate::form::Form;
27use crate::reader::read_forms;
28
29/// One refusal, finished: the id that named it, the VBA error number and
30/// `Err.Source` the catalogue gives it, and the text with its slots filled.
31#[derive(Clone, Debug, PartialEq)]
32pub struct Refusal {
33    pub id: String,
34    pub number: i64,
35    pub source: String,
36    pub text: String,
37}
38
39impl Refusal {
40    /// The reference appends to a caught refusal's text on its way out
41    /// (`VlaTranspile`'s `emitfail`: ` (near vla line N)`); this is that.
42    pub fn with_suffix(mut self, suffix: &str) -> Refusal {
43        self.text.push_str(suffix);
44        self
45    }
46}
47
48impl std::fmt::Display for Refusal {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.write_str(&self.text)
51    }
52}
53
54struct Entry {
55    id: String,
56    number: i64,
57    source: String,
58    template: String,
59}
60
61/// A catalogue: the entries of one `messages.vla` text, parsed once.
62pub struct Catalogue {
63    entries: Vec<Entry>,
64}
65
66impl Catalogue {
67    /// The entries of an exported catalogue text, in its order; a form that
68    /// is not a well-formed `(message ...)` is skipped, as the reference's
69    /// loader would never have registered it.
70    pub fn parse(text: &str) -> Catalogue {
71        // The file is CRLF on a Windows checkout and LF elsewhere; a template
72        // that holds a line break must read the same on both.
73        let text = text.replace("\r\n", "\n");
74        let forms = match read_forms(&text) {
75            Ok(forms) => forms,
76            Err(_) => {
77                return Catalogue {
78                    entries: Vec::new(),
79                }
80            }
81        };
82        let mut entries = Vec::with_capacity(forms.len());
83        for form in &forms {
84            let Form::List(l) = form else { continue };
85            if !l.head_is("message") || l.items.len() != 5 {
86                continue;
87            }
88            let (Form::Sym(id), Form::Sym(number), Form::Str(source), Form::Str(template)) =
89                (&l.items[1], &l.items[2], &l.items[3], &l.items[4])
90            else {
91                continue;
92            };
93            let Ok(number) = number.parse::<i64>() else {
94                continue;
95            };
96            entries.push(Entry {
97                id: id.clone(),
98                number,
99                source: source.clone(),
100                template: template.clone(),
101            });
102        }
103        Catalogue { entries }
104    }
105
106    /// How many entries the catalogue holds.
107    pub fn count(&self) -> usize {
108        self.entries.len()
109    }
110
111    /// Every id, in the catalogue's order.
112    pub fn ids(&self) -> Vec<&str> {
113        self.entries.iter().map(|e| e.id.as_str()).collect()
114    }
115
116    /// The template of an id, unfilled, or `None` for an id the catalogue
117    /// does not know.
118    pub fn template(&self, id: &str) -> Option<&str> {
119        self.entries
120            .iter()
121            .find(|e| e.id == id)
122            .map(|e| e.template.as_str())
123    }
124
125    /// VBA `RaiseMsg` over this catalogue: the entry's number and source,
126    /// and its template with each `{slot}` replaced by the value given for
127    /// that name; a slot left unfilled is the reference's internal error.
128    /// `None` for an id this catalogue does not hold, so that a caller can
129    /// ask another catalogue before it gives up.
130    pub fn raise(&self, id: &str, slots: &[(&str, &str)]) -> Option<Refusal> {
131        let entry = self.entries.iter().find(|e| e.id == id)?;
132        Some(match substitute_slots(&entry.template, slots) {
133            Ok(text) => Refusal {
134                id: id.to_string(),
135                number: entry.number,
136                source: entry.source.clone(),
137                text,
138            },
139            Err(slot) => internal(
140                id,
141                &format!("RaiseMsg: no value supplied for slot '{{{slot}}}'"),
142            ),
143        })
144    }
145}
146
147/// The language's half of the exported catalogue, embedded at build time:
148/// no file is read when the crate runs (the wasm import section stays
149/// empty).
150const CATALOGUE_TEXT: &str = include_str!("../data/messages.vla");
151
152/// The language's catalogue, parsed once.
153pub fn catalogue() -> &'static Catalogue {
154    static CATALOGUE: OnceLock<Catalogue> = OnceLock::new();
155    CATALOGUE.get_or_init(|| Catalogue::parse(CATALOGUE_TEXT))
156}
157
158/// How many entries the language's catalogue holds.
159pub fn count() -> usize {
160    catalogue().count()
161}
162
163/// The template of an id, unfilled, or `None` for an id the language's
164/// catalogue does not know.
165pub fn template(id: &str) -> Option<&'static str> {
166    catalogue().template(id)
167}
168
169/// Every id of the language's catalogue, in the order the VBA registers them.
170pub fn ids() -> Vec<&'static str> {
171    catalogue().ids()
172}
173
174/// VBA `RaiseMsg` over the language's catalogue: the entry's number and
175/// source, and its template with each `{slot}` replaced by the value given
176/// for that name; an id the catalogue does not know is the reference's
177/// internal error, [`unknown`].
178pub fn raise(id: &str, slots: &[(&str, &str)]) -> Refusal {
179    catalogue().raise(id, slots).unwrap_or_else(|| unknown(id))
180}
181
182/// The reference's internal error for an id no catalogue holds: `Err.Raise
183/// 5, "VLA-Messages", ...`; here it keeps the id asked for, so a test can
184/// still see it.
185pub fn unknown(id: &str) -> Refusal {
186    internal(id, &format!("RaiseMsg: unknown message id '{id}'"))
187}
188
189/// The reference's two internal errors are `Err.Raise 5, "VLA-Messages",
190/// ...`; here they keep the id asked for, so a test can still see it.
191fn internal(id: &str, text: &str) -> Refusal {
192    Refusal {
193        id: id.to_string(),
194        number: 5,
195        source: "VLA-Messages".to_string(),
196        text: text.to_string(),
197    }
198}
199
200/// VBA `SubstituteSlots`: scan for `{`, take the name up to the next `}`,
201/// substitute its value; a `{` with no `}` after it ends the scan with the
202/// rest copied as it stands. A name with no value is the error.
203fn substitute_slots(template: &str, slots: &[(&str, &str)]) -> Result<String, String> {
204    let mut out = String::with_capacity(template.len());
205    let mut rest = template;
206    loop {
207        let Some(open) = rest.find('{') else {
208            out.push_str(rest);
209            return Ok(out);
210        };
211        let Some(close_rel) = rest[open..].find('}') else {
212            out.push_str(rest);
213            return Ok(out);
214        };
215        let close = open + close_rel;
216        out.push_str(&rest[..open]);
217        let name = &rest[open + 1..close];
218        match slots.iter().find(|(k, _)| *k == name) {
219            Some((_, v)) => out.push_str(v),
220            None => return Err(name.to_string()),
221        }
222        rest = &rest[close + 1..];
223    }
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229
230    #[test]
231    fn the_language_s_catalogue_is_read_whole() {
232        // 176 entries at the split of 2026-10-08 (PORT.12): the vla, interp,
233        // lint and view families; tools/check_data_exports.ps1 holds the
234        // same floor on the file.
235        assert!(count() >= 176, "{} entries read", count());
236        assert_eq!(ids()[0], "vla-source-not-found");
237        assert_eq!(
238            template("vla-unbalanced-parens").unwrap(),
239            "unbalanced parentheses: missing ')'{loc}"
240        );
241        assert!(template("view-sheet-unknown").is_some());
242        // The English engine's ids are the core's half, not this one's.
243        assert!(template("english-test-failed").is_none());
244        // A named error number, resolved by the exporter from VLA.bas.
245        let r = raise(
246            "vla-interpreter-only-handler",
247            &[("name", "on:sheet-change")],
248        );
249        assert_eq!(r.number, -2147221504 + 7001);
250        assert_eq!(r.source, "VLA");
251        // Every id in the half belongs to one of the language's families.
252        for id in ids() {
253            let family = id.split('-').next().unwrap();
254            assert!(
255                ["vla", "interp", "lint", "view", "calc", "grid"].contains(&family),
256                "{id} is not the language's"
257            );
258        }
259        // The machine's family (KERNEL.22): eleven grid ids, a twelfth since
260        // KERNEL.47 (`grid-format-code`), and the view's and recalculation's
261        // own the machine adds.
262        assert_eq!(
263            ids().iter().filter(|id| id.starts_with("grid-")).count(),
264            12
265        );
266        // A row's line is the answer's own field (KERNEL.23): no grid text
267        // says it, so a write of one row never reads "Line 1".
268        for id in ids().iter().filter(|id| id.starts_with("grid-")) {
269            let text = template(id).unwrap();
270            assert!(!text.contains("{line}"), "{id}: {text}");
271        }
272        for id in [
273            "view-projection-unknown",
274            "view-window-too-large",
275            "calc-construct-not-read",
276        ] {
277            assert!(template(id).is_some(), "{id}");
278        }
279    }
280
281    #[test]
282    fn raise_fills_slots_as_raisemsg_does() {
283        let r = raise("vla-form-missing-element", &[("n", "3")]);
284        assert_eq!(r.id, "vla-form-missing-element");
285        assert_eq!(r.number, 5);
286        assert_eq!(r.source, "VLA");
287        assert_eq!(r.text, "form is missing required element 3");
288        let r = raise("vla-unbalanced-parens", &[("loc", "")]);
289        assert_eq!(r.text, "unbalanced parentheses: missing ')'");
290        let r = raise(
291            "vla-expected-list-at-position",
292            &[("n", "2"), ("value", "x")],
293        );
294        assert_eq!(r.text, "expected a list at position 2, got 'x'");
295    }
296
297    #[test]
298    fn the_two_internal_errors_are_refusals_too() {
299        let r = raise("no-such-id", &[]);
300        assert_eq!(r.number, 5);
301        assert_eq!(r.source, "VLA-Messages");
302        assert_eq!(r.text, "RaiseMsg: unknown message id 'no-such-id'");
303        let r = raise("vla-form-missing-element", &[]);
304        assert_eq!(r.text, "RaiseMsg: no value supplied for slot '{n}'");
305        // A catalogue answers None for an id it does not hold, and Some for
306        // one it holds even when a slot is missing.
307        assert!(catalogue().raise("no-such-id", &[]).is_none());
308        assert!(catalogue().raise("vla-form-missing-element", &[]).is_some());
309    }
310
311    #[test]
312    fn substitute_slots_copies_an_unclosed_brace() {
313        assert_eq!(
314            substitute_slots("a {x} b {", &[("x", "1")]).unwrap(),
315            "a 1 b {"
316        );
317        assert_eq!(substitute_slots("no slots", &[]).unwrap(), "no slots");
318        assert_eq!(
319            substitute_slots("{a}{b}", &[("a", "1"), ("b", "2")]).unwrap(),
320            "12"
321        );
322    }
323}