1
0
mirror of https://github.com/rust-lang/rustlings.git synced 2025-12-26 00:11:49 +02:00

Compare commits

..

72 Commits

Author SHA1 Message Date
mo8it
c8d1d9c51f chore: Release 2024-08-29 17:20:17 +02:00
mo8it
ab2eb3442e Update changelog 2024-08-29 17:10:39 +02:00
mo8it
dbbeb7d4ed Fix displaying the list message in narrow mode 2024-08-29 17:06:37 +02:00
mo8it
bfa00ffbdc Update deps 2024-08-29 16:40:40 +02:00
mo8it
10eb1a3aee Fix header padding 2024-08-29 16:01:41 +02:00
mo8it
fd2bf9f6f6 Simplify next_pending_exercise_ind 2024-08-29 01:59:04 +02:00
mo8it
fc1f9f0124 Optimize reading and writing the state file 2024-08-29 01:56:45 +02:00
mo8it
789492d1a9 The number of exercises can't be zero, but still 2024-08-29 00:32:58 +02:00
mo8it
afc320bed4 Fix error about too many open files during the final check 2024-08-29 00:17:22 +02:00
mo8it
cba4a6f9c8 Only disable links in VS code in the list 2024-08-28 01:19:53 +02:00
mo8it
5556d42b46 Use sol_path 2024-08-28 01:10:19 +02:00
mo8it
7d2bc1c7a4 Use a Vec for the name col padding 2024-08-28 00:56:22 +02:00
mo8it
c209c874a9 Check the exercise name length 2024-08-28 00:34:24 +02:00
mo8it
dd52e9cd72 Separate the scroll state 2024-08-27 00:03:50 +02:00
mo8it
0f71a150ff Making code prettier :P 2024-08-26 22:03:09 +02:00
mo8it
74388d4bf4 Only trigger write when needed 2024-08-26 04:41:26 +02:00
mo8it
e811dd15b5 Fix list on terminals that don't disable line wrapping 2024-08-26 04:29:58 +02:00
mo8it
f22700a4ec Use the correct environment variable 2024-08-26 02:43:08 +02:00
mo8it
ee25a7d458 Disable terminal links in VS-Code 2024-08-26 02:41:22 +02:00
mo8it
594e212b8a Darker highlighting in the list 2024-08-26 00:53:42 +02:00
mo8it
5c355468c1 File link in the list? No problem :D 2024-08-26 00:49:56 +02:00
mo8it
d1571d18f9 Only reset color and underline after link 2024-08-26 00:48:12 +02:00
mo8it
cb86b44dea LOL, swapped colors 2024-08-26 00:40:30 +02:00
mo8it
833e6e0c92 Newline after resetting attributes 2024-08-26 00:24:39 +02:00
mo8it
159273e532 Take stdout as argument in watch mode 2024-08-26 00:09:04 +02:00
mo8it
631f2db1a3 Lower the maximum scroll padding 2024-08-25 23:54:18 +02:00
mo8it
a1f0eaab54 Add disallowed types and methods in Clippy 2024-08-25 23:54:04 +02:00
mo8it
b1898f6d8b Use queue instead of Stylize 2024-08-25 23:53:50 +02:00
mo8it
d29e9e7e07 Update deps 2024-08-25 20:42:13 +02:00
mo8it
360605e284 Merge branch 'rm-ratatui' 2024-08-25 20:31:08 +02:00
mo8it
64772544fa Final touches :D 2024-08-25 20:29:54 +02:00
mo8it
5f4875e2ba Almost done with list 2024-08-25 19:24:12 +02:00
mo8it
fd2a8c01cb Separate drawing rows 2024-08-24 19:18:13 +02:00
mo8it
b6129ad081 Use the full length for the wide footer 2024-08-24 17:45:38 +02:00
mo8it
28d0b0a21e Highlight selected row 2024-08-24 17:45:02 +02:00
mo8it
b779c43126 Almost done with list display 2024-08-24 17:17:56 +02:00
mo8it
4e12725616 Don't exit the list on "to current" if nothing is selected 2024-08-24 00:23:45 +02:00
mo8it
570bc9f32d Start list without Ratatui 2024-08-24 00:14:12 +02:00
mo8it
47976caa69 Import Ordering 2024-08-22 14:42:17 +02:00
mo8it
f1abd8577c Add missing Clippy allows to solutions 2024-08-22 14:41:25 +02:00
mo8it
423b50b068 Use match instead of comparison chain 2024-08-22 14:37:47 +02:00
mo8it
bedf0789f2 Always use strict Clippy when checking solutions 2024-08-22 14:25:14 +02:00
mo8it
a2d1cb3b22 Push newline after running an exercise instead on each rendering 2024-08-20 16:05:52 +02:00
mo8it
e7ba88f905 Highlight the solution file 2024-08-20 16:04:29 +02:00
mo8it
50f6e5232e Leak info_file and cmd_runner in dev check 2024-08-20 14:47:08 +02:00
mo8it
8854f0a5ed Use anyhow! 2024-08-20 14:32:47 +02:00
mo8it
13cc3acdfd Improve readability 2024-08-20 13:56:52 +02:00
mo8it
5b7368c46d Improve error message if no exercise exists 2024-08-20 13:54:20 +02:00
mo8it
27999f2d26 Check if exercise doesn't contain tests 2024-08-20 13:49:48 +02:00
mo8it
e74f2a4274 Check for #[test] with newline at the end 2024-08-20 13:39:14 +02:00
mo8it
d141a73493 threads3: Improve the test 2024-08-20 13:35:07 +02:00
mo8it
631f44331e Remove --show-output for tests and use --format pretty 2024-08-20 13:08:15 +02:00
mo8it
b01fddef8b Show progress of dev check 2024-08-19 23:52:22 +02:00
mo8it
78a8553f1c "Continue at" quits the list 2024-08-19 23:29:17 +02:00
mo8it
b70c1abd7c Update deps 2024-08-19 23:28:53 +02:00
mo8it
71f31d74bc Update deps 2024-08-17 16:57:58 +02:00
mo8it
72e557b3a9 Break help footer on narrow terminals 2024-08-17 16:54:44 +02:00
mo8it
3eaccbb61a Restore the terminal after an error in the list 2024-08-17 16:49:07 +02:00
mo8it
b678bd8ed2 Disable mouse in the list 2024-08-17 16:34:43 +02:00
mo8it
2baa140615 q only quits the list 2024-08-17 15:53:34 +02:00
mo8it
e760f07767 Make it clear that reset only resets one exercise 2024-08-17 15:53:24 +02:00
mo8it
ca5d5f0a49 Remove dot for copy-pasta 2024-08-17 15:45:02 +02:00
mo8it
69b4fd49fc Only take a u8 to avoid huge output 2024-08-17 14:59:00 +02:00
mo8it
36f315c344 Add "the" 2024-08-17 14:56:52 +02:00
mo8it
8016f5ca2d Remove unneeded comma 2024-08-17 14:55:58 +02:00
mo8it
8ef2ff1257 Remove "Hello and" 2024-08-17 14:54:13 +02:00
mo8it
6ce31defb6 Ignore stdout of git init 2024-08-17 14:40:09 +02:00
mo8it
0b3ad9141b Add exercise lints 2024-08-16 00:24:45 +02:00
mo8it
c903db5c53 Add project lints 2024-08-16 00:24:45 +02:00
Mo
8a038b946c Merge pull request #2084 from crd477/patch-1
fix typo
2024-08-16 00:12:58 +02:00
Chad Dougherty
ed9740b72c fix typo
Similarely -> Similarly in comment
2024-08-15 14:21:27 -04:00
mo8it
ce3dcc9856 Fix typo 2024-08-09 12:47:32 +02:00
38 changed files with 1429 additions and 1098 deletions

