Compare commits

...

8 commits

Author SHA1 Message Date
mo8it 9faa5d3aa4 Avoid asking for terminal size on each rendering 2024-09-05 17:45:27 +02:00
mo8it bcc2a136c8 Add error message when unable to get terminal size 2024-09-05 17:37:34 +02:00
mo8it dcad002057 Only render when needed 2024-09-05 17:32:59 +02:00
mo8it 51b8d2ab25 Remove unused import 2024-09-05 17:23:56 +02:00
mo8it aa3eda70e5 Simplify handling terminal events for unbuffered stdin 2024-09-05 17:12:26 +02:00
mo8it 2d0860fe1b Hide input and disable its line buffering 2024-09-05 02:11:19 +02:00
mo8it 17877366b7 Update deps 2024-09-05 01:55:31 +02:00
mo8it 5eb3dee59c Create solution even if the solution's directory is missing 2024-09-05 00:21:24 +02:00
11 changed files with 158 additions and 133 deletions

21
Cargo.lock generated
View file

@ -95,9 +95,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "clap"
version = "4.5.16"
version = "4.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed6719fffa43d0d87e5fd8caeab59be1554fb028cd30edc88fc4369b17971019"
checksum = "3e5a21b8495e732f1b3c364c9949b201ca7bae518c502c80256c96ad79eaf6ac"
dependencies = [
"clap_builder",
"clap_derive",
@ -105,9 +105,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.15"
version = "4.5.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "216aec2b177652e3846684cbfe25c9964d18ec45234f0f5da5157b207ed1aab6"
checksum = "8cf2dd12af7a047ad9d6da2b6b249759a22a7abc0f474c1dae1777afa4b21a73"
dependencies = [
"anstream",
"anstyle",
@ -242,9 +242,9 @@ checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "indexmap"
version = "2.4.0"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93ead53efc7ea8ed3cfb0c79fc8023fbb782a5432b52830b6518941cebe6505c"
checksum = "68b900aa2f7301e21c36462b170ee99994de34dff39a4a6a528e80e7376d07e5"
dependencies = [
"equivalent",
"hashbrown",
@ -490,6 +490,7 @@ dependencies = [
"crossterm",
"notify-debouncer-mini",
"os_pipe",
"rustix",
"rustlings-macros",
"serde",
"serde_json",
@ -549,9 +550,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.127"
version = "1.0.128"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8043c06d9f82bd7271361ed64f415fe5e12a77fdb52e573e7f06a516dea329ad"
checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8"
dependencies = [
"itoa",
"memchr",
@ -612,9 +613,9 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.76"
version = "2.0.77"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "578e081a14e0cefc3279b0472138c513f37b41a08d5a3cca9b6e4e8ceb6cd525"
checksum = "9f35bcdf61fd8e7be6caf75f429fdca8beb3ed76584befb503b1569faee373ed"
dependencies = [
"proc-macro2",
"quote",

View file

@ -48,15 +48,18 @@ include = [
[dependencies]
ahash = { version = "0.8.11", default-features = false }
anyhow = "1.0.86"
clap = { version = "4.5.16", features = ["derive"] }
clap = { version = "4.5.17", features = ["derive"] }
crossterm = { version = "0.28.1", default-features = false, features = ["windows", "events"] }
notify-debouncer-mini = { version = "0.4.1", default-features = false }
os_pipe = "1.2.1"
rustlings-macros = { path = "rustlings-macros", version = "=6.3.0" }
serde_json = "1.0.127"
serde_json = "1.0.128"
serde.workspace = true
toml_edit.workspace = true
[target.'cfg(not(windows))'.dependencies]
rustix = { version = "0.38.35", default-features = false, features = ["std", "stdio", "termios"] }
[dev-dependencies]
tempfile = "3.12.0"

View file

@ -24,10 +24,10 @@ const STATE_FILE_NAME: &str = ".rustlings-state.txt";
pub enum ExercisesProgress {
// All exercises are done.
AllDone,
// The current exercise failed and is still pending.
CurrentPending,
// A new exercise is now pending.
NewPending,
// The current exercise is still pending.
CurrentPending,
}
pub enum StateFileStatus {

View file

@ -1,7 +1,7 @@
use anyhow::{Context, Error, Result};
use std::{
fs::{create_dir, OpenOptions},
io::{self, Write},
fs::{self, create_dir},
io,
};
use crate::info_file::ExerciseInfo;
@ -9,29 +9,6 @@ use crate::info_file::ExerciseInfo;
/// Contains all embedded files.
pub static EMBEDDED_FILES: EmbeddedFiles = rustlings_macros::include_files!();
#[derive(Clone, Copy)]
pub enum WriteStrategy {
IfNotExists,
Overwrite,
}
impl WriteStrategy {
fn write(self, path: &str, content: &[u8]) -> Result<()> {
let file = match self {
Self::IfNotExists => OpenOptions::new().create_new(true).write(true).open(path),
Self::Overwrite => OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path),
};
file.with_context(|| format!("Failed to open the file `{path}` in write mode"))?
.write_all(content)
.with_context(|| format!("Failed to write the file {path}"))
}
}
// Files related to one exercise.
struct ExerciseFiles {
// The content of the exercise file.
@ -42,6 +19,16 @@ struct ExerciseFiles {
dir_ind: usize,
}
fn create_dir_if_not_exists(path: &str) -> Result<()> {
if let Err(e) = create_dir(path) {
if e.kind() != io::ErrorKind::AlreadyExists {
return Err(Error::from(e).context(format!("Failed to create the directory {path}")));
}
}
Ok(())
}
// A directory in the `exercises/` directory.
pub struct ExerciseDir {
pub name: &'static str,
@ -55,21 +42,13 @@ impl ExerciseDir {
let mut dir_path = String::with_capacity(20 + self.name.len());
dir_path.push_str("exercises/");
dir_path.push_str(self.name);
if let Err(e) = create_dir(&dir_path) {
if e.kind() == io::ErrorKind::AlreadyExists {
return Ok(());
}
return Err(
Error::from(e).context(format!("Failed to create the directory {dir_path}"))
);
}
create_dir_if_not_exists(&dir_path)?;
let mut readme_path = dir_path;
readme_path.push_str("/README.md");
WriteStrategy::Overwrite.write(&readme_path, self.readme)
fs::write(&readme_path, self.readme)
.with_context(|| format!("Failed to write the file {readme_path}"))
}
}
@ -86,17 +65,31 @@ impl EmbeddedFiles {
pub fn init_exercises_dir(&self, exercise_infos: &[ExerciseInfo]) -> Result<()> {
create_dir("exercises").context("Failed to create the directory `exercises`")?;
WriteStrategy::IfNotExists.write(
fs::write(
"exercises/README.md",
include_bytes!("../exercises/README.md"),
)?;
)
.context("Failed to write the file exercises/README.md")?;
for dir in self.exercise_dirs {
dir.init_on_disk()?;
}
let mut exercise_path = String::with_capacity(64);
let prefix = "exercises/";
exercise_path.push_str(prefix);
for (exercise_info, exercise_files) in exercise_infos.iter().zip(self.exercise_files) {
WriteStrategy::IfNotExists.write(&exercise_info.path(), exercise_files.exercise)?;
let dir = &self.exercise_dirs[exercise_files.dir_ind];
exercise_path.truncate(prefix.len());
exercise_path.push_str(dir.name);
exercise_path.push('/');
exercise_path.push_str(&exercise_info.name);
exercise_path.push_str(".rs");
fs::write(&exercise_path, exercise_files.exercise)
.with_context(|| format!("Failed to write the exercise file {exercise_path}"))?;
}
Ok(())
@ -107,7 +100,8 @@ impl EmbeddedFiles {
let dir = &self.exercise_dirs[exercise_files.dir_ind];
dir.init_on_disk()?;
WriteStrategy::Overwrite.write(path, exercise_files.exercise)
fs::write(path, exercise_files.exercise)
.with_context(|| format!("Failed to write the exercise file {path}"))
}
/// Write the solution file to disk and return its path.
@ -116,19 +110,25 @@ impl EmbeddedFiles {
exercise_ind: usize,
exercise_name: &str,
) -> Result<String> {
create_dir_if_not_exists("solutions")?;
let exercise_files = &self.exercise_files[exercise_ind];
let dir = &self.exercise_dirs[exercise_files.dir_ind];
// 14 = 10 + 1 + 3
// solutions/ + / + .rs
let mut solution_path = String::with_capacity(14 + dir.name.len() + exercise_name.len());
solution_path.push_str("solutions/");
solution_path.push_str(dir.name);
let mut dir_path = String::with_capacity(14 + dir.name.len() + exercise_name.len());
dir_path.push_str("solutions/");
dir_path.push_str(dir.name);
create_dir_if_not_exists(&dir_path)?;
let mut solution_path = dir_path;
solution_path.push('/');
solution_path.push_str(exercise_name);
solution_path.push_str(".rs");
WriteStrategy::Overwrite.write(&solution_path, exercise_files.solution)?;
fs::write(&solution_path, exercise_files.solution)
.with_context(|| format!("Failed to write the solution file {solution_path}"))?;
Ok(solution_path)
}

View file

@ -20,7 +20,7 @@ mod scroll_state;
mod state;
fn handle_list(app_state: &mut AppState, stdout: &mut StdoutLock) -> Result<()> {
let mut list_state = ListState::new(app_state, stdout)?;
let mut list_state = ListState::build(app_state, stdout)?;
let mut is_searching = false;
loop {

View file

@ -48,7 +48,7 @@ pub struct ListState<'a> {
}
impl<'a> ListState<'a> {
pub fn new(app_state: &'a mut AppState, stdout: &mut StdoutLock) -> io::Result<Self> {
pub fn build(app_state: &'a mut AppState, stdout: &mut StdoutLock) -> Result<Self> {
stdout.queue(Clear(ClearType::All))?;
let name_col_title_len = 4;
@ -64,7 +64,7 @@ impl<'a> ListState<'a> {
let n_rows_with_filter = app_state.exercises().len();
let selected = app_state.current_exercise_ind();
let (width, height) = terminal::size()?;
let (width, height) = terminal::size().context("Failed to get the terminal size")?;
let scroll_state = ScrollState::new(n_rows_with_filter, Some(selected), 5);
let mut slf = Self {

View file

@ -8,7 +8,7 @@ use std::{
};
use term::{clear_terminal, press_enter_prompt};
use self::{app_state::AppState, dev::DevCommands, info_file::InfoFile, watch::WatchExit};
use self::{app_state::AppState, dev::DevCommands, info_file::InfoFile};
mod app_state;
mod cargo_toml;
@ -130,15 +130,7 @@ fn main() -> Result<()> {
)
};
loop {
match watch::watch(&mut app_state, notify_exercise_names)? {
WatchExit::Shutdown => break,
// It is much easier to exit the watch mode, launch the list mode and then restart
// the watch mode instead of trying to pause the watch threads and correct the
// watch state.
WatchExit::List => list::list(&mut app_state)?,
}
}
watch::watch(&mut app_state, notify_exercise_names)?;
}
Some(Subcommands::Run { name }) => {
if let Some(name) = name {

View file

@ -45,7 +45,7 @@ pub fn run(app_state: &mut AppState) -> Result<()> {
}
match app_state.done_current_exercise(&mut stdout)? {
ExercisesProgress::CurrentPending | ExercisesProgress::NewPending => {
ExercisesProgress::NewPending | ExercisesProgress::CurrentPending => {
stdout.write_all(b"Next exercise: ")?;
app_state
.current_exercise()

View file

@ -11,7 +11,10 @@ use std::{
time::Duration,
};
use crate::app_state::{AppState, ExercisesProgress};
use crate::{
app_state::{AppState, ExercisesProgress},
list,
};
use self::{
notify_event::NotifyEventHandler,
@ -26,22 +29,21 @@ mod terminal_event;
enum WatchEvent {
Input(InputEvent),
FileChange { exercise_ind: usize },
TerminalResize,
TerminalResize { width: u16 },
NotifyErr(notify::Error),
TerminalEventErr(io::Error),
}
/// Returned by the watch mode to indicate what to do afterwards.
#[must_use]
pub enum WatchExit {
enum WatchExit {
/// Exit the program.
Shutdown,
/// Enter the list mode and restart the watch mode afterwards.
List,
}
/// `notify_exercise_names` as None activates the manual run mode.
pub fn watch(
fn run_watch(
app_state: &mut AppState,
notify_exercise_names: Option<&'static [&'static [u8]]>,
) -> Result<WatchExit> {
@ -70,7 +72,7 @@ pub fn watch(
None
};
let mut watch_state = WatchState::new(app_state, manual_run);
let mut watch_state = WatchState::build(app_state, manual_run)?;
let mut stdout = io::stdout().lock();
watch_state.run_current_exercise(&mut stdout)?;
@ -81,26 +83,23 @@ pub fn watch(
match event {
WatchEvent::Input(InputEvent::Next) => match watch_state.next_exercise(&mut stdout)? {
ExercisesProgress::AllDone => break,
ExercisesProgress::CurrentPending => watch_state.render(&mut stdout)?,
ExercisesProgress::NewPending => watch_state.run_current_exercise(&mut stdout)?,
ExercisesProgress::CurrentPending => (),
},
WatchEvent::Input(InputEvent::Hint) => watch_state.show_hint(&mut stdout)?,
WatchEvent::Input(InputEvent::List) => {
return Ok(WatchExit::List);
}
WatchEvent::Input(InputEvent::List) => return Ok(WatchExit::List),
WatchEvent::Input(InputEvent::Quit) => {
stdout.write_all(QUIT_MSG)?;
break;
}
WatchEvent::Input(InputEvent::Run) => watch_state.run_current_exercise(&mut stdout)?,
WatchEvent::Input(InputEvent::Unrecognized) => watch_state.render(&mut stdout)?,
WatchEvent::FileChange { exercise_ind } => {
watch_state.handle_file_change(exercise_ind, &mut stdout)?;
}
WatchEvent::TerminalResize => watch_state.render(&mut stdout)?,
WatchEvent::NotifyErr(e) => {
return Err(Error::from(e).context(NOTIFY_ERR));
WatchEvent::TerminalResize { width } => {
watch_state.update_term_width(width, &mut stdout)?;
}
WatchEvent::NotifyErr(e) => return Err(Error::from(e).context(NOTIFY_ERR)),
WatchEvent::TerminalEventErr(e) => {
return Err(Error::from(e).context("Terminal event listener failed"));
}
@ -110,6 +109,48 @@ pub fn watch(
Ok(WatchExit::Shutdown)
}
fn watch_list_loop(
app_state: &mut AppState,
notify_exercise_names: Option<&'static [&'static [u8]]>,
) -> Result<()> {
loop {
match run_watch(app_state, notify_exercise_names)? {
WatchExit::Shutdown => break Ok(()),
// It is much easier to exit the watch mode, launch the list mode and then restart
// the watch mode instead of trying to pause the watch threads and correct the
// watch state.
WatchExit::List => list::list(app_state)?,
}
}
}
/// `notify_exercise_names` as None activates the manual run mode.
pub fn watch(
app_state: &mut AppState,
notify_exercise_names: Option<&'static [&'static [u8]]>,
) -> Result<()> {
#[cfg(not(windows))]
{
let stdin_fd = rustix::stdio::stdin();
let mut termios = rustix::termios::tcgetattr(stdin_fd)?;
let original_local_modes = termios.local_modes;
// Disable stdin line buffering and hide input.
termios.local_modes -=
rustix::termios::LocalModes::ICANON | rustix::termios::LocalModes::ECHO;
rustix::termios::tcsetattr(stdin_fd, rustix::termios::OptionalActions::Now, &termios)?;
let res = watch_list_loop(app_state, notify_exercise_names);
termios.local_modes = original_local_modes;
rustix::termios::tcsetattr(stdin_fd, rustix::termios::OptionalActions::Now, &termios)?;
res
}
#[cfg(windows)]
watch_list_loop(app_state, notify_exercise_names)
}
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.

View file

@ -1,4 +1,4 @@
use anyhow::Result;
use anyhow::{Context, Result};
use crossterm::{
style::{
Attribute, Attributes, Color, ResetColor, SetAttribute, SetAttributes, SetForegroundColor,
@ -27,17 +27,23 @@ pub struct WatchState<'a> {
show_hint: bool,
done_status: DoneStatus,
manual_run: bool,
term_width: u16,
}
impl<'a> WatchState<'a> {
pub fn new(app_state: &'a mut AppState, manual_run: bool) -> Self {
Self {
pub fn build(app_state: &'a mut AppState, manual_run: bool) -> Result<Self> {
let term_width = terminal::size()
.context("Failed to get the terminal size")?
.0;
Ok(Self {
app_state,
output: Vec::with_capacity(OUTPUT_CAPACITY),
show_hint: false,
done_status: DoneStatus::Pending,
manual_run,
}
term_width,
})
}
pub fn run_current_exercise(&mut self, stdout: &mut StdoutLock) -> Result<()> {
@ -175,12 +181,11 @@ impl<'a> WatchState<'a> {
)?;
}
let line_width = terminal::size()?.0;
progress_bar(
stdout,
self.app_state.n_done(),
self.app_state.exercises().len() as u16,
line_width,
self.term_width,
)?;
stdout.write_all(b"\nCurrent exercise: ")?;
@ -195,7 +200,20 @@ impl<'a> WatchState<'a> {
}
pub fn show_hint(&mut self, stdout: &mut StdoutLock) -> io::Result<()> {
self.show_hint = true;
self.render(stdout)
if !self.show_hint {
self.show_hint = true;
self.render(stdout)?;
}
Ok(())
}
pub fn update_term_width(&mut self, width: u16, stdout: &mut StdoutLock) -> io::Result<()> {
if self.term_width != width {
self.term_width = width;
self.render(stdout)?;
}
Ok(())
}
}

View file

@ -1,4 +1,4 @@
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use std::sync::mpsc::Sender;
use super::WatchEvent;
@ -9,13 +9,9 @@ pub enum InputEvent {
Hint,
List,
Quit,
Unrecognized,
}
pub fn terminal_event_handler(tx: Sender<WatchEvent>, manual_run: bool) {
// Only send `Unrecognized` on ENTER if the last input wasn't valid.
let mut last_input_valid = false;
let last_input_event = loop {
let terminal_event = match event::read() {
Ok(v) => v,
@ -34,47 +30,21 @@ pub fn terminal_event_handler(tx: Sender<WatchEvent>, manual_run: bool) {
KeyEventKind::Press => (),
}
if key.modifiers != KeyModifiers::NONE {
last_input_valid = false;
continue;
}
let input_event = match key.code {
KeyCode::Enter => {
if last_input_valid {
continue;
}
InputEvent::Unrecognized
}
KeyCode::Char(c) => {
let input_event = match c {
'n' => InputEvent::Next,
'h' => InputEvent::Hint,
'l' => break InputEvent::List,
'q' => break InputEvent::Quit,
'r' if manual_run => InputEvent::Run,
_ => {
last_input_valid = false;
continue;
}
};
last_input_valid = true;
input_event
}
_ => {
last_input_valid = false;
continue;
}
KeyCode::Char('n') => InputEvent::Next,
KeyCode::Char('h') => InputEvent::Hint,
KeyCode::Char('l') => break InputEvent::List,
KeyCode::Char('q') => break InputEvent::Quit,
KeyCode::Char('r') if manual_run => InputEvent::Run,
_ => continue,
};
if tx.send(WatchEvent::Input(input_event)).is_err() {
return;
}
}
Event::Resize(_, _) => {
if tx.send(WatchEvent::TerminalResize).is_err() {
Event::Resize(width, _) => {
if tx.send(WatchEvent::TerminalResize { width }).is_err() {
return;
}
}