Compare commits

..

No commits in common. "cf3f6fd6a16e81905bb44676d623502aeb8e5d01" and "d9df809838191962a82e98ff01aaaa73950ba670" have entirely different histories.

24 changed files with 1469 additions and 1527 deletions

View file

@ -37,7 +37,7 @@ Please be patient 😇
- Name the file `exercises/yourTopic/yourTopicN.rs`.
- Make sure to put in some helpful links, and link to sections of the book in `exercises/yourTopic/README.md`.
- Add a (possible) solution at `solutions/yourTopic/yourTopicN.rs` with comments and links explaining it.
- Add the [metadata for your exercise](#exercise-metadata) in the `rustlings-macros/info.toml` file.
- Add the [metadata for your exercise](#exercise-metadata) in the `info.toml` file.
- Make sure your exercise runs with `rustlings run yourTopicN`.
- [Open a pull request](#pull-requests).

4
Cargo.lock generated
View file

@ -656,7 +656,7 @@ checksum = "adad44e29e4c806119491a7f06f03de4d1af22c3a680dd47f1e6e179439d1f56"
[[package]]
name = "rustlings"
version = "6.0.0-beta.9"
version = "6.0.0-beta.6"
dependencies = [
"anyhow",
"assert_cmd",
@ -675,7 +675,7 @@ dependencies = [
[[package]]
name = "rustlings-macros"
version = "6.0.0-beta.9"
version = "6.0.0-beta.6"
dependencies = [
"quote",
"serde",

View file

@ -8,7 +8,7 @@ exclude = [
]
[workspace.package]
version = "6.0.0-beta.9"
version = "6.0.0-beta.6"
authors = [
"Liv <mokou@fastmail.com>",
"Mo Bitar <mo8it@proton.me>",
@ -39,6 +39,7 @@ include = [
"/src/",
"/exercises/",
"/solutions/",
"/info.toml",
# A symlink to be able to include `dev/Cargo.toml` although `dev` is excluded.
"/dev-Cargo.toml",
"/README.md",
@ -53,7 +54,7 @@ hashbrown = "0.14.5"
notify-debouncer-mini = { version = "0.4.1", default-features = false }
os_pipe = "1.1.5"
ratatui = { version = "0.26.2", default-features = false, features = ["crossterm"] }
rustlings-macros = { path = "rustlings-macros", version = "=6.0.0-beta.9" }
rustlings-macros = { path = "rustlings-macros", version = "=6.0.0-beta.6" }
serde_json = "1.0.117"
serde.workspace = true
toml_edit.workspace = true

View file

@ -35,7 +35,7 @@ The following command will download and compile Rustlings:
<!-- TODO: Remove @6.0.0-beta.x -->
```bash
cargo install rustlings@6.0.0-beta.9
cargo install rustlings@6.0.0-beta.6
```
<details>
@ -44,7 +44,7 @@ cargo install rustlings@6.0.0-beta.9
<!-- TODO: Remove @6.0.0-beta.x -->
- Make sure you have the latest Rust version by running `rustup update`
- Try adding the `--locked` flag: `cargo install rustlings@6.0.0-beta.9 --locked`
- Try adding the `--locked` flag: `cargo install rustlings@6.0.0-beta.6 --locked`
- Otherwise, please [report the issue](https://github.com/rust-lang/rustlings/issues/new)
</details>
@ -98,7 +98,7 @@ It will rerun the current exercise automatically every time you change the exerc
<details>
<summary><strong>If detecting file changes in the <code>exercises/</code> directory fails…</strong> (<em>click to expand</em>)</summary>
> You can add the **`--manual-run`** flag (`rustlings --manual-run`) to manually rerun the current exercise by entering `r` in the watch mode.
> You can add the **`--manual-run`** flag (`rustlings --manual-run`) to manually rerun the current exercise by entering `r` (or `run`) in the watch mode.
>
> Please [report the issue](https://github.com/rust-lang/rustlings/issues/new) with some information about your operating system and whether you run Rustlings in a container or virtual machine (e.g. WSL).
@ -106,7 +106,7 @@ It will rerun the current exercise automatically every time you change the exerc
### Exercise List
In the [watch mode](#watch-mode) (after launching `rustlings`), you can enter `l` to open the interactive exercise list.
In the [watch mode](#watch-mode) (after launching `rustlings`), you can enter `l` (or `list`) to open the interactive exercise list.
The list allows you to…

View file

@ -1,6 +1,6 @@
// We sometimes encourage you to keep trying things on a given exercise, even
// after you already figured it out. If you got everything working and feel
// ready for the next exercise, enter `n` in the terminal.
// ready for the next exercise, enter `n` (or `next`) in the terminal.
//
// The exercise file will be reloaded when you change one of the lines below!
// Try adding a new `println!`.

1286
info.toml Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

1
rustlings-macros/info.toml Symbolic link
View file

@ -0,0 +1 @@
../info.toml

View file

@ -15,8 +15,7 @@ struct InfoFile {
#[proc_macro]
pub fn include_files(_: TokenStream) -> TokenStream {
let info_file = include_str!("../info.toml");
let exercises = toml_edit::de::from_str::<InfoFile>(info_file)
let exercises = toml_edit::de::from_str::<InfoFile>(include_str!("../info.toml"))
.expect("Failed to parse `info.toml`")
.exercises;
@ -47,7 +46,6 @@ pub fn include_files(_: TokenStream) -> TokenStream {
quote! {
EmbeddedFiles {
info_file: #info_file,
exercise_files: &[#(ExerciseFiles { exercise: include_bytes!(#exercise_files), solution: include_bytes!(#solution_files), dir_ind: #dir_inds }),*],
exercise_dirs: &[#(ExerciseDir { name: #dirs, readme: include_bytes!(#readmes) }),*]
}

View file

@ -21,12 +21,8 @@ const BAD_INDEX_ERR: &str = "The current exercise index is higher than the numbe
#[must_use]
pub enum ExercisesProgress {
// All exercises are done.
AllDone,
// The current exercise failed and is still pending.
CurrentPending,
// A new exercise is now pending.
NewPending,
Pending,
}
pub enum StateFileStatus {
@ -124,27 +120,7 @@ impl AppState {
let exercises = exercise_infos
.into_iter()
.map(|exercise_info| {
// Leaking to be able to borrow in the watch mode `Table`.
// Leaking is not a problem because the `AppState` instance lives until
// the end of the program.
let path = exercise_info.path().leak();
let name = exercise_info.name.leak();
let dir = exercise_info.dir.map(|dir| &*dir.leak());
let hint = exercise_info.hint.trim().to_owned();
Exercise {
dir,
name,
path,
test: exercise_info.test,
strict_clippy: exercise_info.strict_clippy,
hint,
// Updated in `Self::update_from_file`.
done: false,
}
})
.map(Exercise::from)
.collect::<Vec<_>>();
let mut slf = Self {
@ -218,10 +194,6 @@ impl AppState {
}
pub fn set_current_exercise_ind(&mut self, exercise_ind: usize) -> Result<()> {
if exercise_ind == self.current_exercise_ind {
return Ok(());
}
if exercise_ind >= self.exercises.len() {
bail!(BAD_INDEX_ERR);
}
@ -330,8 +302,8 @@ impl AppState {
}
}
/// Official exercises: Dump the solution file form the binary and return its path.
/// Third-party exercises: Check if a solution file exists and return its path in that case.
// Official exercises: Dump the solution file form the binary and return its path.
// Third-party exercises: Check if a solution file exists and return its path in that case.
pub fn current_solution_path(&self) -> Result<Option<String>> {
if DEBUG_PROFILE {
return Ok(None);
@ -358,9 +330,9 @@ impl AppState {
}
}
/// Mark the current exercise as done and move on to the next pending exercise if one exists.
/// If all exercises are marked as done, run all of them to make sure that they are actually
/// done. If an exercise which is marked as done fails, mark it as pending and continue on it.
// Mark the current exercise as done and move on to the next pending exercise if one exists.
// If all exercises are marked as done, run all of them to make sure that they are actually
// done. If an exercise which is marked as done fails, mark it as pending and continue on it.
pub fn done_current_exercise(&mut self, writer: &mut StdoutLock) -> Result<ExercisesProgress> {
let exercise = &mut self.exercises[self.current_exercise_ind];
if !exercise.done {
@ -371,7 +343,7 @@ impl AppState {
if let Some(ind) = self.next_pending_exercise_ind() {
self.set_current_exercise_ind(ind)?;
return Ok(ExercisesProgress::NewPending);
return Ok(ExercisesProgress::Pending);
}
writer.write_all(RERUNNING_ALL_EXERCISES_MSG)?;
@ -394,7 +366,7 @@ impl AppState {
self.write()?;
return Ok(ExercisesProgress::NewPending);
return Ok(ExercisesProgress::Pending);
}
writeln!(writer, "{}", "ok".green())?;

View file

@ -2,10 +2,10 @@ use anyhow::{Context, Result};
use crate::info_file::ExerciseInfo;
/// Return the start and end index of the content of the list `bin = […]`.
/// bin = [xxxxxxxxxxxxxxxxx]
/// |start_ind |
/// |end_ind
// Return the start and end index of the content of the list `bin = […]`.
// bin = [xxxxxxxxxxxxxxxxx]
// |start_ind |
// |end_ind
pub fn bins_start_end_ind(cargo_toml: &str) -> Result<(usize, usize)> {
let start_ind = cargo_toml
.find("bin = [")
@ -20,8 +20,8 @@ pub fn bins_start_end_ind(cargo_toml: &str) -> Result<(usize, usize)> {
Ok((start_ind, end_ind))
}
/// Generate and append the content of the `bin` list in `Cargo.toml`.
/// The `exercise_path_prefix` is the prefix of the `path` field of every list entry.
// Generate and append the content of the `bin` list in `Cargo.toml`.
// The `exercise_path_prefix` is the prefix of the `path` field of every list entry.
pub fn append_bins(
buf: &mut Vec<u8>,
exercise_infos: &[ExerciseInfo],
@ -43,7 +43,7 @@ pub fn append_bins(
}
}
/// Update the `bin` list and leave everything else unchanged.
// Update the `bin` list and leave everything else unchanged.
pub fn updated_cargo_toml(
exercise_infos: &[ExerciseInfo],
current_cargo_toml: &str,

View file

@ -1,8 +1,8 @@
use anyhow::{Context, Result};
use std::{io::Read, path::Path, process::Command};
/// Run a command with a description for a possible error and append the merged stdout and stderr.
/// The boolean in the returned `Result` is true if the command's exit status is success.
// Run a command with a description for a possible error and append the merged stdout and stderr.
// The boolean in the returned `Result` is true if the command's exit status is success.
pub fn run_cmd(mut cmd: Command, description: &str, output: &mut Vec<u8>) -> Result<bool> {
let (mut reader, writer) = os_pipe::pipe()
.with_context(|| format!("Failed to create a pipe to run the command `{description}``"))?;
@ -37,18 +37,18 @@ pub struct CargoCmd<'a> {
pub args: &'a [&'a str],
pub exercise_name: &'a str,
pub description: &'a str,
/// RUSTFLAGS="-A warnings"
// RUSTFLAGS="-A warnings"
pub hide_warnings: bool,
/// Added as `--target-dir` if `Self::dev` is true.
// Added as `--target-dir` if `Self::dev` is true.
pub target_dir: &'a Path,
/// The output buffer to append the merged stdout and stderr.
// The output buffer to append the merged stdout and stderr.
pub output: &'a mut Vec<u8>,
/// true while developing Rustlings.
// true while developing Rustlings.
pub dev: bool,
}
impl<'a> CargoCmd<'a> {
/// Run `cargo SUBCOMMAND --bin EXERCISE_NAME … ARGS`.
// Run `cargo SUBCOMMAND --bin EXERCISE_NAME … ARGS`.
pub fn run(&mut self) -> Result<bool> {
let mut cmd = Command::new("cargo");
cmd.arg(self.subcommand);

View file

@ -6,7 +6,6 @@ use std::{
use crate::info_file::ExerciseInfo;
/// Contains all embedded files.
pub static EMBEDDED_FILES: EmbeddedFiles = rustlings_macros::include_files!();
#[derive(Clone, Copy)]
@ -32,17 +31,12 @@ impl WriteStrategy {
}
}
// Files related to one exercise.
struct ExerciseFiles {
// The content of the exercise file.
exercise: &'static [u8],
// The content of the solution file.
solution: &'static [u8],
// Index of the related `ExerciseDir` in `EmbeddedFiles::exercise_dirs`.
dir_ind: usize,
}
// A directory in the `exercises/` directory.
struct ExerciseDir {
name: &'static str,
readme: &'static [u8],
@ -69,20 +63,18 @@ impl ExerciseDir {
let mut readme_path = dir_path;
readme_path.push_str("/README.md");
WriteStrategy::Overwrite.write(&readme_path, self.readme)
WriteStrategy::Overwrite.write(&readme_path, self.readme)?;
Ok(())
}
}
/// All embedded files.
pub struct EmbeddedFiles {
/// The content of the `info.toml` file.
pub info_file: &'static str,
exercise_files: &'static [ExerciseFiles],
exercise_dirs: &'static [ExerciseDir],
}
impl EmbeddedFiles {
/// Dump all the embedded files of the `exercises/` directory.
pub fn init_exercises_dir(&self, exercise_infos: &[ExerciseInfo]) -> Result<()> {
create_dir("exercises").context("Failed to create the directory `exercises`")?;
@ -103,21 +95,21 @@ impl EmbeddedFiles {
}
pub fn write_exercise_to_disk(&self, exercise_ind: usize, path: &str) -> Result<()> {
let exercise_files = &self.exercise_files[exercise_ind];
let dir = &self.exercise_dirs[exercise_files.dir_ind];
let exercise_files = &EMBEDDED_FILES.exercise_files[exercise_ind];
let dir = &EMBEDDED_FILES.exercise_dirs[exercise_files.dir_ind];
dir.init_on_disk()?;
WriteStrategy::Overwrite.write(path, exercise_files.exercise)
}
/// Write the solution file to disk and return its path.
// Write the solution file to disk and return its path.
pub fn write_solution_to_disk(
&self,
exercise_ind: usize,
exercise_name: &str,
) -> Result<String> {
let exercise_files = &self.exercise_files[exercise_ind];
let dir = &self.exercise_dirs[exercise_files.dir_ind];
let exercise_files = &EMBEDDED_FILES.exercise_files[exercise_ind];
let dir = &EMBEDDED_FILES.exercise_dirs[exercise_files.dir_ind];
// 14 = 10 + 1 + 3
// solutions/ + / + .rs
@ -156,7 +148,7 @@ mod tests {
#[test]
fn dirs() {
let exercises = toml_edit::de::from_str::<InfoFile>(EMBEDDED_FILES.info_file)
let exercises = toml_edit::de::from_str::<InfoFile>(include_str!("../info.toml"))
.expect("Failed to parse `info.toml`")
.exercises;

View file

@ -10,32 +10,30 @@ use std::{
use crate::{
cmd::{run_cmd, CargoCmd},
in_official_repo,
info_file::ExerciseInfo,
terminal_link::TerminalFileLink,
DEBUG_PROFILE,
};
/// The initial capacity of the output buffer.
pub const OUTPUT_CAPACITY: usize = 1 << 14;
/// See `info_file::ExerciseInfo`
pub struct Exercise {
pub dir: Option<&'static str>,
// Exercise's unique name
pub name: &'static str,
/// Path of the exercise file starting with the `exercises/` directory.
// Exercise's path
pub path: &'static str,
pub test: bool,
pub strict_clippy: bool,
// The hint text associated with the exercise
pub hint: String,
pub done: bool,
}
impl Exercise {
// Run the exercise's binary and append its output to the `output` buffer.
// Compilation should be done before calling this method.
fn run_bin(&self, output: &mut Vec<u8>, target_dir: &Path) -> Result<bool> {
writeln!(output, "{}", "Output".underlined())?;
// 7 = "/debug/".len()
let mut bin_path =
PathBuf::with_capacity(target_dir.as_os_str().len() + 7 + self.name.len());
bin_path.push(target_dir);
@ -45,23 +43,18 @@ impl Exercise {
let success = run_cmd(Command::new(&bin_path), &bin_path.to_string_lossy(), output)?;
if !success {
// This output is important to show the user that something went wrong.
// Otherwise, calling something like `exit(1)` in an exercise without further output
// leaves the user confused about why the exercise isn't done yet.
writeln!(
output,
"{}",
"The exercise didn't run successfully (nonzero exit code)"
.bold()
.red(),
.red()
)?;
}
Ok(success)
}
/// Compile, check and run the exercise.
/// The output is written to the `output` buffer after clearing it.
pub fn run(&self, output: &mut Vec<u8>, target_dir: &Path) -> Result<bool> {
output.clear();
@ -83,10 +76,9 @@ impl Exercise {
return Ok(false);
}
// Discard the output of `cargo build` because it will be shown again by Clippy.
// Discard the output of `cargo build` because it will be shown again by the Cargo command.
output.clear();
// `--profile test` is required to also check code with `[cfg(test)]`.
let clippy_args: &[&str] = if self.strict_clippy {
&["--profile", "test", "--", "-D", "warnings"]
} else {
@ -134,6 +126,34 @@ impl Exercise {
}
}
impl From<ExerciseInfo> for Exercise {
fn from(mut exercise_info: ExerciseInfo) -> Self {
// 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 dir = exercise_info.dir.map(|mut dir| {
dir.shrink_to_fit();
&*dir.leak()
});
let hint = exercise_info.hint.trim().to_owned();
Exercise {
dir,
name,
path,
test: exercise_info.test,
strict_clippy: exercise_info.strict_clippy,
hint,
done: false,
}
}
}
impl Display for Exercise {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
self.path.fmt(f)

View file

@ -2,71 +2,44 @@ use anyhow::{bail, Context, Error, Result};
use serde::Deserialize;
use std::{fs, io::ErrorKind};
use crate::embedded::EMBEDDED_FILES;
/// Deserialized from the `info.toml` file.
// Deserialized from the `info.toml` file.
#[derive(Deserialize)]
pub struct ExerciseInfo {
/// Exercise's unique name.
// Name of the exercise
pub name: String,
/// Exercise's directory name inside the `exercises/` directory.
// The exercise's directory inside the `exercises` directory
pub dir: Option<String>,
#[serde(default = "default_true")]
/// Run `cargo test` on the exercise.
pub test: bool,
/// Deny all Clippy warnings.
#[serde(default)]
pub strict_clippy: bool,
/// The exercise's hint to be shown to the user on request.
// The hint text associated with the exercise
pub hint: String,
}
#[inline(always)]
#[inline]
const fn default_true() -> bool {
true
}
impl ExerciseInfo {
/// Path to the exercise file starting with the `exercises/` directory.
pub fn path(&self) -> String {
let mut path = if let Some(dir) = &self.dir {
// 14 = 10 + 1 + 3
// exercises/ + / + .rs
let mut path = String::with_capacity(14 + dir.len() + self.name.len());
path.push_str("exercises/");
path.push_str(dir);
path.push('/');
path
if let Some(dir) = &self.dir {
format!("exercises/{dir}/{}.rs", self.name)
} else {
// 13 = 10 + 3
// exercises/ + .rs
let mut path = String::with_capacity(13 + self.name.len());
path.push_str("exercises/");
path
};
path.push_str(&self.name);
path.push_str(".rs");
path
format!("exercises/{}.rs", self.name)
}
}
}
/// The deserialized `info.toml` file.
#[derive(Deserialize)]
pub struct InfoFile {
/// For possible breaking changes in the future for third-party exercises.
pub format_version: u8,
/// Shown to users when starting with the exercises.
pub welcome_message: Option<String>,
/// Shown to users after finishing all exercises.
pub final_message: Option<String>,
/// List of all exercises.
pub exercises: Vec<ExerciseInfo>,
}
impl InfoFile {
/// Official exercises: Parse the embedded `info.toml` file.
/// Third-party exercises: Parse the `info.toml` file in the current directory.
pub fn parse() -> Result<Self> {
// Read a local `info.toml` if it exists.
let slf = match fs::read_to_string("info.toml") {
@ -74,7 +47,7 @@ impl InfoFile {
.context("Failed to parse the `info.toml` file")?,
Err(e) => {
if e.kind() == ErrorKind::NotFound {
return toml_edit::de::from_str(EMBEDDED_FILES.info_file)
return toml_edit::de::from_str(include_str!("../info.toml"))
.context("Failed to parse the embedded `info.toml` file");
}

View file

@ -11,11 +11,8 @@ use std::{
use crate::{cargo_toml::updated_cargo_toml, embedded::EMBEDDED_FILES, info_file::InfoFile};
pub fn init() -> Result<()> {
// Prevent initialization in a directory that contains the file `Cargo.toml`.
// This can mean that Rustlings was already initialized in this directory.
// Otherwise, this can cause problems with Cargo workspaces.
if Path::new("Cargo.toml").exists() {
bail!(CARGO_TOML_EXISTS_ERR);
if Path::new("exercises").is_dir() && Path::new("Cargo.toml").is_file() {
bail!(PROBABLY_IN_RUSTLINGS_DIR_ERR);
}
let rustlings_path = Path::new("rustlings");
@ -27,7 +24,7 @@ pub fn init() -> Result<()> {
}
set_current_dir("rustlings")
.context("Failed to change the current directory to `rustlings/`")?;
.context("Failed to change the current directory to `rustlings`")?;
let info_file = InfoFile::parse()?;
EMBEDDED_FILES
@ -40,10 +37,9 @@ pub fn init() -> Result<()> {
.as_bytes()
.iter()
.position(|c| *c == b'\n')
.context("The embedded `Cargo.toml` is empty or contains only one line")?;
let current_cargo_toml = current_cargo_toml
.get(newline_ind + 1..)
.context("The embedded `Cargo.toml` contains only one line")?;
.context("The embedded `Cargo.toml` is empty or contains only one line.")?;
let current_cargo_toml =
&current_cargo_toml[(newline_ind + 1).min(current_cargo_toml.len() - 1)..];
let updated_cargo_toml = updated_cargo_toml(&info_file.exercises, current_cargo_toml, b"")
.context("Failed to generate `Cargo.toml`")?;
fs::write("Cargo.toml", updated_cargo_toml)
@ -81,10 +77,12 @@ target
pub const VS_CODE_EXTENSIONS_JSON: &[u8] = br#"{"recommendations":["rust-lang.rust-analyzer"]}"#;
const CARGO_TOML_EXISTS_ERR: &str = "The current directory contains the file `Cargo.toml`.
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 already initialized Rustlings, run the command `rustlings` for instructions on getting started with the exercises.
Otherwise, please run `rustlings init` again in another directory.";
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.

View file

@ -148,12 +148,14 @@ impl<'a> UiState<'a> {
}
}
#[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));

View file

@ -56,7 +56,7 @@ fn press_enter_prompt() -> io::Result<()> {
struct Args {
#[command(subcommand)]
command: Option<Subcommands>,
/// Manually run the current exercise using `r` in the watch mode.
/// 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,

View file

@ -12,7 +12,6 @@ const MIN_LINE_WIDTH: u16 = WRAPPER_WIDTH + 4;
const PROGRESS_EXCEEDS_MAX_ERR: &str =
"The progress of the progress bar is higher than the maximum";
/// Terminal progress bar to be used when not using Ratataui.
pub fn progress_bar(progress: u16, total: u16, line_width: u16) -> Result<String> {
use crossterm::style::Stylize;
@ -55,8 +54,6 @@ pub fn progress_bar(progress: u16, total: u16, line_width: u16) -> Result<String
Ok(line)
}
/// Progress bar to be used with Ratataui.
// Not using Ratatui's Gauge widget to keep the progress bar consistent.
pub fn progress_bar_ratatui(progress: u16, total: u16, line_width: u16) -> Result<Line<'static>> {
use ratatui::style::Stylize;

View file

@ -41,7 +41,7 @@ pub fn run(app_state: &mut AppState) -> Result<()> {
match app_state.done_current_exercise(&mut stdout)? {
ExercisesProgress::AllDone => (),
ExercisesProgress::CurrentPending | ExercisesProgress::NewPending => println!(
ExercisesProgress::Pending => println!(
"Next exercise: {}",
app_state.current_exercise().terminal_link(),
),

View file

@ -7,18 +7,15 @@ pub struct TerminalFileLink<'a>(pub &'a str);
impl<'a> Display for TerminalFileLink<'a> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let path = fs::canonicalize(self.0);
if let Some(path) = path.as_deref().ok().and_then(|path| path.to_str()) {
// Windows itself can't handle its verbatim paths.
#[cfg(windows)]
let path = if path.len() > 5 && &path[0..4] == r"\\?\" {
&path[4..]
} else {
path
};
write!(f, "\x1b]8;;file://{path}\x1b\\{}\x1b]8;;\x1b\\", self.0)
if let Ok(Some(canonical_path)) = fs::canonicalize(self.0)
.as_deref()
.map(|path| path.to_str())
{
write!(
f,
"\x1b]8;;file://{}\x1b\\{}\x1b]8;;\x1b\\",
canonical_path, self.0,
)
} else {
write!(f, "{}", self.0)
}

View file

@ -14,7 +14,7 @@ use std::{
use crate::app_state::{AppState, ExercisesProgress};
use self::{
notify_event::NotifyEventHandler,
notify_event::DebounceEventHandler,
state::WatchState,
terminal_event::{terminal_event_handler, InputEvent},
};
@ -40,7 +40,6 @@ pub enum WatchExit {
List,
}
/// `notify_exercise_names` as None activates the manual run mode.
pub fn watch(
app_state: &mut AppState,
notify_exercise_names: Option<&'static [&'static [u8]]>,
@ -53,7 +52,7 @@ pub fn watch(
let _debouncer_guard = if let Some(exercise_names) = notify_exercise_names {
let mut debouncer = new_debouncer(
Duration::from_millis(200),
NotifyEventHandler {
DebounceEventHandler {
tx: tx.clone(),
exercise_names,
},
@ -80,8 +79,7 @@ pub fn watch(
match event {
WatchEvent::Input(InputEvent::Next) => match watch_state.next_exercise()? {
ExercisesProgress::AllDone => break,
ExercisesProgress::CurrentPending => watch_state.render()?,
ExercisesProgress::NewPending => watch_state.run_current_exercise()?,
ExercisesProgress::Pending => watch_state.run_current_exercise()?,
},
WatchEvent::Input(InputEvent::Hint) => {
watch_state.show_hint()?;
@ -94,9 +92,11 @@ pub fn watch(
break;
}
WatchEvent::Input(InputEvent::Run) => watch_state.run_current_exercise()?,
WatchEvent::Input(InputEvent::Unrecognized) => watch_state.render()?,
WatchEvent::Input(InputEvent::Unrecognized(cmd)) => {
watch_state.handle_invalid_cmd(&cmd)?;
}
WatchEvent::FileChange { exercise_ind } => {
watch_state.handle_file_change(exercise_ind)?;
watch_state.run_exercise_with_ind(exercise_ind)?;
}
WatchEvent::TerminalResize => {
watch_state.render()?;
@ -124,5 +124,5 @@ The automatic detection of exercise file changes failed :(
Please try running `rustlings` again.
If you keep getting this error, run `rustlings --manual-run` to deactivate the file watcher.
You need to manually trigger running the current exercise using `r` then.
You need to manually trigger running the current exercise using `r` (or `run`) then.
";

View file

@ -3,24 +3,23 @@ use std::sync::mpsc::Sender;
use super::WatchEvent;
pub struct NotifyEventHandler {
pub struct DebounceEventHandler {
pub tx: Sender<WatchEvent>,
/// Used to report which exercise was modified.
pub exercise_names: &'static [&'static [u8]],
}
impl notify_debouncer_mini::DebounceEventHandler for NotifyEventHandler {
fn handle_event(&mut self, input_event: DebounceEventResult) {
let output_event = match input_event {
Ok(input_event) => {
let Some(exercise_ind) = input_event
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(|input_event| {
if input_event.kind != DebouncedEventKind::Any {
.filter_map(|event| {
if event.kind != DebouncedEventKind::Any {
return None;
}
let file_name = input_event.path.file_name()?.to_str()?.as_bytes();
let file_name = event.path.file_name()?.to_str()?.as_bytes();
if file_name.len() < 4 {
return None;
@ -47,6 +46,6 @@ impl notify_debouncer_mini::DebounceEventHandler for NotifyEventHandler {
// An error occurs when the receiver is dropped.
// After dropping the receiver, the debouncer guard should also be dropped.
let _ = self.tx.send(output_event);
let _ = self.tx.send(event);
}
}

View file

@ -1,7 +1,7 @@
use anyhow::Result;
use crossterm::{
style::{style, Stylize},
terminal,
terminal::size,
};
use std::io::{self, StdoutLock, Write};
@ -13,7 +13,6 @@ use crate::{
terminal_link::TerminalFileLink,
};
#[derive(PartialEq, Eq)]
enum DoneStatus {
DoneWithSolution(String),
DoneWithoutSolution,
@ -72,22 +71,17 @@ impl<'a> WatchState<'a> {
self.render()
}
pub fn handle_file_change(&mut self, exercise_ind: usize) -> Result<()> {
// Don't skip exercises on file changes to avoid confusion from missing exercises.
// Skipping exercises must be explicit in the interactive list.
// But going back to an earlier exercise on file change is fine.
if self.app_state.current_exercise_ind() < exercise_ind {
return Ok(());
}
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()
}
/// Move on to the next exercise if the current one is done.
pub fn next_exercise(&mut self) -> Result<ExercisesProgress> {
if self.done_status == DoneStatus::Pending {
return Ok(ExercisesProgress::CurrentPending);
if matches!(self.done_status, DoneStatus::Pending) {
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)
@ -97,24 +91,24 @@ impl<'a> WatchState<'a> {
self.writer.write_all(b"\n")?;
if self.manual_run {
write!(self.writer, "{}:run / ", 'r'.bold())?;
write!(self.writer, "{}un/", 'r'.bold())?;
}
if self.done_status != DoneStatus::Pending {
write!(self.writer, "{}:next / ", 'n'.bold())?;
if !matches!(self.done_status, DoneStatus::Pending) {
write!(self.writer, "{}ext/", 'n'.bold())?;
}
if !self.show_hint {
write!(self.writer, "{}:hint / ", 'h'.bold())?;
write!(self.writer, "{}int/", 'h'.bold())?;
}
write!(self.writer, "{}:list / {}:quit ? ", 'l'.bold(), 'q'.bold())?;
write!(self.writer, "{}ist/{}uit? ", 'l'.bold(), 'q'.bold())?;
self.writer.flush()
}
pub fn render(&mut self) -> Result<()> {
// Prevent having the first line shifted if clearing wasn't successful.
// Prevent having the first line shifted.
self.writer.write_all(b"\n")?;
clear_terminal(&mut self.writer)?;
@ -131,12 +125,12 @@ impl<'a> WatchState<'a> {
)?;
}
if self.done_status != DoneStatus::Pending {
if !matches!(self.done_status, DoneStatus::Pending) {
writeln!(
self.writer,
"{}\n",
"Exercise done ✓
When you are done experimenting, enter `n` to move on to the next exercise 🦀"
When you are done experimenting, enter `n` (or `next`) to move on to the next exercise 🦀"
.bold()
.green(),
)?;
@ -146,11 +140,11 @@ When you are done experimenting, enter `n` to move on to the next exercise 🦀"
writeln!(
self.writer,
"A solution file can be found at {}\n",
style(TerminalFileLink(solution_path)).underlined().green(),
style(TerminalFileLink(solution_path)).underlined().green()
)?;
}
let line_width = terminal::size()?.0;
let line_width = size()?.0;
let progress_bar = progress_bar(
self.app_state.n_done(),
self.app_state.exercises().len() as u16,
@ -171,4 +165,15 @@ When you are done experimenting, enter `n` to move on to the next exercise 🦀"
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()
}
}

View file

@ -9,12 +9,11 @@ pub enum InputEvent {
Hint,
List,
Quit,
Unrecognized,
Unrecognized(String),
}
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 mut input = String::with_capacity(8);
let last_input_event = loop {
let terminal_event = match event::read() {
@ -29,48 +28,36 @@ pub fn terminal_event_handler(tx: Sender<WatchEvent>, manual_run: bool) {
match terminal_event {
Event::Key(key) => {
match key.kind {
KeyEventKind::Release | KeyEventKind::Repeat => continue,
KeyEventKind::Press => (),
}
if key.modifiers != KeyModifiers::NONE {
last_input_valid = false;
continue;
}
let input_event = match key.code {
KeyCode::Enter => {
if last_input_valid {
continue;
}
match key.kind {
KeyEventKind::Release => continue,
KeyEventKind::Press | KeyEventKind::Repeat => (),
}
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;
}
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()),
};
last_input_valid = true;
input_event
}
_ => {
last_input_valid = false;
continue;
}
};
if tx.send(WatchEvent::Input(input_event)).is_err() {
return;
}
if tx.send(WatchEvent::Input(input_event)).is_err() {
return;
input.clear();
}
KeyCode::Char(c) => {
input.push(c);
}
_ => (),
}
}
Event::Resize(_, _) => {