diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 5c423e24..80f052d6 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -24,8 +24,6 @@ jobs: globs: "exercises/**/*.md" - name: Run cargo fmt run: cargo fmt --all --check - - name: Run rustfmt on solutions - run: rustfmt --check --edition 2021 --color always solutions/**/*.rs test: runs-on: ${{ matrix.os }} strategy: diff --git a/.typos.toml b/.typos.toml index a74498ab..743c8741 100644 --- a/.typos.toml +++ b/.typos.toml @@ -1,7 +1,7 @@ +[default.extend-words] +"earch" = "earch" # Because of earch in the list footer + [files] extend-exclude = [ "CHANGELOG.md", ] - -[default.extend-words] -"ratatui" = "ratatui" diff --git a/CHANGELOG.md b/CHANGELOG.md index 80e000a1..19bb8fc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,62 @@ + + +## 6.3.0 (2024-08-29) + +### Added + +- Add the following exercise lints: + - `forbid(unsafe_code)`: You shouldn't write unsafe code in Rustlings. + - `forbid(unstable_features)`: You don't need unstable features in Rustlings and shouldn't rely on them while learning Rust. + - `forbid(todo)`: You forgot a `todo!()`. + - `forbid(empty_loop)`: This can only happen by mistake in Rustlings. + - `deny(infinite_loop)`: No infinite loops are needed in Rustlings. + - `deny(mem_forget)`: You shouldn't leak memory while still learning Rust. +- Show a link to every exercise file in the list. +- Add scroll padding in the list. +- Break the help footer of the list into two lines when the terminal width isn't big enough. +- Enable scrolling with the mouse in the list. +- `dev check`: Show the progress of checks. +- `dev check`: Check that the length of all exercise names is lower than 32. +- `dev check`: Check if exercise contains no tests and isn't marked with `test = false`. + +### Changed + +- The compilation time when installing Rustlings is reduced. +- Pressing `c` in the list for "continue on" now quits the list after setting the selected exercise as the current one. +- Better highlighting of the solution file after an exercise is done. +- Don't show the output of successful tests anymore. Instead, show the pretty output for tests. +- Be explicit about `q` only quitting the list and not the whole program in the list. +- Be explicit about `r` only resetting one exercise (the selected one) in the list. +- Ignore the standard output of `git init`. +- `threads3`: Remove the queue length and improve tests. +- `errors4`: Use match instead of a comparison chain in the solution. +- `functions3`: Only take `u8` to avoid using a too high number of iterations by mistake. +- `dev check`: Always check with strict Clippy (warnings to errors) when checking the solutions. + +### Fixed + +- Fix the error on some systems about too many open files during the final check of all exercises. +- Fix the list when the terminal height is too low. +- Restore the terminal after an error in the list. + + + +## 6.2.0 (2024-08-09) + +### Added + +- Show a message before checking and running an exercise. This gives the user instant feedback and avoids confusion if the checks take too long. +- Show a helpful error message when trying to install Rustlings with a Rust version lower than the minimum one that Rustlings supports. +- Add a `README.md` file to the `solutions/` directory. +- Allow initializing Rustlings in a Cargo workspace. +- `dev check`: Check that all solutions are formatted with `rustfmt`. + +### Changed + +- Remove the state file and the solutions directory from the generated `.gitignore` file. +- Run the final check of all exercises in parallel. +- Small exercise improvements. + ## 6.1.0 (2024-07-10) diff --git a/Cargo.lock b/Cargo.lock index f3824f16..1ac56b40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,38 +2,11 @@ # It is not intended for manual editing. version = 3 -[[package]] -name = "ahash" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "aho-corasick" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" -dependencies = [ - "memchr", -] - -[[package]] -name = "allocator-api2" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c6cb57a04249c6480766f7f7cef5467412af1490f8d1e243141daddada3264f" - [[package]] name = "anstream" -version = "0.6.14" +version = "0.6.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "418c75fa768af9c03be99d17643f93f79bbba589895012a80e3452a19ddda15b" +checksum = "64e15c1ab1f89faffbf04a634d5e1962e9074f2741eef6d97f3c4e322426d526" dependencies = [ "anstyle", "anstyle-parse", @@ -46,33 +19,33 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "038dfcf04a5feb68e9c60b21c9625a54c2c0616e79b72b0fd87075a056ae1d1b" +checksum = "1bec1de6f59aedf83baf9ff929c98f2ad654b97c9510f4e70cf6f661d49fd5b1" [[package]] name = "anstyle-parse" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c03a11a9034d92058ceb6ee011ce58af4a9bf61491aa7e1e59ecd24bd40d22d4" +checksum = "eb47de1e80c2b463c735db5b217a0ddc39d612e7ac9e2e96a5aed1f57616c1cb" dependencies = [ "utf8parse", ] [[package]] name = "anstyle-query" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad186efb764318d35165f1758e7dcef3b10628e26d41a44bc5550652e6804391" +checksum = "6d36fc52c7f6c869915e99412912f22093507da8d9e942ceaf66fe4b7c14422a" dependencies = [ "windows-sys 0.52.0", ] [[package]] name = "anstyle-wincon" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61a38449feb7068f52bb06c12759005cf459ee52bb4adc1d5a7c4322d716fb19" +checksum = "5bf74e1b6e971609db8ca7a9ce79fd5768ab6ae46441c572e46cf596f59e57f8" dependencies = [ "anstyle", "windows-sys 0.52.0", @@ -80,30 +53,15 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.86" +version = "1.0.89" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da" - -[[package]] -name = "assert_cmd" -version = "2.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed72493ac66d5804837f480ab3766c72bdfab91a65e565fc54fa9e42db0073a8" -dependencies = [ - "anstyle", - "bstr", - "doc-comment", - "predicates", - "predicates-core", - "predicates-tree", - "wait-timeout", -] +checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6" [[package]] name = "autocfg" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c4b4d0bd25bd0b74681c0ad21497610ce1b7c91b1022cd21c80c6fbdd9476b0" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "bitflags" @@ -117,32 +75,6 @@ version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" -[[package]] -name = "bstr" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05efc5cfd9110c8416e471df0e96702d58690178e206e61b7173706673c93706" -dependencies = [ - "memchr", - "regex-automata", - "serde", -] - -[[package]] -name = "cassowary" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" - -[[package]] -name = "castaway" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0abae9be0aaf9ea96a3b1b8b1b55c602ca751eba1b1500220cea4ecbafe7c0d5" -dependencies = [ - "rustversion", -] - [[package]] name = "cfg-if" version = "1.0.0" @@ -151,9 +83,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "4.5.9" +version = "4.5.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64acc1846d54c1fe936a78dc189c34e28d3f5afc348403f28ecf53660b9b8462" +checksum = "b97f376d85a664d5837dbae44bf546e6477a679ff6610010f17276f686d867e8" dependencies = [ "clap_builder", "clap_derive", @@ -161,9 +93,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.9" +version = "4.5.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fb8393d67ba2e7bfaf28a23458e4e2b543cc73a99595511eb207fdb8aede942" +checksum = "19bc80abd44e4bed93ca373a0704ccbd1b710dc5749406201bb018272808dc54" dependencies = [ "anstream", "anstyle", @@ -173,9 +105,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.8" +version = "4.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bac35c6dafb060fd4d275d9a4ffae97917c13a6327903a8be2153cd964f7085" +checksum = "4ac6a0c7b1a9e9a5186361f67dfa1b88213572f427fb9ab038efb2bd8c582dab" dependencies = [ "heck", "proc-macro2", @@ -185,55 +117,27 @@ dependencies = [ [[package]] name = "clap_lex" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b82cf0babdbd58558212896d1a4272303a57bdb245c2bf1147185fb45640e70" +checksum = "1462739cb27611015575c0c11df5df7601141071f07518d56fcc1be504cbec97" [[package]] name = "colorchoice" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422" - -[[package]] -name = "compact_str" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" -dependencies = [ - "castaway", - "cfg-if", - "itoa", - "ryu", - "static_assertions", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33480d6946193aa8033910124896ca395333cae7e2d1113d1fef6c3272217df2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ec99545bb0ed0ea7bb9b8e1e9122ea386ff8a48c0922e43f36d45ab09e0e80" +checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0" [[package]] name = "crossterm" -version = "0.27.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.6.0", "crossterm_winapi", - "libc", - "mio", + "mio 1.0.2", "parking_lot", + "rustix", "signal-hook", "signal-hook-mio", "winapi", @@ -248,24 +152,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "difflib" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" - -[[package]] -name = "doc-comment" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" - -[[package]] -name = "either" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60b1af1c220855b6ceac025d3f6ecdd2b7c4894bfe9cd9bda4fbb4bc7c0d4cf0" - [[package]] name = "equivalent" version = "1.0.1" @@ -273,24 +159,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" [[package]] -name = "filetime" -version = "0.2.23" +name = "errno" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd" +checksum = "534c5cf6194dfab3db3242765c03bbe257cf92f22b38f6bc0c58d59108a820ba" dependencies = [ - "cfg-if", "libc", - "redox_syscall 0.4.1", "windows-sys 0.52.0", ] [[package]] -name = "float-cmp" -version = "0.9.0" +name = "fastrand" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" +checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6" + +[[package]] +name = "filetime" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586" dependencies = [ - "num-traits", + "cfg-if", + "libc", + "libredox", + "windows-sys 0.59.0", ] [[package]] @@ -304,13 +197,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.14.5" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] +checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" [[package]] name = "heck" @@ -319,10 +208,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "indexmap" -version = "2.2.6" +name = "hermit-abi" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26" +checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024" + +[[package]] +name = "indexmap" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" dependencies = [ "equivalent", "hashbrown", @@ -350,18 +245,9 @@ dependencies = [ [[package]] name = "is_terminal_polyfill" -version = "1.70.0" +version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8478577c03552c21db0e2724ffb8986a5ce7af88107e6be5d2ee6e158c12800" - -[[package]] -name = "itertools" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" -dependencies = [ - "either", -] +checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" [[package]] name = "itoa" @@ -391,9 +277,26 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.155" +version = "0.2.160" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" +checksum = "f0b21006cd1874ae9e650973c565615676dc4a274c965bb0a73796dac838ce4f" + +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags 2.6.0", + "libc", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" [[package]] name = "lock_api" @@ -411,15 +314,6 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" -[[package]] -name = "lru" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc" -dependencies = [ - "hashbrown", -] - [[package]] name = "memchr" version = "2.7.4" @@ -439,10 +333,17 @@ dependencies = [ ] [[package]] -name = "normalize-line-endings" -version = "0.3.0" +name = "mio" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" +dependencies = [ + "hermit-abi", + "libc", + "log", + "wasi", + "windows-sys 0.52.0", +] [[package]] name = "notify" @@ -451,51 +352,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" dependencies = [ "bitflags 2.6.0", - "crossbeam-channel", "filetime", "fsevent-sys", "inotify", "kqueue", "libc", "log", - "mio", + "mio 0.8.11", "walkdir", "windows-sys 0.48.0", ] -[[package]] -name = "notify-debouncer-mini" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d40b221972a1fc5ef4d858a2f671fb34c75983eb385463dff3780eeff6a9d43" -dependencies = [ - "log", - "notify", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - [[package]] name = "once_cell" -version = "1.19.0" +version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" [[package]] name = "os_pipe" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29d73ba8daf8fac13b0501d1abeddcfe21ba7401ada61a819144b6c2a4f32209" +checksum = "5ffd2b0a5634335b135d5728d84c5e0fd726954b87111f7506a61c502280d982" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -516,167 +397,77 @@ checksum = "1e401f977ab385c9e4e3ab30627d6f26d00e2c73eef317493c4ec6d468726cf8" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.3", + "redox_syscall", "smallvec", "windows-targets 0.52.6", ] -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "predicates" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68b87bfd4605926cdfefc1c3b5f8fe560e3feca9d5552cf68c466d3d8236c7e8" -dependencies = [ - "anstyle", - "difflib", - "float-cmp", - "normalize-line-endings", - "predicates-core", - "regex", -] - -[[package]] -name = "predicates-core" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b794032607612e7abeb4db69adb4e33590fa6cf1149e95fd7cb00e634b92f174" - -[[package]] -name = "predicates-tree" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368ba315fb8c5052ab692e68a0eefec6ec57b23a36959c14496f0b0df2c0cecf" -dependencies = [ - "predicates-core", - "termtree", -] - [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "7c3a7fc5db1e57d5a779a352c8cdb57b29aa4c40cc69c3a68a7fedc815fbf2f9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.36" +version = "1.0.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7" +checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" dependencies = [ "proc-macro2", ] -[[package]] -name = "ratatui" -version = "0.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16546c5b5962abf8ce6e2881e722b4e0ae3b6f1a08a26ae3573c55853ca68d3" -dependencies = [ - "bitflags 2.6.0", - "cassowary", - "compact_str", - "crossterm", - "itertools", - "lru", - "paste", - "stability", - "strum", - "strum_macros", - "unicode-segmentation", - "unicode-truncate", - "unicode-width", -] - [[package]] name = "redox_syscall" -version = "0.4.1" +version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" -dependencies = [ - "bitflags 1.3.2", -] - -[[package]] -name = "redox_syscall" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a908a6e00f1fdd0dfd9c0eb08ce85126f6d8bbda50017e74bc4a4b7d4a926a4" +checksum = "9b6dfecf2c74bce2466cabf93f6664d6998a69eb21e39f4207930065b27b771f" dependencies = [ "bitflags 2.6.0", ] [[package]] -name = "regex" -version = "1.10.5" +name = "rustix" +version = "0.38.37" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91213439dad192326a0d7c6ee3955910425f441d7038e0d6933b0aec5c4517f" +checksum = "8acb788b847c24f28525660c4d7758620a7210875711f79e7f663cc152726811" dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", + "bitflags 2.6.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", ] -[[package]] -name = "regex-automata" -version = "0.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38caf58cc5ef2fed281f89292ef23f6365465ed9a41b7a7754eb4e26496c92df" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a66a03ae7c801facd77a29370b4faec201768915ac14a721ba36f20bc9c209b" - [[package]] name = "rustlings" -version = "6.1.0" +version = "6.3.0" dependencies = [ "anyhow", - "assert_cmd", "clap", "crossterm", - "hashbrown", - "notify-debouncer-mini", + "notify", "os_pipe", - "predicates", - "ratatui", + "rustix", "rustlings-macros", "serde", "serde_json", + "tempfile", "toml_edit", ] [[package]] name = "rustlings-macros" -version = "6.1.0" +version = "6.3.0" dependencies = [ "quote", "serde", "toml_edit", ] -[[package]] -name = "rustversion" -version = "1.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "955d28af4278de8121b7ebeb796b6a45735dc01436d898801014aced2773a3d6" - [[package]] name = "ryu" version = "1.0.18" @@ -700,18 +491,18 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.204" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc76f558e0cbb2a839d37354c575f1dc3fdc6546b5be373ba43d95f231bf7c12" +checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.204" +version = "1.0.210" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0cd7e117be63d3c3678776753929474f3b04a43a080c744d6b0ae2a8c28e222" +checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", @@ -720,20 +511,21 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.120" +version = "1.0.128" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e0d21c9a8cae1235ad58a00c11cb40d4b1e5c784f1ef2c537876ed6ffd8b7c5" +checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" dependencies = [ "itoa", + "memchr", "ryu", "serde", ] [[package]] name = "serde_spanned" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0" +checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" dependencies = [ "serde", ] @@ -750,12 +542,12 @@ dependencies = [ [[package]] name = "signal-hook-mio" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" +checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" dependencies = [ "libc", - "mio", + "mio 1.0.2", "signal-hook", ] @@ -774,55 +566,17 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67" -[[package]] -name = "stability" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "static_assertions" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" - [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" -[[package]] -name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn", -] - [[package]] name = "syn" -version = "2.0.72" +version = "2.0.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc4b9b9bf2add8093d3f2c0204471e951b2285580335de42f9d2534f3ae7a8af" +checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" dependencies = [ "proc-macro2", "quote", @@ -830,25 +584,32 @@ dependencies = [ ] [[package]] -name = "termtree" -version = "0.4.1" +name = "tempfile" +version = "3.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3369f5ac52d5eb6ab48c6b4ffdc8efbcad6b89c765749064ba298f2c68a16a76" +checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" +dependencies = [ + "cfg-if", + "fastrand", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] [[package]] name = "toml_datetime" -version = "0.6.6" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf" +checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" dependencies = [ "serde", ] [[package]] name = "toml_edit" -version = "0.22.16" +version = "0.22.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "278f3d518e152219c994ce877758516bca5e118eaed6996192a774fb9fbf0788" +checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" dependencies = [ "indexmap", "serde", @@ -859,32 +620,9 @@ dependencies = [ [[package]] name = "unicode-ident" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" - -[[package]] -name = "unicode-segmentation" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" - -[[package]] -name = "unicode-truncate" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" -dependencies = [ - "itertools", - "unicode-segmentation", - "unicode-width", -] - -[[package]] -name = "unicode-width" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0336d538f7abc86d282a4189614dfaa90810dfc2c6f6427eaf88e16311dd225d" +checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" [[package]] name = "utf8parse" @@ -892,21 +630,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "version_check" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" - -[[package]] -name = "wait-timeout" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" -dependencies = [ - "libc", -] - [[package]] name = "walkdir" version = "2.5.0" @@ -941,11 +664,11 @@ checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" [[package]] name = "winapi-util" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d4cc384e1e73b93bafa6fb4f1df8c41695c8a91cf9c4c64358067d15a7b6c6b" +checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -972,6 +695,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -1095,29 +827,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.6.14" +version = "0.6.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374ec40a2d767a3c1b4972d9475ecd557356637be906f2cb3f7fe17a6eb5e22f" +checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" dependencies = [ "memchr", ] - -[[package]] -name = "zerocopy" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.7.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/Cargo.toml b/Cargo.toml index f5fe7380..4dbcb5fb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,27 +1,26 @@ [workspace] resolver = "2" exclude = [ - "tests/fixture/failure", - "tests/fixture/state", - "tests/fixture/success", + "tests/test_exercises", "dev", ] [workspace.package] -version = "6.1.0" +version = "6.3.0" authors = [ - "Liv ", - "Mo Bitar ", + "Mo Bitar ", # https://github.com/mo8it + "Liv ", # https://github.com/shadows-withal # Alumni - "Carol (Nichols || Goulding) ", + "Carol (Nichols || Goulding) ", # https://github.com/carols10cents ] repository = "https://github.com/rust-lang/rustlings" license = "MIT" -edition = "2021" +edition = "2021" # On Update: Update the edition of the `rustfmt` command that checks the solutions. +rust-version = "1.80" [workspace.dependencies] -serde = { version = "1.0.204", features = ["derive"] } -toml_edit = { version = "0.22.16", default-features = false, features = ["parse", "serde"] } +serde = { version = "1.0.210", features = ["derive"] } +toml_edit = { version = "0.22.22", default-features = false, features = ["parse", "serde"] } [package] name = "rustlings" @@ -31,6 +30,7 @@ authors.workspace = true repository.workspace = true license.workspace = true edition.workspace = true +rust-version.workspace = true keywords = [ "exercise", "learning", @@ -46,21 +46,21 @@ include = [ ] [dependencies] -anyhow = "1.0.86" -clap = { version = "4.5.9", features = ["derive"] } -crossterm = "0.27.0" -hashbrown = "0.14.5" -notify-debouncer-mini = { version = "0.4.1", default-features = false } -os_pipe = "1.2.0" -ratatui = { version = "0.27.0", default-features = false, features = ["crossterm"] } -rustlings-macros = { path = "rustlings-macros", version = "=6.1.0" } -serde_json = "1.0.120" +anyhow = "1.0.89" +clap = { version = "4.5.20", features = ["derive"] } +crossterm = { version = "0.28.1", default-features = false, features = ["windows", "events"] } +notify = { version = "6.1.1", default-features = false, features = ["macos_fsevent"] } +os_pipe = "1.2.1" +rustlings-macros = { path = "rustlings-macros", version = "=6.3.0" } +serde_json = "1.0.128" serde.workspace = true toml_edit.workspace = true +[target.'cfg(not(windows))'.dependencies] +rustix = { version = "0.38.37", default-features = false, features = ["std", "stdio", "termios"] } + [dev-dependencies] -assert_cmd = "2.0.14" -predicates = "3.1.0" +tempfile = "3.13.0" [profile.release] panic = "abort" @@ -70,3 +70,21 @@ panic = "abort" [package.metadata.release] pre-release-hook = ["./release-hook.sh"] + +[workspace.lints.rust] +unsafe_code = "forbid" +unstable_features = "forbid" + +[workspace.lints.clippy] +empty_loop = "forbid" +disallowed-types = "deny" +disallowed-methods = "deny" +infinite_loop = "deny" +mem_forget = "deny" +dbg_macro = "warn" +todo = "warn" +# TODO: Remove after the following fix is released: https://github.com/rust-lang/rust-clippy/pull/13102 +needless_option_as_deref = "allow" + +[lints] +workspace = true diff --git a/README.md b/README.md index a7f81c12..9a223670 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ It contains code examples and exercises similar to Rustlings, but online. ### Installing Rust -Before installing Rustlings, you need to have _Rust installed_. +Before installing Rustlings, you need to have the **latest version of Rust** installed. Visit [www.rust-lang.org/tools/install](https://www.rust-lang.org/tools/install) for further instructions on installing Rust. This will also install _Cargo_, Rust's package/project manager. @@ -88,8 +88,6 @@ While working with Rustlings, please use a modern terminal for the best user exp The default terminal on Linux and Mac should be sufficient. On Windows, we recommend the [Windows Terminal](https://aka.ms/terminal). -If you use VS Code, the builtin terminal should also be fine. - ## Doing exercises The exercises are sorted by topic and can be found in the subdirectory `exercises/`. @@ -126,21 +124,30 @@ The list allows you to… - See the status of all exercises (done or pending) - `c`: Continue at another exercise (temporarily skip some exercises or go back to a previous one) -- `r`: Reset status and file of an exercise (you need to _reload/reopen_ its file in your editor afterwards) +- `r`: Reset status and file of the selected exercise (you need to _reload/reopen_ its file in your editor afterwards) See the footer of the list for all possible keys. +## Questions? + +If you need any help while doing the exercises and the builtin-hints aren't helpful, feel free to ask in the [_Q&A_ category of the discussions](https://github.com/rust-lang/rustlings/discussions/categories/q-a?discussions_q=) if your question wasn't asked yet 💡 + +## Third-Party Exercises + +Third-party exercises are a set of exercises maintained by the community. +You can use the same `rustlings` program that you installed with `cargo install rustlings` to run them: + +- [日本語版 Rustlings](https://github.com/sotanengel/rustlings-jp):A Japanese translation of the Rustlings exercises. + +Do you want to create your own set of Rustlings exercises to focus on some specific topic? +Or do you want to translate the original Rustlings exercises? +Then follow the the guide about [third-party exercises](https://github.com/rust-lang/rustlings/blob/main/THIRD_PARTY_EXERCISES.md)! + ## Continuing On Once you've completed Rustlings, put your new knowledge to good use! Continue practicing your Rust skills by building your own projects, contributing to Rustlings, or finding other open-source projects to contribute to. -## Third-Party Exercises - -Do you want to create your own set of Rustlings exercises to focus on some specific topic? -Or do you want to translate the original Rustlings exercises? -Then follow the link to the guide about [third-party exercises](https://github.com/rust-lang/rustlings/blob/main/THIRD_PARTY_EXERCISES.md)! - ## Uninstalling Rustlings If you want to remove Rustlings from your system, run the following command: diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 00000000..afc9253a --- /dev/null +++ b/clippy.toml @@ -0,0 +1,15 @@ +disallowed-types = [ + # Inefficient. Use `.queue(…)` instead. + "crossterm::style::Stylize", + "crossterm::style::styled_content::StyledContent", +] + +disallowed-methods = [ + # Inefficient. Use `.queue(…)` instead. + "crossterm::style::style", + # Use `thread::Builder::spawn` instead and handle the error. + "std::thread::spawn", + "std::thread::Scope::spawn", + # Return `ExitCode` instead. + "std::process::exit", +] diff --git a/dev/Cargo.toml b/dev/Cargo.toml index 7f3acb51..29a557a0 100644 --- a/dev/Cargo.toml +++ b/dev/Cargo.toml @@ -195,3 +195,29 @@ name = "exercises" edition = "2021" # Don't publish the exercises on crates.io! publish = false + +[profile.release] +panic = "abort" + +[profile.dev] +panic = "abort" + +[lints.rust] +# You shouldn't write unsafe code in Rustlings! +unsafe_code = "forbid" +# You don't need unstable features in Rustlings and shouldn't rely on them while learning Rust. +unstable_features = "forbid" +# Dead code warnings can't be avoided in some exercises and might distract while learning. +dead_code = "allow" + +[lints.clippy] +# You forgot a `todo!()`! +todo = "forbid" +# This can only happen by mistake in Rustlings. +empty_loop = "forbid" +# No infinite loops are needed in Rustlings. +infinite_loop = "deny" +# You shouldn't leak memory while still learning Rust! +mem_forget = "deny" +# Currently, there are no disallowed methods. This line avoids problems when developing Rustlings. +disallowed_methods = "allow" diff --git a/exercises/00_intro/intro1.rs b/exercises/00_intro/intro1.rs index 7b8baa22..172a6ab1 100644 --- a/exercises/00_intro/intro1.rs +++ b/exercises/00_intro/intro1.rs @@ -1,4 +1,4 @@ -// TODO: We sometimes encourage you to keep trying things on a given exercise, +// TODO: 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. // @@ -6,8 +6,7 @@ // Try adding a new `println!` and check the updated output in the terminal. fn main() { - println!("Hello and"); - println!(r#" welcome to... "#); + println!(r#" Welcome to... "#); println!(r#" _ _ _ "#); println!(r#" _ __ _ _ ___| |_| (_)_ __ __ _ ___ "#); println!(r#" | '__| | | / __| __| | | '_ \ / _` / __| "#); diff --git a/exercises/01_variables/variables1.rs b/exercises/01_variables/variables1.rs index dd96d9f3..981d8dd4 100644 --- a/exercises/01_variables/variables1.rs +++ b/exercises/01_variables/variables1.rs @@ -1,5 +1,5 @@ fn main() { - // TODO: Add missing keyword. + // TODO: Add the missing keyword. let x: i32 = 5; println!("x has the value {x}"); diff --git a/exercises/02_functions/functions3.rs b/exercises/02_functions/functions3.rs index 0ce665be..59fd310a 100644 --- a/exercises/02_functions/functions3.rs +++ b/exercises/02_functions/functions3.rs @@ -1,4 +1,4 @@ -fn call_me(num: u32) { +fn call_me(num: u8) { for i in 0..num { println!("Ring! Call number {}", i + 1); } diff --git a/exercises/03_if/if2.rs b/exercises/03_if/if2.rs index 0bdcee6b..10037f26 100644 --- a/exercises/03_if/if2.rs +++ b/exercises/03_if/if2.rs @@ -1,11 +1,9 @@ // TODO: Fix the compiler error on this function. -fn foo_if_fizz(fizzish: &str) -> &str { - if fizzish == "fizz" { - "foo" - } else if fizzish == "fuzz" { - "bar" +fn picky_eater(food: &str) -> &str { + if food == "strawberry" { + "Yummy!" } else { - "baz" + 1 } } @@ -20,18 +18,20 @@ mod tests { use super::*; #[test] - fn foo_for_fizz() { - // This means that calling `foo_if_fizz` with the argument "fizz" should return "foo". - assert_eq!(foo_if_fizz("fizz"), "foo"); + fn yummy_food() { + // This means that calling `picky_eater` with the argument "food" should return "Yummy!". + assert_eq!(picky_eater("strawberry"), "Yummy!"); } #[test] - fn bar_for_fuzz() { - assert_eq!(foo_if_fizz("fuzz"), "bar"); + fn neutral_food() { + assert_eq!(picky_eater("potato"), "I guess I can eat that."); } #[test] - fn default_to_baz() { - assert_eq!(foo_if_fizz("literally anything"), "baz"); + fn default_disliked_food() { + assert_eq!(picky_eater("broccoli"), "No thanks!"); + assert_eq!(picky_eater("gummy bears"), "No thanks!"); + assert_eq!(picky_eater("literally anything"), "No thanks!"); } } diff --git a/exercises/08_enums/enums2.rs b/exercises/08_enums/enums2.rs index bf4b8660..89b11fc4 100644 --- a/exercises/08_enums/enums2.rs +++ b/exercises/08_enums/enums2.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - #[derive(Debug)] struct Point { x: u64, diff --git a/exercises/08_enums/enums3.rs b/exercises/08_enums/enums3.rs index ada7a38e..52208e20 100644 --- a/exercises/08_enums/enums3.rs +++ b/exercises/08_enums/enums3.rs @@ -4,10 +4,7 @@ struct Point { } enum Message { - Resize { - width: u64, - height: u64, - }, + Resize { width: u64, height: u64 }, Move(Point), Echo(String), ChangeColor(u8, u8, u8), diff --git a/exercises/10_modules/modules2.rs b/exercises/10_modules/modules2.rs index 56e1402a..3cabbfd2 100644 --- a/exercises/10_modules/modules2.rs +++ b/exercises/10_modules/modules2.rs @@ -1,7 +1,6 @@ // You can bring module paths into scopes and provide new names for them with // the `use` and `as` keywords. -#[allow(dead_code)] mod delicious_snacks { // TODO: Add the following two `use` statements after fixing them. pub use self::fruits::PEAR as fruit; diff --git a/exercises/11_hashmaps/hashmaps3.rs b/exercises/11_hashmaps/hashmaps3.rs index 262e78bf..51746cc1 100644 --- a/exercises/11_hashmaps/hashmaps3.rs +++ b/exercises/11_hashmaps/hashmaps3.rs @@ -10,14 +10,14 @@ use std::collections::HashMap; // A structure to store the goal details of a team. #[derive(Default)] -struct Team { +struct TeamScores { goals_scored: u8, goals_conceded: u8, } -fn build_scores_table(results: &str) -> HashMap<&str, Team> { +fn build_scores_table(results: &str) -> HashMap<&str, TeamScores> { // The name of the team is the key and its associated struct is the value. - let mut scores = HashMap::new(); + let mut scores = HashMap::<&str, TeamScores>::new(); for line in results.lines() { let mut split_iterator = line.split(','); diff --git a/exercises/13_error_handling/errors1.rs b/exercises/13_error_handling/errors1.rs index 5ec9a6c4..e07fddc3 100644 --- a/exercises/13_error_handling/errors1.rs +++ b/exercises/13_error_handling/errors1.rs @@ -4,12 +4,12 @@ // construct to `Option` that can be used to express error conditions. Change // the function signature and body to return `Result` instead // of `Option`. -fn generate_nametag_text(name: String) -> Result { +fn generate_nametag_text(name: String) -> Option { if name.is_empty() { - // Empty names aren't allowed. - Err("Empty names aren't allowed".to_string()) + // Empty names aren't allowed + None } else { - Ok(format!("Hi! My name is {name}")) + Some(format!("Hi! My name is {name}")) } } diff --git a/exercises/13_error_handling/errors4.rs b/exercises/13_error_handling/errors4.rs index cfb2ac6e..56dac489 100644 --- a/exercises/13_error_handling/errors4.rs +++ b/exercises/13_error_handling/errors4.rs @@ -1,5 +1,3 @@ -#![allow(clippy::comparison_chain)] - #[derive(PartialEq, Debug)] enum CreationError { Negative, diff --git a/exercises/13_error_handling/errors6.rs b/exercises/13_error_handling/errors6.rs index 988afe7e..0e1dd6db 100644 --- a/exercises/13_error_handling/errors6.rs +++ b/exercises/13_error_handling/errors6.rs @@ -25,7 +25,7 @@ impl ParsePosNonzeroError { } // TODO: Add another error conversion function here. - fn from_parseint(err: ParseIntError) -> Self { Self::ParseInt(err) } + fn from_parse_int(err: ParseIntError) -> Self { Self::ParseInt(err) } } #[derive(PartialEq, Debug)] @@ -43,7 +43,7 @@ impl PositiveNonzeroInteger { fn parse(s: &str) -> Result { // TODO: change this to return an appropriate error instead of panicking // when `parse()` returns an error. - let x: i64 = s.parse().map_err(ParsePosNonzeroError::from_parseint)?; + let x: i64 = s.parse().map_err(ParsePosNonzeroError::from_parse_int)?; Self::new(x).map_err(ParsePosNonzeroError::from_creation) } } diff --git a/exercises/15_traits/traits3.rs b/exercises/15_traits/traits3.rs index fbb17298..144ac0a3 100644 --- a/exercises/15_traits/traits3.rs +++ b/exercises/15_traits/traits3.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - trait Licensed { // TODO: Add a default implementation for `licensing_info` so that // implementors like the two structs below can share that default behavior diff --git a/exercises/19_smart_pointers/rc1.rs b/exercises/19_smart_pointers/rc1.rs index 973d62e2..5c9ce846 100644 --- a/exercises/19_smart_pointers/rc1.rs +++ b/exercises/19_smart_pointers/rc1.rs @@ -8,7 +8,6 @@ use std::rc::Rc; #[derive(Debug)] struct Sun; -#[allow(dead_code)] #[derive(Debug)] enum Planet { Mercury(Rc), diff --git a/exercises/20_threads/threads1.rs b/exercises/20_threads/threads1.rs index 01f9ff44..dbc64b16 100644 --- a/exercises/20_threads/threads1.rs +++ b/exercises/20_threads/threads1.rs @@ -1,5 +1,5 @@ -// This program spawns multiple threads that each run for at least 250ms, and -// each thread returns how much time they took to complete. The program should +// This program spawns multiple threads that each runs for at least 250ms, and +// each thread returns how much time it took to complete. The program should // wait until all the spawned threads have finished and should collect their // return values into a vector. diff --git a/exercises/20_threads/threads3.rs b/exercises/20_threads/threads3.rs index 8aa7291f..6d16bd9f 100644 --- a/exercises/20_threads/threads3.rs +++ b/exercises/20_threads/threads3.rs @@ -1,7 +1,6 @@ use std::{sync::mpsc, thread, time::Duration}; struct Queue { - length: u32, first_half: Vec, second_half: Vec, } @@ -9,7 +8,6 @@ struct Queue { impl Queue { fn new() -> Self { Self { - length: 10, first_half: vec![1, 2, 3, 4, 5], second_half: vec![6, 7, 8, 9, 10], } @@ -48,17 +46,15 @@ mod tests { fn threads3() { let (tx, rx) = mpsc::channel(); let queue = Queue::new(); - let queue_length = queue.length; send_tx(queue, tx); - let mut total_received: u32 = 0; - for received in rx { - println!("Got: {received}"); - total_received += 1; + let mut received = Vec::with_capacity(10); + for value in rx { + received.push(value); } - println!("Number of received values: {total_received}"); - assert_eq!(total_received, queue_length); + received.sort(); + assert_eq!(received, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); } } diff --git a/exercises/23_conversions/as_ref_mut.rs b/exercises/23_conversions/as_ref_mut.rs index 54f0cd11..d7892dd4 100644 --- a/exercises/23_conversions/as_ref_mut.rs +++ b/exercises/23_conversions/as_ref_mut.rs @@ -2,10 +2,11 @@ // about them at https://doc.rust-lang.org/std/convert/trait.AsRef.html and // https://doc.rust-lang.org/std/convert/trait.AsMut.html, respectively. -// Obtain the number of bytes (not characters) in the given argument. +// Obtain the number of bytes (not characters) in the given argument +// (`.len()` returns the number of bytes in a string). // TODO: Add the `AsRef` trait appropriately as a trait bound. fn byte_counter(arg: T) -> usize { - arg.as_ref().as_bytes().len() + arg.as_ref().len() } // Obtain the number of characters (not bytes) in the given argument. diff --git a/exercises/23_conversions/from_str.rs b/exercises/23_conversions/from_str.rs index 4b1aaa28..ec6d3fd1 100644 --- a/exercises/23_conversions/from_str.rs +++ b/exercises/23_conversions/from_str.rs @@ -25,7 +25,7 @@ enum ParsePersonError { ParseInt(ParseIntError), } -// TODO: Complete this `From` implementation to be able to parse a `Person` +// TODO: Complete this `FromStr` implementation to be able to parse a `Person` // out of a string in the form of "Mark,20". // Note that you'll need to parse the age component into a `u8` with something // like `"4".parse::()`. diff --git a/release-hook.sh b/release-hook.sh index f021f193..d5954ca8 100755 --- a/release-hook.sh +++ b/release-hook.sh @@ -9,6 +9,5 @@ cargo upgrades # Similar to CI cargo clippy -- --deny warnings cargo fmt --all --check -rustfmt --check --edition 2021 solutions/**/*.rs cargo test --workspace --all-targets cargo run -- dev check --require-solutions diff --git a/rustlings-macros/Cargo.toml b/rustlings-macros/Cargo.toml index 20d6776e..3ed56a18 100644 --- a/rustlings-macros/Cargo.toml +++ b/rustlings-macros/Cargo.toml @@ -6,6 +6,7 @@ authors.workspace = true repository.workspace = true license.workspace = true edition.workspace = true +rust-version.workspace = true include = [ "/src/", "/info.toml", @@ -15,6 +16,9 @@ include = [ proc-macro = true [dependencies] -quote = "1.0.36" +quote = "1.0.37" serde.workspace = true toml_edit.workspace = true + +[lints] +workspace = true diff --git a/rustlings-macros/info.toml b/rustlings-macros/info.toml index 2ecb2264..e7055981 100644 --- a/rustlings-macros/info.toml +++ b/rustlings-macros/info.toml @@ -1,6 +1,7 @@ format_version = 1 -welcome_message = """Is this your first time? Don't worry, Rustlings is made for beginners! +welcome_message = """ +Is this your first time? Don't worry, Rustlings is made for beginners! We are going to teach you a lot of things about Rust, but before we can get started, here are some notes about how Rustlings operates: @@ -10,15 +11,16 @@ get started, here are some notes about how Rustlings operates: and fix them! 2. Make sure to have your editor open in the `rustlings/` directory. Rustlings will show you the path of the current exercise under the progress bar. Open - the exercise file in your editor, fix errors and save the file. Rustlings will - automatically detect the file change and rerun the exercise. If all errors are - fixed, Rustlings will ask you to move on to the next exercise. + the exercise file in your editor, fix errors and save the file. Rustlings + will automatically detect the file change and rerun the exercise. If all + errors are fixed, Rustlings will ask you to move on to the next exercise. 3. If you're stuck on an exercise, enter `h` to show a hint. -4. If an exercise doesn't make sense to you, feel free to open an issue on GitHub! - (https://github.com/rust-lang/rustlings). We look at every issue, and sometimes, - other learners do too so you can help each other out!""" +4. If an exercise doesn't make sense to you, feel free to open an issue on + GitHub! (https://github.com/rust-lang/rustlings). We look at every issue, and + sometimes, other learners do too so you can help each other out!""" -final_message = """We hope you enjoyed learning about the various aspects of Rust! +final_message = """ +We hope you enjoyed learning about the various aspects of Rust! If you noticed any issues, don't hesitate to report them on Github. You can also contribute your own exercises to help the greater community! @@ -120,10 +122,10 @@ dir = "01_variables" test = false hint = """ We know about variables and mutability, but there is another important type of -variables available: constants. +variable available: constants. -Constants are always immutable. They are declared with the keyword `const` instead -of `let`. +Constants are always immutable. They are declared with the keyword `const` +instead of `let`. The type of Constants must always be annotated. @@ -253,7 +255,7 @@ require you to type in 100 items (but you certainly can if you want!). For example, you can do: ``` -let array = ["Are we there yet?"; 10]; +let array = ["Are we there yet?"; 100]; ``` Bonus: what are some other things you could have that would return `true` @@ -319,7 +321,8 @@ hint = """ In the first function, we create an empty vector and want to push new elements to it. -In the second function, we map the values of the input and collect them into a vector. +In the second function, we map the values of the input and collect them into +a vector. After you've completed both functions, decide for yourself which approach you like better. @@ -332,8 +335,8 @@ What do you think is the more commonly used pattern under Rust developers?""" name = "move_semantics1" dir = "06_move_semantics" hint = """ -So you've got the "cannot borrow `vec` as mutable, as it is not declared as mutable" -error on the line where we push an element to the vector, right? +So you've got the "cannot borrow `vec` as mutable, as it is not declared as +mutable" error on the line where we push an element to the vector, right? The fix for this is going to be adding one keyword, and the addition is NOT on the line where we push to the vector (where the error is). @@ -369,7 +372,8 @@ hint = """ Carefully reason about the range in which each mutable reference is in scope. Does it help to update the value of `x` immediately after the mutable reference is taken? -Read more about 'Mutable References' in the book's section 'References and Borrowing': +Read more about 'Mutable References' in the book's section 'References and +Borrowing': https://doc.rust-lang.org/book/ch04-02-references-and-borrowing.html#mutable-references.""" [[exercises]] @@ -508,7 +512,8 @@ name = "strings4" dir = "09_strings" test = false hint = """ -Replace `placeholder` with either `string` or `string_slice` in the `main` function. +Replace `placeholder` with either `string` or `string_slice` in the `main` +function. Example: `placeholder("blue");` @@ -570,12 +575,8 @@ https://doc.rust-lang.org/book/ch08-03-hash-maps.html#only-inserting-a-value-if- name = "hashmaps3" dir = "11_hashmaps" hint = """ -Hint 1: Use the `entry()` and `or_insert()` (or `or_insert_with()`) methods of - `HashMap` to insert the default value of `Team` if a team doesn't - exist in the table yet. - -Learn more in The Book: -https://doc.rust-lang.org/book/ch08-03-hash-maps.html#only-inserting-a-value-if-the-key-has-no-value +Hint 1: Use the `entry()` and `or_default()` methods of `HashMap` to insert the + default value of `TeamScores` if a team doesn't exist in the table yet. Hint 2: If there is already an entry for a given key, the value returned by `entry()` can be updated based on the existing value. @@ -1139,7 +1140,7 @@ constants, but clippy recognizes those imprecise mathematical constants as a source of potential error. See the suggestions of the Clippy warning in the compile output and use the -appropriate replacement constant from `std::f32::consts`...""" +appropriate replacement constant from `std::f32::consts`.""" [[exercises]] name = "clippy2" @@ -1200,7 +1201,8 @@ hint = """ Is there an implementation of `TryFrom` in the standard library that can both do the required integer conversion and check the range of the input? -Challenge: Can you make the `TryFrom` implementations generic over many integer types?""" +Challenge: Can you make the `TryFrom` implementations generic over many integer +types?""" [[exercises]] name = "as_ref_mut" diff --git a/solutions/02_functions/functions3.rs b/solutions/02_functions/functions3.rs index c581c425..ce5fe8eb 100644 --- a/solutions/02_functions/functions3.rs +++ b/solutions/02_functions/functions3.rs @@ -1,4 +1,4 @@ -fn call_me(num: u32) { +fn call_me(num: u8) { for i in 0..num { println!("Ring! Call number {}", i + 1); } diff --git a/solutions/03_if/if2.rs b/solutions/03_if/if2.rs index 440bba05..21c0dcd3 100644 --- a/solutions/03_if/if2.rs +++ b/solutions/03_if/if2.rs @@ -1,10 +1,10 @@ -fn foo_if_fizz(fizzish: &str) -> &str { - if fizzish == "fizz" { - "foo" - } else if fizzish == "fuzz" { - "bar" +fn picky_eater(food: &str) -> &str { + if food == "strawberry" { + "Yummy!" + } else if food == "potato" { + "I guess I can eat that." } else { - "baz" + "No thanks!" } } @@ -17,17 +17,19 @@ mod tests { use super::*; #[test] - fn foo_for_fizz() { - assert_eq!(foo_if_fizz("fizz"), "foo"); + fn yummy_food() { + assert_eq!(picky_eater("strawberry"), "Yummy!"); } #[test] - fn bar_for_fuzz() { - assert_eq!(foo_if_fizz("fuzz"), "bar"); + fn neutral_food() { + assert_eq!(picky_eater("potato"), "I guess I can eat that."); } #[test] - fn default_to_baz() { - assert_eq!(foo_if_fizz("literally anything"), "baz"); + fn default_disliked_food() { + assert_eq!(picky_eater("broccoli"), "No thanks!"); + assert_eq!(picky_eater("gummy bears"), "No thanks!"); + assert_eq!(picky_eater("literally anything"), "No thanks!"); } } diff --git a/solutions/08_enums/enums2.rs b/solutions/08_enums/enums2.rs index 2ee0553a..07aee262 100644 --- a/solutions/08_enums/enums2.rs +++ b/solutions/08_enums/enums2.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - #[derive(Debug)] struct Point { x: u64, diff --git a/solutions/08_enums/enums3.rs b/solutions/08_enums/enums3.rs index 4bc26b7c..94cf250f 100644 --- a/solutions/08_enums/enums3.rs +++ b/solutions/08_enums/enums3.rs @@ -46,8 +46,8 @@ impl State { match message { Message::Resize { width, height } => self.resize(width, height), Message::Move(point) => self.move_position(point), - Message::Echo(s) => self.echo(s), - Message::ChangeColor(r, g, b) => self.change_color(r, g, b), + Message::Echo(string) => self.echo(string), + Message::ChangeColor(red, green, blue) => self.change_color(red, green, blue), Message::Quit => self.quit(), } } diff --git a/solutions/09_strings/strings4.rs b/solutions/09_strings/strings4.rs index fe4733e3..3c69b976 100644 --- a/solutions/09_strings/strings4.rs +++ b/solutions/09_strings/strings4.rs @@ -18,12 +18,11 @@ fn main() { // Here, both answers work. // `.into()` converts a type into an expected type. // If it is called where `String` is expected, it will convert `&str` to `String`. - // But if is called where `&str` is expected, then `&str` is kept `&str` since no - // conversion is needed. string("nice weather".into()); + // But if it is called where `&str` is expected, then `&str` is kept `&str` since no conversion is needed. + // If you remove the `#[allow(…)]` line, then Clippy will tell you to remove `.into()` below since it is a useless conversion. + #[allow(clippy::useless_conversion)] string_slice("nice weather".into()); - // ^^^^^^^ the compiler recommends removing the `.into()` - // call because it is a useless conversion. string(format!("Interpolation {}", "Station")); diff --git a/solutions/10_modules/modules2.rs b/solutions/10_modules/modules2.rs index 298d76eb..55c316d7 100644 --- a/solutions/10_modules/modules2.rs +++ b/solutions/10_modules/modules2.rs @@ -1,4 +1,3 @@ -#[allow(dead_code)] mod delicious_snacks { // Added `pub` and used the expected alias after `as`. pub use self::fruits::PEAR as fruit; diff --git a/solutions/11_hashmaps/hashmaps3.rs b/solutions/11_hashmaps/hashmaps3.rs index 54f480b9..433b16c3 100644 --- a/solutions/11_hashmaps/hashmaps3.rs +++ b/solutions/11_hashmaps/hashmaps3.rs @@ -10,14 +10,14 @@ use std::collections::HashMap; // A structure to store the goal details of a team. #[derive(Default)] -struct Team { +struct TeamScores { goals_scored: u8, goals_conceded: u8, } -fn build_scores_table(results: &str) -> HashMap<&str, Team> { +fn build_scores_table(results: &str) -> HashMap<&str, TeamScores> { // The name of the team is the key and its associated struct is the value. - let mut scores = HashMap::new(); + let mut scores = HashMap::<&str, TeamScores>::new(); for line in results.lines() { let mut split_iterator = line.split(','); @@ -28,13 +28,13 @@ fn build_scores_table(results: &str) -> HashMap<&str, Team> { let team_2_score: u8 = split_iterator.next().unwrap().parse().unwrap(); // Insert the default with zeros if a team doesn't exist yet. - let team_1 = scores.entry(team_1_name).or_insert_with(Team::default); + let team_1 = scores.entry(team_1_name).or_default(); // Update the values. team_1.goals_scored += team_1_score; team_1.goals_conceded += team_2_score; - // Similarely for the second team. - let team_2 = scores.entry(team_2_name).or_insert_with(Team::default); + // Similarly for the second team. + let team_2 = scores.entry(team_2_name).or_default(); team_2.goals_scored += team_2_score; team_2.goals_conceded += team_1_score; } diff --git a/solutions/13_error_handling/errors4.rs b/solutions/13_error_handling/errors4.rs index f4d39bf9..70c5f1ca 100644 --- a/solutions/13_error_handling/errors4.rs +++ b/solutions/13_error_handling/errors4.rs @@ -1,4 +1,4 @@ -#![allow(clippy::comparison_chain)] +use std::cmp::Ordering; #[derive(PartialEq, Debug)] enum CreationError { @@ -11,12 +11,10 @@ struct PositiveNonzeroInteger(u64); impl PositiveNonzeroInteger { fn new(value: i64) -> Result { - if value == 0 { - Err(CreationError::Zero) - } else if value < 0 { - Err(CreationError::Negative) - } else { - Ok(Self(value as u64)) + match value.cmp(&0) { + Ordering::Less => Err(CreationError::Negative), + Ordering::Equal => Err(CreationError::Zero), + Ordering::Greater => Ok(Self(value as u64)), } } } diff --git a/solutions/13_error_handling/errors6.rs b/solutions/13_error_handling/errors6.rs index 429d3ea3..86793619 100644 --- a/solutions/13_error_handling/errors6.rs +++ b/solutions/13_error_handling/errors6.rs @@ -24,7 +24,7 @@ impl ParsePosNonzeroError { Self::Creation(err) } - fn from_parseint(err: ParseIntError) -> Self { + fn from_parse_int(err: ParseIntError) -> Self { Self::ParseInt(err) } } @@ -44,7 +44,7 @@ impl PositiveNonzeroInteger { fn parse(s: &str) -> Result { // Return an appropriate error instead of panicking when `parse()` // returns an error. - let x: i64 = s.parse().map_err(ParsePosNonzeroError::from_parseint)?; + let x: i64 = s.parse().map_err(ParsePosNonzeroError::from_parse_int)?; // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Self::new(x).map_err(ParsePosNonzeroError::from_creation) } diff --git a/solutions/15_traits/traits3.rs b/solutions/15_traits/traits3.rs index 747d9190..3d8ec85e 100644 --- a/solutions/15_traits/traits3.rs +++ b/solutions/15_traits/traits3.rs @@ -1,5 +1,3 @@ -#![allow(dead_code)] - trait Licensed { fn licensing_info(&self) -> String { "Default license".to_string() diff --git a/solutions/18_iterators/iterators4.rs b/solutions/18_iterators/iterators4.rs index 4c3c49d9..4168835a 100644 --- a/solutions/18_iterators/iterators4.rs +++ b/solutions/18_iterators/iterators4.rs @@ -25,6 +25,7 @@ fn factorial_fold(num: u64) -> u64 { // -> 1 * 2 is calculated, then the result 2 is multiplied by // the second element 3 so the result 6 is returned. // And so on… + #[allow(clippy::unnecessary_fold)] (2..=num).fold(1, |acc, x| acc * x) } diff --git a/solutions/19_smart_pointers/rc1.rs b/solutions/19_smart_pointers/rc1.rs index 512eb9ce..c0a41abf 100644 --- a/solutions/19_smart_pointers/rc1.rs +++ b/solutions/19_smart_pointers/rc1.rs @@ -8,7 +8,6 @@ use std::rc::Rc; #[derive(Debug)] struct Sun; -#[allow(dead_code)] #[derive(Debug)] enum Planet { Mercury(Rc), diff --git a/solutions/20_threads/threads1.rs b/solutions/20_threads/threads1.rs index 7f3dd29a..1fc5bc9c 100644 --- a/solutions/20_threads/threads1.rs +++ b/solutions/20_threads/threads1.rs @@ -1,5 +1,5 @@ -// This program spawns multiple threads that each run for at least 250ms, and -// each thread returns how much time they took to complete. The program should +// This program spawns multiple threads that each runs for at least 250ms, and +// each thread returns how much time it took to complete. The program should // wait until all the spawned threads have finished and should collect their // return values into a vector. diff --git a/solutions/20_threads/threads3.rs b/solutions/20_threads/threads3.rs index cd2dfbe3..7ceefea0 100644 --- a/solutions/20_threads/threads3.rs +++ b/solutions/20_threads/threads3.rs @@ -1,7 +1,6 @@ use std::{sync::mpsc, thread, time::Duration}; struct Queue { - length: u32, first_half: Vec, second_half: Vec, } @@ -9,7 +8,6 @@ struct Queue { impl Queue { fn new() -> Self { Self { - length: 10, first_half: vec![1, 2, 3, 4, 5], second_half: vec![6, 7, 8, 9, 10], } @@ -50,17 +48,15 @@ mod tests { fn threads3() { let (tx, rx) = mpsc::channel(); let queue = Queue::new(); - let queue_length = queue.length; send_tx(queue, tx); - let mut total_received: u32 = 0; - for received in rx { - println!("Got: {received}"); - total_received += 1; + let mut received = Vec::with_capacity(10); + for value in rx { + received.push(value); } - println!("Number of received values: {total_received}"); - assert_eq!(total_received, queue_length); + received.sort(); + assert_eq!(received, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); } } diff --git a/solutions/23_conversions/as_ref_mut.rs b/solutions/23_conversions/as_ref_mut.rs index af62e2d8..a5d2d4fa 100644 --- a/solutions/23_conversions/as_ref_mut.rs +++ b/solutions/23_conversions/as_ref_mut.rs @@ -2,9 +2,10 @@ // about them at https://doc.rust-lang.org/std/convert/trait.AsRef.html and // https://doc.rust-lang.org/std/convert/trait.AsMut.html, respectively. -// Obtain the number of bytes (not characters) in the given argument. +// Obtain the number of bytes (not characters) in the given argument +// (`.len()` returns the number of bytes in a string). fn byte_counter>(arg: T) -> usize { - arg.as_ref().as_bytes().len() + arg.as_ref().len() } // Obtain the number of characters (not bytes) in the given argument. diff --git a/solutions/README.md b/solutions/README.md new file mode 100644 index 00000000..5b5176b6 --- /dev/null +++ b/solutions/README.md @@ -0,0 +1,6 @@ +# Official Rustlings solutions + +Before you finish an exercise, its solution file will only contain an empty `main` function. +The content of this file will be automatically replaced by the actual solution once you finish the exercise. + +Note that these solutions are often only _one possibility_ to solve an exercise. diff --git a/src/app_state.rs b/src/app_state.rs index e08f94c6..5979150f 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -1,32 +1,39 @@ -use anyhow::{bail, Context, Result}; -use crossterm::style::Stylize; -use serde::Deserialize; +use anyhow::{bail, Context, Error, Result}; +use crossterm::{cursor, terminal, QueueableCommand}; use std::{ - fs::{self, File}, - io::{Read, StdoutLock, Write}, - path::{Path, PathBuf}, + collections::HashSet, + env, + fs::{File, OpenOptions}, + io::{Read, Seek, StdoutLock, Write}, + path::{Path, MAIN_SEPARATOR_STR}, process::{Command, Stdio}, + sync::{ + atomic::{AtomicUsize, Ordering::Relaxed}, + mpsc, + }, + thread, }; use crate::{ clear_terminal, + cmd::CmdRunner, embedded::EMBEDDED_FILES, - exercise::{Exercise, RunnableExercise, OUTPUT_CAPACITY}, + exercise::{Exercise, RunnableExercise}, info_file::ExerciseInfo, - DEBUG_PROFILE, + term::{self, CheckProgressVisualizer}, }; const STATE_FILE_NAME: &str = ".rustlings-state.txt"; -const BAD_INDEX_ERR: &str = "The current exercise index is higher than the number of exercises"; +const DEFAULT_CHECK_PARALLELISM: usize = 8; #[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, + // The current exercise is still pending. + CurrentPending, } pub enum StateFileStatus { @@ -34,29 +41,12 @@ pub enum StateFileStatus { NotRead, } -// Parses parts of the output of `cargo metadata`. -#[derive(Deserialize)] -struct CargoMetadata { - target_directory: PathBuf, -} - -pub fn parse_target_dir() -> Result { - // Get the target directory from Cargo. - let metadata_output = Command::new("cargo") - .arg("metadata") - .arg("-q") - .arg("--format-version") - .arg("1") - .arg("--no-deps") - .stdin(Stdio::null()) - .stderr(Stdio::inherit()) - .output() - .context(CARGO_METADATA_ERR)? - .stdout; - - serde_json::de::from_slice::(&metadata_output) - .context("Failed to read the field `target_directory` from the `cargo metadata` output") - .map(|metadata| metadata.target_directory) +#[derive(Clone, Copy)] +pub enum CheckProgress { + None, + Checking, + Done, + Pending, } pub struct AppState { @@ -65,67 +55,33 @@ pub struct AppState { // Caches the number of done exercises to avoid iterating over all exercises every time. n_done: u16, final_message: String, + state_file: File, // Preallocated buffer for reading and writing the state file. file_buf: Vec, official_exercises: bool, - // Cargo's target directory. - target_dir: PathBuf, + cmd_runner: CmdRunner, + // Running in VS Code. + vs_code: bool, } impl AppState { - // Update the app state from the state file. - fn update_from_file(&mut self) -> StateFileStatus { - self.file_buf.clear(); - self.n_done = 0; - - if File::open(STATE_FILE_NAME) - .and_then(|mut file| file.read_to_end(&mut self.file_buf)) - .is_err() - { - return StateFileStatus::NotRead; - } - - // See `Self::write` for more information about the file format. - let mut lines = self.file_buf.split(|c| *c == b'\n').skip(2); - - let Some(current_exercise_name) = lines.next() else { - return StateFileStatus::NotRead; - }; - - if current_exercise_name.is_empty() || lines.next().is_none() { - return StateFileStatus::NotRead; - } - - let mut done_exercises = hashbrown::HashSet::with_capacity(self.exercises.len()); - - for done_exerise_name in lines { - if done_exerise_name.is_empty() { - break; - } - done_exercises.insert(done_exerise_name); - } - - for (ind, exercise) in self.exercises.iter_mut().enumerate() { - if done_exercises.contains(exercise.name.as_bytes()) { - exercise.done = true; - self.n_done += 1; - } - - if exercise.name.as_bytes() == current_exercise_name { - self.current_exercise_ind = ind; - } - } - - StateFileStatus::Read - } - pub fn new( exercise_infos: Vec, final_message: String, ) -> Result<(Self, StateFileStatus)> { - let target_dir = parse_target_dir()?; + let cmd_runner = CmdRunner::build()?; + let mut state_file = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(STATE_FILE_NAME) + .with_context(|| { + format!("Failed to open or create the state file {STATE_FILE_NAME}") + })?; - let exercises = exercise_infos + let dir_canonical_path = term::canonicalize("exercises"); + let mut exercises = exercise_infos .into_iter() .map(|exercise_info| { // Leaking to be able to borrow in the watch mode `Table`. @@ -134,33 +90,99 @@ impl AppState { 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.leak().trim_ascii(); - let hint = exercise_info.hint.trim().to_owned(); + let canonical_path = dir_canonical_path.as_deref().map(|dir_canonical_path| { + let mut canonical_path; + if let Some(dir) = dir { + canonical_path = String::with_capacity( + 2 + dir_canonical_path.len() + dir.len() + name.len(), + ); + canonical_path.push_str(dir_canonical_path); + canonical_path.push_str(MAIN_SEPARATOR_STR); + canonical_path.push_str(dir); + } else { + canonical_path = + String::with_capacity(1 + dir_canonical_path.len() + name.len()); + canonical_path.push_str(dir_canonical_path); + } + + canonical_path.push_str(MAIN_SEPARATOR_STR); + canonical_path.push_str(name); + canonical_path.push_str(".rs"); + canonical_path + }); Exercise { dir, name, path, + canonical_path, test: exercise_info.test, strict_clippy: exercise_info.strict_clippy, hint, - // Updated in `Self::update_from_file`. + // Updated below. done: false, } }) .collect::>(); - let mut slf = Self { - current_exercise_ind: 0, - exercises, - n_done: 0, - final_message, - file_buf: Vec::with_capacity(2048), - official_exercises: !Path::new("info.toml").exists(), - target_dir, + let mut current_exercise_ind = 0; + let mut n_done = 0; + let mut file_buf = Vec::with_capacity(2048); + let state_file_status = 'block: { + if state_file.read_to_end(&mut file_buf).is_err() { + break 'block StateFileStatus::NotRead; + } + + // See `Self::write` for more information about the file format. + let mut lines = file_buf.split(|c| *c == b'\n').skip(2); + + let Some(current_exercise_name) = lines.next() else { + break 'block StateFileStatus::NotRead; + }; + + if current_exercise_name.is_empty() || lines.next().is_none() { + break 'block StateFileStatus::NotRead; + } + + let mut done_exercises = HashSet::with_capacity(exercises.len()); + + for done_exercise_name in lines { + if done_exercise_name.is_empty() { + break; + } + done_exercises.insert(done_exercise_name); + } + + for (ind, exercise) in exercises.iter_mut().enumerate() { + if done_exercises.contains(exercise.name.as_bytes()) { + exercise.done = true; + n_done += 1; + } + + if exercise.name.as_bytes() == current_exercise_name { + current_exercise_ind = ind; + } + } + + StateFileStatus::Read }; - let state_file_status = slf.update_from_file(); + file_buf.clear(); + file_buf.extend_from_slice(STATE_FILE_HEADER); + + let slf = Self { + current_exercise_ind, + exercises, + n_done, + final_message, + state_file, + file_buf, + official_exercises: !Path::new("info.toml").exists(), + cmd_runner, + vs_code: env::var_os("TERM_PROGRAM").is_some_and(|v| v == "vscode"), + }; Ok((slf, state_file_status)) } @@ -180,14 +202,24 @@ impl AppState { self.n_done } + #[inline] + pub fn n_pending(&self) -> u16 { + self.exercises.len() as u16 - self.n_done + } + #[inline] pub fn current_exercise(&self) -> &Exercise { &self.exercises[self.current_exercise_ind] } #[inline] - pub fn target_dir(&self) -> &Path { - &self.target_dir + pub fn cmd_runner(&self) -> &CmdRunner { + &self.cmd_runner + } + + #[inline] + pub fn vs_code(&self) -> bool { + self.vs_code } // Write the state file. @@ -199,10 +231,8 @@ impl AppState { // - The fourth line is an empty line. // - All remaining lines are the names of done exercises. fn write(&mut self) -> Result<()> { - self.file_buf.clear(); + self.file_buf.truncate(STATE_FILE_HEADER.len()); - self.file_buf - .extend_from_slice(b"DON'T EDIT THIS FILE!\n\n"); self.file_buf .extend_from_slice(self.current_exercise().name.as_bytes()); self.file_buf.push(b'\n'); @@ -214,7 +244,14 @@ impl AppState { } } - fs::write(STATE_FILE_NAME, &self.file_buf) + self.state_file + .rewind() + .with_context(|| format!("Failed to rewind the state file {STATE_FILE_NAME}"))?; + self.state_file + .set_len(0) + .with_context(|| format!("Failed to truncate the state file {STATE_FILE_NAME}"))?; + self.state_file + .write_all(&self.file_buf) .with_context(|| format!("Failed to write the state file {STATE_FILE_NAME}"))?; Ok(()) @@ -246,15 +283,31 @@ impl AppState { self.write() } - pub fn set_pending(&mut self, exercise_ind: usize) -> Result<()> { + // Set the status of an exercise without saving. Returns `true` if the + // status actually changed (and thus needs saving later). + pub fn set_status(&mut self, exercise_ind: usize, done: bool) -> Result { let exercise = self .exercises .get_mut(exercise_ind) .context(BAD_INDEX_ERR)?; - if exercise.done { - exercise.done = false; + if exercise.done == done { + return Ok(false); + } + + exercise.done = done; + if done { + self.n_done += 1; + } else { self.n_done -= 1; + } + + Ok(true) + } + + // Set the status of an exercise to "pending" and save. + pub fn set_pending(&mut self, exercise_ind: usize) -> Result<()> { + if self.set_status(exercise_ind, false)? { self.write()?; } @@ -298,6 +351,7 @@ impl AppState { Ok(exercise.path) } + // Reset the exercise by index and return its name. pub fn reset_exercise_by_ind(&mut self, exercise_ind: usize) -> Result<&'static str> { if exercise_ind >= self.exercises.len() { bail!(BAD_INDEX_ERR); @@ -307,36 +361,33 @@ impl AppState { let exercise = &self.exercises[exercise_ind]; self.reset(exercise_ind, exercise.path)?; - Ok(exercise.path) + Ok(exercise.name) } // Return the index of the next pending exercise or `None` if all exercises are done. fn next_pending_exercise_ind(&self) -> Option { - if self.current_exercise_ind == self.exercises.len() - 1 { - // The last exercise is done. - // Search for exercises not done from the start. - return self.exercises[..self.current_exercise_ind] - .iter() - .position(|exercise| !exercise.done); - } - - // The done exercise isn't the last one. - // Search for a pending exercise after the current one and then from the start. - match self.exercises[self.current_exercise_ind + 1..] - .iter() - .position(|exercise| !exercise.done) - { - Some(ind) => Some(self.current_exercise_ind + 1 + ind), - None => self.exercises[..self.current_exercise_ind] - .iter() - .position(|exercise| !exercise.done), - } + let next_ind = self.current_exercise_ind + 1; + self.exercises + // If the exercise done isn't the last, search for pending exercises after it. + .get(next_ind..) + .and_then(|later_exercises| { + later_exercises + .iter() + .position(|exercise| !exercise.done) + .map(|ind| next_ind + ind) + }) + // Search from the start. + .or_else(|| { + self.exercises[..self.current_exercise_ind] + .iter() + .position(|exercise| !exercise.done) + }) } - /// Official exercises: Dump the solution file form the binary and return its path. + /// Official exercises: Dump the solution file from 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> { - if DEBUG_PROFILE { + if cfg!(debug_assertions) { return Ok(None); } @@ -347,24 +398,133 @@ impl AppState { .write_solution_to_disk(self.current_exercise_ind, current_exercise.name) .map(Some) } else { - let solution_path = if let Some(dir) = current_exercise.dir { - format!("solutions/{dir}/{}.rs", current_exercise.name) - } else { - format!("solutions/{}.rs", current_exercise.name) - }; + let sol_path = current_exercise.sol_path(); - if Path::new(&solution_path).exists() { - return Ok(Some(solution_path)); + if Path::new(&sol_path).exists() { + return Ok(Some(sol_path)); } Ok(None) } } + fn check_all_exercises_impl(&mut self, stdout: &mut StdoutLock) -> Result> { + let term_width = terminal::size() + .context("Failed to get the terminal size")? + .0; + let mut progress_visualizer = CheckProgressVisualizer::build(stdout, term_width)?; + + let next_exercise_ind = AtomicUsize::new(0); + let mut progresses = vec![CheckProgress::None; self.exercises.len()]; + + thread::scope(|s| { + let (exercise_progress_sender, exercise_progress_receiver) = mpsc::channel(); + let n_threads = thread::available_parallelism() + .map_or(DEFAULT_CHECK_PARALLELISM, |count| count.get()); + + for _ in 0..n_threads { + let exercise_progress_sender = exercise_progress_sender.clone(); + let next_exercise_ind = &next_exercise_ind; + let slf = &self; + thread::Builder::new() + .spawn_scoped(s, move || loop { + let exercise_ind = next_exercise_ind.fetch_add(1, Relaxed); + let Some(exercise) = slf.exercises.get(exercise_ind) else { + // No more exercises. + break; + }; + + if exercise_progress_sender + .send((exercise_ind, CheckProgress::Checking)) + .is_err() + { + break; + }; + + let success = exercise.run_exercise(None, &slf.cmd_runner); + let progress = match success { + Ok(true) => CheckProgress::Done, + Ok(false) => CheckProgress::Pending, + Err(_) => CheckProgress::None, + }; + + if exercise_progress_sender + .send((exercise_ind, progress)) + .is_err() + { + break; + } + }) + .context("Failed to spawn a thread to check all exercises")?; + } + + // Drop this sender to detect when the last thread is done. + drop(exercise_progress_sender); + + while let Ok((exercise_ind, progress)) = exercise_progress_receiver.recv() { + progresses[exercise_ind] = progress; + progress_visualizer.update(&progresses)?; + } + + Ok::<_, Error>(()) + })?; + + let mut first_pending_exercise_ind = None; + for exercise_ind in 0..progresses.len() { + match progresses[exercise_ind] { + CheckProgress::Done => { + self.set_status(exercise_ind, true)?; + } + CheckProgress::Pending => { + self.set_status(exercise_ind, false)?; + if first_pending_exercise_ind.is_none() { + first_pending_exercise_ind = Some(exercise_ind); + } + } + CheckProgress::None | CheckProgress::Checking => { + // If we got an error while checking all exercises in parallel, + // it could be because we exceeded the limit of open file descriptors. + // Therefore, try running exercises with errors sequentially. + progresses[exercise_ind] = CheckProgress::Checking; + progress_visualizer.update(&progresses)?; + + let exercise = &self.exercises[exercise_ind]; + let success = exercise.run_exercise(None, &self.cmd_runner)?; + if success { + progresses[exercise_ind] = CheckProgress::Done; + } else { + progresses[exercise_ind] = CheckProgress::Pending; + if first_pending_exercise_ind.is_none() { + first_pending_exercise_ind = Some(exercise_ind); + } + } + self.set_status(exercise_ind, success)?; + progress_visualizer.update(&progresses)?; + } + } + } + + self.write()?; + + Ok(first_pending_exercise_ind) + } + + // Return the exercise index of the first pending exercise found. + pub fn check_all_exercises(&mut self, stdout: &mut StdoutLock) -> Result> { + stdout.queue(cursor::Hide)?; + let res = self.check_all_exercises_impl(stdout); + stdout.queue(cursor::Show)?; + + res + } + /// 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 { + pub fn done_current_exercise( + &mut self, + stdout: &mut StdoutLock, + ) -> Result { let exercise = &mut self.exercises[self.current_exercise_ind]; if !exercise.done { exercise.done = true; @@ -373,62 +533,42 @@ impl AppState { if let Some(ind) = self.next_pending_exercise_ind() { self.set_current_exercise_ind(ind)?; + return Ok(ExercisesProgress::NewPending); + } + + if CLEAR_BEFORE_FINAL_CHECK { + clear_terminal(stdout)?; + } else { + stdout.write_all(b"\n")?; + } + + if let Some(first_pending_exercise_ind) = self.check_all_exercises(stdout)? { + self.set_current_exercise_ind(first_pending_exercise_ind)?; return Ok(ExercisesProgress::NewPending); } - writer.write_all(RERUNNING_ALL_EXERCISES_MSG)?; - - let mut output = Vec::with_capacity(OUTPUT_CAPACITY); - for (exercise_ind, exercise) in self.exercises().iter().enumerate() { - write!(writer, "Running {exercise} ... ")?; - writer.flush()?; - - let success = exercise.run_exercise(&mut output, &self.target_dir)?; - if !success { - writeln!(writer, "{}\n", "FAILED".red())?; - - self.current_exercise_ind = exercise_ind; - - // No check if the exercise is done before setting it to pending - // because no pending exercise was found. - self.exercises[exercise_ind].done = false; - self.n_done -= 1; - - self.write()?; - - return Ok(ExercisesProgress::NewPending); - } - - writeln!(writer, "{}", "ok".green())?; - } - - // Write that the last exercise is done. - self.write()?; - - clear_terminal(writer)?; - writer.write_all(FENISH_LINE.as_bytes())?; - - let final_message = self.final_message.trim(); - if !final_message.is_empty() { - writer.write_all(final_message.as_bytes())?; - writer.write_all(b"\n")?; - } + self.render_final_message(stdout)?; Ok(ExercisesProgress::AllDone) } + + pub fn render_final_message(&self, stdout: &mut StdoutLock) -> Result<()> { + clear_terminal(stdout)?; + stdout.write_all(FENISH_LINE.as_bytes())?; + + let final_message = self.final_message.trim_ascii(); + if !final_message.is_empty() { + stdout.write_all(final_message.as_bytes())?; + stdout.write_all(b"\n")?; + } + + Ok(()) + } } -const CARGO_METADATA_ERR: &str = "Failed to run the command `cargo metadata …` -Did you already install Rust? -Try running `cargo --version` to diagnose the problem."; - -const RERUNNING_ALL_EXERCISES_MSG: &[u8] = b" -All exercises seem to be done. -Recompiling and running all exercises to make sure that all of them are actually done. - -"; - +const BAD_INDEX_ERR: &str = "The current exercise index is higher than the number of exercises"; +const STATE_FILE_HEADER: &[u8] = b"DON'T EDIT THIS FILE!\n\n"; const FENISH_LINE: &str = "+----------------------------------------------------+ | You made it to the Fe-nish line! | +-------------------------- ------------------------+ @@ -460,9 +600,10 @@ mod tests { dir: None, name: "0", path: "exercises/0.rs", + canonical_path: None, test: false, strict_clippy: false, - hint: String::new(), + hint: "", done: false, } } @@ -474,9 +615,11 @@ mod tests { exercises: vec![dummy_exercise(), dummy_exercise(), dummy_exercise()], n_done: 0, final_message: String::new(), + state_file: tempfile::tempfile().unwrap(), file_buf: Vec::new(), official_exercises: true, - target_dir: PathBuf::new(), + cmd_runner: CmdRunner::build().unwrap(), + vs_code: false, }; let mut assert = |done: [bool; 3], expected: [Option; 3]| { diff --git a/src/cargo_toml.rs b/src/cargo_toml.rs index 445b6b57..8d417ffa 100644 --- a/src/cargo_toml.rs +++ b/src/cargo_toml.rs @@ -1,7 +1,7 @@ use anyhow::{Context, Result}; use std::path::Path; -use crate::info_file::ExerciseInfo; +use crate::{exercise::RunnableExercise, info_file::ExerciseInfo}; /// Initial capacity of the bins buffer. pub const BINS_BUFFER_CAPACITY: usize = 1 << 14; diff --git a/src/cmd.rs b/src/cmd.rs index 6092f531..30f988a6 100644 --- a/src/cmd.rs +++ b/src/cmd.rs @@ -1,30 +1,44 @@ -use anyhow::{Context, Result}; -use std::{io::Read, path::Path, process::Command}; +use anyhow::{bail, Context, Result}; +use serde::Deserialize; +use std::{ + io::Read, + path::PathBuf, + process::{Command, Stdio}, +}; /// 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) -> Result { - let (mut reader, writer) = os_pipe::pipe() - .with_context(|| format!("Failed to create a pipe to run the command `{description}``"))?; +fn run_cmd(mut cmd: Command, description: &str, output: Option<&mut Vec>) -> Result { + let spawn = |mut cmd: Command| { + // NOTE: The closure drops `cmd` which prevents a pipe deadlock. + cmd.stdin(Stdio::null()) + .spawn() + .with_context(|| format!("Failed to run the command `{description}`")) + }; - let writer_clone = writer.try_clone().with_context(|| { - format!("Failed to clone the pipe writer for the command `{description}`") - })?; + let mut handle = if let Some(output) = output { + let (mut reader, writer) = os_pipe::pipe().with_context(|| { + format!("Failed to create a pipe to run the command `{description}``") + })?; - let mut handle = cmd - .stdout(writer_clone) - .stderr(writer) - .spawn() - .with_context(|| format!("Failed to run the command `{description}`"))?; + let writer_clone = writer.try_clone().with_context(|| { + format!("Failed to clone the pipe writer for the command `{description}`") + })?; - // Prevent pipe deadlock. - drop(cmd); + cmd.stdout(writer_clone).stderr(writer); + let handle = spawn(cmd)?; - reader - .read_to_end(output) - .with_context(|| format!("Failed to read the output of the command `{description}`"))?; + reader + .read_to_end(output) + .with_context(|| format!("Failed to read the output of the command `{description}`"))?; - output.push(b'\n'); + output.push(b'\n'); + + handle + } else { + cmd.stdout(Stdio::null()).stderr(Stdio::null()); + spawn(cmd)? + }; handle .wait() @@ -32,50 +46,106 @@ pub fn run_cmd(mut cmd: Command, description: &str, output: &mut Vec) -> Res .map(|status| status.success()) } -pub struct CargoCmd<'a> { - pub subcommand: &'a str, - pub args: &'a [&'a str], - pub bin_name: &'a str, - pub description: &'a str, - /// RUSTFLAGS="-A warnings" - pub hide_warnings: bool, - /// Added as `--target-dir` if `Self::dev` is true. - pub target_dir: &'a Path, - /// The output buffer to append the merged stdout and stderr. - pub output: &'a mut Vec, - /// true while developing Rustlings. - pub dev: bool, +// Parses parts of the output of `cargo metadata`. +#[derive(Deserialize)] +struct CargoMetadata { + target_directory: PathBuf, } -impl<'a> CargoCmd<'a> { - /// Run `cargo SUBCOMMAND --bin EXERCISE_NAME … ARGS`. - pub fn run(&mut self) -> Result { +pub struct CmdRunner { + target_dir: PathBuf, +} + +impl CmdRunner { + pub fn build() -> Result { + // Get the target directory from Cargo. + let metadata_output = Command::new("cargo") + .arg("metadata") + .arg("-q") + .arg("--format-version") + .arg("1") + .arg("--no-deps") + .stdin(Stdio::null()) + .stderr(Stdio::inherit()) + .output() + .context(CARGO_METADATA_ERR)?; + + if !metadata_output.status.success() { + bail!("The command `cargo metadata …` failed. Are you in the `rustlings/` directory?"); + } + + let metadata: CargoMetadata = serde_json::de::from_slice(&metadata_output.stdout) + .context( + "Failed to read the field `target_directory` from the output of the command `cargo metadata …`", + )?; + + Ok(Self { + target_dir: metadata.target_directory, + }) + } + + pub fn cargo<'out>( + &self, + subcommand: &str, + bin_name: &str, + output: Option<&'out mut Vec>, + ) -> CargoSubcommand<'out> { let mut cmd = Command::new("cargo"); - cmd.arg(self.subcommand); + cmd.arg(subcommand).arg("-q").arg("--bin").arg(bin_name); // A hack to make `cargo run` work when developing Rustlings. - if self.dev { - cmd.arg("--manifest-path") - .arg("dev/Cargo.toml") - .arg("--target-dir") - .arg(self.target_dir); + #[cfg(debug_assertions)] + cmd.arg("--manifest-path") + .arg("dev/Cargo.toml") + .arg("--target-dir") + .arg(&self.target_dir); + + if output.is_some() { + cmd.arg("--color").arg("always"); } - cmd.arg("--color") - .arg("always") - .arg("-q") - .arg("--bin") - .arg(self.bin_name) - .args(self.args); + CargoSubcommand { cmd, output } + } - if self.hide_warnings { - cmd.env("RUSTFLAGS", "-A warnings"); - } + /// 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 { + // 7 = "/debug/".len() + let mut bin_path = + PathBuf::with_capacity(self.target_dir.as_os_str().len() + 7 + bin_name.len()); + bin_path.push(&self.target_dir); + bin_path.push("debug"); + bin_path.push(bin_name); - run_cmd(cmd, self.description, self.output) + run_cmd(Command::new(&bin_path), &bin_path.to_string_lossy(), output) } } +pub struct CargoSubcommand<'out> { + cmd: Command, + output: Option<&'out mut Vec>, +} + +impl CargoSubcommand<'_> { + #[inline] + pub fn args<'arg, I>(&mut self, args: I) -> &mut Self + where + I: IntoIterator, + { + self.cmd.args(args); + self + } + + /// The boolean in the returned `Result` is true if the command's exit status is success. + #[inline] + pub fn run(self, description: &str) -> Result { + run_cmd(self.cmd, description, self.output) + } +} + +const CARGO_METADATA_ERR: &str = "Failed to run the command `cargo metadata …` +Did you already install Rust? +Try running `cargo --version` to diagnose the problem."; + #[cfg(test)] mod tests { use super::*; @@ -86,7 +156,7 @@ mod tests { cmd.arg("Hello"); let mut output = Vec::with_capacity(8); - run_cmd(cmd, "echo …", &mut output).unwrap(); + run_cmd(cmd, "echo …", Some(&mut output)).unwrap(); assert_eq!(output, b"Hello\n\n"); } diff --git a/src/dev.rs b/src/dev.rs index 5f7e64c8..8af40d69 100644 --- a/src/dev.rs +++ b/src/dev.rs @@ -2,8 +2,6 @@ use anyhow::{bail, Context, Result}; use clap::Subcommand; use std::path::PathBuf; -use crate::DEBUG_PROFILE; - mod check; mod new; mod update; @@ -32,7 +30,7 @@ impl DevCommands { pub fn run(self) -> Result<()> { match self { Self::New { path, no_git } => { - if DEBUG_PROFILE { + if cfg!(debug_assertions) { bail!("Disabled in the debug build"); } diff --git a/src/dev/check.rs b/src/dev/check.rs index 5c35462c..956c2be2 100644 --- a/src/dev/check.rs +++ b/src/dev/check.rs @@ -1,56 +1,60 @@ -use anyhow::{anyhow, bail, Context, Result}; +use anyhow::{anyhow, bail, Context, Error, Result}; use std::{ cmp::Ordering, + collections::HashSet, fs::{self, read_dir, OpenOptions}, io::{self, Read, Write}, path::{Path, PathBuf}, - sync::{ - atomic::{self, AtomicBool}, - Mutex, - }, + process::{Command, Stdio}, thread, }; use crate::{ - app_state::parse_target_dir, cargo_toml::{append_bins, bins_start_end_ind, BINS_BUFFER_CAPACITY}, + cmd::CmdRunner, exercise::{RunnableExercise, OUTPUT_CAPACITY}, info_file::{ExerciseInfo, InfoFile}, - CURRENT_FORMAT_VERSION, DEBUG_PROFILE, + CURRENT_FORMAT_VERSION, }; +const MAX_N_EXERCISES: usize = 999; +const MAX_EXERCISE_NAME_LEN: usize = 32; + // Find a char that isn't allowed in the exercise's `name` or `dir`. fn forbidden_char(input: &str) -> Option { input.chars().find(|c| !c.is_alphanumeric() && *c != '_') } -// Check that the Cargo.toml file is up-to-date. +// Check that the `Cargo.toml` file is up-to-date. fn check_cargo_toml( exercise_infos: &[ExerciseInfo], - current_cargo_toml: &str, + cargo_toml_path: &str, exercise_path_prefix: &[u8], ) -> Result<()> { - let (bins_start_ind, bins_end_ind) = bins_start_end_ind(current_cargo_toml)?; + let current_cargo_toml = fs::read_to_string(cargo_toml_path) + .with_context(|| format!("Failed to read the file `{cargo_toml_path}`"))?; + + let (bins_start_ind, bins_end_ind) = bins_start_end_ind(¤t_cargo_toml)?; let old_bins = ¤t_cargo_toml.as_bytes()[bins_start_ind..bins_end_ind]; let mut new_bins = Vec::with_capacity(BINS_BUFFER_CAPACITY); append_bins(&mut new_bins, exercise_infos, exercise_path_prefix); if old_bins != new_bins { - if DEBUG_PROFILE { - bail!("The file `dev/Cargo.toml` is outdated. Please run `cargo run -- dev update` to update it"); + if cfg!(debug_assertions) { + bail!("The file `dev/Cargo.toml` is outdated. Run `cargo run -- dev update` to update it. Then run `cargo run -- dev check` again"); } - bail!("The file `Cargo.toml` is outdated. Please run `rustlings dev update` to update it"); + bail!("The file `Cargo.toml` is outdated. Run `rustlings dev update` to update it. Then run `rustlings dev check` again"); } Ok(()) } // Check the info of all exercises and return their paths in a set. -fn check_info_file_exercises(info_file: &InfoFile) -> Result> { - let mut names = hashbrown::HashSet::with_capacity(info_file.exercises.len()); - let mut paths = hashbrown::HashSet::with_capacity(info_file.exercises.len()); +fn check_info_file_exercises(info_file: &InfoFile) -> Result> { + let mut names = HashSet::with_capacity(info_file.exercises.len()); + let mut paths = HashSet::with_capacity(info_file.exercises.len()); let mut file_buf = String::with_capacity(1 << 14); for exercise_info in &info_file.exercises { @@ -58,6 +62,9 @@ fn check_info_file_exercises(info_file: &InfoFile) -> Result MAX_EXERCISE_NAME_LEN { + bail!("The length of the exercise name `{name}` is bigger than the maximum {MAX_EXERCISE_NAME_LEN}"); + } if let Some(c) = forbidden_char(name) { bail!("Char `{c}` in the exercise name `{name}` is not allowed"); } @@ -71,7 +78,7 @@ fn check_info_file_exercises(info_file: &InfoFile) -> Result Result Result, -) -> Result<()> { +fn check_unexpected_files(dir: &str, allowed_rust_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", path.display()) }; @@ -162,135 +171,204 @@ fn check_unexpected_files( Ok(()) } -fn check_exercises_unsolved(info_file: &InfoFile, target_dir: &Path) -> Result<()> { - let error_occurred = AtomicBool::new(false); +fn check_exercises_unsolved( + info_file: &'static InfoFile, + cmd_runner: &'static CmdRunner, +) -> Result<()> { + let mut stdout = io::stdout().lock(); + stdout.write_all(b"Running all exercises to check that they aren't already solved...\n")?; - println!( - "Running all exercises to check that they aren't already solved. This may take a while…\n", - ); - thread::scope(|s| { - for exercise_info in &info_file.exercises { + let handles = info_file + .exercises + .iter() + .filter_map(|exercise_info| { if exercise_info.skip_check_unsolved { - continue; + return None; } - s.spawn(|| { - let error = |e| { - let mut stderr = io::stderr().lock(); - stderr.write_all(e).unwrap(); - stderr.write_all(b"\nProblem with the exercise ").unwrap(); - stderr.write_all(exercise_info.name.as_bytes()).unwrap(); - stderr.write_all(SEPARATOR).unwrap(); - error_occurred.store(true, atomic::Ordering::Relaxed); - }; + Some( + thread::Builder::new() + .spawn(|| exercise_info.run_exercise(None, cmd_runner)) + .map(|handle| (exercise_info.name.as_str(), handle)), + ) + }) + .collect::, _>>() + .context("Failed to spawn a thread to check if an exercise is already solved")?; - let mut output = Vec::with_capacity(OUTPUT_CAPACITY); - match exercise_info.run_exercise(&mut output, target_dir) { - Ok(true) => error(b"Already solved!"), - Ok(false) => (), - Err(e) => error(e.to_string().as_bytes()), - } - }); + let n_handles = handles.len(); + write!(stdout, "Progress: 0/{n_handles}")?; + stdout.flush()?; + let mut handle_num = 1; + + for (exercise_name, handle) in handles { + let Ok(result) = handle.join() else { + bail!("Panic while trying to run the exercise {exercise_name}"); + }; + + match result { + Ok(true) => { + bail!("The exercise {exercise_name} is already solved.\n{SKIP_CHECK_UNSOLVED_HINT}",) + } + Ok(false) => (), + Err(e) => return Err(e), } - }); - if error_occurred.load(atomic::Ordering::Relaxed) { - bail!(CHECK_EXERCISES_UNSOLVED_ERR); + write!(stdout, "\rProgress: {handle_num}/{n_handles}")?; + stdout.flush()?; + handle_num += 1; } + stdout.write_all(b"\n")?; Ok(()) } -fn check_exercises(info_file: &InfoFile, target_dir: &Path) -> Result<()> { +fn check_exercises(info_file: &'static InfoFile, cmd_runner: &'static CmdRunner) -> Result<()> { match info_file.format_version.cmp(&CURRENT_FORMAT_VERSION) { Ordering::Less => bail!("`format_version` < {CURRENT_FORMAT_VERSION} (supported version)\nPlease migrate to the latest format version"), Ordering::Greater => bail!("`format_version` > {CURRENT_FORMAT_VERSION} (supported version)\nTry updating the Rustlings program"), Ordering::Equal => (), } + let handle = thread::Builder::new() + .spawn(move || check_exercises_unsolved(info_file, cmd_runner)) + .context("Failed to spawn a thread to check if any exercise is already solved")?; + let info_file_paths = check_info_file_exercises(info_file)?; check_unexpected_files("exercises", &info_file_paths)?; - check_exercises_unsolved(info_file, target_dir) + handle.join().unwrap() } -fn check_solutions(require_solutions: bool, info_file: &InfoFile, target_dir: &Path) -> Result<()> { - let paths = Mutex::new(hashbrown::HashSet::with_capacity(info_file.exercises.len())); - let error_occurred = AtomicBool::new(false); +enum SolutionCheck { + Success { sol_path: String }, + MissingOptional, + RunFailure { output: Vec }, + Err(Error), +} - println!("Running all solutions. This may take a while…\n"); - thread::scope(|s| { - for exercise_info in &info_file.exercises { - s.spawn(|| { - let error = |e| { - let mut stderr = io::stderr().lock(); - stderr.write_all(e).unwrap(); - stderr - .write_all(b"\nFailed to run the solution of the exercise ") - .unwrap(); - stderr.write_all(exercise_info.name.as_bytes()).unwrap(); - stderr.write_all(SEPARATOR).unwrap(); - error_occurred.store(true, atomic::Ordering::Relaxed); - }; +fn check_solutions( + require_solutions: bool, + info_file: &'static InfoFile, + cmd_runner: &'static CmdRunner, +) -> Result<()> { + let mut stdout = io::stdout().lock(); + stdout.write_all(b"Running all solutions...\n")?; - let path = exercise_info.sol_path(); - if !Path::new(&path).exists() { + let handles = info_file + .exercises + .iter() + .map(|exercise_info| { + thread::Builder::new().spawn(move || { + let sol_path = exercise_info.sol_path(); + if !Path::new(&sol_path).exists() { if require_solutions { - error(b"Solution missing"); + return SolutionCheck::Err(anyhow!( + "The solution of the exercise {} is missing", + exercise_info.name, + )); } - // No solution to check. - return; + return SolutionCheck::MissingOptional; } let mut output = Vec::with_capacity(OUTPUT_CAPACITY); - match exercise_info.run_solution(&mut output, target_dir) { - Ok(true) => { - paths.lock().unwrap().insert(PathBuf::from(path)); - } - Ok(false) => error(&output), - Err(e) => error(e.to_string().as_bytes()), + match exercise_info.run_solution(Some(&mut output), cmd_runner) { + Ok(true) => SolutionCheck::Success { sol_path }, + Ok(false) => SolutionCheck::RunFailure { output }, + Err(e) => SolutionCheck::Err(e), } - }); - } - }); + }) + }) + .collect::, _>>() + .context("Failed to spawn a thread to check a solution")?; - if error_occurred.load(atomic::Ordering::Relaxed) { - bail!("At least one solution failed. See the output above."); + let mut sol_paths = HashSet::with_capacity(info_file.exercises.len()); + let mut fmt_cmd = Command::new("rustfmt"); + fmt_cmd + .arg("--check") + .arg("--edition") + .arg("2021") + .arg("--color") + .arg("always") + .stdin(Stdio::null()); + + let n_handles = handles.len(); + write!(stdout, "Progress: 0/{n_handles}")?; + stdout.flush()?; + let mut handle_num = 1; + + for (exercise_info, handle) in info_file.exercises.iter().zip(handles) { + let Ok(check_result) = handle.join() else { + bail!( + "Panic while trying to run the solution of the exercise {}", + exercise_info.name, + ); + }; + + match check_result { + SolutionCheck::Success { sol_path } => { + fmt_cmd.arg(&sol_path); + sol_paths.insert(PathBuf::from(sol_path)); + } + SolutionCheck::MissingOptional => (), + SolutionCheck::RunFailure { output } => { + stdout.write_all(b"\n\n")?; + stdout.write_all(&output)?; + bail!( + "Running the solution of the exercise {} failed with the error above", + exercise_info.name, + ); + } + SolutionCheck::Err(e) => return Err(e), + } + + write!(stdout, "\rProgress: {handle_num}/{n_handles}")?; + stdout.flush()?; + handle_num += 1; + } + stdout.write_all(b"\n")?; + + let handle = thread::Builder::new() + .spawn(move || check_unexpected_files("solutions", &sol_paths)) + .context( + "Failed to spawn a thread to check for unexpected files in the solutions directory", + )?; + + if !fmt_cmd + .status() + .context("Failed to run `rustfmt` on all solution files")? + .success() + { + bail!("Some solutions aren't formatted. Run `rustfmt` on them"); } - check_unexpected_files("solutions", &paths.into_inner().unwrap())?; - - Ok(()) + handle.join().unwrap() } pub fn check(require_solutions: bool) -> Result<()> { let info_file = InfoFile::parse()?; - // A hack to make `cargo run -- dev check` work when developing Rustlings. - if DEBUG_PROFILE { - check_cargo_toml( - &info_file.exercises, - include_str!("../../dev-Cargo.toml"), - b"../", - )?; - } else { - let current_cargo_toml = - fs::read_to_string("Cargo.toml").context("Failed to read the file `Cargo.toml`")?; - check_cargo_toml(&info_file.exercises, ¤t_cargo_toml, b"")?; + if info_file.exercises.len() > MAX_N_EXERCISES { + bail!("The maximum number of exercises is {MAX_N_EXERCISES}"); } - let target_dir = parse_target_dir()?; - check_exercises(&info_file, &target_dir)?; - check_solutions(require_solutions, &info_file, &target_dir)?; + if cfg!(debug_assertions) { + // A hack to make `cargo run -- dev check` work when developing Rustlings. + check_cargo_toml(&info_file.exercises, "dev/Cargo.toml", b"../")?; + } else { + check_cargo_toml(&info_file.exercises, "Cargo.toml", b"")?; + } - println!("\nEverything looks fine!"); + // Leaking is fine since they are used until the end of the program. + let cmd_runner = Box::leak(Box::new(CmdRunner::build()?)); + let info_file = Box::leak(Box::new(info_file)); + + check_exercises(info_file, cmd_runner)?; + check_solutions(require_solutions, info_file, cmd_runner)?; + + println!("Everything looks fine!"); Ok(()) } -const SEPARATOR: &[u8] = - b"\n========================================================================================\n"; - -const CHECK_EXERCISES_UNSOLVED_ERR: &str = "At least one exercise is already solved or failed to run. See the output above. -If this is an intro exercise that is intended to be already solved, add `skip_check_unsolved = true` to the exercise's metadata in the `info.toml` file."; +const SKIP_CHECK_UNSOLVED_HINT: &str = "If this is an introduction exercise that is intended to be already solved, add `skip_check_unsolved = true` to the exercise's metadata in the `info.toml` file"; diff --git a/src/dev/new.rs b/src/dev/new.rs index 55d5f141..154cd224 100644 --- a/src/dev/new.rs +++ b/src/dev/new.rs @@ -6,7 +6,7 @@ use std::{ process::Command, }; -use crate::CURRENT_FORMAT_VERSION; +use crate::{init::RUST_ANALYZER_TOML, CURRENT_FORMAT_VERSION}; // Create a directory relative to the current directory and print its path. fn create_rel_dir(dir_name: &str, current_dir: &str) -> Result<()> { @@ -62,6 +62,8 @@ pub fn new(path: &Path, no_git: bool) -> Result<()> { write_rel_file("README.md", &dir_path_str, README)?; + write_rel_file("rust-analyzer.toml", &dir_path_str, RUST_ANALYZER_TOML)?; + create_rel_dir(".vscode", &dir_path_str)?; write_rel_file( ".vscode/extensions.json", @@ -76,8 +78,8 @@ pub fn new(path: &Path, no_git: bool) -> Result<()> { pub const GITIGNORE: &[u8] = b".rustlings-state.txt Cargo.lock -target -.vscode +target/ +.vscode/ !.vscode/extensions.json "; diff --git a/src/dev/update.rs b/src/dev/update.rs index 66efe3d0..6de3c8f7 100644 --- a/src/dev/update.rs +++ b/src/dev/update.rs @@ -4,18 +4,19 @@ use std::fs; use crate::{ cargo_toml::updated_cargo_toml, info_file::{ExerciseInfo, InfoFile}, - DEBUG_PROFILE, }; // Update the `Cargo.toml` file. fn update_cargo_toml( exercise_infos: &[ExerciseInfo], - current_cargo_toml: &str, - exercise_path_prefix: &[u8], cargo_toml_path: &str, + exercise_path_prefix: &[u8], ) -> Result<()> { + let current_cargo_toml = fs::read_to_string(cargo_toml_path) + .with_context(|| format!("Failed to read the file `{cargo_toml_path}`"))?; + let updated_cargo_toml = - updated_cargo_toml(exercise_infos, current_cargo_toml, exercise_path_prefix)?; + updated_cargo_toml(exercise_infos, ¤t_cargo_toml, exercise_path_prefix)?; fs::write(cargo_toml_path, updated_cargo_toml) .context("Failed to write the `Cargo.toml` file")?; @@ -26,21 +27,14 @@ fn update_cargo_toml( pub fn update() -> Result<()> { let info_file = InfoFile::parse()?; - // A hack to make `cargo run -- dev update` work when developing Rustlings. - if DEBUG_PROFILE { - update_cargo_toml( - &info_file.exercises, - include_str!("../../dev-Cargo.toml"), - b"../", - "dev/Cargo.toml", - ) - .context("Failed to update the file `dev/Cargo.toml`")?; + if cfg!(debug_assertions) { + // A hack to make `cargo run -- dev update` work when developing Rustlings. + update_cargo_toml(&info_file.exercises, "dev/Cargo.toml", b"../") + .context("Failed to update the file `dev/Cargo.toml`")?; println!("Updated `dev/Cargo.toml`"); } else { - let current_cargo_toml = - fs::read_to_string("Cargo.toml").context("Failed to read the file `Cargo.toml`")?; - update_cargo_toml(&info_file.exercises, ¤t_cargo_toml, b"", "Cargo.toml") + update_cargo_toml(&info_file.exercises, "Cargo.toml", &[]) .context("Failed to update the file `Cargo.toml`")?; println!("Updated `Cargo.toml`"); diff --git a/src/embedded.rs b/src/embedded.rs index 1dce46c5..51a14b6a 100644 --- a/src/embedded.rs +++ b/src/embedded.rs @@ -1,7 +1,7 @@ use anyhow::{Context, Error, Result}; use std::{ - fs::{create_dir, OpenOptions}, - io::{self, Write}, + fs::{self, create_dir}, + io, }; use crate::info_file::ExerciseInfo; @@ -9,29 +9,6 @@ use crate::info_file::ExerciseInfo; /// Contains all embedded files. pub static EMBEDDED_FILES: EmbeddedFiles = rustlings_macros::include_files!(); -#[derive(Clone, Copy)] -pub enum WriteStrategy { - IfNotExists, - Overwrite, -} - -impl WriteStrategy { - fn write(self, path: &str, content: &[u8]) -> Result<()> { - let file = match self { - Self::IfNotExists => OpenOptions::new().create_new(true).write(true).open(path), - Self::Overwrite => OpenOptions::new() - .create(true) - .write(true) - .truncate(true) - .open(path), - }; - - file.with_context(|| format!("Failed to open the file `{path}` in write mode"))? - .write_all(content) - .with_context(|| format!("Failed to write the file {path}")) - } -} - // Files related to one exercise. struct ExerciseFiles { // The content of the exercise file. @@ -42,6 +19,16 @@ struct ExerciseFiles { dir_ind: usize, } +fn create_dir_if_not_exists(path: &str) -> Result<()> { + if let Err(e) = create_dir(path) { + if e.kind() != io::ErrorKind::AlreadyExists { + return Err(Error::from(e).context(format!("Failed to create the directory {path}"))); + } + } + + Ok(()) +} + // A directory in the `exercises/` directory. pub struct ExerciseDir { pub name: &'static str, @@ -55,21 +42,13 @@ impl ExerciseDir { let mut dir_path = String::with_capacity(20 + self.name.len()); dir_path.push_str("exercises/"); dir_path.push_str(self.name); - - if let Err(e) = create_dir(&dir_path) { - if e.kind() == io::ErrorKind::AlreadyExists { - return Ok(()); - } - - return Err( - Error::from(e).context(format!("Failed to create the directory {dir_path}")) - ); - } + create_dir_if_not_exists(&dir_path)?; let mut readme_path = dir_path; readme_path.push_str("/README.md"); - WriteStrategy::Overwrite.write(&readme_path, self.readme) + fs::write(&readme_path, self.readme) + .with_context(|| format!("Failed to write the file {readme_path}")) } } @@ -86,17 +65,31 @@ impl EmbeddedFiles { pub fn init_exercises_dir(&self, exercise_infos: &[ExerciseInfo]) -> Result<()> { create_dir("exercises").context("Failed to create the directory `exercises`")?; - WriteStrategy::IfNotExists.write( + fs::write( "exercises/README.md", include_bytes!("../exercises/README.md"), - )?; + ) + .context("Failed to write the file exercises/README.md")?; for dir in self.exercise_dirs { dir.init_on_disk()?; } + let mut exercise_path = String::with_capacity(64); + let prefix = "exercises/"; + exercise_path.push_str(prefix); + for (exercise_info, exercise_files) in exercise_infos.iter().zip(self.exercise_files) { - WriteStrategy::IfNotExists.write(&exercise_info.path(), exercise_files.exercise)?; + let dir = &self.exercise_dirs[exercise_files.dir_ind]; + + exercise_path.truncate(prefix.len()); + exercise_path.push_str(dir.name); + exercise_path.push('/'); + exercise_path.push_str(&exercise_info.name); + exercise_path.push_str(".rs"); + + fs::write(&exercise_path, exercise_files.exercise) + .with_context(|| format!("Failed to write the exercise file {exercise_path}"))?; } Ok(()) @@ -107,7 +100,8 @@ impl EmbeddedFiles { let dir = &self.exercise_dirs[exercise_files.dir_ind]; dir.init_on_disk()?; - WriteStrategy::Overwrite.write(path, exercise_files.exercise) + fs::write(path, exercise_files.exercise) + .with_context(|| format!("Failed to write the exercise file {path}")) } /// Write the solution file to disk and return its path. @@ -116,19 +110,25 @@ impl EmbeddedFiles { exercise_ind: usize, exercise_name: &str, ) -> Result { + create_dir_if_not_exists("solutions")?; + let exercise_files = &self.exercise_files[exercise_ind]; let dir = &self.exercise_dirs[exercise_files.dir_ind]; // 14 = 10 + 1 + 3 // solutions/ + / + .rs - let mut solution_path = String::with_capacity(14 + dir.name.len() + exercise_name.len()); - solution_path.push_str("solutions/"); - solution_path.push_str(dir.name); + let mut dir_path = String::with_capacity(14 + dir.name.len() + exercise_name.len()); + dir_path.push_str("solutions/"); + dir_path.push_str(dir.name); + create_dir_if_not_exists(&dir_path)?; + + let mut solution_path = dir_path; solution_path.push('/'); solution_path.push_str(exercise_name); solution_path.push_str(".rs"); - WriteStrategy::Overwrite.write(&solution_path, exercise_files.solution)?; + fs::write(&solution_path, exercise_files.solution) + .with_context(|| format!("Failed to write the solution file {solution_path}"))?; Ok(solution_path) } diff --git a/src/exercise.rs b/src/exercise.rs index b6adc141..84908284 100644 --- a/src/exercise.rs +++ b/src/exercise.rs @@ -1,46 +1,58 @@ use anyhow::Result; -use crossterm::style::{style, StyledContent, Stylize}; -use std::{ - fmt::{self, Display, Formatter}, - io::Write, - path::{Path, PathBuf}, - process::Command, +use crossterm::{ + style::{Attribute, Color, ResetColor, SetAttribute, SetForegroundColor}, + QueueableCommand, }; +use std::io::{self, StdoutLock, Write}; use crate::{ - cmd::{run_cmd, CargoCmd}, - in_official_repo, - terminal_link::TerminalFileLink, - DEBUG_PROFILE, + cmd::CmdRunner, + term::{self, terminal_file_link, write_ansi, CountedWrite}, }; /// The initial capacity of the output buffer. pub const OUTPUT_CAPACITY: usize = 1 << 14; +pub fn solution_link_line(stdout: &mut StdoutLock, solution_path: &str) -> io::Result<()> { + stdout.queue(SetAttribute(Attribute::Bold))?; + stdout.write_all(b"Solution")?; + stdout.queue(ResetColor)?; + stdout.write_all(b" for comparison: ")?; + if let Some(canonical_path) = term::canonicalize(solution_path) { + terminal_file_link(stdout, solution_path, &canonical_path, Color::Cyan)?; + } else { + stdout.write_all(solution_path.as_bytes())?; + } + stdout.write_all(b"\n") +} + // Run an exercise binary and append its output to the `output` buffer. // Compilation must be done before calling this method. -fn run_bin(bin_name: &str, output: &mut Vec, target_dir: &Path) -> Result { - writeln!(output, "{}", "Output".underlined())?; +fn run_bin( + bin_name: &str, + mut output: Option<&mut Vec>, + cmd_runner: &CmdRunner, +) -> Result { + if let Some(output) = output.as_deref_mut() { + write_ansi(output, SetAttribute(Attribute::Underlined)); + output.extend_from_slice(b"Output"); + write_ansi(output, ResetColor); + output.push(b'\n'); + } - // 7 = "/debug/".len() - let mut bin_path = PathBuf::with_capacity(target_dir.as_os_str().len() + 7 + bin_name.len()); - bin_path.push(target_dir); - bin_path.push("debug"); - bin_path.push(bin_name); + let success = cmd_runner.run_debug_bin(bin_name, output.as_deref_mut())?; - 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(), - )?; + if let Some(output) = 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. + write_ansi(output, SetAttribute(Attribute::Bold)); + write_ansi(output, SetForegroundColor(Color::Red)); + output.extend_from_slice(b"The exercise didn't run successfully (nonzero exit code)"); + write_ansi(output, ResetColor); + output.push(b'\n'); + } } Ok(success) @@ -52,114 +64,127 @@ pub struct Exercise { pub name: &'static str, /// Path of the exercise file starting with the `exercises/` directory. pub path: &'static str, + pub canonical_path: Option, pub test: bool, pub strict_clippy: bool, - pub hint: String, + pub hint: &'static str, pub done: bool, } impl Exercise { - pub fn terminal_link(&self) -> StyledContent> { - style(TerminalFileLink(self.path)).underlined().blue() - } -} + pub fn terminal_file_link<'a>(&self, writer: &mut impl CountedWrite<'a>) -> io::Result<()> { + if let Some(canonical_path) = self.canonical_path.as_deref() { + return terminal_file_link(writer, self.path, canonical_path, Color::Blue); + } -impl Display for Exercise { - fn fmt(&self, f: &mut Formatter) -> fmt::Result { - self.path.fmt(f) + writer.write_str(self.path) } } pub trait RunnableExercise { fn name(&self) -> &str; + fn dir(&self) -> Option<&str>; fn strict_clippy(&self) -> bool; fn test(&self) -> bool; // Compile, check and run the exercise or its solution (depending on `bin_name´). // The output is written to the `output` buffer after clearing it. - fn run(&self, bin_name: &str, output: &mut Vec, target_dir: &Path) -> Result { - output.clear(); - - // Developing the official Rustlings. - let dev = DEBUG_PROFILE && in_official_repo(); - - let build_success = CargoCmd { - subcommand: "build", - args: &[], - bin_name, - description: "cargo build …", - hide_warnings: false, - target_dir, - output, - dev, + fn run( + &self, + bin_name: &str, + mut output: Option<&mut Vec>, + cmd_runner: &CmdRunner, + ) -> Result { + if let Some(output) = output.as_deref_mut() { + output.clear(); } - .run()?; + + let build_success = cmd_runner + .cargo("build", bin_name, output.as_deref_mut()) + .run("cargo build …")?; if !build_success { return Ok(false); } - // Discard the output of `cargo build` because it will be shown again by Clippy. - output.clear(); + // Discard the compiler output because it will be shown again by `cargo test` or Clippy. + if let Some(output) = output.as_deref_mut() { + 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"] + if self.test() { + let output_is_some = output.is_some(); + let mut test_cmd = cmd_runner.cargo("test", bin_name, output.as_deref_mut()); + if output_is_some { + test_cmd.args(["--", "--color", "always", "--format", "pretty"]); + } + let test_success = test_cmd.run("cargo test …")?; + if !test_success { + run_bin(bin_name, output, cmd_runner)?; + return Ok(false); + } + + // Discard the compiler output because it will be shown again by Clippy. + if let Some(output) = output.as_deref_mut() { + output.clear(); + } + } + + let mut clippy_cmd = cmd_runner.cargo("clippy", bin_name, output.as_deref_mut()); + + // `--profile test` is required to also check code with `#[cfg(test)]`. + if FORCE_STRICT_CLIPPY || self.strict_clippy() { + clippy_cmd.args(["--profile", "test", "--", "-D", "warnings"]); } else { - &["--profile", "test"] - }; - let clippy_success = CargoCmd { - subcommand: "clippy", - args: clippy_args, - bin_name, - description: "cargo clippy …", - hide_warnings: false, - target_dir, - output, - dev, - } - .run()?; - if !clippy_success { - return Ok(false); + clippy_cmd.args(["--profile", "test"]); } - if !self.test() { - return run_bin(bin_name, output, target_dir); - } + let clippy_success = clippy_cmd.run("cargo clippy …")?; + let run_success = run_bin(bin_name, output, cmd_runner)?; - let test_success = CargoCmd { - subcommand: "test", - args: &["--", "--color", "always", "--show-output"], - bin_name, - description: "cargo test …", - // Hide warnings because they are shown by Clippy. - hide_warnings: true, - target_dir, - output, - dev, - } - .run()?; - - let run_success = run_bin(bin_name, output, target_dir)?; - - Ok(test_success && run_success) + Ok(clippy_success && run_success) } /// Compile, check and run the exercise. /// The output is written to the `output` buffer after clearing it. #[inline] - fn run_exercise(&self, output: &mut Vec, target_dir: &Path) -> Result { - self.run(self.name(), output, target_dir) + fn run_exercise(&self, output: Option<&mut Vec>, cmd_runner: &CmdRunner) -> Result { + self.run::(self.name(), output, cmd_runner) } /// Compile, check and run the exercise's solution. /// The output is written to the `output` buffer after clearing it. - fn run_solution(&self, output: &mut Vec, target_dir: &Path) -> Result { + fn run_solution(&self, output: Option<&mut Vec>, cmd_runner: &CmdRunner) -> Result { let name = self.name(); - let mut bin_name = String::with_capacity(name.len()); + let mut bin_name = String::with_capacity(name.len() + 4); bin_name.push_str(name); bin_name.push_str("_sol"); - self.run(&bin_name, output, target_dir) + self.run::(&bin_name, output, cmd_runner) + } + + fn sol_path(&self) -> String { + let name = self.name(); + + let mut path = if let Some(dir) = self.dir() { + // 14 = 10 + 1 + 3 + // solutions/ + / + .rs + let mut path = String::with_capacity(14 + dir.len() + name.len()); + path.push_str("solutions/"); + path.push_str(dir); + path.push('/'); + path + } else { + // 13 = 10 + 3 + // solutions/ + .rs + let mut path = String::with_capacity(13 + name.len()); + path.push_str("solutions/"); + path + }; + + path.push_str(name); + path.push_str(".rs"); + + path } } @@ -169,6 +194,11 @@ impl RunnableExercise for Exercise { self.name } + #[inline] + fn dir(&self) -> Option<&str> { + self.dir + } + #[inline] fn strict_clippy(&self) -> bool { self.strict_clippy diff --git a/src/info_file.rs b/src/info_file.rs index f27d0185..fdc8f0f3 100644 --- a/src/info_file.rs +++ b/src/info_file.rs @@ -52,30 +52,6 @@ impl ExerciseInfo { path } - - /// Path to the solution file starting with the `solutions/` directory. - pub fn sol_path(&self) -> String { - let mut path = if let Some(dir) = &self.dir { - // 14 = 10 + 1 + 3 - // solutions/ + / + .rs - let mut path = String::with_capacity(14 + dir.len() + self.name.len()); - path.push_str("solutions/"); - path.push_str(dir); - path.push('/'); - path - } else { - // 13 = 10 + 3 - // solutions/ + .rs - let mut path = String::with_capacity(13 + self.name.len()); - path.push_str("solutions/"); - path - }; - - path.push_str(&self.name); - path.push_str(".rs"); - - path - } } impl RunnableExercise for ExerciseInfo { @@ -84,6 +60,11 @@ impl RunnableExercise for ExerciseInfo { &self.name } + #[inline] + fn dir(&self) -> Option<&str> { + self.dir.as_deref() + } + #[inline] fn strict_clippy(&self) -> bool { self.strict_clippy @@ -135,4 +116,4 @@ impl InfoFile { } const NO_EXERCISES_ERR: &str = "There are no exercises yet! -If you are developing third-party exercises, add at least one exercise before testing."; +Add at least one exercise before testing."; diff --git a/src/init.rs b/src/init.rs index 4063ca75..ce49bb65 100644 --- a/src/init.rs +++ b/src/init.rs @@ -1,32 +1,94 @@ use anyhow::{bail, Context, Result}; -use crossterm::style::Stylize; +use crossterm::{ + style::{Attribute, Color, ResetColor, SetAttribute, SetForegroundColor}, + QueueableCommand, +}; +use serde::Deserialize; use std::{ env::set_current_dir, fs::{self, create_dir}, - io::ErrorKind, - path::Path, + io::{self, Write}, + path::{Path, PathBuf}, process::{Command, Stdio}, }; -use crate::{cargo_toml::updated_cargo_toml, embedded::EMBEDDED_FILES, info_file::InfoFile}; +use crate::{ + cargo_toml::updated_cargo_toml, embedded::EMBEDDED_FILES, exercise::RunnableExercise, + info_file::InfoFile, term::press_enter_prompt, +}; + +#[derive(Deserialize)] +struct CargoLocateProject { + root: PathBuf, +} 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); + let rustlings_dir = Path::new("rustlings"); + if rustlings_dir.exists() { + bail!(RUSTLINGS_DIR_ALREADY_EXISTS_ERR); } - let rustlings_path = Path::new("rustlings"); - if let Err(e) = create_dir(rustlings_path) { - if e.kind() == ErrorKind::AlreadyExists { - bail!(RUSTLINGS_DIR_ALREADY_EXISTS_ERR); + let locate_project_output = Command::new("cargo") + .arg("locate-project") + .arg("-q") + .arg("--workspace") + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .context(CARGO_LOCATE_PROJECT_ERR)?; + + let mut stdout = io::stdout().lock(); + let mut init_git = true; + + if locate_project_output.status.success() { + if Path::new("exercises").exists() && Path::new("solutions").exists() { + bail!(IN_INITIALIZED_DIR_ERR); } - return Err(e.into()); + + let workspace_manifest = + serde_json::de::from_slice::(&locate_project_output.stdout) + .context( + "Failed to read the field `root` from the output of `cargo locate-project …`", + )? + .root; + + let workspace_manifest_content = fs::read_to_string(&workspace_manifest) + .with_context(|| format!("Failed to read the file {}", workspace_manifest.display()))?; + if !workspace_manifest_content.contains("[workspace]\n") + && !workspace_manifest_content.contains("workspace.") + { + bail!("The current directory is already part of a Cargo project.\nPlease initialize Rustlings in a different directory"); + } + + stdout.write_all(b"This command will create the directory `rustlings/` as a member of this Cargo workspace.\nPress ENTER to continue ")?; + press_enter_prompt(&mut stdout)?; + + // Make sure "rustlings" is added to `workspace.members` by making + // Cargo initialize a new project. + let status = Command::new("cargo") + .arg("new") + .arg("-q") + .arg("--vcs") + .arg("none") + .arg("rustlings") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .status()?; + if !status.success() { + bail!("Failed to initialize a new Cargo workspace member.\nPlease initialize Rustlings in a different directory"); + } + + stdout.write_all(b"The directory `rustlings` has been added to `workspace.members` in the `Cargo.toml` file of this Cargo workspace.\n")?; + fs::remove_dir_all("rustlings") + .context("Failed to remove the temporary directory `rustlings/`")?; + init_git = false; + } else { + stdout.write_all(b"This command will create the directory `rustlings/` which will contain the exercises.\nPress ENTER to continue ")?; + press_enter_prompt(&mut stdout)?; } - set_current_dir("rustlings") + create_dir(rustlings_dir).context("Failed to create the `rustlings/` directory")?; + set_current_dir(rustlings_dir) .context("Failed to change the current directory to `rustlings/`")?; let info_file = InfoFile::parse()?; @@ -35,6 +97,11 @@ pub fn init() -> Result<()> { .context("Failed to initialize the `rustlings/exercises` directory")?; create_dir("solutions").context("Failed to create the `solutions/` directory")?; + fs::write( + "solutions/README.md", + include_bytes!("../solutions/README.md"), + ) + .context("Failed to create the file rustlings/solutions/README.md")?; for dir in EMBEDDED_FILES.exercise_dirs { let mut dir_path = String::with_capacity(10 + dir.name.len()); dir_path.push_str("solutions/"); @@ -63,6 +130,9 @@ pub fn init() -> Result<()> { fs::write("Cargo.toml", updated_cargo_toml) .context("Failed to create the file `rustlings/Cargo.toml`")?; + fs::write("rust-analyzer.toml", RUST_ANALYZER_TOML) + .context("Failed to create the file `rustlings/rust-analyzer.toml`")?; + fs::write(".gitignore", GITIGNORE) .context("Failed to create the file `rustlings/.gitignore`")?; @@ -70,41 +140,53 @@ pub fn init() -> Result<()> { fs::write(".vscode/extensions.json", VS_CODE_EXTENSIONS_JSON) .context("Failed to create the file `rustlings/.vscode/extensions.json`")?; - // Ignore any Git error because Git initialization is not required. - let _ = Command::new("git") - .arg("init") - .stdin(Stdio::null()) - .stderr(Stdio::null()) - .status(); + if init_git { + // Ignore any Git error because Git initialization is not required. + let _ = Command::new("git") + .arg("init") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } - println!( - "\n{}\n\n{}", - "Initialization done ✓".green(), - POST_INIT_MSG.bold(), - ); + stdout.queue(SetForegroundColor(Color::Green))?; + stdout.write_all("Initialization done ✓".as_bytes())?; + stdout.queue(ResetColor)?; + stdout.write_all(b"\n\n")?; + + stdout.queue(SetAttribute(Attribute::Bold))?; + stdout.write_all(POST_INIT_MSG)?; + stdout.queue(ResetColor)?; Ok(()) } +const CARGO_LOCATE_PROJECT_ERR: &str = "Failed to run the command `cargo locate-project …` +Did you already install Rust? +Try running `cargo --version` to diagnose the problem."; + const INIT_SOLUTION_FILE: &[u8] = b"fn main() { // DON'T EDIT THIS SOLUTION FILE! // It will be automatically filled after you finish the exercise. } "; -const GITIGNORE: &[u8] = b".rustlings-state.txt -solutions -Cargo.lock -target -.vscode +pub const RUST_ANALYZER_TOML: &[u8] = br#"check.command = "clippy" +check.extraArgs = ["--profile", "test"] +"#; + +const GITIGNORE: &[u8] = b"Cargo.lock +target/ +.vscode/ "; 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 IN_INITIALIZED_DIR_ERR: &str = "It looks like Rustlings is already initialized in this directory. 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."; +Otherwise, please run `rustlings init` again in a different directory."; const RUSTLINGS_DIR_ALREADY_EXISTS_ERR: &str = "A directory with the name `rustlings` already exists in the current directory. @@ -112,5 +194,6 @@ You probably already initialized Rustlings. Run `cd rustlings` Then run `rustlings` again"; -const POST_INIT_MSG: &str = "Run `cd rustlings` to go into the generated directory. -Then run `rustlings` to get started."; +const POST_INIT_MSG: &[u8] = b"Run `cd rustlings` to go into the generated directory. +Then run `rustlings` to get started. +"; diff --git a/src/list.rs b/src/list.rs index 790c02fe..9f243a17 100644 --- a/src/list.rs +++ b/src/list.rs @@ -1,90 +1,135 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use crossterm::{ - event::{self, Event, KeyCode, KeyEventKind}, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, - ExecutableCommand, + cursor, + event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseEventKind, + }, + terminal::{ + disable_raw_mode, enable_raw_mode, DisableLineWrap, EnableLineWrap, EnterAlternateScreen, + LeaveAlternateScreen, + }, + QueueableCommand, }; -use ratatui::{backend::CrosstermBackend, Terminal}; -use std::io; +use std::io::{self, StdoutLock, Write}; use crate::app_state::AppState; -use self::state::{Filter, UiState}; +use self::state::{Filter, ListState}; +mod scroll_state; mod state; +fn handle_list(app_state: &mut AppState, stdout: &mut StdoutLock) -> Result<()> { + let mut list_state = ListState::build(app_state, stdout)?; + let mut is_searching = false; + + loop { + match event::read().context("Failed to read terminal event")? { + Event::Key(key) => { + match key.kind { + KeyEventKind::Release => continue, + KeyEventKind::Press | KeyEventKind::Repeat => (), + } + + list_state.message.clear(); + + if is_searching { + match key.code { + KeyCode::Esc | KeyCode::Enter => { + is_searching = false; + list_state.search_query.clear(); + } + KeyCode::Char(c) => { + list_state.search_query.push(c); + list_state.apply_search_query(); + } + KeyCode::Backspace => { + list_state.search_query.pop(); + list_state.apply_search_query(); + } + _ => continue, + } + + list_state.draw(stdout)?; + continue; + } + + match key.code { + KeyCode::Char('q') => return Ok(()), + KeyCode::Down | KeyCode::Char('j') => list_state.select_next(), + KeyCode::Up | KeyCode::Char('k') => list_state.select_previous(), + KeyCode::Home | KeyCode::Char('g') => list_state.select_first(), + KeyCode::End | KeyCode::Char('G') => list_state.select_last(), + KeyCode::Char('d') => { + if list_state.filter() == Filter::Done { + list_state.set_filter(Filter::None); + list_state.message.push_str("Disabled filter DONE"); + } else { + list_state.set_filter(Filter::Done); + list_state.message.push_str( + "Enabled filter DONE │ Press d again to disable the filter", + ); + } + } + KeyCode::Char('p') => { + if list_state.filter() == Filter::Pending { + list_state.set_filter(Filter::None); + list_state.message.push_str("Disabled filter PENDING"); + } else { + list_state.set_filter(Filter::Pending); + list_state.message.push_str( + "Enabled filter PENDING │ Press p again to disable the filter", + ); + } + } + KeyCode::Char('r') => list_state.reset_selected()?, + KeyCode::Char('c') => { + if list_state.selected_to_current_exercise()? { + return Ok(()); + } + } + KeyCode::Char('s' | '/') => { + is_searching = true; + list_state.apply_search_query(); + } + // Redraw to remove the message. + KeyCode::Esc => (), + _ => continue, + } + } + Event::Mouse(event) => match event.kind { + MouseEventKind::ScrollDown => list_state.select_next(), + MouseEventKind::ScrollUp => list_state.select_previous(), + _ => continue, + }, + Event::Resize(width, height) => list_state.set_term_size(width, height), + // Ignore + Event::FocusGained | Event::FocusLost => continue, + } + + list_state.draw(stdout)?; + } +} + pub fn list(app_state: &mut AppState) -> Result<()> { let mut stdout = io::stdout().lock(); - stdout.execute(EnterAlternateScreen)?; + stdout + .queue(EnterAlternateScreen)? + .queue(cursor::Hide)? + .queue(DisableLineWrap)? + .queue(EnableMouseCapture)?; enable_raw_mode()?; - let mut terminal = Terminal::new(CrosstermBackend::new(&mut stdout))?; - terminal.clear()?; + let res = handle_list(app_state, &mut stdout); - let mut ui_state = UiState::new(app_state); - - 'outer: loop { - terminal.draw(|frame| ui_state.draw(frame).unwrap())?; - - let key = loop { - match event::read()? { - Event::Key(key) => match key.kind { - KeyEventKind::Press | KeyEventKind::Repeat => break key, - KeyEventKind::Release => (), - }, - // Redraw - Event::Resize(_, _) => continue 'outer, - // Ignore - Event::FocusGained | Event::FocusLost | Event::Mouse(_) | Event::Paste(_) => (), - } - }; - - ui_state.message.clear(); - - match key.code { - KeyCode::Char('q') => break, - KeyCode::Down | KeyCode::Char('j') => ui_state.select_next(), - KeyCode::Up | KeyCode::Char('k') => ui_state.select_previous(), - KeyCode::Home | KeyCode::Char('g') => ui_state.select_first(), - KeyCode::End | KeyCode::Char('G') => ui_state.select_last(), - KeyCode::Char('d') => { - let message = if ui_state.filter == Filter::Done { - ui_state.filter = Filter::None; - "Disabled filter DONE" - } else { - ui_state.filter = Filter::Done; - "Enabled filter DONE │ Press d again to disable the filter" - }; - - ui_state = ui_state.with_updated_rows(); - ui_state.message.push_str(message); - } - KeyCode::Char('p') => { - let message = if ui_state.filter == Filter::Pending { - ui_state.filter = Filter::None; - "Disabled filter PENDING" - } else { - ui_state.filter = Filter::Pending; - "Enabled filter PENDING │ Press p again to disable the filter" - }; - - ui_state = ui_state.with_updated_rows(); - ui_state.message.push_str(message); - } - KeyCode::Char('r') => { - ui_state = ui_state.with_reset_selected()?; - } - KeyCode::Char('c') => { - ui_state.selected_to_current_exercise()?; - ui_state = ui_state.with_updated_rows(); - } - _ => (), - } - } - - drop(terminal); - stdout.execute(LeaveAlternateScreen)?; + // Restore the terminal even if we got an error. + stdout + .queue(LeaveAlternateScreen)? + .queue(cursor::Show)? + .queue(EnableLineWrap)? + .queue(DisableMouseCapture)? + .flush()?; disable_raw_mode()?; - Ok(()) + res } diff --git a/src/list/scroll_state.rs b/src/list/scroll_state.rs new file mode 100644 index 00000000..2c02ed4f --- /dev/null +++ b/src/list/scroll_state.rs @@ -0,0 +1,104 @@ +pub struct ScrollState { + n_rows: usize, + max_n_rows_to_display: usize, + selected: Option, + offset: usize, + scroll_padding: usize, + max_scroll_padding: usize, +} + +impl ScrollState { + pub fn new(n_rows: usize, selected: Option, max_scroll_padding: usize) -> Self { + Self { + n_rows, + max_n_rows_to_display: 0, + selected, + offset: selected.map_or(0, |selected| selected.saturating_sub(max_scroll_padding)), + scroll_padding: 0, + max_scroll_padding, + } + } + + #[inline] + pub fn offset(&self) -> usize { + self.offset + } + + fn update_offset(&mut self) { + let Some(selected) = self.selected else { + return; + }; + + let min_offset = (selected + self.scroll_padding) + .saturating_sub(self.max_n_rows_to_display.saturating_sub(1)); + let max_offset = selected.saturating_sub(self.scroll_padding); + let global_max_offset = self.n_rows.saturating_sub(self.max_n_rows_to_display); + + self.offset = self + .offset + .max(min_offset) + .min(max_offset) + .min(global_max_offset); + } + + #[inline] + pub fn selected(&self) -> Option { + self.selected + } + + pub fn set_selected(&mut self, selected: usize) { + self.selected = Some(selected); + self.update_offset(); + } + + pub fn select_next(&mut self) { + if let Some(selected) = self.selected { + self.set_selected((selected + 1).min(self.n_rows - 1)); + } + } + + pub fn select_previous(&mut self) { + if let Some(selected) = self.selected { + self.set_selected(selected.saturating_sub(1)); + } + } + + pub fn select_first(&mut self) { + if self.n_rows > 0 { + self.set_selected(0); + } + } + + pub fn select_last(&mut self) { + if self.n_rows > 0 { + self.set_selected(self.n_rows - 1); + } + } + + pub fn set_n_rows(&mut self, n_rows: usize) { + self.n_rows = n_rows; + + if self.n_rows == 0 { + self.selected = None; + return; + } + + self.set_selected(self.selected.map_or(0, |selected| selected.min(n_rows - 1))); + } + + #[inline] + fn update_scroll_padding(&mut self) { + self.scroll_padding = (self.max_n_rows_to_display / 4).min(self.max_scroll_padding); + } + + #[inline] + pub fn max_n_rows_to_display(&self) -> usize { + self.max_n_rows_to_display + } + + pub fn set_max_n_rows_to_display(&mut self, max_n_rows_to_display: usize) { + self.max_n_rows_to_display = max_n_rows_to_display; + self.update_scroll_padding(); + self.update_offset(); + } +} diff --git a/src/list/state.rs b/src/list/state.rs index d6df6344..53fe07c4 100644 --- a/src/list/state.rs +++ b/src/list/state.rs @@ -1,14 +1,31 @@ use anyhow::{Context, Result}; -use ratatui::{ - layout::{Constraint, Rect}, - style::{Style, Stylize}, - text::{Line, Span}, - widgets::{Block, Borders, HighlightSpacing, Paragraph, Row, Table, TableState}, - Frame, +use crossterm::{ + cursor::{MoveTo, MoveToNextLine}, + style::{Attribute, Color, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor}, + terminal::{self, BeginSynchronizedUpdate, Clear, ClearType, EndSynchronizedUpdate}, + QueueableCommand, +}; +use std::{ + fmt::Write as _, + io::{self, StdoutLock, Write}, }; -use std::fmt::Write; -use crate::{app_state::AppState, progress_bar::progress_bar_ratatui}; +use crate::{ + app_state::AppState, + exercise::Exercise, + term::{progress_bar, CountedWrite, MaxLenWriter}, +}; + +use super::scroll_state::ScrollState; + +const COL_SPACING: usize = 2; + +fn next_ln(stdout: &mut StdoutLock) -> io::Result<()> { + stdout + .queue(Clear(ClearType::UntilNewLine))? + .queue(MoveToNextLine(1))?; + Ok(()) +} #[derive(Copy, Clone, PartialEq, Eq)] pub enum Filter { @@ -17,255 +34,378 @@ pub enum Filter { None, } -pub struct UiState<'a> { - pub table: Table<'static>, +pub struct ListState<'a> { + /// Footer message to be displayed if not empty. pub message: String, - pub filter: Filter, + pub search_query: String, app_state: &'a mut AppState, - table_state: TableState, - n_rows: usize, + scroll_state: ScrollState, + name_col_padding: Vec, + filter: Filter, + term_width: u16, + term_height: u16, + show_footer: bool, } -impl<'a> UiState<'a> { - pub fn with_updated_rows(mut self) -> Self { - let current_exercise_ind = self.app_state.current_exercise_ind(); +impl<'a> ListState<'a> { + pub fn build(app_state: &'a mut AppState, stdout: &mut StdoutLock) -> Result { + stdout.queue(Clear(ClearType::All))?; - self.n_rows = 0; - let rows = self - .app_state - .exercises() - .iter() - .enumerate() - .filter_map(|(ind, exercise)| { - let exercise_state = if exercise.done { - if self.filter == Filter::Pending { - return None; - } - - "DONE".green() - } else { - if self.filter == Filter::Done { - return None; - } - - "PENDING".yellow() - }; - - self.n_rows += 1; - - let next = if ind == current_exercise_ind { - ">>>>".bold().red() - } else { - Span::default() - }; - - Some(Row::new([ - next, - exercise_state, - Span::raw(exercise.name), - Span::raw(exercise.path), - ])) - }); - - self.table = self.table.rows(rows); - - if self.n_rows == 0 { - self.table_state.select(None); - } else { - self.table_state.select(Some( - self.table_state - .selected() - .map_or(0, |selected| selected.min(self.n_rows - 1)), - )); - } - - self - } - - pub fn new(app_state: &'a mut AppState) -> Self { - let header = Row::new(["Next", "State", "Name", "Path"]); - - let max_name_len = app_state + let name_col_title_len = 4; + let name_col_width = app_state .exercises() .iter() .map(|exercise| exercise.name.len()) .max() - .unwrap_or(4) as u16; - - let widths = [ - Constraint::Length(4), - Constraint::Length(7), - Constraint::Length(max_name_len), - Constraint::Fill(1), - ]; - - let table = Table::default() - .widths(widths) - .header(header) - .column_spacing(2) - .highlight_spacing(HighlightSpacing::Always) - .highlight_style(Style::new().bg(ratatui::style::Color::Rgb(50, 50, 50))) - .highlight_symbol("🦀") - .block(Block::default().borders(Borders::BOTTOM)); - - let selected = app_state.current_exercise_ind(); - let table_state = TableState::default() - .with_offset(selected.saturating_sub(10)) - .with_selected(Some(selected)); + .map_or(name_col_title_len, |max| max.max(name_col_title_len)); + let name_col_padding = vec![b' '; name_col_width + COL_SPACING]; let filter = Filter::None; - let n_rows = app_state.exercises().len(); + let n_rows_with_filter = app_state.exercises().len(); + let selected = app_state.current_exercise_ind(); - let slf = Self { - table, + let (width, height) = terminal::size().context("Failed to get the terminal size")?; + let scroll_state = ScrollState::new(n_rows_with_filter, Some(selected), 5); + + let mut slf = Self { message: String::with_capacity(128), - filter, + search_query: String::new(), app_state, - table_state, - n_rows, + scroll_state, + name_col_padding, + filter, + // Set by `set_term_size` + term_width: 0, + term_height: 0, + show_footer: true, }; - slf.with_updated_rows() + slf.set_term_size(width, height); + slf.draw(stdout)?; + + Ok(slf) } - pub fn select_next(&mut self) { - if self.n_rows > 0 { - let next = self - .table_state - .selected() - .map_or(0, |selected| (selected + 1).min(self.n_rows - 1)); - self.table_state.select(Some(next)); + pub fn set_term_size(&mut self, width: u16, height: u16) { + self.term_width = width; + self.term_height = height; + + if height == 0 { + return; } - } - pub fn select_previous(&mut self) { - if self.n_rows > 0 { - let previous = self - .table_state - .selected() - .map_or(0, |selected| selected.saturating_sub(1)); - self.table_state.select(Some(previous)); - } - } + let header_height = 1; + // 1 progress bar, 2 footer message lines. + let footer_height = 3; + self.show_footer = height > header_height + footer_height; - pub fn select_first(&mut self) { - if self.n_rows > 0 { - self.table_state.select(Some(0)); - } - } - - pub fn select_last(&mut self) { - if self.n_rows > 0 { - self.table_state.select(Some(self.n_rows - 1)); - } - } - - pub fn draw(&mut self, frame: &mut Frame) -> Result<()> { - let area = frame.size(); - - frame.render_stateful_widget( - &self.table, - Rect { - x: 0, - y: 0, - width: area.width, - height: area.height - 3, - }, - &mut self.table_state, + self.scroll_state.set_max_n_rows_to_display( + height.saturating_sub(header_height + u16::from(self.show_footer) * footer_height) + as usize, ); + } - frame.render_widget( - Paragraph::new(progress_bar_ratatui( + fn draw_exericse_name(&self, writer: &mut MaxLenWriter, exercise: &Exercise) -> io::Result<()> { + if !self.search_query.is_empty() { + if let Some((pre_highlight, highlight, post_highlight)) = exercise + .name + .find(&self.search_query) + .and_then(|ind| exercise.name.split_at_checked(ind)) + .and_then(|(pre_highlight, rest)| { + rest.split_at_checked(self.search_query.len()) + .map(|x| (pre_highlight, x.0, x.1)) + }) + { + writer.write_str(pre_highlight)?; + writer.stdout.queue(SetForegroundColor(Color::Magenta))?; + writer.write_str(highlight)?; + writer.stdout.queue(ResetColor)?; + return writer.write_str(post_highlight); + } + } + + writer.write_str(exercise.name) + } + + fn draw_rows( + &self, + stdout: &mut StdoutLock, + filtered_exercises: impl Iterator, + ) -> io::Result { + let current_exercise_ind = self.app_state.current_exercise_ind(); + let row_offset = self.scroll_state.offset(); + let mut n_displayed_rows = 0; + + for (exercise_ind, exercise) in filtered_exercises + .skip(row_offset) + .take(self.scroll_state.max_n_rows_to_display()) + { + let mut writer = MaxLenWriter::new(stdout, self.term_width as usize); + + if self.scroll_state.selected() == Some(row_offset + n_displayed_rows) { + writer.stdout.queue(SetBackgroundColor(Color::Rgb { + r: 40, + g: 40, + b: 40, + }))?; + // The crab emoji has the width of two ascii chars. + writer.add_to_len(2); + writer.stdout.write_all("🦀".as_bytes())?; + } else { + writer.write_ascii(b" ")?; + } + + if exercise_ind == current_exercise_ind { + writer.stdout.queue(SetForegroundColor(Color::Red))?; + writer.write_ascii(b">>>>>>> ")?; + } else { + writer.write_ascii(b" ")?; + } + + if exercise.done { + writer.stdout.queue(SetForegroundColor(Color::Green))?; + writer.write_ascii(b"DONE ")?; + } else { + writer.stdout.queue(SetForegroundColor(Color::Yellow))?; + writer.write_ascii(b"PENDING ")?; + } + writer.stdout.queue(SetForegroundColor(Color::Reset))?; + + self.draw_exericse_name(&mut writer, exercise)?; + + writer.write_ascii(&self.name_col_padding[exercise.name.len()..])?; + + // The list links aren't shown correctly in VS Code on Windows. + // But VS Code shows its own links anyway. + if self.app_state.vs_code() { + writer.write_str(exercise.path)?; + } else { + exercise.terminal_file_link(&mut writer)?; + } + + next_ln(stdout)?; + stdout.queue(ResetColor)?; + n_displayed_rows += 1; + } + + Ok(n_displayed_rows) + } + + pub fn draw(&mut self, stdout: &mut StdoutLock) -> io::Result<()> { + if self.term_height == 0 { + return Ok(()); + } + + stdout.queue(BeginSynchronizedUpdate)?.queue(MoveTo(0, 0))?; + + // Header + let mut writer = MaxLenWriter::new(stdout, self.term_width as usize); + writer.write_ascii(b" Current State Name")?; + writer.write_ascii(&self.name_col_padding[4..])?; + writer.write_ascii(b"Path")?; + next_ln(stdout)?; + + // Rows + let iter = self.app_state.exercises().iter().enumerate(); + let n_displayed_rows = match self.filter { + Filter::Done => self.draw_rows(stdout, iter.filter(|(_, exercise)| exercise.done))?, + Filter::Pending => { + self.draw_rows(stdout, iter.filter(|(_, exercise)| !exercise.done))? + } + Filter::None => self.draw_rows(stdout, iter)?, + }; + + for _ in 0..self.scroll_state.max_n_rows_to_display() - n_displayed_rows { + next_ln(stdout)?; + } + + if self.show_footer { + progress_bar( + &mut MaxLenWriter::new(stdout, self.term_width as usize), self.app_state.n_done(), self.app_state.exercises().len() as u16, - area.width, - )?) - .block(Block::default().borders(Borders::BOTTOM)), - Rect { - x: 0, - y: area.height - 3, - width: area.width, - height: 2, - }, - ); + self.term_width, + )?; + next_ln(stdout)?; - let message = if self.message.is_empty() { - // Help footer. - let mut spans = Vec::with_capacity(4); - spans.push(Span::raw( - "↓/j ↑/k home/g end/G │ ontinue at │ eset │ filter ", - )); - match self.filter { - Filter::Done => { - spans.push("one".underlined().magenta()); - spans.push(Span::raw("/

