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