From 48295e460fbdb47231bda4507d217d4356cf76b6 Mon Sep 17 00:00:00 2001 From: Uncle Scientist Date: Fri, 3 Apr 2026 08:25:30 -0400 Subject: [PATCH] A Rust crate to handle the eccentricities of BASIC input statements --- 00_Common/rust/Cargo.lock | 7 + 00_Common/rust/Cargo.toml | 7 + 00_Common/rust/crates/basic_input/Cargo.toml | 6 + .../rust/crates/basic_input/src/input.rs | 354 ++++++++++++++++++ .../basic_input/src/input/input_state.rs | 77 ++++ .../crates/basic_input/src/input/io_layer.rs | 80 ++++ 00_Common/rust/crates/basic_input/src/lib.rs | 42 +++ .../rust/crates/basic_input/src/parse.rs | 85 +++++ 8 files changed, 658 insertions(+) create mode 100644 00_Common/rust/Cargo.lock create mode 100644 00_Common/rust/Cargo.toml create mode 100644 00_Common/rust/crates/basic_input/Cargo.toml create mode 100644 00_Common/rust/crates/basic_input/src/input.rs create mode 100644 00_Common/rust/crates/basic_input/src/input/input_state.rs create mode 100644 00_Common/rust/crates/basic_input/src/input/io_layer.rs create mode 100644 00_Common/rust/crates/basic_input/src/lib.rs create mode 100644 00_Common/rust/crates/basic_input/src/parse.rs diff --git a/00_Common/rust/Cargo.lock b/00_Common/rust/Cargo.lock new file mode 100644 index 000000000..2c85bc520 --- /dev/null +++ b/00_Common/rust/Cargo.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "basic_input" +version = "0.1.0" diff --git a/00_Common/rust/Cargo.toml b/00_Common/rust/Cargo.toml new file mode 100644 index 000000000..23c680f4e --- /dev/null +++ b/00_Common/rust/Cargo.toml @@ -0,0 +1,7 @@ +# +# Although there is currently only the `basic_input` crate here, I am anticipating +# more utility crates for rust ports, as well as proc macro crates. +# +[workspace] +resolver = "2" +members = [ "crates/basic_input" ] diff --git a/00_Common/rust/crates/basic_input/Cargo.toml b/00_Common/rust/crates/basic_input/Cargo.toml new file mode 100644 index 000000000..d32bdb628 --- /dev/null +++ b/00_Common/rust/crates/basic_input/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "basic_input" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/00_Common/rust/crates/basic_input/src/input.rs b/00_Common/rust/crates/basic_input/src/input.rs new file mode 100644 index 000000000..0955d8ec4 --- /dev/null +++ b/00_Common/rust/crates/basic_input/src/input.rs @@ -0,0 +1,354 @@ +mod input_state; +mod io_layer; + +use input_state::InputState; +use io_layer::IoLayer; + +/// Main entrypoint for this module. Display a prompt and read one or more values from the user. +/// See [the crate docs](module@crate) +pub fn get_input>(prompt: S) -> BI { + let mut io = IoLayer::get_io(); + get_input_from_io(prompt, &mut io) +} + +// This is the trait that we return to the user, which allows us to have +// a different function for each return type. +// let one_input : f32 = get_input("..."); +// let (x, y) : (f32, f32) = get_input("..."); +// let (a, b, c) : (f32, f32, f32) = get_input("..."); +pub trait BasicInput { + fn from_string>(s: S, state: &mut InputState) -> Result + where + Self: Sized; +} + +// -------------------------------------------------------------------------------- + +impl BasicInput for f32 { + fn from_string>(s: S, state: &mut InputState) -> Result { + state.input_f32s(s, 1)?; + Ok(state.data[0].as_f32()) + } +} + +impl BasicInput for (f32, f32) { + fn from_string>(s: S, state: &mut InputState) -> Result { + state.input_f32s(s, 2)?; + + Ok((state.data[0].as_f32(), state.data[1].as_f32())) + } +} + +impl BasicInput for (f32, f32, f32) { + fn from_string>(s: S, state: &mut InputState) -> Result { + state.input_f32s(s, 3)?; + + Ok(( + state.data[0].as_f32(), + state.data[1].as_f32(), + state.data[2].as_f32(), + )) + } +} + +impl BasicInput for (f32, f32, f32, f32) { + fn from_string>(s: S, state: &mut InputState) -> Result { + state.input_f32s(s, 4)?; + + Ok(( + state.data[0].as_f32(), + state.data[1].as_f32(), + state.data[2].as_f32(), + state.data[3].as_f32(), + )) + } +} + +impl BasicInput for (f32, f32, f32, f32, f32, f32, f32, f32, f32, f32) { + fn from_string>(s: S, state: &mut InputState) -> Result { + state.input_f32s(s, 10)?; + + Ok(( + state.data[0].as_f32(), + state.data[1].as_f32(), + state.data[2].as_f32(), + state.data[3].as_f32(), + state.data[4].as_f32(), + state.data[5].as_f32(), + state.data[6].as_f32(), + state.data[7].as_f32(), + state.data[8].as_f32(), + state.data[9].as_f32(), + )) + } +} + +impl BasicInput for String { + fn from_string>(s: S, state: &mut InputState) -> Result { + state.input_strings(s, 1)?; + Ok(state.data[0].as_string()) + } +} + +impl BasicInput for (String, String) { + fn from_string>(s: S, state: &mut InputState) -> Result { + state.input_strings(s, 2)?; + Ok((state.data[0].as_string(), state.data[1].as_string())) + } +} + +// -------------------------------------------------------------------------------- + +fn get_input_from_io>(prompt: S, io: &mut IoLayer) -> BI { + let mut state = InputState { + io, + data: Vec::new(), + }; + + state.io.raw_output_string(format!("{}? ", prompt.as_ref())); + + loop { + let buffer = state.io.raw_input_string(); + match BI::from_string(buffer, &mut state) { + Ok(result) => return result, + Err(e) => match e { + BasicInputError::ParseFailed => { + state.io.raw_output_string("?REENTER\n"); + state.io.raw_output_string(format!("{}? ", prompt.as_ref())); + state.data.clear(); + } + BasicInputError::MissingInput => { + state.io.raw_output_string("??? "); + } + }, + } + } +} + +#[derive(Debug)] +pub enum BasicInputError { + ParseFailed, + MissingInput, +} + +#[cfg(test)] +mod test { + use crate::input::get_input_from_io; + use crate::input::io_layer::{IoLayer, TextInput, TextOutput}; + use std::collections::VecDeque; + + fn getio() -> IoLayer { + IoLayer { + output: Box::new(WriteTestLayer::new()), + input: Box::new(ReadTestLayer::new()), + } + } + + #[test] + fn test_trait_single_input() { + let mut testio = getio(); + testio.input.set_input_text("2"); + let result: f32 = get_input_from_io("prompt", &mut testio); + assert_eq!(2.0, result); + } + + #[test] + fn test_trait_dual_input() { + let mut testio = getio(); + testio.input.set_input_text("2,3"); + let result: (f32, f32) = get_input_from_io("prompt", &mut testio); + assert_eq!( + "prompt? ".to_string(), + testio.output.get_last_line().unwrap() + ); + assert_eq!(2.0, result.0); + assert_eq!(3.0, result.1); + } + + #[test] + fn test_invalid_input() { + let mut testio = getio(); + testio.input.set_input_text("abc"); + testio.input.set_input_text("2"); + let result: f32 = get_input_from_io("prompt", &mut testio); + assert_eq!( + "prompt? ".to_string(), + testio.output.get_last_line().unwrap() + ); + assert_eq!( + "?REENTER\n".to_string(), + testio.output.get_last_line().unwrap() + ); + assert_eq!(2.0, result); + } + + #[test] + fn test_multiple_invalid_input() { + let mut testio = getio(); + testio.input.set_input_text("abc"); + testio.input.set_input_text("2,a"); + testio.input.set_input_text("2,3"); + let result: (f32, f32) = get_input_from_io("prompt", &mut testio); + assert_eq!( + "prompt? ".to_string(), + testio.output.get_last_line().unwrap() + ); + assert_eq!( + "?REENTER\n".to_string(), + testio.output.get_last_line().unwrap() + ); + assert_eq!( + "prompt? ".to_string(), + testio.output.get_last_line().unwrap() + ); + assert_eq!( + "?REENTER\n".to_string(), + testio.output.get_last_line().unwrap() + ); + assert_eq!( + "prompt? ".to_string(), + testio.output.get_last_line().unwrap() + ); + assert_eq!(2.0, result.0); + assert_eq!(3.0, result.1); + } + + #[test] + fn two_inputs_over_multiple_lines() { + let mut testio = getio(); + testio.input.set_input_text("2"); + testio.input.set_input_text("3"); + let result: (f32, f32) = get_input_from_io("prompt", &mut testio); + assert_eq!( + "prompt? ".to_string(), + testio.output.get_last_line().unwrap() + ); + assert_eq!("??? ".to_string(), testio.output.get_last_line().unwrap()); + assert_eq!(2.0, result.0); + assert_eq!(3.0, result.1); + } + + #[test] + fn test_triple_f32() { + let mut testio = getio(); + testio.input.set_input_text("1, 2, 3"); + let result: (f32, f32, f32) = get_input_from_io("enter 3 numbers", &mut testio); + assert_eq!(1.0, result.0); + assert_eq!(2.0, result.1); + assert_eq!(3.0, result.2); + } + + #[test] + fn test_input_string() { + let mut testio = getio(); + testio.input.set_input_text("hello"); + let result: String = get_input_from_io("String prompt", &mut testio); + assert_eq!("hello".to_string(), result); + } + + #[test] + fn test_two_input_strings() { + let mut testio = getio(); + testio.input.set_input_text("hello, world"); + let result: (String, String) = get_input_from_io("Double string prompt", &mut testio); + assert_eq!("hello".to_string(), result.0); + assert_eq!("world".to_string(), result.1); + } + + #[test] + fn test_four_nums() { + let mut testio = getio(); + testio.input.set_input_text("1, 2, 3, 4"); + let result: (f32, f32, f32, f32) = get_input_from_io("whatever", &mut testio); + assert_eq!(4.0, result.3); + } + + #[test] + fn test_extra_ignored() { + let mut testio = getio(); + testio.input.set_input_text("1, 2, 3, 4"); + let result: (f32, f32) = get_input_from_io("whatever", &mut testio); + assert_eq!(Some("whatever? "), testio.output.get_last_line().as_deref()); + assert_eq!( + Some("?EXTRA IGNORED\n"), + testio.output.get_last_line().as_deref() + ); + assert_eq!(2.0, result.1); + } + + #[test] + fn test_extra_ignored_bad_parse() { + let mut testio = getio(); + testio.input.set_input_text("1, 2, hello"); + let result: (f32, f32) = get_input_from_io("whatever", &mut testio); + assert_eq!(Some("whatever? "), testio.output.get_last_line().as_deref()); + assert_eq!( + Some("?EXTRA IGNORED\n"), + testio.output.get_last_line().as_deref() + ); + assert_eq!(2.0, result.1); + } + + #[test] + fn test_ten_nums() { + let mut testio = getio(); + testio.input.set_input_text("1, 2, 3, 4, 5, 6, 7, 8, 9, 10"); + let result: (f32, f32, f32, f32, f32, f32, f32, f32, f32, f32) = + get_input_from_io("whatever", &mut testio); + assert_eq!(Some("whatever? "), testio.output.get_last_line().as_deref()); + assert_eq!(None, testio.output.get_last_line()); + assert_eq!(10.0, result.9); + } + + // + // Helpers for test i/o + // + pub struct ReadTestLayer { + next_line: VecDeque, + } + + impl ReadTestLayer { + pub fn new() -> Self { + Self { + next_line: VecDeque::new(), + } + } + } + + impl TextInput for ReadTestLayer { + fn read(&mut self) -> String { + if let Some(line) = self.next_line.pop_front() { + println!(">> returning {line}"); + line + } else { + panic!("Test tried to read too much input"); + } + } + + fn set_input_text(&mut self, text: &str) { + self.next_line.push_back(text.to_string()); + } + } + + pub struct WriteTestLayer { + last_items_written: VecDeque, + } + + impl WriteTestLayer { + pub fn new() -> Self { + Self { + last_items_written: VecDeque::new(), + } + } + } + + impl TextOutput for WriteTestLayer { + fn write(&mut self, to_write: &str) { + self.last_items_written.push_back(to_write.into()); + } + + fn get_last_line(&mut self) -> Option { + self.last_items_written.pop_front() + } + } +} diff --git a/00_Common/rust/crates/basic_input/src/input/input_state.rs b/00_Common/rust/crates/basic_input/src/input/input_state.rs new file mode 100644 index 000000000..58c19e88f --- /dev/null +++ b/00_Common/rust/crates/basic_input/src/input/input_state.rs @@ -0,0 +1,77 @@ +use crate::{BasicInputError, input::io_layer::IoLayer}; + +pub struct InputState<'a> { + pub(crate) io: &'a mut IoLayer, + pub(crate) data: Vec, +} + +impl<'a> InputState<'a> { + pub(crate) fn input_f32s>( + &mut self, + s: S, + count: usize, + ) -> Result<(), BasicInputError> { + let vals = crate::parse::parse(s); + + for v in vals { + if self.data.len() >= count { + self.io.raw_output_string("?EXTRA IGNORED\n"); + return Ok(()); + } + + let value = v + .trim() + .parse::() + .map_err(|_| BasicInputError::ParseFailed)?; + self.data.push(Value::Num(value)); + } + + if self.data.len() < count { + return Err(BasicInputError::MissingInput); + } + + Ok(()) + } + + pub(crate) fn input_strings>( + &mut self, + s: S, + count: usize, + ) -> Result<(), BasicInputError> { + let vals = crate::parse::parse(s); + + for v in vals { + let value = v.trim(); + self.data.push(Value::Str(value.to_string())); + } + if self.data.len() < count { + return Err(BasicInputError::MissingInput); + } + if self.data.len() > count { + self.io.raw_output_string("?EXTRA IGNORED\n"); + } + Ok(()) + } +} + +#[derive(Clone, Debug)] +pub enum Value { + Num(f32), + Str(String), +} + +impl Value { + pub(crate) fn as_f32(&self) -> f32 { + match self { + Value::Num(num) => *num, + Value::Str(_) => panic!("tried to extract a number, but Value was a String"), + } + } + + pub(crate) fn as_string(&self) -> String { + match self { + Value::Num(_) => panic!("tried to extract a string, but Value was a Num"), + Value::Str(s) => s.clone(), + } + } +} diff --git a/00_Common/rust/crates/basic_input/src/input/io_layer.rs b/00_Common/rust/crates/basic_input/src/input/io_layer.rs new file mode 100644 index 000000000..5939da31f --- /dev/null +++ b/00_Common/rust/crates/basic_input/src/input/io_layer.rs @@ -0,0 +1,80 @@ +use std::io::{BufRead, StdoutLock, Write}; + +pub struct IoLayer { + pub(super) output: Box, + pub(super) input: Box, +} + +impl IoLayer { + pub(crate) fn get_io() -> Self { + Self { + output: StdoutLayer::new(), + input: StdinLayer::new(), + } + } + + pub(crate) fn raw_output_string>(&mut self, s: S) { + self.output.write(s.as_ref()); + } + + pub(crate) fn raw_input_string(&mut self) -> String { + self.input.read() + } +} + +pub(crate) trait TextOutput { + fn write(&mut self, to_write: &str); + #[cfg(test)] + fn get_last_line(&mut self) -> Option; +} + +pub(crate) trait TextInput { + fn read(&mut self) -> String; + #[cfg(test)] + fn set_input_text(&mut self, text: &str); +} + +struct StdoutLayer<'a> { + stdout: StdoutLock<'a>, +} + +impl<'a> TextOutput for StdoutLayer<'a> { + fn write(&mut self, to_write: &str) { + print!("{to_write}"); + let _ = self.stdout.flush(); + } + + #[cfg(test)] + fn get_last_line(&mut self) -> Option { + unreachable!(); + } +} + +struct StdinLayer; +impl TextInput for StdinLayer { + fn read(&mut self) -> String { + let mut buffer = String::new(); + let stdin = std::io::stdin(); + let mut handle = stdin.lock(); + let _ = handle.read_line(&mut buffer); + buffer + } + + #[cfg(test)] + fn set_input_text(&mut self, _text: &str) { + unreachable!(); + } +} + +impl<'a> StdoutLayer<'a> { + fn new() -> Box { + Box::new(Self { + stdout: std::io::stdout().lock(), + }) + } +} +impl StdinLayer { + fn new() -> Box { + Box::new(Self) + } +} diff --git a/00_Common/rust/crates/basic_input/src/lib.rs b/00_Common/rust/crates/basic_input/src/lib.rs new file mode 100644 index 000000000..8d14d9a5f --- /dev/null +++ b/00_Common/rust/crates/basic_input/src/lib.rs @@ -0,0 +1,42 @@ +//! A library for reading input from a basic program. +//! +//! In BASIC, you can have a statement like +//! ```basic +//! 10 INPUT "PROMPT";A$ +//! ``` +//! This crate emulates this with: +//! ```no_run +//! let a_value: String = basic_input::get_input("PROMPT"); +//! ``` +//! +//! BASIC also allows programmers to read multiple values at a time. For example, to +//! read two numbers, the program will have `INPUT "PROMPT";X1,Y1`. This can be done +//! in Rust with a tuple: +//! ```no_run +//! let (x1, y1): (f32, f32) = basic_input::get_input("Enter coordinates"); +//! ``` +//! The code will automatically re-prompt if the user doesn't enter all the values. +//! +//! Guided by the README.md under 00_Common, we know that there are `INPUT` statements +//! in the various games which ask for +//! +//! * 1 or 2 strings, +//! * 1-4 floating-point numbers, or 10(!) floating-point numbers +//! +//! and there are no other kinds of input required. All of these have been accounted +//! for in the `input` module (see `impl BasicInput for `) but they are easy +//! enough to extend/enhance for different kinds of input. +//! +//! To use this in your Rust code, you can reference the local crate by adding the +//! following line to your `Cargo.toml`: +//! +//! ```toml +//! basic_input = { path = "../../00_Common/rust/crates/basic_input" } +//! ``` +//! +//! (adjust the number of `../` for the depth of your own subdirectory) + +pub mod input; +mod parse; + +pub use input::*; diff --git a/00_Common/rust/crates/basic_input/src/parse.rs b/00_Common/rust/crates/basic_input/src/parse.rs new file mode 100644 index 000000000..fa2ac9eff --- /dev/null +++ b/00_Common/rust/crates/basic_input/src/parse.rs @@ -0,0 +1,85 @@ +pub(crate) fn parse>(buffer: B) -> Vec { + let mut result = Vec::new(); + let mut in_quote = false; + let mut need_comma = false; + + let mut current = String::new(); + for ch in buffer.as_ref().chars() { + match (ch, in_quote) { + (',', false) => { + result.push(current.trim().to_string()); + current.clear(); + } + ('"', true) => { + need_comma = true; + result.push(current.trim().to_string()); + current.clear(); + } + ('"', false) => { + in_quote = true; + } + + (',', true) if need_comma => { + need_comma = false; + in_quote = false; + } + (',', true) => current.push(ch), + + _ => { + current.push(ch); + } + } + } + if !current.is_empty() { + result.push(current.trim().to_string()); + } + + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_single_value() { + let result = parse("123"); + assert_eq!("123".to_string(), result[0]); + assert_eq!(1, result.len()); + } + + #[test] + fn parse_two_values() { + let result = parse("123, 456"); + assert_eq!("123".to_string(), result[0]); + assert_eq!("456".to_string(), result[1]); + assert_eq!(2, result.len()); + } + + #[test] + fn parse_quoted_value() { + let result = parse("123, \"456,789\", 10"); + assert_eq!("123".to_string(), result[0]); + assert_eq!("456,789".to_string(), result[1]); + assert_eq!("10".to_string(), result[2]); + assert_eq!(3, result.len()); + } + + #[test] + fn parse_missing_closing_quote() { + let result = parse("123, \"456,789, 10"); + assert_eq!("123".to_string(), result[0]); + assert_eq!("456,789, 10".to_string(), result[1]); + assert_eq!(2, result.len()); + } + + #[test] + fn parse_quoted_value_twice() { + let result = parse("123, \"456,789\", 10, 11"); + assert_eq!("123".to_string(), result[0]); + assert_eq!("456,789".to_string(), result[1]); + assert_eq!("10".to_string(), result[2]); + assert_eq!("11".to_string(), result[3]); + assert_eq!(4, result.len()); + } +}