ending")); + let mut writer = MaxLenWriter::new(stdout, self.term_width as usize); + if self.message.is_empty() { + // Help footer message + if self.scroll_state.selected().is_some() { + writer.write_str("↓/j ↑/k home/g end/G | ontinue at | eset exercise")?; + next_ln(stdout)?; + writer = MaxLenWriter::new(stdout, self.term_width as usize); + + writer.write_ascii(b"earch | filter ")?; + } else { + // Nothing selected (and nothing shown), so only display filter and quit. + writer.write_ascii(b"filter ")?; } - Filter::Pending => { - spans.push(Span::raw("one/")); - spans.push("

ending".underlined().magenta()); + + match self.filter { + Filter::Done => { + writer + .stdout + .queue(SetForegroundColor(Color::Magenta))? + .queue(SetAttribute(Attribute::Underlined))?; + writer.write_ascii(b"one")?; + writer.stdout.queue(ResetColor)?; + writer.write_ascii(b"/

ending")?; + } + Filter::Pending => { + writer.write_ascii(b"one/")?; + writer + .stdout + .queue(SetForegroundColor(Color::Magenta))? + .queue(SetAttribute(Attribute::Underlined))?; + writer.write_ascii(b"

ending")?; + writer.stdout.queue(ResetColor)?; + } + Filter::None => writer.write_ascii(b"one/

