diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/app_state.rs | 502 | ||||
| -rw-r--r-- | src/cargo_toml.rs | 146 | ||||
| -rw-r--r-- | src/cmd.rs | 93 | ||||
| -rw-r--r-- | src/dev.rs | 48 | ||||
| -rw-r--r-- | src/dev/check.rs | 296 | ||||
| -rw-r--r-- | src/dev/new.rs | 145 | ||||
| -rw-r--r-- | src/dev/update.rs | 50 | ||||
| -rw-r--r-- | src/embedded.rs | 168 | ||||
| -rw-r--r-- | src/exercise.rs | 485 | ||||
| -rw-r--r-- | src/info_file.rs | 138 | ||||
| -rw-r--r-- | src/init.rs | 116 | ||||
| -rw-r--r-- | src/list.rs | 90 | ||||
| -rw-r--r-- | src/list/state.rs | 271 | ||||
| -rw-r--r-- | src/main.rs | 573 | ||||
| -rw-r--r-- | src/progress_bar.rs | 100 | ||||
| -rw-r--r-- | src/project.rs | 99 | ||||
| -rw-r--r-- | src/run.rs | 113 | ||||
| -rw-r--r-- | src/terminal_link.rs | 26 | ||||
| -rw-r--r-- | src/ui.rs | 33 | ||||
| -rw-r--r-- | src/verify.rs | 234 | ||||
| -rw-r--r-- | src/watch.rs | 128 | ||||
| -rw-r--r-- | src/watch/notify_event.rs | 52 | ||||
| -rw-r--r-- | src/watch/state.rs | 174 | ||||
| -rw-r--r-- | src/watch/terminal_event.rs | 86 |
24 files changed, 2979 insertions, 1187 deletions
diff --git a/src/app_state.rs b/src/app_state.rs new file mode 100644 index 0000000..e9a5b10 --- /dev/null +++ b/src/app_state.rs @@ -0,0 +1,502 @@ +use anyhow::{bail, Context, Result}; +use crossterm::style::Stylize; +use serde::Deserialize; +use std::{ + fs::{self, File}, + io::{Read, StdoutLock, Write}, + path::{Path, PathBuf}, + process::{Command, Stdio}, +}; + +use crate::{ + clear_terminal, + embedded::EMBEDDED_FILES, + exercise::{Exercise, RunnableExercise, OUTPUT_CAPACITY}, + info_file::ExerciseInfo, + DEBUG_PROFILE, +}; + +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 { + // All exercises are done. + AllDone, + // The current exercise failed and is still pending. + CurrentPending, + // A new exercise is now pending. + NewPending, +} + +pub enum StateFileStatus { + Read, + NotRead, +} + +// Parses parts of the output of `cargo metadata`. +#[derive(Deserialize)] +struct CargoMetadata { + target_directory: PathBuf, +} + +pub fn parse_target_dir() -> Result<PathBuf> { + // Get the target directory from Cargo. + let metadata_output = Command::new("cargo") + .arg("metadata") + .arg("-q") + .arg("--format-version") + .arg("1") + .arg("--no-deps") + .stdin(Stdio::null()) + .stderr(Stdio::inherit()) + .output() + .context(CARGO_METADATA_ERR)? + .stdout; + + serde_json::de::from_slice::<CargoMetadata>(&metadata_output) + .context("Failed to read the field `target_directory` from the `cargo metadata` output") + .map(|metadata| metadata.target_directory) +} + +pub struct AppState { + current_exercise_ind: usize, + exercises: Vec<Exercise>, + // Caches the number of done exercises to avoid iterating over all exercises every time. + n_done: u16, + final_message: String, + // Preallocated buffer for reading and writing the state file. + file_buf: Vec<u8>, + official_exercises: bool, + // Cargo's target directory. + target_dir: PathBuf, +} + +impl AppState { + // Update the app state from the state file. + 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').skip(2); + + 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, + ) -> Result<(Self, StateFileStatus)> { + let target_dir = parse_target_dir()?; + + let exercises = exercise_infos + .into_iter() + .map(|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(); + let name = exercise_info.name.leak(); + let dir = exercise_info.dir.map(|dir| &*dir.leak()); + + let hint = exercise_info.hint.trim().to_owned(); + + Exercise { + dir, + name, + path, + test: exercise_info.test, + strict_clippy: exercise_info.strict_clippy, + hint, + // Updated in `Self::update_from_file`. + done: false, + } + }) + .collect::<Vec<_>>(); + + let mut slf = Self { + current_exercise_ind: 0, + exercises, + n_done: 0, + final_message, + file_buf: Vec::with_capacity(2048), + official_exercises: !Path::new("info.toml").exists(), + target_dir, + }; + + let state_file_status = slf.update_from_file(); + + Ok((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] + } + + #[inline] + pub fn target_dir(&self) -> &Path { + &self.target_dir + } + + // Write the state file. + // The file's format is very simple: + // - The first line is a comment. + // - The second line is an empty line. + // - The third line is the name of the current exercise. It must end with `\n` even if there + // are no done exercises. + // - The fourth 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(b"DON'T EDIT THIS FILE!\n\n"); + 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(()) + } + + pub fn set_current_exercise_ind(&mut self, exercise_ind: usize) -> Result<()> { + if exercise_ind == self.current_exercise_ind { + return Ok(()); + } + + if exercise_ind >= self.exercises.len() { + bail!(BAD_INDEX_ERR); + } + + self.current_exercise_ind = exercise_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, exercise_ind: usize) -> Result<()> { + let exercise = self + .exercises + .get_mut(exercise_ind) + .context(BAD_INDEX_ERR)?; + + if exercise.done { + exercise.done = false; + self.n_done -= 1; + self.write()?; + } + + Ok(()) + } + + // Official exercises: Dump the original file from the binary. + // Third-party exercises: Reset the exercise file with `git stash`. + fn reset(&self, exercise_ind: usize, path: &str) -> Result<()> { + if self.official_exercises { + return EMBEDDED_FILES + .write_exercise_to_disk(exercise_ind, path) + .with_context(|| format!("Failed to reset the exercise {path}")); + } + + let output = Command::new("git") + .arg("stash") + .arg("push") + .arg("--") + .arg(path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .output() + .with_context(|| format!("Failed to run `git stash push -- {path}`"))?; + + if !output.status.success() { + bail!( + "`git stash push -- {path}` didn't run successfully: {}", + String::from_utf8_lossy(&output.stderr), + ); + } + + Ok(()) + } + + pub fn reset_current_exercise(&mut self) -> Result<&'static str> { + self.set_pending(self.current_exercise_ind)?; + let exercise = self.current_exercise(); + self.reset(self.current_exercise_ind, exercise.path)?; + + Ok(exercise.path) + } + + pub fn reset_exercise_by_ind(&mut self, exercise_ind: usize) -> Result<&'static str> { + if exercise_ind >= self.exercises.len() { + bail!(BAD_INDEX_ERR); + } + + self.set_pending(exercise_ind)?; + let exercise = &self.exercises[exercise_ind]; + self.reset(exercise_ind, exercise.path)?; + + Ok(exercise.path) + } + + // Return the index of the next pending exercise or `None` if all exercises are done. + 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), + } + } + + /// Official exercises: Dump the solution file form the binary and return its path. + /// Third-party exercises: Check if a solution file exists and return its path in that case. + pub fn current_solution_path(&self) -> Result<Option<String>> { + if DEBUG_PROFILE { + return Ok(None); + } + + let current_exercise = self.current_exercise(); + + if self.official_exercises { + EMBEDDED_FILES + .write_solution_to_disk(self.current_exercise_ind, current_exercise.name) + .map(Some) + } else { + let solution_path = if let Some(dir) = current_exercise.dir { + format!("solutions/{dir}/{}.rs", current_exercise.name) + } else { + format!("solutions/{}.rs", current_exercise.name) + }; + + if Path::new(&solution_path).exists() { + return Ok(Some(solution_path)); + } + + Ok(None) + } + } + + /// Mark the current exercise as done and move on to the next pending exercise if one exists. + /// If all exercises are marked as done, run all of them to make sure that they are actually + /// done. If an exercise which is marked as done fails, mark it as pending and continue on it. + 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; + } + + if let Some(ind) = self.next_pending_exercise_ind() { + self.set_current_exercise_ind(ind)?; + + return Ok(ExercisesProgress::NewPending); + } + + writer.write_all(RERUNNING_ALL_EXERCISES_MSG)?; + + let mut output = Vec::with_capacity(OUTPUT_CAPACITY); + for (exercise_ind, exercise) in self.exercises().iter().enumerate() { + write!(writer, "Running {exercise} ... ")?; + writer.flush()?; + + let success = exercise.run_exercise(&mut output, &self.target_dir)?; + if !success { + writeln!(writer, "{}\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::NewPending); + } + + writeln!(writer, "{}", "ok".green())?; + } + + clear_terminal(writer)?; + writer.write_all(FENISH_LINE.as_bytes())?; + + let final_message = self.final_message.trim(); + if !final_message.is_empty() { + writer.write_all(final_message.as_bytes())?; + writer.write_all(b"\n")?; + } + + Ok(ExercisesProgress::AllDone) + } +} + +const CARGO_METADATA_ERR: &str = "Failed to run the command `cargo metadata …` +Did you already install Rust? +Try running `cargo --version` to diagnose the problem."; + +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. + +"; + +const FENISH_LINE: &str = "+----------------------------------------------------+ +| You made it to the Fe-nish line! | ++-------------------------- ------------------------+ + \\/\x1b[31m + ▒▒ ▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒ ▒▒ + ▒▒▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒▒▒ + ▒▒▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒▒▒ + ░░▒▒▒▒░░▒▒ ▒▒ ▒▒ ▒▒ ▒▒░░▒▒▒▒ + ▓▓▓▓▓▓▓▓ ▓▓ ▓▓██ ▓▓ ▓▓██ ▓▓ ▓▓▓▓▓▓▓▓ + ▒▒▒▒ ▒▒ ████ ▒▒ ████ ▒▒░░ ▒▒▒▒ + ▒▒ ▒▒▒▒▒▒ ▒▒▒▒▒▒ ▒▒▒▒▒▒ ▒▒ + ▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒ + ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ + ▒▒▒▒▒▒▒▒▒▒██▒▒▒▒▒▒██▒▒▒▒▒▒▒▒▒▒ + ▒▒ ▒▒▒▒▒▒▒▒▒▒██████▒▒▒▒▒▒▒▒▒▒ ▒▒ + ▒▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ▒▒ + ▒▒ ▒▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ▒▒ ▒▒ + ▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒ + ▒▒ ▒▒ ▒▒ ▒▒\x1b[0m + +"; + +#[cfg(test)] +mod tests { + use super::*; + + fn dummy_exercise() -> Exercise { + Exercise { + dir: None, + name: "0", + path: "exercises/0.rs", + test: false, + strict_clippy: false, + hint: String::new(), + done: false, + } + } + + #[test] + fn next_pending_exercise() { + let mut app_state = AppState { + current_exercise_ind: 0, + exercises: vec![dummy_exercise(), dummy_exercise(), dummy_exercise()], + n_done: 0, + final_message: String::new(), + file_buf: Vec::new(), + official_exercises: true, + target_dir: PathBuf::new(), + }; + + let mut assert = |done: [bool; 3], expected: [Option<usize>; 3]| { + for (exercise, done) in app_state.exercises.iter_mut().zip(done) { + exercise.done = done; + } + for (ind, expected) in expected.into_iter().enumerate() { + app_state.current_exercise_ind = ind; + assert_eq!( + app_state.next_pending_exercise_ind(), + expected, + "done={done:?}, ind={ind}", + ); + } + }; + + assert([true, true, true], [None, None, None]); + assert([false, false, false], [Some(1), Some(2), Some(0)]); + assert([false, true, true], [None, Some(0), Some(0)]); + assert([true, false, true], [Some(1), None, Some(1)]); + assert([true, true, false], [Some(2), Some(2), None]); + assert([true, false, false], [Some(1), Some(2), Some(1)]); + assert([false, true, false], [Some(2), Some(2), Some(0)]); + assert([false, false, true], [Some(1), Some(0), Some(0)]); + } +} diff --git a/src/cargo_toml.rs b/src/cargo_toml.rs new file mode 100644 index 0000000..445b6b5 --- /dev/null +++ b/src/cargo_toml.rs @@ -0,0 +1,146 @@ +use anyhow::{Context, Result}; +use std::path::Path; + +use crate::info_file::ExerciseInfo; + +/// Initial capacity of the bins buffer. +pub const BINS_BUFFER_CAPACITY: usize = 1 << 14; + +/// Return the start and end index of the content of the list `bin = […]`. +/// bin = [xxxxxxxxxxxxxxxxx] +/// |start_ind | +/// |end_ind +pub fn bins_start_end_ind(cargo_toml: &str) -> Result<(usize, usize)> { + let start_ind = cargo_toml + .find("bin = [") + .context("Failed to find the start of the `bin` list (`bin = [`)")? + + 7; + let end_ind = start_ind + + cargo_toml + .get(start_ind..) + .and_then(|slice| slice.as_bytes().iter().position(|c| *c == b']')) + .context("Failed to find the end of the `bin` list (`]`)")?; + + Ok((start_ind, end_ind)) +} + +/// Generate and append the content of the `bin` list in `Cargo.toml`. +/// The `exercise_path_prefix` is the prefix of the `path` field of every list entry. +pub fn append_bins( + buf: &mut Vec<u8>, + exercise_infos: &[ExerciseInfo], + exercise_path_prefix: &[u8], +) { + buf.push(b'\n'); + for exercise_info in exercise_infos { + buf.extend_from_slice(b" { name = \""); + buf.extend_from_slice(exercise_info.name.as_bytes()); + buf.extend_from_slice(b"\", path = \""); + buf.extend_from_slice(exercise_path_prefix); + buf.extend_from_slice(b"exercises/"); + if let Some(dir) = &exercise_info.dir { + buf.extend_from_slice(dir.as_bytes()); + buf.push(b'/'); + } + buf.extend_from_slice(exercise_info.name.as_bytes()); + buf.extend_from_slice(b".rs\" },\n"); + + let sol_path = exercise_info.sol_path(); + if !Path::new(&sol_path).exists() { + continue; + } + + buf.extend_from_slice(b" { name = \""); + buf.extend_from_slice(exercise_info.name.as_bytes()); + buf.extend_from_slice(b"_sol"); + buf.extend_from_slice(b"\", path = \""); + buf.extend_from_slice(exercise_path_prefix); + buf.extend_from_slice(b"solutions/"); + if let Some(dir) = &exercise_info.dir { + buf.extend_from_slice(dir.as_bytes()); + buf.push(b'/'); + } + buf.extend_from_slice(exercise_info.name.as_bytes()); + buf.extend_from_slice(b".rs\" },\n"); + } +} + +/// Update the `bin` list and leave everything else unchanged. +pub fn updated_cargo_toml( + exercise_infos: &[ExerciseInfo], + current_cargo_toml: &str, + exercise_path_prefix: &[u8], +) -> Result<Vec<u8>> { + let (bins_start_ind, bins_end_ind) = bins_start_end_ind(current_cargo_toml)?; + + let mut updated_cargo_toml = Vec::with_capacity(BINS_BUFFER_CAPACITY); + updated_cargo_toml.extend_from_slice(current_cargo_toml[..bins_start_ind].as_bytes()); + append_bins( + &mut updated_cargo_toml, + exercise_infos, + exercise_path_prefix, + ); + updated_cargo_toml.extend_from_slice(current_cargo_toml[bins_end_ind..].as_bytes()); + + Ok(updated_cargo_toml) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bins_start_end_ind() { + assert_eq!(bins_start_end_ind("").ok(), None); + assert_eq!(bins_start_end_ind("[]").ok(), None); + assert_eq!(bins_start_end_ind("bin = [").ok(), None); + assert_eq!(bins_start_end_ind("bin = ]").ok(), None); + assert_eq!(bins_start_end_ind("bin = []").ok(), Some((7, 7))); + assert_eq!(bins_start_end_ind("bin= []").ok(), None); + assert_eq!(bins_start_end_ind("bin =[]").ok(), None); + assert_eq!(bins_start_end_ind("bin=[]").ok(), None); + assert_eq!(bins_start_end_ind("bin = [\nxxx\n]").ok(), Some((7, 12))); + } + + #[test] + fn test_bins() { + let exercise_infos = [ + ExerciseInfo { + name: String::from("1"), + dir: None, + test: true, + strict_clippy: true, + hint: String::new(), + skip_check_unsolved: false, + }, + ExerciseInfo { + name: String::from("2"), + dir: Some(String::from("d")), + test: false, + strict_clippy: false, + hint: String::new(), + skip_check_unsolved: false, + }, + ]; + + let mut buf = Vec::with_capacity(128); + append_bins(&mut buf, &exercise_infos, b""); + assert_eq!( + buf, + br#" + { name = "1", path = "exercises/1.rs" }, + { name = "2", path = "exercises/d/2.rs" }, +"#, + ); + + assert_eq!( + updated_cargo_toml(&exercise_infos, "abc\nbin = [xxx]\n123", b"../").unwrap(), + br#"abc +bin = [ + { name = "1", path = "../exercises/1.rs" }, + { name = "2", path = "../exercises/d/2.rs" }, +] +123"#, + ); + } +} diff --git a/src/cmd.rs b/src/cmd.rs new file mode 100644 index 0000000..6092f53 --- /dev/null +++ b/src/cmd.rs @@ -0,0 +1,93 @@ +use anyhow::{Context, Result}; +use std::{io::Read, path::Path, process::Command}; + +/// Run a command with a description for a possible error and append the merged stdout and stderr. +/// The boolean in the returned `Result` is true if the command's exit status is success. +pub fn run_cmd(mut cmd: Command, description: &str, output: &mut Vec<u8>) -> Result<bool> { + let (mut reader, writer) = os_pipe::pipe() + .with_context(|| format!("Failed to create a pipe to run the command `{description}``"))?; + + let writer_clone = writer.try_clone().with_context(|| { + format!("Failed to clone the pipe writer for the command `{description}`") + })?; + + let mut handle = cmd + .stdout(writer_clone) + .stderr(writer) + .spawn() + .with_context(|| format!("Failed to run the command `{description}`"))?; + + // Prevent pipe deadlock. + drop(cmd); + + reader + .read_to_end(output) + .with_context(|| format!("Failed to read the output of the command `{description}`"))?; + + output.push(b'\n'); + + handle + .wait() + .with_context(|| format!("Failed to wait on the command `{description}` to exit")) + .map(|status| status.success()) +} + +pub struct CargoCmd<'a> { + pub subcommand: &'a str, + pub args: &'a [&'a str], + pub bin_name: &'a str, + pub description: &'a str, + /// RUSTFLAGS="-A warnings" + pub hide_warnings: bool, + /// Added as `--target-dir` if `Self::dev` is true. + pub target_dir: &'a Path, + /// The output buffer to append the merged stdout and stderr. + pub output: &'a mut Vec<u8>, + /// true while developing Rustlings. + pub dev: bool, +} + +impl<'a> CargoCmd<'a> { + /// Run `cargo SUBCOMMAND --bin EXERCISE_NAME … ARGS`. + pub fn run(&mut self) -> Result<bool> { + let mut cmd = Command::new("cargo"); + cmd.arg(self.subcommand); + + // A hack to make `cargo run` work when developing Rustlings. + if self.dev { + cmd.arg("--manifest-path") + .arg("dev/Cargo.toml") + .arg("--target-dir") + .arg(self.target_dir); + } + + cmd.arg("--color") + .arg("always") + .arg("-q") + .arg("--bin") + .arg(self.bin_name) + .args(self.args); + + if self.hide_warnings { + cmd.env("RUSTFLAGS", "-A warnings"); + } + + run_cmd(cmd, self.description, self.output) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_run_cmd() { + let mut cmd = Command::new("echo"); + cmd.arg("Hello"); + + let mut output = Vec::with_capacity(8); + run_cmd(cmd, "echo …", &mut output).unwrap(); + + assert_eq!(output, b"Hello\n\n"); + } +} diff --git a/src/dev.rs b/src/dev.rs new file mode 100644 index 0000000..5f7e64c --- /dev/null +++ b/src/dev.rs @@ -0,0 +1,48 @@ +use anyhow::{bail, Context, Result}; +use clap::Subcommand; +use std::path::PathBuf; + +use crate::DEBUG_PROFILE; + +mod check; +mod new; +mod update; + +#[derive(Subcommand)] +pub enum DevCommands { + /// Create a new project for third-party Rustlings exercises + New { + /// The path to create the project in + path: PathBuf, + /// Don't try to initialize a Git repository in the project directory + #[arg(long)] + no_git: bool, + }, + /// Run checks on the exercises + Check { + /// Require that every exercise has a solution + #[arg(short, long)] + require_solutions: bool, + }, + /// Update the `Cargo.toml` file for the exercises + Update, +} + +impl DevCommands { + pub fn run(self) -> Result<()> { + match self { + Self::New { path, no_git } => { + if DEBUG_PROFILE { + bail!("Disabled in the debug build"); + } + + new::new(&path, no_git).context(INIT_ERR) + } + Self::Check { require_solutions } => check::check(require_solutions), + Self::Update => update::update(), + } + } +} + +const INIT_ERR: &str = "Initialization failed. +After resolving the issue, delete the `rustlings` directory (if it was created) and try again"; diff --git a/src/dev/check.rs b/src/dev/check.rs new file mode 100644 index 0000000..5c35462 --- /dev/null +++ b/src/dev/check.rs @@ -0,0 +1,296 @@ +use anyhow::{anyhow, bail, Context, Result}; +use std::{ + cmp::Ordering, + fs::{self, read_dir, OpenOptions}, + io::{self, Read, Write}, + path::{Path, PathBuf}, + sync::{ + atomic::{self, AtomicBool}, + Mutex, + }, + thread, +}; + +use crate::{ + app_state::parse_target_dir, + cargo_toml::{append_bins, bins_start_end_ind, BINS_BUFFER_CAPACITY}, + exercise::{RunnableExercise, OUTPUT_CAPACITY}, + info_file::{ExerciseInfo, InfoFile}, + CURRENT_FORMAT_VERSION, DEBUG_PROFILE, +}; + +// Find a char that isn't allowed in the exercise's `name` or `dir`. +fn forbidden_char(input: &str) -> Option<char> { + input.chars().find(|c| !c.is_alphanumeric() && *c != '_') +} + +// Check that the Cargo.toml file is up-to-date. +fn check_cargo_toml( + exercise_infos: &[ExerciseInfo], + current_cargo_toml: &str, + exercise_path_prefix: &[u8], +) -> Result<()> { + let (bins_start_ind, bins_end_ind) = bins_start_end_ind(current_cargo_toml)?; + + let old_bins = ¤t_cargo_toml.as_bytes()[bins_start_ind..bins_end_ind]; + let mut new_bins = Vec::with_capacity(BINS_BUFFER_CAPACITY); + append_bins(&mut new_bins, exercise_infos, exercise_path_prefix); + + if old_bins != new_bins { + if DEBUG_PROFILE { + bail!("The file `dev/Cargo.toml` is outdated. Please run `cargo run -- dev update` to update it"); + } + + bail!("The file `Cargo.toml` is outdated. Please run `rustlings dev update` to update it"); + } + + Ok(()) +} + +// Check the info of all exercises and return their paths in a set. +fn check_info_file_exercises(info_file: &InfoFile) -> Result<hashbrown::HashSet<PathBuf>> { + let mut names = hashbrown::HashSet::with_capacity(info_file.exercises.len()); + let mut paths = hashbrown::HashSet::with_capacity(info_file.exercises.len()); + + let mut file_buf = String::with_capacity(1 << 14); + for exercise_info in &info_file.exercises { + let name = exercise_info.name.as_str(); + if name.is_empty() { + bail!("Found an empty exercise name in `info.toml`"); + } + if let Some(c) = forbidden_char(name) { + bail!("Char `{c}` in the exercise name `{name}` is not allowed"); + } + + if let Some(dir) = &exercise_info.dir { + if dir.is_empty() { + bail!("The exercise `{name}` has an empty dir name in `info.toml`"); + } + if let Some(c) = forbidden_char(dir) { + bail!("Char `{c}` in the exercise dir `{dir}` is not allowed"); + } + } + + if exercise_info.hint.trim().is_empty() { + bail!("The exercise `{name}` has an empty hint. Please provide a hint or at least tell the user why a hint isn't needed for this exercise"); + } + + if !names.insert(name) { + bail!("The exercise name `{name}` is duplicated. Exercise names must all be unique"); + } + + let path = exercise_info.path(); + + OpenOptions::new() + .read(true) + .open(&path) + .with_context(|| format!("Failed to open the file {path}"))? + .read_to_string(&mut file_buf) + .with_context(|| format!("Failed to read the file {path}"))?; + + if !file_buf.contains("fn main()") { + bail!("The `main` function is missing in the file `{path}`.\nCreate at least an empty `main` function to avoid language server errors"); + } + + if !file_buf.contains("// TODO") { + bail!("Didn't find any `// TODO` comment in the file `{path}`.\nYou need to have at least one such comment to guide the user."); + } + + if !exercise_info.test && file_buf.contains("#[test]") { + bail!("The file `{path}` contains tests annotated with `#[test]` but the exercise `{name}` has `test = false` in the `info.toml` file"); + } + + file_buf.clear(); + + paths.insert(PathBuf::from(path)); + } + + Ok(paths) +} + +// Check `dir` for unexpected files. +// Only Rust files in `allowed_rust_files` and `README.md` files are allowed. +// Only one level of directory nesting is allowed. +fn check_unexpected_files( + dir: &str, + allowed_rust_files: &hashbrown::HashSet<PathBuf>, +) -> Result<()> { + let unexpected_file = |path: &Path| { + anyhow!("Found the file `{}`. Only `README.md` and Rust files related to an exercise in `info.toml` are allowed in the `{dir}` directory", path.display()) + }; + + for entry in read_dir(dir).with_context(|| format!("Failed to open the `{dir}` directory"))? { + let entry = entry.with_context(|| format!("Failed to read the `{dir}` directory"))?; + + if entry.file_type().unwrap().is_file() { + let path = entry.path(); + let file_name = path.file_name().unwrap(); + if file_name == "README.md" { + continue; + } + + if !allowed_rust_files.contains(&path) { + return Err(unexpected_file(&path)); + } + + continue; + } + + let dir_path = entry.path(); + for entry in read_dir(&dir_path) + .with_context(|| format!("Failed to open the directory {}", dir_path.display()))? + { + let entry = entry + .with_context(|| format!("Failed to read the directory {}", dir_path.display()))?; + let path = entry.path(); + + if !entry.file_type().unwrap().is_file() { + bail!("Found `{}` but expected only files. Only one level of exercise nesting is allowed", path.display()); + } + + let file_name = path.file_name().unwrap(); + if file_name == "README.md" { + continue; + } + + if !allowed_rust_files.contains(&path) { + return Err(unexpected_file(&path)); + } + } + } + + Ok(()) +} + +fn check_exercises_unsolved(info_file: &InfoFile, target_dir: &Path) -> Result<()> { + let error_occurred = AtomicBool::new(false); + + println!( + "Running all exercises to check that they aren't already solved. This may take a while…\n", + ); + thread::scope(|s| { + for exercise_info in &info_file.exercises { + if exercise_info.skip_check_unsolved { + continue; + } + + s.spawn(|| { + let error = |e| { + let mut stderr = io::stderr().lock(); + stderr.write_all(e).unwrap(); + stderr.write_all(b"\nProblem with the exercise ").unwrap(); + stderr.write_all(exercise_info.name.as_bytes()).unwrap(); + stderr.write_all(SEPARATOR).unwrap(); + error_occurred.store(true, atomic::Ordering::Relaxed); + }; + + let mut output = Vec::with_capacity(OUTPUT_CAPACITY); + match exercise_info.run_exercise(&mut output, target_dir) { + Ok(true) => error(b"Already solved!"), + Ok(false) => (), + Err(e) => error(e.to_string().as_bytes()), + } + }); + } + }); + + if error_occurred.load(atomic::Ordering::Relaxed) { + bail!(CHECK_EXERCISES_UNSOLVED_ERR); + } + + Ok(()) +} + +fn check_exercises(info_file: &InfoFile, target_dir: &Path) -> Result<()> { + match info_file.format_version.cmp(&CURRENT_FORMAT_VERSION) { + Ordering::Less => bail!("`format_version` < {CURRENT_FORMAT_VERSION} (supported version)\nPlease migrate to the latest format version"), + Ordering::Greater => bail!("`format_version` > {CURRENT_FORMAT_VERSION} (supported version)\nTry updating the Rustlings program"), + Ordering::Equal => (), + } + + let info_file_paths = check_info_file_exercises(info_file)?; + check_unexpected_files("exercises", &info_file_paths)?; + + check_exercises_unsolved(info_file, target_dir) +} + +fn check_solutions(require_solutions: bool, info_file: &InfoFile, target_dir: &Path) -> Result<()> { + let paths = Mutex::new(hashbrown::HashSet::with_capacity(info_file.exercises.len())); + let error_occurred = AtomicBool::new(false); + + println!("Running all solutions. This may take a while…\n"); + thread::scope(|s| { + for exercise_info in &info_file.exercises { + s.spawn(|| { + let error = |e| { + let mut stderr = io::stderr().lock(); + stderr.write_all(e).unwrap(); + stderr + .write_all(b"\nFailed to run the solution of the exercise ") + .unwrap(); + stderr.write_all(exercise_info.name.as_bytes()).unwrap(); + stderr.write_all(SEPARATOR).unwrap(); + error_occurred.store(true, atomic::Ordering::Relaxed); + }; + + let path = exercise_info.sol_path(); + if !Path::new(&path).exists() { + if require_solutions { + error(b"Solution missing"); + } + + // No solution to check. + return; + } + + let mut output = Vec::with_capacity(OUTPUT_CAPACITY); + match exercise_info.run_solution(&mut output, target_dir) { + Ok(true) => { + paths.lock().unwrap().insert(PathBuf::from(path)); + } + Ok(false) => error(&output), + Err(e) => error(e.to_string().as_bytes()), + } + }); + } + }); + + if error_occurred.load(atomic::Ordering::Relaxed) { + bail!("At least one solution failed. See the output above."); + } + + check_unexpected_files("solutions", &paths.into_inner().unwrap())?; + + Ok(()) +} + +pub fn check(require_solutions: bool) -> Result<()> { + let info_file = InfoFile::parse()?; + + // A hack to make `cargo run -- dev check` work when developing Rustlings. + if DEBUG_PROFILE { + check_cargo_toml( + &info_file.exercises, + include_str!("../../dev-Cargo.toml"), + b"../", + )?; + } else { + let current_cargo_toml = + fs::read_to_string("Cargo.toml").context("Failed to read the file `Cargo.toml`")?; + check_cargo_toml(&info_file.exercises, ¤t_cargo_toml, b"")?; + } + + let target_dir = parse_target_dir()?; + check_exercises(&info_file, &target_dir)?; + check_solutions(require_solutions, &info_file, &target_dir)?; + + println!("\nEverything looks fine!"); + + Ok(()) +} + +const SEPARATOR: &[u8] = + b"\n========================================================================================\n"; + +const CHECK_EXERCISES_UNSOLVED_ERR: &str = "At least one exercise is already solved or failed to run. See the output above. +If this is an intro exercise that is intended to be already solved, add `skip_check_unsolved = true` to the exercise's metadata in the `info.toml` file."; diff --git a/src/dev/new.rs b/src/dev/new.rs new file mode 100644 index 0000000..fefc4fc --- /dev/null +++ b/src/dev/new.rs @@ -0,0 +1,145 @@ +use anyhow::{bail, Context, Result}; +use std::{ + env::set_current_dir, + fs::{self, create_dir}, + path::Path, + process::Command, +}; + +use crate::CURRENT_FORMAT_VERSION; + +// Create a directory relative to the current directory and print its path. +fn create_rel_dir(dir_name: &str, current_dir: &str) -> Result<()> { + create_dir(dir_name) + .with_context(|| format!("Failed to create the directory {current_dir}/{dir_name}"))?; + println!("Created the directory {current_dir}/{dir_name}"); + Ok(()) +} + +// Write a file relative to the current directory and print its path. +fn write_rel_file<C>(file_name: &str, current_dir: &str, content: C) -> Result<()> +where + C: AsRef<[u8]>, +{ + fs::write(file_name, content) + .with_context(|| format!("Failed to create the file {current_dir}/{file_name}"))?; + // Space to align with `create_rel_dir`. + println!("Created the file {current_dir}/{file_name}"); + Ok(()) +} + +pub fn new(path: &Path, no_git: bool) -> Result<()> { + let dir_path_str = path.to_string_lossy(); + + create_dir(path).with_context(|| format!("Failed to create the directory {dir_path_str}"))?; + println!("Created the directory {dir_path_str}"); + + set_current_dir(path) + .with_context(|| format!("Failed to set {dir_path_str} as the current directory"))?; + + if !no_git + && !Command::new("git") + .arg("init") + .status() + .context("Failed to run `git init`")? + .success() + { + bail!("`git init` didn't run successfully. See the possible error message above"); + } + + write_rel_file(".gitignore", &dir_path_str, GITIGNORE)?; + + create_rel_dir("exercises", &dir_path_str)?; + create_rel_dir("solutions", &dir_path_str)?; + + write_rel_file( + "info.toml", + &dir_path_str, + format!("{INFO_FILE_BEFORE_FORMAT_VERSION}{CURRENT_FORMAT_VERSION}{INFO_FILE_AFTER_FORMAT_VERSION}"), + )?; + + write_rel_file("Cargo.toml", &dir_path_str, CARGO_TOML)?; + + write_rel_file("README.md", &dir_path_str, README)?; + + create_rel_dir(".vscode", &dir_path_str)?; + write_rel_file( + ".vscode/extensions.json", + &dir_path_str, + crate::init::VS_CODE_EXTENSIONS_JSON, + )?; + + println!("\nInitialization done ✓"); + + Ok(()) +} + +pub const GITIGNORE: &[u8] = b".rustlings-state.txt +Cargo.lock +target +.vscode +!.vscode/extensions.json +"; + +const INFO_FILE_BEFORE_FORMAT_VERSION: &str = + "# The format version is an indicator of the compatibility of third-party exercises with the +# Rustlings program. +# The format version is not the same as the version of the Rustlings program. +# In case Rustlings makes an unavoidable breaking change to the expected format of third-party +# exercises, you would need to raise this version and adapt to the new format. +# Otherwise, the newest version of the Rustlings program won't be able to run these exercises. +format_version = "; + +const INFO_FILE_AFTER_FORMAT_VERSION: &str = r#" + +# Optional multi-line message to be shown to users when just starting with the exercises. +welcome_message = """Welcome to these third-party Rustlings exercises.""" + +# Optional multi-line message to be shown to users after finishing all exercises. +final_message = """We hope that you found the exercises helpful :D""" + +# Repeat this section for every exercise. +[[exercises]] +# Exercise name which is the exercise file name without the `.rs` extension. +name = "???" + +# Optional directory name to be provided if you want to organize exercises in directories. +# If `dir` is specified, the exercise path is `exercises/DIR/NAME.rs` +# Otherwise, the path is `exercises/NAME.rs` +# dir = "???" + +# Rustlings expects the exercise to contain tests and run them. +# You can optionally disable testing by setting `test` to `false` (the default is `true`). +# In that case, the exercise will be considered done when it just successfully compiles. +# test = true + +# Rustlings will always run Clippy on exercises. +# You can optionally set `strict_clippy` to `true` (the default is `false`) to only consider +# the exercise as done when there are no warnings left. +# strict_clippy = false + +# A multi-line hint to be shown to users on request. +hint = """???""" +"#; + +const CARGO_TOML: &[u8] = + br#"# Don't edit the `bin` list manually! It is updated by `rustlings dev update` +bin = [] + +[package] +name = "exercises" +edition = "2021" +# Don't publish the exercises on crates.io! +publish = false + +[dependencies] +"#; + +const README: &str = "# Rustlings 🦀 + +Welcome to these third-party Rustlings exercises 😃 + +First, [install Rustlings using the official instructions in the README of the Rustlings project](https://github.com/rust-lang/rustlings) ✅ + +Then, open your terminal in this directory and run `rustlings` to get started with the exercises 🚀 +"; diff --git a/src/dev/update.rs b/src/dev/update.rs new file mode 100644 index 0000000..66efe3d --- /dev/null +++ b/src/dev/update.rs @@ -0,0 +1,50 @@ +use anyhow::{Context, Result}; +use std::fs; + +use crate::{ + cargo_toml::updated_cargo_toml, + info_file::{ExerciseInfo, InfoFile}, + DEBUG_PROFILE, +}; + +// Update the `Cargo.toml` file. +fn update_cargo_toml( + exercise_infos: &[ExerciseInfo], + current_cargo_toml: &str, + exercise_path_prefix: &[u8], + cargo_toml_path: &str, +) -> Result<()> { + let updated_cargo_toml = + updated_cargo_toml(exercise_infos, current_cargo_toml, exercise_path_prefix)?; + + fs::write(cargo_toml_path, updated_cargo_toml) + .context("Failed to write the `Cargo.toml` file")?; + + Ok(()) +} + +pub fn update() -> Result<()> { + let info_file = InfoFile::parse()?; + + // A hack to make `cargo run -- dev update` work when developing Rustlings. + if DEBUG_PROFILE { + update_cargo_toml( + &info_file.exercises, + include_str!("../../dev-Cargo.toml"), + b"../", + "dev/Cargo.toml", + ) + .context("Failed to update the file `dev/Cargo.toml`")?; + + println!("Updated `dev/Cargo.toml`"); + } else { + let current_cargo_toml = + fs::read_to_string("Cargo.toml").context("Failed to read the file `Cargo.toml`")?; + update_cargo_toml(&info_file.exercises, ¤t_cargo_toml, b"", "Cargo.toml") + .context("Failed to update the file `Cargo.toml`")?; + + println!("Updated `Cargo.toml`"); + } + + Ok(()) +} diff --git a/src/embedded.rs b/src/embedded.rs new file mode 100644 index 0000000..1dce46c --- /dev/null +++ b/src/embedded.rs @@ -0,0 +1,168 @@ +use anyhow::{Context, Error, Result}; +use std::{ + fs::{create_dir, OpenOptions}, + io::{self, Write}, +}; + +use crate::info_file::ExerciseInfo; + +/// Contains all embedded files. +pub static EMBEDDED_FILES: EmbeddedFiles = rustlings_macros::include_files!(); + +#[derive(Clone, Copy)] +pub enum WriteStrategy { + IfNotExists, + Overwrite, +} + +impl WriteStrategy { + fn write(self, path: &str, content: &[u8]) -> Result<()> { + let file = match self { + Self::IfNotExists => OpenOptions::new().create_new(true).write(true).open(path), + Self::Overwrite => OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(path), + }; + + file.with_context(|| format!("Failed to open the file `{path}` in write mode"))? + .write_all(content) + .with_context(|| format!("Failed to write the file {path}")) + } +} + +// Files related to one exercise. +struct ExerciseFiles { + // The content of the exercise file. + exercise: &'static [u8], + // The content of the solution file. + solution: &'static [u8], + // Index of the related `ExerciseDir` in `EmbeddedFiles::exercise_dirs`. + dir_ind: usize, +} + +// A directory in the `exercises/` directory. +pub struct ExerciseDir { + pub name: &'static str, + readme: &'static [u8], +} + +impl ExerciseDir { + fn init_on_disk(&self) -> Result<()> { + // 20 = 10 + 10 + // exercises/ + /README.md + let mut dir_path = String::with_capacity(20 + self.name.len()); + dir_path.push_str("exercises/"); + dir_path.push_str(self.name); + + if let Err(e) = create_dir(&dir_path) { + if e.kind() == io::ErrorKind::AlreadyExists { + return Ok(()); + } + + return Err( + Error::from(e).context(format!("Failed to create the directory {dir_path}")) + ); + } + + let mut readme_path = dir_path; + readme_path.push_str("/README.md"); + + WriteStrategy::Overwrite.write(&readme_path, self.readme) + } +} + +/// All embedded files. +pub struct EmbeddedFiles { + /// The content of the `info.toml` file. + pub info_file: &'static str, + exercise_files: &'static [ExerciseFiles], + pub exercise_dirs: &'static [ExerciseDir], +} + +impl EmbeddedFiles { + /// Dump all the embedded files of the `exercises/` directory. + pub fn init_exercises_dir(&self, exercise_infos: &[ExerciseInfo]) -> Result<()> { + create_dir("exercises").context("Failed to create the directory `exercises`")?; + + WriteStrategy::IfNotExists.write( + "exercises/README.md", + include_bytes!("../exercises/README.md"), + )?; + + for dir in self.exercise_dirs { + dir.init_on_disk()?; + } + + for (exercise_info, exercise_files) in exercise_infos.iter().zip(self.exercise_files) { + WriteStrategy::IfNotExists.write(&exercise_info.path(), exercise_files.exercise)?; + } + + Ok(()) + } + + pub fn write_exercise_to_disk(&self, exercise_ind: usize, path: &str) -> Result<()> { + let exercise_files = &self.exercise_files[exercise_ind]; + let dir = &self.exercise_dirs[exercise_files.dir_ind]; + + dir.init_on_disk()?; + WriteStrategy::Overwrite.write(path, exercise_files.exercise) + } + + /// Write the solution file to disk and return its path. + pub fn write_solution_to_disk( + &self, + exercise_ind: usize, + exercise_name: &str, + ) -> Result<String> { + let exercise_files = &self.exercise_files[exercise_ind]; + let dir = &self.exercise_dirs[exercise_files.dir_ind]; + + // 14 = 10 + 1 + 3 + // solutions/ + / + .rs + let mut solution_path = String::with_capacity(14 + dir.name.len() + exercise_name.len()); + solution_path.push_str("solutions/"); + solution_path.push_str(dir.name); + solution_path.push('/'); + solution_path.push_str(exercise_name); + solution_path.push_str(".rs"); + + WriteStrategy::Overwrite.write(&solution_path, exercise_files.solution)?; + + Ok(solution_path) + } +} + +#[cfg(test)] +mod tests { + use serde::Deserialize; + + use super::*; + + #[derive(Deserialize)] + struct ExerciseInfo { + dir: String, + } + + #[derive(Deserialize)] + struct InfoFile { + exercises: Vec<ExerciseInfo>, + } + + #[test] + fn dirs() { + let exercises = toml_edit::de::from_str::<InfoFile>(EMBEDDED_FILES.info_file) + .expect("Failed to parse `info.toml`") + .exercises; + + assert_eq!(exercises.len(), EMBEDDED_FILES.exercise_files.len()); + + for (exercise, exercise_files) in exercises.iter().zip(EMBEDDED_FILES.exercise_files) { + assert_eq!( + exercise.dir, + EMBEDDED_FILES.exercise_dirs[exercise_files.dir_ind].name, + ); + } + } +} diff --git a/src/exercise.rs b/src/exercise.rs index 664b362..b6adc14 100644 --- a/src/exercise.rs +++ b/src/exercise.rs @@ -1,378 +1,181 @@ -use regex::Regex; -use serde::Deserialize; -use std::env; -use std::fmt::{self, Display, Formatter}; -use std::fs::{self, remove_file, File}; -use std::io::Read; -use std::path::PathBuf; -use std::process::{self, Command}; - -const RUSTC_COLOR_ARGS: &[&str] = &["--color", "always"]; -const RUSTC_EDITION_ARGS: &[&str] = &["--edition", "2021"]; -const RUSTC_NO_DEBUG_ARGS: &[&str] = &["-C", "strip=debuginfo"]; -const I_AM_DONE_REGEX: &str = r"(?m)^\s*///?\s*I\s+AM\s+NOT\s+DONE"; -const CONTEXT: usize = 2; -const CLIPPY_CARGO_TOML_PATH: &str = "./exercises/22_clippy/Cargo.toml"; - -// Get a temporary file name that is hopefully unique -#[inline] -fn temp_file() -> String { - let thread_id: String = format!("{:?}", std::thread::current().id()) - .chars() - .filter(|c| c.is_alphanumeric()) - .collect(); - - format!("./temp_{}_{thread_id}", process::id()) -} - -// The mode of the exercise. -#[derive(Deserialize, Copy, Clone, Debug)] -#[serde(rename_all = "lowercase")] -pub enum Mode { - // Indicates that the exercise should be compiled as a binary - Compile, - // Indicates that the exercise should be compiled as a test harness - Test, - // Indicates that the exercise should be linted with clippy - Clippy, -} +use anyhow::Result; +use crossterm::style::{style, StyledContent, Stylize}; +use std::{ + fmt::{self, Display, Formatter}, + io::Write, + path::{Path, PathBuf}, + process::Command, +}; + +use crate::{ + cmd::{run_cmd, CargoCmd}, + in_official_repo, + terminal_link::TerminalFileLink, + DEBUG_PROFILE, +}; + +/// The initial capacity of the output buffer. +pub const OUTPUT_CAPACITY: usize = 1 << 14; + +// Run an exercise binary and append its output to the `output` buffer. +// Compilation must be done before calling this method. +fn run_bin(bin_name: &str, output: &mut Vec<u8>, target_dir: &Path) -> Result<bool> { + writeln!(output, "{}", "Output".underlined())?; + + // 7 = "/debug/".len() + let mut bin_path = PathBuf::with_capacity(target_dir.as_os_str().len() + 7 + bin_name.len()); + bin_path.push(target_dir); + bin_path.push("debug"); + bin_path.push(bin_name); + + let success = run_cmd(Command::new(&bin_path), &bin_path.to_string_lossy(), output)?; + + if !success { + // This output is important to show the user that something went wrong. + // Otherwise, calling something like `exit(1)` in an exercise without further output + // leaves the user confused about why the exercise isn't done yet. + writeln!( + output, + "{}", + "The exercise didn't run successfully (nonzero exit code)" + .bold() + .red(), + )?; + } -#[derive(Deserialize)] -pub struct ExerciseList { - pub exercises: Vec<Exercise>, + Ok(success) } -// A representation of a rustlings exercise. -// This is deserialized from the accompanying info.toml file -#[derive(Deserialize, Debug)] +/// See `info_file::ExerciseInfo` pub struct Exercise { - // Name of the exercise - pub name: String, - // The path to the file containing the exercise's source code - pub path: PathBuf, - // The mode of the exercise (Test, Compile, or Clippy) - pub mode: Mode, - // The hint text associated with the exercise + pub dir: Option<&'static str>, + pub name: &'static str, + /// Path of the exercise file starting with the `exercises/` directory. + pub path: &'static str, + pub test: bool, + pub strict_clippy: bool, pub hint: String, + pub done: bool, } -// An enum to track of the state of an Exercise. -// An Exercise can be either Done or Pending -#[derive(PartialEq, Debug)] -pub enum State { - // The state of the exercise once it's been completed - Done, - // The state of the exercise while it's not completed yet - Pending(Vec<ContextLine>), -} - -// The context information of a pending exercise -#[derive(PartialEq, Debug)] -pub struct ContextLine { - // The source code that is still pending completion - pub line: String, - // The line number of the source code still pending completion - pub number: usize, - // Whether or not this is important - pub important: bool, -} - -// The result of compiling an exercise -pub struct CompiledExercise<'a> { - exercise: &'a Exercise, - _handle: FileHandle, -} - -impl<'a> CompiledExercise<'a> { - // Run the compiled exercise - pub fn run(&self) -> Result<ExerciseOutput, ExerciseOutput> { - self.exercise.run() +impl Exercise { + pub fn terminal_link(&self) -> StyledContent<TerminalFileLink<'_>> { + style(TerminalFileLink(self.path)).underlined().blue() } } -// A representation of an already executed binary -#[derive(Debug)] -pub struct ExerciseOutput { - // The textual contents of the standard output of the binary - pub stdout: String, - // The textual contents of the standard error of the binary - pub stderr: String, -} - -struct FileHandle; - -impl Drop for FileHandle { - fn drop(&mut self) { - clean(); +impl Display for Exercise { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + self.path.fmt(f) } } -impl Exercise { - pub fn compile(&self) -> Result<CompiledExercise, ExerciseOutput> { - let cmd = match self.mode { - Mode::Compile => Command::new("rustc") - .args([self.path.to_str().unwrap(), "-o", &temp_file()]) - .args(RUSTC_COLOR_ARGS) - .args(RUSTC_EDITION_ARGS) - .args(RUSTC_NO_DEBUG_ARGS) - .output(), - Mode::Test => Command::new("rustc") - .args(["--test", self.path.to_str().unwrap(), "-o", &temp_file()]) - .args(RUSTC_COLOR_ARGS) - .args(RUSTC_EDITION_ARGS) - .args(RUSTC_NO_DEBUG_ARGS) - .output(), - Mode::Clippy => { - let cargo_toml = format!( - r#"[package] -name = "{}" -version = "0.0.1" -edition = "2021" -[[bin]] -name = "{}" -path = "{}.rs""#, - self.name, self.name, self.name - ); - let cargo_toml_error_msg = if env::var("NO_EMOJI").is_ok() { - "Failed to write Clippy Cargo.toml file." - } else { - "Failed to write 📎 Clippy 📎 Cargo.toml file." - }; - fs::write(CLIPPY_CARGO_TOML_PATH, cargo_toml).expect(cargo_toml_error_msg); - // To support the ability to run the clippy exercises, build - // an executable, in addition to running clippy. With a - // compilation failure, this would silently fail. But we expect - // clippy to reflect the same failure while compiling later. - Command::new("rustc") - .args([self.path.to_str().unwrap(), "-o", &temp_file()]) - .args(RUSTC_COLOR_ARGS) - .args(RUSTC_EDITION_ARGS) - .args(RUSTC_NO_DEBUG_ARGS) - .output() - .expect("Failed to compile!"); - // Due to an issue with Clippy, a cargo clean is required to catch all lints. - // See https://github.com/rust-lang/rust-clippy/issues/2604 - // This is already fixed on Clippy's master branch. See this issue to track merging into Cargo: - // https://github.com/rust-lang/rust-clippy/issues/3837 - Command::new("cargo") - .args(["clean", "--manifest-path", CLIPPY_CARGO_TOML_PATH]) - .args(RUSTC_COLOR_ARGS) - .output() - .expect("Failed to run 'cargo clean'"); - Command::new("cargo") - .args(["clippy", "--manifest-path", CLIPPY_CARGO_TOML_PATH]) - .args(RUSTC_COLOR_ARGS) - .args(["--", "-D", "warnings", "-D", "clippy::float_cmp"]) - .output() - } +pub trait RunnableExercise { + fn name(&self) -> &str; + fn strict_clippy(&self) -> bool; + fn test(&self) -> bool; + + // Compile, check and run the exercise or its solution (depending on `bin_name´). + // The output is written to the `output` buffer after clearing it. + fn run(&self, bin_name: &str, output: &mut Vec<u8>, target_dir: &Path) -> Result<bool> { + output.clear(); + + // Developing the official Rustlings. + let dev = DEBUG_PROFILE && in_official_repo(); + + let build_success = CargoCmd { + subcommand: "build", + args: &[], + bin_name, + description: "cargo build …", + hide_warnings: false, + target_dir, + output, + dev, } - .expect("Failed to run 'compile' command."); - - if cmd.status.success() { - Ok(CompiledExercise { - exercise: self, - _handle: FileHandle, - }) - } else { - clean(); - Err(ExerciseOutput { - stdout: String::from_utf8_lossy(&cmd.stdout).to_string(), - stderr: String::from_utf8_lossy(&cmd.stderr).to_string(), - }) + .run()?; + if !build_success { + return Ok(false); } - } - - fn run(&self) -> Result<ExerciseOutput, ExerciseOutput> { - let arg = match self.mode { - Mode::Test => "--show-output", - _ => "", - }; - let cmd = Command::new(temp_file()) - .arg(arg) - .output() - .expect("Failed to run 'run' command"); - let output = ExerciseOutput { - stdout: String::from_utf8_lossy(&cmd.stdout).to_string(), - stderr: String::from_utf8_lossy(&cmd.stderr).to_string(), - }; + // Discard the output of `cargo build` because it will be shown again by Clippy. + output.clear(); - if cmd.status.success() { - Ok(output) + // `--profile test` is required to also check code with `[cfg(test)]`. + let clippy_args: &[&str] = if self.strict_clippy() { + &["--profile", "test", "--", "-D", "warnings"] } else { - Err(output) - } - } - - pub fn state(&self) -> State { - let mut source_file = File::open(&self.path).unwrap_or_else(|e| { - panic!( - "We were unable to open the exercise file {}! {e}", - self.path.display() - ) - }); - - let source = { - let mut s = String::new(); - source_file.read_to_string(&mut s).unwrap_or_else(|e| { - panic!( - "We were unable to read the exercise file {}! {e}", - self.path.display() - ) - }); - s + &["--profile", "test"] }; - - let re = Regex::new(I_AM_DONE_REGEX).unwrap(); - - if !re.is_match(&source) { - return State::Done; + let clippy_success = CargoCmd { + subcommand: "clippy", + args: clippy_args, + bin_name, + description: "cargo clippy …", + hide_warnings: false, + target_dir, + output, + dev, + } + .run()?; + if !clippy_success { + return Ok(false); } - let matched_line_index = source - .lines() - .enumerate() - .find_map(|(i, line)| if re.is_match(line) { Some(i) } else { None }) - .expect("This should not happen at all"); - - let min_line = ((matched_line_index as i32) - (CONTEXT as i32)).max(0) as usize; - let max_line = matched_line_index + CONTEXT; + if !self.test() { + return run_bin(bin_name, output, target_dir); + } - let context = source - .lines() - .enumerate() - .filter(|&(i, _)| i >= min_line && i <= max_line) - .map(|(i, line)| ContextLine { - line: line.to_string(), - number: i + 1, - important: i == matched_line_index, - }) - .collect(); + let test_success = CargoCmd { + subcommand: "test", + args: &["--", "--color", "always", "--show-output"], + bin_name, + description: "cargo test …", + // Hide warnings because they are shown by Clippy. + hide_warnings: true, + target_dir, + output, + dev, + } + .run()?; - State::Pending(context) - } + let run_success = run_bin(bin_name, output, target_dir)?; - // 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) -> bool { - self.state() == State::Done + Ok(test_success && run_success) } -} -impl Display for Exercise { - fn fmt(&self, f: &mut Formatter) -> fmt::Result { - write!(f, "{}", self.path.to_str().unwrap()) + /// Compile, check and run the exercise. + /// The output is written to the `output` buffer after clearing it. + #[inline] + fn run_exercise(&self, output: &mut Vec<u8>, target_dir: &Path) -> Result<bool> { + self.run(self.name(), output, target_dir) } -} - -#[inline] -fn clean() { - let _ignored = remove_file(temp_file()); -} -#[cfg(test)] -mod test { - use super::*; - use std::path::Path; + /// Compile, check and run the exercise's solution. + /// The output is written to the `output` buffer after clearing it. + fn run_solution(&self, output: &mut Vec<u8>, target_dir: &Path) -> Result<bool> { + let name = self.name(); + let mut bin_name = String::with_capacity(name.len()); + bin_name.push_str(name); + bin_name.push_str("_sol"); - #[test] - fn test_clean() { - File::create(temp_file()).unwrap(); - let exercise = Exercise { - name: String::from("example"), - path: PathBuf::from("tests/fixture/state/pending_exercise.rs"), - mode: Mode::Compile, - hint: String::from(""), - }; - let compiled = exercise.compile().unwrap(); - drop(compiled); - assert!(!Path::new(&temp_file()).exists()); + self.run(&bin_name, output, target_dir) } +} - #[test] - #[cfg(target_os = "windows")] - fn test_no_pdb_file() { - [Mode::Compile, Mode::Test] // Clippy doesn't like to test - .iter() - .for_each(|mode| { - let exercise = Exercise { - name: String::from("example"), - // We want a file that does actually compile - path: PathBuf::from("tests/fixture/state/pending_exercise.rs"), - mode: *mode, - hint: String::from(""), - }; - let _ = exercise.compile().unwrap(); - assert!(!Path::new(&format!("{}.pdb", temp_file())).exists()); - }); - } - - #[test] - fn test_pending_state() { - let exercise = Exercise { - name: "pending_exercise".into(), - path: PathBuf::from("tests/fixture/state/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, State::Pending(expected)); +impl RunnableExercise for Exercise { + #[inline] + fn name(&self) -> &str { + self.name } - #[test] - fn test_finished_exercise() { - let exercise = Exercise { - name: "finished_exercise".into(), - path: PathBuf::from("tests/fixture/state/finished_exercise.rs"), - mode: Mode::Compile, - hint: String::new(), - }; - - assert_eq!(exercise.state(), State::Done); + #[inline] + fn strict_clippy(&self) -> bool { + self.strict_clippy } - #[test] - fn test_exercise_with_output() { - let exercise = Exercise { - name: "exercise_with_output".into(), - path: PathBuf::from("tests/fixture/success/testSuccess.rs"), - mode: Mode::Test, - hint: String::new(), - }; - let out = exercise.compile().unwrap().run().unwrap(); - assert!(out.stdout.contains("THIS TEST TOO SHALL PASS")); + #[inline] + fn test(&self) -> bool { + self.test } } diff --git a/src/info_file.rs b/src/info_file.rs new file mode 100644 index 0000000..f27d018 --- /dev/null +++ b/src/info_file.rs @@ -0,0 +1,138 @@ +use anyhow::{bail, Context, Error, Result}; +use serde::Deserialize; +use std::{fs, io::ErrorKind}; + +use crate::{embedded::EMBEDDED_FILES, exercise::RunnableExercise}; + +/// Deserialized from the `info.toml` file. +#[derive(Deserialize)] +pub struct ExerciseInfo { + /// Exercise's unique name. + pub name: String, + /// Exercise's directory name inside the `exercises/` directory. + pub dir: Option<String>, + /// Run `cargo test` on the exercise. + #[serde(default = "default_true")] + pub test: bool, + /// Deny all Clippy warnings. + #[serde(default)] + pub strict_clippy: bool, + /// The exercise's hint to be shown to the user on request. + pub hint: String, + /// The exercise is already solved. Ignore it when checking that all exercises are unsolved. + #[serde(default)] + pub skip_check_unsolved: bool, +} +#[inline(always)] +const fn default_true() -> bool { + true +} + +impl ExerciseInfo { + /// Path to the exercise file starting with the `exercises/` directory. + pub fn path(&self) -> String { + let mut path = if let Some(dir) = &self.dir { + // 14 = 10 + 1 + 3 + // exercises/ + / + .rs + let mut path = String::with_capacity(14 + dir.len() + self.name.len()); + path.push_str("exercises/"); + path.push_str(dir); + path.push('/'); + path + } else { + // 13 = 10 + 3 + // exercises/ + .rs + let mut path = String::with_capacity(13 + self.name.len()); + path.push_str("exercises/"); + path + }; + + path.push_str(&self.name); + path.push_str(".rs"); + + path + } + + /// Path to the solution file starting with the `solutions/` directory. + pub fn sol_path(&self) -> String { + let mut path = if let Some(dir) = &self.dir { + // 14 = 10 + 1 + 3 + // solutions/ + / + .rs + let mut path = String::with_capacity(14 + dir.len() + self.name.len()); + path.push_str("solutions/"); + path.push_str(dir); + path.push('/'); + path + } else { + // 13 = 10 + 3 + // solutions/ + .rs + let mut path = String::with_capacity(13 + self.name.len()); + path.push_str("solutions/"); + path + }; + + path.push_str(&self.name); + path.push_str(".rs"); + + path + } +} + +impl RunnableExercise for ExerciseInfo { + #[inline] + fn name(&self) -> &str { + &self.name + } + + #[inline] + fn strict_clippy(&self) -> bool { + self.strict_clippy + } + + #[inline] + fn test(&self) -> bool { + self.test + } +} + +/// The deserialized `info.toml` file. +#[derive(Deserialize)] +pub struct InfoFile { + /// For possible breaking changes in the future for third-party exercises. + pub format_version: u8, + /// Shown to users when starting with the exercises. + pub welcome_message: Option<String>, + /// Shown to users after finishing all exercises. + pub final_message: Option<String>, + /// List of all exercises. + pub exercises: Vec<ExerciseInfo>, +} + +impl InfoFile { + /// Official exercises: Parse the embedded `info.toml` file. + /// Third-party exercises: Parse the `info.toml` file in the current directory. + pub fn parse() -> Result<Self> { + // Read a local `info.toml` if it exists. + let slf = match fs::read_to_string("info.toml") { + Ok(file_content) => toml_edit::de::from_str::<Self>(&file_content) + .context("Failed to parse the `info.toml` file")?, + Err(e) => { + if e.kind() == ErrorKind::NotFound { + return toml_edit::de::from_str(EMBEDDED_FILES.info_file) + .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}"); + } + + 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 new file mode 100644 index 0000000..4063ca7 --- /dev/null +++ b/src/init.rs @@ -0,0 +1,116 @@ +use anyhow::{bail, Context, Result}; +use crossterm::style::Stylize; +use std::{ + env::set_current_dir, + fs::{self, create_dir}, + io::ErrorKind, + path::Path, + process::{Command, Stdio}, +}; + +use crate::{cargo_toml::updated_cargo_toml, embedded::EMBEDDED_FILES, info_file::InfoFile}; + +pub fn init() -> Result<()> { + // Prevent initialization in a directory that contains the file `Cargo.toml`. + // This can mean that Rustlings was already initialized in this directory. + // Otherwise, this can cause problems with Cargo workspaces. + if Path::new("Cargo.toml").exists() { + bail!(CARGO_TOML_EXISTS_ERR); + } + + let rustlings_path = Path::new("rustlings"); + if let Err(e) = create_dir(rustlings_path) { + if e.kind() == ErrorKind::AlreadyExists { + bail!(RUSTLINGS_DIR_ALREADY_EXISTS_ERR); + } + return Err(e.into()); + } + + set_current_dir("rustlings") + .context("Failed to change the current directory to `rustlings/`")?; + + let info_file = InfoFile::parse()?; + EMBEDDED_FILES + .init_exercises_dir(&info_file.exercises) + .context("Failed to initialize the `rustlings/exercises` directory")?; + + create_dir("solutions").context("Failed to create the `solutions/` directory")?; + for dir in EMBEDDED_FILES.exercise_dirs { + let mut dir_path = String::with_capacity(10 + dir.name.len()); + dir_path.push_str("solutions/"); + dir_path.push_str(dir.name); + create_dir(&dir_path) + .with_context(|| format!("Failed to create the directory {dir_path}"))?; + } + for exercise_info in &info_file.exercises { + let solution_path = exercise_info.sol_path(); + fs::write(&solution_path, INIT_SOLUTION_FILE) + .with_context(|| format!("Failed to create the file {solution_path}"))?; + } + + let current_cargo_toml = include_str!("../dev-Cargo.toml"); + // Skip the first line (comment). + let newline_ind = current_cargo_toml + .as_bytes() + .iter() + .position(|c| *c == b'\n') + .context("The embedded `Cargo.toml` is empty or contains only one line")?; + let current_cargo_toml = current_cargo_toml + .get(newline_ind + 1..) + .context("The embedded `Cargo.toml` contains only one line")?; + let updated_cargo_toml = updated_cargo_toml(&info_file.exercises, current_cargo_toml, b"") + .context("Failed to generate `Cargo.toml`")?; + fs::write("Cargo.toml", updated_cargo_toml) + .context("Failed to create the file `rustlings/Cargo.toml`")?; + + fs::write(".gitignore", GITIGNORE) + .context("Failed to create the file `rustlings/.gitignore`")?; + + create_dir(".vscode").context("Failed to create the directory `rustlings/.vscode`")?; + fs::write(".vscode/extensions.json", VS_CODE_EXTENSIONS_JSON) + .context("Failed to create the file `rustlings/.vscode/extensions.json`")?; + + // Ignore any Git error because Git initialization is not required. + let _ = Command::new("git") + .arg("init") + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .status(); + + println!( + "\n{}\n\n{}", + "Initialization done ✓".green(), + POST_INIT_MSG.bold(), + ); + + Ok(()) +} + +const INIT_SOLUTION_FILE: &[u8] = b"fn main() { + // DON'T EDIT THIS SOLUTION FILE! + // It will be automatically filled after you finish the exercise. +} +"; + +const GITIGNORE: &[u8] = b".rustlings-state.txt +solutions +Cargo.lock +target +.vscode +"; + +pub const VS_CODE_EXTENSIONS_JSON: &[u8] = br#"{"recommendations":["rust-lang.rust-analyzer"]}"#; + +const CARGO_TOML_EXISTS_ERR: &str = "The current directory contains the file `Cargo.toml`. + +If you already initialized Rustlings, run the command `rustlings` for instructions on getting started with the exercises. +Otherwise, please run `rustlings init` again 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"; + +const POST_INIT_MSG: &str = "Run `cd rustlings` to go into the generated directory. +Then run `rustlings` to get started."; diff --git a/src/list.rs b/src/list.rs new file mode 100644 index 0000000..790c02f --- /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; + +use crate::app_state::AppState; + +use self::state::{Filter, UiState}; + +mod state; + +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..d6df634 --- /dev/null +++ b/src/list/state.rs @@ -0,0 +1,271 @@ +use anyhow::{Context, Result}; +use ratatui::{ + layout::{Constraint, Rect}, + style::{Style, Stylize}, + text::{Line, 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)); + } + } + + pub fn select_first(&mut self) { + if self.n_rows > 0 { + self.table_state.select(Some(0)); + } + } + + 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. + let mut spans = Vec::with_capacity(4); + spans.push(Span::raw( + "↓/j ↑/k home/g end/G │ <c>ontinue at │ <r>eset │ filter ", + )); + match self.filter { + Filter::Done => { + spans.push("<d>one".underlined().magenta()); + spans.push(Span::raw("/<p>ending")); + } + Filter::Pending => { + spans.push(Span::raw("<d>one/")); + spans.push("<p>ending".underlined().magenta()); + } + Filter::None => spans.push(Span::raw("<d>one/<p>ending")), + } + spans.push(Span::raw(" │ <q>uit")); + Line::from(spans) + } else { + Line::from(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 = 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")?; + + let exercise_path = self.app_state.reset_exercise_by_ind(ind)?; + write!(self.message, "The exercise {exercise_path} has been reset")?; + + 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 bbff712..2233d8b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,471 +1,220 @@ -use crate::exercise::{Exercise, ExerciseList}; -use crate::project::RustAnalyzerProject; -use crate::run::{reset, run}; -use crate::verify::verify; +use anyhow::{bail, Context, Result}; +use app_state::StateFileStatus; use clap::{Parser, Subcommand}; -use console::Emoji; -use notify::DebouncedEvent; -use notify::{RecommendedWatcher, RecursiveMode, Watcher}; -use std::ffi::OsStr; -use std::fs; -use std::io::{self, prelude::*}; -use std::path::Path; -use std::process::{Command, Stdio}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::mpsc::{channel, RecvTimeoutError}; -use std::sync::{Arc, Mutex}; -use std::thread; -use std::time::Duration; - -#[macro_use] -mod ui; - +use std::{ + io::{self, BufRead, StdoutLock, Write}, + path::Path, + process::exit, +}; + +use self::{app_state::AppState, dev::DevCommands, info_file::InfoFile, watch::WatchExit}; + +mod app_state; +mod cargo_toml; +mod cmd; +mod dev; +mod embedded; mod exercise; -mod project; +mod info_file; +mod init; +mod list; +mod progress_bar; mod run; -mod verify; +mod terminal_link; +mod watch; + +const CURRENT_FORMAT_VERSION: u8 = 1; +const DEBUG_PROFILE: bool = { + #[allow(unused_assignments, unused_mut)] + let mut debug_profile = false; + + #[cfg(debug_assertions)] + { + debug_profile = true; + } + + debug_profile +}; + +// The current directory is the official Rustligns repository. +fn in_official_repo() -> bool { + Path::new("dev/rustlings-repo.txt").exists() +} + +fn clear_terminal(stdout: &mut StdoutLock) -> io::Result<()> { + stdout.write_all(b"\x1b[H\x1b[2J\x1b[3J") +} + +fn press_enter_prompt() -> io::Result<()> { + io::stdin().lock().read_until(b'\n', &mut Vec::new())?; + Ok(()) +} /// 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` in the watch mode. + /// Only use this if Rustlings fails to detect exercise file changes. + #[arg(long)] + manual_run: bool, } #[derive(Subcommand)] enum Subcommands { - /// 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 + /// Initialize the official Rustlings exercises + Init, + /// 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 using "git stash -- <filename>" + /// Reset a single exercise Reset { /// The name of the exercise name: String, }, - /// Return a hint for the given exercise + /// Show a hint. Shows the hint of the next pending exercise if the exercise name is not specified Hint { /// The name of the exercise - name: String, + name: Option<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, - }, - /// Enable rust-analyzer for exercises - Lsp, + /// Commands for developing (third-party) Rustlings exercises + #[command(subcommand)] + Dev(DevCommands), } -fn main() { +fn main() -> Result<()> { let args = Args::parse(); - if args.command.is_none() { - println!("\n{WELCOME}\n"); - } - - if !Path::new("info.toml").exists() { - println!( - "{} must be run from the rustlings directory", - std::env::current_exe().unwrap().to_str().unwrap() - ); - println!("Try `cd rustlings/`!"); - std::process::exit(1); + if !DEBUG_PROFILE && in_official_repo() { + bail!("{OLD_METHOD_ERR}"); } - if !rustc_exists() { - println!("We cannot find `rustc`."); - println!("Try running `rustc --version` to diagnose your problem."); - println!("For instructions on how to install Rust, check the README."); - std::process::exit(1); - } - - let toml_str = &fs::read_to_string("info.toml").unwrap(); - let exercises = toml::from_str::<ExerciseList>(toml_str).unwrap().exercises; - let verbose = args.nocapture; - - let command = args.command.unwrap_or_else(|| { - println!("{DEFAULT_OUT}\n"); - std::process::exit(0); - }); - - match command { - Subcommands::List { - paths, - names, - filter, - unsolved, - solved, - } => { - if !paths && !names { - println!("{:<17}\t{:<46}\t{:<7}", "Name", "Path", "Status"); + match args.command { + Some(Subcommands::Init) => { + if DEBUG_PROFILE { + bail!("Disabled in the debug build"); } - let mut exercises_done: u16 = 0; - let filters = filter.clone().unwrap_or_default().to_lowercase(); - exercises.iter().for_each(|e| { - let fname = format!("{}", e.path.display()); - let filter_cond = filters - .split(',') - .filter(|f| !f.trim().is_empty()) - .any(|f| e.name.contains(f) || fname.contains(f)); - let status = if e.looks_done() { - exercises_done += 1; - "Done" - } else { - "Pending" - }; - let solve_cond = { - (e.looks_done() && solved) - || (!e.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", e.name) - } else { - format!("{:<17}\t{fname:<46}\t{status:<7}\n", e.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 => std::process::exit(0), - _ => std::process::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 - ); - std::process::exit(0); - } - - Subcommands::Run { name } => { - let exercise = find_exercise(&name, &exercises); - - run(exercise, verbose).unwrap_or_else(|_| std::process::exit(1)); - } - Subcommands::Reset { name } => { - let exercise = find_exercise(&name, &exercises); + { + let mut stdout = io::stdout().lock(); + stdout.write_all(b"This command will create the directory `rustlings/` which will contain the exercises.\nPress ENTER to continue ")?; + stdout.flush()?; + press_enter_prompt()?; + stdout.write_all(b"\n")?; + } - reset(exercise).unwrap_or_else(|_| std::process::exit(1)); + return init::init().context("Initialization failed"); } + Some(Subcommands::Dev(dev_command)) => return dev_command.run(), + _ => (), + } - Subcommands::Hint { name } => { - let exercise = find_exercise(&name, &exercises); - - println!("{}", exercise.hint); - } + if !Path::new("exercises").is_dir() { + println!("{PRE_INIT_MSG}"); + exit(1); + } - Subcommands::Verify => { - verify(&exercises, (0, exercises.len()), verbose, false) - .unwrap_or_else(|_| std::process::exit(1)); - } + let info_file = InfoFile::parse()?; - Subcommands::Lsp => { - let mut project = RustAnalyzerProject::new(); - project - .get_sysroot_src() - .expect("Couldn't find toolchain path, do you have `rustc` installed?"); - project - .exercises_to_json() - .expect("Couldn't parse rustlings exercises files"); + if info_file.format_version > CURRENT_FORMAT_VERSION { + bail!(FORMAT_VERSION_HIGHER_ERR); + } - if project.crates.is_empty() { - println!("Failed find any exercises, make sure you're in the `rustlings` folder"); - } else if project.write_to_disk().is_err() { - println!("Failed to write rust-project.json to disk for rust-analyzer"); - } else { - println!("Successfully generated rust-project.json"); - println!("rust-analyzer will now parse exercises, restart your language server or editor") + let (mut app_state, state_file_status) = AppState::new( + info_file.exercises, + info_file.final_message.unwrap_or_default(), + )?; + + // Show the welcome message if the state file doesn't exist yet. + if let Some(welcome_message) = info_file.welcome_message { + match state_file_status { + StateFileStatus::NotRead => { + let mut stdout = io::stdout().lock(); + clear_terminal(&mut stdout)?; + + let welcome_message = welcome_message.trim(); + write!(stdout, "{welcome_message}\n\nPress ENTER to continue ")?; + stdout.flush()?; + press_enter_prompt()?; + clear_terminal(&mut stdout)?; } + StateFileStatus::Read => (), } - - 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."); - std::process::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"); - } - }, } -} -fn spawn_watch_shell( - failed_exercise_hint: &Arc<Mutex<Option<String>>>, - should_quit: Arc<AtomicBool>, -) { - let failed_exercise_hint = Arc::clone(failed_exercise_hint); - println!("Welcome to watch mode! You can type 'help' to get an overview of the commands you can use here."); - thread::spawn(move || loop { - let mut input = String::new(); - match io::stdin().read_line(&mut input) { - Ok(_) => { - 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.eq("quit") { - should_quit.store(true, Ordering::SeqCst); - println!("Bye!"); - } else if input.eq("help") { - println!("Commands available to you in watch mode:"); - println!(" hint - prints the current exercise's hint"); - println!(" clear - clears the screen"); - println!(" quit - quits watch mode"); - println!(" !<cmd> - executes a command, like `!rustc --explain E0381`"); - println!(" help - displays this help message"); - println!(); - println!("Watch mode automatically re-evaluates the current exercise"); - println!("when you edit a file's contents.") - } else if let Some(cmd) = input.strip_prefix('!') { - let parts: Vec<&str> = cmd.split_whitespace().collect(); - if parts.is_empty() { - println!("no command provided"); - } else if let Err(e) = Command::new(parts[0]).args(&parts[1..]).status() { - println!("failed to execute command `{}`: {}", cmd, e); - } - } else { - println!("unknown command: {input}"); + match args.command { + None => { + let notify_exercise_names = if args.manual_run { + None + } else { + // For 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.name.as_bytes()) + .collect::<Vec<_>>() + .leak(), + ) + }; + + loop { + match watch::watch(&mut app_state, notify_exercise_names)? { + 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::list(&mut app_state)?, } } - Err(error) => println!("error reading command: {error}"), } - }); -} - -fn find_exercise<'a>(name: &str, exercises: &'a [Exercise]) -> &'a Exercise { - if name.eq("next") { - exercises - .iter() - .find(|e| !e.looks_done()) - .unwrap_or_else(|| { - println!("🎉 Congratulations! You have done all the exercises!"); - println!("🔚 There are no more exercises to do next!"); - std::process::exit(1) - }) - } else { - exercises - .iter() - .find(|e| e.name == name) - .unwrap_or_else(|| { - println!("No exercise found for '{name}'!"); - std::process::exit(1) - }) - } -} - -enum WatchStatus { - Finished, - Unfinished, -} - -fn watch( - exercises: &[Exercise], - verbose: bool, - success_hints: bool, -) -> notify::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 watcher: RecommendedWatcher = Watcher::new(tx, Duration::from_secs(1))?; - watcher.watch(Path::new("./exercises"), RecursiveMode::Recursive)?; - - clear_screen(); - - let to_owned_hint = |t: &Exercise| t.hint.to_owned(); - let failed_exercise_hint = match verify( - exercises.iter(), - (0, exercises.len()), - verbose, - success_hints, - ) { - Ok(_) => return Ok(WatchStatus::Finished), - Err(exercise) => Arc::new(Mutex::new(Some(to_owned_hint(exercise)))), - }; - spawn_watch_shell(&failed_exercise_hint, Arc::clone(&should_quit)); - loop { - match rx.recv_timeout(Duration::from_secs(1)) { - Ok(event) => match event { - DebouncedEvent::Create(b) | DebouncedEvent::Chmod(b) | DebouncedEvent::Write(b) => { - if b.extension() == Some(OsStr::new("rs")) && b.exists() { - let filepath = b.as_path().canonicalize().unwrap(); - let pending_exercises = exercises - .iter() - .find(|e| filepath.ends_with(&e.path)) - .into_iter() - .chain( - exercises - .iter() - .filter(|e| !e.looks_done() && !filepath.ends_with(&e.path)), - ); - let num_done = exercises - .iter() - .filter(|e| e.looks_done() && !filepath.ends_with(&e.path)) - .count(); - clear_screen(); - match verify( - pending_exercises, - (num_done, exercises.len()), - verbose, - success_hints, - ) { - Ok(_) => return Ok(WatchStatus::Finished), - Err(exercise) => { - let mut failed_exercise_hint = failed_exercise_hint.lock().unwrap(); - *failed_exercise_hint = Some(to_owned_hint(exercise)); - } - } - } - } - _ => {} - }, - Err(RecvTimeoutError::Timeout) => { - // the timeout expired, just check the `should_quit` variable below then loop again + Some(Subcommands::Run { name }) => { + if let Some(name) = name { + app_state.set_current_exercise_by_name(&name)?; } - Err(e) => println!("watch error: {e:?}"), + run::run(&mut app_state)?; + } + Some(Subcommands::Reset { name }) => { + app_state.set_current_exercise_by_name(&name)?; + let exercise_path = app_state.reset_current_exercise()?; + println!("The exercise {exercise_path} has been reset"); } - // Check if we need to exit - if should_quit.load(Ordering::SeqCst) { - return Ok(WatchStatus::Unfinished); + Some(Subcommands::Hint { name }) => { + if let Some(name) = name { + app_state.set_current_exercise_by_name(&name)?; + } + println!("{}", app_state.current_exercise().hint); } + // Handled in an earlier match. + Some(Subcommands::Init | Subcommands::Dev(_)) => (), } -} -fn rustc_exists() -> bool { - Command::new("rustc") - .args(["--version"]) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .stdin(Stdio::null()) - .spawn() - .and_then(|mut child| child.wait()) - .map(|status| status.success()) - .unwrap_or(false) + Ok(()) } -const DEFAULT_OUT: &str = r#"Thanks for installing Rustlings! - -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: - -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! -5. If you want to use `rust-analyzer` with exercises, which provides features like - autocompletion, run the command `rustlings lsp`. +const OLD_METHOD_ERR: &str = "You are trying to run Rustlings using the old method before v6. +The new method doesn't include cloning the Rustlings' repository. +Please follow the instructions in the README: +https://github.com/rust-lang/rustlings#getting-started"; -Got all that? Great! To get started, run `rustlings watch` in order to get the first -exercise. Make sure to have your editor open!"#; +const FORMAT_VERSION_HIGHER_ERR: &str = + "The format version specified in the `info.toml` file is higher than the last one supported. +It is possible that you have an outdated version of Rustlings. +Try to install the latest Rustlings version first."; -const FENISH_LINE: &str = r"+----------------------------------------------------+ -| You made it to the Fe-nish line! | -+-------------------------- ------------------------+ - \\/ - ▒▒ ▒▒▒▒▒▒▒▒ ▒▒▒▒▒▒▒▒ ▒▒ - ▒▒▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒▒▒ - ▒▒▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒▒▒ - ░░▒▒▒▒░░▒▒ ▒▒ ▒▒ ▒▒ ▒▒░░▒▒▒▒ - ▓▓▓▓▓▓▓▓ ▓▓ ▓▓██ ▓▓ ▓▓██ ▓▓ ▓▓▓▓▓▓▓▓ - ▒▒▒▒ ▒▒ ████ ▒▒ ████ ▒▒░░ ▒▒▒▒ - ▒▒ ▒▒▒▒▒▒ ▒▒▒▒▒▒ ▒▒▒▒▒▒ ▒▒ - ▒▒▒▒▒▒▒▒▒▒▓▓▓▓▓▓▒▒▒▒▒▒▒▒▓▓▒▒▓▓▒▒▒▒▒▒▒▒ - ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ - ▒▒▒▒▒▒▒▒▒▒██▒▒▒▒▒▒██▒▒▒▒▒▒▒▒▒▒ - ▒▒ ▒▒▒▒▒▒▒▒▒▒██████▒▒▒▒▒▒▒▒▒▒ ▒▒ - ▒▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ▒▒ - ▒▒ ▒▒ ▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒▒ ▒▒ ▒▒ - ▒▒ ▒▒ ▒▒ ▒▒ ▒▒ ▒▒ - ▒▒ ▒▒ ▒▒ ▒▒ - -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"; - -const WELCOME: &str = r" welcome to... +const PRE_INIT_MSG: &str = r" + Welcome to... _ _ _ _ __ _ _ ___| |_| (_)_ __ __ _ ___ | '__| | | / __| __| | | '_ \ / _` / __| | | | |_| \__ \ |_| | | | | | (_| \__ \ |_| \__,_|___/\__|_|_|_| |_|\__, |___/ - |___/"; + |___/ + +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."; diff --git a/src/progress_bar.rs b/src/progress_bar.rs new file mode 100644 index 0000000..4a54170 --- /dev/null +++ b/src/progress_bar.rs @@ -0,0 +1,100 @@ +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"; + +/// Terminal progress bar to be used when not using Ratataui. +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) +} + +/// Progress bar to be used with Ratataui. +// Not using Ratatui's Gauge widget to keep the progress bar consistent. +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)) +} diff --git a/src/project.rs b/src/project.rs deleted file mode 100644 index bcbd7ad..0000000 --- a/src/project.rs +++ /dev/null @@ -1,99 +0,0 @@ -use glob::glob; -use serde::{Deserialize, Serialize}; -use std::env; -use std::error::Error; -use std::path::PathBuf; -use std::process::Command; - -/// Contains the structure of resulting rust-project.json file -/// and functions to build the data required to create the file -#[derive(Serialize, Deserialize)] -pub struct RustAnalyzerProject { - sysroot_src: String, - pub crates: Vec<Crate>, -} - -#[derive(Serialize, Deserialize)] -pub struct Crate { - root_module: String, - edition: String, - deps: Vec<String>, - cfg: Vec<String>, -} - -impl RustAnalyzerProject { - pub fn new() -> RustAnalyzerProject { - RustAnalyzerProject { - sysroot_src: String::new(), - crates: Vec::new(), - } - } - - /// Write rust-project.json to disk - pub fn write_to_disk(&self) -> Result<(), std::io::Error> { - std::fs::write( - "./rust-project.json", - serde_json::to_vec(&self).expect("Failed to serialize to JSON"), - )?; - Ok(()) - } - - /// If path contains .rs extension, add a crate to `rust-project.json` - fn path_to_json(&mut self, path: PathBuf) -> Result<(), Box<dyn Error>> { - if let Some(ext) = path.extension() { - if ext == "rs" { - self.crates.push(Crate { - root_module: path.display().to_string(), - edition: "2021".to_string(), - deps: Vec::new(), - // This allows rust_analyzer to work inside #[test] blocks - cfg: vec!["test".to_string()], - }) - } - } - - Ok(()) - } - - /// Parse the exercises folder for .rs files, any matches will create - /// a new `crate` in rust-project.json which allows rust-analyzer to - /// treat it like a normal binary - pub fn exercises_to_json(&mut self) -> Result<(), Box<dyn Error>> { - for path in glob("./exercises/**/*")? { - self.path_to_json(path?)?; - } - Ok(()) - } - - /// Use `rustc` to determine the default toolchain - pub fn get_sysroot_src(&mut self) -> Result<(), Box<dyn Error>> { - // check if RUST_SRC_PATH is set - if let Ok(path) = env::var("RUST_SRC_PATH") { - self.sysroot_src = path; - return Ok(()); - } - - let toolchain = Command::new("rustc") - .arg("--print") - .arg("sysroot") - .output()? - .stdout; - - let toolchain = String::from_utf8_lossy(&toolchain); - let mut whitespace_iter = toolchain.split_whitespace(); - - let toolchain = whitespace_iter.next().unwrap_or(&toolchain); - - println!("Determined toolchain: {}\n", &toolchain); - - self.sysroot_src = (std::path::Path::new(toolchain) - .join("lib") - .join("rustlib") - .join("src") - .join("rust") - .join("library") - .to_string_lossy()) - .to_string(); - Ok(()) - } -} @@ -1,74 +1,51 @@ -use std::process::Command; -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 => compile_and_run(exercise)?, - Mode::Clippy => compile_and_run(exercise)?, +use anyhow::{bail, Result}; +use crossterm::style::{style, Stylize}; +use std::io::{self, Write}; + +use crate::{ + app_state::{AppState, ExercisesProgress}, + exercise::{RunnableExercise, OUTPUT_CAPACITY}, + terminal_link::TerminalFileLink, +}; + +pub fn run(app_state: &mut AppState) -> Result<()> { + let exercise = app_state.current_exercise(); + let mut output = Vec::with_capacity(OUTPUT_CAPACITY); + let success = exercise.run_exercise(&mut output, app_state.target_dir())?; + + let mut stdout = io::stdout().lock(); + stdout.write_all(&output)?; + + if !success { + app_state.set_pending(app_state.current_exercise_ind())?; + + bail!( + "Ran {} with errors", + app_state.current_exercise().terminal_link(), + ); } - Ok(()) -} - -// Resets the exercise by stashing the changes. -pub fn reset(exercise: &Exercise) -> Result<(), ()> { - let command = Command::new("git") - .args(["stash", "--"]) - .arg(&exercise.path) - .spawn(); - match command { - Ok(_) => Ok(()), - Err(_) => Err(()), + writeln!( + stdout, + "{}{}", + "✓ Successfully ran ".green(), + exercise.path.green(), + )?; + + if let Some(solution_path) = app_state.current_solution_path()? { + println!( + "\nA solution file can be found at {}\n", + style(TerminalFileLink(&solution_path)).underlined().green(), + ); } -} -// Invoke the rust compiler on the path of the given exercise -// and run the ensuing binary. -// 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!("Compiling {exercise}...")); - progress_bar.enable_steady_tick(Duration::from_millis(100)); - - let compilation_result = exercise.compile(); - let compilation = match compilation_result { - Ok(compilation) => compilation, - Err(output) => { - progress_bar.finish_and_clear(); - warn!( - "Compilation of {} failed!, Compiler error message:\n", - exercise - ); - println!("{}", output.stderr); - return Err(()); - } - }; - - progress_bar.set_message(format!("Running {exercise}...")); - let result = compilation.run(); - progress_bar.finish_and_clear(); - - match result { - Ok(output) => { - println!("{}", output.stdout); - success!("Successfully ran {}", exercise); - Ok(()) - } - Err(output) => { - println!("{}", output.stdout); - println!("{}", output.stderr); - - warn!("Ran {} with errors", exercise); - Err(()) - } + match app_state.done_current_exercise(&mut stdout)? { + ExercisesProgress::AllDone => (), + ExercisesProgress::CurrentPending | ExercisesProgress::NewPending => println!( + "Next exercise: {}", + app_state.current_exercise().terminal_link(), + ), } + + Ok(()) } diff --git a/src/terminal_link.rs b/src/terminal_link.rs new file mode 100644 index 0000000..9bea07d --- /dev/null +++ b/src/terminal_link.rs @@ -0,0 +1,26 @@ +use std::{ + fmt::{self, Display, Formatter}, + fs, +}; + +pub struct TerminalFileLink<'a>(pub &'a str); + +impl<'a> Display for TerminalFileLink<'a> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + let path = fs::canonicalize(self.0); + + if let Some(path) = path.as_deref().ok().and_then(|path| path.to_str()) { + // Windows itself can't handle its verbatim paths. + #[cfg(windows)] + let path = if path.len() > 5 && &path[0..4] == r"\\?\" { + &path[4..] + } else { + path + }; + + write!(f, "\x1b]8;;file://{path}\x1b\\{}\x1b]8;;\x1b\\", self.0) + } else { + write!(f, "{}", self.0) + } + } +} diff --git a/src/ui.rs b/src/ui.rs deleted file mode 100644 index 1ee4631..0000000 --- a/src/ui.rs +++ /dev/null @@ -1,33 +0,0 @@ -macro_rules! warn { - ($fmt:literal, $ex:expr) => {{ - use console::{style, Emoji}; - use std::env; - let formatstr = format!($fmt, $ex); - if env::var("NO_EMOJI").is_ok() { - println!("{} {}", style("!").red(), style(formatstr).red()); - } else { - println!( - "{} {}", - style(Emoji("⚠️ ", "!")).red(), - style(formatstr).red() - ); - } - }}; -} - -macro_rules! success { - ($fmt:literal, $ex:expr) => {{ - use console::{style, Emoji}; - use std::env; - let formatstr = format!($fmt, $ex); - if env::var("NO_EMOJI").is_ok() { - println!("{} {}", style("✓").green(), style(formatstr).green()); - } else { - println!( - "{} {}", - style(Emoji("✅", "✓")).green(), - style(formatstr).green() - ); - } - }}; -} diff --git a/src/verify.rs b/src/verify.rs deleted file mode 100644 index 8a2ad49..0000000 --- a/src/verify.rs +++ /dev/null @@ -1,234 +0,0 @@ -use crate::exercise::{CompiledExercise, Exercise, Mode, State}; -use console::style; -use indicatif::{ProgressBar, ProgressStyle}; -use std::{env, time::Duration}; - -// 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>( - exercises: impl IntoIterator<Item = &'a Exercise>, - progress: (usize, usize), - verbose: bool, - success_hints: bool, -) -> Result<(), &'a Exercise> { - 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!("({:.1} %)", percentage)); - - for exercise in 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.unwrap_or(false) { - return Err(exercise); - } - percentage += 100.0 / total as f32; - bar.inc(1); - bar.set_message(format!("({:.1} %)", percentage)); - } - Ok(()) -} - -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 _ = compile(exercise, &progress_bar)?; - progress_bar.finish_and_clear(); - - Ok(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!("Compiling {exercise}...")); - progress_bar.enable_steady_tick(Duration::from_millis(100)); - - let compilation = compile(exercise, &progress_bar)?; - - progress_bar.set_message(format!("Running {exercise}...")); - let result = compilation.run(); - progress_bar.finish_and_clear(); - - let output = match result { - Ok(output) => output, - Err(output) => { - warn!("Ran {} with errors", exercise); - println!("{}", output.stdout); - println!("{}", output.stderr); - return Err(()); - } - }; - - Ok(prompt_for_completion( - exercise, - Some(output.stdout), - 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 compilation = compile(exercise, &progress_bar)?; - let result = compilation.run(); - progress_bar.finish_and_clear(); - - match result { - Ok(output) => { - if verbose { - println!("{}", output.stdout); - } - if let RunMode::Interactive = run_mode { - Ok(prompt_for_completion(exercise, None, success_hints)) - } else { - Ok(true) - } - } - Err(output) => { - warn!( - "Testing of {} failed! Please try again. Here's the output:", - exercise - ); - println!("{}", output.stdout); - Err(()) - } - } -} - -// Compile the given Exercise and return an object with information -// about the state of the compilation -fn compile<'a>( - exercise: &'a Exercise, - progress_bar: &ProgressBar, -) -> Result<CompiledExercise<'a>, ()> { - let compilation_result = exercise.compile(); - - match compilation_result { - Ok(compilation) => Ok(compilation), - Err(output) => { - progress_bar.finish_and_clear(); - warn!( - "Compiling of {} failed! Please try again. Here's the output:", - exercise - ); - println!("{}", output.stderr); - Err(()) - } - } -} - -fn prompt_for_completion( - exercise: &Exercise, - prompt_output: Option<String>, - success_hints: bool, -) -> bool { - let context = match exercise.state() { - State::Done => return 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, - }; - println!(); - if no_emoji { - println!("~*~ {success_msg} ~*~") - } else { - println!("🎉 🎉 {success_msg} 🎉 🎉") - } - println!(); - - if let Some(output) = prompt_output { - println!("Output:"); - println!("{}", separator()); - println!("{output}"); - println!("{}", separator()); - println!(); - } - if success_hints { - println!("Hints:"); - println!("{}", separator()); - println!("{}", exercise.hint); - println!("{}", separator()); - println!(); - } - - 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.to_string() - }; - - println!( - "{:>2} {} {}", - style(context_line.number).blue().bold(), - style("|").blue(), - formatted_line - ); - } - - 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..88a1230 --- /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, +}; + +use crate::app_state::{AppState, ExercisesProgress}; + +use self::{ + notify_event::NotifyEventHandler, + state::WatchState, + terminal_event::{terminal_event_handler, InputEvent}, +}; + +mod notify_event; +mod state; +mod terminal_event; + +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, +} + +/// `notify_exercise_names` as None activates the manual run mode. +pub fn watch( + app_state: &mut AppState, + notify_exercise_names: Option<&'static [&'static [u8]]>, +) -> 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_names) = notify_exercise_names { + let mut debouncer = new_debouncer( + Duration::from_millis(200), + NotifyEventHandler { + tx: tx.clone(), + exercise_names, + }, + ) + .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::CurrentPending => watch_state.render()?, + ExercisesProgress::NewPending => 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) => watch_state.render()?, + WatchEvent::FileChange { exercise_ind } => { + watch_state.handle_file_change(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` then. +"; diff --git a/src/watch/notify_event.rs b/src/watch/notify_event.rs new file mode 100644 index 0000000..7471640 --- /dev/null +++ b/src/watch/notify_event.rs @@ -0,0 +1,52 @@ +use notify_debouncer_mini::{DebounceEventResult, DebouncedEventKind}; +use std::sync::mpsc::Sender; + +use super::WatchEvent; + +pub struct NotifyEventHandler { + pub tx: Sender<WatchEvent>, + /// Used to report which exercise was modified. + pub exercise_names: &'static [&'static [u8]], +} + +impl notify_debouncer_mini::DebounceEventHandler for NotifyEventHandler { + fn handle_event(&mut self, input_event: DebounceEventResult) { + let output_event = match input_event { + Ok(input_event) => { + let Some(exercise_ind) = input_event + .iter() + .filter_map(|input_event| { + if input_event.kind != DebouncedEventKind::Any { + return None; + } + + let file_name = input_event.path.file_name()?.to_str()?.as_bytes(); + + if file_name.len() < 4 { + return None; + } + let (file_name_without_ext, ext) = file_name.split_at(file_name.len() - 3); + + if ext != b".rs" { + return None; + } + + self.exercise_names + .iter() + .position(|exercise_name| *exercise_name == file_name_without_ext) + }) + .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(output_event); + } +} diff --git a/src/watch/state.rs b/src/watch/state.rs new file mode 100644 index 0000000..78af30a --- /dev/null +++ b/src/watch/state.rs @@ -0,0 +1,174 @@ +use anyhow::Result; +use crossterm::{ + style::{style, Stylize}, + terminal, +}; +use std::io::{self, StdoutLock, Write}; + +use crate::{ + app_state::{AppState, ExercisesProgress}, + clear_terminal, + exercise::{RunnableExercise, OUTPUT_CAPACITY}, + progress_bar::progress_bar, + terminal_link::TerminalFileLink, +}; + +#[derive(PartialEq, Eq)] +enum DoneStatus { + DoneWithSolution(String), + DoneWithoutSolution, + Pending, +} + +pub struct WatchState<'a> { + writer: StdoutLock<'a>, + app_state: &'a mut AppState, + output: Vec<u8>, + show_hint: bool, + done_status: DoneStatus, + 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, + output: Vec::with_capacity(OUTPUT_CAPACITY), + show_hint: false, + done_status: DoneStatus::Pending, + 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 success = self + .app_state + .current_exercise() + .run_exercise(&mut self.output, self.app_state.target_dir())?; + if success { + self.done_status = + if let Some(solution_path) = self.app_state.current_solution_path()? { + DoneStatus::DoneWithSolution(solution_path) + } else { + DoneStatus::DoneWithoutSolution + }; + } else { + self.app_state + .set_pending(self.app_state.current_exercise_ind())?; + + self.done_status = DoneStatus::Pending; + } + + self.render() + } + + pub fn handle_file_change(&mut self, exercise_ind: usize) -> Result<()> { + // Don't skip exercises on file changes to avoid confusion from missing exercises. + // Skipping exercises must be explicit in the interactive list. + // But going back to an earlier exercise on file change is fine. + if self.app_state.current_exercise_ind() < exercise_ind { + return Ok(()); + } + + self.app_state.set_current_exercise_ind(exercise_ind)?; + self.run_current_exercise() + } + + /// Move on to the next exercise if the current one is done. + pub fn next_exercise(&mut self) -> Result<ExercisesProgress> { + if self.done_status == DoneStatus::Pending { + return Ok(ExercisesProgress::CurrentPending); + } + + 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 { + write!(self.writer, "{}:run / ", 'r'.bold())?; + } + + if self.done_status != DoneStatus::Pending { + write!(self.writer, "{}:{} / ", 'n'.bold(), "next".underlined())?; + } + + if !self.show_hint { + write!(self.writer, "{}:hint / ", 'h'.bold())?; + } + + write!(self.writer, "{}:list / {}:quit ? ", 'l'.bold(), 'q'.bold())?; + + self.writer.flush() + } + + pub fn render(&mut self) -> Result<()> { + // Prevent having the first line shifted if clearing wasn't successful. + self.writer.write_all(b"\n")?; + + clear_terminal(&mut self.writer)?; + + self.writer.write_all(&self.output)?; + self.writer.write_all(b"\n")?; + + if self.show_hint { + writeln!( + self.writer, + "{}\n{}\n", + "Hint".bold().cyan().underlined(), + self.app_state.current_exercise().hint, + )?; + } + + if self.done_status != DoneStatus::Pending { + writeln!( + self.writer, + "{}\n", + "Exercise done ✓ +When you are done experimenting, enter `n` to move on to the next exercise 🦀" + .bold() + .green(), + )?; + } + + if let DoneStatus::DoneWithSolution(solution_path) = &self.done_status { + writeln!( + self.writer, + "A solution file can be found at {}\n", + style(TerminalFileLink(solution_path)).underlined().green(), + )?; + } + + let line_width = terminal::size()?.0; + let progress_bar = progress_bar( + self.app_state.n_done(), + self.app_state.exercises().len() as u16, + line_width, + )?; + writeln!( + self.writer, + "{progress_bar}Current exercise: {}", + self.app_state.current_exercise().terminal_link(), + )?; + + self.show_prompt()?; + + Ok(()) + } + + pub fn show_hint(&mut self) -> Result<()> { + self.show_hint = true; + self.render() + } +} diff --git a/src/watch/terminal_event.rs b/src/watch/terminal_event.rs new file mode 100644 index 0000000..f54af17 --- /dev/null +++ b/src/watch/terminal_event.rs @@ -0,0 +1,86 @@ +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, +} + +pub fn terminal_event_handler(tx: Sender<WatchEvent>, manual_run: bool) { + // Only send `Unrecognized` on ENTER if the last input wasn't valid. + let mut last_input_valid = false; + + 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) => { + match key.kind { + KeyEventKind::Release | KeyEventKind::Repeat => continue, + KeyEventKind::Press => (), + } + + if key.modifiers != KeyModifiers::NONE { + last_input_valid = false; + continue; + } + + let input_event = match key.code { + KeyCode::Enter => { + if last_input_valid { + continue; + } + + InputEvent::Unrecognized + } + KeyCode::Char(c) => { + let input_event = match c { + 'n' => InputEvent::Next, + 'h' => InputEvent::Hint, + 'l' => break InputEvent::List, + 'q' => break InputEvent::Quit, + 'r' if manual_run => InputEvent::Run, + _ => { + last_input_valid = false; + continue; + } + }; + + last_input_valid = true; + input_event + } + _ => { + last_input_valid = false; + continue; + } + }; + + if tx.send(WatchEvent::Input(input_event)).is_err() { + return; + } + } + 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)); +} |
