diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app_state.rs | 277 | ||||
| -rw-r--r-- | src/bin/gen-dev-cargo-toml.rs | 64 | ||||
| -rw-r--r-- | src/embedded.rs | 8 | ||||
| -rw-r--r-- | src/exercise.rs | 294 | ||||
| -rw-r--r-- | src/info_file.rs | 79 | ||||
| -rw-r--r-- | src/init.rs | 63 | ||||
| -rw-r--r-- | src/list.rs | 90 | ||||
| -rw-r--r-- | src/list/state.rs | 261 | ||||
| -rw-r--r-- | src/main.rs | 484 | ||||
| -rw-r--r-- | src/progress_bar.rs | 97 | ||||
| -rw-r--r-- | src/run.rs | 60 | ||||
| -rw-r--r-- | src/ui.rs | 28 | ||||
| -rw-r--r-- | src/verify.rs | 223 | ||||
| -rw-r--r-- | src/watch.rs | 128 | ||||
| -rw-r--r-- | src/watch/notify_event.rs | 42 | ||||
| -rw-r--r-- | src/watch/state.rs | 168 | ||||
| -rw-r--r-- | src/watch/terminal_event.rs | 73 |
17 files changed, 1441 insertions, 998 deletions
diff --git a/src/app_state.rs b/src/app_state.rs new file mode 100644 index 0000000..432a9a2 --- /dev/null +++ b/src/app_state.rs @@ -0,0 +1,277 @@ +use anyhow::{bail, Context, Result}; +use crossterm::{ + style::Stylize, + terminal::{Clear, ClearType}, + ExecutableCommand, +}; +use std::{ + fs::{self, File}, + io::{Read, StdoutLock, Write}, +}; + +use crate::{exercise::Exercise, info_file::ExerciseInfo, FENISH_LINE}; + +const STATE_FILE_NAME: &str = ".rustlings-state.txt"; +const BAD_INDEX_ERR: &str = "The current exercise index is higher than the number of exercises"; + +#[must_use] +pub enum ExercisesProgress { + AllDone, + Pending, +} + +pub enum StateFileStatus { + Read, + NotRead, +} + +pub struct AppState { + current_exercise_ind: usize, + exercises: Vec<Exercise>, + n_done: u16, + final_message: String, + file_buf: Vec<u8>, +} + +impl AppState { + fn update_from_file(&mut self) -> StateFileStatus { + self.file_buf.clear(); + self.n_done = 0; + + if File::open(STATE_FILE_NAME) + .and_then(|mut file| file.read_to_end(&mut self.file_buf)) + .is_err() + { + return StateFileStatus::NotRead; + } + + // See `Self::write` for more information about the file format. + let mut lines = self.file_buf.split(|c| *c == b'\n'); + let Some(current_exercise_name) = lines.next() else { + return StateFileStatus::NotRead; + }; + + if current_exercise_name.is_empty() || lines.next().is_none() { + return StateFileStatus::NotRead; + } + + let mut done_exercises = hashbrown::HashSet::with_capacity(self.exercises.len()); + + for done_exerise_name in lines { + if done_exerise_name.is_empty() { + break; + } + done_exercises.insert(done_exerise_name); + } + + for (ind, exercise) in self.exercises.iter_mut().enumerate() { + if done_exercises.contains(exercise.name.as_bytes()) { + exercise.done = true; + self.n_done += 1; + } + + if exercise.name.as_bytes() == current_exercise_name { + self.current_exercise_ind = ind; + } + } + + StateFileStatus::Read + } + + pub fn new( + exercise_infos: Vec<ExerciseInfo>, + final_message: String, + ) -> (Self, StateFileStatus) { + let exercises = exercise_infos + .into_iter() + .map(|mut exercise_info| { + // Leaking to be able to borrow in the watch mode `Table`. + // Leaking is not a problem because the `AppState` instance lives until + // the end of the program. + let path = exercise_info.path().leak(); + + exercise_info.name.shrink_to_fit(); + let name = exercise_info.name.leak(); + + let hint = exercise_info.hint.trim().to_owned(); + + Exercise { + name, + path, + mode: exercise_info.mode, + hint, + done: false, + } + }) + .collect::<Vec<_>>(); + + let mut slf = Self { + current_exercise_ind: 0, + exercises, + n_done: 0, + final_message, + file_buf: Vec::with_capacity(2048), + }; + + let state_file_status = slf.update_from_file(); + + (slf, state_file_status) + } + + #[inline] + pub fn current_exercise_ind(&self) -> usize { + self.current_exercise_ind + } + + #[inline] + pub fn exercises(&self) -> &[Exercise] { + &self.exercises + } + + #[inline] + pub fn n_done(&self) -> u16 { + self.n_done + } + + #[inline] + pub fn current_exercise(&self) -> &Exercise { + &self.exercises[self.current_exercise_ind] + } + + pub fn set_current_exercise_ind(&mut self, ind: usize) -> Result<()> { + if ind >= self.exercises.len() { + bail!(BAD_INDEX_ERR); + } + + self.current_exercise_ind = ind; + + self.write() + } + + pub fn set_current_exercise_by_name(&mut self, name: &str) -> Result<()> { + // O(N) is fine since this method is used only once until the program exits. + // Building a hashmap would have more overhead. + self.current_exercise_ind = self + .exercises + .iter() + .position(|exercise| exercise.name == name) + .with_context(|| format!("No exercise found for '{name}'!"))?; + + self.write() + } + + pub fn set_pending(&mut self, ind: usize) -> Result<()> { + let exercise = self.exercises.get_mut(ind).context(BAD_INDEX_ERR)?; + + if exercise.done { + exercise.done = false; + self.n_done -= 1; + self.write()?; + } + + Ok(()) + } + + fn next_pending_exercise_ind(&self) -> Option<usize> { + if self.current_exercise_ind == self.exercises.len() - 1 { + // The last exercise is done. + // Search for exercises not done from the start. + return self.exercises[..self.current_exercise_ind] + .iter() + .position(|exercise| !exercise.done); + } + + // The done exercise isn't the last one. + // Search for a pending exercise after the current one and then from the start. + match self.exercises[self.current_exercise_ind + 1..] + .iter() + .position(|exercise| !exercise.done) + { + Some(ind) => Some(self.current_exercise_ind + 1 + ind), + None => self.exercises[..self.current_exercise_ind] + .iter() + .position(|exercise| !exercise.done), + } + } + + pub fn done_current_exercise(&mut self, writer: &mut StdoutLock) -> Result<ExercisesProgress> { + let exercise = &mut self.exercises[self.current_exercise_ind]; + if !exercise.done { + exercise.done = true; + self.n_done += 1; + } + + let Some(ind) = self.next_pending_exercise_ind() else { + writer.write_all(RERUNNING_ALL_EXERCISES_MSG)?; + + for (exercise_ind, exercise) in self.exercises().iter().enumerate() { + writer.write_fmt(format_args!("Running {exercise} ... "))?; + writer.flush()?; + + if !exercise.run()?.status.success() { + writer.write_fmt(format_args!("{}\n\n", "FAILED".red()))?; + + self.current_exercise_ind = exercise_ind; + + // No check if the exercise is done before setting it to pending + // because no pending exercise was found. + self.exercises[exercise_ind].done = false; + self.n_done -= 1; + + self.write()?; + + return Ok(ExercisesProgress::Pending); + } + + writer.write_fmt(format_args!("{}\n", "ok".green()))?; + } + + writer.execute(Clear(ClearType::All))?; + writer.write_all(FENISH_LINE.as_bytes())?; + + let final_message = self.final_message.trim(); + if !final_message.is_empty() { + writer.write_all(self.final_message.as_bytes())?; + writer.write_all(b"\n")?; + } + + return Ok(ExercisesProgress::AllDone); + }; + + self.set_current_exercise_ind(ind)?; + + Ok(ExercisesProgress::Pending) + } + + // Write the state file. + // The file's format is very simple: + // - The first line is the name of the current exercise. It must end with `\n` even if there + // are no done exercises. + // - The second line is an empty line. + // - All remaining lines are the names of done exercises. + fn write(&mut self) -> Result<()> { + self.file_buf.clear(); + + self.file_buf + .extend_from_slice(self.current_exercise().name.as_bytes()); + self.file_buf.push(b'\n'); + + for exercise in &self.exercises { + if exercise.done { + self.file_buf.push(b'\n'); + self.file_buf.extend_from_slice(exercise.name.as_bytes()); + } + } + + fs::write(STATE_FILE_NAME, &self.file_buf) + .with_context(|| format!("Failed to write the state file {STATE_FILE_NAME}"))?; + + Ok(()) + } +} + +const RERUNNING_ALL_EXERCISES_MSG: &[u8] = b" +All exercises seem to be done. +Recompiling and running all exercises to make sure that all of them are actually done. + +"; diff --git a/src/bin/gen-dev-cargo-toml.rs b/src/bin/gen-dev-cargo-toml.rs deleted file mode 100644 index ff8f31d..0000000 --- a/src/bin/gen-dev-cargo-toml.rs +++ /dev/null @@ -1,64 +0,0 @@ -// Generates `dev/Cargo.toml` such that it is synced with `info.toml`. -// `dev/Cargo.toml` is a hack to allow using `cargo r` to test `rustlings` -// during development. - -use anyhow::{bail, Context, Result}; -use serde::Deserialize; -use std::{ - fs::{self, create_dir}, - io::ErrorKind, -}; - -#[derive(Deserialize)] -struct Exercise { - name: String, - path: String, -} - -#[derive(Deserialize)] -struct InfoToml { - exercises: Vec<Exercise>, -} - -fn main() -> Result<()> { - let exercises = toml_edit::de::from_str::<InfoToml>( - &fs::read_to_string("info.toml").context("Failed to read `info.toml`")?, - ) - .context("Failed to deserialize `info.toml`")? - .exercises; - - let mut buf = Vec::with_capacity(1 << 14); - - buf.extend_from_slice( - b"# This file is a hack to allow using `cargo r` to test `rustlings` during development. -# You shouldn't edit it manually. It is created and updated by running `cargo run --bin gen-dev-cargo-toml`. - -bin = [\n", - ); - - for exercise in exercises { - buf.extend_from_slice(b" { name = \""); - buf.extend_from_slice(exercise.name.as_bytes()); - buf.extend_from_slice(b"\", path = \"../"); - buf.extend_from_slice(exercise.path.as_bytes()); - buf.extend_from_slice(b"\" },\n"); - } - - buf.extend_from_slice( - br#"] - -[package] -name = "rustlings" -edition = "2021" -publish = false -"#, - ); - - if let Err(e) = create_dir("dev") { - if e.kind() != ErrorKind::AlreadyExists { - bail!("Failed to create the `dev` directory: {e}"); - } - } - - fs::write("dev/Cargo.toml", buf).context("Failed to write `dev/Cargo.toml`") -} diff --git a/src/embedded.rs b/src/embedded.rs index 56b4b61..866b12b 100644 --- a/src/embedded.rs +++ b/src/embedded.rs @@ -65,7 +65,6 @@ struct ExercisesDir { } pub struct EmbeddedFiles { - pub info_toml_content: &'static str, exercises_dir: ExercisesDir, } @@ -92,7 +91,12 @@ impl EmbeddedFiles { Ok(()) } - pub fn write_exercise_to_disk(&self, path: &Path, strategy: WriteStrategy) -> io::Result<()> { + pub fn write_exercise_to_disk<P>(&self, path: P, strategy: WriteStrategy) -> io::Result<()> + where + P: AsRef<Path>, + { + let path = path.as_ref(); + if let Some(file) = self .exercises_dir .files diff --git a/src/exercise.rs b/src/exercise.rs index 450acf4..2ec8d97 100644 --- a/src/exercise.rs +++ b/src/exercise.rs @@ -1,92 +1,47 @@ use anyhow::{Context, Result}; -use serde::Deserialize; -use std::fmt::{self, Debug, Display, Formatter}; -use std::fs::{self, File}; -use std::io::{self, BufRead, BufReader}; -use std::path::PathBuf; -use std::process::{exit, Command, Output}; -use std::{array, mem}; -use winnow::ascii::{space0, Caseless}; -use winnow::combinator::opt; -use winnow::Parser; - -use crate::embedded::EMBEDDED_FILES; - -// The number of context lines above and below a highlighted line. -const CONTEXT: usize = 2; - -// Check if the line contains the "I AM NOT DONE" comment. -fn contains_not_done_comment(input: &str) -> bool { - ( - space0::<_, ()>, - "//", - opt('/'), - space0, - Caseless("I AM NOT DONE"), - ) - .parse_next(&mut &*input) - .is_ok() -} - -// The mode of the exercise. -#[derive(Deserialize, Copy, Clone)] -#[serde(rename_all = "lowercase")] -pub enum Mode { - // The exercise should be compiled as a binary - Compile, - // The exercise should be compiled as a test harness - Test, - // The exercise should be linted with clippy - Clippy, +use crossterm::style::{style, StyledContent, Stylize}; +use std::{ + fmt::{self, Display, Formatter}, + fs, + process::{Command, Output}, +}; + +use crate::{ + embedded::{WriteStrategy, EMBEDDED_FILES}, + info_file::Mode, +}; + +pub struct TerminalFileLink<'a> { + path: &'a str, } -#[derive(Deserialize)] -pub struct ExerciseList { - pub exercises: Vec<Exercise>, -} - -impl ExerciseList { - pub fn parse() -> Result<Self> { - // Read a local `info.toml` if it exists. - // Mainly to let the tests work for now. - if let Ok(file_content) = fs::read_to_string("info.toml") { - toml_edit::de::from_str(&file_content) +impl<'a> Display for TerminalFileLink<'a> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + if let Ok(Some(canonical_path)) = fs::canonicalize(self.path) + .as_deref() + .map(|path| path.to_str()) + { + write!( + f, + "\x1b]8;;file://{}\x1b\\{}\x1b]8;;\x1b\\", + canonical_path, self.path, + ) } else { - toml_edit::de::from_str(EMBEDDED_FILES.info_toml_content) + write!(f, "{}", self.path,) } - .context("Failed to parse `info.toml`") } } -// Deserialized from the `info.toml` file. -#[derive(Deserialize)] pub struct Exercise { - // Name of the exercise - pub name: String, - // The path to the file containing the exercise's source code - pub path: PathBuf, + // Exercise's unique name + pub name: &'static str, + // Exercise's path + pub path: &'static str, // The mode of the exercise pub mode: Mode, // The hint text associated with the exercise pub hint: String, -} - -// The state of an Exercise. -#[derive(PartialEq, Eq, Debug)] -pub enum State { - Done, - Pending(Vec<ContextLine>), -} - -// The context information of a pending exercise. -#[derive(PartialEq, Eq, Debug)] -pub struct ContextLine { - // The source code line - pub line: String, - // The line number - pub number: usize, - // Whether this is important and should be highlighted - pub important: bool, + pub done: bool, } impl Exercise { @@ -105,7 +60,7 @@ impl Exercise { .arg("always") .arg("-q") .arg("--bin") - .arg(&self.name) + .arg(self.name) .args(args) .output() .context("Failed to run Cargo") @@ -113,8 +68,8 @@ impl Exercise { pub fn run(&self) -> Result<Output> { match self.mode { - Mode::Compile => self.cargo_cmd("run", &[]), - Mode::Test => self.cargo_cmd("test", &["--", "--nocapture"]), + Mode::Run => self.cargo_cmd("run", &[]), + Mode::Test => self.cargo_cmd("test", &["--", "--nocapture", "--format", "pretty"]), Mode::Clippy => self.cargo_cmd( "clippy", &["--", "-D", "warnings", "-D", "clippy::float_cmp"], @@ -122,107 +77,16 @@ impl Exercise { } } - pub fn state(&self) -> Result<State> { - let source_file = File::open(&self.path) - .with_context(|| format!("Failed to open the exercise file {}", self.path.display()))?; - let mut source_reader = BufReader::new(source_file); - - // Read the next line into `buf` without the newline at the end. - let mut read_line = |buf: &mut String| -> io::Result<_> { - let n = source_reader.read_line(buf)?; - if buf.ends_with('\n') { - buf.pop(); - if buf.ends_with('\r') { - buf.pop(); - } - } - Ok(n) - }; - - let mut current_line_number: usize = 1; - // Keep the last `CONTEXT` lines while iterating over the file lines. - let mut prev_lines: [_; CONTEXT] = array::from_fn(|_| String::with_capacity(256)); - let mut line = String::with_capacity(256); - - loop { - let n = read_line(&mut line).unwrap_or_else(|e| { - println!( - "Failed to read the exercise file {}: {e}", - self.path.display(), - ); - exit(1); - }); - - // Reached the end of the file and didn't find the comment. - if n == 0 { - return Ok(State::Done); - } - - if contains_not_done_comment(&line) { - let mut context = Vec::with_capacity(2 * CONTEXT + 1); - // Previous lines. - for (ind, prev_line) in prev_lines - .into_iter() - .take(current_line_number - 1) - .enumerate() - .rev() - { - context.push(ContextLine { - line: prev_line, - number: current_line_number - 1 - ind, - important: false, - }); - } - - // Current line. - context.push(ContextLine { - line, - number: current_line_number, - important: true, - }); - - // Next lines. - for ind in 0..CONTEXT { - let mut next_line = String::with_capacity(256); - let Ok(n) = read_line(&mut next_line) else { - // If an error occurs, just ignore the next lines. - break; - }; - - // Reached the end of the file. - if n == 0 { - break; - } - - context.push(ContextLine { - line: next_line, - number: current_line_number + 1 + ind, - important: false, - }); - } - - return Ok(State::Pending(context)); - } - - current_line_number += 1; - // Add the current line as a previous line and shift the older lines by one. - for prev_line in &mut prev_lines { - mem::swap(&mut line, prev_line); - } - // The current line now contains the oldest previous line. - // Recycle it for reading the next line. - line.clear(); - } + pub fn reset(&self) -> Result<()> { + EMBEDDED_FILES + .write_exercise_to_disk(self.path, WriteStrategy::Overwrite) + .with_context(|| format!("Failed to reset the exercise {self}")) } - // Check that the exercise looks to be solved using self.state() - // This is not the best way to check since - // the user can just remove the "I AM NOT DONE" string from the file - // without actually having solved anything. - // The only other way to truly check this would to compile and run - // the exercise; which would be both costly and counterintuitive - pub fn looks_done(&self) -> Result<bool> { - self.state().map(|state| state == State::Done) + pub fn terminal_link(&self) -> StyledContent<TerminalFileLink<'_>> { + style(TerminalFileLink { path: self.path }) + .underlined() + .blue() } } @@ -231,77 +95,3 @@ impl Display for Exercise { self.path.fmt(f) } } - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_pending_state() { - let exercise = Exercise { - name: "pending_exercise".into(), - path: PathBuf::from("tests/fixture/state/exercises/pending_exercise.rs"), - mode: Mode::Compile, - hint: String::new(), - }; - - let state = exercise.state(); - let expected = vec![ - ContextLine { - line: "// fake_exercise".to_string(), - number: 1, - important: false, - }, - ContextLine { - line: "".to_string(), - number: 2, - important: false, - }, - ContextLine { - line: "// I AM NOT DONE".to_string(), - number: 3, - important: true, - }, - ContextLine { - line: "".to_string(), - number: 4, - important: false, - }, - ContextLine { - line: "fn main() {".to_string(), - number: 5, - important: false, - }, - ]; - - assert_eq!(state.unwrap(), State::Pending(expected)); - } - - #[test] - fn test_finished_exercise() { - let exercise = Exercise { - name: "finished_exercise".into(), - path: PathBuf::from("tests/fixture/state/exercises/finished_exercise.rs"), - mode: Mode::Compile, - hint: String::new(), - }; - - assert_eq!(exercise.state().unwrap(), State::Done); - } - - #[test] - fn test_not_done() { - assert!(contains_not_done_comment("// I AM NOT DONE")); - assert!(contains_not_done_comment("/// I AM NOT DONE")); - assert!(contains_not_done_comment("// I AM NOT DONE")); - assert!(contains_not_done_comment("/// I AM NOT DONE")); - assert!(contains_not_done_comment("// I AM NOT DONE ")); - assert!(contains_not_done_comment("// I AM NOT DONE!")); - assert!(contains_not_done_comment("// I am not done")); - assert!(contains_not_done_comment("// i am NOT done")); - - assert!(!contains_not_done_comment("I AM NOT DONE")); - assert!(!contains_not_done_comment("// NOT DONE")); - assert!(!contains_not_done_comment("DONE")); - } -} diff --git a/src/info_file.rs b/src/info_file.rs new file mode 100644 index 0000000..2a45e02 --- /dev/null +++ b/src/info_file.rs @@ -0,0 +1,79 @@ +use anyhow::{bail, Context, Error, Result}; +use serde::Deserialize; +use std::fs; + +// The mode of the exercise. +#[derive(Deserialize, Copy, Clone)] +#[serde(rename_all = "lowercase")] +pub enum Mode { + // The exercise should be compiled as a binary + Run, + // The exercise should be compiled as a test harness + Test, + // The exercise should be linted with clippy + Clippy, +} + +// Deserialized from the `info.toml` file. +#[derive(Deserialize)] +pub struct ExerciseInfo { + // Name of the exercise + pub name: String, + // The exercise's directory inside the `exercises` directory + pub dir: Option<String>, + // The mode of the exercise + pub mode: Mode, + // The hint text associated with the exercise + pub hint: String, +} + +impl ExerciseInfo { + pub fn path(&self) -> String { + if let Some(dir) = &self.dir { + format!("exercises/{dir}/{}.rs", self.name) + } else { + format!("exercises/{}.rs", self.name) + } + } +} + +#[derive(Deserialize)] +pub struct InfoFile { + pub welcome_message: Option<String>, + pub final_message: Option<String>, + pub exercises: Vec<ExerciseInfo>, +} + +impl InfoFile { + pub fn parse() -> Result<Self> { + // Read a local `info.toml` if it exists. + let slf: Self = match fs::read_to_string("info.toml") { + Ok(file_content) => toml_edit::de::from_str(&file_content) + .context("Failed to parse the `info.toml` file")?, + Err(e) => match e.kind() { + std::io::ErrorKind::NotFound => { + toml_edit::de::from_str(include_str!("../info.toml")) + .context("Failed to parse the embedded `info.toml` file")? + } + _ => return Err(Error::from(e).context("Failed to read the `info.toml` file")), + }, + }; + + if slf.exercises.is_empty() { + bail!("{NO_EXERCISES_ERR}"); + } + + let mut names_set = hashbrown::HashSet::with_capacity(slf.exercises.len()); + for exercise in &slf.exercises { + if !names_set.insert(exercise.name.as_str()) { + bail!("Exercise names must all be unique!") + } + } + drop(names_set); + + Ok(slf) + } +} + +const NO_EXERCISES_ERR: &str = "There are no exercises yet! +If you are developing third-party exercises, add at least one exercise before testing."; diff --git a/src/init.rs b/src/init.rs index 6af3235..459519d 100644 --- a/src/init.rs +++ b/src/init.rs @@ -6,17 +6,21 @@ use std::{ path::Path, }; -use crate::{embedded::EMBEDDED_FILES, exercise::Exercise}; +use crate::{embedded::EMBEDDED_FILES, info_file::ExerciseInfo}; -fn create_cargo_toml(exercises: &[Exercise]) -> io::Result<()> { +fn create_cargo_toml(exercise_infos: &[ExerciseInfo]) -> io::Result<()> { let mut cargo_toml = Vec::with_capacity(1 << 13); cargo_toml.extend_from_slice(b"bin = [\n"); - for exercise in exercises { + for exercise_info in exercise_infos { cargo_toml.extend_from_slice(b" { name = \""); - cargo_toml.extend_from_slice(exercise.name.as_bytes()); - cargo_toml.extend_from_slice(b"\", path = \""); - cargo_toml.extend_from_slice(exercise.path.to_str().unwrap().as_bytes()); - cargo_toml.extend_from_slice(b"\" },\n"); + cargo_toml.extend_from_slice(exercise_info.name.as_bytes()); + cargo_toml.extend_from_slice(b"\", path = \"exercises/"); + if let Some(dir) = &exercise_info.dir { + cargo_toml.extend_from_slice(dir.as_bytes()); + cargo_toml.push(b'/'); + } + cargo_toml.extend_from_slice(exercise_info.name.as_bytes()); + cargo_toml.extend_from_slice(b".rs\" },\n"); } cargo_toml.extend_from_slice( @@ -36,46 +40,33 @@ publish = false } fn create_gitignore() -> io::Result<()> { - let gitignore = b"/target"; OpenOptions::new() .create_new(true) .write(true) .open(".gitignore")? - .write_all(gitignore) + .write_all(GITIGNORE) } fn create_vscode_dir() -> Result<()> { create_dir(".vscode").context("Failed to create the directory `.vscode`")?; - let vs_code_extensions_json = br#"{"recommendations":["rust-lang.rust-analyzer"]}"#; OpenOptions::new() .create_new(true) .write(true) .open(".vscode/extensions.json")? - .write_all(vs_code_extensions_json)?; + .write_all(VS_CODE_EXTENSIONS_JSON)?; Ok(()) } -pub fn init_rustlings(exercises: &[Exercise]) -> Result<()> { +pub fn init(exercise_infos: &[ExerciseInfo]) -> Result<()> { if Path::new("exercises").is_dir() && Path::new("Cargo.toml").is_file() { - bail!( - "A directory with the name `exercises` and a file with the name `Cargo.toml` already exist -in the current directory. It looks like Rustlings was already initialized here. -Run `rustlings` for instructions on getting started with the exercises. - -If you didn't already initialize Rustlings, please initialize it in another directory." - ); + bail!(PROBABLY_IN_RUSTLINGS_DIR_ERR); } let rustlings_path = Path::new("rustlings"); if let Err(e) = create_dir(rustlings_path) { if e.kind() == ErrorKind::AlreadyExists { - bail!( - "A directory with the name `rustlings` already exists in the current directory. -You probably already initialized Rustlings. -Run `cd rustlings` -Then run `rustlings` again" - ); + bail!(RUSTLINGS_DIR_ALREADY_EXISTS_ERR); } return Err(e.into()); } @@ -87,7 +78,8 @@ Then run `rustlings` again" .init_exercises_dir() .context("Failed to initialize the `rustlings/exercises` directory")?; - create_cargo_toml(exercises).context("Failed to create the file `rustlings/Cargo.toml`")?; + create_cargo_toml(exercise_infos) + .context("Failed to create the file `rustlings/Cargo.toml`")?; create_gitignore().context("Failed to create the file `rustlings/.gitignore`")?; @@ -95,3 +87,22 @@ Then run `rustlings` again" Ok(()) } + +const GITIGNORE: &[u8] = b"/target +/.rustlings-state.txt +"; + +const VS_CODE_EXTENSIONS_JSON: &[u8] = br#"{"recommendations":["rust-lang.rust-analyzer"]}"#; + +const PROBABLY_IN_RUSTLINGS_DIR_ERR: &str = + "A directory with the name `exercises` and a file with the name `Cargo.toml` already exist +in the current directory. It looks like Rustlings was already initialized here. +Run `rustlings` for instructions on getting started with the exercises. + +If you didn't already initialize Rustlings, please initialize it in another directory."; + +const RUSTLINGS_DIR_ALREADY_EXISTS_ERR: &str = + "A directory with the name `rustlings` already exists in the current directory. +You probably already initialized Rustlings. +Run `cd rustlings` +Then run `rustlings` again"; diff --git a/src/list.rs b/src/list.rs new file mode 100644 index 0000000..2bb813d --- /dev/null +++ b/src/list.rs @@ -0,0 +1,90 @@ +use anyhow::Result; +use crossterm::{ + event::{self, Event, KeyCode, KeyEventKind}, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, + ExecutableCommand, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; +use std::io; + +mod state; + +use crate::app_state::AppState; + +use self::state::{Filter, UiState}; + +pub fn list(app_state: &mut AppState) -> Result<()> { + let mut stdout = io::stdout().lock(); + stdout.execute(EnterAlternateScreen)?; + enable_raw_mode()?; + + let mut terminal = Terminal::new(CrosstermBackend::new(&mut stdout))?; + terminal.clear()?; + + let mut ui_state = UiState::new(app_state); + + 'outer: loop { + terminal.draw(|frame| ui_state.draw(frame).unwrap())?; + + let key = loop { + match event::read()? { + Event::Key(key) => match key.kind { + KeyEventKind::Press | KeyEventKind::Repeat => break key, + KeyEventKind::Release => (), + }, + // Redraw + Event::Resize(_, _) => continue 'outer, + // Ignore + Event::FocusGained | Event::FocusLost | Event::Mouse(_) | Event::Paste(_) => (), + } + }; + + ui_state.message.clear(); + + match key.code { + KeyCode::Char('q') => break, + KeyCode::Down | KeyCode::Char('j') => ui_state.select_next(), + KeyCode::Up | KeyCode::Char('k') => ui_state.select_previous(), + KeyCode::Home | KeyCode::Char('g') => ui_state.select_first(), + KeyCode::End | KeyCode::Char('G') => ui_state.select_last(), + KeyCode::Char('d') => { + let message = if ui_state.filter == Filter::Done { + ui_state.filter = Filter::None; + "Disabled filter DONE" + } else { + ui_state.filter = Filter::Done; + "Enabled filter DONE ā Press d again to disable the filter" + }; + + ui_state = ui_state.with_updated_rows(); + ui_state.message.push_str(message); + } + KeyCode::Char('p') => { + let message = if ui_state.filter == Filter::Pending { + ui_state.filter = Filter::None; + "Disabled filter PENDING" + } else { + ui_state.filter = Filter::Pending; + "Enabled filter PENDING ā Press p again to disable the filter" + }; + + ui_state = ui_state.with_updated_rows(); + ui_state.message.push_str(message); + } + KeyCode::Char('r') => { + ui_state = ui_state.with_reset_selected()?; + } + KeyCode::Char('c') => { + ui_state.selected_to_current_exercise()?; + ui_state = ui_state.with_updated_rows(); + } + _ => (), + } + } + + drop(terminal); + stdout.execute(LeaveAlternateScreen)?; + disable_raw_mode()?; + + Ok(()) +} diff --git a/src/list/state.rs b/src/list/state.rs new file mode 100644 index 0000000..2a1fef1 --- /dev/null +++ b/src/list/state.rs @@ -0,0 +1,261 @@ +use anyhow::{Context, Result}; +use ratatui::{ + layout::{Constraint, Rect}, + style::{Style, Stylize}, + text::Span, + widgets::{Block, Borders, HighlightSpacing, Paragraph, Row, Table, TableState}, + Frame, +}; +use std::fmt::Write; + +use crate::{app_state::AppState, progress_bar::progress_bar_ratatui}; + +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum Filter { + Done, + Pending, + None, +} + +pub struct UiState<'a> { + pub table: Table<'static>, + pub message: String, + pub filter: Filter, + app_state: &'a mut AppState, + table_state: TableState, + n_rows: usize, +} + +impl<'a> UiState<'a> { + pub fn with_updated_rows(mut self) -> Self { + let current_exercise_ind = self.app_state.current_exercise_ind(); + + self.n_rows = 0; + let rows = self + .app_state + .exercises() + .iter() + .enumerate() + .filter_map(|(ind, exercise)| { + let exercise_state = if exercise.done { + if self.filter == Filter::Pending { + return None; + } + + "DONE".green() + } else { + if self.filter == Filter::Done { + return None; + } + + "PENDING".yellow() + }; + + self.n_rows += 1; + + let next = if ind == current_exercise_ind { + ">>>>".bold().red() + } else { + Span::default() + }; + + Some(Row::new([ + next, + exercise_state, + Span::raw(exercise.name), + Span::raw(exercise.path), + ])) + }); + + self.table = self.table.rows(rows); + + if self.n_rows == 0 { + self.table_state.select(None); + } else { + self.table_state.select(Some( + self.table_state + .selected() + .map_or(0, |selected| selected.min(self.n_rows - 1)), + )); + } + + self + } + + pub fn new(app_state: &'a mut AppState) -> Self { + let header = Row::new(["Next", "State", "Name", "Path"]); + + let max_name_len = app_state + .exercises() + .iter() + .map(|exercise| exercise.name.len()) + .max() + .unwrap_or(4) as u16; + + let widths = [ + Constraint::Length(4), + Constraint::Length(7), + Constraint::Length(max_name_len), + Constraint::Fill(1), + ]; + + let table = Table::default() + .widths(widths) + .header(header) + .column_spacing(2) + .highlight_spacing(HighlightSpacing::Always) + .highlight_style(Style::new().bg(ratatui::style::Color::Rgb(50, 50, 50))) + .highlight_symbol("š¦") + .block(Block::default().borders(Borders::BOTTOM)); + + let selected = app_state.current_exercise_ind(); + let table_state = TableState::default() + .with_offset(selected.saturating_sub(10)) + .with_selected(Some(selected)); + + let filter = Filter::None; + let n_rows = app_state.exercises().len(); + + let slf = Self { + table, + message: String::with_capacity(128), + filter, + app_state, + table_state, + n_rows, + }; + + slf.with_updated_rows() + } + + pub fn select_next(&mut self) { + if self.n_rows > 0 { + let next = self + .table_state + .selected() + .map_or(0, |selected| (selected + 1).min(self.n_rows - 1)); + self.table_state.select(Some(next)); + } + } + + pub fn select_previous(&mut self) { + if self.n_rows > 0 { + let previous = self + .table_state + .selected() + .map_or(0, |selected| selected.saturating_sub(1)); + self.table_state.select(Some(previous)); + } + } + + #[inline] + pub fn select_first(&mut self) { + if self.n_rows > 0 { + self.table_state.select(Some(0)); + } + } + + #[inline] + pub fn select_last(&mut self) { + if self.n_rows > 0 { + self.table_state.select(Some(self.n_rows - 1)); + } + } + + pub fn draw(&mut self, frame: &mut Frame) -> Result<()> { + let area = frame.size(); + + frame.render_stateful_widget( + &self.table, + Rect { + x: 0, + y: 0, + width: area.width, + height: area.height - 3, + }, + &mut self.table_state, + ); + + frame.render_widget( + Paragraph::new(progress_bar_ratatui( + self.app_state.n_done(), + self.app_state.exercises().len() as u16, + area.width, + )?) + .block(Block::default().borders(Borders::BOTTOM)), + Rect { + x: 0, + y: area.height - 3, + width: area.width, + height: 2, + }, + ); + + let message = if self.message.is_empty() { + // Help footer. + Span::raw( + "ā/j ā/k home/g end/G ā filter <d>one/<p>ending ā <r>eset ā <c>ontinue at ā <q>uit", + ) + } else { + self.message.as_str().light_blue() + }; + frame.render_widget( + message, + Rect { + x: 0, + y: area.height - 1, + width: area.width, + height: 1, + }, + ); + + Ok(()) + } + + pub fn with_reset_selected(mut self) -> Result<Self> { + let Some(selected) = self.table_state.selected() else { + return Ok(self); + }; + + let (ind, exercise) = self + .app_state + .exercises() + .iter() + .enumerate() + .filter_map(|(ind, exercise)| match self.filter { + Filter::Done => exercise.done.then_some((ind, exercise)), + Filter::Pending => (!exercise.done).then_some((ind, exercise)), + Filter::None => Some((ind, exercise)), + }) + .nth(selected) + .context("Invalid selection index")?; + + exercise.reset()?; + self.message + .write_fmt(format_args!("The exercise {exercise} has been reset!"))?; + self.app_state.set_pending(ind)?; + + Ok(self.with_updated_rows()) + } + + pub fn selected_to_current_exercise(&mut self) -> Result<()> { + let Some(selected) = self.table_state.selected() else { + return Ok(()); + }; + + let ind = self + .app_state + .exercises() + .iter() + .enumerate() + .filter_map(|(ind, exercise)| match self.filter { + Filter::Done => exercise.done.then_some(ind), + Filter::Pending => (!exercise.done).then_some(ind), + Filter::None => Some(ind), + }) + .nth(selected) + .context("Invalid selection index")?; + + self.app_state.set_current_exercise_ind(ind) + } +} diff --git a/src/main.rs b/src/main.rs index c8c6584..ed5becf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,59 +1,55 @@ -use crate::embedded::{WriteStrategy, EMBEDDED_FILES}; -use crate::exercise::{Exercise, ExerciseList}; -use crate::run::run; -use crate::verify::verify; -use anyhow::{bail, Context, Result}; +use anyhow::{Context, Result}; +use app_state::StateFileStatus; use clap::{Parser, Subcommand}; -use console::Emoji; -use notify_debouncer_mini::notify::RecursiveMode; -use notify_debouncer_mini::{new_debouncer, DebouncedEventKind}; -use shlex::Shlex; -use std::io::{BufRead, Write}; -use std::path::Path; -use std::process::{exit, Command}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::{channel, RecvTimeoutError}; -use std::sync::{Arc, Mutex}; -use std::time::Duration; -use std::{io, thread}; -use verify::VerifyState; - -#[macro_use] -mod ui; - +use crossterm::{ + terminal::{Clear, ClearType}, + ExecutableCommand, +}; +use std::{ + io::{self, BufRead, Write}, + path::Path, + process::exit, +}; + +mod app_state; mod embedded; mod exercise; +mod info_file; mod init; +mod list; +mod progress_bar; mod run; -mod verify; +mod watch; + +use self::{ + app_state::AppState, + info_file::InfoFile, + init::init, + list::list, + run::run, + watch::{watch, WatchExit}, +}; /// Rustlings is a collection of small exercises to get you used to writing and reading Rust code #[derive(Parser)] #[command(version)] struct Args { - /// Show outputs from the test exercises - #[arg(long)] - nocapture: bool, #[command(subcommand)] command: Option<Subcommands>, + /// Manually run the current exercise using `r` or `run` in the watch mode. + /// Only use this if Rustlings fails to detect exercise file changes. + #[arg(long)] + manual_run: bool, } #[derive(Subcommand)] enum Subcommands { /// Initialize Rustlings Init, - /// Verify all exercises according to the recommended order - Verify, - /// Rerun `verify` when files were edited - Watch { - /// Show hints on success - #[arg(long)] - success_hints: bool, - }, - /// Run/Test a single exercise + /// Run a single exercise. Runs the next pending exercise if the exercise name is not specified. Run { /// The name of the exercise - name: String, + name: Option<String>, }, /// Reset a single exercise Reset { @@ -65,375 +61,120 @@ enum Subcommands { /// The name of the exercise name: String, }, - /// List the exercises available in Rustlings - List { - /// Show only the paths of the exercises - #[arg(short, long)] - paths: bool, - /// Show only the names of the exercises - #[arg(short, long)] - names: bool, - /// Provide a string to match exercise names. - /// Comma separated patterns are accepted - #[arg(short, long)] - filter: Option<String>, - /// Display only exercises not yet solved - #[arg(short, long)] - unsolved: bool, - /// Display only exercises that have been solved - #[arg(short, long)] - solved: bool, - }, } fn main() -> Result<()> { let args = Args::parse(); - if args.command.is_none() { - println!("\n{WELCOME}\n"); - } - - which::which("cargo").context( - "Failed to find `cargo`. -Did you already install Rust? -Try running `cargo --version` to diagnose the problem.", - )?; + which::which("cargo").context(CARGO_NOT_FOUND_ERR)?; - let exercises = ExerciseList::parse()?.exercises; + let info_file = InfoFile::parse()?; if matches!(args.command, Some(Subcommands::Init)) { - init::init_rustlings(&exercises).context("Initialization failed")?; - println!( - "\nDone initialization!\n -Run `cd rustlings` to go into the generated directory. -Then run `rustlings` for further instructions on getting started." - ); + init(&info_file.exercises).context("Initialization failed")?; + + println!("{POST_INIT_MSG}"); return Ok(()); } else if !Path::new("exercises").is_dir() { - println!( - "\nThe `exercises` directory wasn't found in the current directory. -If you are just starting with Rustlings, run the command `rustlings init` to initialize it." - ); + println!("{PRE_INIT_MSG}"); exit(1); } - let verbose = args.nocapture; - let command = args.command.unwrap_or_else(|| { - println!("{DEFAULT_OUT}\n"); - exit(0); - }); - - match command { - // `Init` is handled above. - Subcommands::Init => (), - Subcommands::List { - paths, - names, - filter, - unsolved, - solved, - } => { - if !paths && !names { - println!("{:<17}\t{:<46}\t{:<7}", "Name", "Path", "Status"); - } - let mut exercises_done: u16 = 0; - let lowercase_filter = filter - .as_ref() - .map(|s| s.to_lowercase()) - .unwrap_or_default(); - let filters = lowercase_filter - .split(',') - .filter_map(|f| { - let f = f.trim(); - if f.is_empty() { - None - } else { - Some(f) - } - }) - .collect::<Vec<_>>(); - - for exercise in &exercises { - let fname = exercise.path.to_string_lossy(); - let filter_cond = filters - .iter() - .any(|f| exercise.name.contains(f) || fname.contains(f)); - let looks_done = exercise.looks_done()?; - let status = if looks_done { - exercises_done += 1; - "Done" - } else { - "Pending" - }; - let solve_cond = - (looks_done && solved) || (!looks_done && unsolved) || (!solved && !unsolved); - if solve_cond && (filter_cond || filter.is_none()) { - let line = if paths { - format!("{fname}\n") - } else if names { - format!("{}\n", exercise.name) - } else { - format!("{:<17}\t{fname:<46}\t{status:<7}\n", exercise.name) - }; - // Somehow using println! leads to the binary panicking - // when its output is piped. - // So, we're handling a Broken Pipe error and exiting with 0 anyway - let stdout = std::io::stdout(); - { - let mut handle = stdout.lock(); - handle.write_all(line.as_bytes()).unwrap_or_else(|e| { - match e.kind() { - std::io::ErrorKind::BrokenPipe => exit(0), - _ => exit(1), - }; - }); - } - } - } - - let percentage_progress = exercises_done as f32 / exercises.len() as f32 * 100.0; - println!( - "Progress: You completed {} / {} exercises ({:.1} %).", - exercises_done, - exercises.len(), - percentage_progress - ); - exit(0); - } + let (mut app_state, state_file_status) = AppState::new( + info_file.exercises, + info_file.final_message.unwrap_or_default(), + ); - Subcommands::Run { name } => { - let exercise = find_exercise(&name, &exercises)?; - run(exercise, verbose).unwrap_or_else(|_| exit(1)); - } + if let Some(welcome_message) = info_file.welcome_message { + match state_file_status { + StateFileStatus::NotRead => { + let mut stdout = io::stdout().lock(); + stdout.execute(Clear(ClearType::All))?; - Subcommands::Reset { name } => { - let exercise = find_exercise(&name, &exercises)?; - EMBEDDED_FILES - .write_exercise_to_disk(&exercise.path, WriteStrategy::Overwrite) - .with_context(|| format!("Failed to reset the exercise {exercise}"))?; - println!("The file {} has been reset!", exercise.path.display()); - } + let welcome_message = welcome_message.trim(); + write!(stdout, "{welcome_message}\n\nPress ENTER to continue ")?; + stdout.flush()?; - Subcommands::Hint { name } => { - let exercise = find_exercise(&name, &exercises)?; - println!("{}", exercise.hint); - } - - Subcommands::Verify => match verify(&exercises, (0, exercises.len()), verbose, false)? { - VerifyState::AllExercisesDone => println!("All exercises done!"), - VerifyState::Failed(exercise) => bail!("Exercise {exercise} failed"), - }, + io::stdin().lock().read_until(b'\n', &mut Vec::new())?; - Subcommands::Watch { success_hints } => match watch(&exercises, verbose, success_hints) { - Err(e) => { - println!("Error: Could not watch your progress. Error message was {e:?}."); - println!("Most likely you've run out of disk space or your 'inotify limit' has been reached."); - exit(1); - } - Ok(WatchStatus::Finished) => { - println!( - "{emoji} All exercises completed! {emoji}", - emoji = Emoji("š", "ā
") - ); - println!("\n{FENISH_LINE}\n"); - } - Ok(WatchStatus::Unfinished) => { - println!("We hope you're enjoying learning about Rust!"); - println!("If you want to continue working on the exercises at a later point, you can simply run `rustlings watch` again"); + stdout.execute(Clear(ClearType::All))?; } - }, + StateFileStatus::Read => (), + } } - Ok(()) -} - -fn spawn_watch_shell( - failed_exercise_hint: Arc<Mutex<Option<String>>>, - should_quit: Arc<AtomicBool>, -) { - println!("Welcome to watch mode! You can type 'help' to get an overview of the commands you can use here."); - - thread::spawn(move || { - let mut input = String::with_capacity(32); - let mut stdin = io::stdin().lock(); - - loop { - // Recycle input buffer. - input.clear(); - - if let Err(e) = stdin.read_line(&mut input) { - println!("error reading command: {e}"); - } - - let input = input.trim(); - if input == "hint" { - if let Some(hint) = &*failed_exercise_hint.lock().unwrap() { - println!("{hint}"); - } - } else if input == "clear" { - println!("\x1B[2J\x1B[1;1H"); - } else if input == "quit" { - should_quit.store(true, Ordering::SeqCst); - println!("Bye!"); - } else if input == "help" { - println!("{WATCH_MODE_HELP_MESSAGE}"); - } else if let Some(cmd) = input.strip_prefix('!') { - let mut parts = Shlex::new(cmd); - - let Some(program) = parts.next() else { - println!("no command provided"); - continue; - }; - - if let Err(e) = Command::new(program).args(parts).status() { - println!("failed to execute command `{cmd}`: {e}"); - } + match args.command { + None => { + let notify_exercise_paths: Option<&'static [&'static str]> = if args.manual_run { + None } else { - println!("unknown command: {input}\n{WATCH_MODE_HELP_MESSAGE}"); + // For the the notify event handler thread. + // Leaking is not a problem because the slice lives until the end of the program. + Some( + app_state + .exercises() + .iter() + .map(|exercise| exercise.path) + .collect::<Vec<_>>() + .leak(), + ) + }; + + loop { + match watch(&mut app_state, notify_exercise_paths)? { + WatchExit::Shutdown => break, + // It is much easier to exit the watch mode, launch the list mode and then restart + // the watch mode instead of trying to pause the watch threads and correct the + // watch state. + WatchExit::List => list(&mut app_state)?, + } } } - }); -} - -fn find_exercise<'a>(name: &str, exercises: &'a [Exercise]) -> Result<&'a Exercise> { - if name == "next" { - for exercise in exercises { - if !exercise.looks_done()? { - return Ok(exercise); + // `Init` is handled above. + Some(Subcommands::Init) => (), + Some(Subcommands::Run { name }) => { + if let Some(name) = name { + app_state.set_current_exercise_by_name(&name)?; } + run(&mut app_state)?; } - - println!("š Congratulations! You have done all the exercises!"); - println!("š There are no more exercises to do next!"); - exit(0); - } - - exercises - .iter() - .find(|e| e.name == name) - .with_context(|| format!("No exercise found for '{name}'!")) -} - -enum WatchStatus { - Finished, - Unfinished, -} - -fn watch(exercises: &[Exercise], verbose: bool, success_hints: bool) -> Result<WatchStatus> { - /* Clears the terminal with an ANSI escape code. - Works in UNIX and newer Windows terminals. */ - fn clear_screen() { - println!("\x1Bc"); - } - - let (tx, rx) = channel(); - let should_quit = Arc::new(AtomicBool::new(false)); - - let mut debouncer = new_debouncer(Duration::from_secs(1), tx)?; - debouncer - .watcher() - .watch(Path::new("exercises"), RecursiveMode::Recursive)?; - - clear_screen(); - - let failed_exercise_hint = - match verify(exercises, (0, exercises.len()), verbose, success_hints)? { - VerifyState::AllExercisesDone => return Ok(WatchStatus::Finished), - VerifyState::Failed(exercise) => Arc::new(Mutex::new(Some(exercise.hint.clone()))), - }; - - spawn_watch_shell(Arc::clone(&failed_exercise_hint), Arc::clone(&should_quit)); - - let mut pending_exercises = Vec::with_capacity(exercises.len()); - loop { - match rx.recv_timeout(Duration::from_secs(1)) { - Ok(event) => match event { - Ok(events) => { - for event in events { - if event.kind == DebouncedEventKind::Any - && event.path.extension().is_some_and(|ext| ext == "rs") - { - pending_exercises.extend(exercises.iter().filter(|exercise| { - !exercise.looks_done().unwrap_or(false) - || event.path.ends_with(&exercise.path) - })); - let num_done = exercises.len() - pending_exercises.len(); - - clear_screen(); - - match verify( - pending_exercises.iter().copied(), - (num_done, exercises.len()), - verbose, - success_hints, - )? { - VerifyState::AllExercisesDone => return Ok(WatchStatus::Finished), - VerifyState::Failed(exercise) => { - let hint = exercise.hint.clone(); - *failed_exercise_hint.lock().unwrap() = Some(hint); - } - } - - pending_exercises.clear(); - } - } - } - Err(e) => println!("watch error: {e:?}"), - }, - Err(RecvTimeoutError::Timeout) => { - // the timeout expired, just check the `should_quit` variable below then loop again - } - Err(e) => println!("watch error: {e:?}"), + Some(Subcommands::Reset { name }) => { + app_state.set_current_exercise_by_name(&name)?; + let exercise = app_state.current_exercise(); + exercise.reset()?; + println!("The exercise {exercise} has been reset!"); + app_state.set_pending(app_state.current_exercise_ind())?; } - // Check if we need to exit - if should_quit.load(Ordering::SeqCst) { - return Ok(WatchStatus::Unfinished); + Some(Subcommands::Hint { name }) => { + app_state.set_current_exercise_by_name(&name)?; + println!("{}", app_state.current_exercise().hint); } } + + Ok(()) } -const WELCOME: &str = r" welcome to... +const CARGO_NOT_FOUND_ERR: &str = "Failed to find `cargo`. +Did you already install Rust? +Try running `cargo --version` to diagnose the problem."; + +const PRE_INIT_MSG: &str = r" + welcome to... _ _ _ _ __ _ _ ___| |_| (_)_ __ __ _ ___ | '__| | | / __| __| | | '_ \ / _` / __| | | | |_| \__ \ |_| | | | | | (_| \__ \ |_| \__,_|___/\__|_|_|_| |_|\__, |___/ - |___/"; + |___/ -const DEFAULT_OUT: &str = - "Is this your first time? Don't worry, Rustlings was made for beginners! We are -going to teach you a lot of things about Rust, but before we can get -started, here's a couple of notes about how Rustlings operates: +The `exercises` directory wasn't found in the current directory. +If you are just starting with Rustlings, run the command `rustlings init` to initialize it."; -1. The central concept behind Rustlings is that you solve exercises. These - exercises usually have some sort of syntax error in them, which will cause - them to fail compilation or testing. Sometimes there's a logic error instead - of a syntax error. No matter what error, it's your job to find it and fix it! - You'll know when you fixed it because then, the exercise will compile and - Rustlings will be able to move on to the next exercise. -2. If you run Rustlings in watch mode (which we recommend), it'll automatically - start with the first exercise. Don't get confused by an error message popping - up as soon as you run Rustlings! This is part of the exercise that you're - supposed to solve, so open the exercise file in an editor and start your - detective work! -3. If you're stuck on an exercise, there is a helpful hint you can view by typing - 'hint' (in watch mode), or running `rustlings hint exercise_name`. -4. If an exercise doesn't make sense to you, feel free to open an issue on GitHub! - (https://github.com/rust-lang/rustlings/issues/new). We look at every issue, - and sometimes, other learners do too so you can help each other out! +const POST_INIT_MSG: &str = " +Done initialization! -Got all that? Great! To get started, run `rustlings watch` in order to get the first exercise. -Make sure to have your editor open in the `rustlings` directory!"; - -const WATCH_MODE_HELP_MESSAGE: &str = "Commands available to you in watch mode: - hint - prints the current exercise's hint - clear - clears the screen - quit - quits watch mode - !<cmd> - executes a command, like `!rustc --explain E0381` - help - displays this help message - -Watch mode automatically re-evaluates the current exercise -when you edit a file's contents."; +Run `cd rustlings` to go into the generated directory. +Then run `rustlings` to get started."; const FENISH_LINE: &str = "+----------------------------------------------------+ | You made it to the Fe-nish line! | @@ -446,7 +187,7 @@ const FENISH_LINE: &str = "+---------------------------------------------------- āāāāāāāā āā āāāā āā āāāā āā āāāāāāāā āāāā āā āāāā āā āāāā āāāā āāāā āā āāāāāā āāāāāā āāāāāā āā - āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā + āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā āāāāāāāāāāāāāāāāāāāāāāāāāāāāāā āā āāāāāāāāāāāāāāāāāāāāāāāāāā āā @@ -455,9 +196,4 @@ const FENISH_LINE: &str = "+---------------------------------------------------- āā āā āā āā āā āā āā āā āā āā\x1b[0m -We hope you enjoyed learning about the various aspects of Rust! -If you noticed any issues, please don't hesitate to report them to our repo. -You can also contribute your own exercises to help the greater community! - -Before reporting an issue or contributing, please read our guidelines: -https://github.com/rust-lang/rustlings/blob/main/CONTRIBUTING.md"; +"; diff --git a/src/progress_bar.rs b/src/progress_bar.rs new file mode 100644 index 0000000..d6962b8 --- /dev/null +++ b/src/progress_bar.rs @@ -0,0 +1,97 @@ +use anyhow::{bail, Result}; +use ratatui::text::{Line, Span}; +use std::fmt::Write; + +const PREFIX: &str = "Progress: ["; +const PREFIX_WIDTH: u16 = PREFIX.len() as u16; +// Leaving the last char empty (_) for `total` > 99. +const POSTFIX_WIDTH: u16 = "] xxx/xx exercises_".len() as u16; +const WRAPPER_WIDTH: u16 = PREFIX_WIDTH + POSTFIX_WIDTH; +const MIN_LINE_WIDTH: u16 = WRAPPER_WIDTH + 4; + +const PROGRESS_EXCEEDS_MAX_ERR: &str = + "The progress of the progress bar is higher than the maximum"; + +pub fn progress_bar(progress: u16, total: u16, line_width: u16) -> Result<String> { + use crossterm::style::Stylize; + + if progress > total { + bail!(PROGRESS_EXCEEDS_MAX_ERR); + } + + if line_width < MIN_LINE_WIDTH { + return Ok(format!("Progress: {progress}/{total} exercises")); + } + + let mut line = String::with_capacity(usize::from(line_width)); + line.push_str(PREFIX); + + let width = line_width - WRAPPER_WIDTH; + let filled = (width * progress) / total; + + let mut green_part = String::with_capacity(usize::from(filled + 1)); + for _ in 0..filled { + green_part.push('#'); + } + + if filled < width { + green_part.push('>'); + } + write!(line, "{}", green_part.green()).unwrap(); + + let width_minus_filled = width - filled; + if width_minus_filled > 1 { + let red_part_width = width_minus_filled - 1; + let mut red_part = String::with_capacity(usize::from(red_part_width)); + for _ in 0..red_part_width { + red_part.push('-'); + } + write!(line, "{}", red_part.red()).unwrap(); + } + + writeln!(line, "] {progress:>3}/{total} exercises").unwrap(); + + Ok(line) +} + +pub fn progress_bar_ratatui(progress: u16, total: u16, line_width: u16) -> Result<Line<'static>> { + use ratatui::style::Stylize; + + if progress > total { + bail!(PROGRESS_EXCEEDS_MAX_ERR); + } + + if line_width < MIN_LINE_WIDTH { + return Ok(Line::raw(format!("Progress: {progress}/{total} exercises"))); + } + + let mut spans = Vec::with_capacity(4); + spans.push(Span::raw(PREFIX)); + + let width = line_width - WRAPPER_WIDTH; + let filled = (width * progress) / total; + + let mut green_part = String::with_capacity(usize::from(filled + 1)); + for _ in 0..filled { + green_part.push('#'); + } + + if filled < width { + green_part.push('>'); + } + spans.push(green_part.green()); + + let width_minus_filled = width - filled; + if width_minus_filled > 1 { + let red_part_width = width_minus_filled - 1; + let mut red_part = String::with_capacity(usize::from(red_part_width)); + for _ in 0..red_part_width { + red_part.push('-'); + } + spans.push(red_part.red()); + } + + spans.push(Span::raw(format!("] {progress:>3}/{total} exercises"))); + + Ok(Line::from(spans)) +} @@ -1,39 +1,41 @@ use anyhow::{bail, Result}; -use std::io::{stdout, Write}; -use std::time::Duration; - -use crate::exercise::{Exercise, Mode}; -use crate::verify::test; -use indicatif::ProgressBar; - -// Invoke the rust compiler on the path of the given exercise, -// and run the ensuing binary. -// The verbose argument helps determine whether or not to show -// the output from the test harnesses (if the mode of the exercise is test) -pub fn run(exercise: &Exercise, verbose: bool) -> Result<()> { - match exercise.mode { - Mode::Test => test(exercise, verbose), - Mode::Compile | Mode::Clippy => compile_and_run(exercise), - } -} +use crossterm::style::Stylize; +use std::io::{self, Write}; -// Compile and run an exercise. -// This is strictly for non-test binaries, so output is displayed -fn compile_and_run(exercise: &Exercise) -> Result<()> { - let progress_bar = ProgressBar::new_spinner(); - progress_bar.set_message(format!("Running {exercise}...")); - progress_bar.enable_steady_tick(Duration::from_millis(100)); +use crate::app_state::{AppState, ExercisesProgress}; +pub fn run(app_state: &mut AppState) -> Result<()> { + let exercise = app_state.current_exercise(); let output = exercise.run()?; - progress_bar.finish_and_clear(); - stdout().write_all(&output.stdout)?; + let mut stdout = io::stdout().lock(); + stdout.write_all(&output.stdout)?; + stdout.write_all(b"\n")?; + stdout.write_all(&output.stderr)?; + stdout.flush()?; + if !output.status.success() { - stdout().write_all(&output.stderr)?; - warn!("Ran {} with errors", exercise); - bail!("TODO"); + app_state.set_pending(app_state.current_exercise_ind())?; + + bail!( + "Ran {} with errors", + app_state.current_exercise().terminal_link(), + ); + } + + stdout.write_fmt(format_args!( + "{}{}\n", + "ā Successfully ran ".green(), + exercise.path.green(), + ))?; + + match app_state.done_current_exercise(&mut stdout)? { + ExercisesProgress::AllDone => (), + ExercisesProgress::Pending => println!( + "Next exercise: {}", + app_state.current_exercise().terminal_link(), + ), } - success!("Successfully ran {}", exercise); Ok(()) } diff --git a/src/ui.rs b/src/ui.rs deleted file mode 100644 index 22d60d9..0000000 --- a/src/ui.rs +++ /dev/null @@ -1,28 +0,0 @@ -macro_rules! print_emoji { - ($emoji:expr, $sign:expr, $color: ident, $fmt:literal, $ex:expr) => {{ - use console::{style, Emoji}; - use std::env; - let formatstr = format!($fmt, $ex); - if env::var_os("NO_EMOJI").is_some() { - println!("{} {}", style($sign).$color(), style(formatstr).$color()); - } else { - println!( - "{} {}", - style(Emoji($emoji, $sign)).$color(), - style(formatstr).$color() - ); - } - }}; -} - -macro_rules! warn { - ($fmt:literal, $ex:expr) => {{ - print_emoji!("ā ļø ", "!", red, $fmt, $ex); - }}; -} - -macro_rules! success { - ($fmt:literal, $ex:expr) => {{ - print_emoji!("ā
", "ā", green, $fmt, $ex); - }}; -} diff --git a/src/verify.rs b/src/verify.rs deleted file mode 100644 index ef966f6..0000000 --- a/src/verify.rs +++ /dev/null @@ -1,223 +0,0 @@ -use anyhow::{bail, Result}; -use console::style; -use indicatif::{ProgressBar, ProgressStyle}; -use std::{ - env, - io::{stdout, Write}, - process::Output, - time::Duration, -}; - -use crate::exercise::{Exercise, Mode, State}; - -pub enum VerifyState<'a> { - AllExercisesDone, - Failed(&'a Exercise), -} - -// Verify that the provided container of Exercise objects -// can be compiled and run without any failures. -// Any such failures will be reported to the end user. -// If the Exercise being verified is a test, the verbose boolean -// determines whether or not the test harness outputs are displayed. -pub fn verify<'a>( - pending_exercises: impl IntoIterator<Item = &'a Exercise>, - progress: (usize, usize), - verbose: bool, - success_hints: bool, -) -> Result<VerifyState<'a>> { - let (num_done, total) = progress; - let bar = ProgressBar::new(total as u64); - let mut percentage = num_done as f32 / total as f32 * 100.0; - bar.set_style( - ProgressStyle::default_bar() - .template("Progress: [{bar:60.green/red}] {pos}/{len} {msg}") - .expect("Progressbar template should be valid!") - .progress_chars("#>-"), - ); - bar.set_position(num_done as u64); - bar.set_message(format!("({percentage:.1} %)")); - - for exercise in pending_exercises { - let compile_result = match exercise.mode { - Mode::Test => compile_and_test(exercise, RunMode::Interactive, verbose, success_hints)?, - Mode::Compile => compile_and_run_interactively(exercise, success_hints)?, - Mode::Clippy => compile_only(exercise, success_hints)?, - }; - if !compile_result { - return Ok(VerifyState::Failed(exercise)); - } - percentage += 100.0 / total as f32; - bar.inc(1); - bar.set_message(format!("({percentage:.1} %)")); - } - - bar.finish(); - println!("You completed all exercises!"); - - Ok(VerifyState::AllExercisesDone) -} - -#[derive(PartialEq, Eq)] -enum RunMode { - Interactive, - NonInteractive, -} - -// Compile and run the resulting test harness of the given Exercise -pub fn test(exercise: &Exercise, verbose: bool) -> Result<()> { - compile_and_test(exercise, RunMode::NonInteractive, verbose, false)?; - Ok(()) -} - -// Invoke the rust compiler without running the resulting binary -fn compile_only(exercise: &Exercise, success_hints: bool) -> Result<bool> { - let progress_bar = ProgressBar::new_spinner(); - progress_bar.set_message(format!("Compiling {exercise}...")); - progress_bar.enable_steady_tick(Duration::from_millis(100)); - - let _ = exercise.run()?; - progress_bar.finish_and_clear(); - - prompt_for_completion(exercise, None, success_hints) -} - -// Compile the given Exercise and run the resulting binary in an interactive mode -fn compile_and_run_interactively(exercise: &Exercise, success_hints: bool) -> Result<bool> { - let progress_bar = ProgressBar::new_spinner(); - progress_bar.set_message(format!("Running {exercise}...")); - progress_bar.enable_steady_tick(Duration::from_millis(100)); - - let output = exercise.run()?; - progress_bar.finish_and_clear(); - - if !output.status.success() { - warn!("Ran {} with errors", exercise); - { - let mut stdout = stdout().lock(); - stdout.write_all(&output.stdout)?; - stdout.write_all(&output.stderr)?; - stdout.flush()?; - } - bail!("TODO"); - } - - prompt_for_completion(exercise, Some(output), success_hints) -} - -// Compile the given Exercise as a test harness and display -// the output if verbose is set to true -fn compile_and_test( - exercise: &Exercise, - run_mode: RunMode, - verbose: bool, - success_hints: bool, -) -> Result<bool> { - let progress_bar = ProgressBar::new_spinner(); - progress_bar.set_message(format!("Testing {exercise}...")); - progress_bar.enable_steady_tick(Duration::from_millis(100)); - - let output = exercise.run()?; - progress_bar.finish_and_clear(); - - if !output.status.success() { - warn!( - "Testing of {} failed! Please try again. Here's the output:", - exercise - ); - { - let mut stdout = stdout().lock(); - stdout.write_all(&output.stdout)?; - stdout.write_all(&output.stderr)?; - stdout.flush()?; - } - bail!("TODO"); - } - - if verbose { - stdout().write_all(&output.stdout)?; - } - - if run_mode == RunMode::Interactive { - prompt_for_completion(exercise, None, success_hints) - } else { - Ok(true) - } -} - -fn prompt_for_completion( - exercise: &Exercise, - prompt_output: Option<Output>, - success_hints: bool, -) -> Result<bool> { - let context = match exercise.state()? { - State::Done => return Ok(true), - State::Pending(context) => context, - }; - match exercise.mode { - Mode::Compile => success!("Successfully ran {}!", exercise), - Mode::Test => success!("Successfully tested {}!", exercise), - Mode::Clippy => success!("Successfully compiled {}!", exercise), - } - - let no_emoji = env::var("NO_EMOJI").is_ok(); - - let clippy_success_msg = if no_emoji { - "The code is compiling, and Clippy is happy!" - } else { - "The code is compiling, and š Clippy š is happy!" - }; - - let success_msg = match exercise.mode { - Mode::Compile => "The code is compiling!", - Mode::Test => "The code is compiling, and the tests pass!", - Mode::Clippy => clippy_success_msg, - }; - - if no_emoji { - println!("\n~*~ {success_msg} ~*~\n"); - } else { - println!("\nš š {success_msg} š š\n"); - } - - if let Some(output) = prompt_output { - let separator = separator(); - println!("Output:\n{separator}"); - stdout().write_all(&output.stdout).unwrap(); - println!("\n{separator}\n"); - } - if success_hints { - println!( - "Hints:\n{separator}\n{}\n{separator}\n", - exercise.hint, - separator = separator(), - ); - } - - println!("You can keep working on this exercise,"); - println!( - "or jump into the next one by removing the {} comment:", - style("`I AM NOT DONE`").bold() - ); - println!(); - for context_line in context { - let formatted_line = if context_line.important { - format!("{}", style(context_line.line).bold()) - } else { - context_line.line - }; - - println!( - "{:>2} {} {}", - style(context_line.number).blue().bold(), - style("|").blue(), - formatted_line, - ); - } - - Ok(false) -} - -fn separator() -> console::StyledObject<&'static str> { - style("====================").bold() -} diff --git a/src/watch.rs b/src/watch.rs new file mode 100644 index 0000000..d20e552 --- /dev/null +++ b/src/watch.rs @@ -0,0 +1,128 @@ +use anyhow::{Error, Result}; +use notify_debouncer_mini::{ + new_debouncer, + notify::{self, RecursiveMode}, +}; +use std::{ + io::{self, Write}, + path::Path, + sync::mpsc::channel, + thread, + time::Duration, +}; + +mod notify_event; +mod state; +mod terminal_event; + +use crate::app_state::{AppState, ExercisesProgress}; + +use self::{ + notify_event::DebounceEventHandler, + state::WatchState, + terminal_event::{terminal_event_handler, InputEvent}, +}; + +enum WatchEvent { + Input(InputEvent), + FileChange { exercise_ind: usize }, + TerminalResize, + NotifyErr(notify::Error), + TerminalEventErr(io::Error), +} + +/// Returned by the watch mode to indicate what to do afterwards. +#[must_use] +pub enum WatchExit { + /// Exit the program. + Shutdown, + /// Enter the list mode and restart the watch mode afterwards. + List, +} + +pub fn watch( + app_state: &mut AppState, + notify_exercise_paths: Option<&'static [&'static str]>, +) -> Result<WatchExit> { + let (tx, rx) = channel(); + + let mut manual_run = false; + // Prevent dropping the guard until the end of the function. + // Otherwise, the file watcher exits. + let _debouncer_guard = if let Some(exercise_paths) = notify_exercise_paths { + let mut debouncer = new_debouncer( + Duration::from_secs(1), + DebounceEventHandler { + tx: tx.clone(), + exercise_paths, + }, + ) + .inspect_err(|_| eprintln!("{NOTIFY_ERR}"))?; + debouncer + .watcher() + .watch(Path::new("exercises"), RecursiveMode::Recursive) + .inspect_err(|_| eprintln!("{NOTIFY_ERR}"))?; + + Some(debouncer) + } else { + manual_run = true; + None + }; + + let mut watch_state = WatchState::new(app_state, manual_run); + + watch_state.run_current_exercise()?; + + thread::spawn(move || terminal_event_handler(tx, manual_run)); + + while let Ok(event) = rx.recv() { + match event { + WatchEvent::Input(InputEvent::Next) => match watch_state.next_exercise()? { + ExercisesProgress::AllDone => break, + ExercisesProgress::Pending => watch_state.run_current_exercise()?, + }, + WatchEvent::Input(InputEvent::Hint) => { + watch_state.show_hint()?; + } + WatchEvent::Input(InputEvent::List) => { + return Ok(WatchExit::List); + } + WatchEvent::Input(InputEvent::Quit) => { + watch_state.into_writer().write_all(QUIT_MSG)?; + break; + } + WatchEvent::Input(InputEvent::Run) => watch_state.run_current_exercise()?, + WatchEvent::Input(InputEvent::Unrecognized(cmd)) => { + watch_state.handle_invalid_cmd(&cmd)?; + } + WatchEvent::FileChange { exercise_ind } => { + watch_state.run_exercise_with_ind(exercise_ind)?; + } + WatchEvent::TerminalResize => { + watch_state.render()?; + } + WatchEvent::NotifyErr(e) => { + watch_state.into_writer().write_all(NOTIFY_ERR.as_bytes())?; + return Err(Error::from(e)); + } + WatchEvent::TerminalEventErr(e) => { + return Err(Error::from(e).context("Terminal event listener failed")); + } + } + } + + Ok(WatchExit::Shutdown) +} + +const QUIT_MSG: &[u8] = b" +We hope you're enjoying learning Rust! +If you want to continue working on the exercises at a later point, you can simply run `rustlings` again. +"; + +const NOTIFY_ERR: &str = " +The automatic detection of exercise file changes failed :( +Please try running `rustlings` again. + +If you keep getting this error, run `rustlings --manual-run` to deactivate the file watcher. +You need to manually trigger running the current exercise using `r` or `run` then. +"; diff --git a/src/watch/notify_event.rs b/src/watch/notify_event.rs new file mode 100644 index 0000000..fb9a8c0 --- /dev/null +++ b/src/watch/notify_event.rs @@ -0,0 +1,42 @@ +use notify_debouncer_mini::{DebounceEventResult, DebouncedEventKind}; +use std::sync::mpsc::Sender; + +use super::WatchEvent; + +pub struct DebounceEventHandler { + pub tx: Sender<WatchEvent>, + pub exercise_paths: &'static [&'static str], +} + +impl notify_debouncer_mini::DebounceEventHandler for DebounceEventHandler { + fn handle_event(&mut self, event: DebounceEventResult) { + let event = match event { + Ok(event) => { + let Some(exercise_ind) = event + .iter() + .filter_map(|event| { + if event.kind != DebouncedEventKind::Any + || !event.path.extension().is_some_and(|ext| ext == "rs") + { + return None; + } + + self.exercise_paths + .iter() + .position(|path| event.path.ends_with(path)) + }) + .min() + else { + return; + }; + + WatchEvent::FileChange { exercise_ind } + } + Err(e) => WatchEvent::NotifyErr(e), + }; + + // An error occurs when the receiver is dropped. + // After dropping the receiver, the debouncer guard should also be dropped. + let _ = self.tx.send(event); + } +} diff --git a/src/watch/state.rs b/src/watch/state.rs new file mode 100644 index 0000000..c0f6c53 --- /dev/null +++ b/src/watch/state.rs @@ -0,0 +1,168 @@ +use anyhow::Result; +use crossterm::{ + style::Stylize, + terminal::{size, Clear, ClearType}, + ExecutableCommand, +}; +use std::io::{self, StdoutLock, Write}; + +use crate::{ + app_state::{AppState, ExercisesProgress}, + progress_bar::progress_bar, +}; + +pub struct WatchState<'a> { + writer: StdoutLock<'a>, + app_state: &'a mut AppState, + stdout: Option<Vec<u8>>, + stderr: Option<Vec<u8>>, + show_hint: bool, + show_done: bool, + manual_run: bool, +} + +impl<'a> WatchState<'a> { + pub fn new(app_state: &'a mut AppState, manual_run: bool) -> Self { + let writer = io::stdout().lock(); + + Self { + writer, + app_state, + stdout: None, + stderr: None, + show_hint: false, + show_done: false, + manual_run, + } + } + + #[inline] + pub fn into_writer(self) -> StdoutLock<'a> { + self.writer + } + + pub fn run_current_exercise(&mut self) -> Result<()> { + self.show_hint = false; + + let output = self.app_state.current_exercise().run()?; + self.stdout = Some(output.stdout); + + if output.status.success() { + self.stderr = None; + self.show_done = true; + } else { + self.app_state + .set_pending(self.app_state.current_exercise_ind())?; + + self.stderr = Some(output.stderr); + self.show_done = false; + } + + self.render() + } + + pub fn run_exercise_with_ind(&mut self, exercise_ind: usize) -> Result<()> { + self.app_state.set_current_exercise_ind(exercise_ind)?; + self.run_current_exercise() + } + + pub fn next_exercise(&mut self) -> Result<ExercisesProgress> { + if !self.show_done { + self.writer + .write_all(b"The current exercise isn't done yet\n")?; + self.show_prompt()?; + return Ok(ExercisesProgress::Pending); + } + + self.app_state.done_current_exercise(&mut self.writer) + } + + fn show_prompt(&mut self) -> io::Result<()> { + self.writer.write_all(b"\n")?; + + if self.manual_run { + self.writer.write_fmt(format_args!("{}un/", 'r'.bold()))?; + } + + if self.show_done { + self.writer.write_fmt(format_args!("{}ext/", 'n'.bold()))?; + } + + if !self.show_hint { + self.writer.write_fmt(format_args!("{}int/", 'h'.bold()))?; + } + + self.writer + .write_fmt(format_args!("{}ist/{}uit? ", 'l'.bold(), 'q'.bold()))?; + + self.writer.flush() + } + + pub fn render(&mut self) -> Result<()> { + // Prevent having the first line shifted. + self.writer.write_all(b"\n")?; + + self.writer.execute(Clear(ClearType::All))?; + + if let Some(stdout) = &self.stdout { + self.writer.write_all(stdout)?; + self.writer.write_all(b"\n")?; + } + + if let Some(stderr) = &self.stderr { + self.writer.write_all(stderr)?; + self.writer.write_all(b"\n")?; + } + + self.writer.write_all(b"\n")?; + + if self.show_hint { + self.writer.write_fmt(format_args!( + "{}\n{}\n\n", + "Hint".bold().cyan().underlined(), + self.app_state.current_exercise().hint, + ))?; + } + + if self.show_done { + self.writer.write_fmt(format_args!( + "{}\n\n", + "Exercise done ā +When you are done experimenting, enter `n` or `next` to go to the next exercise š¦" + .bold() + .green(), + ))?; + } + + let line_width = size()?.0; + let progress_bar = progress_bar( + self.app_state.n_done(), + self.app_state.exercises().len() as u16, + line_width, + )?; + self.writer.write_fmt(format_args!( + "{progress_bar}Current exercise: {}\n", + self.app_state.current_exercise().terminal_link(), + ))?; + + self.show_prompt()?; + + Ok(()) + } + + pub fn show_hint(&mut self) -> Result<()> { + self.show_hint = true; + self.render() + } + + pub fn handle_invalid_cmd(&mut self, cmd: &str) -> io::Result<()> { + self.writer.write_all(b"Invalid command: ")?; + self.writer.write_all(cmd.as_bytes())?; + if cmd.len() > 1 { + self.writer + .write_all(b" (confusing input can occur after resizing the terminal)")?; + } + self.writer.write_all(b"\n")?; + self.show_prompt() + } +} diff --git a/src/watch/terminal_event.rs b/src/watch/terminal_event.rs new file mode 100644 index 0000000..6d790b7 --- /dev/null +++ b/src/watch/terminal_event.rs @@ -0,0 +1,73 @@ +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use std::sync::mpsc::Sender; + +use super::WatchEvent; + +pub enum InputEvent { + Run, + Next, + Hint, + List, + Quit, + Unrecognized(String), +} + +pub fn terminal_event_handler(tx: Sender<WatchEvent>, manual_run: bool) { + let mut input = String::with_capacity(8); + + let last_input_event = loop { + let terminal_event = match event::read() { + Ok(v) => v, + Err(e) => { + // If `send` returns an error, then the receiver is dropped and + // a shutdown has been already initialized. + let _ = tx.send(WatchEvent::TerminalEventErr(e)); + return; + } + }; + + match terminal_event { + Event::Key(key) => { + if key.modifiers != KeyModifiers::NONE { + continue; + } + + match key.kind { + KeyEventKind::Release => continue, + KeyEventKind::Press | KeyEventKind::Repeat => (), + } + + match key.code { + KeyCode::Enter => { + let input_event = match input.trim() { + "n" | "next" => InputEvent::Next, + "h" | "hint" => InputEvent::Hint, + "l" | "list" => break InputEvent::List, + "q" | "quit" => break InputEvent::Quit, + "r" | "run" if manual_run => InputEvent::Run, + _ => InputEvent::Unrecognized(input.clone()), + }; + + if tx.send(WatchEvent::Input(input_event)).is_err() { + return; + } + + input.clear(); + } + KeyCode::Char(c) => { + input.push(c); + } + _ => (), + } + } + Event::Resize(_, _) => { + if tx.send(WatchEvent::TerminalResize).is_err() { + return; + } + } + Event::FocusGained | Event::FocusLost | Event::Mouse(_) | Event::Paste(_) => continue, + } + }; + + let _ = tx.send(WatchEvent::Input(last_input_event)); +} |