View File

@@ -2,6 +2,3 @@
extend-exclude = [
"CHANGELOG.md",
]
[default.extend-words]
"ratatui" = "ratatui"

View File

@@ -1,3 +1,44 @@
<a name="6.3.0"></a>
## 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.
<a name="6.2.0"></a>
## 6.2.0 (2024-08-09)

245
Cargo.lock generated
View File

@@ -14,12 +14,6 @@ dependencies = [
"zerocopy",
]
[[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.15"
@@ -93,21 +87,6 @@ version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de"
[[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"
@@ -116,9 +95,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "clap"
version = "4.5.14"
version = "4.5.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c937d4061031a6d0c8da4b9a4f98a172fc2976dfb1c19213a9cf7d0d3c837e36"
checksum = "ed6719fffa43d0d87e5fd8caeab59be1554fb028cd30edc88fc4369b17971019"
dependencies = [
"clap_builder",
"clap_derive",
@@ -126,9 +105,9 @@ dependencies = [
[[package]]
name = "clap_builder"
version = "4.5.14"
version = "4.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85379ba512b21a328adf887e85f7742d12e96eb31f3ef077df4ffc26b506ffed"
checksum = "216aec2b177652e3846684cbfe25c9964d18ec45234f0f5da5157b207ed1aab6"
dependencies = [
"anstream",
"anstyle",
@@ -160,20 +139,6 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3fd119d74b830634cea2a0f58bbd0d54540518a14397557951e79340abc28c0"
[[package]]
name = "compact_str"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6050c3a16ddab2e412160b31f2c871015704239bca62f72f6e5f0be631d3f644"
dependencies = [
"castaway",
"cfg-if",
"itoa",
"rustversion",
"ryu",
"static_assertions",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.13"
@@ -197,7 +162,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6"
dependencies = [
"bitflags 2.6.0",
"crossterm_winapi",
"mio 1.0.1",
"mio 1.0.2",
"parking_lot",
"rustix",
"signal-hook",
@@ -214,12 +179,6 @@ dependencies = [
"winapi",
]
[[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"
@@ -238,20 +197,20 @@ dependencies = [
[[package]]
name = "fastrand"
version = "2.1.0"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fc0510504f03c51ada170672ac806f1f105a88aa97a5281117e1ddc3368e51a"
checksum = "e8c02a5121d4ea3eb16a80748c74f5549a5665e4c21333c6098f283870fbdea6"
[[package]]
name = "filetime"
version = "0.2.23"
version = "0.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ee447700ac8aa0b2f2bd7bc4462ad686ba06baa6727ac149a2d6277f0d240fd"
checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586"
dependencies = [
"cfg-if",
"libc",
"redox_syscall 0.4.1",
"windows-sys 0.52.0",
"libredox",
"windows-sys 0.59.0",
]
[[package]]
@@ -268,10 +227,6 @@ name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
"allocator-api2",
]
[[package]]
name = "heck"
@@ -287,9 +242,9 @@ checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "indexmap"
version = "2.3.0"
version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de3fc2e30ba82dd1b3911c8de1ffc143c74a914a14e99514d7637e3099df5ea0"
checksum = "93ead53efc7ea8ed3cfb0c79fc8023fbb782a5432b52830b6518941cebe6505c"
dependencies = [
"equivalent",
"hashbrown",
@@ -315,31 +270,12 @@ dependencies = [
"libc",
]
[[package]]
name = "instability"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b23a0c8dfe501baac4adf6ebbfa6eddf8f0c07f56b058cc1288017e32397846c"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
name = "itertools"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.11"
@@ -368,9 +304,20 @@ dependencies = [
[[package]]
name = "libc"
version = "0.2.155"
version = "0.2.158"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
checksum = "d8adc4bb1803a324070e64a98ae98f38934d91957a99cfb3a43dcbc01bc56439"
[[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"
@@ -394,15 +341,6 @@ version = "0.4.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24"
[[package]]
name = "lru"
version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37ee39891760e7d94734f6f63fedc29a2e4a152f836120753a72503f09fcf904"
dependencies = [
"hashbrown",
]
[[package]]
name = "memchr"
version = "2.7.4"
@@ -423,9 +361,9 @@ dependencies = [
[[package]]
name = "mio"
version = "1.0.1"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4569e456d394deccd22ce1c1913e6ea0e54519f577285001215d33557431afe4"
checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec"
dependencies = [
"hermit-abi",
"libc",
@@ -497,17 +435,11 @@ 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 = "proc-macro2"
version = "1.0.86"
@@ -519,43 +451,13 @@ dependencies = [
[[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.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ba6a365afbe5615999275bea2446b970b10a41102500e27ce7678d50d978303"
dependencies = [
"bitflags 2.6.0",
"cassowary",
"compact_str",
"crossterm",
"instability",
"itertools",
"lru",
"paste",
"strum",
"strum_macros",
"unicode-segmentation",
"unicode-truncate",
"unicode-width",
]
[[package]]
name = "redox_syscall"
version = "0.4.1"
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"
@@ -567,9 +469,9 @@ dependencies = [
[[package]]
name = "rustix"
version = "0.38.34"
version = "0.38.35"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70dc5ec042f7a43c4a73241207cecc9873a06d45debb38b329f8541d85c2730f"
checksum = "a85d50532239da68e9addb745ba38ff4612a242c1c7ceea689c4bc7c2f43c36f"
dependencies = [
"bitflags 2.6.0",
"errno",
@@ -580,14 +482,14 @@ dependencies = [
[[package]]
name = "rustlings"
version = "6.2.0"
version = "6.3.0"
dependencies = [
"ahash",
"anyhow",
"clap",
"crossterm",
"notify-debouncer-mini",
"os_pipe",
"ratatui",
"rustlings-macros",
"serde",
"serde_json",
@@ -597,19 +499,13 @@ dependencies = [
[[package]]
name = "rustlings-macros"
version = "6.2.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"
@@ -633,18 +529,18 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.205"
version = "1.0.209"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e33aedb1a7135da52b7c21791455563facbbcc43d0f0f66165b42c21b3dfb150"
checksum = "99fce0ffe7310761ca6bf9faf5115afbc19688edd00171d81b1bb1b116c63e09"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.205"
version = "1.0.209"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "692d6f5ac90220161d6774db30c662202721e64aed9058d2c394f451261420c1"
checksum = "a5831b979fd7b5439637af1752d535ff49f4860c0f341d1baeb6faf0f4242170"
dependencies = [
"proc-macro2",
"quote",
@@ -653,9 +549,9 @@ dependencies = [
[[package]]
name = "serde_json"
version = "1.0.122"
version = "1.0.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784b6203951c57ff748476b126ccb5e8e2959a5c19e5c617ab1956be3dbc68da"
checksum = "8043c06d9f82bd7271361ed64f415fe5e12a77fdb52e573e7f06a516dea329ad"
dependencies = [
"itoa",
"memchr",
@@ -689,7 +585,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd"
dependencies = [
"libc",
"mio 1.0.1",
"mio 1.0.2",
"signal-hook",
]
@@ -708,45 +604,17 @@ version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
[[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.76"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc4b9b9bf2add8093d3f2c0204471e951b2285580335de42f9d2534f3ae7a8af"
checksum = "578e081a14e0cefc3279b0472138c513f37b41a08d5a3cca9b6e4e8ceb6cd525"
dependencies = [
"proc-macro2",
"quote",
@@ -794,29 +662,6 @@ version = "1.0.12"
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"
[[package]]
name = "utf8parse"
version = "0.2.2"

View File

@@ -6,7 +6,7 @@ exclude = [
]
[workspace.package]
version = "6.2.0"
version = "6.3.0"
authors = [
"Mo Bitar <mo8it@proton.me>", # https://github.com/mo8it
"Liv <mokou@fastmail.com>", # https://github.com/shadows-withal
@@ -19,7 +19,7 @@ edition = "2021" # On Update: Update the edition of the `rustfmt` command that c
rust-version = "1.80"
[workspace.dependencies]
serde = { version = "1.0.205", features = ["derive"] }
serde = { version = "1.0.209", features = ["derive"] }
toml_edit = { version = "0.22.20", default-features = false, features = ["parse", "serde"] }
[package]
@@ -48,12 +48,12 @@ include = [
[dependencies]
ahash = { version = "0.8.11", default-features = false }
anyhow = "1.0.86"
clap = { version = "4.5.14", features = ["derive"] }
clap = { version = "4.5.16", features = ["derive"] }
crossterm = { version = "0.28.1", default-features = false, features = ["windows", "events"] }
notify-debouncer-mini = { version = "0.4.1", default-features = false }
os_pipe = "1.2.1"
ratatui = { version = "0.28.0", default-features = false, features = ["crossterm"] }
rustlings-macros = { path = "rustlings-macros", version = "=6.2.0" }
serde_json = "1.0.122"
rustlings-macros = { path = "rustlings-macros", version = "=6.3.0" }
serde_json = "1.0.127"
serde.workspace = true
toml_edit.workspace = true
@@ -69,6 +69,20 @@ 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
[lints.clippy]
needless_option_as_deref = "allow"
[lints]
workspace = true

13
clippy.toml Normal file
View File

@@ -0,0 +1,13 @@
disallowed-types = [
# Inefficient. Use `.queue(…)` instead.
"crossterm::style::Stylize",
"crossterm::style::styled_content::StyledContent",
]
disallowed-methods = [
# We use `ahash` instead of the default hasher.
"std::collections::HashSet::new",
"std::collections::HashSet::with_capacity",
# Inefficient. Use `.queue(…)` instead.
"crossterm::style::style",
]

View File

@@ -201,3 +201,19 @@ 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"
[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"

View File

@@ -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#" | '__| | | / __| __| | | '_ \ / _` / __| "#);

View File

@@ -1,5 +1,5 @@
fn main() {
// TODO: Add missing keyword.
// TODO: Add the missing keyword.
x = 5;
println!("x has the value {x}");

View File

@@ -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);
}

View File

@@ -6,7 +6,7 @@
// of `Option<String>`.
fn generate_nametag_text(name: String) -> Option<String> {
if name.is_empty() {
// Empty names aren't allowed.
// Empty names aren't allowed
None
} else {
Some(format!("Hi! My name is {name}"))

View File

@@ -1,5 +1,3 @@
#![allow(clippy::comparison_chain)]
#[derive(PartialEq, Debug)]
enum CreationError {
Negative,

View File

@@ -1,7 +1,6 @@
use std::{sync::mpsc, thread, time::Duration};
struct Queue {
length: u32,
first_half: Vec<u32>,
second_half: Vec<u32>,
}
@@ -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]);
}
}

View File

@@ -16,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

View File

@@ -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);
}

View File

@@ -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"));

View File

@@ -35,7 +35,7 @@ fn build_scores_table(results: &str) -> HashMap<&str, TeamScores> {
team_1.goals_scored += team_1_score;
team_1.goals_conceded += team_2_score;
// Similarely for the second team.
// Similarly for the second team.
let team_2 = scores
.entry(team_2_name)
.or_insert_with(TeamScores::default);

View File

@@ -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<Self, CreationError> {
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)),
}
}
}

View File

@@ -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)
}

View File

@@ -1,7 +1,6 @@
use std::{sync::mpsc, thread, time::Duration};
struct Queue {
length: u32,
first_half: Vec<u32>,
second_half: Vec<u32>,
}
@@ -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]);
}
}

View File

@@ -3,4 +3,4 @@
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 solution are often only _one possibility_ to solve an exercise.
Note that these solutions are often only _one possibility_ to solve an exercise.

View File

@@ -1,7 +1,8 @@
use anyhow::{bail, Context, Error, Result};
use anyhow::{bail, Context, Result};
use std::{
fs::{self, File},
io::{Read, StdoutLock, Write},
env,
fs::{File, OpenOptions},
io::{self, Read, Seek, StdoutLock, Write},
path::Path,
process::{Command, Stdio},
thread,
@@ -17,7 +18,6 @@ use crate::{
};
const STATE_FILE_NAME: &str = ".rustlings-state.txt";
const BAD_INDEX_ERR: &str = "The current exercise index is higher than the number of exercises";
#[must_use]
pub enum ExercisesProgress {
@@ -34,72 +34,44 @@ pub enum StateFileStatus {
NotRead,
}
enum AllExercisesCheck {
Pending(usize),
AllDone,
CheckedUntil(usize),
}
pub struct AppState {
current_exercise_ind: usize,
exercises: Vec<Exercise>,
// 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<u8>,
official_exercises: bool,
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 = hash_set_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<ExerciseInfo>,
final_message: String,
) -> Result<(Self, StateFileStatus)> {
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 mut exercises = exercise_infos
.into_iter()
.map(|exercise_info| {
// Leaking to be able to borrow in the watch mode `Table`.
@@ -117,23 +89,68 @@ impl AppState {
test: exercise_info.test,
strict_clippy: exercise_info.strict_clippy,
hint,
// Updated in `Self::update_from_file`.
// Updated below.
done: false,
}
})
.collect::<Vec<_>>();
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(),
cmd_runner,
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 = hash_set_with_capacity(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 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))
}
@@ -163,6 +180,11 @@ impl AppState {
&self.cmd_runner
}
#[inline]
pub fn vs_code(&self) -> bool {
self.vs_code
}
// Write the state file.
// The file's format is very simple:
// - The first line is a comment.
@@ -172,10 +194,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');
@@ -187,7 +207,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(())
@@ -271,6 +298,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);
@@ -280,30 +308,27 @@ 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<usize> {
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.
@@ -320,24 +345,72 @@ 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)
}
}
// Return the exercise index of the first pending exercise found.
fn check_all_exercises(&self, stdout: &mut StdoutLock) -> Result<Option<usize>> {
stdout.write_all(RERUNNING_ALL_EXERCISES_MSG)?;
let n_exercises = self.exercises.len();
let status = thread::scope(|s| {
let handles = self
.exercises
.iter()
.map(|exercise| s.spawn(|| exercise.run_exercise(None, &self.cmd_runner)))
.collect::<Vec<_>>();
for (exercise_ind, handle) in handles.into_iter().enumerate() {
write!(stdout, "\rProgress: {exercise_ind}/{n_exercises}")?;
stdout.flush()?;
let Ok(success) = handle.join().unwrap() else {
return Ok(AllExercisesCheck::CheckedUntil(exercise_ind));
};
if !success {
return Ok(AllExercisesCheck::Pending(exercise_ind));
}
}
Ok::<_, io::Error>(AllExercisesCheck::AllDone)
})?;
let mut exercise_ind = match status {
AllExercisesCheck::Pending(exercise_ind) => return Ok(Some(exercise_ind)),
AllExercisesCheck::AllDone => return Ok(None),
AllExercisesCheck::CheckedUntil(ind) => ind,
};
// We got an error while checking all exercises in parallel.
// This could be because we exceeded the limit of open file descriptors.
// Therefore, try to continue the check sequentially.
for exercise in &self.exercises[exercise_ind..] {
write!(stdout, "\rProgress: {exercise_ind}/{n_exercises}")?;
stdout.flush()?;
let success = exercise.run_exercise(None, &self.cmd_runner)?;
if !success {
return Ok(Some(exercise_ind));
}
exercise_ind += 1;
}
Ok(None)
}
/// Mark the current exercise as done and move on to the next pending exercise if one exists.
/// If all exercises are marked as done, run all of them to make sure that they are actually
/// done. If an exercise which is marked as done fails, mark it as pending and continue on it.
pub fn done_current_exercise(&mut self, writer: &mut StdoutLock) -> Result<ExercisesProgress> {
pub fn done_current_exercise(&mut self, stdout: &mut StdoutLock) -> Result<ExercisesProgress> {
let exercise = &mut self.exercises[self.current_exercise_ind];
if !exercise.done {
exercise.done = true;
@@ -349,44 +422,13 @@ impl AppState {
return Ok(ExercisesProgress::NewPending);
}
writer.write_all(RERUNNING_ALL_EXERCISES_MSG)?;
if let Some(pending_exercise_ind) = self.check_all_exercises(stdout)? {
stdout.write_all(b"\n\n")?;
let n_exercises = self.exercises.len();
let pending_exercise_ind = thread::scope(|s| {
let handles = self
.exercises
.iter_mut()
.map(|exercise| {
s.spawn(|| {
let success = exercise.run_exercise(None, &self.cmd_runner)?;
exercise.done = success;
Ok::<_, Error>(success)
})
})
.collect::<Vec<_>>();
for (exercise_ind, handle) in handles.into_iter().enumerate() {
write!(writer, "\rProgress: {exercise_ind}/{n_exercises}")?;
writer.flush()?;
let success = handle.join().unwrap()?;
if !success {
writer.write_all(b"\n\n")?;
return Ok(Some(exercise_ind));
}
}
Ok::<_, Error>(None)
})?;
if let Some(pending_exercise_ind) = pending_exercise_ind {
self.current_exercise_ind = pending_exercise_ind;
self.n_done = self
.exercises
.iter()
.filter(|exercise| exercise.done)
.count() as u16;
self.exercises[pending_exercise_ind].done = false;
// All exercises were marked as done.
self.n_done -= 1;
self.write()?;
return Ok(ExercisesProgress::NewPending);
}
@@ -394,24 +436,25 @@ impl AppState {
// Write that the last exercise is done.
self.write()?;
clear_terminal(writer)?;
writer.write_all(FENISH_LINE.as_bytes())?;
clear_terminal(stdout)?;
stdout.write_all(FENISH_LINE.as_bytes())?;
let final_message = self.final_message.trim_ascii();
if !final_message.is_empty() {
writer.write_all(final_message.as_bytes())?;
writer.write_all(b"\n")?;
stdout.write_all(final_message.as_bytes())?;
stdout.write_all(b"\n")?;
}
Ok(ExercisesProgress::AllDone)
}
}
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 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 FENISH_LINE: &str = "+----------------------------------------------------+
| You made it to the Fe-nish line! |
+-------------------------- ------------------------+
@@ -457,9 +500,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,
cmd_runner: CmdRunner::build().unwrap(),
vs_code: false,
};
let mut assert = |done: [bool; 3], expected: [Option<usize>; 3]| {

View File

@@ -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;

View File

@@ -74,12 +74,14 @@ impl CmdRunner {
bail!("The command `cargo metadata …` failed. Are you in the `rustlings/` directory?");
}
let target_dir = serde_json::de::from_slice::<CargoMetadata>(&metadata_output.stdout)
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 …`",
)?.target_directory;
)?;
Ok(Self { target_dir })
Ok(Self {
target_dir: metadata.target_directory,
})
}
pub fn cargo<'out>(

View File

@@ -17,6 +17,8 @@ use crate::{
CURRENT_FORMAT_VERSION,
};
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<char> {
input.chars().find(|c| !c.is_alphanumeric() && *c != '_')
@@ -59,6 +61,9 @@ fn check_info_file_exercises(info_file: &InfoFile) -> Result<HashSet<PathBuf>> {
if name.is_empty() {
bail!("Found an empty exercise name in `info.toml`");
}
if name.len() > 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");
}
@@ -97,7 +102,12 @@ fn check_info_file_exercises(info_file: &InfoFile) -> Result<HashSet<PathBuf>> {
bail!("Didn't find any `// TODO` comment in the file `{path}`.\nYou need to have at least one such comment to guide the user.");
}
if !exercise_info.test && file_buf.contains("#[test]") {
let contains_tests = file_buf.contains("#[test]\n");
if exercise_info.test {
if !contains_tests {
bail!("The file `{path}` doesn't contain any tests. If you don't want to add tests to this exercise, set `test = false` for this exercise in the `info.toml` file");
}
} else if contains_tests {
bail!("The file `{path}` contains tests annotated with `#[test]` but the exercise `{name}` has `test = false` in the `info.toml` file");
}
@@ -160,61 +170,72 @@ fn check_unexpected_files(dir: &str, allowed_rust_files: &HashSet<PathBuf>) -> R
Ok(())
}
fn check_exercises_unsolved(info_file: &InfoFile, cmd_runner: &CmdRunner) -> Result<()> {
println!(
"Running all exercises to check that they aren't already solved. This may take a while…\n",
);
thread::scope(|s| {
let handles = info_file
.exercises
.iter()
.filter_map(|exercise_info| {
if exercise_info.skip_check_unsolved {
return None;
}
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")?;
Some((
exercise_info.name.as_str(),
s.spawn(|| exercise_info.run_exercise(None, cmd_runner)),
))
})
.collect::<Vec<_>>();
for (exercise_name, handle) in handles {
let Ok(result) = handle.join() else {
bail!("Panic while trying to run the exericse {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),
let handles = info_file
.exercises
.iter()
.filter_map(|exercise_info| {
if exercise_info.skip_check_unsolved {
return None;
}
Some((
exercise_info.name.as_str(),
thread::spawn(|| exercise_info.run_exercise(None, cmd_runner)),
))
})
.collect::<Vec<_>>();
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 exericse {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),
}
Ok(())
})
write!(stdout, "\rProgress: {handle_num}/{n_handles}")?;
stdout.flush()?;
handle_num += 1;
}
stdout.write_all(b"\n")?;
Ok(())
}
fn check_exercises(info_file: &InfoFile, cmd_runner: &CmdRunner) -> 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 info_file_paths = check_info_file_exercises(info_file)?;
let handle = thread::spawn(move || check_unexpected_files("exercises", &info_file_paths));
let handle = thread::spawn(move || check_exercises_unsolved(info_file, cmd_runner));
let info_file_paths = check_info_file_exercises(info_file)?;
check_unexpected_files("exercises", &info_file_paths)?;
check_exercises_unsolved(info_file, cmd_runner)?;
handle.join().unwrap()
}
enum SolutionCheck {
Success { sol_path: String },
MissingRequired,
MissingOptional,
RunFailure { output: Vec<u8> },
Err(Error),
@@ -222,88 +243,96 @@ enum SolutionCheck {
fn check_solutions(
require_solutions: bool,
info_file: &InfoFile,
cmd_runner: &CmdRunner,
info_file: &'static InfoFile,
cmd_runner: &'static CmdRunner,
) -> Result<()> {
println!("Running all solutions. This may take a while…\n");
thread::scope(|s| {
let handles = info_file
.exercises
.iter()
.map(|exercise_info| {
s.spawn(|| {
let sol_path = exercise_info.sol_path();
if !Path::new(&sol_path).exists() {
if require_solutions {
return SolutionCheck::MissingRequired;
}
let mut stdout = io::stdout().lock();
stdout.write_all(b"Running all solutions...\n")?;
return SolutionCheck::MissingOptional;
let handles = info_file
.exercises
.iter()
.map(|exercise_info| {
thread::spawn(move || {
let sol_path = exercise_info.sol_path();
if !Path::new(&sol_path).exists() {
if require_solutions {
return SolutionCheck::Err(anyhow!(
"The solution of the exercise {} is missing",
exercise_info.name,
));
}
let mut output = Vec::with_capacity(OUTPUT_CAPACITY);
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),
}
})
return SolutionCheck::MissingOptional;
}
let mut output = Vec::with_capacity(OUTPUT_CAPACITY);
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::<Vec<_>>();
})
.collect::<Vec<_>>();
let mut sol_paths = hash_set_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 mut sol_paths = hash_set_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());
for (exercise_info, handle) in info_file.exercises.iter().zip(handles) {
let Ok(check_result) = handle.join() else {
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 exericse {}",
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!(
"Panic while trying to run the solution of the exericse {}",
"Running the solution of the exercise {} failed with the error above",
exercise_info.name,
);
};
match check_result {
SolutionCheck::Success { sol_path } => {
fmt_cmd.arg(&sol_path);
sol_paths.insert(PathBuf::from(sol_path));
}
SolutionCheck::MissingRequired => {
bail!(
"The solution of the exercise {} is missing",
exercise_info.name,
);
}
SolutionCheck::MissingOptional => (),
SolutionCheck::RunFailure { output } => {
io::stderr().lock().write_all(&output)?;
bail!(
"Running the solution of the exercise {} failed with the error above",
exercise_info.name,
);
}
SolutionCheck::Err(e) => return Err(e),
}
SolutionCheck::Err(e) => return Err(e),
}
let handle = s.spawn(move || check_unexpected_files("solutions", &sol_paths));
write!(stdout, "\rProgress: {handle_num}/{n_handles}")?;
stdout.flush()?;
handle_num += 1;
}
stdout.write_all(b"\n")?;
if !fmt_cmd
.status()
.context("Failed to run `rustfmt` on all solution files")?
.success()
{
bail!("Some solutions aren't formatted. Run `rustfmt` on them");
}
let handle = thread::spawn(move || check_unexpected_files("solutions", &sol_paths));
handle.join().unwrap()
})
if !fmt_cmd
.status()
.context("Failed to run `rustfmt` on all solution files")?
.success()
{
bail!("Some solutions aren't formatted. Run `rustfmt` on them");
}
handle.join().unwrap()
}
pub fn check(require_solutions: bool) -> Result<()> {
@@ -316,9 +345,12 @@ pub fn check(require_solutions: bool) -> Result<()> {
check_cargo_toml(&info_file.exercises, "Cargo.toml", b"")?;
}
let cmd_runner = CmdRunner::build()?;
check_exercises(&info_file, &cmd_runner)?;
check_solutions(require_solutions, &info_file, &cmd_runner)?;
// 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!");

View File

@@ -1,15 +1,27 @@
use anyhow::Result;
use ratatui::crossterm::style::{style, StyledContent, Stylize};
use std::{
fmt::{self, Display, Formatter},
io::Write,
use crossterm::{
style::{Attribute, Color, ResetColor, SetAttribute, SetForegroundColor},
QueueableCommand,
};
use std::io::{self, StdoutLock, Write};
use crate::{cmd::CmdRunner, terminal_link::TerminalFileLink};
use crate::{
cmd::CmdRunner,
term::{terminal_file_link, write_ansi},
};
/// 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: ")?;
terminal_file_link(stdout, solution_path, Color::Cyan)?;
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(
@@ -18,7 +30,10 @@ fn run_bin(
cmd_runner: &CmdRunner,
) -> Result<bool> {
if let Some(output) = output.as_deref_mut() {
writeln!(output, "{}", "Output".underlined())?;
write_ansi(output, SetAttribute(Attribute::Underlined));
output.extend_from_slice(b"Output");
write_ansi(output, ResetColor);
output.push(b'\n');
}
let success = cmd_runner.run_debug_bin(bin_name, output.as_deref_mut())?;
@@ -28,13 +43,11 @@ fn run_bin(
// 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(),
)?;
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');
}
}
@@ -53,26 +66,15 @@ pub struct Exercise {
pub done: bool,
}
impl Exercise {
pub fn terminal_link(&self) -> StyledContent<TerminalFileLink<'_>> {
style(TerminalFileLink(self.path)).underlined().blue()
}
}
impl Display for Exercise {
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
self.path.fmt(f)
}
}
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(
fn run<const FORCE_STRICT_CLIPPY: bool>(
&self,
bin_name: &str,
mut output: Option<&mut Vec<u8>>,
@@ -98,7 +100,7 @@ pub trait RunnableExercise {
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", "--show-output"]);
test_cmd.args(["--", "--color", "always", "--format", "pretty"]);
}
let test_success = test_cmd.run("cargo test …")?;
if !test_success {
@@ -115,7 +117,7 @@ pub trait RunnableExercise {
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 self.strict_clippy() {
if FORCE_STRICT_CLIPPY || self.strict_clippy() {
clippy_cmd.args(["--profile", "test", "--", "-D", "warnings"]);
} else {
clippy_cmd.args(["--profile", "test"]);
@@ -131,7 +133,7 @@ pub trait RunnableExercise {
/// The output is written to the `output` buffer after clearing it.
#[inline]
fn run_exercise(&self, output: Option<&mut Vec<u8>>, cmd_runner: &CmdRunner) -> Result<bool> {
self.run(self.name(), output, cmd_runner)
self.run::<false>(self.name(), output, cmd_runner)
}
/// Compile, check and run the exercise's solution.
@@ -142,7 +144,32 @@ pub trait RunnableExercise {
bin_name.push_str(name);
bin_name.push_str("_sol");
self.run(&bin_name, output, cmd_runner)
self.run::<true>(&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
}
}
@@ -152,6 +179,11 @@ impl RunnableExercise for Exercise {
self.name
}
#[inline]
fn dir(&self) -> Option<&str> {
self.dir
}
#[inline]
fn strict_clippy(&self) -> bool {
self.strict_clippy

View File

@@ -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.";

View File

@@ -1,5 +1,8 @@
use anyhow::{bail, Context, Result};
use ratatui::crossterm::style::Stylize;
use crossterm::{
style::{Attribute, Color, ResetColor, SetAttribute, SetForegroundColor},
QueueableCommand,
};
use serde::Deserialize;
use std::{
env::set_current_dir,
@@ -10,8 +13,8 @@ use std::{
};
use crate::{
cargo_toml::updated_cargo_toml, embedded::EMBEDDED_FILES, info_file::InfoFile,
term::press_enter_prompt,
cargo_toml::updated_cargo_toml, embedded::EMBEDDED_FILES, exercise::RunnableExercise,
info_file::InfoFile, term::press_enter_prompt,
};
#[derive(Deserialize)]
@@ -139,16 +142,19 @@ pub fn init() -> Result<()> {
let _ = Command::new("git")
.arg("init")
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
writeln!(
stdout,
"\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(())
}
@@ -181,5 +187,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.
";

View File

@@ -1,93 +1,109 @@
use anyhow::Result;
use ratatui::{
backend::CrosstermBackend,
crossterm::{
event::{self, Event, KeyCode, KeyEventKind},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
use anyhow::{Context, Result};
use crossterm::{
cursor,
event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEventKind, MouseEventKind,
},
Terminal,
terminal::{
disable_raw_mode, enable_raw_mode, DisableLineWrap, EnableLineWrap, EnterAlternateScreen,
LeaveAlternateScreen,
},
QueueableCommand,
};
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::new(app_state, stdout)?;
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();
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') => {
let message = if list_state.filter() == Filter::Pending {
list_state.set_filter(Filter::None);
"Disabled filter PENDING"
} else {
list_state.set_filter(Filter::Pending);
"Enabled filter PENDING │ Press p again to disable the filter"
};
list_state.message.push_str(message);
}
KeyCode::Char('r') => list_state.reset_selected()?,
KeyCode::Char('c') => {
if list_state.selected_to_current_exercise()? {
return Ok(());
}
}
// 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.try_draw(|frame| ui_state.draw(frame).map_err(io::Error::other))?;
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
}

104
src/list/scroll_state.rs Normal file
View File

@@ -0,0 +1,104 @@
pub struct ScrollState {
n_rows: usize,
max_n_rows_to_display: usize,
selected: Option<usize>,
offset: usize,
scroll_padding: usize,
max_scroll_padding: usize,
}
impl ScrollState {
pub fn new(n_rows: usize, selected: Option<usize>, 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<usize> {
self.selected
}
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();
}
}

View File

@@ -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, terminal_file_link, 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,352 @@ 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,
app_state: &'a mut AppState,
table_state: TableState,
n_rows: usize,
scroll_state: ScrollState,
name_col_padding: Vec<u8>,
filter: Filter,
term_width: u16,
term_height: u16,
separator_line: Vec<u8>,
narrow_term: bool,
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 new(app_state: &'a mut AppState, stdout: &mut StdoutLock) -> io::Result<Self> {
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()?;
let scroll_state = ScrollState::new(n_rows_with_filter, Some(selected), 5);
let mut slf = Self {
message: String::with_capacity(128),
filter,
app_state,
table_state,
n_rows,
scroll_state,
name_col_padding,
filter,
// Set by `set_term_size`
term_width: 0,
term_height: 0,
separator_line: Vec::new(),
narrow_term: false,
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 wide_help_footer_width = 95;
// The help footer is shorter when nothing is selected.
self.narrow_term = width < wide_help_footer_width && self.scroll_state.selected().is_some();
let header_height = 1;
// 2 separator, 1 progress bar, 1-2 footer message.
let footer_height = 4 + u16::from(self.narrow_term);
self.show_footer = height > header_height + footer_height;
if self.show_footer {
self.separator_line = "".as_bytes().repeat(width as usize);
}
}
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.area();
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_rows(
&self,
stdout: &mut StdoutLock,
filtered_exercises: impl Iterator<Item = (usize, &'a Exercise)>,
) -> io::Result<usize> {
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))?;
writer.write_str(exercise.name)?;
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 {
terminal_file_link(&mut writer, exercise.path, Color::Blue)?;
}
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 {
stdout.write_all(&self.separator_line)?;
next_ln(stdout)?;
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 │ <c>ontinue at │ <r>eset │ filter ",
));
match self.filter {
Filter::Done => {
spans.push("<d>one".underlined().magenta());
spans.push(Span::raw("/<p>ending"));
stdout.write_all(&self.separator_line)?;
next_ln(stdout)?;
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 | <c>ontinue at | <r>eset exercise")?;
if self.narrow_term {
next_ln(stdout)?;
writer = MaxLenWriter::new(stdout, self.term_width as usize);
writer.write_ascii(b"filter ")?;
} else {
writer.write_ascii(b" | filter ")?;
}
} else {
// Nothing selected (and nothing shown), so only display filter and quit.
writer.write_ascii(b"filter ")?;
}
Filter::Pending => {
spans.push(Span::raw("<d>one/"));
spans.push("<p>ending".underlined().magenta());
match self.filter {
Filter::Done => {
writer
.stdout
.queue(SetForegroundColor(Color::Magenta))?
.queue(SetAttribute(Attribute::Underlined))?;
writer.write_ascii(b"<d>one")?;
writer.stdout.queue(ResetColor)?;
writer.write_ascii(b"/<p>ending")?;
}
Filter::Pending => {
writer.write_ascii(b"<d>one/")?;
writer
.stdout
.queue(SetForegroundColor(Color::Magenta))?
.queue(SetAttribute(Attribute::Underlined))?;
writer.write_ascii(b"<p>ending")?;
writer.stdout.queue(ResetColor)?;
}
Filter::None => writer.write_ascii(b"<d>one/<p>ending")?,
}
writer.write_ascii(b" | <q>uit list")?;
next_ln(stdout)?;
} else {
writer.stdout.queue(SetForegroundColor(Color::Magenta))?;
writer.write_str(&self.message)?;
stdout.queue(ResetColor)?;
next_ln(stdout)?;
if self.narrow_term {
next_ln(stdout)?;
}
Filter::None => spans.push(Span::raw("<d>one/<p>ending")),
}
spans.push(Span::raw(" │ <q>uit"));
Line::from(spans)
} else {
Line::from(self.message.as_str().light_blue())
}
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<usize> {
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<Self> {
let Some(selected) = self.table_state.selected() else {
return Ok(self);
// Return `true` if there was something to select.
pub fn selected_to_current_exercise(&mut self) -> Result<bool> {
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)?;
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())
}
pub fn selected_to_current_exercise(&mut self) -> Result<()> {
let Some(selected) = self.table_state.selected() else {
return Ok(());
};
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")?;
self.app_state.set_current_exercise_ind(ind)
Ok(true)
}
}

View File

@@ -20,10 +20,8 @@ mod exercise;
mod info_file;
mod init;
mod list;
mod progress_bar;
mod run;
mod term;
mod terminal_link;
mod watch;
const CURRENT_FORMAT_VERSION: u8 = 1;
@@ -72,9 +70,7 @@ fn main() -> Result<()> {
}
match args.command {
Some(Subcommands::Init) => {
return init::init().context("Initialization failed");
}
Some(Subcommands::Init) => return init::init().context("Initialization failed"),
Some(Subcommands::Dev(dev_command)) => return dev_command.run(),
_ => (),
}

View File

@@ -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<String> {
use ratatui::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<Line<'static>> {
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))
}

View File

@@ -1,11 +1,17 @@
use anyhow::{bail, Result};
use ratatui::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::exit,
};
use crate::{
app_state::{AppState, ExercisesProgress},
exercise::{RunnableExercise, OUTPUT_CAPACITY},
terminal_link::TerminalFileLink,
exercise::{solution_link_line, RunnableExercise, OUTPUT_CAPACITY},
term::terminal_file_link,
};
pub fn run(app_state: &mut AppState) -> Result<()> {
@@ -19,32 +25,31 @@ 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 ")?;
terminal_file_link(&mut stdout, app_state.current_exercise().path, Color::Blue)?;
stdout.write_all(b" with errors\n")?;
exit(1);
}
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)? {
ExercisesProgress::CurrentPending | ExercisesProgress::NewPending => {
stdout.write_all(b"Next exercise: ")?;
terminal_file_link(&mut stdout, app_state.current_exercise().path, Color::Blue)?;
stdout.write_all(b"\n")?;
}
ExercisesProgress::AllDone => (),
ExercisesProgress::CurrentPending | ExercisesProgress::NewPending => println!(
"Next exercise: {}",
app_state.current_exercise().terminal_link(),
),
}
Ok(())

View File

@@ -1,12 +1,202 @@
use std::io::{self, BufRead, StdoutLock, Write};
use std::{
fmt, fs,
io::{self, BufRead, StdoutLock, Write},
};
use crossterm::{
cursor::MoveTo,
style::{Attribute, Color, ResetColor, SetAttribute, SetForegroundColor},
terminal::{Clear, ClearType},
Command, QueueableCommand,
};
pub struct MaxLenWriter<'a, 'b> {
pub stdout: &'a mut StdoutLock<'b>,
len: usize,
max_len: usize,
}
impl<'a, 'b> MaxLenWriter<'a, 'b> {
#[inline]
pub fn new(stdout: &'a mut StdoutLock<'b>, 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<'a> {
fn write_ascii(&mut self, ascii: &[u8]) -> io::Result<()>;
fn write_str(&mut self, unicode: &str) -> io::Result<()>;
fn stdout(&mut self) -> &mut StdoutLock<'a>;
}
impl<'a, 'b> CountedWrite<'b> for MaxLenWriter<'a, 'b> {
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<'b> {
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
}
}
/// Terminal progress bar to be used when not using Ratataui.
pub fn progress_bar<'a>(
writer: &mut impl CountedWrite<'a>,
progress: u16,
total: u16,
line_width: u16,
) -> io::Result<()> {
debug_assert!(progress <= total);
const PREFIX: &[u8] = b"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;
if line_width < MIN_LINE_WIDTH {
writer.write_ascii(b"Progress: ")?;
// Integers are in ASCII.
writer.write_ascii(format!("{progress}/{total}").as_bytes())?;
return writer.write_ascii(b" exercises");
}
let stdout = writer.stdout();
stdout.write_all(PREFIX)?;
let width = line_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(ResetColor)?;
write!(stdout, "] {progress:>3}/{total} exercises")
}
pub fn clear_terminal(stdout: &mut StdoutLock) -> io::Result<()> {
stdout.write_all(b"\x1b[H\x1b[2J\x1b[3J")
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")?;
stdout.write_all(b"\n")
}
pub fn terminal_file_link<'a>(
writer: &mut impl CountedWrite<'a>,
path: &str,
color: Color,
) -> io::Result<()> {
let canonical_path = fs::canonicalize(path).ok();
let Some(canonical_path) = canonical_path.as_deref().and_then(|p| p.to_str()) else {
return writer.write_str(path);
};
// Windows itself can't handle its verbatim paths.
#[cfg(windows)]
let canonical_path = if canonical_path.len() > 5 && &canonical_path[0..4] == r"\\?\" {
&canonical_path[4..]
} else {
canonical_path
};
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<u8>, command: impl Command) {
struct FmtWriter<'a>(&'a mut Vec<u8>);
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));
}

View File

@@ -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)
}
}
}

View File

@@ -72,35 +72,32 @@ pub fn watch(
let mut watch_state = WatchState::new(app_state, manual_run);
watch_state.run_current_exercise()?;
let mut stdout = io::stdout().lock();
watch_state.run_current_exercise(&mut stdout)?;
thread::spawn(move || terminal_event_handler(tx, manual_run));
while let Ok(event) = rx.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::CurrentPending => watch_state.render(&mut stdout)?,
ExercisesProgress::NewPending => watch_state.run_current_exercise(&mut stdout)?,
},
WatchEvent::Input(InputEvent::Hint) => {
watch_state.show_hint()?;
}
WatchEvent::Input(InputEvent::Hint) => watch_state.show_hint(&mut stdout)?,
WatchEvent::Input(InputEvent::List) => {
return Ok(WatchExit::List);
}
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::Input(InputEvent::Run) => watch_state.run_current_exercise(&mut stdout)?,
WatchEvent::Input(InputEvent::Unrecognized) => watch_state.render(&mut stdout)?,
WatchEvent::FileChange { exercise_ind } => {
watch_state.handle_file_change(exercise_ind)?;
}
WatchEvent::TerminalResize => {
watch_state.render()?;
watch_state.handle_file_change(exercise_ind, &mut stdout)?;
}
WatchEvent::TerminalResize => watch_state.render(&mut stdout)?,
WatchEvent::NotifyErr(e) => {
return Err(Error::from(e).context(NOTIFY_ERR));
}

View File

@@ -1,16 +1,17 @@
use anyhow::Result;
use ratatui::crossterm::{
style::{style, Stylize},
terminal,
use crossterm::{
style::{
Attribute, Attributes, Color, ResetColor, SetAttribute, SetAttributes, SetForegroundColor,
},
terminal, QueueableCommand,
};
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, terminal_file_link},
};
#[derive(PartialEq, Eq)]
@@ -21,7 +22,6 @@ enum DoneStatus {
}
pub struct WatchState<'a> {
writer: StdoutLock<'a>,
app_state: &'a mut AppState,
output: Vec<u8>,
show_hint: bool,
@@ -31,10 +31,7 @@ pub struct WatchState<'a> {
impl<'a> WatchState<'a> {
pub fn new(app_state: &'a mut AppState, manual_run: bool) -> Self {
let writer = io::stdout().lock();
Self {
writer,
app_state,
output: Vec::with_capacity(OUTPUT_CAPACITY),
show_hint: false,
@@ -43,23 +40,20 @@ impl<'a> WatchState<'a> {
}
}
#[inline]
pub fn into_writer(self) -> StdoutLock<'a> {
self.writer
}
pub fn run_current_exercise(&mut self) -> Result<()> {
pub fn run_current_exercise(&mut self, stdout: &mut StdoutLock) -> Result<()> {
self.show_hint = false;
writeln!(
self.writer,
stdout,
"\nChecking the exercise `{}`. Please wait…",
self.app_state.current_exercise().name,
)?;
let success = self
.app_state
.current_exercise()
.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()? {
@@ -74,10 +68,15 @@ 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<()> {
pub fn handle_file_change(
&mut self,
exercise_ind: usize,
stdout: &mut StdoutLock,
) -> 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.
@@ -86,94 +85,115 @@ impl<'a> WatchState<'a> {
}
self.app_state.set_current_exercise_ind(exercise_ind)?;
self.run_current_exercise()
self.run_current_exercise(stdout)
}
/// Move on to the next exercise if the current one is done.
pub fn next_exercise(&mut self) -> Result<ExercisesProgress> {
pub fn next_exercise(&mut self, stdout: &mut StdoutLock) -> Result<ExercisesProgress> {
if self.done_status == DoneStatus::Pending {
return Ok(ExercisesProgress::CurrentPending);
}
self.app_state.done_current_exercise(&mut self.writer)
self.app_state.done_current_exercise(stdout)
}
fn show_prompt(&mut self) -> io::Result<()> {
self.writer.write_all(b"\n")?;
fn show_prompt(&self, stdout: &mut StdoutLock) -> io::Result<()> {
if self.manual_run {
write!(self.writer, "{}:run / ", 'r'.bold())?;
stdout.queue(SetAttribute(Attribute::Bold))?;
stdout.write_all(b"r")?;
stdout.queue(ResetColor)?;
stdout.write_all(b":run / ")?;
}
if self.done_status != DoneStatus::Pending {
write!(self.writer, "{}:{} / ", 'n'.bold(), "next".underlined())?;
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.show_hint {
write!(self.writer, "{}:hint / ", 'h'.bold())?;
stdout.queue(SetAttribute(Attribute::Bold))?;
stdout.write_all(b"h")?;
stdout.queue(ResetColor)?;
stdout.write_all(b":hint / ")?;
}
write!(self.writer, "{}:list / {}:quit ? ", 'l'.bold(), 'q'.bold())?;
stdout.queue(SetAttribute(Attribute::Bold))?;
stdout.write_all(b"l")?;
stdout.queue(ResetColor)?;
stdout.write_all(b":list / ")?;
self.writer.flush()
stdout.queue(SetAttribute(Attribute::Bold))?;
stdout.write_all(b"q")?;
stdout.queue(ResetColor)?;
stdout.write_all(b":quit ? ")?;
stdout.flush()
}
pub fn render(&mut self) -> Result<()> {
pub fn render(&self, stdout: &mut StdoutLock) -> io::Result<()> {
// Prevent having the first line shifted if clearing wasn't successful.
self.writer.write_all(b"\n")?;
stdout.write_all(b"\n")?;
clear_terminal(stdout)?;
clear_terminal(&mut self.writer)?;
self.writer.write_all(&self.output)?;
self.writer.write_all(b"\n")?;
stdout.write_all(&self.output)?;
if self.show_hint {
writeln!(
self.writer,
"{}\n{}\n",
"Hint".bold().cyan().underlined(),
self.app_state.current_exercise().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 {
writeln!(
self.writer,
"{}\n",
"Exercise done ✓
When you are done experimenting, enter `n` to move on to the next exercise 🦀"
.bold()
.green(),
)?;
}
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 {
writeln!(
self.writer,
"A solution file can be found at {}\n",
style(TerminalFileLink(solution_path)).underlined().green(),
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(),
)?;
}
let line_width = terminal::size()?.0;
let progress_bar = progress_bar(
progress_bar(
stdout,
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()?;
stdout.write_all(b"\nCurrent exercise: ")?;
terminal_file_link(stdout, self.app_state.current_exercise().path, Color::Blue)?;
stdout.write_all(b"\n\n")?;
self.show_prompt(stdout)?;
Ok(())
}
pub fn show_hint(&mut self) -> Result<()> {
pub fn show_hint(&mut self, stdout: &mut StdoutLock) -> io::Result<()> {
self.show_hint = true;
self.render()
self.render(stdout)
}
}

View File

@@ -1,4 +1,4 @@
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use std::sync::mpsc::Sender;
use super::WatchEvent;
@@ -78,7 +78,7 @@ pub fn terminal_event_handler(tx: Sender<WatchEvent>, manual_run: bool) {
return;
}
}
Event::FocusGained | Event::FocusLost | Event::Mouse(_) | Event::Paste(_) => continue,
Event::FocusGained | Event::FocusLost | Event::Mouse(_) => continue,
}
};