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