diff --git a/dev/Cargo.toml b/dev/Cargo.toml index 66bc1dfe..c57bc95d 100644 --- a/dev/Cargo.toml +++ b/dev/Cargo.toml @@ -188,6 +188,8 @@ bin = [ { name = "conversions4_sol", path = "../solutions/23_conversions/conversions4.rs" }, { name = "conversions5", path = "../exercises/23_conversions/conversions5.rs" }, { name = "conversions5_sol", path = "../solutions/23_conversions/conversions5.rs" }, + { name = "async1", path = "../exercises/24_async/async1.rs" }, + { name = "async1_sol", path = "../solutions/24_async/async1.rs" }, ] [package] @@ -196,6 +198,9 @@ edition = "2024" # Don't publish the exercises on crates.io! publish = false +[dependencies] +tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread"] } + [profile.release] panic = "abort" diff --git a/exercises/24_async/README.md b/exercises/24_async/README.md new file mode 100644 index 00000000..4e61bc32 --- /dev/null +++ b/exercises/24_async/README.md @@ -0,0 +1,13 @@ +# Async + +Asynchronous programming is a model where tasks are delegated to a runtime that executes them concurrently. +It is particularly efficient for applications where many independent IO-operations are performed, e.g. web servers. + +Rust provides the necessary primitives to do asynchronous programming in the language. +However, Rust's standard library does not include a runtime. +For these exercises, we will use the mainstream runtime called `tokio`. + +## Further information + +- [Fundamentals of Asynchronous Programming](https://doc.rust-lang.org/book/ch17-00-async-await.html) +- [Tokio documentation](https://docs.rs/tokio/latest/tokio/) diff --git a/exercises/24_async/async1.rs b/exercises/24_async/async1.rs new file mode 100644 index 00000000..4549759e --- /dev/null +++ b/exercises/24_async/async1.rs @@ -0,0 +1,40 @@ +// Alice is an elementary school teacher who needs to calculate the mean test +// score for three classes she teaches. Instead of calculating them one after +// the other, she decides to ask her friends Bob and Catherine for help. Working +// together, they can finish the job much faster. +// +// Let's simulate this using asynchronous programming. Each person is +// represented as an asynchronous task, which can be executed concurrently. + +// Async tasks need to be executed by a "runtime", which is not provided by +// Rust's standard library. Here, we use the mainstream runtime `tokio`. +// The macro `tokio::main` wraps the entire main function in a runtime. +#[tokio::main] +async fn main() { + let mean_score_a = tokio::spawn(calculate_mean_score("scores_class_a.txt")); + let mean_score_b = tokio::spawn(calculate_mean_score("scores_class_b.txt")); + let mean_score_c = tokio::spawn(calculate_mean_score("scores_class_c.txt")); + + // TODO: Await the spawned tasks to check their results. + assert_eq!(mean_score_a, 84); // alice + assert_eq!(mean_score_b, 89); // bob + assert_eq!(mean_score_c, 76); // catherine +} + +// TODO: Fix the compiler errors by making the spawned function async. +fn calculate_mean_score(scores_file: &str) -> usize { + // Read the file asynchronously + let file = tokio::fs::read_to_string(scores_file).await.unwrap(); + + // Initialize the sum and the number of scores + let mut sum = 0; + let mut n = 0; + for line in file.lines() { + // Parse every line as a score + let score = line.parse::().unwrap(); + sum += score; + n += 1; + } + + sum / n +} diff --git a/exercises/24_async/scores_class_a.txt b/exercises/24_async/scores_class_a.txt new file mode 100644 index 00000000..29fc7d8f --- /dev/null +++ b/exercises/24_async/scores_class_a.txt @@ -0,0 +1,3 @@ +83 +77 +92 diff --git a/exercises/24_async/scores_class_b.txt b/exercises/24_async/scores_class_b.txt new file mode 100644 index 00000000..7bbc2e8e --- /dev/null +++ b/exercises/24_async/scores_class_b.txt @@ -0,0 +1,3 @@ +84 +88 +96 diff --git a/exercises/24_async/scores_class_c.txt b/exercises/24_async/scores_class_c.txt new file mode 100644 index 00000000..cda6e810 --- /dev/null +++ b/exercises/24_async/scores_class_c.txt @@ -0,0 +1,3 @@ +71 +83 +76 diff --git a/rustlings-macros/info.toml b/rustlings-macros/info.toml index 97c1ccae..226f0038 100644 --- a/rustlings-macros/info.toml +++ b/rustlings-macros/info.toml @@ -1208,3 +1208,22 @@ name = "conversions5" dir = "23_conversions" hint = """ Add `AsRef` or `AsMut` as a trait bound to the functions.""" + +# ASYNC + +[[exercises]] +name = "async1" +dir = "24_async" +test = false +input_files = [ + "scores_class_a.txt", + "scores_class_b.txt", + "scores_class_c.txt", +] +hint = """ +Asynchronous runtimes like tokio can only spawn tasks that are defined as async +functions, not regular ones. Add the "async" keyword before the "fn" keyword of +the functions "tim", "carl" and "nick". + +An async task can wait for another one to complete by "awaiting" it. Add +".await" after the three "task_name" variables in the "block_on" call.""" diff --git a/rustlings-macros/src/lib.rs b/rustlings-macros/src/lib.rs index db758d59..b672b850 100644 --- a/rustlings-macros/src/lib.rs +++ b/rustlings-macros/src/lib.rs @@ -6,6 +6,8 @@ use serde::Deserialize; struct ExerciseInfo<'a> { name: &'a str, dir: &'a str, + #[serde(default)] + input_files: Vec<&'a str>, } #[derive(Deserialize)] @@ -17,9 +19,8 @@ struct InfoFile<'a> { #[proc_macro] pub fn include_files(_: TokenStream) -> TokenStream { let info_file = include_str!("../info.toml"); - let exercises = toml::de::from_str::(info_file) - .expect("Failed to parse `info.toml`") - .exercises; + let info = toml::de::from_str::(info_file).expect("Failed to parse `info.toml`"); + let exercises = info.exercises; let exercise_files = exercises .iter() @@ -42,6 +43,20 @@ pub fn include_files(_: TokenStream) -> TokenStream { *dir_ind = dirs.len() - 1; } + let input_files = exercises.iter().map(|exercise| { + let names = exercise.input_files.iter(); + let paths = exercise + .input_files + .iter() + .map(|f| format!("../exercises/{}/{}", exercise.dir, f)); + quote! { + &[#(InputFile { + name: #names, + content: include_str!(#paths), + }),*] + } + }); + let readmes = dirs .iter() .map(|dir| format!("../exercises/{dir}/README.md")); @@ -49,8 +64,13 @@ 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) }),*] + exercise_files: &[#(ExerciseFiles { + exercise: include_bytes!(#exercise_files), + solution: include_bytes!(#solution_files), + dir_ind: #dir_inds, + input_files: #input_files, + }),*], + exercise_dirs: &[#(ExerciseDir { name: #dirs, readme: include_bytes!(#readmes) }),*], } } .into() diff --git a/solutions/24_async/async1.rs b/solutions/24_async/async1.rs new file mode 100644 index 00000000..45f0ab8e --- /dev/null +++ b/solutions/24_async/async1.rs @@ -0,0 +1,38 @@ +// Alice is an elementary school teacher who needs to calculate the mean test +// score for three classes she teaches. Instead of calculating them one after +// the other, she decides to ask her friends Bob and Catherine for help. Working +// together, they can finish the job much faster. +// +// Let's simulate this using asynchronous programming. Each person is +// represented as an asynchronous task, which can be executed concurrently. + +// Async tasks need to be executed by a "runtime", which is not provided by +// Rust's standard library. Here, we use the mainstream runtime `tokio`. +// The macro `tokio::main` wraps the entire main function in a runtime. +#[tokio::main] +async fn main() { + let mean_score_a = tokio::spawn(calculate_mean_score("scores_class_a.txt")); + let mean_score_b = tokio::spawn(calculate_mean_score("scores_class_b.txt")); + let mean_score_c = tokio::spawn(calculate_mean_score("scores_class_c.txt")); + + assert_eq!(mean_score_a.await.unwrap(), 84); // alice + assert_eq!(mean_score_b.await.unwrap(), 89); // bob + assert_eq!(mean_score_c.await.unwrap(), 76); // catherine +} + +async fn calculate_mean_score(scores_file: &str) -> usize { + // Read the file asynchronously + let file = tokio::fs::read_to_string(scores_file).await.unwrap(); + + // Initialize the sum and the number of scores + let mut sum = 0; + let mut n = 0; + for line in file.lines() { + // Parse every line as a score + let score = line.parse::().unwrap(); + sum += score; + n += 1; + } + + sum / n +} diff --git a/src/app_state.rs b/src/app_state.rs index 541534a2..c29fe07f 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -81,9 +81,11 @@ impl AppState { })?; let dir_canonical_path = term::canonicalize("exercises"); + let official_exercises = !Path::new("info.toml").exists(); let mut exercises = exercise_infos .into_iter() - .map(|exercise_info| { + .enumerate() + .map(|(i, exercise_info)| { let canonical_path = dir_canonical_path.as_deref().map(|dir_canonical_path| { let mut canonical_path; if let Some(dir) = exercise_info.dir { @@ -105,10 +107,16 @@ impl AppState { canonical_path.push_str(".rs"); canonical_path }); + let embedded_input_files = if official_exercises { + EMBEDDED_FILES.exercise_files[i].input_files + } else { + &[] + }; Exercise { name: exercise_info.name, dir: exercise_info.dir, + embedded_input_files, // LEAKING: For `Editor::open`. The app state is used until the end of the program. path: exercise_info.path().leak(), canonical_path, @@ -173,7 +181,7 @@ impl AppState { final_message, state_file, file_buf, - official_exercises: !Path::new("info.toml").exists(), + official_exercises, cmd_runner, // VS Code has its own file link handling emit_file_links: !vs_code_term, @@ -597,6 +605,7 @@ mod tests { Exercise { name: "0", dir: None, + embedded_input_files: &[], path: "exercises/0.rs", canonical_path: None, test: false, diff --git a/src/cargo_toml.rs b/src/cargo_toml.rs index 24077452..fcbf0588 100644 --- a/src/cargo_toml.rs +++ b/src/cargo_toml.rs @@ -110,6 +110,7 @@ mod tests { dir: None, test: true, strict_clippy: true, + input_files: vec![], hint: String::new(), skip_check_unsolved: false, }, @@ -118,6 +119,7 @@ mod tests { dir: Some("d"), test: false, strict_clippy: false, + input_files: vec![], hint: String::new(), skip_check_unsolved: false, }, diff --git a/src/cmd.rs b/src/cmd.rs index 094db5a0..0717ee0b 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -13,7 +13,12 @@ const TIMEOUT_SECS: u64 = 30; /// 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. -fn run_cmd(mut cmd: Command, description: &str, output: Option<&mut Vec>) -> Result { +fn run_cmd( + mut cmd: Command, + description: &str, + cwd: Option<&str>, + output: Option<&mut Vec>, +) -> Result { let spawn = |mut cmd: Command| { // The closure drops `cmd` which prevents a pipe deadlock. cmd.stdin(Stdio::null()) @@ -25,6 +30,9 @@ fn run_cmd(mut cmd: Command, description: &str, output: Option<&mut Vec>) -> .wait_timeout(Duration::from_secs(TIMEOUT_SECS)) .with_context(|| format!("Failed to wait on `{description}` to exit")) }; + if let Some(cwd) = cwd { + cmd.current_dir(cwd); + } let mut handle = if let Some(output) = output { let (mut reader, writer) = @@ -133,7 +141,12 @@ impl CmdRunner { } /// The boolean in the returned `Result` is true if the command's exit status is success. - pub fn run_debug_bin(&self, bin_name: &str, output: Option<&mut Vec>) -> Result { + pub fn run_debug_bin( + &self, + bin_name: &str, + cwd: &str, + output: Option<&mut Vec>, + ) -> Result { // 7 = "/debug/".len() let mut bin_path = PathBuf::with_capacity(self.target_dir.as_os_str().len() + 7 + bin_name.len()); @@ -141,7 +154,7 @@ impl CmdRunner { bin_path.push("debug"); bin_path.push(bin_name); - run_cmd(Command::new(&bin_path), bin_name, output) + run_cmd(Command::new(&bin_path), bin_name, Some(cwd), output) } } @@ -161,7 +174,7 @@ impl CargoSubcommand<'_> { /// The boolean in the returned `Result` is true if the command's exit status is success. pub fn run(self, description: &str) -> Result { - run_cmd(self.cmd, description, self.output) + run_cmd(self.cmd, description, None, self.output) } } @@ -179,7 +192,7 @@ mod tests { cmd.arg("Hello"); let mut output = Vec::with_capacity(8); - run_cmd(cmd, "echo …", Some(&mut output)).unwrap(); + run_cmd(cmd, "echo …", None, Some(&mut output)).unwrap(); assert_eq!(output, b"Hello\n\n"); } diff --git a/src/dev/check.rs b/src/dev/check.rs index 58c2a174..a9b64316 100644 --- a/src/dev/check.rs +++ b/src/dev/check.rs @@ -133,19 +133,26 @@ fn check_info_file_exercises(info_file: &InfoFile) -> Result> { file_buf.clear(); - paths.insert(PathBuf::from(path)); + let path = PathBuf::from(path); + let parent = path.parent().unwrap(); + + for input_file in &exercise_info.input_files { + paths.insert(parent.join(input_file)); + } + + paths.insert(path); } Ok(paths) } // Check `dir` for unexpected files. -// Only Rust files in `allowed_rust_files` and `README.md` files are allowed. +// Only files in `allowed_files` and `README.md` files are allowed. // Only one level of directory nesting is allowed. -fn check_unexpected_files(dir: &str, allowed_rust_files: &HashSet) -> Result<()> { +fn check_unexpected_files(dir: &str, allowed_files: &HashSet) -> Result<()> { let unexpected_file = |path: &Path| { anyhow!( - "Found the file `{}`. Only `README.md` and Rust files related to an exercise in `info.toml` are allowed in the `{dir}` directory", + "Found the file `{}`. Only `README.md`, Rust files and input files related to an exercise in `info.toml` are allowed in the `{dir}` directory", path.display() ) }; @@ -160,7 +167,7 @@ fn check_unexpected_files(dir: &str, allowed_rust_files: &HashSet) -> R continue; } - if !allowed_rust_files.contains(&path) { + if !allowed_files.contains(&path) { return Err(unexpected_file(&path)); } @@ -187,7 +194,7 @@ fn check_unexpected_files(dir: &str, allowed_rust_files: &HashSet) -> R continue; } - if !allowed_rust_files.contains(&path) { + if !allowed_files.contains(&path) { return Err(unexpected_file(&path)); } } diff --git a/src/embedded.rs b/src/embedded.rs index bee4119c..9e2527d6 100644 --- a/src/embedded.rs +++ b/src/embedded.rs @@ -10,13 +10,21 @@ use crate::info_file::ExerciseInfo; pub static EMBEDDED_FILES: EmbeddedFiles = rustlings_macros::include_files!(); // Files related to one exercise. -struct ExerciseFiles { +pub 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, + // Files that are read by the exercise. + pub input_files: &'static [InputFile], +} + +// Input files that may be read by exercises. +pub struct InputFile { + pub name: &'static str, + pub content: &'static str, } fn create_dir_if_not_exists(path: &str) -> Result<()> { @@ -56,7 +64,7 @@ impl ExerciseDir { pub struct EmbeddedFiles { /// The content of the `info.toml` file. pub info_file: &'static str, - exercise_files: &'static [ExerciseFiles], + pub exercise_files: &'static [ExerciseFiles], pub exercise_dirs: &'static [ExerciseDir], } @@ -90,6 +98,12 @@ impl EmbeddedFiles { fs::write(&exercise_path, exercise_files.exercise) .with_context(|| format!("Failed to write the exercise file {exercise_path}"))?; + + for InputFile { name, content } in exercise_files.input_files { + let path = format!("{prefix}/{dir_name}/{name}", dir_name = dir.name); + fs::write(&path, content) + .with_context(|| format!("Failed to write the input file {path}"))?; + } } Ok(()) diff --git a/src/exercise.rs b/src/exercise.rs index 7732e1a2..180e3b12 100644 --- a/src/exercise.rs +++ b/src/exercise.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use crossterm::{ QueueableCommand, style::{Attribute, Color, ResetColor, SetAttribute, SetForegroundColor}, @@ -7,6 +7,7 @@ use std::io::{self, StdoutLock, Write}; use crate::{ cmd::CmdRunner, + embedded::InputFile, term::{self, CountedWrite, file_path, terminal_file_link, write_ansi}, }; @@ -36,6 +37,7 @@ pub fn solution_link_line( // Compilation must be done before calling this method. fn run_bin( bin_name: &str, + cwd: &str, mut output: Option<&mut Vec>, cmd_runner: &CmdRunner, ) -> Result { @@ -46,7 +48,7 @@ fn run_bin( output.push(b'\n'); } - let success = cmd_runner.run_debug_bin(bin_name, output.as_deref_mut())?; + let success = cmd_runner.run_debug_bin(bin_name, cwd, output.as_deref_mut())?; if let Some(output) = output && !success @@ -68,6 +70,7 @@ fn run_bin( pub struct Exercise { pub name: &'static str, pub dir: Option<&'static str>, + pub embedded_input_files: &'static [InputFile], /// Path of the exercise file starting with the `exercises/` directory. pub path: &'static str, pub canonical_path: Option, @@ -98,6 +101,7 @@ pub trait RunnableExercise { fn dir(&self) -> Option<&str>; fn strict_clippy(&self) -> bool; fn test(&self) -> bool; + fn embedded_input_files(&self) -> &[InputFile]; // Compile, check and run the exercise or its solution (depending on `bin_name´). // The output is written to the `output` buffer after clearing it. @@ -111,6 +115,19 @@ pub trait RunnableExercise { output.clear(); } + // Input files are already written to disk during `rustlings init`. + // We repeat it here to ensure an exercise doesn't fail because the + // input files were deleted or modified in the meantime. Note that + // `self.embedded_input_files()` is empty for community exercises. + for input_file in self.embedded_input_files() { + let name = input_file.name; + let path = match self.dir() { + Some(dir) => format!("exercises/{dir}/{name}"), + None => format!("exercises/{name}"), + }; + std::fs::write(path, input_file.content).context("failed to write input file")?; + } + let build_success = cmd_runner .cargo("build", bin_name, output.as_deref_mut()) .run("cargo build …")?; @@ -123,6 +140,14 @@ pub trait RunnableExercise { output.clear(); } + let cwd_buf; + let cwd = if let Some(dir) = self.dir() { + cwd_buf = format!("exercises/{dir}"); + cwd_buf.as_str() + } else { + "exercises" + }; + if self.test() { let output_is_some = output.is_some(); let mut test_cmd = cmd_runner.cargo("test", bin_name, output.as_deref_mut()); @@ -131,7 +156,7 @@ pub trait RunnableExercise { } let test_success = test_cmd.run("cargo test …")?; if !test_success { - run_bin(bin_name, output, cmd_runner)?; + run_bin(bin_name, cwd, output, cmd_runner)?; return Ok(false); } @@ -151,7 +176,7 @@ pub trait RunnableExercise { } let clippy_success = clippy_cmd.run("cargo clippy …")?; - let run_success = run_bin(bin_name, output, cmd_runner)?; + let run_success = run_bin(bin_name, cwd, output, cmd_runner)?; Ok(clippy_success && run_success) } @@ -215,4 +240,8 @@ impl RunnableExercise for Exercise { fn test(&self) -> bool { self.test } + + fn embedded_input_files(&self) -> &[InputFile] { + self.embedded_input_files + } } diff --git a/src/info_file.rs b/src/info_file.rs index da4086aa..047e0c18 100644 --- a/src/info_file.rs +++ b/src/info_file.rs @@ -2,7 +2,10 @@ use anyhow::{Context, Error, Result, bail}; use serde::Deserialize; use std::{fs, io::ErrorKind}; -use crate::{embedded::EMBEDDED_FILES, exercise::RunnableExercise}; +use crate::{ + embedded::{EMBEDDED_FILES, InputFile}, + exercise::RunnableExercise, +}; /// Deserialized from the `info.toml` file. #[derive(Deserialize)] @@ -17,6 +20,9 @@ pub struct ExerciseInfo<'a> { /// Deny all Clippy warnings. #[serde(default)] pub strict_clippy: bool, + // Files that are read by the exercise. + #[serde(default)] + pub input_files: Vec<&'a str>, /// The exercise's hint to be shown to the user on request. pub hint: String, /// The exercise is already solved. Ignore it when checking that all exercises are unsolved. @@ -69,6 +75,11 @@ impl RunnableExercise for ExerciseInfo<'_> { fn test(&self) -> bool { self.test } + + fn embedded_input_files(&self) -> &[InputFile] { + // We don't have the input files embedded for communtiy exercises. + &[] + } } /// The deserialized `info.toml` file.