├── .gitignore ├── example.gif ├── Cargo.toml ├── README.md ├── src └── main.rs └── Cargo.lock /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexwlchan/inquire_tag_autocomplete/main/example.gif -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "inquire_tag_autocomplete" 3 | version = "1.0.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | inquire = "0.3.0-alpha.2" 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # inquire_tag_autocomplete 2 | 3 | This is an experiment with the autocomplete functionality in the Rust crate [inquire](https://crates.io/crates/inquire). 4 | 5 | In particular, suppose I want the user to pick some tags. 6 | I already have some tags they might want to use, or they can add new ones. 7 | 8 | I want a `Text` input where I can: 9 | 10 | * enter multiple tags, separated by spaces 11 | * get suggestions and autocomplete for already-known tags 12 | * create brand new tags 13 | 14 | This is what it looks like: 15 | 16 | 17 | 18 | Here's what I'm doing: 19 | 20 | 1. Initially I'm shown all five known tags (`adventure`, `action`, `mystery`, `romance` and `scifi`). 21 | 22 | 2. I type `adv` and then press `tab`. 23 | This autocompletes to the known tag `adventure`. 24 | 25 | 3. I type `s`, which shows me the two tags that contain `s` (`mystery` and `scifi`). 26 | I use the arrow keys to select `scifi`. 27 | 28 | 4. I type `a` and then press `tab`. 29 | This autocompletes to the known tag `action`. 30 | 31 | Notice that `adventure` isn't offered as a suggestion: it knows I already have this tag. 32 | 33 | 5. I start typing `ro`. 34 | It suggests the known tag `romance`, but I keep typing the new tag `robots`. 35 | 36 | 6. I press `enter` to complete my typing, and I see the list of tags I've given (`adventure`, `scifi`, `action` and `robots`). 37 | 38 | I already know one project where I want to use it, but I can imagine this is the sort of component I might want to use in a bunch of places. 39 | I've pulled it into a standalone repo so it's easier to find and maintain as a reusable snippet. 40 | 41 | ## Usage 42 | 43 | Copy the code from `main.rs` into your project. 44 | Play around with it. 45 | Tweak it. 46 | Fiddle with the details. 47 | 48 | The behaviour probably isn't quite right for what you need, but hopefully it's a starting point. 49 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use inquire::{error::CustomUserError, Text}; 4 | 5 | // The list of tags is fetched as a function so it can be created 6 | // at runtime, e.g. by querying a database -- it doesn't have to be 7 | // compiled into the binary. 8 | fn get_tags<'a>() -> Vec<&'a str> { 9 | vec!["adventure", "action", "mystery", "romance", "scifi"] 10 | } 11 | 12 | fn suggester(val: &str) -> Result, CustomUserError> { 13 | let tags = HashSet::from_iter(get_tags()); 14 | 15 | // What tags have already been used? Tags can only be selected 16 | // once, so we don't want to suggest a tag already in the input. 17 | let used_tags: HashSet<&str> = HashSet::from_iter(val.split_whitespace()); 18 | let mut available_tags = tags.difference(&used_tags).cloned().collect::>(); 19 | available_tags.sort(); 20 | 21 | // What's the latest tag the user is typing? i.e. what are we trying 22 | // to autocomplete on this tag. 23 | let this_tag = val.split_whitespace().last(); 24 | 25 | let prefix = match this_tag { 26 | None => val, 27 | Some(t) => &val[..(val.len() - t.len())], 28 | }; 29 | 30 | Ok(available_tags 31 | .iter() 32 | // Note: this will filter to all the matching tags if the user 33 | // is midway through matching a tag (e.g. "adventure ac" -> "action"), 34 | // but will also display *all* the tags on the initial prompt. 35 | // 36 | // If there are lots of tags, that might be unwieldy. 37 | .filter(|s| match this_tag { 38 | None => true, 39 | Some(t) => s.contains(&t), 40 | }) 41 | // Note: the prefix may be empty if the user hasn't typed 42 | // anything yet. 43 | .map(|s| { 44 | if prefix.is_empty() { 45 | format!("{} ", s) 46 | } else { 47 | format!("{} {} ", prefix.trim_end(), s) 48 | } 49 | }) 50 | .collect()) 51 | } 52 | 53 | fn completer(val: &str) -> Result, CustomUserError> { 54 | let suggestions = suggester(val)?; 55 | 56 | if suggestions.len() == 1 { 57 | Ok(Some(suggestions[0].clone())) 58 | } else { 59 | Ok(None) 60 | } 61 | } 62 | 63 | #[cfg(test)] 64 | mod tests { 65 | use crate::suggester; 66 | 67 | #[test] 68 | fn it_offers_all_options_initially() { 69 | let result = suggester(""); 70 | assert_eq!( 71 | result.unwrap(), 72 | vec!["adventure ", "fiction ", "mystery ", "romance ", "scifi "] 73 | ); 74 | } 75 | 76 | #[test] 77 | fn it_offers_all_options_with_a_matching_substring() { 78 | let result = suggester("s"); 79 | assert_eq!(result.unwrap(), vec!["mystery ", "scifi "]); 80 | } 81 | 82 | #[test] 83 | fn it_only_offers_unused_options() { 84 | let result = suggester("scifi s"); 85 | assert_eq!(result.unwrap(), vec!["scifi mystery "]); 86 | } 87 | 88 | #[test] 89 | fn it_offers_no_options_if_no_matches() { 90 | let result = suggester("scifi z"); 91 | assert_eq!(result.unwrap().len(), 0); 92 | 93 | let result = suggester("z"); 94 | assert_eq!(result.unwrap().len(), 0); 95 | } 96 | } 97 | 98 | fn main() { 99 | let answer = Text::new("What are the tags?") 100 | .with_suggester(&suggester) 101 | .with_completer(&completer) 102 | .prompt() 103 | .unwrap(); 104 | 105 | let tags: Vec<&str> = answer.split_whitespace().collect(); 106 | 107 | println!("The tags are {:?}", tags); 108 | } 109 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "autocfg" 7 | version = "1.1.0" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" 10 | 11 | [[package]] 12 | name = "bitflags" 13 | version = "1.3.2" 14 | source = "registry+https://github.com/rust-lang/crates.io-index" 15 | checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" 16 | 17 | [[package]] 18 | name = "cfg-if" 19 | version = "1.0.0" 20 | source = "registry+https://github.com/rust-lang/crates.io-index" 21 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 22 | 23 | [[package]] 24 | name = "crossterm" 25 | version = "0.21.0" 26 | source = "registry+https://github.com/rust-lang/crates.io-index" 27 | checksum = "486d44227f71a1ef39554c0dc47e44b9f4139927c75043312690c3f476d1d788" 28 | dependencies = [ 29 | "bitflags", 30 | "crossterm_winapi", 31 | "libc", 32 | "mio", 33 | "parking_lot", 34 | "signal-hook", 35 | "signal-hook-mio", 36 | "winapi", 37 | ] 38 | 39 | [[package]] 40 | name = "crossterm_winapi" 41 | version = "0.8.0" 42 | source = "registry+https://github.com/rust-lang/crates.io-index" 43 | checksum = "3a6966607622438301997d3dac0d2f6e9a90c68bb6bc1785ea98456ab93c0507" 44 | dependencies = [ 45 | "winapi", 46 | ] 47 | 48 | [[package]] 49 | name = "inquire" 50 | version = "0.3.0-alpha.2" 51 | source = "registry+https://github.com/rust-lang/crates.io-index" 52 | checksum = "ccd2330d94af4679fb8f6351e40d0814babed901ffb24bdd1b2b03dcba424b8d" 53 | dependencies = [ 54 | "bitflags", 55 | "crossterm", 56 | "lazy_static", 57 | "newline-converter", 58 | "thiserror", 59 | "unicode-segmentation", 60 | "unicode-width", 61 | ] 62 | 63 | [[package]] 64 | name = "inquire_tag_autocomplete" 65 | version = "1.0.0" 66 | dependencies = [ 67 | "inquire", 68 | ] 69 | 70 | [[package]] 71 | name = "instant" 72 | version = "0.1.12" 73 | source = "registry+https://github.com/rust-lang/crates.io-index" 74 | checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" 75 | dependencies = [ 76 | "cfg-if", 77 | ] 78 | 79 | [[package]] 80 | name = "lazy_static" 81 | version = "1.4.0" 82 | source = "registry+https://github.com/rust-lang/crates.io-index" 83 | checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" 84 | 85 | [[package]] 86 | name = "libc" 87 | version = "0.2.132" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "8371e4e5341c3a96db127eb2465ac681ced4c433e01dd0e938adbef26ba93ba5" 90 | 91 | [[package]] 92 | name = "lock_api" 93 | version = "0.4.7" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "327fa5b6a6940e4699ec49a9beae1ea4845c6bab9314e4f84ac68742139d8c53" 96 | dependencies = [ 97 | "autocfg", 98 | "scopeguard", 99 | ] 100 | 101 | [[package]] 102 | name = "log" 103 | version = "0.4.17" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | checksum = "abb12e687cfb44aa40f41fc3978ef76448f9b6038cad6aef4259d3c095a2382e" 106 | dependencies = [ 107 | "cfg-if", 108 | ] 109 | 110 | [[package]] 111 | name = "mio" 112 | version = "0.7.14" 113 | source = "registry+https://github.com/rust-lang/crates.io-index" 114 | checksum = "8067b404fe97c70829f082dec8bcf4f71225d7eaea1d8645349cb76fa06205cc" 115 | dependencies = [ 116 | "libc", 117 | "log", 118 | "miow", 119 | "ntapi", 120 | "winapi", 121 | ] 122 | 123 | [[package]] 124 | name = "miow" 125 | version = "0.3.7" 126 | source = "registry+https://github.com/rust-lang/crates.io-index" 127 | checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21" 128 | dependencies = [ 129 | "winapi", 130 | ] 131 | 132 | [[package]] 133 | name = "newline-converter" 134 | version = "0.2.0" 135 | source = "registry+https://github.com/rust-lang/crates.io-index" 136 | checksum = "d6f81c2b19eebbc4249b3ca6aff70ae05bf18d6a99b7cc63cf0248774e640565" 137 | 138 | [[package]] 139 | name = "ntapi" 140 | version = "0.3.7" 141 | source = "registry+https://github.com/rust-lang/crates.io-index" 142 | checksum = "c28774a7fd2fbb4f0babd8237ce554b73af68021b5f695a3cebd6c59bac0980f" 143 | dependencies = [ 144 | "winapi", 145 | ] 146 | 147 | [[package]] 148 | name = "parking_lot" 149 | version = "0.11.2" 150 | source = "registry+https://github.com/rust-lang/crates.io-index" 151 | checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" 152 | dependencies = [ 153 | "instant", 154 | "lock_api", 155 | "parking_lot_core", 156 | ] 157 | 158 | [[package]] 159 | name = "parking_lot_core" 160 | version = "0.8.5" 161 | source = "registry+https://github.com/rust-lang/crates.io-index" 162 | checksum = "d76e8e1493bcac0d2766c42737f34458f1c8c50c0d23bcb24ea953affb273216" 163 | dependencies = [ 164 | "cfg-if", 165 | "instant", 166 | "libc", 167 | "redox_syscall", 168 | "smallvec", 169 | "winapi", 170 | ] 171 | 172 | [[package]] 173 | name = "proc-macro2" 174 | version = "1.0.43" 175 | source = "registry+https://github.com/rust-lang/crates.io-index" 176 | checksum = "0a2ca2c61bc9f3d74d2886294ab7b9853abd9c1ad903a3ac7815c58989bb7bab" 177 | dependencies = [ 178 | "unicode-ident", 179 | ] 180 | 181 | [[package]] 182 | name = "quote" 183 | version = "1.0.21" 184 | source = "registry+https://github.com/rust-lang/crates.io-index" 185 | checksum = "bbe448f377a7d6961e30f5955f9b8d106c3f5e449d493ee1b125c1d43c2b5179" 186 | dependencies = [ 187 | "proc-macro2", 188 | ] 189 | 190 | [[package]] 191 | name = "redox_syscall" 192 | version = "0.2.16" 193 | source = "registry+https://github.com/rust-lang/crates.io-index" 194 | checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" 195 | dependencies = [ 196 | "bitflags", 197 | ] 198 | 199 | [[package]] 200 | name = "scopeguard" 201 | version = "1.1.0" 202 | source = "registry+https://github.com/rust-lang/crates.io-index" 203 | checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" 204 | 205 | [[package]] 206 | name = "signal-hook" 207 | version = "0.3.14" 208 | source = "registry+https://github.com/rust-lang/crates.io-index" 209 | checksum = "a253b5e89e2698464fc26b545c9edceb338e18a89effeeecfea192c3025be29d" 210 | dependencies = [ 211 | "libc", 212 | "signal-hook-registry", 213 | ] 214 | 215 | [[package]] 216 | name = "signal-hook-mio" 217 | version = "0.2.3" 218 | source = "registry+https://github.com/rust-lang/crates.io-index" 219 | checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af" 220 | dependencies = [ 221 | "libc", 222 | "mio", 223 | "signal-hook", 224 | ] 225 | 226 | [[package]] 227 | name = "signal-hook-registry" 228 | version = "1.4.0" 229 | source = "registry+https://github.com/rust-lang/crates.io-index" 230 | checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" 231 | dependencies = [ 232 | "libc", 233 | ] 234 | 235 | [[package]] 236 | name = "smallvec" 237 | version = "1.9.0" 238 | source = "registry+https://github.com/rust-lang/crates.io-index" 239 | checksum = "2fd0db749597d91ff862fd1d55ea87f7855a744a8425a64695b6fca237d1dad1" 240 | 241 | [[package]] 242 | name = "syn" 243 | version = "1.0.99" 244 | source = "registry+https://github.com/rust-lang/crates.io-index" 245 | checksum = "58dbef6ec655055e20b86b15a8cc6d439cca19b667537ac6a1369572d151ab13" 246 | dependencies = [ 247 | "proc-macro2", 248 | "quote", 249 | "unicode-ident", 250 | ] 251 | 252 | [[package]] 253 | name = "thiserror" 254 | version = "1.0.32" 255 | source = "registry+https://github.com/rust-lang/crates.io-index" 256 | checksum = "f5f6586b7f764adc0231f4c79be7b920e766bb2f3e51b3661cdb263828f19994" 257 | dependencies = [ 258 | "thiserror-impl", 259 | ] 260 | 261 | [[package]] 262 | name = "thiserror-impl" 263 | version = "1.0.32" 264 | source = "registry+https://github.com/rust-lang/crates.io-index" 265 | checksum = "12bafc5b54507e0149cdf1b145a5d80ab80a90bcd9275df43d4fff68460f6c21" 266 | dependencies = [ 267 | "proc-macro2", 268 | "quote", 269 | "syn", 270 | ] 271 | 272 | [[package]] 273 | name = "unicode-ident" 274 | version = "1.0.3" 275 | source = "registry+https://github.com/rust-lang/crates.io-index" 276 | checksum = "c4f5b37a154999a8f3f98cc23a628d850e154479cd94decf3414696e12e31aaf" 277 | 278 | [[package]] 279 | name = "unicode-segmentation" 280 | version = "1.9.0" 281 | source = "registry+https://github.com/rust-lang/crates.io-index" 282 | checksum = "7e8820f5d777f6224dc4be3632222971ac30164d4a258d595640799554ebfd99" 283 | 284 | [[package]] 285 | name = "unicode-width" 286 | version = "0.1.9" 287 | source = "registry+https://github.com/rust-lang/crates.io-index" 288 | checksum = "3ed742d4ea2bd1176e236172c8429aaf54486e7ac098db29ffe6529e0ce50973" 289 | 290 | [[package]] 291 | name = "winapi" 292 | version = "0.3.9" 293 | source = "registry+https://github.com/rust-lang/crates.io-index" 294 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 295 | dependencies = [ 296 | "winapi-i686-pc-windows-gnu", 297 | "winapi-x86_64-pc-windows-gnu", 298 | ] 299 | 300 | [[package]] 301 | name = "winapi-i686-pc-windows-gnu" 302 | version = "0.4.0" 303 | source = "registry+https://github.com/rust-lang/crates.io-index" 304 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 305 | 306 | [[package]] 307 | name = "winapi-x86_64-pc-windows-gnu" 308 | version = "0.4.0" 309 | source = "registry+https://github.com/rust-lang/crates.io-index" 310 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 311 | --------------------------------------------------------------------------------