endbasic_repl/
lib.rs

1// EndBASIC
2// Copyright 2020 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//! Interactive interpreter for the EndBASIC language.
17
18// Keep these in sync with other top-level files.
19#![allow(clippy::await_holding_refcell_ref)]
20#![allow(clippy::collapsible_else_if)]
21#![warn(anonymous_parameters, bad_style, missing_docs)]
22#![warn(unused, unused_extern_crates, unused_import_braces, unused_qualifications)]
23#![warn(unsafe_code)]
24
25use endbasic_core::exec::{Machine, StopReason};
26use endbasic_std::console::{self, is_narrow, refill_and_print, Console};
27use endbasic_std::program::{continue_if_modified, Program, BREAK_MSG};
28use endbasic_std::storage::Storage;
29use std::cell::RefCell;
30use std::io;
31use std::rc::Rc;
32
33pub mod demos;
34pub mod editor;
35
36/// Prints the EndBASIC welcome message to the given console.
37pub fn print_welcome(console: Rc<RefCell<dyn Console>>) -> io::Result<()> {
38    let mut console = console.borrow_mut();
39
40    if is_narrow(&*console) {
41        console.print(&format!("EndBASIC {}", env!("CARGO_PKG_VERSION")))?;
42    } else {
43        console.print("")?;
44        console.print(&format!("    EndBASIC {}", env!("CARGO_PKG_VERSION")))?;
45        console.print("    Copyright 2020-2024 Julio Merino")?;
46        console.print("")?;
47        console.print("    Type HELP for interactive usage information.")?;
48    }
49    console.print("")?;
50
51    Ok(())
52}
53
54/// Loads the `AUTOEXEC.BAS` file if it exists in the `drive`.
55///
56/// Failures to process the file are logged to the `console` but are ignored.  Other failures are
57/// returned.
58pub async fn try_load_autoexec(
59    machine: &mut Machine,
60    console: Rc<RefCell<dyn Console>>,
61    storage: Rc<RefCell<Storage>>,
62) -> io::Result<()> {
63    let code = match storage.borrow().get("AUTOEXEC.BAS").await {
64        Ok(code) => code,
65        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
66        Err(e) => {
67            return console
68                .borrow_mut()
69                .print(&format!("AUTOEXEC.BAS exists but cannot be read: {}", e));
70        }
71    };
72
73    console.borrow_mut().print("Loading AUTOEXEC.BAS...")?;
74    match machine.exec(&mut code.as_bytes()).await {
75        Ok(_) => Ok(()),
76        Err(e) => {
77            console.borrow_mut().print(&format!("AUTOEXEC.BAS failed: {}", e))?;
78            Ok(())
79        }
80    }
81}
82
83/// Loads the program given in `username_path` pair (which is of the form `user/path`) from the
84/// cloud and executes it on the `machine`.
85pub async fn run_from_cloud(
86    machine: &mut Machine,
87    console: Rc<RefCell<dyn Console>>,
88    storage: Rc<RefCell<Storage>>,
89    program: Rc<RefCell<dyn Program>>,
90    username_path: &str,
91    will_run_repl: bool,
92) -> endbasic_core::exec::Result<i32> {
93    let (fs_uri, path) = match username_path.split_once('/') {
94        Some((username, path)) => (format!("cloud://{}", username), format!("AUTORUN:/{}", path)),
95        None => {
96            let mut console = console.borrow_mut();
97            console.print(&format!(
98                "Invalid program to run '{}'; must be of the form 'username/path'",
99                username_path
100            ))?;
101            return Ok(1);
102        }
103    };
104
105    console.borrow_mut().print(&format!("Mounting {} as AUTORUN...", fs_uri))?;
106    storage.borrow_mut().mount("AUTORUN", &fs_uri)?;
107    storage.borrow_mut().cd("AUTORUN:/")?;
108
109    console.borrow_mut().print(&format!("Loading {}...", path))?;
110    let content = storage.borrow().get(&path).await?;
111    program.borrow_mut().load(Some(&path), &content);
112
113    console.borrow_mut().print("Starting...")?;
114    console.borrow_mut().print("")?;
115
116    let result = machine.exec(&mut "RUN".as_bytes()).await;
117
118    let mut console = console.borrow_mut();
119
120    console.print("")?;
121    let code = match result {
122        Ok(r @ StopReason::Eof) => {
123            console.print("**** Program exited due to EOF ****")?;
124            r.as_exit_code()
125        }
126        Ok(r @ StopReason::Exited(_)) => {
127            let code = r.as_exit_code();
128            console.print(&format!("**** Program exited with code {} ****", code))?;
129            code
130        }
131        Ok(r @ StopReason::Break) => {
132            console.print("**** Program stopped due to BREAK ****")?;
133            r.as_exit_code()
134        }
135        Err(e) => {
136            console.print(&format!("**** ERROR: {} ****", e))?;
137            1
138        }
139    };
140
141    if will_run_repl {
142        console.print("")?;
143        refill_and_print(
144            &mut *console,
145            [
146                "You are now being dropped into the EndBASIC interpreter.",
147                "The program you asked to run is still loaded in memory and you can interact with \
148it now.  Use LIST to view the source code, EDIT to launch an editor on the source code, and RUN to \
149execute the program again.",
150                "Type HELP for interactive usage information.",
151            ],
152            "   ",
153        )?;
154        console.print("")?;
155    }
156
157    Ok(code)
158}
159
160/// Enters the interactive interpreter.
161///
162/// The `console` provided here is used for the REPL prompt interaction and should match the
163/// console that's in use by the machine (if any).  They don't necessarily have to match though.
164pub async fn run_repl_loop(
165    machine: &mut Machine,
166    console: Rc<RefCell<dyn Console>>,
167    program: Rc<RefCell<dyn Program>>,
168) -> io::Result<i32> {
169    let mut stop_reason = StopReason::Eof;
170    let mut history = vec![];
171    while stop_reason == StopReason::Eof {
172        let line = {
173            let mut console = console.borrow_mut();
174            if console.is_interactive() {
175                console.print("Ready")?;
176            }
177            console::read_line(&mut *console, "", "", Some(&mut history)).await
178        };
179
180        // Any signals entered during console input should not impact upcoming execution.  Drain
181        // them all.
182        machine.drain_signals();
183
184        match line {
185            Ok(line) => match machine.exec(&mut line.as_bytes()).await {
186                Ok(reason) => stop_reason = reason,
187                Err(e) => {
188                    let mut console = console.borrow_mut();
189                    console.print(format!("ERROR: {}", e).as_str())?;
190                }
191            },
192            Err(e) => {
193                if e.kind() == io::ErrorKind::Interrupted {
194                    let mut console = console.borrow_mut();
195                    console.print(BREAK_MSG)?;
196                    // Do not exit the interpreter.  Other REPLs, such as Python's, do not do so,
197                    // and it is actually pretty annoying to exit the REPL when one may be furiously
198                    // pressing CTRL+C to stop a program inside of it.
199                } else if e.kind() == io::ErrorKind::UnexpectedEof {
200                    let mut console = console.borrow_mut();
201                    console.print("End of input by CTRL-D")?;
202                    stop_reason = StopReason::Exited(0);
203                } else {
204                    stop_reason = StopReason::Exited(1);
205                }
206            }
207        }
208
209        match stop_reason {
210            StopReason::Eof => (),
211            StopReason::Break => {
212                console.borrow_mut().print("**** BREAK ****")?;
213                stop_reason = StopReason::Eof;
214            }
215            StopReason::Exited(_) => {
216                if !continue_if_modified(&*program.borrow(), &mut *console.borrow_mut()).await? {
217                    console.borrow_mut().print("Exit aborted; resuming REPL loop.")?;
218                    stop_reason = StopReason::Eof;
219                }
220            }
221        }
222    }
223    Ok(stop_reason.as_exit_code())
224}
225
226#[cfg(test)]
227mod tests {
228    use super::*;
229    use endbasic_core::exec::Signal;
230    use endbasic_std::console::{CharsXY, Key};
231    use endbasic_std::storage::{Drive, DriveFactory, InMemoryDrive};
232    use endbasic_std::testutils::*;
233    use futures_lite::future::block_on;
234    use std::convert::TryFrom;
235
236    /// Runs `print_welcome` against a console that is `console_width` in height and returns
237    /// whether the narrow welcome message was printed or not, and the maximum width of all
238    /// printed messages.
239    fn check_is_narrow_welcome(console_width: u16) -> (bool, usize) {
240        let console = Rc::from(RefCell::from(MockConsole::default()));
241        console.borrow_mut().set_size_chars(CharsXY::new(console_width, 1));
242        print_welcome(console.clone()).unwrap();
243
244        let mut console = console.borrow_mut();
245        let mut found = false;
246        let mut max_length = 0;
247        for output in console.take_captured_out() {
248            match output {
249                CapturedOut::Print(msg) => {
250                    if msg.contains("Type HELP") {
251                        found = true;
252                        max_length = std::cmp::max(max_length, msg.len());
253                    }
254                }
255                _ => panic!("Unexpected console operation: {:?}", output),
256            }
257        }
258        (!found, max_length)
259    }
260
261    #[test]
262    fn test_print_welcome_wide_console() {
263        assert!(!check_is_narrow_welcome(50).0, "Long welcome not found");
264    }
265
266    #[test]
267    fn test_print_welcome_narrow_console() {
268        assert!(check_is_narrow_welcome(10).0, "Long welcome found");
269    }
270
271    #[test]
272    fn test_print_welcome_and_is_narrow_agree() {
273        let (narrow, max_length) = check_is_narrow_welcome(1000);
274        assert!(!narrow, "Long message not found");
275
276        for i in 0..max_length {
277            assert!(check_is_narrow_welcome(u16::try_from(i).unwrap()).0, "Long message found");
278        }
279    }
280
281    #[test]
282    fn test_autoexec_ok() {
283        // The code in the autoexec test file should access, in a mutable fashion, all the resources
284        // that the try_load_autoexec function uses to ensure the function's code doesn't hold onto
285        // references while executing the autoexec code and causing a borrowing violation.
286        let autoexec = "PRINT \"hello\": global_var = 3: CD \"MEMORY:/\"";
287        let mut tester = Tester::default().write_file("AUTOEXEC.BAS", autoexec);
288        let (console, storage) = (tester.get_console(), tester.get_storage());
289        block_on(try_load_autoexec(tester.get_machine(), console, storage)).unwrap();
290        tester
291            .run("")
292            .expect_var("global_var", 3)
293            .expect_prints(["Loading AUTOEXEC.BAS...", "hello"])
294            .expect_file("MEMORY:/AUTOEXEC.BAS", autoexec)
295            .check();
296    }
297
298    #[test]
299    fn test_autoexec_compilation_error_is_ignored() {
300        let autoexec = "a = 1\nb = undef: c = 2";
301        let mut tester = Tester::default().write_file("AUTOEXEC.BAS", autoexec);
302        let (console, storage) = (tester.get_console(), tester.get_storage());
303        block_on(try_load_autoexec(tester.get_machine(), console, storage)).unwrap();
304        tester
305            .run("after = 5")
306            .expect_var("after", 5)
307            .expect_prints([
308                "Loading AUTOEXEC.BAS...",
309                "AUTOEXEC.BAS failed: 2:5: Undefined variable undef",
310            ])
311            .expect_file("MEMORY:/AUTOEXEC.BAS", autoexec)
312            .check();
313    }
314
315    #[test]
316    fn test_autoexec_execution_error_is_ignored() {
317        let autoexec = "a = 1\nb = 3 >> -1: c = 2";
318        let mut tester = Tester::default().write_file("AUTOEXEC.BAS", autoexec);
319        let (console, storage) = (tester.get_console(), tester.get_storage());
320        block_on(try_load_autoexec(tester.get_machine(), console, storage)).unwrap();
321        tester
322            .run("after = 5")
323            .expect_var("a", 1)
324            .expect_var("after", 5)
325            .expect_prints([
326                "Loading AUTOEXEC.BAS...",
327                "AUTOEXEC.BAS failed: 2:7: Number of bits to >> (-1) must be positive",
328            ])
329            .expect_file("MEMORY:/AUTOEXEC.BAS", autoexec)
330            .check();
331    }
332
333    #[test]
334    fn test_autoexec_name_is_case_sensitive() {
335        let mut tester = Tester::default()
336            .write_file("AUTOEXEC.BAS", "a = 1")
337            .write_file("autoexec.bas", "a = 2");
338        let (console, storage) = (tester.get_console(), tester.get_storage());
339        block_on(try_load_autoexec(tester.get_machine(), console, storage)).unwrap();
340        tester
341            .run("")
342            .expect_var("a", 1)
343            .expect_prints(["Loading AUTOEXEC.BAS..."])
344            .expect_file("MEMORY:/AUTOEXEC.BAS", "a = 1")
345            .expect_file("MEMORY:/autoexec.bas", "a = 2")
346            .check();
347    }
348
349    #[test]
350    fn test_autoexec_missing() {
351        let mut tester = Tester::default();
352        let (console, storage) = (tester.get_console(), tester.get_storage());
353        block_on(try_load_autoexec(tester.get_machine(), console, storage)).unwrap();
354        tester.run("").check();
355    }
356
357    /// Factory for drives that mimic the behavior of a cloud drive with fixed contents.
358    struct MockDriveFactory {
359        exp_username: &'static str,
360        exp_file: &'static str,
361    }
362
363    impl MockDriveFactory {
364        /// Verbatim contents of the single file included in the mock drives.
365        const SCRIPT: &'static str = r#"PRINT "Success""#;
366    }
367
368    impl DriveFactory for MockDriveFactory {
369        fn create(&self, target: &str) -> io::Result<Box<dyn Drive>> {
370            let mut drive = InMemoryDrive::default();
371            block_on(drive.put(self.exp_file, Self::SCRIPT)).unwrap();
372            assert_eq!(self.exp_username, target);
373            Ok(Box::from(drive))
374        }
375    }
376
377    #[test]
378    fn test_run_from_cloud_no_repl() {
379        let mut tester = Tester::default();
380        let (console, storage, program) =
381            (tester.get_console(), tester.get_storage(), tester.get_program());
382
383        storage.borrow_mut().register_scheme(
384            "cloud",
385            Box::from(MockDriveFactory { exp_username: "foo", exp_file: "bar.bas" }),
386        );
387
388        block_on(run_from_cloud(
389            tester.get_machine(),
390            console,
391            storage,
392            program,
393            "foo/bar.bas",
394            false,
395        ))
396        .unwrap();
397        tester
398            .run("")
399            .expect_prints([
400                "Mounting cloud://foo as AUTORUN...",
401                "Loading AUTORUN:/bar.bas...",
402                "Starting...",
403                "",
404            ])
405            .expect_clear()
406            .expect_prints(["Success", "", "**** Program exited due to EOF ****"])
407            .expect_program(Some("AUTORUN:/bar.bas"), MockDriveFactory::SCRIPT)
408            .check();
409    }
410
411    #[test]
412    fn test_run_from_cloud_repl() {
413        let mut tester = Tester::default();
414        let (console, storage, program) =
415            (tester.get_console(), tester.get_storage(), tester.get_program());
416
417        storage.borrow_mut().register_scheme(
418            "cloud",
419            Box::from(MockDriveFactory { exp_username: "abcd", exp_file: "the-path.bas" }),
420        );
421
422        block_on(run_from_cloud(
423            tester.get_machine(),
424            console,
425            storage,
426            program,
427            "abcd/the-path.bas",
428            true,
429        ))
430        .unwrap();
431        let mut checker = tester.run("");
432        let output = flatten_output(checker.take_captured_out());
433        checker.expect_program(Some("AUTORUN:/the-path.bas"), MockDriveFactory::SCRIPT).check();
434
435        assert!(output.contains("You are now being dropped into"));
436    }
437
438    #[test]
439    fn test_run_repl_loop_signal_before_exec() {
440        let mut tester = Tester::default();
441        let (console, program) = (tester.get_console(), tester.get_program());
442        let signals_tx = tester.get_machine().get_signals_tx();
443
444        {
445            let mut console = console.borrow_mut();
446            console.add_input_chars("PRINT");
447            block_on(signals_tx.send(Signal::Break)).unwrap();
448            console.add_input_chars(" 123");
449            console.add_input_keys(&[Key::NewLine, Key::Eof]);
450        }
451        block_on(run_repl_loop(tester.get_machine(), console, program)).unwrap();
452        tester.run("").expect_prints([" 123", "End of input by CTRL-D"]).check();
453    }
454}