texttale/lib.rs
1#![doc = include_str!("../README.md")]
2
3use std::fs::read_to_string;
4use std::io::Write;
5use std::path::Path;
6
7use rustyline::error::ReadlineError;
8use rustyline::history::MemHistory;
9use rustyline::Editor;
10
11///////////////////////////////////////////// TextTale /////////////////////////////////////////////
12
13/// A [TextTale] creates a text-mode adventure by feeding the next command from the command prompt
14/// as a string. Will return None when the user kills the session or the underlying writer is
15/// exhausted.
16pub trait TextTale: Write {
17 /// Handle an unexpected EOF.
18 fn unexpected_eof(&mut self);
19 /// Get the current prompt.
20 fn get_prompt(&mut self) -> &'static str;
21 /// Set the current prompt.
22 fn set_prompt(&mut self, prompt: &'static str);
23 /// Return the next command, according to the texttale's rules.
24 fn next_command(&mut self) -> Option<String>;
25}
26
27/////////////////////////////////////////// ShellTextTale //////////////////////////////////////////
28
29/// A [ShellTextTale] gives an interactive shell for testing. It's intended to be interactive.
30pub struct ShellTextTale {
31 rl: Editor<(), MemHistory>,
32 prompt: &'static str,
33}
34
35impl ShellTextTale {
36 /// Create a new texttale shell, using the provided readline editor and prompt.
37 pub fn new(rl: Editor<(), MemHistory>, prompt: &'static str) -> Self {
38 Self { rl, prompt }
39 }
40}
41
42impl Write for ShellTextTale {
43 fn write(&mut self, buf: &[u8]) -> Result<usize, std::io::Error> {
44 std::io::stdout().write(buf)
45 }
46
47 fn flush(&mut self) -> Result<(), std::io::Error> {
48 std::io::stdout().flush()
49 }
50}
51
52impl TextTale for ShellTextTale {
53 fn unexpected_eof(&mut self) {
54 std::process::exit(1);
55 }
56
57 fn get_prompt(&mut self) -> &'static str {
58 self.prompt
59 }
60
61 fn set_prompt(&mut self, prompt: &'static str) {
62 self.prompt = prompt;
63 }
64
65 fn next_command(&mut self) -> Option<String> {
66 let line = self.rl.readline(self.prompt);
67 match line {
68 Ok(line) => Some(line.trim().to_owned()),
69 Err(ReadlineError::Interrupted) => {
70 std::process::exit(1);
71 }
72 Err(ReadlineError::Eof) => None,
73 Err(err) => {
74 panic!("could not read line: {err}");
75 }
76 }
77 }
78}
79
80////////////////////////////////////////// ExpectTextTale //////////////////////////////////////////
81
82/// An [ExpectTextTale] gives an adventure that gets recorded and compared against the input. It's
83/// intended to run the script and compare its output to the file. See `CHECKSUMS.zsh` for an
84/// example of the tests in the `scripts/` directory.
85#[derive(Default)]
86pub struct ExpectTextTale {
87 prompt: &'static str,
88 input_lines: Vec<String>,
89 output_buffer: Vec<u8>,
90}
91
92impl ExpectTextTale {
93 /// Create a new expect text tale that reads from script and compares the output of the
94 /// texttale against the expected output in the script.
95 pub fn new<P: AsRef<Path>>(script: P, prompt: &'static str) -> Result<Self, std::io::Error> {
96 let script = read_to_string(script)?;
97 let input_lines = script.lines().map(|s| s.to_string()).collect();
98 Ok(Self {
99 prompt,
100 input_lines,
101 output_buffer: Vec::new(),
102 })
103 }
104}
105
106impl Write for ExpectTextTale {
107 fn write(&mut self, buf: &[u8]) -> Result<usize, std::io::Error> {
108 self.output_buffer.write(buf)
109 }
110
111 fn flush(&mut self) -> Result<(), std::io::Error> {
112 self.output_buffer.flush()
113 }
114}
115
116fn diff(exp: &str, got: &str) {
117 if exp == got {
118 return;
119 }
120 let exp: Vec<String> = exp.trim_end().split('\n').map(String::from).collect();
121 let got: Vec<String> = got.trim_end().split('\n').map(String::from).collect();
122 let mut arr = vec![vec![0; got.len() + 1]; exp.len() + 1];
123 for i in 0..exp.len() {
124 #[allow(clippy::needless_range_loop)]
125 for j in 0..got.len() {
126 if exp[i] == got[j] {
127 arr[i + 1][j + 1] = arr[i][j] + 1;
128 } else {
129 arr[i + 1][j + 1] = std::cmp::max(arr[i][j + 1], arr[i + 1][j]);
130 }
131 }
132 }
133 let mut e = exp.len();
134 let mut g = got.len();
135 let mut diff = vec![];
136 while e > 0 && g > 0 {
137 if exp[e - 1] == got[g - 1] {
138 diff.push(" ".to_string() + &exp[e - 1]);
139 e -= 1;
140 g -= 1;
141 } else if arr[e][g] == arr[e][g - 1] {
142 diff.push("+".to_string() + &got[g - 1]);
143 g -= 1;
144 } else {
145 diff.push("-".to_string() + &exp[e - 1]);
146 e -= 1;
147 }
148 }
149 while g > 0 {
150 diff.push(format!("+{}", got[g - 1]));
151 g -= 1;
152 }
153 while e > 0 {
154 diff.push(format!("-{}", exp[e - 1]));
155 e -= 1;
156 }
157 diff.reverse();
158 panic!(
159 "texttale doesn't meet expectations\n-expected +returned:\n{}",
160 diff.join("\n")
161 );
162}
163
164impl TextTale for ExpectTextTale {
165 fn unexpected_eof(&mut self) {
166 panic!("unexpected end of file");
167 }
168
169 fn get_prompt(&mut self) -> &'static str {
170 self.prompt
171 }
172
173 fn set_prompt(&mut self, prompt: &'static str) {
174 self.prompt = prompt;
175 }
176
177 fn next_command(&mut self) -> Option<String> {
178 let mut expected_output = String::new();
179 loop {
180 if !self.input_lines.is_empty() && self.input_lines[0].starts_with(self.prompt) {
181 let cmd = self.input_lines.remove(0);
182 let exp = expected_output.trim();
183 let got = String::from_utf8(self.output_buffer.clone()).unwrap();
184 let got = got.trim();
185 diff(exp, got);
186 if !expected_output.is_empty() {
187 println!("{expected_output}");
188 }
189 println!("{cmd}");
190 self.output_buffer.clear();
191 return Some(cmd[self.prompt.len()..].to_owned());
192 } else if !self.input_lines.is_empty() {
193 if !expected_output.is_empty() {
194 expected_output += "\n";
195 }
196 expected_output += &self.input_lines.remove(0);
197 } else {
198 if !expected_output.is_empty() {
199 panic!("expected output truncated: are you ending with a prompt?");
200 }
201 return None;
202 }
203 }
204 }
205}
206
207/////////////////////////////////////////// StoryElement ///////////////////////////////////////////
208
209/// A [StoryElement] dictates what to do next in the story.
210pub enum StoryElement {
211 /// Continue with the story.
212 Continue,
213 /// Return from the current function defined by the story macro.
214 Return,
215 /// Print the provided help string before the next prompt.
216 PrintHelp,
217}
218
219//////////////////////////////////////////// story macro ///////////////////////////////////////////
220
221/// A [story] always takes the same form. It is addressed to the method that it will generate for
222/// the author. It is always followed by the prompt that will be displayed when the user asks for
223/// help. Then, it's a sequence of commands that need to be interpreted.
224///
225/// ```
226/// use texttale::{story, StoryElement, TextTale};
227///
228/// struct Player<T: TextTale> {
229/// name: String,
230/// age: u8,
231/// gender: String,
232/// race: String,
233/// tale: T,
234/// }
235///
236/// story! {
237/// self cmd,
238/// character by Player<T>;
239/// "Craft your character.
240///
241/// help: .... Print this help menu.
242/// name: .... Set your character's name.
243/// age: ..... Set your character's age.
244/// gender: .. Set your character's gender.
245/// race: .... Set your character's race.
246/// print: ... Print your character.
247/// save: .... Commit changes to the configuration and return to previous menu.
248/// ";
249/// "name" => {
250/// self.name = cmd[1..].to_vec().join(" ");
251/// StoryElement::Continue
252/// }
253/// "gender" => {
254/// self.gender = cmd[1..].to_vec().join(" ");
255/// StoryElement::Continue
256/// }
257/// "race" => {
258/// self.race = cmd[1..].to_vec().join(" ");
259/// StoryElement::Continue
260/// }
261/// "age" => {
262/// if cmd.len() != 2 {
263/// writeln!(self.tale, "USAGE: age [age]").unwrap();
264/// } else {
265/// match cmd[1].parse::<u8>() {
266/// Ok(age) => {
267/// self.age = age;
268/// },
269/// Err(err) => {
270/// writeln!(self.tale, "invalid age: {}", err).unwrap();
271/// },
272/// };
273/// }
274/// StoryElement::Continue
275/// }
276/// "print" => {
277/// let debug = format!("{:#?}", self);
278/// writeln!(self.tale, "{}", debug).unwrap();
279/// StoryElement::Continue
280/// }
281/// "save" => {
282/// StoryElement::Return
283/// }
284/// }
285///
286/// impl<T: TextTale> std::fmt::Debug for Player<T> {
287/// fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
288/// f.debug_struct("Player")
289/// .field("name", &self.name)
290/// .field("age", &self.age)
291/// .field("gender", &self.gender)
292/// .field("race", &self.race)
293/// .finish()
294/// }
295/// }
296/// ```
297#[macro_export]
298macro_rules! story {
299 ($this:ident $cmd:ident, $story_title:ident by $story_teller:ty; $help:literal; $($command:literal => $code:tt)*) => {
300 impl<T: TextTale> $story_teller {
301 pub fn $story_title(&mut $this) {
302 let mut print_help = true;
303 'adventuring:
304 loop {
305 if print_help {
306 writeln!($this.tale, "{}", $help).expect("print help");
307 print_help = false;
308 }
309 if let Some(ref line) = $this.tale.next_command() {
310 let $cmd: Vec<&str> = line.split_whitespace().collect();
311 if $cmd.is_empty() {
312 continue 'adventuring;
313 }
314 let element: $crate::StoryElement = match $cmd[0] {
315 $($command => $code),*
316 _ => {
317 writeln!($this.tale, "unknown command: {}", line.as_str()).expect("unknown command");
318 continue 'adventuring;
319 },
320 };
321 match element {
322 StoryElement::Continue => {
323 continue 'adventuring;
324 },
325 StoryElement::Return => {
326 break 'adventuring;
327 }
328 StoryElement::PrintHelp => {
329 print_help = true;
330 continue 'adventuring;
331 }
332 }
333 } else {
334 break 'adventuring;
335 }
336 }
337 }
338 }
339 };
340}
341
342/////////////////////////////////////////////// Menu ///////////////////////////////////////////////
343
344/// A [Menu] dictates what to do next within a menu.
345pub enum Menu {
346 /// Continue to the next prompt in the menu.
347 Continue,
348 /// Retry the current prompt; usually this is used for data that doesn't validate.
349 Retry,
350 /// Announce an unexpected end-of-file.
351 UnexpectedEof,
352}
353
354//////////////////////////////////////////// menu macro ////////////////////////////////////////////
355
356/// An [menu] is a series of interactive prompts to be answered in order. Where a story provides
357/// choice via branches, an menu sequences prompts in-order and expects an answer to each prompt.
358///
359/// ```
360/// use texttale::{menu, story, Menu, StoryElement, TextTale};
361///
362/// #[derive(Debug)]
363/// struct Player<T: TextTale> {
364/// name: String,
365/// age: u8,
366/// gender: String,
367/// race: String,
368/// tale: T,
369/// }
370///
371/// story! {
372/// self cmd,
373/// character by Player<T>;
374/// "Craft your character.
375///
376/// help: ....... Print this help menu.
377/// interview: .. Answer questions to fill in your character's details.
378/// ";
379/// "name" => {
380/// menu! {
381/// self cmd;
382/// "name" => {
383/// /* code */
384/// Menu::Continue
385/// }
386/// "age" => {
387/// /* more code */
388/// Menu::Continue
389/// }
390/// }
391/// StoryElement::Continue
392/// }
393/// }
394/// ```
395#[macro_export]
396macro_rules! menu {
397 ($this:ident $cmd:ident; $($prompt:literal => $code:tt)*) => {
398 {
399 let prompt = $this.tale.get_prompt();
400 $(
401 'retrying: loop {
402 $this.tale.set_prompt($prompt);
403 let $cmd = $this.tale.next_command();
404 let action = if let Some($cmd) = $cmd {
405 $code
406 } else {
407 $crate::Menu::UnexpectedEof
408 };
409 match action {
410 $crate::Menu::Continue => {
411 break 'retrying;
412 }
413 $crate::Menu::Retry => {}
414 $crate::Menu::UnexpectedEof => {
415 $this.tale.unexpected_eof();
416 },
417 }
418 }
419 )*
420 $this.tale.set_prompt(prompt);
421 }
422 };
423}