Skip to main content

endbasic_std/
program.rs

1// EndBASIC
2// Copyright 2021 Julio Merino
3//
4// Licensed under the Apache License, Version 2.0 (the "License"); you may not
5// use this file except in compliance with the License.  You may obtain a copy
6// of the License at:
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
13// License for the specific language governing permissions and limitations
14// under the License.
15
16//! Stored program manipulation.
17
18use 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
32/// Category description for all symbols provided by this module.
33const 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
46/// Message to print on the console when receiving a break signal.
47pub const BREAK_MSG: &str = "**** BREAK ****";
48
49/// Default extension to add to file names.
50const DEFAULT_EXTENSION: &str = "bas";
51
52/// Representation of the single program that we can keep in memory.
53#[async_trait(?Send)]
54pub trait Program {
55    /// Returns true if the program was modified since it was last saved (as indicated by a call to
56    /// `set_name`).
57    fn is_dirty(&self) -> bool;
58
59    /// Edits the program interactively via the given `console`.
60    async fn edit(&mut self, console: &mut dyn Console) -> io::Result<()>;
61
62    /// Reloads the contents of the stored program with the given `text` and tracks them as coming
63    /// from the file given in `name`.
64    fn load(&mut self, name: Option<&str>, text: &str);
65
66    /// Path of the loaded program.  Should be `None` if the program has never been saved yet.
67    fn name(&self) -> Option<&str>;
68
69    /// Resets the name of the program.  Used when saving it.
70    fn set_name(&mut self, name: &str);
71
72    /// Gets the contents of the stored program as a single string.
73    fn text(&self) -> String;
74}
75
76/// Trivial implementation of a recorded program that doesn't support editing.
77#[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
111/// If the `program` is dirty, asks if it's OK to continue on `console` and discard its changes.
112pub 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
128/// The `DISASM` command.
129pub struct DisasmCommand {
130    metadata: CallableMetadata,
131    console: Rc<RefCell<dyn Console>>,
132    program: Rc<RefCell<dyn Program>>,
133}
134
135impl DisasmCommand {
136    /// Creates a new `DISASM` command that dumps the disassembled version of the program.
137    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        // TODO(jmmv): We shouldn't have to parse and compile the stored program here.  The machine
165        // should hold a copy at all times.
166        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
196/// The `EDIT` command.
197pub struct EditCommand {
198    metadata: CallableMetadata,
199    console: Rc<RefCell<dyn Console>>,
200    program: Rc<RefCell<dyn Program>>,
201}
202
203impl EditCommand {
204    /// Creates a new `EDIT` command that edits the stored `program` in the `console`.
205    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
234/// The `LIST` command.
235pub struct ListCommand {
236    metadata: CallableMetadata,
237    console: Rc<RefCell<dyn Console>>,
238    program: Rc<RefCell<dyn Program>>,
239}
240
241impl ListCommand {
242    /// Creates a new `LIST` command that dumps the `program` to the `console`.
243    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
274/// The `LOAD` command.
275pub 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    /// Creates a new `LOAD` command that loads a program from `storage` into `program` and that
284    /// uses `console` to communicate unsaved changes.
285    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
362/// The `NEW` command.
363pub struct NewCommand {
364    metadata: CallableMetadata,
365    console: Rc<RefCell<dyn Console>>,
366    program: Rc<RefCell<dyn Program>>,
367}
368
369impl NewCommand {
370    /// Creates a new `NEW` command that clears the contents of `program` and that uses `console`
371    /// to communicate unsaved changes.
372    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
418/// The `RUN` command.
419pub struct RunCommand {
420    metadata: CallableMetadata,
421    console: Rc<RefCell<dyn Console>>,
422    program: Rc<RefCell<dyn Program>>,
423}
424
425impl RunCommand {
426    /// Creates a new `RUN` command that executes the `program`.
427    ///
428    /// Reports any non-successful return codes from the program to the console.
429    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
475/// The `SAVE` command.
476pub 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    /// Creates a new `SAVE` command that saves the contents of the `program` into `storage`.
485    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
563/// Adds all program editing commands against the stored `program` to the `machine`, using
564/// `console` for interactive editing and using `storage` as the on-disk storage for the programs.
565pub 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    /// Checks errors that should be handled the same way by `LOAD` and `SAVE`.
792    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}