Compare commits

...

20 commits

Author SHA1 Message Date
mo8it d0fcd8ae8a Use a color for the message 2024-04-08 03:21:13 +02:00
mo8it 7c46e7ac69 Simplify building rows.
No more lifetimes championship :(
2024-04-08 03:16:38 +02:00
mo8it 1db5de9653 Fix selection after applying filters 2024-04-08 03:08:05 +02:00
mo8it b5fc06bd56 Show more exercises before the selected one 2024-04-08 02:46:35 +02:00
mo8it 7c4d33654f Implement done/pending filters 2024-04-08 02:41:48 +02:00
mo8it 05729b27a0 Set a list offset 2024-04-08 01:49:38 +02:00
mo8it 0bf3f7e01f Lowercase "filter" in help footer 2024-04-08 01:34:41 +02:00
mo8it bd5503a0d3 Show message on reset 2024-04-08 01:33:11 +02:00
mo8it 25e855a009 Merge imports 2024-04-08 00:36:26 +02:00
mo8it c2501ae733 Remove list tests because of the TUI 2024-04-08 00:36:10 +02:00
mo8it 3a4f2bebb4 Remove test because of defaulting to watch mode 2024-04-08 00:35:51 +02:00
mo8it 394ca402a8 Remove the info_toml_content field 2024-04-07 23:57:54 +02:00
mo8it db25cc9157 Ignore .rustlings-state.json 2024-04-07 23:54:32 +02:00
mo8it 93f8d1610d Some renamings 2024-04-07 23:37:40 +02:00
mo8it 99c9ab467b Implement resetting 2024-04-07 22:43:59 +02:00
mo8it db43efe3ec Update .gitignore 2024-04-07 22:40:50 +02:00
mo8it 9a4ee47c52 Separate WatchState 2024-04-07 19:29:16 +02:00
mo8it 0a674a158d Separate UiState 2024-04-07 19:05:29 +02:00
mo8it 3bd26c7a24 State -> StateFile 2024-04-07 19:01:08 +02:00
mo8it 8c31d38fa1 Better variable name 2024-04-07 17:57:20 +02:00
12 changed files with 521 additions and 449 deletions

28
.gitignore vendored
View file

@ -1,19 +1,27 @@
# Cargo
target/
/tests/fixture/*/Cargo.lock
/dev/Cargo.lock
*.swp
**/*.rs.bk
# State file
.rustlings-state.json
# oranda
public/
.netlify
# OS
.DS_Store
*.pdb
.direnv/
# Editor
*.swp
.idea
*.iml
# VS Code extension recommendations
.vscode/*
!.vscode/extensions.json
*.iml
*.o
public/
.direnv/
.ignore
# Local Netlify folder
.netlify
# Ignore file for editors like Helix
.ignore

View file

@ -75,7 +75,6 @@ pub fn include_files(_: TokenStream) -> TokenStream {
quote! {
EmbeddedFiles {
info_toml_content: ::std::include_str!("../info.toml"),
exercises_dir: ExercisesDir {
readme: EmbeddedFile {
path: "exercises/README.md",

View file

@ -65,7 +65,6 @@ struct ExercisesDir {
}
pub struct EmbeddedFiles {
pub info_toml_content: &'static str,
exercises_dir: ExercisesDir,
}

View file

@ -1,16 +1,21 @@
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::{Command, Output};
use std::{array, mem};
use winnow::ascii::{space0, Caseless};
use winnow::combinator::opt;
use winnow::Parser;
use std::{
array,
fmt::{self, Debug, Display, Formatter},
fs::{self, File},
io::{self, BufRead, BufReader},
mem,
path::PathBuf,
process::{Command, Output},
};
use winnow::{
ascii::{space0, Caseless},
combinator::opt,
Parser,
};
use crate::embedded::EMBEDDED_FILES;
use crate::embedded::{WriteStrategy, EMBEDDED_FILES};
// The number of context lines above and below a highlighted line.
const CONTEXT: usize = 2;
@ -41,18 +46,18 @@ pub enum Mode {
}
#[derive(Deserialize)]
pub struct ExerciseList {
pub struct InfoFile {
pub exercises: Vec<Exercise>,
}
impl ExerciseList {
impl InfoFile {
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)
} else {
toml_edit::de::from_str(EMBEDDED_FILES.info_toml_content)
toml_edit::de::from_str(include_str!("../info.toml"))
}
.context("Failed to parse `info.toml`")
}
@ -220,6 +225,12 @@ impl Exercise {
pub fn looks_done(&self) -> Result<bool> {
self.state().map(|state| state == State::Done)
}
pub fn reset(&self) -> Result<()> {
EMBEDDED_FILES
.write_exercise_to_disk(&self.path, WriteStrategy::Overwrite)
.with_context(|| format!("Failed to reset the exercise {self}"))
}
}
impl Display for Exercise {

View file

@ -36,7 +36,8 @@ publish = false
}
fn create_gitignore() -> io::Result<()> {
let gitignore = b"/target";
let gitignore = b"/target
/.rustlings-state.json";
OpenOptions::new()
.create_new(true)
.write(true)
@ -56,7 +57,7 @@ fn create_vscode_dir() -> Result<()> {
Ok(())
}
pub fn init_rustlings(exercises: &[Exercise]) -> Result<()> {
pub fn init(exercises: &[Exercise]) -> 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

View file

@ -4,151 +4,16 @@ use crossterm::{
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
use ratatui::{
backend::CrosstermBackend,
layout::{Constraint, Rect},
style::{Style, Stylize},
text::Span,
widgets::{Block, Borders, HighlightSpacing, Row, Table, TableState},
Frame, Terminal,
};
use std::io;
use ratatui::{backend::CrosstermBackend, Terminal};
use std::{fmt::Write, io};
use crate::{exercise::Exercise, state::State};
mod state;
struct UiState<'a> {
pub table: Table<'a>,
selected: usize,
table_state: TableState,
last_ind: usize,
}
use crate::{exercise::Exercise, state_file::StateFile};
impl<'a> UiState<'a> {
pub fn rows<'s, 'i>(
state: &'s State,
exercises: &'a [Exercise],
) -> impl Iterator<Item = Row<'a>> + 'i
where
's: 'i,
'a: 'i,
{
exercises
.iter()
.zip(state.progress())
.enumerate()
.map(|(ind, (exercise, done))| {
let next = if ind == state.next_exercise_ind() {
">>>>".bold().red()
} else {
Span::default()
};
use self::state::{Filter, UiState};
let exercise_state = if *done {
"DONE".green()
} else {
"PENDING".yellow()
};
Row::new([
next,
exercise_state,
Span::raw(&exercise.name),
Span::raw(exercise.path.to_string_lossy()),
])
})
}
pub fn new(state: &State, exercises: &'a [Exercise]) -> Self {
let header = Row::new(["Next", "State", "Name", "Path"]);
let max_name_len = 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 rows = Self::rows(state, exercises);
let table = Table::new(rows, 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 = 0;
let table_state = TableState::default().with_selected(Some(selected));
let last_ind = exercises.len() - 1;
Self {
table,
selected,
table_state,
last_ind,
}
}
fn select(&mut self, ind: usize) {
self.selected = ind;
self.table_state.select(Some(ind));
}
pub fn select_next(&mut self) {
self.select(self.selected.saturating_add(1).min(self.last_ind));
}
pub fn select_previous(&mut self) {
self.select(self.selected.saturating_sub(1));
}
#[inline]
pub fn select_first(&mut self) {
self.select(0);
}
#[inline]
pub fn select_last(&mut self) {
self.select(self.last_ind);
}
pub fn draw(&mut self, frame: &mut Frame) {
let area = frame.size();
frame.render_stateful_widget(
&self.table,
Rect {
x: 0,
y: 0,
width: area.width,
height: area.height - 1,
},
&mut self.table_state,
);
// Help footer
let footer =
"↓/j ↑/k home/g end/G │ Filter <d>one/<p>ending │ <r>eset │ <c>ontinue at │ <q>uit";
frame.render_widget(
Span::raw(footer),
Rect {
x: 0,
y: area.height - 1,
width: area.width,
height: 1,
},
);
}
}
pub fn list(state: &mut State, exercises: &[Exercise]) -> Result<()> {
pub fn list(state_file: &mut StateFile, exercises: &[Exercise]) -> Result<()> {
let mut stdout = io::stdout().lock();
stdout.execute(EnterAlternateScreen)?;
enable_raw_mode()?;
@ -156,7 +21,7 @@ pub fn list(state: &mut State, exercises: &[Exercise]) -> Result<()> {
let mut terminal = Terminal::new(CrosstermBackend::new(&mut stdout))?;
terminal.clear()?;
let mut ui_state = UiState::new(state, exercises);
let mut ui_state = UiState::new(state_file, exercises);
'outer: loop {
terminal.draw(|frame| ui_state.draw(frame))?;
@ -177,15 +42,52 @@ pub fn list(state: &mut State, exercises: &[Exercise]) -> Result<()> {
}
};
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(state_file);
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(state_file);
ui_state.message.push_str(message);
}
KeyCode::Char('r') => {
let selected = ui_state.selected();
let exercise = &exercises[selected];
exercise.reset()?;
state_file.reset(selected)?;
ui_state = ui_state.with_updated_rows(state_file);
ui_state
.message
.write_fmt(format_args!("The exercise {exercise} has been reset!"))?;
}
KeyCode::Char('c') => {
state.set_next_exercise_ind(ui_state.selected)?;
ui_state.table = ui_state.table.rows(UiState::rows(state, exercises));
state_file.set_next_exercise_ind(ui_state.selected())?;
ui_state = ui_state.with_updated_rows(state_file);
}
_ => (),
}

175
src/list/state.rs Normal file
View file

@ -0,0 +1,175 @@
use ratatui::{
layout::{Constraint, Rect},
style::{Style, Stylize},
text::Span,
widgets::{Block, Borders, HighlightSpacing, Row, Table, TableState},
Frame,
};
use crate::{exercise::Exercise, state_file::StateFile};
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum Filter {
Done,
Pending,
None,
}
pub struct UiState<'a> {
pub table: Table<'a>,
pub message: String,
pub filter: Filter,
exercises: &'a [Exercise],
selected: usize,
table_state: TableState,
last_ind: usize,
}
impl<'a> UiState<'a> {
pub fn with_updated_rows(mut self, state_file: &StateFile) -> Self {
let mut rows_counter: usize = 0;
let rows = self
.exercises
.iter()
.zip(state_file.progress().iter().copied())
.enumerate()
.filter_map(|(ind, (exercise, done))| {
match (self.filter, done) {
(Filter::Done, false) | (Filter::Pending, true) => return None,
_ => (),
}
rows_counter += 1;
let next = if ind == state_file.next_exercise_ind() {
">>>>".bold().red()
} else {
Span::default()
};
let exercise_state = if done {
"DONE".green()
} else {
"PENDING".yellow()
};
Some(Row::new([
next,
exercise_state,
Span::raw(&exercise.name),
Span::raw(exercise.path.to_string_lossy()),
]))
});
self.table = self.table.rows(rows);
self.last_ind = rows_counter.saturating_sub(1);
self.select(self.selected.min(self.last_ind));
self
}
pub fn new(state_file: &StateFile, exercises: &'a [Exercise]) -> Self {
let header = Row::new(["Next", "State", "Name", "Path"]);
let max_name_len = 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 = state_file.next_exercise_ind();
let table_state = TableState::default()
.with_offset(selected.saturating_sub(10))
.with_selected(Some(selected));
let slf = Self {
table,
message: String::with_capacity(128),
filter: Filter::None,
exercises,
selected,
table_state,
last_ind: 0,
};
slf.with_updated_rows(state_file)
}
#[inline]
pub fn selected(&self) -> usize {
self.selected
}
fn select(&mut self, ind: usize) {
self.selected = ind;
self.table_state.select(Some(ind));
}
pub fn select_next(&mut self) {
self.select(self.selected.saturating_add(1).min(self.last_ind));
}
pub fn select_previous(&mut self) {
self.select(self.selected.saturating_sub(1));
}
#[inline]
pub fn select_first(&mut self) {
self.select(0);
}
#[inline]
pub fn select_last(&mut self) {
self.select(self.last_ind);
}
pub fn draw(&mut self, frame: &mut Frame) {
let area = frame.size();
frame.render_stateful_widget(
&self.table,
Rect {
x: 0,
y: 0,
width: area.width,
height: area.height - 1,
},
&mut self.table_state,
);
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().blue()
};
frame.render_widget(
message,
Rect {
x: 0,
y: area.height - 1,
width: area.width,
height: 1,
},
);
}
}

View file

@ -1,14 +1,6 @@
use crate::consts::WELCOME;
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 clap::{Parser, Subcommand};
use state::State;
use std::path::Path;
use std::process::exit;
use verify::VerifyState;
use std::{path::Path, process::exit};
mod consts;
mod embedded;
@ -16,10 +8,18 @@ mod exercise;
mod init;
mod list;
mod run;
mod state;
mod state_file;
mod verify;
mod watch;
use self::{
consts::WELCOME,
exercise::{Exercise, InfoFile},
run::run,
state_file::StateFile,
verify::{verify, VerifyState},
};
/// Rustlings is a collection of small exercises to get you used to writing and reading Rust code
#[derive(Parser)]
#[command(version)]
@ -55,6 +55,26 @@ enum Subcommands {
List,
}
fn find_exercise<'a>(name: &str, exercises: &'a [Exercise]) -> Result<(usize, &'a Exercise)> {
if name == "next" {
for (ind, exercise) in exercises.iter().enumerate() {
if !exercise.looks_done()? {
return Ok((ind, exercise));
}
}
println!("🎉 Congratulations! You have done all the exercises!");
println!("🔚 There are no more exercises to do next!");
exit(0);
}
exercises
.iter()
.enumerate()
.find(|(_, exercise)| exercise.name == name)
.with_context(|| format!("No exercise found for '{name}'!"))
}
fn main() -> Result<()> {
let args = Args::parse();
@ -64,10 +84,10 @@ Did you already install Rust?
Try running `cargo --version` to diagnose the problem.",
)?;
let exercises = ExerciseList::parse()?.exercises;
let exercises = InfoFile::parse()?.exercises;
if matches!(args.command, Some(Subcommands::Init)) {
init::init_rustlings(&exercises).context("Initialization failed")?;
init::init(&exercises).context("Initialization failed")?;
println!(
"\nDone initialization!\n
Run `cd rustlings` to go into the generated directory.
@ -85,30 +105,29 @@ If you are just starting with Rustlings, run the command `rustlings init` to ini
exit(1);
}
let mut state = State::read_or_default(&exercises);
let mut state_file = StateFile::read_or_default(&exercises);
match args.command {
None | Some(Subcommands::Watch) => {
watch::watch(&state, &exercises)?;
watch::watch(&state_file, &exercises)?;
}
// `Init` is handled above.
Some(Subcommands::Init) => (),
Some(Subcommands::List) => {
list::list(&mut state, &exercises)?;
list::list(&mut state_file, &exercises)?;
}
Some(Subcommands::Run { name }) => {
let exercise = find_exercise(&name, &exercises)?;
let (_, exercise) = find_exercise(&name, &exercises)?;
run(exercise).unwrap_or_else(|_| exit(1));
}
Some(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 (ind, exercise) = find_exercise(&name, &exercises)?;
exercise.reset()?;
state_file.reset(ind)?;
println!("The exercise {exercise} has been reset!");
}
Some(Subcommands::Hint { name }) => {
let exercise = find_exercise(&name, &exercises)?;
let (_, exercise) = find_exercise(&name, &exercises)?;
println!("{}", exercise.hint);
}
Some(Subcommands::Verify) => match verify(&exercises, 0)? {
@ -119,22 +138,3 @@ If you are just starting with Rustlings, run the command `rustlings init` to ini
Ok(())
}
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);
}
}
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}'!"))
}

View file

@ -5,14 +5,16 @@ use std::fs;
use crate::exercise::Exercise;
#[derive(Serialize, Deserialize)]
pub struct State {
pub struct StateFile {
next_exercise_ind: usize,
progress: Vec<bool>,
}
impl State {
const BAD_INDEX_ERR: &str = "The next exercise index is higher than the number of exercises";
impl StateFile {
fn read(exercises: &[Exercise]) -> Option<Self> {
let file_content = fs::read(".rustlings.json").ok()?;
let file_content = fs::read(".rustlings-state.json").ok()?;
let slf: Self = serde_json::de::from_slice(&file_content).ok()?;
@ -34,6 +36,8 @@ impl State {
// TODO: Capacity
let mut buf = Vec::with_capacity(1024);
serde_json::ser::to_writer(&mut buf, self).context("Failed to serialize the state")?;
fs::write(".rustlings-state.json", buf)
.context("Failed to write the state file `.rustlings-state.json`")?;
Ok(())
}
@ -45,9 +49,8 @@ impl State {
pub fn set_next_exercise_ind(&mut self, ind: usize) -> Result<()> {
if ind >= self.progress.len() {
bail!("The next exercise index is higher than the number of exercises");
bail!(BAD_INDEX_ERR);
}
self.next_exercise_ind = ind;
self.write()
}
@ -56,4 +59,10 @@ impl State {
pub fn progress(&self) -> &[bool] {
&self.progress
}
pub fn reset(&mut self, ind: usize) -> Result<()> {
let done = self.progress.get_mut(ind).context(BAD_INDEX_ERR)?;
*done = false;
self.write()
}
}

View file

@ -1,25 +1,18 @@
use anyhow::Result;
use crossterm::{
style::{Attribute, ContentStyle, Stylize},
terminal::{Clear, ClearType},
ExecutableCommand,
};
use notify_debouncer_mini::{
new_debouncer, notify::RecursiveMode, DebounceEventResult, DebouncedEventKind,
};
use notify_debouncer_mini::{new_debouncer, notify::RecursiveMode};
use std::{
fmt::Write as _,
io::{self, BufRead, StdoutLock, Write},
io::{self, BufRead, Write},
path::Path,
sync::mpsc::{channel, sync_channel, Receiver},
sync::mpsc::{channel, sync_channel},
thread,
time::Duration,
};
use crate::{
exercise::{self, Exercise},
state::State,
};
mod state;
use crate::{exercise::Exercise, state_file::StateFile};
use self::state::WatchState;
enum Event {
Hint,
@ -27,160 +20,14 @@ enum Event {
Quit,
}
struct WatchState<'a> {
writer: StdoutLock<'a>,
rx: Receiver<DebounceEventResult>,
exercises: &'a [Exercise],
exercise: &'a Exercise,
current_exercise_ind: usize,
stdout: Option<Vec<u8>>,
stderr: Option<Vec<u8>>,
message: Option<String>,
prompt: Vec<u8>,
}
impl<'a> WatchState<'a> {
fn run_exercise(&mut self) -> Result<bool> {
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<()> {
pub fn watch(state_file: &StateFile, 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.next_exercise_ind();
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(),
};
let mut watch_state = WatchState::new(state_file, exercises, rx);
watch_state.run_exercise()?;
watch_state.render()?;
@ -214,24 +61,20 @@ pub fn watch(state: &State, exercises: &[Exercise]) -> Result<()> {
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()?;
watch_state.show_hint()?;
}
Some(Event::Clear) => {
watch_state.render()?;
}
Some(Event::Quit) => break,
None => {
watch_state.writer.write_all(b"Invalid command")?;
watch_state.prompt()?;
watch_state.handle_invalid_cmd()?;
}
}
}
}
watch_state.writer.write_all(b"
watch_state.into_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.
")?;

186
src/watch/state.rs Normal file
View file

@ -0,0 +1,186 @@
use anyhow::Result;
use crossterm::{
style::{Attribute, ContentStyle, Stylize},
terminal::{Clear, ClearType},
ExecutableCommand,
};
use notify_debouncer_mini::{DebounceEventResult, DebouncedEventKind};
use std::{
fmt::Write as _,
io::{self, StdoutLock, Write as _},
sync::mpsc::Receiver,
time::Duration,
};
use crate::{
exercise::{Exercise, State},
state_file::StateFile,
};
pub struct WatchState<'a> {
writer: StdoutLock<'a>,
rx: Receiver<DebounceEventResult>,
exercises: &'a [Exercise],
exercise: &'a Exercise,
current_exercise_ind: usize,
stdout: Option<Vec<u8>>,
stderr: Option<Vec<u8>>,
message: Option<String>,
prompt: Vec<u8>,
}
impl<'a> WatchState<'a> {
pub fn new(
state_file: &StateFile,
exercises: &'a [Exercise],
rx: Receiver<DebounceEventResult>,
) -> Self {
let current_exercise_ind = state_file.next_exercise_ind();
let exercise = &exercises[current_exercise_ind];
let writer = io::stdout().lock();
let prompt = format!(
"\n\n{}int/{}lear/{}uit? ",
"h".bold(),
"c".bold(),
"q".bold()
)
.into_bytes();
Self {
writer,
rx,
exercises,
exercise,
current_exercise_ind,
stdout: None,
stderr: None,
message: None,
prompt,
}
}
#[inline]
pub fn into_writer(self) -> StdoutLock<'a> {
self.writer
}
pub fn run_exercise(&mut self) -> Result<bool> {
let output = self.exercise.run()?;
if !output.status.success() {
self.stdout = Some(output.stdout);
self.stderr = Some(output.stderr);
return Ok(false);
}
if let 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)
}
pub 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(())
}
pub fn show_prompt(&mut self) -> io::Result<()> {
self.writer.write_all(&self.prompt)?;
self.writer.flush()
}
pub fn render(&mut self) -> io::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.show_prompt()
}
pub fn show_hint(&mut self) -> io::Result<()> {
self.writer.write_all(self.exercise.hint.as_bytes())?;
self.show_prompt()
}
pub fn handle_invalid_cmd(&mut self) -> io::Result<()> {
self.writer.write_all(b"Invalid command")?;
self.show_prompt()
}
}

View file

@ -1,14 +1,7 @@
use assert_cmd::prelude::*;
use glob::glob;
use predicates::boolean::PredicateBooleanExt;
use std::fs::File;
use std::io::Read;
use std::process::Command;
#[test]
fn runs_without_arguments() {
Command::cargo_bin("rustlings").unwrap().assert().success();
}
use std::{fs::File, io::Read, process::Command};
#[test]
fn fails_when_in_wrong_dir() {
@ -201,57 +194,3 @@ fn run_single_test_success_with_output() {
.code(0)
.stdout(predicates::str::contains("THIS TEST TOO SHALL PASS"));
}
#[test]
fn run_rustlings_list() {
Command::cargo_bin("rustlings")
.unwrap()
.args(["list"])
.current_dir("tests/fixture/success")
.assert()
.success();
}
#[test]
fn run_rustlings_list_no_pending() {
Command::cargo_bin("rustlings")
.unwrap()
.args(["list"])
.current_dir("tests/fixture/success")
.assert()
.success()
.stdout(predicates::str::contains("Pending").not());
}
#[test]
fn run_rustlings_list_both_done_and_pending() {
Command::cargo_bin("rustlings")
.unwrap()
.args(["list"])
.current_dir("tests/fixture/state")
.assert()
.success()
.stdout(predicates::str::contains("Done").and(predicates::str::contains("Pending")));
}
#[test]
fn run_rustlings_list_without_pending() {
Command::cargo_bin("rustlings")
.unwrap()
.args(["list", "--solved"])
.current_dir("tests/fixture/state")
.assert()
.success()
.stdout(predicates::str::contains("Pending").not());
}
#[test]
fn run_rustlings_list_without_done() {
Command::cargo_bin("rustlings")
.unwrap()
.args(["list", "--unsolved"])
.current_dir("tests/fixture/state")
.assert()
.success()
.stdout(predicates::str::contains("Done").not());
}