1use crate::console::{read_line, Console, Pager};
19use crate::storage::Storage;
20use crate::strings::parse_boolean;
21use async_trait::async_trait;
22use endbasic_core::ast::ExprType;
23use endbasic_core::compiler::{compile, ArgSepSyntax, RequiredValueSyntax, SingularArgSyntax};
24use endbasic_core::exec::{Machine, Scope, StopReason};
25use endbasic_core::parser::parse;
26use endbasic_core::syms::{
27 CallError, CallResult, Callable, CallableMetadata, CallableMetadataBuilder,
28};
29use std::borrow::Cow;
30use std::cell::RefCell;
31use std::io;
32use std::path::PathBuf;
33use std::rc::Rc;
34use std::str;
35
36const CATEGORY: &str = "Stored program
38The EndBASIC interpreter has a piece of read/write memory called the \"stored program\". This \
39memory serves to maintain the code of a program you edit and manipulate right from the \
40interpreter.
41The common flow to interact with a stored program is to load a program from disk using the LOAD \
42command, modify its contents via the EDIT command, execute the program via the RUN command, and \
43finally save the new or modified program via the SAVE command.
44Be aware that the stored program's content is lost whenever you load a program, exit the \
45interpreter, or use the NEW command. These operations will ask you to save the program if you \
46have forgotten to do so, but it's better to get in the habit of saving often.
47See the \"File system\" help topic for information on where the programs can be saved and loaded \
48from.";
49
50pub const BREAK_MSG: &str = "**** BREAK ****";
52
53#[async_trait(?Send)]
55pub trait Program {
56 fn is_dirty(&self) -> bool;
59
60 async fn edit(&mut self, console: &mut dyn Console) -> io::Result<()>;
62
63 fn load(&mut self, name: Option<&str>, text: &str);
66
67 fn name(&self) -> Option<&str>;
69
70 fn set_name(&mut self, name: &str);
72
73 fn text(&self) -> String;
75}
76
77#[derive(Default)]
79pub(crate) struct ImmutableProgram {
80 name: Option<String>,
81 text: String,
82}
83
84#[async_trait(?Send)]
85impl Program for ImmutableProgram {
86 fn is_dirty(&self) -> bool {
87 false
88 }
89
90 async fn edit(&mut self, _console: &mut dyn Console) -> io::Result<()> {
91 Err(io::Error::new(io::ErrorKind::Other, "Editing not supported"))
92 }
93
94 fn load(&mut self, name: Option<&str>, text: &str) {
95 self.name = name.map(str::to_owned);
96 text.clone_into(&mut self.text);
97 }
98
99 fn name(&self) -> Option<&str> {
100 self.name.as_deref()
101 }
102
103 fn set_name(&mut self, name: &str) {
104 self.name = Some(name.to_owned());
105 }
106
107 fn text(&self) -> String {
108 self.text.clone()
109 }
110}
111
112fn add_extension<S: Into<PathBuf>>(path: S) -> io::Result<String> {
114 let mut path = path.into();
115
116 if let Some(ext) = path.extension() {
117 if ext != "bas" && ext != "BAS" {
118 return Err(io::Error::new(io::ErrorKind::InvalidInput, "Invalid filename extension"));
119 }
120 } else {
121 let mut ext = "BAS";
125 for ch in path.to_string_lossy().chars() {
126 if ch.is_ascii_lowercase() {
127 ext = "bas";
128 break;
129 }
130 }
131 path.set_extension(ext);
132 }
133 Ok(path.to_str().expect("Path came from a String").to_owned())
134}
135
136pub async fn continue_if_modified(
138 program: &dyn Program,
139 console: &mut dyn Console,
140) -> io::Result<bool> {
141 if !program.is_dirty() {
142 return Ok(true);
143 }
144
145 match program.name() {
146 Some(name) => console.print(&format!("Current program {} has unsaved changes!", name))?,
147 None => console.print("Current program has unsaved changes and has never been saved!")?,
148 }
149 let answer = read_line(console, "Discard and continue (y/N)? ", "", None).await?;
150 Ok(parse_boolean(&answer).unwrap_or(false))
151}
152
153pub struct DisasmCommand {
155 metadata: CallableMetadata,
156 console: Rc<RefCell<dyn Console>>,
157 program: Rc<RefCell<dyn Program>>,
158}
159
160impl DisasmCommand {
161 pub fn new(console: Rc<RefCell<dyn Console>>, program: Rc<RefCell<dyn Program>>) -> Rc<Self> {
163 Rc::from(Self {
164 metadata: CallableMetadataBuilder::new("DISASM")
165 .with_syntax(&[(&[], None)])
166 .with_category(CATEGORY)
167 .with_description(
168 "Disassembles the stored program.
169The assembly code printed by this command is provided as a tool to understand how high level code \
170gets translated to the machine code of a fictitious stack-based machine. Note, however, that the \
171assembly code cannot be reassembled nor modified at this point.",
172 )
173 .build(),
174 console,
175 program,
176 })
177 }
178}
179
180#[async_trait(?Send)]
181impl Callable for DisasmCommand {
182 fn metadata(&self) -> &CallableMetadata {
183 &self.metadata
184 }
185
186 async fn exec(&self, scope: Scope<'_>, machine: &mut Machine) -> CallResult {
187 debug_assert_eq!(0, scope.nargs());
188
189 let image = {
192 let program = self.program.borrow_mut();
193 let ast = match parse(&mut program.text().as_bytes()) {
194 Ok(ast) => ast,
195 Err(e) => return Err(CallError::NestedError(e.to_string())),
196 };
197 compile(ast, machine.get_symbols())?
198 };
199
200 let mut console = self.console.borrow_mut();
201 let mut pager = Pager::new(&mut *console)?;
202 for (addr, instr) in image.instrs.iter().enumerate() {
203 let (op, args) = instr.repr();
204 let mut line = format!("{:04x} {}", addr, op);
205 if let Some(args) = args {
206 while line.len() < 20 {
207 line.push(' ');
208 }
209 line += &args;
210 }
211 if let Some(pos) = instr.pos() {
212 while line.len() < 44 {
213 line.push(' ');
214 }
215 line += &format!(" # {}:{}", pos.line, pos.col);
216 }
217 pager.print(&line).await?;
218 }
219 pager.print("").await?;
220
221 Ok(())
222 }
223}
224
225pub struct KillCommand {
230 metadata: CallableMetadata,
231 storage: Rc<RefCell<Storage>>,
232}
233
234impl KillCommand {
235 pub fn new(storage: Rc<RefCell<Storage>>) -> Rc<Self> {
237 Rc::from(Self {
238 metadata: CallableMetadataBuilder::new("KILL")
239 .with_syntax(&[(
240 &[SingularArgSyntax::RequiredValue(
241 RequiredValueSyntax {
242 name: Cow::Borrowed("filename"),
243 vtype: ExprType::Text,
244 },
245 ArgSepSyntax::End,
246 )],
247 None,
248 )])
249 .with_category(CATEGORY)
250 .with_description(
251 "Deletes the given program.
252The filename must be a string and must be a valid EndBASIC path. The .BAS extension is optional \
253but, if present, it must be .BAS.
254See the \"File system\" help topic for information on the path syntax.",
255 )
256 .build(),
257 storage,
258 })
259 }
260}
261
262#[async_trait(?Send)]
263impl Callable for KillCommand {
264 fn metadata(&self) -> &CallableMetadata {
265 &self.metadata
266 }
267
268 async fn exec(&self, mut scope: Scope<'_>, _machine: &mut Machine) -> CallResult {
269 debug_assert_eq!(1, scope.nargs());
270 let name = scope.pop_string();
271
272 let name = add_extension(name)?;
273 self.storage.borrow_mut().delete(&name).await?;
274
275 Ok(())
276 }
277}
278
279pub struct EditCommand {
281 metadata: CallableMetadata,
282 console: Rc<RefCell<dyn Console>>,
283 program: Rc<RefCell<dyn Program>>,
284}
285
286impl EditCommand {
287 pub fn new(console: Rc<RefCell<dyn Console>>, program: Rc<RefCell<dyn Program>>) -> Rc<Self> {
289 Rc::from(Self {
290 metadata: CallableMetadataBuilder::new("EDIT")
291 .with_syntax(&[(&[], None)])
292 .with_category(CATEGORY)
293 .with_description("Interactively edits the stored program.")
294 .build(),
295 console,
296 program,
297 })
298 }
299}
300
301#[async_trait(?Send)]
302impl Callable for EditCommand {
303 fn metadata(&self) -> &CallableMetadata {
304 &self.metadata
305 }
306
307 async fn exec(&self, scope: Scope<'_>, _machine: &mut Machine) -> CallResult {
308 debug_assert_eq!(0, scope.nargs());
309
310 let mut console = self.console.borrow_mut();
311 let mut program = self.program.borrow_mut();
312 program.edit(&mut *console).await?;
313 Ok(())
314 }
315}
316
317pub struct ListCommand {
319 metadata: CallableMetadata,
320 console: Rc<RefCell<dyn Console>>,
321 program: Rc<RefCell<dyn Program>>,
322}
323
324impl ListCommand {
325 pub fn new(console: Rc<RefCell<dyn Console>>, program: Rc<RefCell<dyn Program>>) -> Rc<Self> {
327 Rc::from(Self {
328 metadata: CallableMetadataBuilder::new("LIST")
329 .with_syntax(&[(&[], None)])
330 .with_category(CATEGORY)
331 .with_description("Prints the currently-loaded program.")
332 .build(),
333 console,
334 program,
335 })
336 }
337}
338
339#[async_trait(?Send)]
340impl Callable for ListCommand {
341 fn metadata(&self) -> &CallableMetadata {
342 &self.metadata
343 }
344
345 async fn exec(&self, scope: Scope<'_>, _machine: &mut Machine) -> CallResult {
346 debug_assert_eq!(0, scope.nargs());
347
348 let mut console = self.console.borrow_mut();
349 let mut pager = Pager::new(&mut *console)?;
350 for line in self.program.borrow().text().lines() {
351 pager.print(line).await?;
352 }
353 Ok(())
354 }
355}
356
357pub struct LoadCommand {
359 metadata: CallableMetadata,
360 console: Rc<RefCell<dyn Console>>,
361 storage: Rc<RefCell<Storage>>,
362 program: Rc<RefCell<dyn Program>>,
363}
364
365impl LoadCommand {
366 pub fn new(
369 console: Rc<RefCell<dyn Console>>,
370 storage: Rc<RefCell<Storage>>,
371 program: Rc<RefCell<dyn Program>>,
372 ) -> Rc<Self> {
373 Rc::from(Self {
374 metadata: CallableMetadataBuilder::new("LOAD")
375 .with_syntax(&[(
376 &[SingularArgSyntax::RequiredValue(
377 RequiredValueSyntax {
378 name: Cow::Borrowed("filename"),
379 vtype: ExprType::Text,
380 },
381 ArgSepSyntax::End,
382 )],
383 None,
384 )])
385 .with_category(CATEGORY)
386 .with_description(
387 "Loads the given program.
388The filename must be a string and must be a valid EndBASIC path. The .BAS extension is optional \
389but, if present, it must be .BAS.
390Any previously stored program is discarded from memory, but LOAD will pause to ask before \
391discarding any unsaved modifications.
392See the \"File system\" help topic for information on the path syntax.",
393 )
394 .build(),
395 console,
396 storage,
397 program,
398 })
399 }
400}
401
402#[async_trait(?Send)]
403impl Callable for LoadCommand {
404 fn metadata(&self) -> &CallableMetadata {
405 &self.metadata
406 }
407
408 async fn exec(&self, mut scope: Scope<'_>, machine: &mut Machine) -> CallResult {
409 debug_assert_eq!(1, scope.nargs());
410 let pathname = scope.pop_string();
411
412 if continue_if_modified(&*self.program.borrow(), &mut *self.console.borrow_mut()).await? {
413 let pathname = add_extension(pathname)?;
414 let content = self.storage.borrow().get(&pathname).await?;
415 let full_name = self.storage.borrow().make_canonical(&pathname)?;
416 self.program.borrow_mut().load(Some(&full_name), &content);
417 machine.clear();
418 } else {
419 self.console
420 .borrow_mut()
421 .print("LOAD aborted; use SAVE to save your current changes.")?;
422 }
423 Ok(())
424 }
425}
426
427pub struct NewCommand {
429 metadata: CallableMetadata,
430 console: Rc<RefCell<dyn Console>>,
431 program: Rc<RefCell<dyn Program>>,
432}
433
434impl NewCommand {
435 pub fn new(console: Rc<RefCell<dyn Console>>, program: Rc<RefCell<dyn Program>>) -> Rc<Self> {
438 Rc::from(Self {
439 metadata: CallableMetadataBuilder::new("NEW")
440 .with_syntax(&[(&[], None)])
441 .with_category(CATEGORY)
442 .with_description(
443 "Restores initial machine state and creates a new program.
444This command resets the machine to a pristine state by clearing all user-defined variables \
445and restoring the state of shared resources. These resources include: the console, whose color \
446and video syncing bit are reset; and the GPIO pins, which are set to their default state.
447The stored program is also discarded from memory, but NEW will pause to ask before discarding \
448any unsaved modifications. To reset resources but avoid clearing the stored program, use CLEAR \
449instead.",
450 )
451 .build(),
452 console,
453 program,
454 })
455 }
456}
457
458#[async_trait(?Send)]
459impl Callable for NewCommand {
460 fn metadata(&self) -> &CallableMetadata {
461 &self.metadata
462 }
463
464 async fn exec(&self, scope: Scope<'_>, machine: &mut Machine) -> CallResult {
465 debug_assert_eq!(0, scope.nargs());
466
467 if continue_if_modified(&*self.program.borrow(), &mut *self.console.borrow_mut()).await? {
468 self.program.borrow_mut().load(None, "");
469 machine.clear();
470 } else {
471 self.console
472 .borrow_mut()
473 .print("NEW aborted; use SAVE to save your current changes.")?;
474 }
475 Ok(())
476 }
477}
478
479pub struct RunCommand {
481 metadata: CallableMetadata,
482 console: Rc<RefCell<dyn Console>>,
483 program: Rc<RefCell<dyn Program>>,
484}
485
486impl RunCommand {
487 pub fn new(console: Rc<RefCell<dyn Console>>, program: Rc<RefCell<dyn Program>>) -> Rc<Self> {
491 Rc::from(Self {
492 metadata: CallableMetadataBuilder::new("RUN")
493 .with_syntax(&[(&[], None)])
494 .with_category(CATEGORY)
495 .with_description(
496 "Runs the stored program.
497This issues a CLEAR operation before starting the program to prevent previous leftover state \
498from interfering with the new execution.",
499 )
500 .build(),
501 console,
502 program,
503 })
504 }
505}
506
507#[async_trait(?Send)]
508impl Callable for RunCommand {
509 fn metadata(&self) -> &CallableMetadata {
510 &self.metadata
511 }
512
513 async fn exec(&self, scope: Scope<'_>, machine: &mut Machine) -> CallResult {
514 debug_assert_eq!(0, scope.nargs());
515
516 machine.clear();
517 let program = self.program.borrow().text();
518 let stop_reason = match machine.exec(&mut program.as_bytes()).await {
519 Ok(stop_reason) => stop_reason,
520 Err(e) => return Err(CallError::NestedError(format!("{}", e))),
521 };
522 match stop_reason {
523 StopReason::Break => self.console.borrow_mut().print(BREAK_MSG)?,
524 stop_reason => {
525 if stop_reason.as_exit_code() != 0 {
526 self.console.borrow_mut().print(&format!(
527 "Program exited with code {}",
528 stop_reason.as_exit_code()
529 ))?;
530 }
531 }
532 }
533 Ok(())
534 }
535}
536
537pub struct SaveCommand {
539 metadata: CallableMetadata,
540 console: Rc<RefCell<dyn Console>>,
541 storage: Rc<RefCell<Storage>>,
542 program: Rc<RefCell<dyn Program>>,
543}
544
545impl SaveCommand {
546 pub fn new(
548 console: Rc<RefCell<dyn Console>>,
549 storage: Rc<RefCell<Storage>>,
550 program: Rc<RefCell<dyn Program>>,
551 ) -> Rc<Self> {
552 Rc::from(Self {
553 metadata: CallableMetadataBuilder::new("SAVE")
554 .with_syntax(&[
555 (&[], None),
556 (
557 &[SingularArgSyntax::RequiredValue(
558 RequiredValueSyntax {
559 name: Cow::Borrowed("filename"),
560 vtype: ExprType::Text,
561 },
562 ArgSepSyntax::End,
563 )],
564 None,
565 ),
566 ])
567 .with_category(CATEGORY)
568 .with_description(
569 "Saves the current program in memory to the given filename.
570The filename must be a string and must be a valid EndBASIC path. The .BAS extension is optional \
571but, if present, it must be .BAS.
572If no filename is given, SAVE will try to use the filename of the loaded program (if any) and \
573will fail if no name has been given yet.
574See the \"File system\" help topic for information on the path syntax.",
575 )
576 .build(),
577 console,
578 storage,
579 program,
580 })
581 }
582}
583
584#[async_trait(?Send)]
585impl Callable for SaveCommand {
586 fn metadata(&self) -> &CallableMetadata {
587 &self.metadata
588 }
589
590 async fn exec(&self, mut scope: Scope<'_>, _machine: &mut Machine) -> CallResult {
591 let name = if scope.nargs() == 0 {
592 match self.program.borrow().name() {
593 Some(name) => name.to_owned(),
594 None => {
595 return Err(CallError::IoError(io::Error::new(
596 io::ErrorKind::InvalidInput,
597 "Unnamed program; please provide a filename".to_owned(),
598 )));
599 }
600 }
601 } else {
602 debug_assert_eq!(1, scope.nargs());
603 scope.pop_string()
604 };
605
606 let name = add_extension(name)?;
607 let full_name = self.storage.borrow().make_canonical(&name)?;
608 let content = self.program.borrow().text();
609 self.storage.borrow_mut().put(&name, &content).await?;
610 self.program.borrow_mut().set_name(&full_name);
611
612 self.console.borrow_mut().print(&format!("Saved as {}", full_name))?;
613
614 Ok(())
615 }
616}
617
618pub fn add_all(
621 machine: &mut Machine,
622 program: Rc<RefCell<dyn Program>>,
623 console: Rc<RefCell<dyn Console>>,
624 storage: Rc<RefCell<Storage>>,
625) {
626 machine.add_callable(DisasmCommand::new(console.clone(), program.clone()));
627 machine.add_callable(EditCommand::new(console.clone(), program.clone()));
628 machine.add_callable(KillCommand::new(storage.clone()));
629 machine.add_callable(ListCommand::new(console.clone(), program.clone()));
630 machine.add_callable(LoadCommand::new(console.clone(), storage.clone(), program.clone()));
631 machine.add_callable(NewCommand::new(console.clone(), program.clone()));
632 machine.add_callable(RunCommand::new(console.clone(), program.clone()));
633 machine.add_callable(SaveCommand::new(console, storage, program));
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639 use crate::console::{CharsXY, Key};
640 use crate::testutils::*;
641
642 const NO_ANSWERS: &[&str] =
643 &["n\n", "N\n", "no\n", "NO\n", "false\n", "FALSE\n", "xyz\n", "\n", "1\n"];
644
645 const YES_ANSWERS: &[&str] = &["y\n", "yes\n", "Y\n", "YES\n", "true\n", "TRUE\n"];
646
647 #[test]
648 fn test_kill_ok() {
649 for p in &["foo", "foo.bas"] {
650 Tester::default()
651 .set_program(Some("foo.bas"), "Leave me alone")
652 .write_file("bar.bas", "")
653 .write_file("foo.bas", "line 1\n line 2\n")
654 .run(format!(r#"KILL "{}""#, p))
655 .expect_program(Some("foo.bas"), "Leave me alone")
656 .expect_file("MEMORY:/bar.bas", "")
657 .check();
658 }
659 }
660
661 #[test]
662 fn test_kill_errors() {
663 check_load_save_common_errors("KILL");
664
665 Tester::default()
666 .run("KILL")
667 .expect_compilation_err("1:1: In call to KILL: expected filename$")
668 .check();
669
670 check_stmt_err("1:1: In call to KILL: Entry not found", r#"KILL "missing-file""#);
671
672 Tester::default()
673 .write_file("mismatched-extension.bat", "")
674 .run(r#"KILL "mismatched-extension""#)
675 .expect_err("1:1: In call to KILL: Entry not found")
676 .expect_file("MEMORY:/mismatched-extension.bat", "")
677 .check();
678 }
679
680 #[test]
681 fn test_disasm_nothing() {
682 Tester::default().run("DISASM").expect_prints([""]).check();
683 }
684
685 #[test]
686 fn test_disasm_ok() {
687 Tester::default()
688 .set_program(None, "A = 2 + 3")
689 .run("DISASM")
690 .expect_prints([
691 "0000 PUSH% 2 # 1:5",
692 "0001 PUSH% 3 # 1:9",
693 "0002 ADD% # 1:7",
694 "0003 SETV A",
695 "",
696 ])
697 .expect_program(None as Option<&str>, "A = 2 + 3")
698 .check();
699 }
700
701 #[test]
702 fn test_disasm_paging() {
703 let t = Tester::default();
704 t.get_console().borrow_mut().set_interactive(true);
705 t.get_console().borrow_mut().set_size_chars(CharsXY { x: 80, y: 4 });
706 t.get_console().borrow_mut().add_input_keys(&[Key::NewLine]);
707 t.set_program(None, "A = 2 + 3")
708 .run("DISASM")
709 .expect_prints([
710 "0000 PUSH% 2 # 1:5",
711 "0001 PUSH% 3 # 1:9",
712 "0002 ADD% # 1:7",
713 " << Press any key for more; ESC or Ctrl+C to stop >> ",
714 "0003 SETV A",
715 "",
716 ])
717 .expect_program(None as Option<&str>, "A = 2 + 3")
718 .check();
719 }
720
721 #[test]
722 fn test_disasm_code_errors() {
723 Tester::default()
724 .set_program(None, "A = 3 +")
725 .run("DISASM")
726 .expect_err("1:7: Not enough values to apply operator")
727 .expect_program(None as Option<&str>, "A = 3 +")
728 .check();
729 }
730
731 #[test]
732 fn test_disasm_errors() {
733 check_stmt_compilation_err("1:1: In call to DISASM: expected no arguments", "DISASM 2");
734 }
735
736 #[test]
737 fn test_edit_ok() {
738 Tester::default()
739 .set_program(Some("foo.bas"), "previous\n")
740 .add_input_chars("new line\n")
741 .run("EDIT")
742 .expect_program(Some("foo.bas"), "previous\nnew line\n")
743 .check();
744 }
745
746 #[test]
747 fn test_edit_errors() {
748 check_stmt_compilation_err("1:1: In call to EDIT: expected no arguments", "EDIT 1");
749 }
750
751 #[test]
752 fn test_list_ok() {
753 Tester::default().run("LIST").check();
754
755 Tester::default()
756 .set_program(None, "one\n\nthree\n")
757 .run("LIST")
758 .expect_prints(["one", "", "three"])
759 .expect_program(None as Option<&str>, "one\n\nthree\n")
760 .check();
761 }
762
763 #[test]
764 fn test_list_paging() {
765 let t = Tester::default();
766 t.get_console().borrow_mut().set_interactive(true);
767 t.get_console().borrow_mut().set_size_chars(CharsXY { x: 30, y: 5 });
768 t.get_console().borrow_mut().add_input_keys(&[Key::NewLine]);
769 t.set_program(None, "one\n\nthree\nfour\nfive")
770 .run("LIST")
771 .expect_prints(["one", "", "three", "four", " << More >> ", "five"])
772 .expect_program(None as Option<&str>, "one\n\nthree\nfour\nfive")
773 .check();
774 }
775
776 #[test]
777 fn test_list_errors() {
778 check_stmt_compilation_err("1:1: In call to LIST: expected no arguments", "LIST 2");
779 }
780
781 #[test]
782 fn test_load_ok() {
783 let content = "line 1\n\n line 2\n";
784 for (explicit, canonical) in &[
785 ("foo", "MEMORY:foo.bas"),
786 ("foo.bas", "MEMORY:foo.bas"),
787 ("BAR", "MEMORY:BAR.BAS"),
788 ("BAR.BAS", "MEMORY:BAR.BAS"),
789 ("Baz", "MEMORY:Baz.bas"),
790 ] {
791 Tester::default()
792 .write_file("foo.bas", content)
793 .write_file("foo.bak", "")
794 .write_file("BAR.BAS", content)
795 .write_file("Baz.bas", content)
796 .run(format!(r#"LOAD "{}""#, explicit))
797 .expect_clear()
798 .expect_program(Some(*canonical), "line 1\n\n line 2\n")
799 .expect_file("MEMORY:/foo.bas", content)
800 .expect_file("MEMORY:/foo.bak", "")
801 .expect_file("MEMORY:/BAR.BAS", content)
802 .expect_file("MEMORY:/Baz.bas", content)
803 .check();
804 }
805 }
806
807 #[test]
808 fn test_load_dirty_no_name_abort() {
809 for answer in NO_ANSWERS {
810 Tester::default()
811 .add_input_chars("modified unnamed file\n")
812 .add_input_chars(answer)
813 .write_file("other.bas", "other file\n")
814 .run("EDIT: LOAD \"other.bas\"")
815 .expect_prints([
816 "Current program has unsaved changes and has never been saved!",
817 "LOAD aborted; use SAVE to save your current changes.",
818 ])
819 .expect_program(None as Option<&str>, "modified unnamed file\n")
820 .expect_file("MEMORY:/other.bas", "other file\n")
821 .check();
822 }
823 }
824
825 #[test]
826 fn test_load_dirty_no_name_continue() {
827 for answer in YES_ANSWERS {
828 Tester::default()
829 .add_input_chars("modified unnamed file\n")
830 .add_input_chars(answer)
831 .write_file("other.bas", "other file\n")
832 .run("EDIT: LOAD \"other.bas\"")
833 .expect_prints(["Current program has unsaved changes and has never been saved!"])
834 .expect_clear()
835 .expect_program(Some("MEMORY:other.bas"), "other file\n")
836 .expect_file("MEMORY:/other.bas", "other file\n")
837 .check();
838 }
839 }
840
841 #[test]
842 fn test_load_dirty_with_name_abort() {
843 for answer in NO_ANSWERS {
844 Tester::default()
845 .add_input_chars("modified named file\n")
846 .add_input_chars(answer)
847 .write_file("other.bas", "other file\n")
848 .set_program(Some("MEMORY:/named.bas"), "previous contents\n")
849 .run("EDIT: LOAD \"other.bas\"")
850 .expect_prints([
851 "Current program MEMORY:/named.bas has unsaved changes!",
852 "LOAD aborted; use SAVE to save your current changes.",
853 ])
854 .expect_program(
855 Some("MEMORY:/named.bas"),
856 "previous contents\nmodified named file\n",
857 )
858 .expect_file("MEMORY:/other.bas", "other file\n")
859 .check();
860 }
861 }
862
863 #[test]
864 fn test_load_dirty_with_name_continue() {
865 for answer in YES_ANSWERS {
866 Tester::default()
867 .add_input_chars("modified unnamed file\n")
868 .add_input_chars(answer)
869 .write_file("other.bas", "other file\n")
870 .set_program(Some("MEMORY:/named.bas"), "previous contents\n")
871 .run("EDIT: LOAD \"other.bas\"")
872 .expect_prints(["Current program MEMORY:/named.bas has unsaved changes!"])
873 .expect_clear()
874 .expect_program(Some("MEMORY:other.bas"), "other file\n")
875 .expect_file("MEMORY:/other.bas", "other file\n")
876 .check();
877 }
878 }
879
880 fn check_load_save_common_errors(cmd: &str) {
882 Tester::default()
883 .run(format!("{} 3", cmd))
884 .expect_compilation_err(format!(
885 "1:1: In call to {}: 1:{}: INTEGER is not a STRING",
886 cmd,
887 cmd.len() + 2,
888 ))
889 .check();
890
891 Tester::default()
892 .run(format!(r#"{} "a/b.bas""#, cmd))
893 .expect_err(format!("1:1: In call to {}: Too many / separators in path 'a/b.bas'", cmd))
894 .check();
895
896 for p in &["foo.bak", "foo.ba", "foo.basic"] {
897 Tester::default()
898 .run(format!(r#"{} "{}""#, cmd, p))
899 .expect_err(format!("1:1: In call to {}: Invalid filename extension", cmd))
900 .check();
901 }
902 }
903
904 #[test]
905 fn test_load_errors() {
906 check_load_save_common_errors("LOAD");
907
908 Tester::default()
909 .run("LOAD")
910 .expect_compilation_err("1:1: In call to LOAD: expected filename$")
911 .check();
912
913 check_stmt_err("1:1: In call to LOAD: Entry not found", r#"LOAD "missing-file""#);
914
915 Tester::default()
916 .write_file("mismatched-extension.bat", "")
917 .run(r#"LOAD "mismatched-extension""#)
918 .expect_err("1:1: In call to LOAD: Entry not found")
919 .expect_file("MEMORY:/mismatched-extension.bat", "")
920 .check();
921 }
922
923 #[test]
924 fn test_new_nothing() {
925 Tester::default().run("NEW").expect_clear().check();
926 }
927
928 #[test]
929 fn test_new_clears_program_and_variables() {
930 Tester::default()
931 .set_program(Some("previous.bas"), "some stuff")
932 .run("a = 3: NEW")
933 .expect_clear()
934 .check();
935 }
936
937 #[test]
938 fn test_new_dirty_no_name_abort() {
939 for answer in NO_ANSWERS {
940 Tester::default()
941 .add_input_chars("modified unnamed file\n")
942 .add_input_chars(answer)
943 .run("EDIT: NEW")
944 .expect_prints([
945 "Current program has unsaved changes and has never been saved!",
946 "NEW aborted; use SAVE to save your current changes.",
947 ])
948 .expect_program(None as Option<&str>, "modified unnamed file\n")
949 .check();
950 }
951 }
952
953 #[test]
954 fn test_new_dirty_no_name_continue() {
955 for answer in YES_ANSWERS {
956 Tester::default()
957 .add_input_chars("modified unnamed file\n")
958 .add_input_chars(answer)
959 .run("EDIT: NEW")
960 .expect_prints(["Current program has unsaved changes and has never been saved!"])
961 .expect_clear()
962 .check();
963 }
964 }
965
966 #[test]
967 fn test_new_dirty_with_name_abort() {
968 for answer in NO_ANSWERS {
969 Tester::default()
970 .add_input_chars("modified named file\n")
971 .add_input_chars(answer)
972 .set_program(Some("MEMORY:/named.bas"), "previous contents\n")
973 .run("EDIT: NEW")
974 .expect_prints([
975 "Current program MEMORY:/named.bas has unsaved changes!",
976 "NEW aborted; use SAVE to save your current changes.",
977 ])
978 .expect_program(
979 Some("MEMORY:/named.bas"),
980 "previous contents\nmodified named file\n",
981 )
982 .check();
983 }
984 }
985
986 #[test]
987 fn test_new_dirty_with_name_continue() {
988 for answer in YES_ANSWERS {
989 Tester::default()
990 .add_input_chars("modified named file\n")
991 .add_input_chars(answer)
992 .set_program(Some("MEMORY:/named.bas"), "previous contents\n")
993 .run("EDIT: NEW")
994 .expect_prints(["Current program MEMORY:/named.bas has unsaved changes!"])
995 .expect_clear()
996 .check();
997 }
998 }
999
1000 #[test]
1001 fn test_new_errors() {
1002 check_stmt_compilation_err("1:1: In call to NEW: expected no arguments", "NEW 10");
1003 }
1004
1005 #[test]
1006 fn test_run_nothing() {
1007 Tester::default().run("RUN").expect_clear().check();
1008 }
1009
1010 #[test]
1011 fn test_run_clears_before_execution_only() {
1012 let program = "DIM a(1) AS INTEGER: a(0) = 123";
1013 let mut t = Tester::default().set_program(Some("untouched.bas"), program);
1014 t.run("DIM a(1) AS STRING: RUN")
1015 .expect_array_simple("a", ExprType::Integer, vec![123.into()])
1016 .expect_clear()
1017 .expect_program(Some("untouched.bas"), program)
1018 .check();
1019 t.run("RUN")
1020 .expect_array_simple("a", ExprType::Integer, vec![123.into()])
1021 .expect_clear()
1022 .expect_clear()
1023 .expect_program(Some("untouched.bas"), program)
1024 .check();
1025 }
1026
1027 #[test]
1028 fn test_run_something_that_exits() {
1029 let program = "PRINT 5: END 1: PRINT 4";
1030 Tester::default()
1031 .set_program(Some("untouched.bas"), program)
1032 .run(r#"RUN: PRINT "after""#)
1033 .expect_clear()
1034 .expect_prints([" 5", "Program exited with code 1", "after"])
1035 .expect_program(Some("untouched.bas"), program)
1036 .check();
1037 }
1038
1039 #[test]
1040 fn test_run_errors() {
1041 check_stmt_compilation_err("1:1: In call to RUN: expected no arguments", "RUN 10");
1042 }
1043
1044 #[test]
1045 fn test_save_ok_explicit_name() {
1046 let content = "\n some line \n ";
1047 for (explicit, actual, canonical) in &[
1048 ("first", "MEMORY:/first.bas", "MEMORY:first.bas"),
1049 ("second.bas", "MEMORY:/second.bas", "MEMORY:second.bas"),
1050 ("THIRD", "MEMORY:/THIRD.BAS", "MEMORY:THIRD.BAS"),
1051 ("FOURTH.BAS", "MEMORY:/FOURTH.BAS", "MEMORY:FOURTH.BAS"),
1052 ("Fifth", "MEMORY:/Fifth.bas", "MEMORY:Fifth.bas"),
1053 ] {
1054 Tester::default()
1055 .set_program(Some("before.bas"), content)
1056 .run(format!(r#"SAVE "{}""#, explicit))
1057 .expect_program(Some(*canonical), content)
1058 .expect_prints([format!("Saved as {}", canonical)])
1059 .expect_file(*actual, content)
1060 .check();
1061 }
1062 }
1063
1064 #[test]
1065 fn test_save_reuse_name() {
1066 Tester::default()
1067 .set_program(Some("loaded.bas"), "content\n")
1068 .run("SAVE")
1069 .expect_program(Some("MEMORY:loaded.bas"), "content\n")
1070 .expect_prints(["Saved as MEMORY:loaded.bas"])
1071 .expect_file("MEMORY:/loaded.bas", "content\n")
1072 .check();
1073 }
1074
1075 #[test]
1076 fn test_save_unnamed_error() {
1077 Tester::default()
1078 .add_input_chars("modified file\n")
1079 .run("EDIT: SAVE")
1080 .expect_program(None as Option<&str>, "modified file\n")
1081 .expect_err("1:7: In call to SAVE: Unnamed program; please provide a filename")
1082 .check();
1083 }
1084
1085 #[test]
1086 fn test_save_errors() {
1087 check_load_save_common_errors("SAVE");
1088
1089 Tester::default()
1090 .run("SAVE 2, 3")
1091 .expect_compilation_err("1:1: In call to SAVE: expected <> | <filename$>")
1092 .check();
1093 }
1094}