diff --git a/src/main.rs b/src/main.rs index 50517850..e8218efe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,11 @@ -use crate::consts::{DEFAULT_OUT, WELCOME}; +use crate::consts::WELCOME; use crate::embedded::{WriteStrategy, EMBEDDED_FILES}; use crate::exercise::{Exercise, ExerciseList}; use crate::run::run; -use crate::tui::tui; use crate::verify::verify; use anyhow::{bail, Context, Result}; use clap::{Parser, Subcommand}; +use state::State; use std::io::Write; use std::path::Path; use std::process::exit; @@ -17,8 +17,8 @@ mod exercise; mod init; mod run; mod state; -mod tui; mod verify; +mod watch; /// Rustlings is a collection of small exercises to get you used to writing and reading Rust code #[derive(Parser)] @@ -75,10 +75,6 @@ enum Subcommands { 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? @@ -97,16 +93,20 @@ Then run `rustlings` for further instructions on getting started." return Ok(()); } else if !Path::new("exercises").is_dir() { println!( - "\nThe `exercises` directory wasn't found in the current directory. + " +{WELCOME} + +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." ); exit(1); } + let state = State::read_or_default(&exercises); + match args.command { None | Some(Subcommands::Watch) => { - println!("{DEFAULT_OUT}\n"); - tui(&exercises)?; + watch::watch(&state, &exercises)?; } // `Init` is handled above. Some(Subcommands::Init) => (), @@ -199,7 +199,7 @@ If you are just starting with Rustlings, run the command `rustlings init` to ini let exercise = find_exercise(&name, &exercises)?; println!("{}", exercise.hint); } - Some(Subcommands::Verify) => match verify(&exercises, (0, exercises.len()))? { + Some(Subcommands::Verify) => match verify(&exercises, 0)? { VerifyState::AllExercisesDone => println!("All exercises done!"), VerifyState::Failed(exercise) => bail!("Exercise {exercise} failed"), }, diff --git a/src/tui.rs b/src/tui.rs deleted file mode 100644 index bb873652..00000000 --- a/src/tui.rs +++ /dev/null @@ -1,92 +0,0 @@ -use anyhow::Result; -use crossterm::{ - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, - ExecutableCommand, -}; -use notify_debouncer_mini::{new_debouncer, notify::RecursiveMode, DebouncedEventKind}; -use ratatui::{backend::CrosstermBackend, Terminal}; -use std::{ - io::stdout, - path::Path, - sync::mpsc::{channel, RecvTimeoutError}, - time::Duration, -}; - -use crate::{ - exercise::Exercise, - verify::{verify, VerifyState}, -}; - -fn watch(exercises: &[Exercise]) -> Result<()> { - let (tx, rx) = channel(); - - let mut debouncer = new_debouncer(Duration::from_secs(1), tx)?; - debouncer - .watcher() - .watch(Path::new("exercises"), RecursiveMode::Recursive)?; - - let mut failed_exercise_hint = match verify(exercises, (0, exercises.len()))? { - VerifyState::AllExercisesDone => return Ok(()), - VerifyState::Failed(exercise) => Some(&exercise.hint), - }; - - 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(); - - match verify( - pending_exercises.iter().copied(), - (num_done, exercises.len()), - )? { - VerifyState::AllExercisesDone => return Ok(()), - VerifyState::Failed(exercise) => { - failed_exercise_hint = Some(&exercise.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:?}"), - } - - // TODO: Check if we need to exit - } -} - -pub fn tui(exercises: &[Exercise]) -> Result<()> { - let mut stdout = stdout().lock(); - stdout.execute(EnterAlternateScreen)?; - enable_raw_mode()?; - let mut terminal = Terminal::new(CrosstermBackend::new(&mut stdout))?; - terminal.clear()?; - - watch(exercises)?; - - drop(terminal); - stdout.execute(LeaveAlternateScreen)?; - disable_raw_mode()?; - - // TODO - 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"); - - Ok(()) -} diff --git a/src/watch.rs b/src/watch.rs new file mode 100644 index 00000000..92da20dd --- /dev/null +++ b/src/watch.rs @@ -0,0 +1,240 @@ +use anyhow::Result; +use crossterm::{ + style::{Attribute, ContentStyle, Stylize}, + terminal::{Clear, ClearType}, + ExecutableCommand, +}; +use notify_debouncer_mini::{ + new_debouncer, notify::RecursiveMode, DebounceEventResult, DebouncedEventKind, +}; +use std::{ + fmt::Write as _, + io::{self, BufRead, StdoutLock, Write}, + path::Path, + sync::mpsc::{channel, sync_channel, Receiver}, + thread, + time::Duration, +}; + +use crate::{ + exercise::{self, Exercise}, + state::State, +}; + +enum Event { + Hint, + Clear, + Quit, +} + +struct WatchState<'a> { + writer: StdoutLock<'a>, + rx: Receiver, + exercises: &'a [Exercise], + exercise: &'a Exercise, + current_exercise_ind: usize, + stdout: Option>, + stderr: Option>, + message: Option, + prompt: Vec, +} + +impl<'a> WatchState<'a> { + fn run_exercise(&mut self) -> Result { + let output = self.exercise.run()?; + + if !output.status.success() { + self.stdout = Some(output.stdout); + self.stderr = Some(output.stderr); + return Ok(false); + } + + if let exercise::State::Pending(context) = self.exercise.state()? { + let mut message = format!( + " +You can keep working on this exercise or jump into the next one by removing the {} comment: + +", + "`I AM NOT DONE`".bold(), + ); + + for context_line in context { + let formatted_line = if context_line.important { + context_line.line.bold() + } else { + context_line.line.stylize() + }; + + writeln!( + message, + "{:>2} {} {}", + ContentStyle { + foreground_color: Some(crossterm::style::Color::Blue), + background_color: None, + underline_color: None, + attributes: Attribute::Bold.into() + } + .apply(context_line.number), + "|".blue(), + formatted_line, + )?; + } + + self.stdout = Some(output.stdout); + self.message = Some(message); + return Ok(false); + } + + Ok(true) + } + + fn try_recv_event(&mut self) -> Result<()> { + let Ok(events) = self.rx.recv_timeout(Duration::from_millis(100)) else { + return Ok(()); + }; + + if let Some(current_exercise_ind) = events? + .iter() + .filter_map(|event| { + if event.kind != DebouncedEventKind::Any + || !event.path.extension().is_some_and(|ext| ext == "rs") + { + return None; + } + + self.exercises + .iter() + .position(|exercise| event.path.ends_with(&exercise.path)) + }) + .min() + { + self.current_exercise_ind = current_exercise_ind; + } else { + return Ok(()); + }; + + while self.current_exercise_ind < self.exercises.len() { + self.exercise = &self.exercises[self.current_exercise_ind]; + if !self.run_exercise()? { + break; + } + + self.current_exercise_ind += 1; + } + + Ok(()) + } + + fn prompt(&mut self) -> io::Result<()> { + self.writer.write_all(&self.prompt)?; + self.writer.flush() + } + + fn render(&mut self) -> Result<()> { + self.writer.execute(Clear(ClearType::All))?; + + if let Some(stdout) = &self.stdout { + self.writer.write_all(stdout)?; + } + + if let Some(stderr) = &self.stderr { + self.writer.write_all(stderr)?; + } + + if let Some(message) = &self.message { + self.writer.write_all(message.as_bytes())?; + } + + self.prompt()?; + + Ok(()) + } +} + +pub fn watch(state: &State, exercises: &[Exercise]) -> Result<()> { + let (tx, rx) = channel(); + let mut debouncer = new_debouncer(Duration::from_secs(1), tx)?; + debouncer + .watcher() + .watch(Path::new("exercises"), RecursiveMode::Recursive)?; + + let current_exercise_ind = state.progress.iter().position(|done| *done).unwrap_or(0); + + let exercise = &exercises[current_exercise_ind]; + + let writer = io::stdout().lock(); + + let mut watch_state = WatchState { + writer, + rx, + exercises, + exercise, + current_exercise_ind, + stdout: None, + stderr: None, + message: None, + prompt: format!( + "\n\n{}int/{}lear/{}uit? ", + "h".bold(), + "c".bold(), + "q".bold() + ) + .into_bytes(), + }; + + watch_state.run_exercise()?; + watch_state.render()?; + + let (tx, rx) = sync_channel(0); + thread::spawn(move || { + let mut stdin = io::stdin().lock(); + let mut stdin_buf = String::with_capacity(8); + + loop { + stdin.read_line(&mut stdin_buf).unwrap(); + + let event = match stdin_buf.trim() { + "h" | "hint" => Some(Event::Hint), + "c" | "clear" => Some(Event::Clear), + "q" | "quit" => Some(Event::Quit), + _ => None, + }; + + stdin_buf.clear(); + + if tx.send(event).is_err() { + break; + }; + } + }); + + loop { + watch_state.try_recv_event()?; + + if let Ok(event) = rx.try_recv() { + match event { + Some(Event::Hint) => { + watch_state + .writer + .write_all(watch_state.exercise.hint.as_bytes())?; + watch_state.prompt()?; + } + Some(Event::Clear) => { + watch_state.render()?; + } + Some(Event::Quit) => break, + None => { + watch_state.writer.write_all(b"Invalid command")?; + watch_state.prompt()?; + } + } + } + } + + watch_state.writer.write_all(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. +")?; + + Ok(()) +}