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`. - 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`. - 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 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`. - Make sure your exercise runs with `rustlings run yourTopicN`.
- [Open a pull request](#pull-requests). - [Open a pull request](#pull-requests).

4
Cargo.lock generated
View file

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

View file

@ -8,7 +8,7 @@ exclude = [
] ]
[workspace.package] [workspace.package]
version = "6.0.0-beta.9" version = "6.0.0-beta.6"
authors = [ authors = [
"Liv <mokou@fastmail.com>", "Liv <mokou@fastmail.com>",
"Mo Bitar <mo8it@proton.me>", "Mo Bitar <mo8it@proton.me>",
@ -39,6 +39,7 @@ include = [
"/src/", "/src/",
"/exercises/", "/exercises/",
"/solutions/", "/solutions/",
"/info.toml",
# A symlink to be able to include `dev/Cargo.toml` although `dev` is excluded. # A symlink to be able to include `dev/Cargo.toml` although `dev` is excluded.
"/dev-Cargo.toml", "/dev-Cargo.toml",
"/README.md", "/README.md",
@ -53,7 +54,7 @@ hashbrown = "0.14.5"
notify-debouncer-mini = { version = "0.4.1", default-features = false } notify-debouncer-mini = { version = "0.4.1", default-features = false }
os_pipe = "1.1.5" os_pipe = "1.1.5"
ratatui = { version = "0.26.2", default-features = false, features = ["crossterm"] } 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_json = "1.0.117"
serde.workspace = true serde.workspace = true
toml_edit.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 --> <!-- TODO: Remove @6.0.0-beta.x -->
```bash ```bash
cargo install rustlings@6.0.0-beta.9 cargo install rustlings@6.0.0-beta.6
``` ```
<details> <details>
@ -44,7 +44,7 @@ cargo install rustlings@6.0.0-beta.9
<!-- TODO: Remove @6.0.0-beta.x --> <!-- TODO: Remove @6.0.0-beta.x -->
- Make sure you have the latest Rust version by running `rustup update` - 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) - Otherwise, please [report the issue](https://github.com/rust-lang/rustlings/issues/new)
</details> </details>
@ -98,7 +98,7 @@ It will rerun the current exercise automatically every time you change the exerc
<details> <details>
<summary><strong>If detecting file changes in the <code>exercises/</code> directory fails…</strong> (<em>click to expand</em>)</summary> <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). > 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 ### 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… The list allows you to…

View file

@ -1,6 +1,6 @@
// We sometimes encourage you to keep trying things on a given exercise, even // 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 // 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! // The exercise file will be reloaded when you change one of the lines below!
// Try adding a new `println!`. // 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] #[proc_macro]
pub fn include_files(_: TokenStream) -> TokenStream { pub fn include_files(_: TokenStream) -> TokenStream {
let info_file = include_str!("../info.toml"); let exercises = toml_edit::de::from_str::<InfoFile>(include_str!("../info.toml"))
let exercises = toml_edit::de::from_str::<InfoFile>(info_file)
.expect("Failed to parse `info.toml`") .expect("Failed to parse `info.toml`")
.exercises; .exercises;
@ -47,7 +46,6 @@ pub fn include_files(_: TokenStream) -> TokenStream {
quote! { quote! {
EmbeddedFiles { EmbeddedFiles {
info_file: #info_file,
exercise_files: &[#(ExerciseFiles { exercise: include_bytes!(#exercise_files), solution: include_bytes!(#solution_files), dir_ind: #dir_inds }),*], 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) }),*] 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] #[must_use]
pub enum ExercisesProgress { pub enum ExercisesProgress {
// All exercises are done.
AllDone, AllDone,
// The current exercise failed and is still pending. Pending,
CurrentPending,
// A new exercise is now pending.
NewPending,
} }
pub enum StateFileStatus { pub enum StateFileStatus {
@ -124,27 +120,7 @@ impl AppState {
let exercises = exercise_infos let exercises = exercise_infos
.into_iter() .into_iter()
.map(|exercise_info| { .map(Exercise::from)
// 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,
}
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let mut slf = Self { let mut slf = Self {
@ -218,10 +194,6 @@ impl AppState {
} }
pub fn set_current_exercise_ind(&mut self, exercise_ind: usize) -> Result<()> { 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() { if exercise_ind >= self.exercises.len() {
bail!(BAD_INDEX_ERR); bail!(BAD_INDEX_ERR);
} }
@ -330,8 +302,8 @@ impl AppState {
} }
} }
/// Official exercises: Dump the solution file form the binary and return its path. // 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. // 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>> { pub fn current_solution_path(&self) -> Result<Option<String>> {
if DEBUG_PROFILE { if DEBUG_PROFILE {
return Ok(None); 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. // 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 // 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. // 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> { pub fn done_current_exercise(&mut self, writer: &mut StdoutLock) -> Result<ExercisesProgress> {
let exercise = &mut self.exercises[self.current_exercise_ind]; let exercise = &mut self.exercises[self.current_exercise_ind];
if !exercise.done { if !exercise.done {
@ -371,7 +343,7 @@ impl AppState {
if let Some(ind) = self.next_pending_exercise_ind() { if let Some(ind) = self.next_pending_exercise_ind() {
self.set_current_exercise_ind(ind)?; self.set_current_exercise_ind(ind)?;
return Ok(ExercisesProgress::NewPending); return Ok(ExercisesProgress::Pending);
} }
writer.write_all(RERUNNING_ALL_EXERCISES_MSG)?; writer.write_all(RERUNNING_ALL_EXERCISES_MSG)?;
@ -394,7 +366,7 @@ impl AppState {
self.write()?; self.write()?;
return Ok(ExercisesProgress::NewPending); return Ok(ExercisesProgress::Pending);
} }
writeln!(writer, "{}", "ok".green())?; writeln!(writer, "{}", "ok".green())?;

View file

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

View file

@ -1,8 +1,8 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use std::{io::Read, path::Path, process::Command}; 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. // 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. // 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> { pub fn run_cmd(mut cmd: Command, description: &str, output: &mut Vec<u8>) -> Result<bool> {
let (mut reader, writer) = os_pipe::pipe() let (mut reader, writer) = os_pipe::pipe()
.with_context(|| format!("Failed to create a pipe to run the command `{description}``"))?; .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 args: &'a [&'a str],
pub exercise_name: &'a str, pub exercise_name: &'a str,
pub description: &'a str, pub description: &'a str,
/// RUSTFLAGS="-A warnings" // RUSTFLAGS="-A warnings"
pub hide_warnings: bool, 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, 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>, pub output: &'a mut Vec<u8>,
/// true while developing Rustlings. // true while developing Rustlings.
pub dev: bool, pub dev: bool,
} }
impl<'a> CargoCmd<'a> { 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> { pub fn run(&mut self) -> Result<bool> {
let mut cmd = Command::new("cargo"); let mut cmd = Command::new("cargo");
cmd.arg(self.subcommand); cmd.arg(self.subcommand);

View file

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

View file

@ -10,32 +10,30 @@ use std::{
use crate::{ use crate::{
cmd::{run_cmd, CargoCmd}, cmd::{run_cmd, CargoCmd},
in_official_repo, in_official_repo,
info_file::ExerciseInfo,
terminal_link::TerminalFileLink, terminal_link::TerminalFileLink,
DEBUG_PROFILE, DEBUG_PROFILE,
}; };
/// The initial capacity of the output buffer.
pub const OUTPUT_CAPACITY: usize = 1 << 14; pub const OUTPUT_CAPACITY: usize = 1 << 14;
/// See `info_file::ExerciseInfo`
pub struct Exercise { pub struct Exercise {
pub dir: Option<&'static str>, pub dir: Option<&'static str>,
// Exercise's unique name
pub name: &'static str, pub name: &'static str,
/// Path of the exercise file starting with the `exercises/` directory. // Exercise's path
pub path: &'static str, pub path: &'static str,
pub test: bool, pub test: bool,
pub strict_clippy: bool, pub strict_clippy: bool,
// The hint text associated with the exercise
pub hint: String, pub hint: String,
pub done: bool, pub done: bool,
} }
impl Exercise { 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> { fn run_bin(&self, output: &mut Vec<u8>, target_dir: &Path) -> Result<bool> {
writeln!(output, "{}", "Output".underlined())?; writeln!(output, "{}", "Output".underlined())?;
// 7 = "/debug/".len()
let mut bin_path = let mut bin_path =
PathBuf::with_capacity(target_dir.as_os_str().len() + 7 + self.name.len()); PathBuf::with_capacity(target_dir.as_os_str().len() + 7 + self.name.len());
bin_path.push(target_dir); 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)?; let success = run_cmd(Command::new(&bin_path), &bin_path.to_string_lossy(), output)?;
if !success { 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!( writeln!(
output, output,
"{}", "{}",
"The exercise didn't run successfully (nonzero exit code)" "The exercise didn't run successfully (nonzero exit code)"
.bold() .bold()
.red(), .red()
)?; )?;
} }
Ok(success) 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> { pub fn run(&self, output: &mut Vec<u8>, target_dir: &Path) -> Result<bool> {
output.clear(); output.clear();
@ -83,10 +76,9 @@ impl Exercise {
return Ok(false); 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(); output.clear();
// `--profile test` is required to also check code with `[cfg(test)]`.
let clippy_args: &[&str] = if self.strict_clippy { let clippy_args: &[&str] = if self.strict_clippy {
&["--profile", "test", "--", "-D", "warnings"] &["--profile", "test", "--", "-D", "warnings"]
} else { } 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 { impl Display for Exercise {
fn fmt(&self, f: &mut Formatter) -> fmt::Result { fn fmt(&self, f: &mut Formatter) -> fmt::Result {
self.path.fmt(f) self.path.fmt(f)

View file

@ -2,71 +2,44 @@ use anyhow::{bail, Context, Error, Result};
use serde::Deserialize; use serde::Deserialize;
use std::{fs, io::ErrorKind}; use std::{fs, io::ErrorKind};
use crate::embedded::EMBEDDED_FILES; // Deserialized from the `info.toml` file.
/// Deserialized from the `info.toml` file.
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ExerciseInfo { pub struct ExerciseInfo {
/// Exercise's unique name. // Name of the exercise
pub name: String, pub name: String,
/// Exercise's directory name inside the `exercises/` directory. // The exercise's directory inside the `exercises` directory
pub dir: Option<String>, pub dir: Option<String>,
#[serde(default = "default_true")] #[serde(default = "default_true")]
/// Run `cargo test` on the exercise.
pub test: bool, pub test: bool,
/// Deny all Clippy warnings.
#[serde(default)] #[serde(default)]
pub strict_clippy: bool, 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, pub hint: String,
} }
#[inline(always)] #[inline]
const fn default_true() -> bool { const fn default_true() -> bool {
true true
} }
impl ExerciseInfo { impl ExerciseInfo {
/// Path to the exercise file starting with the `exercises/` directory.
pub fn path(&self) -> String { pub fn path(&self) -> String {
let mut path = if let Some(dir) = &self.dir { if let Some(dir) = &self.dir {
// 14 = 10 + 1 + 3 format!("exercises/{dir}/{}.rs", self.name)
// 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
} else { } else {
// 13 = 10 + 3 format!("exercises/{}.rs", self.name)
// 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
} }
} }
/// The deserialized `info.toml` file.
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct InfoFile { pub struct InfoFile {
/// For possible breaking changes in the future for third-party exercises.
pub format_version: u8, pub format_version: u8,
/// Shown to users when starting with the exercises.
pub welcome_message: Option<String>, pub welcome_message: Option<String>,
/// Shown to users after finishing all exercises.
pub final_message: Option<String>, pub final_message: Option<String>,
/// List of all exercises.
pub exercises: Vec<ExerciseInfo>, pub exercises: Vec<ExerciseInfo>,
} }
impl InfoFile { 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> { pub fn parse() -> Result<Self> {
// Read a local `info.toml` if it exists. // Read a local `info.toml` if it exists.
let slf = match fs::read_to_string("info.toml") { let slf = match fs::read_to_string("info.toml") {
@ -74,7 +47,7 @@ impl InfoFile {
.context("Failed to parse the `info.toml` file")?, .context("Failed to parse the `info.toml` file")?,
Err(e) => { Err(e) => {
if e.kind() == ErrorKind::NotFound { 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"); .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}; use crate::{cargo_toml::updated_cargo_toml, embedded::EMBEDDED_FILES, info_file::InfoFile};
pub fn init() -> Result<()> { pub fn init() -> Result<()> {
// Prevent initialization in a directory that contains the file `Cargo.toml`. if Path::new("exercises").is_dir() && Path::new("Cargo.toml").is_file() {
// This can mean that Rustlings was already initialized in this directory. bail!(PROBABLY_IN_RUSTLINGS_DIR_ERR);
// Otherwise, this can cause problems with Cargo workspaces.
if Path::new("Cargo.toml").exists() {
bail!(CARGO_TOML_EXISTS_ERR);
} }
let rustlings_path = Path::new("rustlings"); let rustlings_path = Path::new("rustlings");
@ -27,7 +24,7 @@ pub fn init() -> Result<()> {
} }
set_current_dir("rustlings") 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()?; let info_file = InfoFile::parse()?;
EMBEDDED_FILES EMBEDDED_FILES
@ -40,10 +37,9 @@ pub fn init() -> Result<()> {
.as_bytes() .as_bytes()
.iter() .iter()
.position(|c| *c == b'\n') .position(|c| *c == b'\n')
.context("The embedded `Cargo.toml` is empty or contains only one line")?; .context("The embedded `Cargo.toml` is empty or contains only one line.")?;
let current_cargo_toml = current_cargo_toml let current_cargo_toml =
.get(newline_ind + 1..) &current_cargo_toml[(newline_ind + 1).min(current_cargo_toml.len() - 1)..];
.context("The embedded `Cargo.toml` contains only one line")?;
let updated_cargo_toml = updated_cargo_toml(&info_file.exercises, current_cargo_toml, b"") let updated_cargo_toml = updated_cargo_toml(&info_file.exercises, current_cargo_toml, b"")
.context("Failed to generate `Cargo.toml`")?; .context("Failed to generate `Cargo.toml`")?;
fs::write("Cargo.toml", updated_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"]}"#; 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. If you didn't already initialize Rustlings, please initialize it in another directory.";
Otherwise, please run `rustlings init` again in another directory.";
const RUSTLINGS_DIR_ALREADY_EXISTS_ERR: &str = const RUSTLINGS_DIR_ALREADY_EXISTS_ERR: &str =
"A directory with the name `rustlings` already exists in the current directory. "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) { pub fn select_first(&mut self) {
if self.n_rows > 0 { if self.n_rows > 0 {
self.table_state.select(Some(0)); self.table_state.select(Some(0));
} }
} }
#[inline]
pub fn select_last(&mut self) { pub fn select_last(&mut self) {
if self.n_rows > 0 { if self.n_rows > 0 {
self.table_state.select(Some(self.n_rows - 1)); self.table_state.select(Some(self.n_rows - 1));

View file

@ -56,7 +56,7 @@ fn press_enter_prompt() -> io::Result<()> {
struct Args { struct Args {
#[command(subcommand)] #[command(subcommand)]
command: Option<Subcommands>, 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. /// Only use this if Rustlings fails to detect exercise file changes.
#[arg(long)] #[arg(long)]
manual_run: bool, manual_run: bool,

View file

@ -12,7 +12,6 @@ const MIN_LINE_WIDTH: u16 = WRAPPER_WIDTH + 4;
const PROGRESS_EXCEEDS_MAX_ERR: &str = const PROGRESS_EXCEEDS_MAX_ERR: &str =
"The progress of the progress bar is higher than the maximum"; "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> { pub fn progress_bar(progress: u16, total: u16, line_width: u16) -> Result<String> {
use crossterm::style::Stylize; use crossterm::style::Stylize;
@ -55,8 +54,6 @@ pub fn progress_bar(progress: u16, total: u16, line_width: u16) -> Result<String
Ok(line) 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>> { pub fn progress_bar_ratatui(progress: u16, total: u16, line_width: u16) -> Result<Line<'static>> {
use ratatui::style::Stylize; 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)? { match app_state.done_current_exercise(&mut stdout)? {
ExercisesProgress::AllDone => (), ExercisesProgress::AllDone => (),
ExercisesProgress::CurrentPending | ExercisesProgress::NewPending => println!( ExercisesProgress::Pending => println!(
"Next exercise: {}", "Next exercise: {}",
app_state.current_exercise().terminal_link(), 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> { impl<'a> Display for TerminalFileLink<'a> {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
let path = fs::canonicalize(self.0); if let Ok(Some(canonical_path)) = fs::canonicalize(self.0)
.as_deref()
if let Some(path) = path.as_deref().ok().and_then(|path| path.to_str()) { .map(|path| path.to_str())
// Windows itself can't handle its verbatim paths. {
#[cfg(windows)] write!(
let path = if path.len() > 5 && &path[0..4] == r"\\?\" { f,
&path[4..] "\x1b]8;;file://{}\x1b\\{}\x1b]8;;\x1b\\",
} else { canonical_path, self.0,
path )
};
write!(f, "\x1b]8;;file://{path}\x1b\\{}\x1b]8;;\x1b\\", self.0)
} else { } else {
write!(f, "{}", self.0) write!(f, "{}", self.0)
} }

View file

@ -14,7 +14,7 @@ use std::{
use crate::app_state::{AppState, ExercisesProgress}; use crate::app_state::{AppState, ExercisesProgress};
use self::{ use self::{
notify_event::NotifyEventHandler, notify_event::DebounceEventHandler,
state::WatchState, state::WatchState,
terminal_event::{terminal_event_handler, InputEvent}, terminal_event::{terminal_event_handler, InputEvent},
}; };
@ -40,7 +40,6 @@ pub enum WatchExit {
List, List,
} }
/// `notify_exercise_names` as None activates the manual run mode.
pub fn watch( pub fn watch(
app_state: &mut AppState, app_state: &mut AppState,
notify_exercise_names: Option<&'static [&'static [u8]]>, 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 _debouncer_guard = if let Some(exercise_names) = notify_exercise_names {
let mut debouncer = new_debouncer( let mut debouncer = new_debouncer(
Duration::from_millis(200), Duration::from_millis(200),
NotifyEventHandler { DebounceEventHandler {
tx: tx.clone(), tx: tx.clone(),
exercise_names, exercise_names,
}, },
@ -80,8 +79,7 @@ pub fn watch(
match event { match event {
WatchEvent::Input(InputEvent::Next) => match watch_state.next_exercise()? { WatchEvent::Input(InputEvent::Next) => match watch_state.next_exercise()? {
ExercisesProgress::AllDone => break, ExercisesProgress::AllDone => break,
ExercisesProgress::CurrentPending => watch_state.render()?, ExercisesProgress::Pending => watch_state.run_current_exercise()?,
ExercisesProgress::NewPending => watch_state.run_current_exercise()?,
}, },
WatchEvent::Input(InputEvent::Hint) => { WatchEvent::Input(InputEvent::Hint) => {
watch_state.show_hint()?; watch_state.show_hint()?;
@ -94,9 +92,11 @@ pub fn watch(
break; break;
} }
WatchEvent::Input(InputEvent::Run) => watch_state.run_current_exercise()?, 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 } => { WatchEvent::FileChange { exercise_ind } => {
watch_state.handle_file_change(exercise_ind)?; watch_state.run_exercise_with_ind(exercise_ind)?;
} }
WatchEvent::TerminalResize => { WatchEvent::TerminalResize => {
watch_state.render()?; watch_state.render()?;
@ -124,5 +124,5 @@ The automatic detection of exercise file changes failed :(
Please try running `rustlings` again. Please try running `rustlings` again.
If you keep getting this error, run `rustlings --manual-run` to deactivate the file watcher. 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; use super::WatchEvent;
pub struct NotifyEventHandler { pub struct DebounceEventHandler {
pub tx: Sender<WatchEvent>, pub tx: Sender<WatchEvent>,
/// Used to report which exercise was modified.
pub exercise_names: &'static [&'static [u8]], pub exercise_names: &'static [&'static [u8]],
} }
impl notify_debouncer_mini::DebounceEventHandler for NotifyEventHandler { impl notify_debouncer_mini::DebounceEventHandler for DebounceEventHandler {
fn handle_event(&mut self, input_event: DebounceEventResult) { fn handle_event(&mut self, event: DebounceEventResult) {
let output_event = match input_event { let event = match event {
Ok(input_event) => { Ok(event) => {
let Some(exercise_ind) = input_event let Some(exercise_ind) = event
.iter() .iter()
.filter_map(|input_event| { .filter_map(|event| {
if input_event.kind != DebouncedEventKind::Any { if event.kind != DebouncedEventKind::Any {
return None; 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 { if file_name.len() < 4 {
return None; return None;
@ -47,6 +46,6 @@ impl notify_debouncer_mini::DebounceEventHandler for NotifyEventHandler {
// An error occurs when the receiver is dropped. // An error occurs when the receiver is dropped.
// After dropping the receiver, the debouncer guard should also be 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 anyhow::Result;
use crossterm::{ use crossterm::{
style::{style, Stylize}, style::{style, Stylize},
terminal, terminal::size,
}; };
use std::io::{self, StdoutLock, Write}; use std::io::{self, StdoutLock, Write};
@ -13,7 +13,6 @@ use crate::{
terminal_link::TerminalFileLink, terminal_link::TerminalFileLink,
}; };
#[derive(PartialEq, Eq)]
enum DoneStatus { enum DoneStatus {
DoneWithSolution(String), DoneWithSolution(String),
DoneWithoutSolution, DoneWithoutSolution,
@ -72,22 +71,17 @@ impl<'a> WatchState<'a> {
self.render() self.render()
} }
pub fn handle_file_change(&mut self, exercise_ind: usize) -> Result<()> { pub fn run_exercise_with_ind(&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(());
}
self.app_state.set_current_exercise_ind(exercise_ind)?; self.app_state.set_current_exercise_ind(exercise_ind)?;
self.run_current_exercise() self.run_current_exercise()
} }
/// Move on to the next exercise if the current one is done.
pub fn next_exercise(&mut self) -> Result<ExercisesProgress> { pub fn next_exercise(&mut self) -> Result<ExercisesProgress> {
if self.done_status == DoneStatus::Pending { if matches!(self.done_status, DoneStatus::Pending) {
return Ok(ExercisesProgress::CurrentPending); 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) self.app_state.done_current_exercise(&mut self.writer)
@ -97,24 +91,24 @@ impl<'a> WatchState<'a> {
self.writer.write_all(b"\n")?; self.writer.write_all(b"\n")?;
if self.manual_run { if self.manual_run {
write!(self.writer, "{}:run / ", 'r'.bold())?; write!(self.writer, "{}un/", 'r'.bold())?;
} }
if self.done_status != DoneStatus::Pending { if !matches!(self.done_status, DoneStatus::Pending) {
write!(self.writer, "{}:next / ", 'n'.bold())?; write!(self.writer, "{}ext/", 'n'.bold())?;
} }
if !self.show_hint { 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() self.writer.flush()
} }
pub fn render(&mut self) -> Result<()> { 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")?; self.writer.write_all(b"\n")?;
clear_terminal(&mut self.writer)?; 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!( writeln!(
self.writer, self.writer,
"{}\n", "{}\n",
"Exercise done ✓ "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() .bold()
.green(), .green(),
)?; )?;
@ -146,11 +140,11 @@ When you are done experimenting, enter `n` to move on to the next exercise 🦀"
writeln!( writeln!(
self.writer, self.writer,
"A solution file can be found at {}\n", "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( let progress_bar = progress_bar(
self.app_state.n_done(), self.app_state.n_done(),
self.app_state.exercises().len() as u16, 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.show_hint = true;
self.render() 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, Hint,
List, List,
Quit, Quit,
Unrecognized, Unrecognized(String),
} }
pub fn terminal_event_handler(tx: Sender<WatchEvent>, manual_run: bool) { 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 input = String::with_capacity(8);
let mut last_input_valid = false;
let last_input_event = loop { let last_input_event = loop {
let terminal_event = match event::read() { let terminal_event = match event::read() {
@ -29,48 +28,36 @@ pub fn terminal_event_handler(tx: Sender<WatchEvent>, manual_run: bool) {
match terminal_event { match terminal_event {
Event::Key(key) => { Event::Key(key) => {
match key.kind {
KeyEventKind::Release | KeyEventKind::Repeat => continue,
KeyEventKind::Press => (),
}
if key.modifiers != KeyModifiers::NONE { if key.modifiers != KeyModifiers::NONE {
last_input_valid = false;
continue; continue;
} }
let input_event = match key.code { match key.kind {
KeyCode::Enter => { KeyEventKind::Release => continue,
if last_input_valid { KeyEventKind::Press | KeyEventKind::Repeat => (),
continue; }
}
InputEvent::Unrecognized match key.code {
} KeyCode::Enter => {
KeyCode::Char(c) => { let input_event = match input.trim() {
let input_event = match c { "n" | "next" => InputEvent::Next,
'n' => InputEvent::Next, "h" | "hint" => InputEvent::Hint,
'h' => InputEvent::Hint, "l" | "list" => break InputEvent::List,
'l' => break InputEvent::List, "q" | "quit" => break InputEvent::Quit,
'q' => break InputEvent::Quit, "r" | "run" if manual_run => InputEvent::Run,
'r' if manual_run => InputEvent::Run, _ => InputEvent::Unrecognized(input.clone()),
_ => {
last_input_valid = false;
continue;
}
}; };
last_input_valid = true; if tx.send(WatchEvent::Input(input_event)).is_err() {
input_event return;
} }
_ => {
last_input_valid = false;
continue;
}
};
if tx.send(WatchEvent::Input(input_event)).is_err() { input.clear();
return; }
KeyCode::Char(c) => {
input.push(c);
}
_ => (),
} }
} }
Event::Resize(_, _) => { Event::Resize(_, _) => {