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::{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
36/// Category description for all symbols provided by this module.
37const 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
50/// Message to print on the console when receiving a break signal.
51pub const BREAK_MSG: &str = "**** BREAK ****";
52
53/// Representation of the single program that we can keep in memory.
54#[async_trait(?Send)]
55pub trait Program {
56    /// Returns true if the program was modified since it was last saved (as indicated by a call to
57    /// `set_name`).
58    fn is_dirty(&self) -> bool;
59
60    /// Edits the program interactively via the given `console`.
61    async fn edit(&mut self, console: &mut dyn Console) -> io::Result<()>;
62
63    /// Reloads the contents of the stored program with the given `text` and tracks them as coming
64    /// from the file given in `name`.
65    fn load(&mut self, name: Option<&str>, text: &str);
66
67    /// Path of the loaded program.  Should be `None` if the program has never been saved yet.
68    fn name(&self) -> Option<&str>;
69
70    /// Resets the name of the program.  Used when saving it.
71    fn set_name(&mut self, name: &str);
72
73    /// Gets the contents of the stored program as a single string.
74    fn text(&self) -> String;
75}
76
77/// Trivial implementation of a recorded program that doesn't support editing.
78#[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
112/// Adds an extension to `path` if one is not present.
113fn 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        // Attempt to determine a sensible extension based on the case of the basename, assuming
122        // that an all-uppercase basename wants an all-uppercase extension.  This is fragile on
123        // case-sensitive file systems, but there is not a lot we can do.
124        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
136/// If the `program` is dirty, asks if it's OK to continue on `console` and discard its changes.
137pub 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
153/// The `DISASM` command.
154pub struct DisasmCommand {
155    metadata: CallableMetadata,
156    console: Rc<RefCell<dyn Console>>,
157    program: Rc<RefCell<dyn Program>>,
158}
159
160impl DisasmCommand {
161    /// Creates a new `DISASM` command that dumps the disassembled version of the program.
162    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        // TODO(jmmv): We shouldn't have to parse and compile the stored program here.  The machine
190        // should hold a copy at all times.
191        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
225/// The `KILL` command.
226// TODO(jmmv): This should be in the storage module because it isn't really tied to the stored
227// program.  However, this currently relies on the automatic addition of extensions to file names,
228// which is logic that should only exist here.  Maybe we should remove that from this command.
229pub struct KillCommand {
230    metadata: CallableMetadata,
231    storage: Rc<RefCell<Storage>>,
232}
233
234impl KillCommand {
235    /// Creates a new `KILL` command that deletes a file from `storage`.
236    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
279/// The `EDIT` command.
280pub struct EditCommand {
281    metadata: CallableMetadata,
282    console: Rc<RefCell<dyn Console>>,
283    program: Rc<RefCell<dyn Program>>,
284}
285
286impl EditCommand {
287    /// Creates a new `EDIT` command that edits the stored `program` in the `console`.
288    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
317/// The `LIST` command.
318pub struct ListCommand {
319    metadata: CallableMetadata,
320    console: Rc<RefCell<dyn Console>>,
321    program: Rc<RefCell<dyn Program>>,
322}
323
324impl ListCommand {
325    /// Creates a new `LIST` command that dumps the `program` to the `console`.
326    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
357/// The `LOAD` command.
358pub 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    /// Creates a new `LOAD` command that loads a program from `storage` into `program` and that
367    /// uses `console` to communicate unsaved changes.
368    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
427/// The `NEW` command.
428pub struct NewCommand {
429    metadata: CallableMetadata,
430    console: Rc<RefCell<dyn Console>>,
431    program: Rc<RefCell<dyn Program>>,
432}
433
434impl NewCommand {
435    /// Creates a new `NEW` command that clears the contents of `program` and that uses `console`
436    /// to communicate unsaved changes.
437    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
479/// The `RUN` command.
480pub struct RunCommand {
481    metadata: CallableMetadata,
482    console: Rc<RefCell<dyn Console>>,
483    program: Rc<RefCell<dyn Program>>,
484}
485
486impl RunCommand {
487    /// Creates a new `RUN` command that executes the `program`.
488    ///
489    /// Reports any non-successful return codes from the program to the console.
490    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
537/// The `SAVE` command.
538pub 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    /// Creates a new `SAVE` command that saves the contents of the `program` into `storage`.
547    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
618/// Adds all program editing commands against the stored `program` to the `machine`, using
619/// `console` for interactive editing and using `storage` as the on-disk storage for the programs.
620pub 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    /// Checks errors that should be handled the same way by `LOAD` and `SAVE`.
881    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}