Skip to main content

endbasic_repl/
lib.rs

1// EndBASIC
2// Copyright 2020 Julio Merino
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU Affero General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU Affero General Public License for more details.
13//
14// You should have received a copy of the GNU Affero General Public License
15// along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17//! Interactive interpreter for the EndBASIC language.
18
19use endbasic_std::console::{self, Console, PixelsXY, is_narrow, refill_and_print};
20use endbasic_std::program::{BREAK_MSG, Program, continue_if_modified};
21use endbasic_std::storage::Storage;
22use endbasic_std::{Error as StdError, Machine};
23use std::cell::RefCell;
24use std::io;
25use std::rc::Rc;
26
27pub mod demos;
28pub mod editor;
29mod logo;
30
31/// Prints the EndBASIC welcome message to a wide console, with optional extra indentation to
32/// leave space for the logo.
33fn print_wide_welcome(console: &mut dyn Console, extra_indent: &str) -> io::Result<()> {
34    console.print("")?;
35    console.print(&format!("    {}EndBASIC {}", extra_indent, env!("CARGO_PKG_VERSION")))?;
36    console.print(&format!("    {}Copyright 2020-2026 Julio Merino", extra_indent))?;
37    console.print("")?;
38    console.print("    Type HELP for interactive usage information.")
39}
40
41/// Prints the EndBASIC welcome message to a graphical console.
42fn print_graphical_welcome(console: &mut dyn Console) -> io::Result<()> {
43    let glyph_size = console.glyph_size()?;
44
45    let previous_sync = console.set_sync(false)?;
46    let result = (|| {
47        print_wide_welcome(console, "     ")?;
48
49        let x1 = i32::from(glyph_size.width) * 4;
50        let x2 = i32::from(glyph_size.width) * 8;
51        let y1 = i32::from(glyph_size.height) / 2;
52        let y2 = i32::from(glyph_size.height) * 7 / 2;
53        logo::draw_logo(
54            console,
55            PixelsXY::new(x1 as i16, y1 as i16),
56            Some(PixelsXY::new(x2 as i16, y2 as i16)),
57        )?;
58
59        Ok(())
60    })();
61    console.set_sync(previous_sync)?;
62    result
63}
64
65/// Checks if the given `console` has graphics support.
66fn has_graphics(console: &dyn Console) -> bool {
67    console.size_pixels().is_ok() && console.glyph_size().is_ok()
68}
69
70/// Prints the EndBASIC welcome message to the given console.
71pub fn print_welcome(console: &mut dyn Console) -> io::Result<()> {
72    if is_narrow(&*console) {
73        console.print(&format!("EndBASIC {}", env!("CARGO_PKG_VERSION")))?;
74    } else if has_graphics(&*console) {
75        print_graphical_welcome(&mut *console)?;
76    } else {
77        print_wide_welcome(console, "")?;
78    }
79    console.print("")?;
80
81    Ok(())
82}
83
84/// Loads the `AUTOEXEC.BAS` file if it exists in the `drive`.
85///
86/// Failures to process the file are logged to the `console` but are ignored.  Other failures are
87/// returned.
88pub async fn try_load_autoexec(
89    machine: &mut Machine,
90    console: Rc<RefCell<dyn Console>>,
91    storage: Rc<RefCell<Storage>>,
92) -> io::Result<()> {
93    let code = match storage.borrow().get("AUTOEXEC.BAS").await {
94        Ok(code) => code,
95        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
96        Err(e) => {
97            return console
98                .borrow_mut()
99                .print(&format!("AUTOEXEC.BAS exists but cannot be read: {}", e));
100        }
101    };
102
103    match machine.compile(&mut code.as_slice()) {
104        Ok(()) => match machine.exec().await {
105            Ok(_) => Ok(()),
106            Err(e) => {
107                console.borrow_mut().print(&format!("AUTOEXEC.BAS failed: {}", e))?;
108                Ok(())
109            }
110        },
111        Err(e) => {
112            console.borrow_mut().print(&format!("AUTOEXEC.BAS failed: {}", e))?;
113            Ok(())
114        }
115    }
116}
117
118/// Resolves `username_path`, which must be of the form `user/path`, into an `AUTORUN` location.
119pub fn mount_cloud_share(
120    console: Rc<RefCell<dyn Console>>,
121    storage: Rc<RefCell<Storage>>,
122    username_path: &str,
123) -> io::Result<String> {
124    let (fs_uri, path) = match username_path.split_once('/') {
125        Some((username, path)) => (format!("cloud://{}", username), format!("AUTORUN:/{}", path)),
126        None => {
127            return Err(io::Error::new(
128                io::ErrorKind::InvalidInput,
129                format!(
130                    "Invalid program to run '{}'; must be of the form 'username/path'",
131                    username_path
132                ),
133            ));
134        }
135    };
136
137    console.borrow_mut().print(&format!("Mounting {} as AUTORUN...", fs_uri))?;
138    storage.borrow_mut().mount("AUTORUN", &fs_uri)?;
139    storage.borrow_mut().cd("AUTORUN:/")?;
140    Ok(path)
141}
142
143/// Loads the program given by `path` from storage and executes it on the `machine`.
144pub async fn run_from_storage_path(
145    machine: &mut Machine,
146    console: Rc<RefCell<dyn Console>>,
147    storage: Rc<RefCell<Storage>>,
148    program: Rc<RefCell<dyn Program>>,
149    path: &str,
150    will_run_repl: bool,
151) -> io::Result<i32> {
152    let path = storage.borrow().make_canonical_with_extension(path, "bas")?;
153
154    console.borrow_mut().print(&format!("Loading {}...", path))?;
155    let content = storage.borrow().get(&path).await?;
156    let content = match String::from_utf8(content) {
157        Ok(text) => text,
158        Err(e) => {
159            let mut console = console.borrow_mut();
160            console.print(&format!("Invalid program to run '{}': {}", path, e))?;
161            return Ok(1);
162        }
163    };
164    program.borrow_mut().load(Some(&path), &content);
165
166    console.borrow_mut().print("Starting...")?;
167    console.borrow_mut().print("")?;
168
169    if let Err(e) = machine.compile(&mut "RUN".as_bytes()) {
170        let mut console = console.borrow_mut();
171        console.print(&format!("**** ERROR: {} ****", e))?;
172        return Ok(1);
173    }
174
175    let result = machine.exec().await;
176
177    let mut console = console.borrow_mut();
178
179    console.print("")?;
180    let code = match result {
181        Ok(None) => {
182            console.print("**** Program exited due to EOF ****")?;
183            0
184        }
185        Ok(Some(code)) => {
186            console.print(&format!("**** Program exited with code {} ****", code))?;
187            code
188        }
189        Err(StdError::Break) => {
190            console.print("**** Program stopped due to BREAK ****")?;
191            130
192        }
193        Err(e) => {
194            console.print(&format!("**** ERROR: {} ****", e))?;
195            1
196        }
197    };
198
199    if will_run_repl {
200        console.print("")?;
201        refill_and_print(
202            &mut *console,
203            [
204                "You are now being dropped into the EndBASIC interpreter.",
205                "The program you asked to run is still loaded in memory and you can interact with \
206    it now.  Use LIST to view the source code, EDIT to launch an editor on the source code, and RUN to \
207    execute the program again.",
208                "Type HELP for interactive usage information.",
209            ],
210            "   ",
211        )?;
212        console.print("")?;
213    }
214
215    Ok(code)
216}
217
218/// Enters the interactive interpreter.
219///
220/// The `console` provided here is used for the REPL prompt interaction and should match the
221/// console that's in use by the machine (if any).  They don't necessarily have to match though.
222pub async fn run_repl_loop(
223    machine: &mut Machine,
224    console: Rc<RefCell<dyn Console>>,
225    program: Rc<RefCell<dyn Program>>,
226) -> io::Result<i32> {
227    let mut stop_reason = None;
228    let mut history = vec![];
229    while stop_reason.is_none() {
230        let line = {
231            let mut console = console.borrow_mut();
232            if console.is_interactive() {
233                console.print("Ready")?;
234            }
235            console::read_line(&mut *console, "", "", Some(&mut history)).await
236        };
237
238        // Any signals entered during console input should not impact upcoming execution.  Drain
239        // them all.
240        machine.drain_signals();
241
242        match line {
243            Ok(line) => match machine.compile(&mut line.as_bytes()) {
244                Ok(()) => match machine.exec().await {
245                    Ok(None) => stop_reason = None,
246                    Ok(Some(code)) => {
247                        let should_continue = {
248                            let program = program.borrow();
249                            let mut console = console.borrow_mut();
250                            continue_if_modified(&*program, &mut *console).await?
251                        };
252                        if should_continue {
253                            stop_reason = Some(code);
254                        } else {
255                            let mut console = console.borrow_mut();
256                            console.print("Exit aborted; resuming REPL loop.")?;
257                        }
258                    }
259                    Err(StdError::Break) => {
260                        let mut console = console.borrow_mut();
261                        console.print(BREAK_MSG)?;
262                    }
263                    Err(e) => {
264                        let mut console = console.borrow_mut();
265                        console.print(format!("ERROR: {}", e).as_str())?;
266                    }
267                },
268                Err(e) => {
269                    let mut console = console.borrow_mut();
270                    console.print(format!("ERROR: {}", e).as_str())?;
271                }
272            },
273            Err(e) => {
274                if e.kind() == io::ErrorKind::Interrupted {
275                    let mut console = console.borrow_mut();
276                    console.print(BREAK_MSG)?;
277                    // Do not exit the interpreter.  Other REPLs, such as Python's, do not do so,
278                    // and it is actually pretty annoying to exit the REPL when one may be furiously
279                    // pressing CTRL+C to stop a program inside of it.
280                } else if e.kind() == io::ErrorKind::UnexpectedEof {
281                    let mut console = console.borrow_mut();
282                    console.print("End of input by CTRL-D")?;
283                    stop_reason = Some(0);
284                } else {
285                    stop_reason = Some(1);
286                }
287            }
288        }
289    }
290    Ok(stop_reason.unwrap())
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use endbasic_sdl::testutils::SdlTest;
297    use endbasic_std::Signal;
298    use endbasic_std::console::{CharsXY, Key};
299    use endbasic_std::gfx::lcd::fonts::{FONT_5X8, FONT_16X16};
300    use endbasic_std::storage::{Drive, DriveFactory, InMemoryDrive};
301    use endbasic_std::testutils::*;
302    use futures_lite::future::block_on;
303
304    /// Runs `print_welcome` against a console that is `console_width` in height and returns
305    /// whether the narrow welcome message was printed or not, and the maximum width of all
306    /// printed messages.
307    fn check_is_narrow_welcome(console_width: u16) -> (bool, usize) {
308        let console = Rc::from(RefCell::from(MockConsole::default()));
309        console.borrow_mut().set_size_chars(CharsXY::new(console_width, 1));
310        print_welcome(&mut *console.borrow_mut()).unwrap();
311
312        let mut console = console.borrow_mut();
313        let mut found = false;
314        let mut max_length = 0;
315        for output in console.take_captured_out() {
316            match output {
317                CapturedOut::Print(msg) => {
318                    if msg.contains("Type HELP") {
319                        found = true;
320                        max_length = std::cmp::max(max_length, msg.len());
321                    }
322                }
323                _ => panic!("Unexpected console operation: {:?}", output),
324            }
325        }
326        (!found, max_length)
327    }
328
329    #[test]
330    fn test_print_welcome_wide_console() {
331        assert!(!check_is_narrow_welcome(50).0, "Long welcome not found");
332    }
333
334    #[test]
335    fn test_print_welcome_narrow_console() {
336        assert!(check_is_narrow_welcome(10).0, "Long welcome found");
337    }
338
339    #[test]
340    fn test_print_welcome_and_is_narrow_agree() {
341        let (narrow, max_length) = check_is_narrow_welcome(1000);
342        assert!(!narrow, "Long message not found");
343
344        for i in 0..max_length {
345            assert!(check_is_narrow_welcome(u16::try_from(i).unwrap()).0, "Long message found");
346        }
347    }
348
349    #[test]
350    fn test_print_welcome_draw_logo_default() {
351        let mut test = SdlTest::default();
352        print_welcome(test.console()).unwrap();
353        test.verify("repl/src", "welcome-banner-default");
354    }
355
356    #[test]
357    fn test_print_welcome_draw_logo_tiny() {
358        let mut test = SdlTest::new(800, 600, &FONT_5X8);
359        print_welcome(test.console()).unwrap();
360        test.verify("repl/src", "welcome-banner-tiny");
361    }
362
363    #[test]
364    fn test_print_welcome_draw_logo_big() {
365        let mut test = SdlTest::new(800, 600, &FONT_16X16);
366        print_welcome(test.console()).unwrap();
367        test.verify("repl/src", "welcome-banner-big");
368    }
369
370    #[test]
371    fn test_autoexec_ok() {
372        // The code in the autoexec test file should access, in a mutable fashion, all the resources
373        // that the try_load_autoexec function uses to ensure the function's code doesn't hold onto
374        // references while executing the autoexec code and causing a borrowing violation.
375        let autoexec = "PRINT \"hello\": global_var = 3: CD \"MEMORY:/\"";
376        let tester = Tester::default().write_file("AUTOEXEC.BAS", autoexec);
377        let (console, storage) = (tester.get_console(), tester.get_storage());
378        let mut continuation = tester.continue_from_here();
379        block_on(try_load_autoexec(continuation.get_machine(), console, storage)).unwrap();
380        continuation
381            .run("")
382            .expect_prints(["hello"])
383            .expect_file("MEMORY:/AUTOEXEC.BAS", autoexec)
384            .check();
385    }
386
387    #[test]
388    fn test_autoexec_compilation_error_is_ignored() {
389        let autoexec = "a = 1\nb = undef: c = 2";
390        let tester = Tester::default().write_file("AUTOEXEC.BAS", autoexec);
391        let (console, storage) = (tester.get_console(), tester.get_storage());
392        let mut continuation = tester.continue_from_here();
393        block_on(try_load_autoexec(continuation.get_machine(), console, storage)).unwrap();
394        continuation
395            .run("after = 5")
396            .expect_var("after", 5)
397            .expect_prints(["AUTOEXEC.BAS failed: 2:5: Undefined symbol undef"])
398            .expect_file("MEMORY:/AUTOEXEC.BAS", autoexec)
399            .check();
400    }
401
402    #[test]
403    fn test_autoexec_execution_error_is_ignored() {
404        let autoexec = "a = 1\nb = 3 >> -1: c = 2";
405        let tester = Tester::default().write_file("AUTOEXEC.BAS", autoexec);
406        let (console, storage) = (tester.get_console(), tester.get_storage());
407        let mut continuation = tester.continue_from_here();
408        block_on(try_load_autoexec(continuation.get_machine(), console, storage)).unwrap();
409        continuation
410            .run("after = 5")
411            .expect_prints(["AUTOEXEC.BAS failed: 2:7: Number of bits to >> (-1) must be positive"])
412            .expect_file("MEMORY:/AUTOEXEC.BAS", autoexec)
413            .check();
414    }
415
416    #[test]
417    fn test_autoexec_name_is_case_sensitive() {
418        let tester = Tester::default()
419            .write_file("AUTOEXEC.BAS", "a = 1")
420            .write_file("autoexec.bas", "a = 2");
421        let (console, storage) = (tester.get_console(), tester.get_storage());
422        let mut continuation = tester.continue_from_here();
423        block_on(try_load_autoexec(continuation.get_machine(), console, storage)).unwrap();
424        continuation
425            .run("")
426            .expect_file("MEMORY:/AUTOEXEC.BAS", "a = 1")
427            .expect_file("MEMORY:/autoexec.bas", "a = 2")
428            .check();
429    }
430
431    #[test]
432    fn test_autoexec_missing() {
433        let tester = Tester::default();
434        let (console, storage) = (tester.get_console(), tester.get_storage());
435        let mut continuation = tester.continue_from_here();
436        block_on(try_load_autoexec(continuation.get_machine(), console, storage)).unwrap();
437        continuation.run("").check();
438    }
439
440    /// Factory for drives that mimic the behavior of a cloud drive with fixed contents.
441    struct MockDriveFactory {
442        exp_username: &'static str,
443        exp_file: &'static str,
444    }
445
446    impl MockDriveFactory {
447        /// Verbatim contents of the single file included in the mock drives.
448        const SCRIPT: &'static str = r#"PRINT "Success""#;
449    }
450
451    impl DriveFactory for MockDriveFactory {
452        fn create(&self, target: &str) -> io::Result<Box<dyn Drive>> {
453            let mut drive = InMemoryDrive::default();
454            block_on(drive.put(self.exp_file, Self::SCRIPT.as_bytes())).unwrap();
455            assert_eq!(self.exp_username, target);
456            Ok(Box::from(drive))
457        }
458    }
459
460    #[test]
461    fn test_mount_cloud_share_invalid_path() {
462        let tester = Tester::default();
463        let (console, storage) = (tester.get_console(), tester.get_storage());
464
465        let e = mount_cloud_share(console, storage, "foo").unwrap_err();
466        assert_eq!(io::ErrorKind::InvalidInput, e.kind());
467        assert_eq!(
468            "Invalid program to run 'foo'; must be of the form 'username/path'",
469            format!("{}", e)
470        );
471    }
472
473    #[test]
474    fn test_mount_cloud_share_ok() {
475        let tester = Tester::default();
476        let (console, storage) = (tester.get_console(), tester.get_storage());
477        let continuation = tester.continue_from_here();
478
479        storage.borrow_mut().register_scheme(
480            "cloud",
481            Box::from(MockDriveFactory { exp_username: "foo", exp_file: "bar.bas" }),
482        );
483
484        let path = mount_cloud_share(console, storage, "foo/bar.bas").unwrap();
485        assert_eq!("AUTORUN:/bar.bas", path);
486        assert_eq!(Some(&"cloud://foo"), tester.get_storage().borrow().mounted().get("AUTORUN"));
487        assert_eq!("AUTORUN:/", tester.get_storage().borrow().cwd());
488        continuation.run("").expect_prints(["Mounting cloud://foo as AUTORUN..."]).check();
489    }
490
491    #[test]
492    fn test_run_from_storage_path_no_repl() {
493        let tester = Tester::default();
494        let (console, storage, program) =
495            (tester.get_console(), tester.get_storage(), tester.get_program());
496        let mut continuation = tester.continue_from_here();
497
498        storage.borrow_mut().mount("SOME", "memory://").unwrap();
499        block_on(storage.borrow_mut().put("SOME:bar.bas", MockDriveFactory::SCRIPT.as_bytes()))
500            .unwrap();
501
502        block_on(run_from_storage_path(
503            continuation.get_machine(),
504            console,
505            storage,
506            program,
507            "some:bar.bas",
508            false,
509        ))
510        .unwrap();
511        continuation
512            .run("")
513            .expect_prints(["Loading SOME:bar.bas...", "Starting...", ""])
514            .expect_clear()
515            .expect_prints(["Success", "", "**** Program exited due to EOF ****"])
516            .expect_file("SOME:/bar.bas", MockDriveFactory::SCRIPT)
517            .expect_program(Some("SOME:bar.bas"), MockDriveFactory::SCRIPT)
518            .check();
519    }
520
521    #[test]
522    fn test_run_from_storage_path_with_default_extension_and_repl() {
523        let tester = Tester::default().write_file("demo.bas", MockDriveFactory::SCRIPT);
524        let (console, storage, program) =
525            (tester.get_console(), tester.get_storage(), tester.get_program());
526        let mut continuation = tester.continue_from_here();
527
528        block_on(run_from_storage_path(
529            continuation.get_machine(),
530            console,
531            storage,
532            program,
533            "memory:demo",
534            true,
535        ))
536        .unwrap();
537        let mut checker = continuation.run("");
538        let output = flatten_output(checker.take_captured_out());
539        checker
540            .expect_file("MEMORY:/demo.bas", MockDriveFactory::SCRIPT)
541            .expect_program(Some("MEMORY:demo.bas"), MockDriveFactory::SCRIPT)
542            .check();
543
544        assert!(output.contains("Loading MEMORY:demo.bas..."));
545        assert!(output.contains("You are now being dropped into"));
546    }
547
548    #[test]
549    fn test_run_repl_loop_signal_before_exec() {
550        let mut tester = Tester::default();
551        let (console, program) = (tester.get_console(), tester.get_program());
552        let (signals_tx, signals_rx) = async_channel::unbounded();
553        let mut machine = endbasic_std::MachineBuilder::default()
554            .with_console(console.clone())
555            .with_signals_chan((signals_tx.clone(), signals_rx))
556            .build();
557
558        {
559            let mut console = console.borrow_mut();
560            console.add_input_chars("PRINT");
561            block_on(signals_tx.send(Signal::Break)).unwrap();
562            console.add_input_chars(" 123");
563            console.add_input_keys(&[Key::NewLine, Key::EofOrDelete]);
564        }
565        block_on(run_repl_loop(&mut machine, console, program)).unwrap();
566        tester.run("").expect_prints([" 123", "End of input by CTRL-D"]).check();
567    }
568
569    #[test]
570    fn test_run_repl_loop_eof_during_input_does_not_exit_repl() {
571        let mut tester = Tester::default();
572        let (console, program) = (tester.get_console(), tester.get_program());
573        let mut machine =
574            endbasic_std::MachineBuilder::default().with_console(console.clone()).build();
575
576        {
577            let mut console = console.borrow_mut();
578            console.add_input_chars("INPUT a\n");
579            console.add_input_keys(&[Key::EofOrDelete]);
580            console.add_input_chars("PRINT 3\n");
581            console.add_input_keys(&[Key::EofOrDelete]);
582        }
583        block_on(run_repl_loop(&mut machine, console, program)).unwrap();
584        tester.run("").expect_prints(["ERROR: 1:1: EOF", " 3", "End of input by CTRL-D"]).check();
585    }
586}