Skip to main content

frazaro_core/
build.rs

1//! The build (PORT.7): a program's sentences to a workbook's bytes, through
2//! the sheet model and the OOXML writer.
3//!
4//! Slice 7a writes the room's first sheet, `Frazaro`: the program's lines in
5//! column B from row 1 exactly as written, blank lines included, and OK in
6//! green in column C beside every line that holds text, which is what the
7//! add-in's Check marks (`DoCheck`, VLA_IDE.bas: once the program
8//! translates, a row whose column B is not blank is marked OK). The widths,
9//! the fills, the hidden column A and the gridlines are `BuildWorkspace`'s
10//! (VLA_IDE.bas), so the room looks the same through this door.
11//!
12//! Slice 7b walks the program's VLA, read and macro-expanded exactly as
13//! `VlaTranspile` reads it (the prelude first, every `defmacro` defined, every
14//! other form expanded to its fixpoint), and writes what a sheet can hold
15//! with nothing running: a value or a formula string into a cell, a range,
16//! rows of a column or a cell of a named sheet; `Work on sheet` and `Go to
17//! sheet`; a `Define`d constant or a variable set to a value that folds; a
18//! parameterless step, inlined where it is called; an `If` whose condition
19//! folds over literals, so that `set-formula-rows`' guard works. Everything
20//! else is refused by name with the sentence quoted (`build-not-representable`),
21//! and a sheet named before anything made it is refused too
22//! (`build-sheet-unknown`). The scaffold `EnglishToVla` writes around a
23//! program (`on-error goto vla-fail`, the `vla-step` counter, the trace
24//! guards, `exit-sub`, the fail label) is recognised and stepped over.
25//!
26//! A formula into more than one cell is written as Excel writes a filled
27//! formula, a shared formula, so the host adjusts the references for each
28//! cell as `Range.Formula2` would have; a newer function takes its file
29//! prefix (`sheet::xlfn`). New ground, said plainly: the reference runs the
30//! program in Excel, and Excel does all of this.
31//!
32//! The build is a pure function of its texts: the same sentences and the
33//! same core give the same bytes on every machine, which the build golden
34//! (`scripts/build/fixture_golden.xlsx`, the treaty's amendment of
35//! 2026-10-03) holds it to.
36
37use std::collections::HashMap;
38
39use crate::expand::Expander;
40use crate::form::{Form, List};
41use crate::intrinsics::{fold, is_numeric, val};
42use crate::messages::{raise, Refusal};
43use crate::reader::{count_lf, parse_all, tokenize};
44use crate::sha256::sha256_hex_skipping_whitespace;
45use crate::sheet::merge::{self, HostInfo, Model};
46use crate::sheet::xlfn::{can_return_array, prefix_future_functions};
47use crate::sheet::{
48    ooxml, parse_a1_range, xml, zip, A1Range, Cell, Column, Content, NumFmt, Rgb, Sheet, Style,
49    Styles, Workbook,
50};
51
52/// The defined name that carries the build stamp.
53pub const STAMP_NAME: &str = "Frazaro.Build";
54
55/// The sheet that holds the program, named as the add-in names its
56/// workspace (`IDE_SHEET`).
57pub const FRAZARO_SHEET: &str = "Frazaro";
58/// The sheet a program's results go to unless it says `Work on sheet`
59/// (`OUT_SHEET`).
60pub const OUTPUT_SHEET: &str = "Output";
61/// `BuildWorkspace`'s whisper of the logo's lavender on the sentence column.
62pub const LAVENDER: Rgb = Rgb(247, 244, 252);
63/// The result column's gray, under the marks.
64pub const RESULT_GRAY: Rgb = Rgb(242, 242, 242);
65/// `MarkOK`'s green.
66pub const OK_GREEN: Rgb = Rgb(221, 235, 221);
67/// `BuildWorkspace`'s column widths, in characters.
68pub const SENTENCE_COLUMN_WIDTH: f64 = 72.0;
69pub const RESULT_COLUMN_WIDTH: f64 = 60.0;
70/// The most cells one sentence may fill: one whole column's worth.
71pub const MAX_CELLS_PER_WRITE: u64 = 1_048_576;
72/// How deep steps may call steps before the build calls it a loop.
73const MAX_INLINE_DEPTH: u32 = 32;
74
75/// The `Frazaro` sheet of a program whose text translated: each line of the
76/// text in column B of its own row, and OK in column C beside a line that
77/// is not blank. Blank is `Len(Trim$(...)) = 0`, and VBA's `Trim$` trims
78/// spaces only, so a line of tabs is marked and a line of spaces is not.
79pub fn frazaro_sheet(program_text: &str, styles: &mut Styles) -> Sheet {
80    let sentence = styles.id(Style {
81        fill: Some(LAVENDER),
82        num_fmt: NumFmt::Text,
83        wrap: false,
84    });
85    let result = styles.id(Style {
86        fill: Some(RESULT_GRAY),
87        num_fmt: NumFmt::General,
88        wrap: true,
89    });
90    let ok = styles.id(Style {
91        fill: Some(OK_GREEN),
92        num_fmt: NumFmt::General,
93        wrap: true,
94    });
95    let mut sheet = Sheet::new(FRAZARO_SHEET);
96    sheet.gridlines = false;
97    sheet.active_cell = (1, 2);
98    sheet.columns = vec![
99        Column {
100            index: 1,
101            width: None,
102            hidden: true,
103            style: None,
104        },
105        Column {
106            index: 2,
107            width: Some(SENTENCE_COLUMN_WIDTH),
108            hidden: false,
109            style: Some(sentence),
110        },
111        Column {
112            index: 3,
113            width: Some(RESULT_COLUMN_WIDTH),
114            hidden: false,
115            style: Some(result),
116        },
117    ];
118    for (i, line) in program_text.lines().enumerate() {
119        let row = i as u32 + 1;
120        if line.is_empty() {
121            continue;
122        }
123        sheet.set(
124            row,
125            2,
126            Cell {
127                content: Content::Text(line.to_string()),
128                style: sentence,
129            },
130        );
131        // The sentence's own row wrote its cell, and the mark beside it.
132        sheet.sentences.insert((row, 2), row);
133        if !line.trim_matches(' ').is_empty() {
134            sheet.set(
135                row,
136                3,
137                Cell {
138                    content: Content::Text("OK".to_string()),
139                    style: ok,
140                },
141            );
142            sheet.sentences.insert((row, 3), row);
143        }
144    }
145    sheet
146}
147
148/// A value the build can put in a cell, or fold a condition from.
149#[derive(Clone, Debug, PartialEq)]
150enum Value {
151    Num(f64),
152    Text(String),
153    Bool(bool),
154}
155
156/// VBA's `CStr` of a value, as `&` spells its operands: an integral number
157/// without a decimal point, anything else as Rust spells it (close enough
158/// for the cell addresses a program builds).
159fn cstr(v: &Value) -> String {
160    match v {
161        Value::Num(n) => {
162            if n.fract() == 0.0 && n.abs() < 1e15 {
163                format!("{}", *n as i64)
164            } else {
165                format!("{n}")
166            }
167        }
168        Value::Text(t) => t.clone(),
169        Value::Bool(b) => (if *b { "True" } else { "False" }).to_string(),
170    }
171}
172
173/// Why Excel would refuse a sheet name, or `None` for a name it takes: the
174/// rules `VlaCheckSheetName` (VLA_Runtime.bas) holds a program to at run
175/// time, with the runtime's own words; here the build's.
176fn sheet_name_problem(name: &str) -> Option<String> {
177    if name.is_empty() {
178        return Some("it is empty".to_string());
179    }
180    let n = name.chars().count();
181    if n > 31 {
182        return Some(format!("it is {n} characters long"));
183    }
184    // The first offending character in the runtime's own order, as its
185    // message names it.
186    if let Some(c) = ":\\/?*[]".chars().find(|c| name.contains(*c)) {
187        return Some(format!("it contains {c}"));
188    }
189    if name.starts_with('\'') || name.ends_with('\'') {
190        return Some("it begins or ends with an apostrophe".to_string());
191    }
192    if fold(name) == "history" {
193        return Some("History is a name Excel keeps for itself".to_string());
194    }
195    None
196}
197
198/// What the build keeps of a program's procedure: how many parameters it
199/// takes, and its body.
200#[derive(Clone)]
201struct Step {
202    params: usize,
203    body: Vec<Form>,
204}
205
206/// Whether a statement let the walk go on, or ended the procedure.
207enum Flow {
208    Next,
209    Exit,
210}
211
212/// Where `(range ...)` writes: a sheet of this build's, or one of the host
213/// workbook's, which a program may go to but never write into.
214#[derive(Clone, Debug, PartialEq, Eq)]
215enum Target {
216    Ours(usize),
217    Host(String),
218}
219
220struct Walker<'a> {
221    lines: Vec<&'a str>,
222    wb: Workbook,
223    /// The host workbook's own sheets, when the build adds to one.
224    host_sheets: Vec<String>,
225    /// The sheet a `(range ...)` means: `None` until something writes, then
226    /// `Output` unless `Work on sheet` chose another.
227    current: Option<Target>,
228    /// Constants and variables whose values fold, by folded name.
229    bindings: HashMap<String, Value>,
230    /// The program's procedures, by folded name.
231    steps: HashMap<String, Step>,
232    /// The program line the walk is on, from the nearest `(at-line N ...)`.
233    line: u32,
234    depth: u32,
235}
236
237impl<'a> Walker<'a> {
238    fn new(program_text: &'a str, host_sheets: Vec<String>) -> Walker<'a> {
239        Walker {
240            lines: program_text.lines().collect(),
241            wb: Workbook::new(),
242            host_sheets,
243            current: None,
244            bindings: HashMap::new(),
245            steps: HashMap::new(),
246            line: 0,
247            depth: 0,
248        }
249    }
250
251    /// The sentence the walk is on, quoted in a refusal.
252    fn sentence(&self) -> String {
253        if self.line == 0 {
254            return "the program's own setup".to_string();
255        }
256        match self.lines.get(self.line as usize - 1).map(|s| s.trim()) {
257            Some(s) if !s.is_empty() => s.to_string(),
258            _ => format!("the sentence on line {}", self.line),
259        }
260    }
261
262    fn refuse_sentence(&self) -> Refusal {
263        raise(
264            "build-not-representable",
265            &[
266                ("line", &self.line.to_string()),
267                ("sentence", &self.sentence()),
268            ],
269        )
270    }
271
272    fn refuse_sheet(&self, name: &str) -> Refusal {
273        raise(
274            "build-sheet-unknown",
275            &[("line", &self.line.to_string()), ("name", name)],
276        )
277    }
278
279    /// A top-level form: a constant is bound, a procedure kept, an include
280    /// refused as the compiler refuses it (no file system here either), and
281    /// the declarations the compiled module needs are nothing to a sheet.
282    fn collect_top(&mut self, f: &Form) -> Result<(), Refusal> {
283        let Form::List(l) = f else { return Ok(()) };
284        let Ok(head) = l.head_sym() else {
285            return Ok(());
286        };
287        match fold(&head).as_str() {
288            "const" => {
289                if l.count() >= 3 {
290                    if let Some(name) = l.items[1].atom_text() {
291                        if let Some(v) = self.fold_expr(&l.items[2]) {
292                            self.bindings.insert(fold(&name), v);
293                        }
294                    }
295                }
296            }
297            "sub" => {
298                if l.count() >= 3 {
299                    if let (Some(name), Form::List(params)) = (l.items[1].atom_text(), &l.items[2])
300                    {
301                        let mut body: &[Form] = &l.items[3..];
302                        if let Some(Form::Str(_)) = body.first() {
303                            body = &body[1..]; // a docstring
304                        }
305                        self.steps.insert(
306                            fold(&name),
307                            Step {
308                                params: params.count(),
309                                body: body.to_vec(),
310                            },
311                        );
312                    }
313                }
314            }
315            "include" => {
316                let name = l
317                    .items
318                    .get(1)
319                    .and_then(|f| f.atom_text())
320                    .unwrap_or_default();
321                return Err(raise(
322                    "vla-include-cannot-read",
323                    &[("name", &name), ("path", &name)],
324                ));
325            }
326            _ => {}
327        }
328        Ok(())
329    }
330
331    fn walk_main(&mut self) -> Result<(), Refusal> {
332        let Some(main) = self.steps.get("main").cloned() else {
333            return Ok(()); // a program with no statements builds its sheet alone
334        };
335        self.walk_body(&main.body)?;
336        Ok(())
337    }
338
339    fn walk_body(&mut self, body: &[Form]) -> Result<Flow, Refusal> {
340        for f in body {
341            if let Flow::Exit = self.walk_stmt(f)? {
342                return Ok(Flow::Exit);
343            }
344        }
345        Ok(Flow::Next)
346    }
347
348    fn walk_stmt(&mut self, f: &Form) -> Result<Flow, Refusal> {
349        let Form::List(l) = f else {
350            return Err(self.refuse_sentence());
351        };
352        let head = match l.head_sym() {
353            Ok(h) => fold(&h),
354            Err(_) => return Err(self.refuse_sentence()),
355        };
356        match head.as_str() {
357            "at-line" => {
358                if let Some(n) = l.items.get(1).and_then(|f| f.atom_text()) {
359                    self.line = val(&n) as u32;
360                }
361                self.walk_body(&l.items[2.min(l.items.len())..])
362            }
363            "begin" => self.walk_body(&l.items[1..]),
364            // Locals, and the scaffold's own report call.
365            "dim" | "vla-report-error" => Ok(Flow::Next),
366            // The scaffold's error handler; a `Try:` block's handlers are not it.
367            "on-error" => {
368                let target = l
369                    .items
370                    .last()
371                    .and_then(|f| f.atom_text())
372                    .unwrap_or_default();
373                if fold(&target) == "vla-fail" || target == "0" {
374                    Ok(Flow::Next)
375                } else {
376                    Err(self.refuse_sentence())
377                }
378            }
379            "label" => {
380                let name = l
381                    .items
382                    .get(1)
383                    .and_then(|f| f.atom_text())
384                    .unwrap_or_default();
385                if fold(&name) == "vla-fail" {
386                    Ok(Flow::Exit)
387                } else {
388                    Err(self.refuse_sentence())
389                }
390            }
391            "exit-sub" | "exit-function" => Ok(Flow::Exit),
392            "set!" => self.walk_set(l),
393            "if" => self.walk_if(l),
394            "vlaensuresheet" => {
395                let name = self.fold_text(l.items.get(1))?;
396                if self.host_sheet(&name).is_some() {
397                    return Ok(Flow::Next); // the host has it; nothing to make
398                }
399                if let Some(reason) = sheet_name_problem(&name) {
400                    return Err(raise(
401                        "build-sheet-name-invalid",
402                        &[
403                            ("line", &self.line.to_string()),
404                            ("name", &name),
405                            ("reason", &reason),
406                        ],
407                    ));
408                }
409                self.wb.ensure_sheet(&name);
410                Ok(Flow::Next)
411            }
412            "." => self.walk_dot(l),
413            _ => self.walk_call(l, &head),
414        }
415    }
416
417    /// `(set! place value)`: a variable bound, a cell or range written, a
418    /// formula written, a cell of a named sheet written.
419    fn walk_set(&mut self, l: &List) -> Result<Flow, Refusal> {
420        if l.count() != 3 {
421            return Err(self.refuse_sentence());
422        }
423        let place = &l.items[1];
424        let value = &l.items[2];
425        match place {
426            Form::Sym(name) => {
427                let v = self
428                    .fold_expr(value)
429                    .ok_or_else(|| self.refuse_sentence())?;
430                self.bindings.insert(fold(name), v);
431                Ok(Flow::Next)
432            }
433            Form::Str(_) => Err(self.refuse_sentence()),
434            Form::List(pl) => {
435                let phead = pl.head_sym().map(|h| fold(&h)).unwrap_or_default();
436                if phead == "range" && pl.count() == 2 {
437                    let range = self.range_of(&pl.items[1])?;
438                    let v = self
439                        .fold_expr(value)
440                        .ok_or_else(|| self.refuse_sentence())?;
441                    let sheet = self.writable_current()?;
442                    self.write_value(sheet, range, &v)?;
443                    return Ok(Flow::Next);
444                }
445                if phead != "." || pl.count() < 3 {
446                    return Err(self.refuse_sentence());
447                }
448                let obj = &pl.items[1];
449                let member = fold(&pl.items[2].atom_text().unwrap_or_default());
450                // (. (range R) formula) and (. (range R) value)
451                if let (Some(rl), true) = (obj.as_list(), obj.head_is("range")) {
452                    if pl.count() == 3
453                        && rl.count() == 2
454                        && matches!(member.as_str(), "formula" | "formula2" | "value")
455                    {
456                        let range = self.range_of(&rl.items[1])?;
457                        let v = self
458                            .fold_expr(value)
459                            .ok_or_else(|| self.refuse_sentence())?;
460                        let sheet = self.writable_current()?;
461                        if member == "value" {
462                            self.write_value(sheet, range, &v)?;
463                        } else {
464                            self.write_formula(sheet, range, &v)?;
465                        }
466                        return Ok(Flow::Next);
467                    }
468                }
469                // (. (worksheets S) range R): put-into-cell-of-sheet
470                if let (Some(wl), true) = (obj.as_list(), obj.head_is("worksheets")) {
471                    if pl.count() == 4 && wl.count() == 2 && member == "range" {
472                        let name = self.fold_text(wl.items.get(1))?;
473                        let sheet = self.writable_named(&name)?;
474                        let range = self.range_of(&pl.items[3])?;
475                        let v = self
476                            .fold_expr(value)
477                            .ok_or_else(|| self.refuse_sentence())?;
478                        self.write_value(sheet, range, &v)?;
479                        return Ok(Flow::Next);
480                    }
481                }
482                Err(self.refuse_sentence())
483            }
484        }
485    }
486
487    /// `(if cond (then ...) [(else ...)])`: the trace guard is skipped (no
488    /// trace runs in a build); another condition must fold.
489    fn walk_if(&mut self, l: &List) -> Result<Flow, Refusal> {
490        let Some(cond) = l.items.get(1) else {
491            return Err(self.refuse_sentence());
492        };
493        if cond.head_is("vlatraceon") {
494            return Ok(Flow::Next);
495        }
496        let truth = match self.fold_expr(cond) {
497            Some(Value::Bool(b)) => b,
498            Some(Value::Num(n)) => n != 0.0,
499            _ => return Err(self.refuse_sentence()),
500        };
501        for branch in &l.items[2..] {
502            if let Form::List(bl) = branch {
503                let taken = (truth && bl.head_is("then")) || (!truth && bl.head_is("else"));
504                if taken {
505                    return self.walk_body(&bl.items[1..]);
506                }
507            }
508        }
509        Ok(Flow::Next)
510    }
511
512    /// `(. (worksheets S) activate)`: `Go to sheet`, and the second half of
513    /// `Work on sheet`.
514    fn walk_dot(&mut self, l: &List) -> Result<Flow, Refusal> {
515        let obj = &l.items[1];
516        let member = fold(
517            &l.items
518                .get(2)
519                .and_then(|f| f.atom_text())
520                .unwrap_or_default(),
521        );
522        if let (Some(wl), true) = (obj.as_list(), obj.head_is("worksheets")) {
523            if l.count() == 3 && wl.count() == 2 && member == "activate" {
524                let name = self.fold_text(wl.items.get(1))?;
525                let target = self.target_named(&name)?;
526                self.current = Some(target);
527                return Ok(Flow::Next);
528            }
529        }
530        Err(self.refuse_sentence())
531    }
532
533    /// A call of one of the program's own steps, with no values passed:
534    /// its body inlined here. Anything else a sentence calls needs a host.
535    fn walk_call(&mut self, l: &List, head: &str) -> Result<Flow, Refusal> {
536        let Some(step) = self.steps.get(head).cloned() else {
537            return Err(self.refuse_sentence());
538        };
539        if step.params != 0 || l.count() != 1 || self.depth >= MAX_INLINE_DEPTH {
540            return Err(self.refuse_sentence());
541        }
542        self.depth += 1;
543        let saved = self.line;
544        let flow = self.walk_body(&step.body);
545        self.depth -= 1;
546        self.line = saved;
547        flow?; // a step's own Exit Sub ends the step, not its caller
548        Ok(Flow::Next)
549    }
550
551    fn fold_text(&self, f: Option<&Form>) -> Result<String, Refusal> {
552        match f.and_then(|f| self.fold_expr(f)) {
553            Some(Value::Text(t)) => Ok(t),
554            _ => Err(self.refuse_sentence()),
555        }
556    }
557
558    fn range_of(&self, f: &Form) -> Result<A1Range, Refusal> {
559        let text = self.fold_text(Some(f))?;
560        parse_a1_range(&text).ok_or_else(|| self.refuse_sentence())
561    }
562
563    /// The host workbook's sheet of that name, as the host spells it.
564    fn host_sheet(&self, name: &str) -> Option<String> {
565        let want = fold(name);
566        self.host_sheets.iter().find(|s| fold(s) == want).cloned()
567    }
568
569    /// Where `(range ...)` writes: `Output` made on first use, unless the
570    /// host workbook has an `Output` of its own.
571    fn current_target(&mut self) -> Target {
572        if let Some(t) = &self.current {
573            return t.clone();
574        }
575        let t = match self.host_sheet(OUTPUT_SHEET) {
576            Some(h) => Target::Host(h),
577            None => Target::Ours(self.wb.ensure_sheet(OUTPUT_SHEET)),
578        };
579        self.current = Some(t.clone());
580        t
581    }
582
583    /// A sheet named by the program: one it made, one of the host's, or
584    /// `Output`, which the add-in has at the start of every run.
585    fn target_named(&mut self, name: &str) -> Result<Target, Refusal> {
586        if let Some(i) = self.wb.find_sheet(name) {
587            return Ok(Target::Ours(i));
588        }
589        if let Some(h) = self.host_sheet(name) {
590            return Ok(Target::Host(h));
591        }
592        if fold(name) == fold(OUTPUT_SHEET) {
593            return Ok(Target::Ours(self.wb.ensure_sheet(OUTPUT_SHEET)));
594        }
595        Err(self.refuse_sheet(name))
596    }
597
598    /// A target a sentence may write to: one of this build's sheets. One
599    /// of the host's is refused by name, since the build leaves the host's
600    /// own sheets as they are.
601    fn writable(&self, t: Target) -> Result<usize, Refusal> {
602        match t {
603            Target::Ours(i) => Ok(i),
604            Target::Host(name) => Err(raise(
605                "build-into-model-sheet",
606                &[("line", &self.line.to_string()), ("name", &name)],
607            )),
608        }
609    }
610
611    fn writable_current(&mut self) -> Result<usize, Refusal> {
612        let t = self.current_target();
613        self.writable(t)
614    }
615
616    fn writable_named(&mut self, name: &str) -> Result<usize, Refusal> {
617        let t = self.target_named(name)?;
618        self.writable(t)
619    }
620
621    fn write_value(&mut self, sheet: usize, range: A1Range, v: &Value) -> Result<(), Refusal> {
622        if range.cells() > MAX_CELLS_PER_WRITE {
623            return Err(self.refuse_sentence());
624        }
625        let content = match v {
626            Value::Num(n) => Content::Number(*n),
627            Value::Text(t) => Content::Text(t.clone()),
628            Value::Bool(b) => Content::Bool(*b),
629        };
630        let sh = &mut self.wb.sheets[sheet];
631        for row in range.top..=range.bottom {
632            for col in range.left..=range.right {
633                sh.set(
634                    row,
635                    col,
636                    Cell {
637                        content: content.clone(),
638                        style: 0,
639                    },
640                );
641            }
642        }
643        sh.wrote(range, self.line);
644        Ok(())
645    }
646
647    /// `Range.Formula2 = text`: a text beginning with `=` is a formula, with
648    /// newer functions under their file prefix; anything else is the value
649    /// itself, as Excel stores it. SEC.15: the text is scanned first, as the
650    /// add-in's `VlaSetFormula` scans it, and a formula that reaches outside
651    /// the workbook on its own is refused by name with the sentence quoted
652    /// (`build-formula-egress`), nothing written.
653    fn write_formula(&mut self, sheet: usize, range: A1Range, v: &Value) -> Result<(), Refusal> {
654        if range.cells() > MAX_CELLS_PER_WRITE {
655            return Err(self.refuse_sentence());
656        }
657        if let Value::Text(t) = v {
658            if let Some(name) = crate::egress::egress_call(t) {
659                return Err(raise(
660                    "build-formula-egress",
661                    &[
662                        ("line", &self.line.to_string()),
663                        ("name", &name),
664                        ("sentence", &self.sentence()),
665                    ],
666                ));
667            }
668            if let Some(body) = t.strip_prefix('=') {
669                let text = prefix_future_functions(body);
670                if can_return_array(&text) {
671                    // What Excel stores for a Formula2 entry of such a formula,
672                    // and reads back without its @.
673                    self.wb.sheets[sheet].set_formula_dynamic(range, &text, 0);
674                } else {
675                    self.wb.sheets[sheet].set_formula(range, &text, 0);
676                }
677                self.wb.sheets[sheet].wrote(range, self.line);
678                return Ok(());
679            }
680        }
681        self.write_value(sheet, range, v)
682    }
683
684    /// A value from literals, constants, variables set to literals, and the
685    /// operators over them; `None` for anything that needs a host.
686    fn fold_expr(&self, f: &Form) -> Option<Value> {
687        match f {
688            Form::Str(s) => Some(Value::Text(s.clone())),
689            Form::Sym(s) => {
690                if is_numeric(s) {
691                    return Some(Value::Num(val(s)));
692                }
693                let key = fold(s);
694                match key.as_str() {
695                    "true" => Some(Value::Bool(true)),
696                    "false" => Some(Value::Bool(false)),
697                    _ => self.bindings.get(&key).cloned(),
698                }
699            }
700            Form::List(l) => {
701                let head = fold(&l.head_sym().ok()?);
702                let args: Vec<Value> = l.items[1..]
703                    .iter()
704                    .map(|a| self.fold_expr(a))
705                    .collect::<Option<Vec<Value>>>()?;
706                fold_op(&head, &args)
707            }
708        }
709    }
710}
711
712fn nums(args: &[Value]) -> Option<Vec<f64>> {
713    args.iter()
714        .map(|v| match v {
715            Value::Num(n) => Some(*n),
716            _ => None,
717        })
718        .collect()
719}
720
721fn bools(args: &[Value]) -> Option<Vec<bool>> {
722    args.iter()
723        .map(|v| match v {
724            Value::Bool(b) => Some(*b),
725            _ => None,
726        })
727        .collect()
728}
729
730/// The operators the build folds: arithmetic and comparison over numbers,
731/// `&` over anything, comparison over texts, `not`/`and`/`or` over truths.
732fn fold_op(head: &str, args: &[Value]) -> Option<Value> {
733    match head {
734        "&" => Some(Value::Text(
735            args.iter().map(cstr).collect::<Vec<_>>().join(""),
736        )),
737        "+" | "*" | "/" | "-" => {
738            let n = nums(args)?;
739            if n.is_empty() {
740                return None;
741            }
742            if head == "-" && n.len() == 1 {
743                return Some(Value::Num(-n[0]));
744            }
745            let mut acc = n[0];
746            for x in &n[1..] {
747                acc = match head {
748                    "+" => acc + x,
749                    "*" => acc * x,
750                    "-" => acc - x,
751                    _ => {
752                        if *x == 0.0 {
753                            return None;
754                        }
755                        acc / x
756                    }
757                };
758            }
759            Some(Value::Num(acc))
760        }
761        "=" | "<>" | "<" | ">" | "<=" | ">=" => {
762            if args.len() != 2 {
763                return None;
764            }
765            let ord = match (&args[0], &args[1]) {
766                (Value::Num(a), Value::Num(b)) => a.partial_cmp(b)?,
767                (Value::Text(a), Value::Text(b)) => a.cmp(b),
768                (Value::Bool(a), Value::Bool(b)) => a.cmp(b),
769                _ => return None,
770            };
771            Some(Value::Bool(match head {
772                "=" => ord.is_eq(),
773                "<>" => ord.is_ne(),
774                "<" => ord.is_lt(),
775                ">" => ord.is_gt(),
776                "<=" => ord.is_le(),
777                _ => ord.is_ge(),
778            }))
779        }
780        "not" => {
781            let b = bools(args)?;
782            (b.len() == 1).then(|| Value::Bool(!b[0]))
783        }
784        "and" => Some(Value::Bool(bools(args)?.iter().all(|b| *b))),
785        "or" => Some(Value::Bool(bools(args)?.iter().any(|b| *b))),
786        _ => None,
787    }
788}
789
790/// One source's identity in the stamp: `EnglishSourceHash`'s digest of its
791/// non-whitespace bytes, and how many there were.
792#[derive(Clone, Debug, PartialEq, Eq)]
793pub struct SourceHash {
794    pub hex: String,
795    pub count: usize,
796}
797
798impl SourceHash {
799    pub fn of(text: &str) -> SourceHash {
800        let (hex, count) = sha256_hex_skipping_whitespace(text.as_bytes());
801        SourceHash { hex, count }
802    }
803
804    fn text(&self) -> String {
805        format!(
806            "sha256:{} over {} non-whitespace bytes",
807            self.hex, self.count
808        )
809    }
810
811    fn parse(s: &str) -> Option<SourceHash> {
812        let rest = s.strip_prefix("sha256:")?;
813        let (hex, tail) = rest.split_once(" over ")?;
814        let count = tail.strip_suffix(" non-whitespace bytes")?.parse().ok()?;
815        if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
816            return None;
817        }
818        Some(SourceHash {
819            hex: hex.to_string(),
820            count,
821        })
822    }
823}
824
825/// What the stamp records of the workbook a build was added to
826/// (`--into`): enough for `rebuild` to render the build's sheets again
827/// exactly, without the model at hand.
828#[derive(Clone, Debug, PartialEq, Eq)]
829pub struct IntoStamp {
830    /// The SHA-256 of the model's bytes.
831    pub model_hex: String,
832    /// How many cell formats the model had: where this build's begin.
833    pub style_base: u32,
834    /// The cell-metadata index the build's dynamic-array formulas point
835    /// at; 0 when none was needed.
836    pub cm: u32,
837}
838
839/// The build stamp: the core's version, the workbook added to when there
840/// was one, and the identity of the sentences, the prelude and each
841/// phrasebook in order, as the defined name `Frazaro.Build` carries it in
842/// one string.
843#[derive(Clone, Debug, PartialEq, Eq)]
844pub struct Stamp {
845    pub version: String,
846    pub into: Option<IntoStamp>,
847    pub sentences: SourceHash,
848    pub prelude: SourceHash,
849    pub phrasebooks: Vec<SourceHash>,
850}
851
852impl Stamp {
853    pub fn of(program_text: &str, prelude: &str, books: &[&str], into: Option<IntoStamp>) -> Stamp {
854        Stamp {
855            version: crate::VERSION.to_string(),
856            into,
857            sentences: SourceHash::of(program_text),
858            prelude: SourceHash::of(prelude),
859            phrasebooks: books.iter().map(|b| SourceHash::of(b)).collect(),
860        }
861    }
862
863    /// `Frazaro 0.7.1; [into sha256:... with N cell formats and metadata
864    /// M; ]sentences sha256:... over N non-whitespace bytes; prelude ...;
865    /// phrasebook 1 ...`.
866    pub fn text(&self) -> String {
867        let mut s = format!("Frazaro {}", self.version);
868        if let Some(into) = &self.into {
869            s.push_str(&format!(
870                "; into sha256:{} with {} cell formats and metadata {}",
871                into.model_hex, into.style_base, into.cm
872            ));
873        }
874        s.push_str(&format!(
875            "; sentences {}; prelude {}",
876            self.sentences.text(),
877            self.prelude.text()
878        ));
879        for (i, b) in self.phrasebooks.iter().enumerate() {
880            s.push_str(&format!("; phrasebook {} {}", i + 1, b.text()));
881        }
882        s
883    }
884
885    pub fn parse(text: &str) -> Option<Stamp> {
886        let mut fields = text.split("; ").peekable();
887        let version = fields.next()?.strip_prefix("Frazaro ")?.to_string();
888        let into = if fields.peek().is_some_and(|f| f.starts_with("into ")) {
889            let f = fields.next()?.strip_prefix("into sha256:")?;
890            let (hex, rest) = f.split_once(" with ")?;
891            let (base, cm) = rest.split_once(" cell formats and metadata ")?;
892            if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
893                return None;
894            }
895            Some(IntoStamp {
896                model_hex: hex.to_string(),
897                style_base: base.parse().ok()?,
898                cm: cm.parse().ok()?,
899            })
900        } else {
901            None
902        };
903        let sentences = SourceHash::parse(fields.next()?.strip_prefix("sentences ")?)?;
904        let prelude = SourceHash::parse(fields.next()?.strip_prefix("prelude ")?)?;
905        let mut phrasebooks = Vec::new();
906        for (i, f) in fields.enumerate() {
907            let rest = f.strip_prefix(&format!("phrasebook {} ", i + 1))?;
908            phrasebooks.push(SourceHash::parse(rest)?);
909        }
910        Some(Stamp {
911            version,
912            into,
913            sentences,
914            prelude,
915            phrasebooks,
916        })
917    }
918
919    /// Why the stamp does not name these sources, or `None` when it does.
920    pub fn disagreement(
921        &self,
922        program_text: &str,
923        prelude: &str,
924        books: &[&str],
925    ) -> Option<String> {
926        if self.sentences != SourceHash::of(program_text) {
927            return Some(
928                "the sentences in its Frazaro sheet are not the ones the stamp names".to_string(),
929            );
930        }
931        if self.prelude != SourceHash::of(prelude) {
932            return Some("the prelude given is not the one the stamp names".to_string());
933        }
934        if self.phrasebooks.len() != books.len() {
935            return Some(format!(
936                "the stamp names {} phrasebook(s) and {} were given",
937                self.phrasebooks.len(),
938                books.len()
939            ));
940        }
941        for (i, (want, text)) in self.phrasebooks.iter().zip(books.iter()).enumerate() {
942            if *want != SourceHash::of(text) {
943                return Some(format!(
944                    "phrasebook {} given is not the one the stamp names",
945                    i + 1
946                ));
947            }
948        }
949        None
950    }
951}
952
953/// The stamp as a defined name's formula: a string constant.
954fn stamp_formula(stamp: &Stamp) -> String {
955    format!("\"{}\"", stamp.text().replace('"', "\"\""))
956}
957
958/// What `frazaro rebuild` reads out of a built workbook before building it
959/// again.
960#[derive(Clone, Debug, PartialEq, Eq)]
961pub struct BuildFile {
962    pub stamp: Stamp,
963    /// The program, line by line, as the `Frazaro` sheet holds it.
964    pub program_text: String,
965    /// The lines with text in them, which Check marks and the stamp counts.
966    pub sentences: usize,
967    /// The sheets that are not the build's: the model's, when the build
968    /// was added to one.
969    pub host_sheets: Vec<String>,
970}
971
972/// What `frazaro rebuild` answers.
973#[derive(Clone, Debug, PartialEq, Eq)]
974pub struct Rebuilt {
975    pub sentences: usize,
976    pub built_by: String,
977    pub matches: bool,
978    /// Empty when it matches.
979    pub why: String,
980    /// Whether only the build's own sheets were checked, as for a workbook
981    /// the build was added to, whose own parts the rebuild cannot remake.
982    pub partial: bool,
983}
984
985impl Rebuilt {
986    /// The one line `frazaro rebuild` prints.
987    pub fn line(&self) -> String {
988        let head = if self.partial {
989            format!(
990                "This workbook's Frazaro sheets were built from these {} sentences by Frazaro {}, into a workbook whose own sheets are not checked:",
991                self.sentences, self.built_by
992            )
993        } else {
994            format!(
995                "This workbook was built from these {} sentences by Frazaro {}:",
996                self.sentences, self.built_by
997            )
998        };
999        if self.matches {
1000            format!("{head} yes.")
1001        } else {
1002            format!("{head} no. Why: {}.", self.why)
1003        }
1004    }
1005}
1006
1007/// A stored part of a build, as text, for `rebuild` to compare with what
1008/// it renders again; `None` when the part is missing or not stored.
1009pub fn own_part_text(file: &[u8], name: &str) -> Option<String> {
1010    let entries = zip::entries(file)?;
1011    let e = entries.iter().find(|e| e.name == name)?;
1012    String::from_utf8(zip::stored_data(file, e)?.to_vec()).ok()
1013}
1014
1015fn refuse_rebuild(why: &str) -> Refusal {
1016    raise("rebuild-not-a-build", &[("why", why)])
1017}
1018
1019/// The text between `<open ...>` and `</close>` after `from`, with the
1020/// element's start tag given up to its name.
1021fn element_text<'a>(xml_text: &'a str, start_tag: &str, end_tag: &str) -> Option<&'a str> {
1022    let open = xml_text.find(start_tag)?;
1023    let after_open = open + xml_text[open..].find('>')? + 1;
1024    let close = after_open + xml_text[after_open..].find(end_tag)?;
1025    Some(&xml_text[after_open..close])
1026}
1027
1028/// A stored part of our own archive, as text.
1029fn stored_part(file: &[u8], entries: &[zip::Entry], name: &str) -> Result<String, Refusal> {
1030    let e = entries
1031        .iter()
1032        .find(|e| e.name == name)
1033        .ok_or_else(|| refuse_rebuild(&format!("it has no part {name}")))?;
1034    let data = zip::stored_data(file, e).ok_or_else(|| {
1035        refuse_rebuild(
1036            "its parts are not stored as frazaro build stores them, so a host has saved it since it was built, if it was built at all",
1037        )
1038    })?;
1039    String::from_utf8(data.to_vec())
1040        .map_err(|_| refuse_rebuild(&format!("its part {name} is not UTF-8")))
1041}
1042
1043/// A built workbook read back: its stamp and its sentences.
1044pub fn read_build(file: &[u8]) -> Result<BuildFile, Refusal> {
1045    let entries = zip::entries(file)
1046        .ok_or_else(|| refuse_rebuild("it is not a zip archive this reader knows"))?;
1047    let workbook = stored_part(file, &entries, "xl/workbook.xml")?;
1048    let raw = element_text(
1049        &workbook,
1050        &format!("<definedName name=\"{STAMP_NAME}\""),
1051        "</definedName>",
1052    )
1053    .ok_or_else(|| refuse_rebuild("it carries no Frazaro.Build stamp"))?;
1054    let formula = xml::unescape(raw);
1055    let quoted = formula
1056        .strip_prefix('"')
1057        .and_then(|s| s.strip_suffix('"'))
1058        .ok_or_else(|| refuse_rebuild("its Frazaro.Build stamp is not a text"))?;
1059    let stamp = Stamp::parse(&quoted.replace("\"\"", "\""))
1060        .ok_or_else(|| refuse_rebuild("its Frazaro.Build stamp does not read"))?;
1061    // The Frazaro sheet is the first sheet part; its column B is the program.
1062    let sheet = stored_part(file, &entries, &ooxml::sheet_part(1))?;
1063    let mut lines: Vec<String> = Vec::new();
1064    let mut rest = sheet.as_str();
1065    while let Some(i) = rest.find("<c r=\"B") {
1066        let cell = &rest[i + 7..];
1067        let Some(q) = cell.find('"') else { break };
1068        let row: usize = match cell[..q].parse() {
1069            Ok(n) => n,
1070            Err(_) => {
1071                rest = cell;
1072                continue;
1073            }
1074        };
1075        let Some(end) = cell.find("</c>") else { break };
1076        let body = &cell[..end];
1077        let text = element_text(body, "<t", "</t>")
1078            .map(xml::unescape)
1079            .unwrap_or_default();
1080        if row >= 1 {
1081            if lines.len() < row {
1082                lines.resize(row, String::new());
1083            }
1084            lines[row - 1] = text;
1085        }
1086        rest = &cell[end..];
1087    }
1088    let sentences = lines
1089        .iter()
1090        .filter(|l| !l.trim_matches(' ').is_empty())
1091        .count();
1092    let rels = stored_part(file, &entries, "xl/_rels/workbook.xml.rels")?;
1093    let host_sheets = merge::host_sheets_of(&workbook, &rels);
1094    Ok(BuildFile {
1095        stamp,
1096        program_text: lines.join("\n"),
1097        sentences,
1098        host_sheets,
1099    })
1100}
1101
1102/// The workbook a translated program builds: the `Frazaro` sheet, then
1103/// the sheets its static subset writes, and the stamp. `vla` is the
1104/// program's VLA as `EnglishToVla` wrote it, read with the prelude and
1105/// expanded as the compiler reads it; `books` are the phrasebook texts the
1106/// translation used, in order, for the stamp alone. With a `host`, the
1107/// build is being added to that workbook: the host's sheets may be named
1108/// but not written to, and the stamp records the host.
1109pub fn build_workbook_with(
1110    program_text: &str,
1111    vla: &str,
1112    prelude: &str,
1113    books: &[&str],
1114    host: Option<&HostInfo>,
1115) -> Result<Workbook, Refusal> {
1116    let line_offset = count_lf(prelude) as u32 + 1;
1117    let text = format!("{prelude}\r\n{vla}");
1118    let forms = parse_all(&tokenize(&text), line_offset)?;
1119    let mut expander = Expander::new();
1120    let body = expander.collect(forms)?;
1121    let mut expanded = Vec::with_capacity(body.len());
1122    for f in &body {
1123        expanded.push(expander.expand(f, 0)?);
1124    }
1125    let mut w = Walker::new(
1126        program_text,
1127        host.map(|h| h.sheet_names.clone()).unwrap_or_default(),
1128    );
1129    if let Some(h) = host {
1130        if let Some(taken) = w.host_sheet(FRAZARO_SHEET) {
1131            return Err(raise(
1132                "build-into-sheet-name-taken",
1133                &[("path", &h.label), ("name", &taken)],
1134            ));
1135        }
1136    }
1137    let sheet = frazaro_sheet(program_text, &mut w.wb.styles);
1138    w.wb.sheets.push(sheet);
1139    w.wb.active_sheet = 0;
1140    for f in &expanded {
1141        w.collect_top(f)?;
1142    }
1143    w.walk_main()?;
1144    let into = match host {
1145        None => None,
1146        Some(h) => {
1147            if h.cm.is_none() && w.wb.has_dynamic_formulas() {
1148                return Err(raise(
1149                    "build-into-unsupported",
1150                    &[
1151                        ("path", &h.label),
1152                        ("part", "xl/metadata.xml"),
1153                        ("why", "its cell metadata has no dynamic-array block for this build's formulas to point at"),
1154                    ],
1155                ));
1156            }
1157            Some(IntoStamp {
1158                model_hex: h.model_hex.clone(),
1159                style_base: h.style_base,
1160                cm: h.cm.unwrap_or(0),
1161            })
1162        }
1163    };
1164    let stamp = Stamp::of(program_text, prelude, books, into);
1165    w.wb.defined_names
1166        .push((STAMP_NAME.to_string(), stamp_formula(&stamp)));
1167    Ok(w.wb)
1168}
1169
1170/// A fresh workbook from a translated program.
1171pub fn build_workbook(
1172    program_text: &str,
1173    vla: &str,
1174    prelude: &str,
1175    books: &[&str],
1176) -> Result<Workbook, Refusal> {
1177    build_workbook_with(program_text, vla, prelude, books, None)
1178}
1179
1180/// The workbook's bytes, or the catalogue's refusal when they would not fit
1181/// the file format. Every formula the declared subset computes holds its
1182/// value (KERNEL.20, the values folded at build time): the workbook is
1183/// recalculated once under Excel's library, as the view record's `value`
1184/// rows are, so that a reader that does not calculate shows what the host
1185/// would. A formula outside the subset holds none, and the host still
1186/// computes every formula when it opens the file.
1187pub fn build_xlsx(
1188    program_text: &str,
1189    vla: &str,
1190    prelude: &str,
1191    books: &[&str],
1192) -> Result<Vec<u8>, Refusal> {
1193    let wb = build_workbook(program_text, vla, prelude, books)?;
1194    let library = crate::excel::library();
1195    let values = crate::calc::Calc::run(&wb, &library);
1196    ooxml::workbook_bytes_valued(&wb, &values).ok_or_else(|| raise("build-workbook-too-large", &[]))
1197}
1198
1199/// The model with the program's sheets added, as bytes (`--into`): the
1200/// model's parts as they were, this build's appended (`sheet::merge`).
1201/// `label` is what a refusal calls the model. No formula holds a value: the
1202/// build's formulas may read the model's own cells and names, which the
1203/// build does not read, so a value computed without them could be wrong.
1204pub fn build_xlsx_into(
1205    program_text: &str,
1206    vla: &str,
1207    prelude: &str,
1208    books: &[&str],
1209    model_bytes: Vec<u8>,
1210    label: &str,
1211) -> Result<Vec<u8>, Refusal> {
1212    let model = Model::read(model_bytes, label)?;
1213    let host = model.host_info();
1214    let wb = build_workbook_with(program_text, vla, prelude, books, Some(&host))?;
1215    merge::write(&model, &wb, host.cm.unwrap_or(0))
1216}
1217
1218#[cfg(test)]
1219mod tests {
1220    use super::*;
1221    use crate::sheet::zip;
1222
1223    const PRELUDE: &str = include_str!("../../scripts/prelude.vla");
1224    const ENGLISH: &str = include_str!("../../scripts/polyglotta/english.vla");
1225    const FIXTURE: &str = include_str!("../../scripts/build/fixture.txt");
1226    const GOLDEN: &[u8] = include_bytes!("../../scripts/build/fixture_golden.xlsx");
1227
1228    fn text_of(cell: &Cell) -> &str {
1229        match &cell.content {
1230            Content::Text(t) => t,
1231            other => panic!("not a text: {other:?}"),
1232        }
1233    }
1234
1235    #[test]
1236    fn the_frazaro_sheet_is_the_room() {
1237        let mut styles = Styles::new();
1238        let sh = frazaro_sheet(
1239            "Put 5 into cell B2.\n\n# a comment\n   \n\tindented\nlast",
1240            &mut styles,
1241        );
1242        assert_eq!(sh.name, "Frazaro");
1243        assert!(!sh.gridlines);
1244        assert_eq!(sh.active_cell, (1, 2));
1245        assert!(sh.columns[0].hidden);
1246        assert_eq!(sh.columns[1].width, Some(72.0));
1247        assert_eq!(sh.columns[2].width, Some(60.0));
1248        // Row 1: the sentence and its OK. Row 2, blank: nothing. Row 3, a
1249        // comment: marked, as Check marks any row with text. Row 4, spaces
1250        // only: the text kept, no mark. Row 5, a tab: marked, as Trim$ would
1251        // leave the tab. Row 6: the last line, without a line break after it.
1252        assert_eq!(text_of(&sh.cells[&(1, 2)]), "Put 5 into cell B2.");
1253        assert_eq!(text_of(&sh.cells[&(1, 3)]), "OK");
1254        assert!(!sh.cells.contains_key(&(2, 2)));
1255        assert_eq!(text_of(&sh.cells[&(3, 2)]), "# a comment");
1256        assert_eq!(text_of(&sh.cells[&(3, 3)]), "OK");
1257        assert_eq!(text_of(&sh.cells[&(4, 2)]), "   ");
1258        assert!(!sh.cells.contains_key(&(4, 3)));
1259        assert_eq!(text_of(&sh.cells[&(5, 3)]), "OK");
1260        assert_eq!(text_of(&sh.cells[&(6, 2)]), "last");
1261        assert_eq!(sh.cells.len(), 9);
1262        // The three formats, in order of first use, after the default.
1263        let xfs = styles.xfs();
1264        assert_eq!(xfs.len(), 4);
1265        assert_eq!(xfs[1].fill, Some(LAVENDER));
1266        assert_eq!(xfs[1].num_fmt, NumFmt::Text);
1267        assert_eq!(xfs[2].fill, Some(RESULT_GRAY));
1268        assert_eq!(xfs[3].fill, Some(OK_GREEN));
1269        assert!(xfs[3].wrap);
1270    }
1271
1272    /// A program's VLA as EnglishToVla scaffolds it, written by hand.
1273    const STATIC_VLA: &str = r#"(const rate 0.2)
1274
1275(dim vla-step Long)
1276
1277(sub main ()
1278  (dim total)
1279  (on-error goto vla-fail)
1280  (set! vla-step 1)
1281  (if (vlatraceon) (then (vlatracestep 1 (vla-step-text 1))))
1282  (at-line 1
1283  (set! (range "b2") 5))
1284  (at-line 2
1285  (set! (. (range "b3") formula) "=B2*2"))
1286  (at-line 3
1287  (if (>= 4 2) (then (set! (. (range (& "c" 2 ":" "c" 4)) formula) "=IFS(B2>3,\"big\",TRUE,\"small\")"))))
1288  (at-line 4
1289  (begin (vlaensuresheet "data") (. (worksheets "data") activate)))
1290  (at-line 5
1291  (set! (range "a1") "west"))
1292  (at-line 6
1293  (set! (. (worksheets "output") range "b1") 7))
1294  (at-line 7
1295  (set! total 5))
1296  (at-line 8
1297  (set! (range "a2") (+ total rate)))
1298  (at-line 9
1299  (if (> total 3) (then (at-line 10 (set! (range "a3") true))) (else (at-line 11 (set! (range "a3") false)))))
1300  (at-line 12
1301  (set! (. (range "a4:b4") value) (& "x" 1)))
1302  (at-line 13
1303  (tally))
1304  (at-line 14
1305  (set! (. (range "a5") formula) "plain text"))
1306  (at-line 15
1307  (exit-sub))
1308  (at-line 16
1309  (set! (range "a6") 1))
1310  (exit-sub)
1311  (label vla-fail)
1312  (vla-report-error))
1313
1314(sub tally ()
1315  (on-error goto vla-fail)
1316  (at-line 20
1317  (set! (range "a7") 70))
1318  (at-line 21
1319  (exit-sub))
1320  (at-line 22
1321  (set! (range "a8") 80))
1322  (exit-sub)
1323  (label vla-fail)
1324  (vla-report-error))
1325
1326(sub vla-report-error ()
1327  (vlareportstop vla-step (vla-step-text vla-step) err.description))
1328"#;
1329
1330    #[test]
1331    fn the_static_subset_lands_in_cells() {
1332        let wb = build_workbook("", STATIC_VLA, "", &[]).expect("the static subset builds");
1333        let names: Vec<&str> = wb.sheets.iter().map(|s| s.name.as_str()).collect();
1334        assert_eq!(names, vec!["Frazaro", "Output", "data"]);
1335        let out = &wb.sheets[1];
1336        assert_eq!(out.cells[&(2, 2)].content, Content::Number(5.0));
1337        assert_eq!(
1338            out.cells[&(3, 2)].content,
1339            Content::Formula("B2*2".to_string())
1340        );
1341        // IFS can return an array, so the fill is a dynamic-array formula per
1342        // cell with its references moved (7c), not a shared formula.
1343        for (row, moved) in [(2, "B2"), (3, "B3"), (4, "B4")] {
1344            assert_eq!(
1345                out.cells[&(row, 3)].content,
1346                Content::DynamicFormula(format!("_xlfn.IFS({moved}>3,\"big\",TRUE,\"small\")"))
1347            );
1348        }
1349        assert_eq!(out.cells[&(1, 2)].content, Content::Number(7.0));
1350        assert!(wb.has_dynamic_formulas());
1351        assert_eq!(wb.defined_names.len(), 1);
1352        assert_eq!(wb.defined_names[0].0, "Frazaro.Build");
1353        assert!(wb.defined_names[0]
1354            .1
1355            .starts_with(&format!("\"Frazaro {}; sentences sha256:", crate::VERSION)));
1356        let data = &wb.sheets[2];
1357        assert_eq!(
1358            data.cells[&(1, 1)].content,
1359            Content::Text("west".to_string())
1360        );
1361        assert_eq!(data.cells[&(2, 1)].content, Content::Number(5.2));
1362        assert_eq!(data.cells[&(3, 1)].content, Content::Bool(true));
1363        assert_eq!(data.cells[&(4, 1)].content, Content::Text("x1".to_string()));
1364        assert_eq!(data.cells[&(4, 2)].content, Content::Text("x1".to_string()));
1365        // The step inlined up to its own Stop; the main's Stop ends the walk.
1366        assert_eq!(data.cells[&(7, 1)].content, Content::Number(70.0));
1367        assert!(!data.cells.contains_key(&(8, 1)));
1368        assert_eq!(
1369            data.cells[&(5, 1)].content,
1370            Content::Text("plain text".to_string())
1371        );
1372        assert!(!data.cells.contains_key(&(6, 1)));
1373    }
1374
1375    fn vla_with(stmt: &str) -> String {
1376        format!(
1377            "(sub main ()\n  (on-error goto vla-fail)\n  (at-line 1\n  (set! (range \"b2\") 5))\n  (at-line 2\n  {stmt})\n  (exit-sub)\n  (label vla-fail)\n  (vla-report-error))\n"
1378        )
1379    }
1380
1381    #[test]
1382    fn a_formula_that_reaches_outside_is_refused_by_name() {
1383        // SEC.15: the writer asks the egress scan before it writes a formula,
1384        // through either spelling of the member and whatever the text begins
1385        // with; the refusal names the line, the function and the sentence.
1386        let program = "Put 5 into cell B2.\nPut formula \"=HYPERLINK(\"\"https://example.com/\"\",\"\"go\"\")\" into cell B3.\n";
1387        for (stmt, name) in [
1388            (
1389                "(set! (. (range \"b3\") formula) \"=HYPERLINK(\\\"https://example.com/\\\",\\\"go\\\")\")",
1390                "HYPERLINK",
1391            ),
1392            (
1393                "(set! (. (range \"b3\") formula) \"=cmd|'/c calc'!A0\")",
1394                "DDE",
1395            ),
1396            (
1397                "(set! (. (range \"b3\") formula2) \"=_xlfn.WEBSERVICE(\\\"https://example.com/\\\")\")",
1398                "WEBSERVICE",
1399            ),
1400            (
1401                "(set! (. (range \"b3:b5\") formula) \"WEBSERVICE(1)\")",
1402                "WEBSERVICE",
1403            ),
1404        ] {
1405            let r = build_workbook(program, &vla_with(stmt), "", &[]).expect_err(stmt);
1406            assert_eq!(r.id, "build-formula-egress", "{stmt}");
1407            assert!(
1408                r.text
1409                    .contains(&format!("Line 2 would write a formula that uses {name}")),
1410                "{}",
1411                r.text
1412            );
1413            assert!(r.text.contains("Put formula \"=HYPERLINK"), "{}", r.text);
1414            assert!(r.text.ends_with("Nothing was written."), "{}", r.text);
1415        }
1416        // The same text through the value member is a text in the cell, as
1417        // Excel stores a value, so nothing is refused and nothing runs.
1418        let wb = build_workbook(
1419            program,
1420            &vla_with(
1421                "(set! (. (range \"b3\") value) \"=HYPERLINK(\\\"https://example.com/\\\",\\\"go\\\")\")",
1422            ),
1423            "",
1424            &[],
1425        )
1426        .expect("a value");
1427        assert_eq!(
1428            wb.sheets[1].cells[&(3, 2)].content,
1429            Content::Text("=HYPERLINK(\"https://example.com/\",\"go\")".to_string())
1430        );
1431        // A listed name inside a string literal is text, and the formula is
1432        // written as before.
1433        let wb = build_workbook(
1434            program,
1435            &vla_with("(set! (. (range \"b3\") formula) \"=\\\"WEBSERVICE(\\\"\")"),
1436            "",
1437            &[],
1438        )
1439        .expect("a name inside a string");
1440        assert_eq!(
1441            wb.sheets[1].cells[&(3, 2)].content,
1442            Content::Formula("\"WEBSERVICE(\"".to_string())
1443        );
1444    }
1445
1446    #[test]
1447    fn what_needs_a_host_is_refused_with_its_sentence() {
1448        let program = "Put 5 into cell B2.\nSay \"hello\".\n";
1449        for stmt in [
1450            "(msgbox \"hello\")",
1451            "(set! (range \"c:c\") 0)",
1452            "(set! (range \"a1\") (range \"b1\"))",
1453            "(set! (. (range \"a1\") interior.color) 255)",
1454            "(if (> (range \"a1\") 1) (then (set! (range \"a2\") 1)))",
1455            "(dotimes i 3 (set! (range \"a1\") 1))",
1456            "(on-error goto vla-tryf-1)",
1457            "(set! x (range \"a1\"))",
1458        ] {
1459            let r = build_workbook(program, &vla_with(stmt), "", &[]).expect_err(stmt);
1460            assert_eq!(r.id, "build-not-representable", "{stmt}");
1461            assert!(
1462                r.text.contains("Line 2 asks for more: Say \"hello\"."),
1463                "{}",
1464                r.text
1465            );
1466        }
1467        let r = build_workbook(
1468            program,
1469            &vla_with("(. (worksheets \"nowhere\") activate)"),
1470            "",
1471            &[],
1472        )
1473        .expect_err("an unmade sheet");
1474        assert_eq!(r.id, "build-sheet-unknown");
1475        assert!(r.text.contains("Line 2 names sheet nowhere"), "{}", r.text);
1476        // A step that takes values, and a step that calls itself.
1477        let r = build_workbook(
1478            program,
1479            &format!(
1480                "{}(sub taxed ((byval a Variant))\n  (at-line 5 (set! (range \"a1\") a)))\n",
1481                vla_with("(taxed 5)")
1482            ),
1483            "",
1484            &[],
1485        )
1486        .expect_err("a step with values");
1487        assert_eq!(r.id, "build-not-representable");
1488        let r = build_workbook(
1489            program,
1490            &format!(
1491                "{}(sub again ()\n  (at-line 5 (again)))\n",
1492                vla_with("(again)")
1493            ),
1494            "",
1495            &[],
1496        )
1497        .expect_err("a step calling itself");
1498        assert_eq!(r.id, "build-not-representable");
1499        assert!(r.text.contains("Line 5"), "{}", r.text);
1500        // A library the build cannot read, as the compiler says it.
1501        let r =
1502            build_workbook(program, "(include \"alien.vla\")\n", "", &[]).expect_err("an include");
1503        assert_eq!(r.id, "vla-include-cannot-read");
1504        // A sheet name Excel would refuse.
1505        for (name, why) in [
1506            (
1507                "abcdefghijklmnopqrstuvwxyz123456",
1508                "it is 32 characters long",
1509            ),
1510            ("a:b", "it contains :"),
1511            ("'q1", "it begins or ends with an apostrophe"),
1512            ("History", "History is a name Excel keeps for itself"),
1513        ] {
1514            let r = build_workbook(
1515                program,
1516                &vla_with(&format!("(vlaensuresheet \"{name}\")")),
1517                "",
1518                &[],
1519            )
1520            .expect_err(name);
1521            assert_eq!(r.id, "build-sheet-name-invalid", "{name}");
1522            assert!(r.text.contains(why), "{}", r.text);
1523        }
1524        assert_eq!(sheet_name_problem("Q1 Data"), None);
1525        assert_eq!(sheet_name_problem(""), Some("it is empty".to_string()));
1526    }
1527
1528    #[test]
1529    fn folding_spells_values_as_vba_does() {
1530        assert_eq!(
1531            fold_op(
1532                "&",
1533                &[
1534                    Value::Text("c".into()),
1535                    Value::Num(2.0),
1536                    Value::Text(":".into())
1537                ]
1538            ),
1539            Some(Value::Text("c2:".into()))
1540        );
1541        assert_eq!(cstr(&Value::Num(2.5)), "2.5");
1542        assert_eq!(cstr(&Value::Num(-3.0)), "-3");
1543        assert_eq!(cstr(&Value::Bool(true)), "True");
1544        assert_eq!(fold_op("-", &[Value::Num(5.0)]), Some(Value::Num(-5.0)));
1545        assert_eq!(fold_op("/", &[Value::Num(5.0), Value::Num(0.0)]), None);
1546        assert_eq!(
1547            fold_op("<", &[Value::Text("a".into()), Value::Text("b".into())]),
1548            Some(Value::Bool(true))
1549        );
1550        assert_eq!(fold_op("and", &[Value::Bool(true), Value::Num(1.0)]), None);
1551        assert_eq!(fold_op("sum", &[Value::Num(1.0)]), None);
1552    }
1553
1554    #[test]
1555    fn the_same_text_gives_the_same_bytes() {
1556        let vla = "(sub main ()\n  (at-line 1\n  (set! (range \"b2\") 5)))";
1557        let a = build_xlsx("Put 5 into cell B2.\n", vla, "", &[]).unwrap();
1558        let b = build_xlsx("Put 5 into cell B2.\n", vla, "", &[]).unwrap();
1559        assert_eq!(a, b);
1560        assert!(build_xlsx("Log 1.\n", "(sub main", "", &[]).is_err());
1561    }
1562
1563    #[test]
1564    fn the_refusals_come_from_the_catalogue() {
1565        let r = raise("build-output-exists", &[("path", "out.xlsx")]);
1566        assert_eq!(r.source, "VLA-Build");
1567        assert!(r.text.contains("out.xlsx"));
1568        assert!(r.text.contains("--replace"));
1569        let r = raise("build-workbook-too-large", &[]);
1570        assert_eq!(r.source, "VLA-Build");
1571        let r = raise(
1572            "build-not-representable",
1573            &[("line", "4"), ("sentence", "Say \"hi\".")],
1574        );
1575        assert_eq!(r.source, "VLA-Build");
1576        assert!(r.text.contains("Line 4 asks for more: Say \"hi\"."));
1577        let r = raise("build-sheet-unknown", &[("line", "4"), ("name", "data")]);
1578        assert_eq!(r.source, "VLA-Build");
1579        assert!(r.text.contains("Work on sheet data."));
1580    }
1581
1582    #[test]
1583    fn the_stamp_round_trips() {
1584        let stamp = Stamp::of(
1585            "Put 5 into cell B2.\r\n",
1586            "(defmacro (a) 1)",
1587            &["book one", "book\ttwo"],
1588            None,
1589        );
1590        assert_eq!(stamp.version, crate::VERSION);
1591        assert_eq!(stamp.phrasebooks.len(), 2);
1592        let text = stamp.text();
1593        assert!(text.starts_with(&format!("Frazaro {}; sentences sha256:", crate::VERSION)));
1594        assert!(text.contains("; prelude sha256:"));
1595        assert!(text.contains("; phrasebook 1 sha256:"));
1596        assert!(text.contains("; phrasebook 2 sha256:"));
1597        assert!(text.ends_with(" non-whitespace bytes"));
1598        assert_eq!(Stamp::parse(&text), Some(stamp.clone()));
1599        assert_eq!(Stamp::parse("Frazaro 0.1.0; sentences nonsense"), None);
1600        assert_eq!(Stamp::parse(""), None);
1601        // The same sentences in another spelling of their whitespace agree.
1602        assert_eq!(
1603            stamp.disagreement(
1604                "Put 5 into cell B2.",
1605                "(defmacro (a) 1)",
1606                &["book one", "book two"]
1607            ),
1608            None
1609        );
1610        assert_eq!(
1611            stamp.disagreement(
1612                "Put 6 into cell B2.",
1613                "(defmacro (a) 1)",
1614                &["book one", "book two"]
1615            ),
1616            Some("the sentences in its Frazaro sheet are not the ones the stamp names".to_string())
1617        );
1618        assert_eq!(
1619            stamp.disagreement(
1620                "Put 5 into cell B2.",
1621                "(defmacro (a) 2)",
1622                &["book one", "book two"]
1623            ),
1624            Some("the prelude given is not the one the stamp names".to_string())
1625        );
1626        assert_eq!(
1627            stamp.disagreement("Put 5 into cell B2.", "(defmacro (a) 1)", &["book one"]),
1628            Some("the stamp names 2 phrasebook(s) and 1 were given".to_string())
1629        );
1630        assert_eq!(
1631            stamp.disagreement(
1632                "Put 5 into cell B2.",
1633                "(defmacro (a) 1)",
1634                &["book one", "book three"]
1635            ),
1636            Some("phrasebook 2 given is not the one the stamp names".to_string())
1637        );
1638    }
1639
1640    #[test]
1641    fn a_built_workbook_reads_back() {
1642        let program = " Put 5 into cell B2.\n\n# a & b <c>\n   \nlast";
1643        let vla = "(sub main ()\n  (at-line 1\n  (set! (range \"b2\") 5)))";
1644        let bytes = build_xlsx(program, vla, "pre", &["book"]).unwrap();
1645        let read = read_build(&bytes).expect("a build reads back");
1646        assert_eq!(
1647            read.program_text,
1648            " Put 5 into cell B2.\n\n# a & b <c>\n   \nlast"
1649        );
1650        assert_eq!(read.sentences, 3);
1651        assert_eq!(read.stamp, Stamp::of(program, "pre", &["book"], None));
1652        assert!(read.host_sheets.is_empty());
1653        assert_eq!(
1654            read.stamp
1655                .disagreement(&read.program_text, "pre", &["book"]),
1656            None
1657        );
1658        // Not a build: not a zip; a zip with no stamp.
1659        let r = read_build(b"not a workbook at all").expect_err("not a zip");
1660        assert_eq!(r.id, "rebuild-not-a-build");
1661        assert!(r.text.contains("not a zip archive"));
1662        let plain =
1663            zip::write_stored(&[("xl/workbook.xml".to_string(), b"<workbook/>".to_vec())]).unwrap();
1664        let r = read_build(&plain).expect_err("no stamp");
1665        assert_eq!(r.id, "rebuild-not-a-build");
1666        assert!(r.text.contains("no Frazaro.Build stamp"), "{}", r.text);
1667        // The answer's one line.
1668        let yes = Rebuilt {
1669            sentences: 9,
1670            built_by: "0.7.1".to_string(),
1671            matches: true,
1672            why: String::new(),
1673            partial: false,
1674        };
1675        assert_eq!(
1676            yes.line(),
1677            "This workbook was built from these 9 sentences by Frazaro 0.7.1: yes."
1678        );
1679        let no = Rebuilt {
1680            why: "the prelude given is not the one the stamp names".to_string(),
1681            matches: false,
1682            ..yes
1683        };
1684        assert_eq!(
1685            no.line(),
1686            "This workbook was built from these 9 sentences by Frazaro 0.7.1: no. Why: the prelude given is not the one the stamp names."
1687        );
1688    }
1689
1690    #[test]
1691    fn rebuild_says_yes_to_its_own_build_and_no_to_a_changed_one() {
1692        let r = crate::api::english_rebuild_xlsx(GOLDEN, PRELUDE, &[ENGLISH])
1693            .unwrap_or_else(|r| panic!("the golden did not read back: {}", r.refusal));
1694        assert!(r.matches, "{}", r.why);
1695        assert_eq!(r.sentences, 9);
1696        assert_eq!(r.built_by, crate::VERSION);
1697        let mut changed = GOLDEN.to_vec();
1698        changed[100] ^= 1; // inside [Content_Types].xml, the first part
1699        let r = crate::api::english_rebuild_xlsx(&changed, PRELUDE, &[ENGLISH]).unwrap();
1700        assert!(!r.matches);
1701        assert!(r.why.contains("not what this core builds"), "{}", r.why);
1702        let r = crate::api::english_rebuild_xlsx(GOLDEN, "", &[ENGLISH]).unwrap();
1703        assert!(!r.matches);
1704        assert_eq!(r.why, "the prelude given is not the one the stamp names");
1705    }
1706
1707    #[test]
1708    fn a_build_into_a_model_leaves_the_model_alone() {
1709        use crate::sheet::merge::test_model;
1710        // Writes to Output (new) and Checks (new) land; the model's Model
1711        // and Notes may be gone to but never written into.
1712        let vla = "(sub main ()\n  (at-line 1\n  (set! (range \"b1\") 5))\n  (at-line 2\n  (begin (vlaensuresheet \"checks\") (. (worksheets \"checks\") activate)))\n  (at-line 3\n  (set! (. (range \"a1\") formula) \"=IFS(Model!B3>0,\\\"profit\\\",TRUE,\\\"loss\\\")\"))\n  (at-line 4\n  (begin (vlaensuresheet \"notes\") (. (worksheets \"notes\") activate))))";
1713        let program = "Put 5 into cell B1.\nWork on sheet Checks.\nPut formula \"=IFS(...)\" into cell A1.\nWork on sheet Notes.\n";
1714        let out =
1715            build_xlsx_into(program, vla, "", &["book"], test_model(None), "model.xlsx").unwrap();
1716        let read = read_build(&out).expect("the output reads back as a build");
1717        assert_eq!(read.host_sheets, ["Model", "Notes"]);
1718        assert_eq!(read.sentences, 4);
1719        let into = read.stamp.into.clone().expect("an into stamp");
1720        assert_eq!(into.style_base, 3);
1721        assert_eq!(into.cm, 1);
1722        assert_eq!(into.model_hex, crate::sha256::sha256_hex(&test_model(None)));
1723        let names: Vec<String> = zip::entries(&out)
1724            .unwrap()
1725            .into_iter()
1726            .map(|e| e.name)
1727            .collect();
1728        assert!(names.contains(&"xl/worksheets/frazaro_1.xml".to_string()));
1729        assert!(names.contains(&"xl/worksheets/frazaro_3.xml".to_string()));
1730        assert!(names.contains(&"xl/metadata.xml".to_string()));
1731        // A write into the model's own sheet is refused with its line.
1732        let bad = "(sub main ()\n  (at-line 1\n  (. (worksheets \"model\") activate))\n  (at-line 2\n  (set! (range \"a5\") 7)))";
1733        let r = build_xlsx_into(
1734            "Go to sheet Model.\nPut 7 into cell A5.\n",
1735            bad,
1736            "",
1737            &[],
1738            test_model(None),
1739            "model.xlsx",
1740        )
1741        .expect_err("a write into the model");
1742        assert_eq!(r.id, "build-into-model-sheet");
1743        assert!(
1744            r.text.contains("Line 2 writes into sheet Model"),
1745            "{}",
1746            r.text
1747        );
1748        // So is a cross-sheet put into it.
1749        let bad2 =
1750            "(sub main ()\n  (at-line 1\n  (set! (. (worksheets \"notes\") range \"a1\") 7)))";
1751        let r = build_xlsx_into(
1752            "Put 7 into cell A1 of sheet Notes.\n",
1753            bad2,
1754            "",
1755            &[],
1756            test_model(None),
1757            "model.xlsx",
1758        )
1759        .expect_err("a put into the model");
1760        assert_eq!(r.id, "build-into-model-sheet");
1761        assert!(r.text.contains("sheet Notes"), "{}", r.text);
1762        // A model that already has a Frazaro sheet cannot take another.
1763        let taken = String::from_utf8(test_model(None)).unwrap_or_default();
1764        let _ = taken;
1765        let with_frazaro = {
1766            let bytes = test_model(None);
1767            let m = Model::read(bytes, "m.xlsx").unwrap();
1768            let host = HostInfo {
1769                sheet_names: vec!["Model".to_string(), "FRAZARO".to_string()],
1770                ..m.host_info()
1771            };
1772            build_workbook_with(
1773                "Put 1 into cell A1.\n",
1774                "(sub main ()\n  (at-line 1\n  (set! (range \"a1\") 1)))",
1775                "",
1776                &[],
1777                Some(&host),
1778            )
1779        };
1780        let r = with_frazaro.expect_err("the name is taken");
1781        assert_eq!(r.id, "build-into-sheet-name-taken");
1782        assert!(
1783            r.text.contains("already has a sheet named FRAZARO"),
1784            "{}",
1785            r.text
1786        );
1787        // Not a model at all.
1788        let r = build_xlsx_into("", "(sub main ())", "", &[], b"nope".to_vec(), "x.xlsx")
1789            .expect_err("not a zip");
1790        assert_eq!(r.id, "build-into-not-a-workbook");
1791    }
1792
1793    #[test]
1794    fn the_into_stamp_round_trips() {
1795        let into = IntoStamp {
1796            model_hex: "AB".repeat(32),
1797            style_base: 7,
1798            cm: 2,
1799        };
1800        let stamp = Stamp::of("x", "p", &["b"], Some(into.clone()));
1801        let text = stamp.text();
1802        assert!(text.contains(&format!(
1803            "; into sha256:{} with 7 cell formats and metadata 2; sentences ",
1804            "AB".repeat(32)
1805        )));
1806        assert_eq!(Stamp::parse(&text), Some(stamp));
1807        assert_eq!(Stamp::parse(&text.replace(" with 7 ", " with x ")), None);
1808        let partial = Rebuilt {
1809            sentences: 6,
1810            built_by: "0.7.1".to_string(),
1811            matches: true,
1812            why: String::new(),
1813            partial: true,
1814        };
1815        assert_eq!(
1816            partial.line(),
1817            "This workbook's Frazaro sheets were built from these 6 sentences by Frazaro 0.7.1, into a workbook whose own sheets are not checked: yes."
1818        );
1819    }
1820
1821    /// KERNEL.20, the values folded at build time: a fresh build's file holds,
1822    /// for every formula the view computes, the value the view prints, read
1823    /// back by the reader, and nothing for a formula the view does not
1824    /// compute; `calc` over the file finds every computed value agreeing
1825    /// with the one it holds; `rebuild` still says yes.
1826    #[test]
1827    fn a_fresh_build_holds_each_value_its_view_computes() {
1828        let program = "Put 5 into cell B2.\n\
1829            Put formula \"=B2*2\" into cell B3.\n\
1830            Put formula \"=B2>3\" into cell C1.\n\
1831            Put formula \"=1/0\" into cell C2.\n\
1832            Put formula \"=\"\"a & <b> \"\"&B2\" into cell C3.\n\
1833            Put formula \"=VLOOKUP(1,B2:B3,1)\" into cell C4.\n\
1834            Put formula \"=C4+1\" into cell C5.\n\
1835            Put formula \"=0.1+0.2\" into cell C6.\n\
1836            Put formula \"=B9\" into cell C7.\n\
1837            Put formula \"=IFS(B2>3,\"\"big\"\",TRUE,\"\"small\"\")\" into cell D2.\n";
1838        let bytes = crate::api::english_build_xlsx(program, PRELUDE, &[ENGLISH])
1839            .unwrap_or_else(|r| panic!("the program did not build: {}", r.refusal));
1840        let relations = crate::api::reflect_relations(&bytes, "built").unwrap();
1841        let view = crate::api::english_view(program, PRELUDE, &[ENGLISH], "Output", None)
1842            .unwrap_or_else(|r| panic!("the program did not view: {}", r.refusal));
1843        let mut computed = 0;
1844        let mut not = 0;
1845        for line in view.lines().filter(|l| l.starts_with("(value ")) {
1846            // `"Output" "C3"`, then the datum.
1847            let rest = &line["(value ".len()..line.len() - 1];
1848            let address_end = rest.match_indices('"').nth(3).unwrap().0;
1849            let (cell, value) = (&rest[..=address_end], rest[address_end + 1..].trim_start());
1850            if value.starts_with("(not-computed") {
1851                not += 1;
1852                assert!(
1853                    !relations.contains(&format!("(cell {cell} ")),
1854                    "{cell} is not computed, yet the file holds a value for it:\n{relations}"
1855                );
1856            } else {
1857                computed += 1;
1858                let row = format!("(cell {cell} {value})");
1859                assert!(
1860                    relations.lines().any(|l| l == row),
1861                    "the file does not hold {row}:\n{relations}"
1862                );
1863            }
1864        }
1865        assert_eq!((computed, not), (7, 2));
1866        assert!(relations.contains("(cell \"Output\" \"C1\" true)"));
1867        assert!(relations.contains("(cell \"Output\" \"C2\" (error \"#DIV/0!\"))"));
1868        assert!(relations.contains("(cell \"Output\" \"C3\" \"a & <b> 5\")"));
1869        assert!(relations.contains("(cell \"Output\" \"C6\" 0.30000000000000004)"));
1870        assert!(relations.contains("(cell \"Output\" \"D2\" \"big\")"));
1871        for row in crate::api::calc_rows(&bytes, "built").unwrap().lines() {
1872            if row.contains("(not-computed ") {
1873                assert!(row.ends_with(" none unchecked)"), "{row}");
1874            } else {
1875                assert!(row.ends_with(" agree)"), "{row}");
1876            }
1877        }
1878        let again = crate::api::english_rebuild_xlsx(&bytes, PRELUDE, &[ENGLISH]).unwrap();
1879        assert!(again.matches, "{}", again.line());
1880    }
1881
1882    /// The build golden: the fixture, built with the prelude and english.vla,
1883    /// is `scripts/build/fixture_golden.xlsx` byte for byte. When this fails
1884    /// the writer changed: regenerate with `frazaro build
1885    /// scripts/build/fixture.txt --prelude scripts/prelude.vla --phrasebook
1886    /// scripts/polyglotta/english.vla --out scripts/build/fixture_golden.xlsx
1887    /// --replace`, open it in Excel (the owner's live pass), and raise
1888    /// tools/check_build_golden.ps1's floor if it grew.
1889    #[test]
1890    fn the_build_golden_is_reproduced() {
1891        let got = crate::api::english_build_xlsx(FIXTURE, PRELUDE, &[ENGLISH])
1892            .unwrap_or_else(|r| panic!("the fixture did not build: {}", r.refusal));
1893        same_bytes(&got, GOLDEN, "scripts/build/fixture_golden.xlsx");
1894    }
1895
1896    /// A build and its golden byte for byte, the first difference named by
1897    /// its part and its offset.
1898    fn same_bytes(got: &[u8], golden: &[u8], path: &str) {
1899        if got != golden {
1900            let at = got
1901                .iter()
1902                .zip(golden.iter())
1903                .position(|(a, b)| a != b)
1904                .unwrap_or(got.len().min(golden.len()));
1905            let part = zip::entries(golden)
1906                .unwrap_or_default()
1907                .into_iter()
1908                .find(|e| at >= e.data_offset && at < e.data_offset + e.size as usize)
1909                .map(|e| format!("{} at offset {}", e.name, at - e.data_offset))
1910                .unwrap_or_else(|| "a header".to_string());
1911            panic!(
1912                "the build differs from {path} at byte {at} of {} ({part}); got {} bytes",
1913                golden.len(),
1914                got.len()
1915            );
1916        }
1917    }
1918
1919    const SHEETS: &str = include_str!("../../scripts/build/sheets.txt");
1920    const SHEETS_GOLDEN: &[u8] = include_bytes!("../../scripts/build/sheets_golden.xlsx");
1921
1922    /// PORT.13: fills that read another sheet are written one formula a cell,
1923    /// as Excel writes them, and the fill on its own sheet stays shared; the
1924    /// golden is regenerated as the fixture's is, with sheets.txt and
1925    /// sheets_golden.xlsx, and opened in Excel.
1926    #[test]
1927    fn the_sheets_golden_is_reproduced() {
1928        let got = crate::api::english_build_xlsx(SHEETS, PRELUDE, &[ENGLISH])
1929            .unwrap_or_else(|r| panic!("sheets.txt did not build: {}", r.refusal));
1930        same_bytes(&got, SHEETS_GOLDEN, "scripts/build/sheets_golden.xlsx");
1931        let entries = zip::entries(&got).unwrap();
1932        let output = entries
1933            .iter()
1934            .find(|e| e.name == "xl/worksheets/frazaro_3.xml")
1935            .unwrap();
1936        let part = String::from_utf8(zip::stored_data(&got, output).unwrap().to_vec()).unwrap();
1937        assert!(
1938            part.contains("<c r=\"B2\" s=\"0\"><f>Data!A2*10</f><v>30</v></c>"),
1939            "{part}"
1940        );
1941        assert!(
1942            part.contains("<f>SUM(Data!$A$1:$A$3)+C3</f><v>61</v>"),
1943            "{part}"
1944        );
1945        assert!(
1946            part.contains("<f t=\"shared\" ref=\"C1:C3\" si=\"0\">B1+1</f>"),
1947            "{part}"
1948        );
1949        assert_eq!(part.matches("t=\"shared\"").count(), 3, "{part}");
1950        let r = crate::api::english_rebuild_xlsx(&got, PRELUDE, &[ENGLISH])
1951            .unwrap_or_else(|r| panic!("the golden did not read back: {}", r.refusal));
1952        assert!(r.matches, "{}", r.why);
1953    }
1954}