1use 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
52pub const STAMP_NAME: &str = "Frazaro.Build";
54
55pub const FRAZARO_SHEET: &str = "Frazaro";
58pub const OUTPUT_SHEET: &str = "Output";
61pub const LAVENDER: Rgb = Rgb(247, 244, 252);
63pub const RESULT_GRAY: Rgb = Rgb(242, 242, 242);
65pub const OK_GREEN: Rgb = Rgb(221, 235, 221);
67pub const SENTENCE_COLUMN_WIDTH: f64 = 72.0;
69pub const RESULT_COLUMN_WIDTH: f64 = 60.0;
70pub const MAX_CELLS_PER_WRITE: u64 = 1_048_576;
72const MAX_INLINE_DEPTH: u32 = 32;
74
75pub 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 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#[derive(Clone, Debug, PartialEq)]
150enum Value {
151 Num(f64),
152 Text(String),
153 Bool(bool),
154}
155
156fn 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
173fn 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 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#[derive(Clone)]
201struct Step {
202 params: usize,
203 body: Vec<Form>,
204}
205
206enum Flow {
208 Next,
209 Exit,
210}
211
212#[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 host_sheets: Vec<String>,
225 current: Option<Target>,
228 bindings: HashMap<String, Value>,
230 steps: HashMap<String, Step>,
232 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 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 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..]; }
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(()); };
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 "dim" | "vla-report-error" => Ok(Flow::Next),
366 "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); }
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 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 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 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 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 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 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?; 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 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 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 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 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 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 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 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
730fn 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#[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#[derive(Clone, Debug, PartialEq, Eq)]
829pub struct IntoStamp {
830 pub model_hex: String,
832 pub style_base: u32,
834 pub cm: u32,
837}
838
839#[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 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 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
953fn stamp_formula(stamp: &Stamp) -> String {
955 format!("\"{}\"", stamp.text().replace('"', "\"\""))
956}
957
958#[derive(Clone, Debug, PartialEq, Eq)]
961pub struct BuildFile {
962 pub stamp: Stamp,
963 pub program_text: String,
965 pub sentences: usize,
967 pub host_sheets: Vec<String>,
970}
971
972#[derive(Clone, Debug, PartialEq, Eq)]
974pub struct Rebuilt {
975 pub sentences: usize,
976 pub built_by: String,
977 pub matches: bool,
978 pub why: String,
980 pub partial: bool,
983}
984
985impl Rebuilt {
986 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
1007pub 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
1019fn 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
1028fn 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
1043pub 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("ed.replace("\"\"", "\""))
1060 .ok_or_else(|| refuse_rebuild("its Frazaro.Build stamp does not read"))?;
1061 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
1102pub 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
1170pub 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
1180pub 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
1199pub 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 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 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 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 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 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 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 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 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 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 let r =
1502 build_workbook(program, "(include \"alien.vla\")\n", "", &[]).expect_err("an include");
1503 assert_eq!(r.id, "vla-include-cannot-read");
1504 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 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 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 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; 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 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 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 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 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 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 #[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 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 #[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 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 #[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}