ending")?, } - Filter::None => spans.push(Span::raw("one/

ending")), + + writer.write_ascii(b" | uit list")?; + } else { + writer.stdout.queue(SetForegroundColor(Color::Magenta))?; + writer.write_str(&self.message)?; + stdout.queue(ResetColor)?; + next_ln(stdout)?; } - spans.push(Span::raw(" │ uit")); - Line::from(spans) - } else { - Line::from(self.message.as_str().light_blue()) + + next_ln(stdout)?; + } + + stdout.queue(EndSynchronizedUpdate)?.flush() + } + + fn update_rows(&mut self) { + let n_rows = match self.filter { + Filter::Done => self + .app_state + .exercises() + .iter() + .filter(|exercise| exercise.done) + .count(), + Filter::Pending => self + .app_state + .exercises() + .iter() + .filter(|exercise| !exercise.done) + .count(), + Filter::None => self.app_state.exercises().len(), }; - frame.render_widget( - message, - Rect { - x: 0, - y: area.height - 1, - width: area.width, - height: 1, - }, - ); + + self.scroll_state.set_n_rows(n_rows); + } + + #[inline] + pub fn filter(&self) -> Filter { + self.filter + } + + pub fn set_filter(&mut self, filter: Filter) { + self.filter = filter; + self.update_rows(); + } + + #[inline] + pub fn select_next(&mut self) { + self.scroll_state.select_next(); + } + + #[inline] + pub fn select_previous(&mut self) { + self.scroll_state.select_previous(); + } + + #[inline] + pub fn select_first(&mut self) { + self.scroll_state.select_first(); + } + + #[inline] + pub fn select_last(&mut self) { + self.scroll_state.select_last(); + } + + fn selected_to_exercise_ind(&self, selected: usize) -> Result { + match self.filter { + Filter::Done => self + .app_state + .exercises() + .iter() + .enumerate() + .filter(|(_, exercise)| exercise.done) + .nth(selected) + .context("Invalid selection index") + .map(|(ind, _)| ind), + Filter::Pending => self + .app_state + .exercises() + .iter() + .enumerate() + .filter(|(_, exercise)| !exercise.done) + .nth(selected) + .context("Invalid selection index") + .map(|(ind, _)| ind), + Filter::None => Ok(selected), + } + } + + pub fn reset_selected(&mut self) -> Result<()> { + let Some(selected) = self.scroll_state.selected() else { + self.message.push_str("Nothing selected to reset!"); + return Ok(()); + }; + + let exercise_ind = self.selected_to_exercise_ind(selected)?; + let exercise_name = self.app_state.reset_exercise_by_ind(exercise_ind)?; + self.update_rows(); + write!( + self.message, + "The exercise `{exercise_name}` has been reset", + )?; Ok(()) } - pub fn with_reset_selected(mut self) -> Result { - let Some(selected) = self.table_state.selected() else { - return Ok(self); + pub fn apply_search_query(&mut self) { + self.message.push_str("search:"); + self.message.push_str(&self.search_query); + self.message.push('|'); + + if self.search_query.is_empty() { + return; + } + + let is_search_result = |exercise: &Exercise| exercise.name.contains(&self.search_query); + let mut iter = self.app_state.exercises().iter(); + let ind = match self.filter { + Filter::None => iter.position(is_search_result), + Filter::Done => iter + .filter(|exercise| exercise.done) + .position(is_search_result), + Filter::Pending => iter + .filter(|exercise| !exercise.done) + .position(is_search_result), }; - let ind = self - .app_state - .exercises() - .iter() - .enumerate() - .filter_map(|(ind, exercise)| match self.filter { - Filter::Done => exercise.done.then_some(ind), - Filter::Pending => (!exercise.done).then_some(ind), - Filter::None => Some(ind), - }) - .nth(selected) - .context("Invalid selection index")?; - - let exercise_path = self.app_state.reset_exercise_by_ind(ind)?; - write!(self.message, "The exercise {exercise_path} has been reset")?; - - Ok(self.with_updated_rows()) + match ind { + Some(exercise_ind) => self.scroll_state.set_selected(exercise_ind), + None => self.message.push_str(" (not found)"), + } } - pub fn selected_to_current_exercise(&mut self) -> Result<()> { - let Some(selected) = self.table_state.selected() else { - return Ok(()); + // Return `true` if there was something to select. + pub fn selected_to_current_exercise(&mut self) -> Result { + let Some(selected) = self.scroll_state.selected() else { + self.message.push_str("Nothing selected to continue at!"); + return Ok(false); }; - let ind = self - .app_state - .exercises() - .iter() - .enumerate() - .filter_map(|(ind, exercise)| match self.filter { - Filter::Done => exercise.done.then_some(ind), - Filter::Pending => (!exercise.done).then_some(ind), - Filter::None => Some(ind), - }) - .nth(selected) - .context("Invalid selection index")?; + let exercise_ind = self.selected_to_exercise_ind(selected)?; + self.app_state.set_current_exercise_ind(exercise_ind)?; - self.app_state.set_current_exercise_ind(ind) + Ok(true) } } diff --git a/src/main.rs b/src/main.rs index 3c96d1a9..eeb1883e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,12 +2,13 @@ use anyhow::{bail, Context, Result}; use app_state::StateFileStatus; use clap::{Parser, Subcommand}; use std::{ - io::{self, BufRead, IsTerminal, StdoutLock, Write}, + io::{self, IsTerminal, Write}, path::Path, - process::exit, + process::ExitCode, }; +use term::{clear_terminal, press_enter_prompt}; -use self::{app_state::AppState, dev::DevCommands, info_file::InfoFile, watch::WatchExit}; +use self::{app_state::AppState, dev::DevCommands, info_file::InfoFile}; mod app_state; mod cargo_toml; @@ -18,37 +19,11 @@ mod exercise; mod info_file; mod init; mod list; -mod progress_bar; mod run; -mod terminal_link; +mod term; mod watch; const CURRENT_FORMAT_VERSION: u8 = 1; -const DEBUG_PROFILE: bool = { - #[allow(unused_assignments, unused_mut)] - let mut debug_profile = false; - - #[cfg(debug_assertions)] - { - debug_profile = true; - } - - debug_profile -}; - -// The current directory is the official Rustligns repository. -fn in_official_repo() -> bool { - Path::new("dev/rustlings-repo.txt").exists() -} - -fn clear_terminal(stdout: &mut StdoutLock) -> io::Result<()> { - stdout.write_all(b"\x1b[H\x1b[2J\x1b[3J") -} - -fn press_enter_prompt() -> io::Result<()> { - io::stdin().lock().read_until(b'\n', &mut Vec::new())?; - Ok(()) -} /// Rustlings is a collection of small exercises to get you used to writing and reading Rust code #[derive(Parser)] @@ -71,6 +46,8 @@ enum Subcommands { /// The name of the exercise name: Option, }, + /// Check all the exercises, marking them as done or pending accordingly. + CheckAll, /// Reset a single exercise Reset { /// The name of the exercise @@ -86,36 +63,26 @@ enum Subcommands { Dev(DevCommands), } -fn main() -> Result<()> { +fn main() -> Result { let args = Args::parse(); - if !DEBUG_PROFILE && in_official_repo() { + if cfg!(not(debug_assertions)) && Path::new("dev/rustlings-repo.txt").exists() { bail!("{OLD_METHOD_ERR}"); } - match args.command { - Some(Subcommands::Init) => { - if DEBUG_PROFILE { - bail!("Disabled in the debug build"); - } - - { - let mut stdout = io::stdout().lock(); - stdout.write_all(b"This command will create the directory `rustlings/` which will contain the exercises.\nPress ENTER to continue ")?; - stdout.flush()?; - press_enter_prompt()?; - stdout.write_all(b"\n")?; - } - - return init::init().context("Initialization failed"); + 'priority_cmd: { + match args.command { + Some(Subcommands::Init) => init::init().context("Initialization failed")?, + Some(Subcommands::Dev(dev_command)) => dev_command.run()?, + _ => break 'priority_cmd, } - Some(Subcommands::Dev(dev_command)) => return dev_command.run(), - _ => (), + + return Ok(ExitCode::SUCCESS); } if !Path::new("exercises").is_dir() { println!("{PRE_INIT_MSG}"); - exit(1); + return Ok(ExitCode::FAILURE); } let info_file = InfoFile::parse()?; @@ -136,11 +103,12 @@ fn main() -> Result<()> { let mut stdout = io::stdout().lock(); clear_terminal(&mut stdout)?; - let welcome_message = welcome_message.trim(); + let welcome_message = welcome_message.trim_ascii(); write!(stdout, "{welcome_message}\n\nPress ENTER to continue ")?; - stdout.flush()?; - press_enter_prompt()?; + press_enter_prompt(&mut stdout)?; clear_terminal(&mut stdout)?; + // Flush to be able to show errors occurring before printing a newline to stdout. + stdout.flush()?; } StateFileStatus::Read => (), } @@ -167,21 +135,41 @@ fn main() -> Result<()> { ) }; - loop { - match watch::watch(&mut app_state, notify_exercise_names)? { - WatchExit::Shutdown => break, - // It is much easier to exit the watch mode, launch the list mode and then restart - // the watch mode instead of trying to pause the watch threads and correct the - // watch state. - WatchExit::List => list::list(&mut app_state)?, - } - } + watch::watch(&mut app_state, notify_exercise_names)?; } Some(Subcommands::Run { name }) => { if let Some(name) = name { app_state.set_current_exercise_by_name(&name)?; } - run::run(&mut app_state)?; + return run::run(&mut app_state); + } + Some(Subcommands::CheckAll) => { + let mut stdout = io::stdout().lock(); + if let Some(first_pending_exercise_ind) = app_state.check_all_exercises(&mut stdout)? { + if app_state.current_exercise().done { + app_state.set_current_exercise_ind(first_pending_exercise_ind)?; + } + + stdout.write_all(b"\n\n")?; + let pending = app_state.n_pending(); + if pending == 1 { + stdout.write_all(b"One exercise pending: ")?; + } else { + write!( + stdout, + "{pending}/{} exercises pending. The first: ", + app_state.exercises().len(), + )?; + } + app_state + .current_exercise() + .terminal_file_link(&mut stdout)?; + stdout.write_all(b"\n")?; + + return Ok(ExitCode::FAILURE); + } else { + app_state.render_final_message(&mut stdout)?; + } } Some(Subcommands::Reset { name }) => { app_state.set_current_exercise_by_name(&name)?; @@ -198,7 +186,7 @@ fn main() -> Result<()> { Some(Subcommands::Init | Subcommands::Dev(_)) => (), } - Ok(()) + Ok(ExitCode::SUCCESS) } const OLD_METHOD_ERR: &str = diff --git a/src/progress_bar.rs b/src/progress_bar.rs deleted file mode 100644 index 4a54170a..00000000 --- a/src/progress_bar.rs +++ /dev/null @@ -1,100 +0,0 @@ -use anyhow::{bail, Result}; -use ratatui::text::{Line, Span}; -use std::fmt::Write; - -const PREFIX: &str = "Progress: ["; -const PREFIX_WIDTH: u16 = PREFIX.len() as u16; -// Leaving the last char empty (_) for `total` > 99. -const POSTFIX_WIDTH: u16 = "] xxx/xx exercises_".len() as u16; -const WRAPPER_WIDTH: u16 = PREFIX_WIDTH + POSTFIX_WIDTH; -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 { - use crossterm::style::Stylize; - - if progress > total { - bail!(PROGRESS_EXCEEDS_MAX_ERR); - } - - if line_width < MIN_LINE_WIDTH { - return Ok(format!("Progress: {progress}/{total} exercises")); - } - - let mut line = String::with_capacity(usize::from(line_width)); - line.push_str(PREFIX); - - let width = line_width - WRAPPER_WIDTH; - let filled = (width * progress) / total; - - let mut green_part = String::with_capacity(usize::from(filled + 1)); - for _ in 0..filled { - green_part.push('#'); - } - - if filled < width { - green_part.push('>'); - } - write!(line, "{}", green_part.green()).unwrap(); - - let width_minus_filled = width - filled; - if width_minus_filled > 1 { - let red_part_width = width_minus_filled - 1; - let mut red_part = String::with_capacity(usize::from(red_part_width)); - for _ in 0..red_part_width { - red_part.push('-'); - } - write!(line, "{}", red_part.red()).unwrap(); - } - - writeln!(line, "] {progress:>3}/{total} exercises").unwrap(); - - 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> { - use ratatui::style::Stylize; - - if progress > total { - bail!(PROGRESS_EXCEEDS_MAX_ERR); - } - - if line_width < MIN_LINE_WIDTH { - return Ok(Line::raw(format!("Progress: {progress}/{total} exercises"))); - } - - let mut spans = Vec::with_capacity(4); - spans.push(Span::raw(PREFIX)); - - let width = line_width - WRAPPER_WIDTH; - let filled = (width * progress) / total; - - let mut green_part = String::with_capacity(usize::from(filled + 1)); - for _ in 0..filled { - green_part.push('#'); - } - - if filled < width { - green_part.push('>'); - } - spans.push(green_part.green()); - - let width_minus_filled = width - filled; - if width_minus_filled > 1 { - let red_part_width = width_minus_filled - 1; - let mut red_part = String::with_capacity(usize::from(red_part_width)); - for _ in 0..red_part_width { - red_part.push('-'); - } - spans.push(red_part.red()); - } - - spans.push(Span::raw(format!("] {progress:>3}/{total} exercises"))); - - Ok(Line::from(spans)) -} diff --git a/src/run.rs b/src/run.rs index 899d0a94..ac8b26ad 100644 --- a/src/run.rs +++ b/src/run.rs @@ -1,17 +1,22 @@ -use anyhow::{bail, Result}; -use crossterm::style::{style, Stylize}; -use std::io::{self, Write}; +use anyhow::Result; +use crossterm::{ + style::{Color, ResetColor, SetForegroundColor}, + QueueableCommand, +}; +use std::{ + io::{self, Write}, + process::ExitCode, +}; use crate::{ app_state::{AppState, ExercisesProgress}, - exercise::{RunnableExercise, OUTPUT_CAPACITY}, - terminal_link::TerminalFileLink, + exercise::{solution_link_line, RunnableExercise, OUTPUT_CAPACITY}, }; -pub fn run(app_state: &mut AppState) -> Result<()> { +pub fn run(app_state: &mut AppState) -> Result { let exercise = app_state.current_exercise(); let mut output = Vec::with_capacity(OUTPUT_CAPACITY); - let success = exercise.run_exercise(&mut output, app_state.target_dir())?; + let success = exercise.run_exercise(Some(&mut output), app_state.cmd_runner())?; let mut stdout = io::stdout().lock(); stdout.write_all(&output)?; @@ -19,33 +24,37 @@ pub fn run(app_state: &mut AppState) -> Result<()> { if !success { app_state.set_pending(app_state.current_exercise_ind())?; - bail!( - "Ran {} with errors", - app_state.current_exercise().terminal_link(), - ); + stdout.write_all(b"Ran ")?; + app_state + .current_exercise() + .terminal_file_link(&mut stdout)?; + stdout.write_all(b" with errors\n")?; + + return Ok(ExitCode::FAILURE); } - writeln!( - stdout, - "{}{}", - "✓ Successfully ran ".green(), - exercise.path.green(), - )?; + stdout.queue(SetForegroundColor(Color::Green))?; + stdout.write_all("✓ Successfully ran ".as_bytes())?; + stdout.write_all(exercise.path.as_bytes())?; + stdout.queue(ResetColor)?; + stdout.write_all(b"\n")?; if let Some(solution_path) = app_state.current_solution_path()? { - println!( - "\nA solution file can be found at {}\n", - style(TerminalFileLink(&solution_path)).underlined().green(), - ); + stdout.write_all(b"\n")?; + solution_link_line(&mut stdout, &solution_path)?; + stdout.write_all(b"\n")?; } - match app_state.done_current_exercise(&mut stdout)? { + match app_state.done_current_exercise::(&mut stdout)? { + ExercisesProgress::NewPending | ExercisesProgress::CurrentPending => { + stdout.write_all(b"Next exercise: ")?; + app_state + .current_exercise() + .terminal_file_link(&mut stdout)?; + stdout.write_all(b"\n")?; + } ExercisesProgress::AllDone => (), - ExercisesProgress::CurrentPending | ExercisesProgress::NewPending => println!( - "Next exercise: {}", - app_state.current_exercise().terminal_link(), - ), } - Ok(()) + Ok(ExitCode::SUCCESS) } diff --git a/src/term.rs b/src/term.rs new file mode 100644 index 00000000..cb0a07ce --- /dev/null +++ b/src/term.rs @@ -0,0 +1,279 @@ +use crossterm::{ + cursor::MoveTo, + style::{Attribute, Color, ResetColor, SetAttribute, SetForegroundColor}, + terminal::{Clear, ClearType}, + Command, QueueableCommand, +}; +use std::{ + fmt, fs, + io::{self, BufRead, StdoutLock, Write}, +}; + +use crate::app_state::CheckProgress; + +pub struct MaxLenWriter<'a, 'lock> { + pub stdout: &'a mut StdoutLock<'lock>, + len: usize, + max_len: usize, +} + +impl<'a, 'lock> MaxLenWriter<'a, 'lock> { + #[inline] + pub fn new(stdout: &'a mut StdoutLock<'lock>, max_len: usize) -> Self { + Self { + stdout, + len: 0, + max_len, + } + } + + // Additional is for emojis that take more space. + #[inline] + pub fn add_to_len(&mut self, additional: usize) { + self.len += additional; + } +} + +pub trait CountedWrite<'lock> { + fn write_ascii(&mut self, ascii: &[u8]) -> io::Result<()>; + fn write_str(&mut self, unicode: &str) -> io::Result<()>; + fn stdout(&mut self) -> &mut StdoutLock<'lock>; +} + +impl<'lock> CountedWrite<'lock> for MaxLenWriter<'_, 'lock> { + fn write_ascii(&mut self, ascii: &[u8]) -> io::Result<()> { + let n = ascii.len().min(self.max_len.saturating_sub(self.len)); + if n > 0 { + self.stdout.write_all(&ascii[..n])?; + self.len += n; + } + Ok(()) + } + + fn write_str(&mut self, unicode: &str) -> io::Result<()> { + if let Some((ind, c)) = unicode + .char_indices() + .take(self.max_len.saturating_sub(self.len)) + .last() + { + self.stdout + .write_all(&unicode.as_bytes()[..ind + c.len_utf8()])?; + self.len += ind + 1; + } + + Ok(()) + } + + #[inline] + fn stdout(&mut self) -> &mut StdoutLock<'lock> { + self.stdout + } +} + +impl<'a> CountedWrite<'a> for StdoutLock<'a> { + #[inline] + fn write_ascii(&mut self, ascii: &[u8]) -> io::Result<()> { + self.write_all(ascii) + } + + #[inline] + fn write_str(&mut self, unicode: &str) -> io::Result<()> { + self.write_all(unicode.as_bytes()) + } + + #[inline] + fn stdout(&mut self) -> &mut StdoutLock<'a> { + self + } +} + +pub struct CheckProgressVisualizer<'a, 'lock> { + stdout: &'a mut StdoutLock<'lock>, + n_cols: usize, +} + +impl<'a, 'lock> CheckProgressVisualizer<'a, 'lock> { + const CHECKING_COLOR: Color = Color::Blue; + const DONE_COLOR: Color = Color::Green; + const PENDING_COLOR: Color = Color::Red; + + pub fn build(stdout: &'a mut StdoutLock<'lock>, term_width: u16) -> io::Result { + clear_terminal(stdout)?; + stdout.write_all("Checking all exercises…\n".as_bytes())?; + + // Legend + stdout.write_all(b"Color of exercise number: ")?; + stdout.queue(SetForegroundColor(Self::CHECKING_COLOR))?; + stdout.write_all(b"Checking")?; + stdout.queue(ResetColor)?; + stdout.write_all(b" - ")?; + stdout.queue(SetForegroundColor(Self::DONE_COLOR))?; + stdout.write_all(b"Done")?; + stdout.queue(ResetColor)?; + stdout.write_all(b" - ")?; + stdout.queue(SetForegroundColor(Self::PENDING_COLOR))?; + stdout.write_all(b"Pending")?; + stdout.queue(ResetColor)?; + stdout.write_all(b"\n")?; + + // Exercise numbers with up to 3 digits. + // +1 because the last column doesn't end with a whitespace. + let n_cols = usize::from(term_width + 1) / 4; + + Ok(Self { stdout, n_cols }) + } + + pub fn update(&mut self, progresses: &[CheckProgress]) -> io::Result<()> { + self.stdout.queue(MoveTo(0, 2))?; + + let mut exercise_num = 1; + for exercise_progress in progresses { + match exercise_progress { + CheckProgress::None => (), + CheckProgress::Checking => { + self.stdout + .queue(SetForegroundColor(Self::CHECKING_COLOR))?; + } + CheckProgress::Done => { + self.stdout.queue(SetForegroundColor(Self::DONE_COLOR))?; + } + CheckProgress::Pending => { + self.stdout.queue(SetForegroundColor(Self::PENDING_COLOR))?; + } + } + + write!(self.stdout, "{exercise_num:<3}")?; + self.stdout.queue(ResetColor)?; + + if exercise_num != progresses.len() { + if exercise_num % self.n_cols == 0 { + self.stdout.write_all(b"\n")?; + } else { + self.stdout.write_all(b" ")?; + } + + exercise_num += 1; + } + } + + self.stdout.flush() + } +} + +pub fn progress_bar<'a>( + writer: &mut impl CountedWrite<'a>, + progress: u16, + total: u16, + term_width: u16, +) -> io::Result<()> { + debug_assert!(total <= 999); + debug_assert!(progress <= total); + + const PREFIX: &[u8] = b"Progress: ["; + const PREFIX_WIDTH: u16 = PREFIX.len() as u16; + const POSTFIX_WIDTH: u16 = "] xxx/xxx".len() as u16; + const WRAPPER_WIDTH: u16 = PREFIX_WIDTH + POSTFIX_WIDTH; + const MIN_LINE_WIDTH: u16 = WRAPPER_WIDTH + 4; + + if term_width < MIN_LINE_WIDTH { + writer.write_ascii(b"Progress: ")?; + // Integers are in ASCII. + return writer.write_ascii(format!("{progress}/{total}").as_bytes()); + } + + let stdout = writer.stdout(); + stdout.write_all(PREFIX)?; + + let width = term_width - WRAPPER_WIDTH; + let filled = (width * progress) / total; + + stdout.queue(SetForegroundColor(Color::Green))?; + for _ in 0..filled { + stdout.write_all(b"#")?; + } + + if filled < width { + stdout.write_all(b">")?; + } + + let width_minus_filled = width - filled; + if width_minus_filled > 1 { + let red_part_width = width_minus_filled - 1; + stdout.queue(SetForegroundColor(Color::Red))?; + for _ in 0..red_part_width { + stdout.write_all(b"-")?; + } + } + + stdout.queue(SetForegroundColor(Color::Reset))?; + + write!(stdout, "] {progress:>3}/{total}") +} + +pub fn clear_terminal(stdout: &mut StdoutLock) -> io::Result<()> { + stdout + .queue(MoveTo(0, 0))? + .queue(Clear(ClearType::All))? + .queue(Clear(ClearType::Purge)) + .map(|_| ()) +} + +pub fn press_enter_prompt(stdout: &mut StdoutLock) -> io::Result<()> { + stdout.flush()?; + io::stdin().lock().read_until(b'\n', &mut Vec::new())?; + stdout.write_all(b"\n") +} + +/// Canonicalize, convert to string and remove verbatim part on Windows. +pub fn canonicalize(path: &str) -> Option { + fs::canonicalize(path) + .ok()? + .into_os_string() + .into_string() + .ok() + .map(|mut path| { + // Windows itself can't handle its verbatim paths. + if cfg!(windows) && path.as_bytes().starts_with(br"\\?\") { + path.drain(..4); + } + + path + }) +} + +pub fn terminal_file_link<'a>( + writer: &mut impl CountedWrite<'a>, + path: &str, + canonical_path: &str, + color: Color, +) -> io::Result<()> { + writer + .stdout() + .queue(SetForegroundColor(color))? + .queue(SetAttribute(Attribute::Underlined))?; + writer.stdout().write_all(b"\x1b]8;;file://")?; + writer.stdout().write_all(canonical_path.as_bytes())?; + writer.stdout().write_all(b"\x1b\\")?; + // Only this part is visible. + writer.write_str(path)?; + writer.stdout().write_all(b"\x1b]8;;\x1b\\")?; + writer + .stdout() + .queue(SetForegroundColor(Color::Reset))? + .queue(SetAttribute(Attribute::NoUnderline))?; + + Ok(()) +} + +pub fn write_ansi(output: &mut Vec, command: impl Command) { + struct FmtWriter<'a>(&'a mut Vec); + + impl fmt::Write for FmtWriter<'_> { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.0.extend_from_slice(s.as_bytes()); + Ok(()) + } + } + + let _ = command.write_ansi(&mut FmtWriter(output)); +} diff --git a/src/terminal_link.rs b/src/terminal_link.rs deleted file mode 100644 index 9bea07d9..00000000 --- a/src/terminal_link.rs +++ /dev/null @@ -1,26 +0,0 @@ -use std::{ - fmt::{self, Display, Formatter}, - fs, -}; - -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) - } else { - write!(f, "{}", self.0) - } - } -} diff --git a/src/watch.rs b/src/watch.rs index 88a12301..6259c9df 100644 --- a/src/watch.rs +++ b/src/watch.rs @@ -1,110 +1,127 @@ use anyhow::{Error, Result}; -use notify_debouncer_mini::{ - new_debouncer, - notify::{self, RecursiveMode}, -}; +use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; use std::{ io::{self, Write}, path::Path, - sync::mpsc::channel, - thread, + sync::{ + atomic::{AtomicBool, Ordering::Relaxed}, + mpsc::channel, + }, time::Duration, }; -use crate::app_state::{AppState, ExercisesProgress}; - -use self::{ - notify_event::NotifyEventHandler, - state::WatchState, - terminal_event::{terminal_event_handler, InputEvent}, +use crate::{ + app_state::{AppState, ExercisesProgress}, + list, }; +use self::{notify_event::NotifyEventHandler, state::WatchState, terminal_event::InputEvent}; + mod notify_event; mod state; mod terminal_event; +static EXERCISE_RUNNING: AtomicBool = AtomicBool::new(false); + +// Private unit type to force using the constructor function. +#[must_use = "When the guard is dropped, the input is unpaused"] +pub struct InputPauseGuard(()); + +impl InputPauseGuard { + #[inline] + pub fn scoped_pause() -> Self { + EXERCISE_RUNNING.store(true, Relaxed); + Self(()) + } +} + +impl Drop for InputPauseGuard { + #[inline] + fn drop(&mut self) { + EXERCISE_RUNNING.store(false, Relaxed); + } +} + enum WatchEvent { Input(InputEvent), FileChange { exercise_ind: usize }, - TerminalResize, + TerminalResize { width: u16 }, NotifyErr(notify::Error), TerminalEventErr(io::Error), } /// Returned by the watch mode to indicate what to do afterwards. #[must_use] -pub enum WatchExit { +enum WatchExit { /// Exit the program. Shutdown, /// Enter the list mode and restart the watch mode afterwards. List, } -/// `notify_exercise_names` as None activates the manual run mode. -pub fn watch( +fn run_watch( app_state: &mut AppState, notify_exercise_names: Option<&'static [&'static [u8]]>, ) -> Result { - let (tx, rx) = channel(); + let (watch_event_sender, watch_event_receiver) = channel(); let mut manual_run = false; // Prevent dropping the guard until the end of the function. // Otherwise, the file watcher exits. - let _debouncer_guard = if let Some(exercise_names) = notify_exercise_names { - let mut debouncer = new_debouncer( - Duration::from_millis(200), - NotifyEventHandler { - tx: tx.clone(), - exercise_names, - }, + let _watcher_guard = if let Some(exercise_names) = notify_exercise_names { + let notify_event_handler = + NotifyEventHandler::build(watch_event_sender.clone(), exercise_names)?; + + let mut watcher = RecommendedWatcher::new( + notify_event_handler, + Config::default().with_poll_interval(Duration::from_secs(1)), ) .inspect_err(|_| eprintln!("{NOTIFY_ERR}"))?; - debouncer - .watcher() + + watcher .watch(Path::new("exercises"), RecursiveMode::Recursive) .inspect_err(|_| eprintln!("{NOTIFY_ERR}"))?; - Some(debouncer) + Some(watcher) } else { manual_run = true; None }; - let mut watch_state = WatchState::new(app_state, manual_run); + let mut watch_state = WatchState::build(app_state, watch_event_sender, manual_run)?; + let mut stdout = io::stdout().lock(); - watch_state.run_current_exercise()?; + watch_state.run_current_exercise(&mut stdout)?; - thread::spawn(move || terminal_event_handler(tx, manual_run)); - - while let Ok(event) = rx.recv() { + while let Ok(event) = watch_event_receiver.recv() { match event { - WatchEvent::Input(InputEvent::Next) => match watch_state.next_exercise()? { + WatchEvent::Input(InputEvent::Next) => match watch_state.next_exercise(&mut stdout)? { ExercisesProgress::AllDone => break, - ExercisesProgress::CurrentPending => watch_state.render()?, - ExercisesProgress::NewPending => watch_state.run_current_exercise()?, + ExercisesProgress::NewPending => watch_state.run_current_exercise(&mut stdout)?, + ExercisesProgress::CurrentPending => (), }, - WatchEvent::Input(InputEvent::Hint) => { - watch_state.show_hint()?; - } - WatchEvent::Input(InputEvent::List) => { - return Ok(WatchExit::List); - } + WatchEvent::Input(InputEvent::Run) => watch_state.run_current_exercise(&mut stdout)?, + WatchEvent::Input(InputEvent::Hint) => watch_state.show_hint(&mut stdout)?, + WatchEvent::Input(InputEvent::List) => return Ok(WatchExit::List), + WatchEvent::Input(InputEvent::CheckAll) => match watch_state + .check_all_exercises(&mut stdout)? + { + ExercisesProgress::AllDone => break, + ExercisesProgress::NewPending => watch_state.run_current_exercise(&mut stdout)?, + ExercisesProgress::CurrentPending => watch_state.render(&mut stdout)?, + }, + WatchEvent::Input(InputEvent::Reset) => watch_state.reset_exercise(&mut stdout)?, WatchEvent::Input(InputEvent::Quit) => { - watch_state.into_writer().write_all(QUIT_MSG)?; + stdout.write_all(QUIT_MSG)?; break; } - WatchEvent::Input(InputEvent::Run) => watch_state.run_current_exercise()?, - WatchEvent::Input(InputEvent::Unrecognized) => watch_state.render()?, WatchEvent::FileChange { exercise_ind } => { - watch_state.handle_file_change(exercise_ind)?; + watch_state.handle_file_change(exercise_ind, &mut stdout)?; } - WatchEvent::TerminalResize => { - watch_state.render()?; - } - WatchEvent::NotifyErr(e) => { - watch_state.into_writer().write_all(NOTIFY_ERR.as_bytes())?; - return Err(Error::from(e)); + WatchEvent::TerminalResize { width } => { + watch_state.update_term_width(width, &mut stdout)?; } + WatchEvent::NotifyErr(e) => return Err(Error::from(e).context(NOTIFY_ERR)), WatchEvent::TerminalEventErr(e) => { return Err(Error::from(e).context("Terminal event listener failed")); } @@ -114,9 +131,52 @@ pub fn watch( Ok(WatchExit::Shutdown) } +fn watch_list_loop( + app_state: &mut AppState, + notify_exercise_names: Option<&'static [&'static [u8]]>, +) -> Result<()> { + loop { + match run_watch(app_state, notify_exercise_names)? { + WatchExit::Shutdown => break Ok(()), + // It is much easier to exit the watch mode, launch the list mode and then restart + // the watch mode instead of trying to pause the watch threads and correct the + // watch state. + WatchExit::List => list::list(app_state)?, + } + } +} + +/// `notify_exercise_names` as None activates the manual run mode. +pub fn watch( + app_state: &mut AppState, + notify_exercise_names: Option<&'static [&'static [u8]]>, +) -> Result<()> { + #[cfg(not(windows))] + { + let stdin_fd = rustix::stdio::stdin(); + let mut termios = rustix::termios::tcgetattr(stdin_fd)?; + let original_local_modes = termios.local_modes; + // Disable stdin line buffering and hide input. + termios.local_modes -= + rustix::termios::LocalModes::ICANON | rustix::termios::LocalModes::ECHO; + rustix::termios::tcsetattr(stdin_fd, rustix::termios::OptionalActions::Now, &termios)?; + + let res = watch_list_loop(app_state, notify_exercise_names); + + termios.local_modes = original_local_modes; + rustix::termios::tcsetattr(stdin_fd, rustix::termios::OptionalActions::Now, &termios)?; + + res + } + + #[cfg(windows)] + watch_list_loop(app_state, notify_exercise_names) +} + const QUIT_MSG: &[u8] = b" + We hope you're enjoying learning Rust! -If you want to continue working on the exercises at a later point, you can simply run `rustlings` again. +If you want to continue working on the exercises at a later point, you can simply run `rustlings` again in this directory. "; const NOTIFY_ERR: &str = " diff --git a/src/watch/notify_event.rs b/src/watch/notify_event.rs index 74716409..2051e544 100644 --- a/src/watch/notify_event.rs +++ b/src/watch/notify_event.rs @@ -1,52 +1,132 @@ -use notify_debouncer_mini::{DebounceEventResult, DebouncedEventKind}; -use std::sync::mpsc::Sender; +use anyhow::{Context, Result}; +use notify::{ + event::{AccessKind, AccessMode, MetadataKind, ModifyKind, RenameMode}, + Event, EventKind, +}; +use std::{ + sync::{ + atomic::Ordering::Relaxed, + mpsc::{sync_channel, RecvTimeoutError, Sender, SyncSender}, + }, + thread, + time::Duration, +}; -use super::WatchEvent; +use super::{WatchEvent, EXERCISE_RUNNING}; + +const DEBOUNCE_DURATION: Duration = Duration::from_millis(200); pub struct NotifyEventHandler { - pub tx: Sender, - /// Used to report which exercise was modified. - pub exercise_names: &'static [&'static [u8]], + error_sender: Sender, + // Sends the index of the updated exercise. + update_sender: SyncSender, + // Used to report which exercise was modified. + 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 - .iter() - .filter_map(|input_event| { - if input_event.kind != DebouncedEventKind::Any { - return None; +impl NotifyEventHandler { + pub fn build( + watch_event_sender: Sender, + exercise_names: &'static [&'static [u8]], + ) -> Result { + let (update_sender, update_receiver) = sync_channel(0); + let error_sender = watch_event_sender.clone(); + + // Debouncer + thread::Builder::new() + .spawn(move || { + let mut exercise_updated = vec![false; exercise_names.len()]; + + loop { + match update_receiver.recv_timeout(DEBOUNCE_DURATION) { + Ok(exercise_ind) => exercise_updated[exercise_ind] = true, + Err(RecvTimeoutError::Timeout) => { + for (exercise_ind, updated) in exercise_updated.iter_mut().enumerate() { + if *updated { + if watch_event_sender + .send(WatchEvent::FileChange { exercise_ind }) + .is_err() + { + break; + } + + *updated = false; + } + } } + Err(RecvTimeoutError::Disconnected) => break, + } + } + }) + .context("Failed to spawn a thread to debounce file changes")?; - let file_name = input_event.path.file_name()?.to_str()?.as_bytes(); - - if file_name.len() < 4 { - return None; - } - let (file_name_without_ext, ext) = file_name.split_at(file_name.len() - 3); - - if ext != b".rs" { - return None; - } - - self.exercise_names - .iter() - .position(|exercise_name| *exercise_name == file_name_without_ext) - }) - .min() - else { - return; - }; - - WatchEvent::FileChange { exercise_ind } - } - Err(e) => WatchEvent::NotifyErr(e), - }; - - // 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); + Ok(Self { + error_sender, + update_sender, + exercise_names, + }) + } +} + +impl notify::EventHandler for NotifyEventHandler { + fn handle_event(&mut self, input_event: notify::Result) { + if EXERCISE_RUNNING.load(Relaxed) { + return; + } + + let input_event = match input_event { + Ok(v) => v, + Err(e) => { + // An error occurs when the receiver is dropped. + // After dropping the receiver, the watcher guard should also be dropped. + let _ = self.error_sender.send(WatchEvent::NotifyErr(e)); + return; + } + }; + + match input_event.kind { + EventKind::Any => (), + EventKind::Modify(modify_kind) => match modify_kind { + ModifyKind::Any | ModifyKind::Data(_) => (), + ModifyKind::Name(rename_mode) => match rename_mode { + RenameMode::Any | RenameMode::To => (), + RenameMode::From | RenameMode::Both | RenameMode::Other => return, + }, + ModifyKind::Metadata(metadata_kind) => match metadata_kind { + MetadataKind::Any | MetadataKind::WriteTime => (), + MetadataKind::AccessTime + | MetadataKind::Permissions + | MetadataKind::Ownership + | MetadataKind::Extended + | MetadataKind::Other => return, + }, + ModifyKind::Other => return, + }, + EventKind::Access(access_kind) => match access_kind { + AccessKind::Any => (), + AccessKind::Close(access_mode) => match access_mode { + AccessMode::Any | AccessMode::Write => (), + AccessMode::Execute | AccessMode::Read | AccessMode::Other => return, + }, + AccessKind::Read | AccessKind::Open(_) | AccessKind::Other => return, + }, + EventKind::Create(_) | EventKind::Remove(_) | EventKind::Other => return, + } + + let _ = input_event + .paths + .into_iter() + .filter_map(|path| { + let file_name = path.file_name()?.to_str()?.as_bytes(); + + let [file_name_without_ext @ .., b'.', b'r', b's'] = file_name else { + return None; + }; + + self.exercise_names + .iter() + .position(|exercise_name| *exercise_name == file_name_without_ext) + }) + .try_for_each(|exercise_ind| self.update_sender.send(exercise_ind)); } } diff --git a/src/watch/state.rs b/src/watch/state.rs index 78af30a4..0ac758ce 100644 --- a/src/watch/state.rs +++ b/src/watch/state.rs @@ -1,18 +1,25 @@ -use anyhow::Result; +use anyhow::{Context, Result}; use crossterm::{ - style::{style, Stylize}, - terminal, + style::{ + Attribute, Attributes, Color, ResetColor, SetAttribute, SetAttributes, SetForegroundColor, + }, + terminal, QueueableCommand, +}; +use std::{ + io::{self, Read, StdoutLock, Write}, + sync::mpsc::{sync_channel, Sender, SyncSender}, + thread, }; -use std::io::{self, StdoutLock, Write}; use crate::{ app_state::{AppState, ExercisesProgress}, clear_terminal, - exercise::{RunnableExercise, OUTPUT_CAPACITY}, - progress_bar::progress_bar, - terminal_link::TerminalFileLink, + exercise::{solution_link_line, RunnableExercise, OUTPUT_CAPACITY}, + term::progress_bar, }; +use super::{terminal_event::terminal_event_handler, InputPauseGuard, WatchEvent}; + #[derive(PartialEq, Eq)] enum DoneStatus { DoneWithSolution(String), @@ -21,40 +28,65 @@ enum DoneStatus { } pub struct WatchState<'a> { - writer: StdoutLock<'a>, app_state: &'a mut AppState, output: Vec, show_hint: bool, done_status: DoneStatus, manual_run: bool, + term_width: u16, + terminal_event_unpause_sender: SyncSender<()>, } impl<'a> WatchState<'a> { - pub fn new(app_state: &'a mut AppState, manual_run: bool) -> Self { - let writer = io::stdout().lock(); + pub fn build( + app_state: &'a mut AppState, + watch_event_sender: Sender, + manual_run: bool, + ) -> Result { + let term_width = terminal::size() + .context("Failed to get the terminal size")? + .0; - Self { - writer, + let (terminal_event_unpause_sender, terminal_event_unpause_receiver) = sync_channel(0); + + thread::Builder::new() + .spawn(move || { + terminal_event_handler( + watch_event_sender, + terminal_event_unpause_receiver, + manual_run, + ) + }) + .context("Failed to spawn a thread to handle terminal events")?; + + Ok(Self { app_state, output: Vec::with_capacity(OUTPUT_CAPACITY), show_hint: false, done_status: DoneStatus::Pending, manual_run, - } + term_width, + terminal_event_unpause_sender, + }) } - #[inline] - pub fn into_writer(self) -> StdoutLock<'a> { - self.writer - } + pub fn run_current_exercise(&mut self, stdout: &mut StdoutLock) -> Result<()> { + // Ignore any input until running the exercise is done. + let _input_pause_guard = InputPauseGuard::scoped_pause(); - pub fn run_current_exercise(&mut self) -> Result<()> { self.show_hint = false; + writeln!( + stdout, + "\nChecking the exercise `{}`. Please wait…", + self.app_state.current_exercise().name, + )?; + let success = self .app_state .current_exercise() - .run_exercise(&mut self.output, self.app_state.target_dir())?; + .run_exercise(Some(&mut self.output), self.app_state.cmd_runner())?; + self.output.push(b'\n'); if success { self.done_status = if let Some(solution_path) = self.app_state.current_solution_path()? { @@ -69,106 +101,207 @@ impl<'a> WatchState<'a> { self.done_status = DoneStatus::Pending; } - self.render() + self.render(stdout)?; + Ok(()) } - 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 reset_exercise(&mut self, stdout: &mut StdoutLock) -> Result<()> { + clear_terminal(stdout)?; + + stdout.write_all(b"Resetting will undo all your changes to the file ")?; + stdout.write_all(self.app_state.current_exercise().path.as_bytes())?; + stdout.write_all(b"\nReset (y/n)? ")?; + stdout.flush()?; + + { + let mut stdin = io::stdin().lock(); + let mut answer = [0]; + loop { + stdin + .read_exact(&mut answer) + .context("Failed to read the user's input")?; + + match answer[0] { + b'y' | b'Y' => { + self.app_state.reset_current_exercise()?; + + // The file watcher reruns the exercise otherwise. + if self.manual_run { + self.run_current_exercise(stdout)?; + } + } + b'n' | b'N' => self.render(stdout)?, + _ => continue, + } + + break; + } } - 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 { - if self.done_status == DoneStatus::Pending { - return Ok(ExercisesProgress::CurrentPending); - } - - self.app_state.done_current_exercise(&mut self.writer) - } - - fn show_prompt(&mut self) -> io::Result<()> { - self.writer.write_all(b"\n")?; - - if self.manual_run { - write!(self.writer, "{}:run / ", 'r'.bold())?; - } - - if self.done_status != DoneStatus::Pending { - write!(self.writer, "{}:{} / ", 'n'.bold(), "next".underlined())?; - } - - if !self.show_hint { - write!(self.writer, "{}:hint / ", 'h'.bold())?; - } - - write!(self.writer, "{}:list / {}:quit ? ", 'l'.bold(), 'q'.bold())?; - - self.writer.flush() - } - - pub fn render(&mut self) -> Result<()> { - // Prevent having the first line shifted if clearing wasn't successful. - self.writer.write_all(b"\n")?; - - clear_terminal(&mut self.writer)?; - - self.writer.write_all(&self.output)?; - self.writer.write_all(b"\n")?; - - if self.show_hint { - writeln!( - self.writer, - "{}\n{}\n", - "Hint".bold().cyan().underlined(), - self.app_state.current_exercise().hint, - )?; - } - - if 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 🦀" - .bold() - .green(), - )?; - } - - if let DoneStatus::DoneWithSolution(solution_path) = &self.done_status { - writeln!( - self.writer, - "A solution file can be found at {}\n", - style(TerminalFileLink(solution_path)).underlined().green(), - )?; - } - - let line_width = terminal::size()?.0; - let progress_bar = progress_bar( - self.app_state.n_done(), - self.app_state.exercises().len() as u16, - line_width, - )?; - writeln!( - self.writer, - "{progress_bar}Current exercise: {}", - self.app_state.current_exercise().terminal_link(), - )?; - - self.show_prompt()?; + self.terminal_event_unpause_sender.send(())?; Ok(()) } - pub fn show_hint(&mut self) -> Result<()> { - self.show_hint = true; - self.render() + pub fn handle_file_change( + &mut self, + exercise_ind: usize, + stdout: &mut StdoutLock, + ) -> Result<()> { + if self.app_state.current_exercise_ind() != exercise_ind { + return Ok(()); + } + + self.run_current_exercise(stdout) + } + + /// Move on to the next exercise if the current one is done. + pub fn next_exercise(&mut self, stdout: &mut StdoutLock) -> Result { + match self.done_status { + DoneStatus::DoneWithSolution(_) | DoneStatus::DoneWithoutSolution => (), + DoneStatus::Pending => return Ok(ExercisesProgress::CurrentPending), + } + + self.app_state.done_current_exercise::(stdout) + } + + fn show_prompt(&self, stdout: &mut StdoutLock) -> io::Result<()> { + if self.done_status != DoneStatus::Pending { + stdout.queue(SetAttribute(Attribute::Bold))?; + stdout.write_all(b"n")?; + stdout.queue(ResetColor)?; + stdout.write_all(b":")?; + stdout.queue(SetAttribute(Attribute::Underlined))?; + stdout.write_all(b"next")?; + stdout.queue(ResetColor)?; + stdout.write_all(b" / ")?; + } + + if self.manual_run { + stdout.queue(SetAttribute(Attribute::Bold))?; + stdout.write_all(b"r")?; + stdout.queue(ResetColor)?; + stdout.write_all(b":run / ")?; + } + + if !self.show_hint { + stdout.queue(SetAttribute(Attribute::Bold))?; + stdout.write_all(b"h")?; + stdout.queue(ResetColor)?; + stdout.write_all(b":hint / ")?; + } + + stdout.queue(SetAttribute(Attribute::Bold))?; + stdout.write_all(b"l")?; + stdout.queue(ResetColor)?; + stdout.write_all(b":list / ")?; + + stdout.queue(SetAttribute(Attribute::Bold))?; + stdout.write_all(b"c")?; + stdout.queue(ResetColor)?; + stdout.write_all(b":check all / ")?; + + stdout.queue(SetAttribute(Attribute::Bold))?; + stdout.write_all(b"x")?; + stdout.queue(ResetColor)?; + stdout.write_all(b":reset / ")?; + + stdout.queue(SetAttribute(Attribute::Bold))?; + stdout.write_all(b"q")?; + stdout.queue(ResetColor)?; + stdout.write_all(b":quit ? ")?; + + stdout.flush() + } + + pub fn render(&self, stdout: &mut StdoutLock) -> io::Result<()> { + // Prevent having the first line shifted if clearing wasn't successful. + stdout.write_all(b"\n")?; + clear_terminal(stdout)?; + + stdout.write_all(&self.output)?; + + if self.show_hint { + stdout + .queue(SetAttributes( + Attributes::from(Attribute::Bold).with(Attribute::Underlined), + ))? + .queue(SetForegroundColor(Color::Cyan))?; + stdout.write_all(b"Hint")?; + stdout.queue(ResetColor)?; + stdout.write_all(b"\n")?; + + stdout.write_all(self.app_state.current_exercise().hint.as_bytes())?; + stdout.write_all(b"\n\n")?; + } + + if self.done_status != DoneStatus::Pending { + stdout + .queue(SetAttribute(Attribute::Bold))? + .queue(SetForegroundColor(Color::Green))?; + stdout.write_all("Exercise done ✓".as_bytes())?; + stdout.queue(ResetColor)?; + stdout.write_all(b"\n")?; + + if let DoneStatus::DoneWithSolution(solution_path) = &self.done_status { + solution_link_line(stdout, solution_path)?; + } + + stdout.write_all( + "When done experimenting, enter `n` to move on to the next exercise 🦀\n\n" + .as_bytes(), + )?; + } + + progress_bar( + stdout, + self.app_state.n_done(), + self.app_state.exercises().len() as u16, + self.term_width, + )?; + + stdout.write_all(b"\nCurrent exercise: ")?; + self.app_state + .current_exercise() + .terminal_file_link(stdout)?; + stdout.write_all(b"\n\n")?; + + self.show_prompt(stdout)?; + + Ok(()) + } + + pub fn show_hint(&mut self, stdout: &mut StdoutLock) -> io::Result<()> { + if !self.show_hint { + self.show_hint = true; + self.render(stdout)?; + } + + Ok(()) + } + + pub fn check_all_exercises(&mut self, stdout: &mut StdoutLock) -> Result { + if let Some(first_pending_exercise_ind) = self.app_state.check_all_exercises(stdout)? { + // Only change exercise if the current one is done. + if self.app_state.current_exercise().done { + self.app_state + .set_current_exercise_ind(first_pending_exercise_ind)?; + Ok(ExercisesProgress::NewPending) + } else { + Ok(ExercisesProgress::CurrentPending) + } + } else { + self.app_state.render_final_message(stdout)?; + Ok(ExercisesProgress::AllDone) + } + } + + pub fn update_term_width(&mut self, width: u16, stdout: &mut StdoutLock) -> io::Result<()> { + if self.term_width != width { + self.term_width = width; + self.render(stdout)?; + } + + Ok(()) } } diff --git a/src/watch/terminal_event.rs b/src/watch/terminal_event.rs index f54af17a..48411db0 100644 --- a/src/watch/terminal_event.rs +++ b/src/watch/terminal_event.rs @@ -1,86 +1,73 @@ -use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; -use std::sync::mpsc::Sender; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use std::sync::{ + atomic::Ordering::Relaxed, + mpsc::{Receiver, Sender}, +}; -use super::WatchEvent; +use super::{WatchEvent, EXERCISE_RUNNING}; pub enum InputEvent { - Run, Next, + Run, Hint, List, + CheckAll, + Reset, Quit, - Unrecognized, } -pub fn terminal_event_handler(tx: Sender, manual_run: bool) { - // Only send `Unrecognized` on ENTER if the last input wasn't valid. - let mut last_input_valid = false; - - let last_input_event = loop { - let terminal_event = match event::read() { - Ok(v) => v, - Err(e) => { - // If `send` returns an error, then the receiver is dropped and - // a shutdown has been already initialized. - let _ = tx.send(WatchEvent::TerminalEventErr(e)); - return; - } - }; - - match terminal_event { - Event::Key(key) => { +pub fn terminal_event_handler( + sender: Sender, + unpause_receiver: Receiver<()>, + manual_run: bool, +) { + let last_watch_event = loop { + match event::read() { + Ok(Event::Key(key)) => { match key.kind { KeyEventKind::Release | KeyEventKind::Repeat => continue, KeyEventKind::Press => (), } - if key.modifiers != KeyModifiers::NONE { - last_input_valid = false; + if EXERCISE_RUNNING.load(Relaxed) { continue; } let input_event = match key.code { - KeyCode::Enter => { - if last_input_valid { - continue; + KeyCode::Char('n') => InputEvent::Next, + KeyCode::Char('r') if manual_run => InputEvent::Run, + KeyCode::Char('h') => InputEvent::Hint, + KeyCode::Char('l') => break WatchEvent::Input(InputEvent::List), + KeyCode::Char('c') => InputEvent::CheckAll, + KeyCode::Char('x') => { + if sender.send(WatchEvent::Input(InputEvent::Reset)).is_err() { + return; } - 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; - } + // Pause input until quitting the confirmation prompt. + if unpause_receiver.recv().is_err() { + return; }; - last_input_valid = true; - input_event - } - _ => { - last_input_valid = false; continue; } + KeyCode::Char('q') => break WatchEvent::Input(InputEvent::Quit), + _ => continue, }; - if tx.send(WatchEvent::Input(input_event)).is_err() { + if sender.send(WatchEvent::Input(input_event)).is_err() { return; } } - Event::Resize(_, _) => { - if tx.send(WatchEvent::TerminalResize).is_err() { + Ok(Event::Resize(width, _)) => { + if sender.send(WatchEvent::TerminalResize { width }).is_err() { return; } } - Event::FocusGained | Event::FocusLost | Event::Mouse(_) | Event::Paste(_) => continue, + Ok(Event::FocusGained | Event::FocusLost | Event::Mouse(_)) => continue, + Err(e) => break WatchEvent::TerminalEventErr(e), } }; - let _ = tx.send(WatchEvent::Input(last_input_event)); + let _ = sender.send(last_watch_event); } diff --git a/tests/fixture/failure/Cargo.toml b/tests/fixture/failure/Cargo.toml deleted file mode 100644 index 7ee2f068..00000000 --- a/tests/fixture/failure/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "failure" -edition = "2021" -publish = false - -[[bin]] -name = "compFailure" -path = "exercises/compFailure.rs" - -[[bin]] -name = "compNoExercise" -path = "exercises/compNoExercise.rs" - -[[bin]] -name = "testFailure" -path = "exercises/testFailure.rs" - -[[bin]] -name = "testNotPassed" -path = "exercises/testNotPassed.rs" diff --git a/tests/fixture/failure/exercises/testFailure.rs b/tests/fixture/failure/exercises/testFailure.rs deleted file mode 100644 index fcbcf90a..00000000 --- a/tests/fixture/failure/exercises/testFailure.rs +++ /dev/null @@ -1,6 +0,0 @@ -fn main() {} - -#[test] -fn passing() { - asset!(true); -} diff --git a/tests/fixture/failure/exercises/testNotPassed.rs b/tests/fixture/failure/exercises/testNotPassed.rs deleted file mode 100644 index de0d61c0..00000000 --- a/tests/fixture/failure/exercises/testNotPassed.rs +++ /dev/null @@ -1,6 +0,0 @@ -fn main() {} - -#[test] -fn not_passing() { - assert!(false); -} diff --git a/tests/fixture/failure/info.toml b/tests/fixture/failure/info.toml deleted file mode 100644 index 554607a8..00000000 --- a/tests/fixture/failure/info.toml +++ /dev/null @@ -1,10 +0,0 @@ -format_version = 1 - -[[exercises]] -name = "compFailure" -test = false -hint = "" - -[[exercises]] -name = "testFailure" -hint = "Hello!" diff --git a/tests/fixture/state/Cargo.toml b/tests/fixture/state/Cargo.toml deleted file mode 100644 index adbd8ab1..00000000 --- a/tests/fixture/state/Cargo.toml +++ /dev/null @@ -1,16 +0,0 @@ -[package] -name = "state" -edition = "2021" -publish = false - -[[bin]] -name = "pending_exercise" -path = "exercises/pending_exercise.rs" - -[[bin]] -name = "pending_test_exercise" -path = "exercises/pending_test_exercise.rs" - -[[bin]] -name = "finished_exercise" -path = "exercises/finished_exercise.rs" diff --git a/tests/fixture/state/exercises/pending_exercise.rs b/tests/fixture/state/exercises/pending_exercise.rs deleted file mode 100644 index f328e4d9..00000000 --- a/tests/fixture/state/exercises/pending_exercise.rs +++ /dev/null @@ -1 +0,0 @@ -fn main() {} diff --git a/tests/fixture/state/exercises/pending_test_exercise.rs b/tests/fixture/state/exercises/pending_test_exercise.rs deleted file mode 100644 index 718e1dbb..00000000 --- a/tests/fixture/state/exercises/pending_test_exercise.rs +++ /dev/null @@ -1,4 +0,0 @@ -fn main() {} - -#[test] -fn it_works() {} diff --git a/tests/fixture/state/info.toml b/tests/fixture/state/info.toml deleted file mode 100644 index ff0b932e..00000000 --- a/tests/fixture/state/info.toml +++ /dev/null @@ -1,15 +0,0 @@ -format_version = 1 - -[[exercises]] -name = "pending_exercise" -test = false -hint = """""" - -[[exercises]] -name = "pending_test_exercise" -hint = """""" - -[[exercises]] -name = "finished_exercise" -test = false -hint = """""" diff --git a/tests/fixture/success/Cargo.toml b/tests/fixture/success/Cargo.toml deleted file mode 100644 index 028cf35a..00000000 --- a/tests/fixture/success/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "success" -edition = "2021" -publish = false - -[[bin]] -name = "compSuccess" -path = "exercises/compSuccess.rs" - -[[bin]] -name = "testSuccess" -path = "exercises/testSuccess.rs" diff --git a/tests/fixture/success/exercises/compSuccess.rs b/tests/fixture/success/exercises/compSuccess.rs deleted file mode 100644 index f328e4d9..00000000 --- a/tests/fixture/success/exercises/compSuccess.rs +++ /dev/null @@ -1 +0,0 @@ -fn main() {} diff --git a/tests/fixture/success/exercises/testSuccess.rs b/tests/fixture/success/exercises/testSuccess.rs deleted file mode 100644 index 4296cf61..00000000 --- a/tests/fixture/success/exercises/testSuccess.rs +++ /dev/null @@ -1,7 +0,0 @@ -fn main() {} - -#[test] -fn passing() { - println!("THIS TEST TOO SHALL PASS"); - assert!(true); -} diff --git a/tests/fixture/success/info.toml b/tests/fixture/success/info.toml deleted file mode 100644 index d66d7d47..00000000 --- a/tests/fixture/success/info.toml +++ /dev/null @@ -1,10 +0,0 @@ -format_version = 1 - -[[exercises]] -name = "compSuccess" -test = false -hint = """""" - -[[exercises]] -name = "testSuccess" -hint = """""" diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 7d30467b..bb3a084b 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -1,134 +1,182 @@ -use assert_cmd::prelude::*; -use std::process::Command; +use std::{ + env::{self, consts::EXE_SUFFIX}, + process::{Command, Stdio}, + str::from_utf8, +}; -#[test] -fn fails_when_in_wrong_dir() { - Command::cargo_bin("rustlings") - .unwrap() - .current_dir("tests/") - .assert() - .code(1); +enum Output<'a> { + FullStdout(&'a str), + PartialStdout(&'a str), + PartialStderr(&'a str), +} + +use Output::*; + +#[derive(Default)] +struct Cmd<'a> { + current_dir: Option<&'a str>, + args: &'a [&'a str], + output: Option>, +} + +impl<'a> Cmd<'a> { + #[inline] + fn current_dir(&mut self, current_dir: &'a str) -> &mut Self { + self.current_dir = Some(current_dir); + self + } + + #[inline] + fn args(&mut self, args: &'a [&'a str]) -> &mut Self { + self.args = args; + self + } + + #[inline] + fn output(&mut self, output: Output<'a>) -> &mut Self { + self.output = Some(output); + self + } + + fn assert(&self, success: bool) { + let rustlings_bin = { + let mut path = env::current_exe().unwrap(); + // Pop test binary name + path.pop(); + // Pop `/deps` + path.pop(); + + path.push("rustlings"); + let mut path = path.into_os_string(); + path.push(EXE_SUFFIX); + path + }; + + let mut cmd = Command::new(rustlings_bin); + + if let Some(current_dir) = self.current_dir { + cmd.current_dir(current_dir); + } + + cmd.args(self.args).stdin(Stdio::null()); + + let status = match self.output { + None => cmd + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap(), + Some(FullStdout(stdout)) => { + let output = cmd.stderr(Stdio::null()).output().unwrap(); + assert_eq!(from_utf8(&output.stdout).unwrap(), stdout); + output.status + } + Some(PartialStdout(stdout)) => { + let output = cmd.stderr(Stdio::null()).output().unwrap(); + assert!(from_utf8(&output.stdout).unwrap().contains(stdout)); + output.status + } + Some(PartialStderr(stderr)) => { + let output = cmd.stdout(Stdio::null()).output().unwrap(); + assert!(from_utf8(&output.stderr).unwrap().contains(stderr)); + output.status + } + }; + + assert_eq!(status.success(), success, "{cmd:?}"); + } + + #[inline] + fn success(&self) { + self.assert(true); + } + + #[inline] + fn fail(&self) { + self.assert(false); + } } #[test] -fn run_single_compile_success() { - Command::cargo_bin("rustlings") - .unwrap() - .args(["run", "compSuccess"]) - .current_dir("tests/fixture/success/") - .assert() +fn run_compilation_success() { + Cmd::default() + .current_dir("tests/test_exercises") + .args(&["run", "compilation_success"]) .success(); } #[test] -fn run_single_compile_failure() { - Command::cargo_bin("rustlings") - .unwrap() - .args(["run", "compFailure"]) - .current_dir("tests/fixture/failure/") - .assert() - .code(1); +fn run_compilation_failure() { + Cmd::default() + .current_dir("tests/test_exercises") + .args(&["run", "compilation_failure"]) + .fail(); } #[test] -fn run_single_test_success() { - Command::cargo_bin("rustlings") - .unwrap() - .args(["run", "testSuccess"]) - .current_dir("tests/fixture/success/") - .assert() +fn run_test_success() { + Cmd::default() + .current_dir("tests/test_exercises") + .args(&["run", "test_success"]) + .output(PartialStdout("\nOutput from `main` function\n")) .success(); } #[test] -fn run_single_test_failure() { - Command::cargo_bin("rustlings") - .unwrap() - .args(["run", "testFailure"]) - .current_dir("tests/fixture/failure/") - .assert() - .code(1); +fn run_test_failure() { + Cmd::default() + .current_dir("tests/test_exercises") + .args(&["run", "test_failure"]) + .fail(); } #[test] -fn run_single_test_not_passed() { - Command::cargo_bin("rustlings") - .unwrap() - .args(["run", "testNotPassed.rs"]) - .current_dir("tests/fixture/failure/") - .assert() - .code(1); +fn run_exercise_not_in_info() { + Cmd::default() + .current_dir("tests/test_exercises") + .args(&["run", "not_in_info"]) + .fail(); } #[test] -fn run_single_test_no_exercise() { - Command::cargo_bin("rustlings") - .unwrap() - .args(["run", "compNoExercise.rs"]) - .current_dir("tests/fixture/failure") - .assert() - .code(1); +fn reset_without_exercise_name() { + Cmd::default().args(&["reset"]).fail(); } #[test] -fn reset_single_exercise() { - Command::cargo_bin("rustlings") - .unwrap() - .args(["reset", "intro1"]) - .assert() - .code(0); +fn hint() { + Cmd::default() + .current_dir("tests/test_exercises") + .args(&["hint", "test_failure"]) + .output(FullStdout("The answer to everything: 42\n")) + .success(); } #[test] -fn reset_no_exercise() { - Command::cargo_bin("rustlings") - .unwrap() - .arg("reset") - .assert() - .code(2) - .stderr(predicates::str::contains( - "required arguments were not provided", - )); -} +fn init() { + let test_dir = tempfile::TempDir::new().unwrap(); + let test_dir = test_dir.path().to_str().unwrap(); -#[test] -fn get_hint_for_single_test() { - Command::cargo_bin("rustlings") - .unwrap() - .args(["hint", "testFailure"]) - .current_dir("tests/fixture/failure") - .assert() - .code(0) - .stdout("Hello!\n"); -} + Cmd::default().current_dir(test_dir).fail(); -#[test] -fn run_compile_exercise_does_not_prompt() { - Command::cargo_bin("rustlings") - .unwrap() - .args(["run", "pending_exercise"]) - .current_dir("tests/fixture/state") - .assert() - .code(0); -} + Cmd::default() + .current_dir(test_dir) + .args(&["init"]) + .success(); -#[test] -fn run_test_exercise_does_not_prompt() { - Command::cargo_bin("rustlings") - .unwrap() - .args(["run", "pending_test_exercise"]) - .current_dir("tests/fixture/state") - .assert() - .code(0); -} + // Running `init` after a successful initialization. + Cmd::default() + .current_dir(test_dir) + .args(&["init"]) + .output(PartialStderr("`cd rustlings`")) + .fail(); -#[test] -fn run_single_test_success_with_output() { - Command::cargo_bin("rustlings") - .unwrap() - .args(["run", "testSuccess"]) - .current_dir("tests/fixture/success/") - .assert() - .code(0) - .stdout(predicates::str::contains("THIS TEST TOO SHALL PASS")); + let initialized_dir = format!("{test_dir}/rustlings"); + + // Running `init` in the initialized directory. + Cmd::default() + .current_dir(&initialized_dir) + .args(&["init"]) + .output(PartialStderr("already initialized")) + .fail(); } diff --git a/tests/test_exercises/dev/Cargo.toml b/tests/test_exercises/dev/Cargo.toml new file mode 100644 index 00000000..01fe7c10 --- /dev/null +++ b/tests/test_exercises/dev/Cargo.toml @@ -0,0 +1,11 @@ +bin = [ + { name = "compilation_success", path = "../exercises/compilation_success.rs" }, + { name = "compilation_failure", path = "../exercises/compilation_failure.rs" }, + { name = "test_success", path = "../exercises/test_success.rs" }, + { name = "test_failure", path = "../exercises/test_failure.rs" }, +] + +[package] +name = "test_exercises" +edition = "2021" +publish = false diff --git a/tests/fixture/failure/exercises/compFailure.rs b/tests/test_exercises/exercises/compilation_failure.rs similarity index 100% rename from tests/fixture/failure/exercises/compFailure.rs rename to tests/test_exercises/exercises/compilation_failure.rs diff --git a/tests/fixture/failure/exercises/compNoExercise.rs b/tests/test_exercises/exercises/compilation_success.rs similarity index 100% rename from tests/fixture/failure/exercises/compNoExercise.rs rename to tests/test_exercises/exercises/compilation_success.rs diff --git a/tests/fixture/state/exercises/finished_exercise.rs b/tests/test_exercises/exercises/not_in_info.rs similarity index 100% rename from tests/fixture/state/exercises/finished_exercise.rs rename to tests/test_exercises/exercises/not_in_info.rs diff --git a/tests/test_exercises/exercises/test_failure.rs b/tests/test_exercises/exercises/test_failure.rs new file mode 100644 index 00000000..8c8d59d8 --- /dev/null +++ b/tests/test_exercises/exercises/test_failure.rs @@ -0,0 +1,9 @@ +fn main() {} + +#[cfg(test)] +mod tests { + #[test] + fn fails() { + assert!(false); + } +} diff --git a/tests/test_exercises/exercises/test_success.rs b/tests/test_exercises/exercises/test_success.rs new file mode 100644 index 00000000..8c8a3c61 --- /dev/null +++ b/tests/test_exercises/exercises/test_success.rs @@ -0,0 +1,9 @@ +fn main() { + println!("Output from `main` function"); +} + +#[cfg(test)] +mod tests { + #[test] + fn passes() {} +} diff --git a/tests/test_exercises/info.toml b/tests/test_exercises/info.toml new file mode 100644 index 00000000..d91094c0 --- /dev/null +++ b/tests/test_exercises/info.toml @@ -0,0 +1,19 @@ +format_version = 1 + +[[exercises]] +name = "compilation_success" +test = false +hint = "" + +[[exercises]] +name = "compilation_failure" +test = false +hint = "" + +[[exercises]] +name = "test_success" +hint = "" + +[[exercises]] +name = "test_failure" +hint = "The answer to everything: 42"