├── .github └── workflows │ └── ci.yml ├── .gitignore ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── demo.sh ├── embedded-cli-macros ├── Cargo.toml └── src │ ├── command │ ├── args.rs │ ├── autocomplete.rs │ ├── doc.rs │ ├── help.rs │ ├── mod.rs │ ├── model.rs │ └── parse.rs │ ├── group │ ├── command_group.rs │ └── mod.rs │ ├── lib.rs │ ├── processor.rs │ └── utils.rs ├── embedded-cli ├── Cargo.toml ├── src │ ├── arguments.rs │ ├── autocomplete.rs │ ├── buffer.rs │ ├── builder.rs │ ├── cli.rs │ ├── codes.rs │ ├── command.rs │ ├── editor.rs │ ├── help.rs │ ├── history.rs │ ├── input.rs │ ├── lib.rs │ ├── private │ │ └── mod.rs │ ├── service.rs │ ├── token.rs │ ├── utf8.rs │ ├── utils.rs │ └── writer.rs └── tests │ └── cli │ ├── autocomplete.rs │ ├── autocomplete_disabled.rs │ ├── base.rs │ ├── defaults.rs │ ├── help_simple.rs │ ├── help_subcommand.rs │ ├── history.rs │ ├── history_disabled.rs │ ├── main.rs │ ├── options.rs │ ├── subcommand.rs │ ├── terminal.rs │ ├── wrapper.rs │ └── writer.rs └── examples ├── arduino ├── .cargo │ └── config.toml ├── .gitignore ├── Cargo.toml ├── README.md ├── demo.gif ├── memory.sh ├── rust-toolchain.toml └── src │ └── main.rs └── desktop ├── Cargo.toml ├── README.md └── src └── main.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | on: 3 | push: 4 | pull_request: 5 | branches: [ main ] 6 | 7 | env: 8 | CARGO_TERM_COLOR: always 9 | 10 | jobs: 11 | build-lib: 12 | env: 13 | RUSTFLAGS: "-C instrument-coverage" 14 | 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Install Rust 21 | run: | 22 | rustup override set stable 23 | rustup update stable 24 | rustup component add rustfmt clippy llvm-tools 25 | 26 | - name: Install grcov 27 | uses: SierraSoftworks/setup-grcov@v1 28 | with: 29 | github-token: ${{ secrets.GITHUB_TOKEN }} 30 | version: latest 31 | 32 | - name: Check formatting 33 | run: cargo fmt --check --all 34 | 35 | - name: Run clippy 36 | run: cargo clippy -- -Dwarnings 37 | 38 | - name: Run tests 39 | run: cargo test 40 | 41 | - name: Build 42 | run: cargo build 43 | 44 | - name: Process coverage 45 | run: > 46 | grcov embedded-cli -s embedded-cli/src --binary-path target/debug -t cobertura 47 | --branch --ignore-not-existing -o target/coverage.xml 48 | --ignore "**/tests/**" --ignore "**/.cargo/registry/**" 49 | --ignore "**/embedded-cli-macros/**" --ignore "**/examples/**" 50 | 51 | - name: Upload coverage reports to Codecov 52 | uses: codecov/codecov-action@v4 53 | env: 54 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 55 | files: target/coverage.xml 56 | fail_ci_if_error: true 57 | verbose: true 58 | 59 | build-arduino: 60 | runs-on: ubuntu-latest 61 | steps: 62 | - name: Checkout repository 63 | uses: actions/checkout@v4 64 | 65 | - name: Install Rust 66 | run: | 67 | rustup override set nightly 68 | rustup update nightly 69 | rustup component add rustfmt clippy 70 | 71 | - name: Install gcc avr 72 | run: sudo apt-get install -y avr-libc gcc-avr 73 | 74 | - name: Check formatting 75 | working-directory: examples/arduino 76 | run: cargo fmt --check --all 77 | 78 | - name: Run clippy 79 | working-directory: examples/arduino 80 | run: cargo clippy -- -Dwarnings 81 | 82 | - name: Build 83 | working-directory: examples/arduino 84 | run: cargo build --release 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by cargo 2 | target 3 | Cargo.lock 4 | 5 | # IDE 6 | .vscode 7 | .idea 8 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | members = [ 4 | "embedded-cli", 5 | "embedded-cli-macros", 6 | "examples/*", 7 | ] 8 | exclude = ["examples/arduino"] 9 | 10 | [workspace.package] 11 | license = "MIT OR Apache-2.0" 12 | edition = "2021" 13 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy 2 | of this software and associated documentation files (the "Software"), to deal 3 | in the Software without restriction, including without limitation the rights 4 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 5 | copies of the Software, and to permit persons to whom the Software is 6 | furnished to do so, subject to the following conditions: 7 | 8 | The above copyright notice and this permission notice shall be included in all 9 | copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 13 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 14 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 15 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 16 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 17 | SOFTWARE. 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # embedded-cli 2 | 3 | > **Command Line Interface for embedded systems** 4 | 5 | [![Crates.io](https://img.shields.io/crates/v/embedded-cli?style=flat-square)](https://crates.io/crates/embedded-cli) 6 | [![License](https://img.shields.io/badge/license-Apache%202.0-blue?style=flat-square)](LICENSE-APACHE) 7 | [![License](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](LICENSE-MIT) 8 | [![Build Status](https://img.shields.io/github/actions/workflow/status/funbiscuit/embedded-cli-rs/ci.yml?branch=main&style=flat-square)](https://github.com/funbiscuit/embedded-cli-rs/actions/workflows/ci.yml?query=branch%3Amain) 9 | [![Coverage Status](https://img.shields.io/codecov/c/github/funbiscuit/embedded-cli-rs/main?style=flat-square)](https://app.codecov.io/github/funbiscuit/embedded-cli-rs) 10 | 11 | [Demo](examples/arduino/README.md) of CLI running on Arduino Nano. 12 | Memory usage: 16KiB of ROM and 0.6KiB of static RAM. Most of static RAM is used by help strings. 13 | 14 | ![Arduino Demo](examples/arduino/demo.gif) 15 | 16 | Dual-licensed under [Apache 2.0](LICENSE-APACHE) or [MIT](LICENSE-MIT). 17 | 18 | This library is not stable yet, meaning it's API is likely to change. 19 | Some of the API might be a bit ugly, but I don't see a better solution 20 | for now. If you have suggestions - open an Issue or a Pull Request. 21 | 22 | ## Features 23 | 24 | - [x] Static allocation 25 | - [x] UTF-8 support 26 | - [x] No dynamic dispatch 27 | - [x] Configurable memory usage 28 | - [x] Declaration of commands with enums 29 | - [x] Options and flags support 30 | - [x] Subcommand support 31 | - [x] Left/right support (move inside current input) 32 | - [x] Parsing of arguments to common types 33 | - [x] Autocompletion of command names (with tab) 34 | - [x] History (navigate with up and down keypress) 35 | - [x] Help (generated from doc comments) 36 | - [x] Formatted write with [ufmt](https://github.com/japaric/ufmt) 37 | - [x] No panicking branches in generated code, when optimized 38 | - [x] Any byte-stream interface is supported (`embedded_io::Write` as output stream, input bytes are given one-by-one) 39 | - [ ] Colors through ANSI escape sequences 40 | - [ ] Navigation through history with search of current input 41 | - [ ] Support wrapping of generated str slices in user macro (useful for arduino progmem) 42 | 43 | ## How to use 44 | 45 | ### Add dependencies 46 | 47 | Add `embedded-cli` and necessary crates to your app: 48 | 49 | ```toml 50 | [dependencies] 51 | embedded-cli = "0.2.1" 52 | embedded-io = "0.6.1" 53 | ufmt = "0.2.0" 54 | ``` 55 | 56 | ### Implement byte writer 57 | 58 | Define a writer that will be used to output bytes: 59 | 60 | ```rust 61 | struct Writer { 62 | // necessary fields (for example, uart tx handle) 63 | }; 64 | 65 | impl embedded_io::ErrorType for Writer { 66 | // your error type 67 | } 68 | 69 | impl embedded_io::Write for Writer { 70 | fn write(&mut self, buf: &[u8]) -> Result { 71 | todo!() 72 | } 73 | 74 | fn flush(&mut self) -> Result<(), Self::Error> { 75 | todo!() 76 | } 77 | } 78 | ``` 79 | 80 | ### Build CLI instance 81 | 82 | Build a CLI, specifying how much memory to use for command buffer 83 | (where bytes are stored until user presses enter) and history buffer 84 | (so user can navigate with up/down keypress): 85 | 86 | ```rust 87 | let (command_buffer, history_buffer) = unsafe { 88 | static mut COMMAND_BUFFER: [u8; 32] = [0; 32]; 89 | static mut HISTORY_BUFFER: [u8; 32] = [0; 32]; 90 | (COMMAND_BUFFER.as_mut(), HISTORY_BUFFER.as_mut()) 91 | }; 92 | let mut cli = CliBuilder::default() 93 | .writer(writer) 94 | .command_buffer(command_buffer) 95 | .history_buffer(history_buffer) 96 | .build() 97 | .ok()?; 98 | ``` 99 | 100 | In this example static mut buffers were used, so we don't use stack memory. 101 | Note that we didn't call `unwrap()`. It's quite important to keep embedded code 102 | without panics since every panic adds quite a lot to RAM and ROM usage. And 103 | most embedded systems don't have a lot of it. 104 | 105 | ### Describe your commands 106 | 107 | Define you command structure with enums and derive macro: 108 | 109 | ```rust 110 | use embedded_cli::Command; 111 | 112 | #[derive(Command)] 113 | enum Base<'a> { 114 | /// Say hello to World or someone else 115 | Hello { 116 | /// To whom to say hello (World by default) 117 | name: Option<&'a str>, 118 | }, 119 | 120 | /// Stop CLI and exit 121 | Exit, 122 | } 123 | ``` 124 | 125 | Doc-comments will be used in generated help. 126 | 127 | ### Pass input to CLI and process commands 128 | 129 | And you're ready to provide all incoming bytes to cli and handle commands: 130 | 131 | ```rust 132 | use ufmt::uwrite; 133 | 134 | // read byte from somewhere (for example, uart) 135 | // let byte = nb::block!(rx.read()).void_unwrap(); 136 | 137 | let _ = cli.process_byte::( 138 | byte, 139 | &mut Base::processor(|cli, command| { 140 | match command { 141 | Base::Hello { name } => { 142 | // last write in command callback may or may not 143 | // end with newline. so both uwrite!() and uwriteln!() 144 | // will give identical results 145 | uwrite!(cli.writer(), "Hello, {}", name.unwrap_or("World"))?; 146 | } 147 | Base::Exit => { 148 | // We can write via normal function if formatting not needed 149 | cli.writer().write_str("Cli can't shutdown now")?; 150 | } 151 | } 152 | Ok(()) 153 | }), 154 | ); 155 | ``` 156 | 157 | ### Split commands into modules 158 | 159 | If you have a lot of commands it may be useful to split them into multiple enums 160 | and place their logic into multiple modules. This is also supported via command groups. 161 | 162 | Create extra command enum: 163 | 164 | ```rust 165 | #[derive(Command)] 166 | #[command(help_title = "Manage Hardware")] 167 | enum GetCommand { 168 | /// Get current LED value 169 | GetLed { 170 | /// ID of requested LED 171 | led: u8, 172 | }, 173 | 174 | /// Get current ADC value 175 | GetAdc { 176 | /// ID of requested ADC 177 | adc: u8, 178 | }, 179 | } 180 | ``` 181 | 182 | Group commands into new enum: 183 | 184 | ```rust 185 | #[derive(CommandGroup)] 186 | enum Group<'a> { 187 | Base(Base<'a>), 188 | Get(GetCommand), 189 | 190 | /// This variant will capture everything, that 191 | /// other commands didn't parse. You don't need 192 | /// to add it, just for example 193 | Other(RawCommand<'a>), 194 | } 195 | ``` 196 | 197 | And then process it in similar way: 198 | 199 | ```rust 200 | let _ = cli.process_byte::( 201 | byte, 202 | &mut Group::processor(|cli, command| { 203 | match command { 204 | Group::Base(cmd) => todo!("process base command"), 205 | Group::Get(cmd) => todo!("process get command"), 206 | Group::Other(cmd) => todo!("process all other, not parsed commands"), 207 | } 208 | Ok(()) 209 | }), 210 | ); 211 | ``` 212 | 213 | You can check full arduino example [here](examples/arduino/README.md). 214 | There is also a desktop [example](examples/desktop/README.md) that runs in normal terminal. 215 | So you can play with CLI without flashing a real device. 216 | 217 | ## Argument parsing 218 | 219 | Command can have any number of arguments. Types of argument must implement `FromArgument` trait: 220 | 221 | ```rust 222 | struct CustomArg<'a> { 223 | // fields 224 | } 225 | 226 | impl<'a> embedded_cli::arguments::FromArgument<'a> for CustomArg<'a> { 227 | fn from_arg(arg: &'a str) -> Result 228 | where 229 | Self: Sized { 230 | todo!() 231 | } 232 | } 233 | ``` 234 | 235 | Library provides implementation for following types: 236 | 237 | * All numbers (u8, i8, u16, i16, u32, i32, u64, i64, u128, i128, usize, isize, f32, f64) 238 | * Boolean (bool) 239 | * Character (char) 240 | * String slices (&str) 241 | 242 | Open an issue if you need some other type. 243 | 244 | ## Input tokenization 245 | 246 | CLI uses whitespace (normal ASCII whitespace with code `0x20`) to split input into command 247 | and its arguments. If you want to provide argument, that contains spaces, just wrap it 248 | with quotes. 249 | 250 | | Input | Argument 1 | Argument 2 | Notes | 251 | |-----------------------|------------|------------|-------------------------------------------------------| 252 | | cmd abc def | abc | def | Space is treated as argument separator | 253 | | cmd "abc def" | abc def | | To use space inside argument, surround it with quotes | 254 | | cmd "abc\\" d\\\\ef" | abc" d\\ef | | To use quotes or slashes, escape them with \\ | 255 | | cmd "abc def" test | abc def | test | You can mix quoted arguments and non-quoted | 256 | | cmd "abc def"test | abc def | test | Space between quoted args is optional | 257 | | cmd "abc def""test 2" | abc def | test 2 | Space between quoted args is optional | 258 | 259 | ## Generated help 260 | 261 | When using `Command` derive macro, it automatically generates help from doc comments: 262 | 263 | ```rust 264 | #[derive(Command)] 265 | enum Base<'a> { 266 | /// Say hello to World or someone else 267 | Hello { 268 | /// To whom to say hello (World by default) 269 | name: Option<&'a str>, 270 | }, 271 | 272 | /// Stop CLI and exit 273 | Exit, 274 | } 275 | ``` 276 | 277 | List all commands with `help`: 278 | 279 | ``` 280 | $ help 281 | Commands: 282 | hello Say hello to World or someone else 283 | exit Stop CLI and exit 284 | ``` 285 | 286 | Get help for specific command with `help `: 287 | 288 | ``` 289 | $ help hello 290 | Say hello to World or someone else 291 | 292 | Usage: hello [NAME] 293 | 294 | Arguments: 295 | [NAME] To whom to say hello (World by default) 296 | 297 | Options: 298 | -h, --help Print help 299 | ``` 300 | 301 | Or with ` --help` or ` -h`: 302 | 303 | ``` 304 | $ exit --help 305 | Stop CLI and exit 306 | 307 | Usage: exit 308 | 309 | Options: 310 | -h, --help Print help 311 | ``` 312 | 313 | ## User Guide 314 | 315 | You'll need to begin communication (usually through a UART) with a device running a CLI. 316 | Terminal is required for correct experience. Following control sequences are supported: 317 | 318 | * \r or \n sends a command (\r\n is also supported) 319 | * \b removes last typed character 320 | * \t tries to autocomplete current input 321 | * Esc[A (key up) and Esc[B (key down) navigates through history 322 | * Esc[C (key right) and Esc[D (key left) moves cursor inside current input 323 | 324 | If you run CLI through a serial port (like on Arduino with its UART-USB converter), 325 | you can use for example [PuTTY](https://putty.org) or [tio](https://github.com/tio/tio). 326 | 327 | ## Memory usage 328 | 329 | Memory usage depends on version of crate, enabled features and complexity of your commands. 330 | Below is memory usage of arduino [example](examples/arduino/README.md) when different features are enabled. 331 | Memory usage might change in future versions, but I'll try to keep this table up to date. 332 | 333 | | Features | ROM, bytes | Static RAM, bytes | 334 | |---------------------------------|:----------:|:-----------------:| 335 | | | 10182 | 274 | 336 | | `autocomplete` | 12112 | 290 | 337 | | `history` | 12032 | 315 | 338 | | `autocomplete` `history` | 13586 | 331 | 339 | | `help` | 14412 | 544 | 340 | | `autocomplete` `help` | 16110 | 556 | 341 | | `history` `help` | 16402 | 585 | 342 | | `autocomplete` `history` `help` | 16690 | 597 | 343 | 344 | This table is generated using this [script](examples/arduino/memory.sh). 345 | As table shows, enabling help adds quite a lot to memory usage since help usually requires a lot of text to be stored. 346 | Also enabling all features almost doubles ROM usage comparing to all features disabled. 347 | -------------------------------------------------------------------------------- /demo.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script emulates predefined input to showcase usage of CLI 3 | # Size of screen: 65x15 4 | # Dependencies: xdotool 5 | # To run demo you need to flash arduino example to real arduino nano 6 | # Then run script in background: 7 | # ./demo.sh & 8 | # 9 | # To convert recorded mp4 it's best to use gifski: 10 | # ffmpeg -i embedded-cli.mp4 frame%04d.png 11 | # gifski -o demo.gif -Q 50 frame*.png 12 | 13 | SPEED=1 14 | 15 | declare -i DELAY=(300)/SPEED 16 | declare -i LONGER_DELAY=(350)/SPEED 17 | 18 | type () { 19 | xdotool type --delay $DELAY -- "$1" 20 | } 21 | submit () { 22 | xdotool key --delay $LONGER_DELAY Return 23 | } 24 | backspace () { 25 | local repeat=${1:-1} 26 | xdotool key --delay $DELAY --repeat $repeat BackSpace 27 | } 28 | tab () { 29 | xdotool key --delay $LONGER_DELAY Tab 30 | } 31 | left () { 32 | local repeat=${1:-1} 33 | xdotool key --delay $DELAY --repeat $repeat Left 34 | } 35 | right () { 36 | local repeat=${1:-1} 37 | xdotool key --delay $DELAY --repeat $repeat Right 38 | } 39 | up () { 40 | local repeat=${1:-1} 41 | xdotool key --delay $LONGER_DELAY --repeat $repeat Up 42 | } 43 | down () { 44 | local repeat=${1:-1} 45 | xdotool key --delay $LONGER_DELAY --repeat $repeat Down 46 | } 47 | 48 | echo "Demo started" 49 | 50 | # Connect to device 51 | sleep 1 52 | xdotool key ctrl+l 53 | # For quick testing locally 54 | #xdotool type "cargo run" 55 | xdotool type "tio /dev/ttyUSB0 --map ODELBS" 56 | xdotool key Return 57 | # long sleep so initial keys disappear and arduino boots 58 | sleep 5 59 | 60 | type "help" 61 | submit 62 | 63 | type "l" 64 | tab 65 | 66 | submit 67 | 68 | up 69 | 70 | type "--hlp" 71 | left 2 72 | type "e" 73 | submit 74 | 75 | up 2 76 | 77 | type "--id 1 get" 78 | submit 79 | 80 | up 81 | backspace 3 82 | type "set" 83 | submit 84 | 85 | up 86 | type " --help" 87 | submit 88 | 89 | up 2 90 | type " 12" 91 | submit 92 | 93 | type "l" 94 | tab 95 | type "--id 1 get" 96 | submit 97 | 98 | 99 | type "a" 100 | tab 101 | type "-h" 102 | submit 103 | 104 | up 105 | left 2 106 | type "--id 1 read " 107 | submit 108 | 109 | up 110 | backspace 2 111 | type "--sampler mean" 112 | submit 113 | 114 | up 115 | type " -V" 116 | submit 117 | 118 | up 119 | left 3 120 | type " val" 121 | submit 122 | 123 | up 124 | left 11 125 | type "\"" 126 | right 8 127 | type "\"" 128 | submit 129 | 130 | # Wait until keys disappear 131 | sleep 5 132 | echo "Demo is finished" 133 | -------------------------------------------------------------------------------- /embedded-cli-macros/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "embedded-cli-macros" 3 | version = "0.2.1" 4 | authors = ["Sviatoslav Kokurin "] 5 | description = """ 6 | Macros for embedded-cli lib 7 | """ 8 | repository = "https://github.com/funbiscuit/embedded-cli-rs" 9 | readme = "../README.md" 10 | keywords = ["no_std", "cli", "macro", "embedded"] 11 | license = "MIT OR Apache-2.0" 12 | categories = ["command-line-interface", "embedded", "no-std"] 13 | edition = "2021" 14 | 15 | [lib] 16 | proc-macro = true 17 | 18 | [features] 19 | default = [] 20 | autocomplete = [] 21 | help = [] 22 | 23 | [dependencies] 24 | convert_case = "0.6.0" 25 | darling = "0.20.8" 26 | proc-macro2 = "1.0.79" 27 | quote = "1.0.36" 28 | syn = { version = "2.0.58", features = ["full"] } 29 | 30 | [dev-dependencies] 31 | -------------------------------------------------------------------------------- /embedded-cli-macros/src/command/args.rs: -------------------------------------------------------------------------------- 1 | use syn::Type; 2 | 3 | use crate::utils; 4 | 5 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 6 | pub enum ArgType { 7 | Option, 8 | Normal, 9 | } 10 | 11 | pub struct TypedArg<'a> { 12 | ty: ArgType, 13 | inner: &'a Type, 14 | } 15 | 16 | impl<'a> TypedArg<'a> { 17 | pub fn new(ty: &'a Type) -> Self { 18 | if let Some(ty) = 19 | utils::extract_generic_type(ty, &["Option", "std:option:Option", "core:option:Option"]) 20 | { 21 | TypedArg { 22 | ty: ArgType::Option, 23 | inner: ty, 24 | } 25 | } else { 26 | TypedArg { 27 | ty: ArgType::Normal, 28 | inner: ty, 29 | } 30 | } 31 | } 32 | 33 | pub fn inner(&self) -> &'_ Type { 34 | self.inner 35 | } 36 | 37 | pub fn ty(&self) -> ArgType { 38 | self.ty 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /embedded-cli-macros/src/command/autocomplete.rs: -------------------------------------------------------------------------------- 1 | use darling::Result; 2 | use proc_macro2::TokenStream; 3 | use quote::quote; 4 | 5 | use super::{model::Command, TargetType}; 6 | 7 | #[cfg(feature = "autocomplete")] 8 | pub fn derive_autocomplete(target: &TargetType, commands: &[Command]) -> Result { 9 | let command_count = commands.len(); 10 | let command_names: Vec = commands.iter().map(|c| c.name.to_string()).collect(); 11 | 12 | let ident = target.ident(); 13 | let named_lifetime = target.named_lifetime(); 14 | 15 | let output = quote! { 16 | impl #named_lifetime _cli::service::Autocomplete for #ident #named_lifetime { 17 | fn autocomplete( 18 | request: _cli::autocomplete::Request<'_>, 19 | autocompletion: &mut _cli::autocomplete::Autocompletion<'_>, 20 | ) { 21 | const NAMES: &[&str; #command_count] = &[#(#command_names),*]; 22 | if let _cli::autocomplete::Request::CommandName(name) = request { 23 | NAMES 24 | .iter() 25 | .skip_while(|n| !n.starts_with(name)) 26 | .take_while(|n| n.starts_with(name)) 27 | .for_each(|n| { 28 | // SAFETY: n starts with name, so name cannot be longer 29 | let autocompleted = unsafe { n.get_unchecked(name.len()..) }; 30 | autocompletion.merge_autocompletion(autocompleted) 31 | }); 32 | } 33 | } 34 | } 35 | }; 36 | 37 | Ok(output) 38 | } 39 | 40 | #[allow(unused_variables)] 41 | #[cfg(not(feature = "autocomplete"))] 42 | pub fn derive_autocomplete(target: &TargetType, commands: &[Command]) -> Result { 43 | let ident = target.ident(); 44 | let named_lifetime = target.named_lifetime(); 45 | 46 | let output = quote! { 47 | impl #named_lifetime _cli::service::Autocomplete for #ident #named_lifetime { } 48 | }; 49 | 50 | Ok(output) 51 | } 52 | -------------------------------------------------------------------------------- /embedded-cli-macros/src/command/doc.rs: -------------------------------------------------------------------------------- 1 | //! Code is mostly taken from clap/structopt 2 | //! 3 | //! Works in terms of "paragraphs". Paragraph is a sequence of 4 | //! non-empty adjacent lines, delimited by sequences of blank (whitespace only) lines. 5 | 6 | use std::iter; 7 | 8 | pub struct Help { 9 | short: Option, 10 | long: Option, 11 | } 12 | 13 | impl Help { 14 | pub fn parse(attrs: &[syn::Attribute]) -> darling::Result { 15 | let lines = extract_doc_comment(attrs); 16 | 17 | let (short, long) = format_doc_comment(&lines); 18 | 19 | Ok(Self { short, long }) 20 | } 21 | 22 | pub fn long(&self) -> Option<&str> { 23 | self.long.as_deref() 24 | } 25 | 26 | pub fn short(&self) -> Option<&str> { 27 | self.short.as_deref() 28 | } 29 | } 30 | 31 | fn extract_doc_comment(attrs: &[syn::Attribute]) -> Vec { 32 | // multiline comments (`/** ... */`) may have LFs (`\n`) in them, 33 | // we need to split so we could handle the lines correctly 34 | // 35 | // we also need to remove leading and trailing blank lines 36 | let mut lines: Vec<_> = attrs 37 | .iter() 38 | .filter(|attr| attr.path().is_ident("doc")) 39 | .filter_map(|attr| { 40 | // non #[doc = "..."] attributes are not our concern 41 | // we leave them for rustc to handle 42 | match &attr.meta { 43 | syn::Meta::NameValue(syn::MetaNameValue { 44 | value: 45 | syn::Expr::Lit(syn::ExprLit { 46 | lit: syn::Lit::Str(s), 47 | .. 48 | }), 49 | .. 50 | }) => Some(s.value()), 51 | _ => None, 52 | } 53 | }) 54 | .skip_while(|s| is_blank(s)) 55 | .flat_map(|s| { 56 | let lines = s 57 | .split('\n') 58 | .map(|s| { 59 | // remove one leading space no matter what 60 | let s = s.strip_prefix(' ').unwrap_or(s); 61 | s.to_owned() 62 | }) 63 | .collect::>(); 64 | lines 65 | }) 66 | .collect(); 67 | 68 | while let Some(true) = lines.last().map(|s| is_blank(s)) { 69 | lines.pop(); 70 | } 71 | 72 | lines 73 | } 74 | 75 | fn format_doc_comment(lines: &[String]) -> (Option, Option) { 76 | if lines.is_empty() { 77 | return (None, None); 78 | } 79 | if lines.iter().any(|s| is_blank(s)) { 80 | let paragraphs = split_paragraphs(lines); 81 | let short = paragraphs[0].clone(); 82 | let long = paragraphs.join("\r\n\r\n"); 83 | (Some(remove_period(short)), Some(long)) 84 | } else { 85 | let short = merge_lines(lines); 86 | let long = short.clone(); 87 | let short = remove_period(short); 88 | (Some(short), Some(long)) 89 | } 90 | } 91 | 92 | fn split_paragraphs(lines: &[String]) -> Vec { 93 | let mut last_line = 0; 94 | iter::from_fn(|| { 95 | let slice = &lines[last_line..]; 96 | let start = slice.iter().position(|s| !is_blank(s)).unwrap_or(0); 97 | 98 | let slice = &slice[start..]; 99 | let len = slice 100 | .iter() 101 | .position(|s| is_blank(s)) 102 | .unwrap_or(slice.len()); 103 | 104 | last_line += start + len; 105 | 106 | if len != 0 { 107 | Some(merge_lines(&slice[..len])) 108 | } else { 109 | None 110 | } 111 | }) 112 | .collect() 113 | } 114 | 115 | fn remove_period(mut s: String) -> String { 116 | if s.ends_with('.') && !s.ends_with("..") { 117 | s.pop(); 118 | } 119 | s 120 | } 121 | 122 | fn is_blank(s: &str) -> bool { 123 | s.trim().is_empty() 124 | } 125 | 126 | fn merge_lines(lines: impl IntoIterator>) -> String { 127 | lines 128 | .into_iter() 129 | .map(|s| s.as_ref().trim().to_owned()) 130 | .collect::>() 131 | .join(" ") 132 | } 133 | -------------------------------------------------------------------------------- /embedded-cli-macros/src/command/mod.rs: -------------------------------------------------------------------------------- 1 | use darling::{Error, FromDeriveInput, Result}; 2 | use proc_macro2::TokenStream; 3 | use quote::quote; 4 | use syn::{Data, DeriveInput}; 5 | 6 | use crate::{processor, utils::TargetType}; 7 | 8 | use self::model::Command; 9 | 10 | mod args; 11 | mod autocomplete; 12 | #[cfg(feature = "help")] 13 | mod doc; 14 | mod help; 15 | mod model; 16 | mod parse; 17 | 18 | #[derive(FromDeriveInput, Default)] 19 | #[darling(default, attributes(command), forward_attrs(allow, doc, cfg))] 20 | struct ServiceAttrs { 21 | help_title: Option, 22 | skip_autocomplete: bool, 23 | skip_help: bool, 24 | skip_from_raw: bool, 25 | } 26 | 27 | pub fn derive_command(input: DeriveInput) -> Result { 28 | let opts = ServiceAttrs::from_derive_input(&input)?; 29 | let DeriveInput { 30 | ident, 31 | data, 32 | generics, 33 | .. 34 | } = input; 35 | 36 | let data = if let Data::Enum(data) = data { 37 | data 38 | } else { 39 | return Err(Error::custom("Command can be derived only for an enum").with_span(&ident)); 40 | }; 41 | 42 | let target = TargetType::parse(ident, generics)?; 43 | 44 | let mut errors = Error::accumulator(); 45 | let commands: Vec = data 46 | .variants 47 | .iter() 48 | .filter_map(|variant| errors.handle_in(|| Command::parse(variant))) 49 | .collect(); 50 | errors.finish()?; 51 | 52 | let help_title = opts.help_title.unwrap_or("Commands".to_string()); 53 | 54 | let derive_autocomplete = if opts.skip_autocomplete { 55 | quote! {} 56 | } else { 57 | autocomplete::derive_autocomplete(&target, &commands)? 58 | }; 59 | let derive_help = if opts.skip_help { 60 | quote! {} 61 | } else { 62 | help::derive_help(&target, &help_title, &commands)? 63 | }; 64 | let derive_from_raw = if opts.skip_from_raw { 65 | quote! {} 66 | } else { 67 | parse::derive_from_raw(&target, &commands)? 68 | }; 69 | let impl_processor = processor::impl_processor(&target)?; 70 | 71 | let output = quote! { 72 | #derive_autocomplete 73 | 74 | #derive_help 75 | 76 | #derive_from_raw 77 | 78 | #impl_processor 79 | }; 80 | 81 | Ok(output) 82 | } 83 | -------------------------------------------------------------------------------- /embedded-cli-macros/src/command/parse.rs: -------------------------------------------------------------------------------- 1 | use convert_case::{Case, Casing}; 2 | use darling::Result; 3 | use proc_macro2::{Ident, TokenStream}; 4 | use quote::{format_ident, quote}; 5 | 6 | use super::{ 7 | args::ArgType, 8 | model::{Command, CommandArgType}, 9 | TargetType, 10 | }; 11 | 12 | pub fn derive_from_raw(target: &TargetType, commands: &[Command]) -> Result { 13 | let ident = target.ident(); 14 | 15 | let parsing = create_parsing(ident, commands)?; 16 | 17 | let named_lifetime = target.named_lifetime(); 18 | 19 | let output = quote! { 20 | 21 | impl<'a> _cli::service::FromRaw<'a> for #ident #named_lifetime { 22 | fn parse(command: _cli::command::RawCommand<'a>) -> Result> { 23 | #parsing 24 | Ok(command) 25 | } 26 | } 27 | }; 28 | 29 | Ok(output) 30 | } 31 | 32 | fn create_parsing(ident: &Ident, commands: &[Command]) -> Result { 33 | let match_arms: Vec<_> = commands.iter().map(|c| command_parsing(ident, c)).collect(); 34 | 35 | Ok(quote! { 36 | let command = match command.name() { 37 | #(#match_arms)* 38 | cmd => return Err(_cli::service::ParseError::UnknownCommand), 39 | }; 40 | }) 41 | } 42 | 43 | fn command_parsing(ident: &Ident, command: &Command) -> TokenStream { 44 | let name = &command.name; 45 | let variant_name = &command.ident; 46 | let variant_fqn = quote! { #ident::#variant_name }; 47 | 48 | let rhs = if command.args.is_empty() && command.subcommand.is_none() { 49 | quote! { #variant_fqn, } 50 | } else { 51 | let (parsing, arguments) = create_arg_parsing(command); 52 | if command.named_args { 53 | quote! { 54 | { 55 | #parsing 56 | #variant_fqn { #(#arguments)* } 57 | } 58 | } 59 | } else { 60 | quote! { 61 | { 62 | #parsing 63 | #variant_fqn ( #(#arguments)* ) 64 | } 65 | } 66 | } 67 | }; 68 | 69 | quote! { #name => #rhs } 70 | } 71 | 72 | fn create_arg_parsing(command: &Command) -> (TokenStream, Vec) { 73 | let mut variables = vec![]; 74 | let mut arguments = vec![]; 75 | let mut positional_value_arms = vec![]; 76 | let mut extra_states = vec![]; 77 | let mut option_name_arms = vec![]; 78 | let mut option_value_arms = vec![]; 79 | 80 | let mut positional = 0usize; 81 | for arg in &command.args { 82 | let fi_raw = format_ident!("{}", arg.field_name); 83 | let fi = format_ident!("arg_{}", arg.field_name); 84 | let ty = &arg.field_type; 85 | 86 | let arg_default; 87 | 88 | match &arg.arg_type { 89 | CommandArgType::Flag { long, short } => { 90 | arg_default = Some(quote! { false }); 91 | 92 | option_name_arms.push(create_option_name_arm( 93 | short, 94 | long, 95 | quote! { 96 | { 97 | #fi = Some(true); 98 | state = States::Normal; 99 | } 100 | }, 101 | )); 102 | } 103 | CommandArgType::Option { long, short } => { 104 | arg_default = arg.default_value.clone(); 105 | let state = format_ident!( 106 | "Expect{}", 107 | arg.field_name.from_case(Case::Snake).to_case(Case::Pascal) 108 | ); 109 | extra_states.push(quote! { #state, }); 110 | 111 | let parse_value = create_parse_arg_value(ty); 112 | option_value_arms.push(quote! { 113 | _cli::arguments::Arg::Value(val) if state == States::#state => { 114 | #fi = Some(#parse_value); 115 | state = States::Normal; 116 | } 117 | }); 118 | 119 | option_name_arms.push(create_option_name_arm( 120 | short, 121 | long, 122 | quote! { state = States::#state }, 123 | )); 124 | } 125 | CommandArgType::Positional => { 126 | arg_default = arg.default_value.clone(); 127 | let parse_value = create_parse_arg_value(ty); 128 | 129 | positional_value_arms.push(quote! { 130 | #positional => { 131 | #fi = Some(#parse_value); 132 | }, 133 | }); 134 | positional += 1; 135 | } 136 | } 137 | 138 | let constructor_arg = match arg.ty { 139 | ArgType::Option => quote! { #fi_raw: #fi }, 140 | ArgType::Normal => { 141 | if let Some(default) = arg_default { 142 | quote! { 143 | #fi_raw: #fi.unwrap_or(#default) 144 | } 145 | } else { 146 | let name = arg.full_name(); 147 | quote! { 148 | #fi_raw: #fi.ok_or(_cli::service::ParseError::MissingRequiredArgument { 149 | name: #name, 150 | })? 151 | } 152 | } 153 | } 154 | }; 155 | 156 | variables.push(quote! { 157 | let mut #fi = None; 158 | }); 159 | arguments.push(quote! { 160 | #constructor_arg, 161 | }); 162 | } 163 | 164 | let subcommand_value_arm; 165 | if let Some(subcommand) = &command.subcommand { 166 | let fi_raw; 167 | let fi; 168 | if let Some(field_name) = &subcommand.field_name { 169 | let ident_raw = format_ident!("{}", field_name); 170 | fi_raw = quote! { #ident_raw: }; 171 | fi = format_ident!("sub_{}", field_name); 172 | } else { 173 | fi_raw = quote! {}; 174 | fi = format_ident!("sub_command"); 175 | } 176 | let ty = &subcommand.field_type; 177 | 178 | subcommand_value_arm = Some(quote! { 179 | let args = args.into_args(); 180 | let raw = _cli::command::RawCommand::new(name, args); 181 | 182 | #fi = Some(<#ty as _cli::service::FromRaw>::parse(raw)?); 183 | 184 | break; 185 | }); 186 | 187 | let constructor_arg = match subcommand.ty { 188 | ArgType::Option => quote! { #fi_raw #fi }, 189 | ArgType::Normal => { 190 | let name = subcommand.full_name(); 191 | quote! { 192 | #fi_raw #fi.ok_or(_cli::service::ParseError::MissingRequiredArgument { 193 | name: #name, 194 | })? 195 | } 196 | } 197 | }; 198 | 199 | variables.push(quote! { 200 | let mut #fi = None; 201 | }); 202 | arguments.push(quote! { 203 | #constructor_arg, 204 | }); 205 | } else { 206 | subcommand_value_arm = None; 207 | } 208 | 209 | let value_arm = if let Some(subcommand_arm) = subcommand_value_arm { 210 | quote! { 211 | _cli::arguments::Arg::Value(name) if state == States::Normal => { 212 | #subcommand_arm 213 | } 214 | } 215 | } else if positional_value_arms.is_empty() { 216 | quote! { 217 | _cli::arguments::Arg::Value(value) if state == States::Normal => 218 | return Err(_cli::service::ParseError::UnexpectedArgument { 219 | value, 220 | }) 221 | } 222 | } else { 223 | quote! { 224 | _cli::arguments::Arg::Value(val) if state == States::Normal => { 225 | match positional { 226 | #(#positional_value_arms)* 227 | _ => return Err(_cli::service::ParseError::UnexpectedArgument{ 228 | value: val 229 | }) 230 | } 231 | positional += 1; 232 | } 233 | } 234 | }; 235 | 236 | let parsing = quote! { 237 | #(#variables)* 238 | 239 | #[derive(Eq, PartialEq)] 240 | enum States { 241 | Normal, 242 | #(#extra_states)* 243 | } 244 | let mut state = States::Normal; 245 | let mut positional = 0; 246 | 247 | let mut args = command.args().args(); 248 | while let Some(arg) = args.next() { 249 | match arg { 250 | #(#option_name_arms)* 251 | #(#option_value_arms)* 252 | #value_arm, 253 | _cli::arguments::Arg::Value(_) => unreachable!(), 254 | _cli::arguments::Arg::LongOption(option) => { 255 | return Err(_cli::service::ParseError::UnexpectedLongOption { name: option }) 256 | } 257 | _cli::arguments::Arg::ShortOption(option) => { 258 | return Err(_cli::service::ParseError::UnexpectedShortOption { name: option }) 259 | } 260 | _cli::arguments::Arg::DoubleDash => {} 261 | } 262 | } 263 | }; 264 | 265 | (parsing, arguments) 266 | } 267 | 268 | pub fn create_option_name_arm( 269 | short: &Option, 270 | long: &Option, 271 | action: TokenStream, 272 | ) -> TokenStream { 273 | match (short, long) { 274 | (Some(short), Some(long)) => { 275 | quote! { 276 | _cli::arguments::Arg::LongOption(#long) 277 | | _cli::arguments::Arg::ShortOption(#short) => #action, 278 | } 279 | } 280 | (Some(short), None) => { 281 | quote! { 282 | _cli::arguments::Arg::ShortOption(#short) => #action, 283 | } 284 | } 285 | (None, Some(long)) => { 286 | quote! { 287 | _cli::arguments::Arg::LongOption(#long) => #action, 288 | } 289 | } 290 | (None, None) => unreachable!(), 291 | } 292 | } 293 | 294 | fn create_parse_arg_value(ty: &TokenStream) -> TokenStream { 295 | quote! { 296 | <#ty as _cli::arguments::FromArgument>::from_arg(val)?, 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /embedded-cli-macros/src/group/command_group.rs: -------------------------------------------------------------------------------- 1 | use darling::{Error, FromVariant, Result}; 2 | use proc_macro2::Ident; 3 | use syn::{Fields, Type, Variant}; 4 | 5 | #[derive(Debug, FromVariant, Default)] 6 | #[darling(default, attributes(group), forward_attrs(allow, doc, cfg))] 7 | struct GroupAttrs { 8 | hidden: bool, 9 | } 10 | 11 | #[derive(Debug)] 12 | pub struct CommandGroup { 13 | pub ident: Ident, 14 | pub field_type: Type, 15 | pub hidden: bool, 16 | } 17 | 18 | impl CommandGroup { 19 | pub fn parse(variant: &Variant) -> Result { 20 | let variant_ident = &variant.ident; 21 | let attrs = GroupAttrs::from_variant(variant)?; 22 | 23 | let field = match &variant.fields { 24 | Fields::Unnamed(fields) => { 25 | if fields.unnamed.len() != 1 { 26 | return Err( 27 | Error::custom("Group variant must have a single tuple field") 28 | .with_span(fields), 29 | ); 30 | } 31 | fields.unnamed.first().unwrap() 32 | } 33 | _ => { 34 | return Err( 35 | Error::custom("Group variant must have a single tuple field") 36 | .with_span(variant), 37 | ) 38 | } 39 | }; 40 | 41 | Ok(Self { 42 | ident: variant_ident.clone(), 43 | field_type: field.ty.clone(), 44 | hidden: attrs.hidden, 45 | }) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /embedded-cli-macros/src/group/mod.rs: -------------------------------------------------------------------------------- 1 | use darling::{Error, Result}; 2 | use proc_macro2::TokenStream; 3 | use quote::quote; 4 | use syn::{Data, DeriveInput}; 5 | 6 | use crate::{processor, utils::TargetType}; 7 | 8 | use self::command_group::CommandGroup; 9 | 10 | mod command_group; 11 | 12 | pub fn derive_command_group(input: DeriveInput) -> Result { 13 | let DeriveInput { 14 | ident, 15 | data, 16 | generics, 17 | .. 18 | } = input; 19 | 20 | let data = if let Data::Enum(data) = data { 21 | data 22 | } else { 23 | return Err(Error::custom("Command can be derived only for an enum").with_span(&ident)); 24 | }; 25 | 26 | let target = TargetType::parse(ident, generics)?; 27 | 28 | let mut errors = Error::accumulator(); 29 | let groups: Vec = data 30 | .variants 31 | .iter() 32 | .filter_map(|variant| errors.handle_in(|| CommandGroup::parse(variant))) 33 | .collect(); 34 | errors.finish()?; 35 | 36 | let derive_autocomplete = derive_autocomplete(&target, &groups); 37 | let derive_help = derive_help(&target, &groups); 38 | let derive_from_raw = derive_from_raw(&target, &groups); 39 | let impl_processor = processor::impl_processor(&target)?; 40 | 41 | let output = quote! { 42 | #derive_autocomplete 43 | #derive_help 44 | #derive_from_raw 45 | #impl_processor 46 | }; 47 | 48 | Ok(output) 49 | } 50 | 51 | #[cfg(feature = "autocomplete")] 52 | fn derive_autocomplete(target: &TargetType, groups: &[CommandGroup]) -> TokenStream { 53 | let ident = target.ident(); 54 | let named_lifetime = target.named_lifetime(); 55 | 56 | let groups = groups 57 | .iter() 58 | .filter(|group| !group.hidden) 59 | .map(|group| { 60 | let ty = &group.field_type; 61 | quote! { 62 | <#ty as _cli::service::Autocomplete>::autocomplete(request.clone(), autocompletion); 63 | } 64 | }) 65 | .collect::>(); 66 | 67 | quote! { 68 | impl #named_lifetime _cli::service::Autocomplete for #ident #named_lifetime { 69 | fn autocomplete( 70 | request: _cli::autocomplete::Request<'_>, 71 | autocompletion: &mut _cli::autocomplete::Autocompletion<'_>, 72 | ) { 73 | #(#groups)* 74 | } 75 | } 76 | } 77 | } 78 | 79 | #[allow(unused_variables)] 80 | #[cfg(not(feature = "autocomplete"))] 81 | fn derive_autocomplete(target: &TargetType, groups: &[CommandGroup]) -> TokenStream { 82 | let ident = target.ident(); 83 | let named_lifetime = target.named_lifetime(); 84 | 85 | quote! { 86 | impl #named_lifetime _cli::service::Autocomplete for #ident #named_lifetime { } 87 | } 88 | } 89 | 90 | #[cfg(feature = "help")] 91 | fn derive_help(target: &TargetType, groups: &[CommandGroup]) -> TokenStream { 92 | let ident = target.ident(); 93 | let named_lifetime = target.named_lifetime(); 94 | 95 | let command_counts = groups 96 | .iter() 97 | .filter(|group| !group.hidden) 98 | .enumerate() 99 | .map(|(i, group)| { 100 | let ty = &group.field_type; 101 | if i > 0 { 102 | quote! { 103 | + <#ty as _cli::service::Help>::command_count() 104 | } 105 | } else { 106 | quote! { 107 | <#ty as _cli::service::Help>::command_count() 108 | } 109 | } 110 | }) 111 | .collect::>(); 112 | 113 | let command_help = groups 114 | .iter() 115 | .filter(|group| !group.hidden) 116 | .enumerate() 117 | .map(|(i, group)| { 118 | let ty = &group.field_type; 119 | if i > 0 { 120 | quote! { 121 | .or_else(|_| <#ty as _cli::service::Help>::command_help(parent, command.clone(), writer)) 122 | } 123 | } else { 124 | quote! { 125 | <#ty as _cli::service::Help>::command_help(parent, command.clone(), writer) 126 | } 127 | } 128 | }) 129 | .collect::>(); 130 | 131 | let list_commands = groups 132 | .iter() 133 | .filter(|group| !group.hidden) 134 | .map(|group| { 135 | let ty = &group.field_type; 136 | quote! { 137 | if <#ty as _cli::service::Help>::command_count() > 0 { 138 | if has_output { 139 | writer.writeln_str("")?; 140 | } 141 | <#ty as _cli::service::Help>::list_commands(writer)?; 142 | has_output = true; 143 | } 144 | } 145 | }) 146 | .collect::>(); 147 | 148 | quote! { 149 | impl #named_lifetime _cli::service::Help for #ident #named_lifetime { 150 | 151 | fn command_count() -> usize { 152 | #(#command_counts)* 153 | } 154 | 155 | fn list_commands, E: _io::Error>( 156 | writer: &mut _cli::writer::Writer<'_, W, E>, 157 | ) -> Result<(), E> { 158 | let mut has_output = false; 159 | #(#list_commands)* 160 | Ok(()) 161 | } 162 | 163 | fn command_help< 164 | W: _io::Write, 165 | E: _io::Error, 166 | F: FnMut(&mut _cli::writer::Writer<'_, W, E>) -> Result<(), E>, 167 | >( 168 | parent: &mut F, 169 | command: _cli::command::RawCommand<'_>, 170 | writer: &mut _cli::writer::Writer<'_, W, E>, 171 | ) -> Result<(), _cli::service::HelpError> { 172 | #(#command_help)*?; 173 | Ok(()) 174 | } 175 | } 176 | } 177 | } 178 | 179 | #[allow(unused_variables)] 180 | #[cfg(not(feature = "help"))] 181 | fn derive_help(target: &TargetType, groups: &[CommandGroup]) -> TokenStream { 182 | let ident = target.ident(); 183 | let named_lifetime = target.named_lifetime(); 184 | 185 | quote! { 186 | impl #named_lifetime _cli::service::Help for #ident #named_lifetime { } 187 | } 188 | } 189 | 190 | fn derive_from_raw(target: &TargetType, groups: &[CommandGroup]) -> TokenStream { 191 | let ident = target.ident(); 192 | let named_lifetime = target.named_lifetime(); 193 | 194 | let groups = groups 195 | .iter() 196 | .map(|group| { 197 | let ident = &group.ident; 198 | let ty = &group.field_type; 199 | quote! { 200 | match <#ty as _cli::service::FromRaw>::parse(raw.clone()) { 201 | Ok(cmd) => { 202 | return Ok(Self:: #ident (cmd)); 203 | } 204 | Err(_cli::service::ParseError::UnknownCommand) => {} 205 | Err(err) => return Err(err), 206 | } 207 | } 208 | }) 209 | .collect::>(); 210 | 211 | quote! { 212 | impl<'a> _cli::service::FromRaw<'a> for #ident #named_lifetime { 213 | fn parse(raw: _cli::command::RawCommand<'a>) -> Result> { 214 | #(#groups)* 215 | 216 | Err(_cli::service::ParseError::UnknownCommand) 217 | } 218 | } 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /embedded-cli-macros/src/lib.rs: -------------------------------------------------------------------------------- 1 | use proc_macro::TokenStream; 2 | use quote::quote; 3 | use syn::parse_macro_input; 4 | 5 | mod command; 6 | mod group; 7 | mod processor; 8 | mod utils; 9 | 10 | #[proc_macro_derive(Command, attributes(command, arg))] 11 | pub fn derive_command(input: TokenStream) -> TokenStream { 12 | let input = parse_macro_input!(input); 13 | 14 | let output = match command::derive_command(input) { 15 | Ok(output) => output, 16 | Err(e) => return e.write_errors().into(), 17 | }; 18 | 19 | // wrap with anonymous scope 20 | quote! { 21 | const _: () = { 22 | extern crate embedded_cli as _cli; 23 | use _cli::__private::io as _io; 24 | 25 | #output 26 | }; 27 | } 28 | .into() 29 | } 30 | 31 | #[proc_macro_derive(CommandGroup, attributes(group))] 32 | pub fn derive_command_group(input: TokenStream) -> TokenStream { 33 | let input = parse_macro_input!(input); 34 | 35 | let output = match group::derive_command_group(input) { 36 | Ok(output) => output, 37 | Err(e) => return e.write_errors().into(), 38 | }; 39 | 40 | // wrap with anonymous scope 41 | quote! { 42 | const _: () = { 43 | extern crate embedded_cli as _cli; 44 | use _cli::__private::io as _io; 45 | 46 | #output 47 | }; 48 | } 49 | .into() 50 | } 51 | -------------------------------------------------------------------------------- /embedded-cli-macros/src/processor.rs: -------------------------------------------------------------------------------- 1 | use darling::Result; 2 | use proc_macro2::TokenStream; 3 | use quote::quote; 4 | 5 | use crate::utils::TargetType; 6 | 7 | pub fn impl_processor(target: &TargetType) -> Result { 8 | let ident = target.ident(); 9 | let named_lifetime = target.named_lifetime(); 10 | let unnamed_lifetime = target.unnamed_lifetime(); 11 | 12 | let output = quote! { 13 | 14 | impl #named_lifetime #ident #named_lifetime { 15 | fn processor< 16 | W: _io::Write, 17 | E: _io::Error, 18 | F: FnMut(&mut _cli::cli::CliHandle<'_, W, E>, #ident #unnamed_lifetime) -> Result<(), E>, 19 | >( 20 | f: F, 21 | ) -> impl _cli::service::CommandProcessor { 22 | struct Processor< 23 | W: _io::Write, 24 | E: _io::Error, 25 | F: FnMut(&mut _cli::cli::CliHandle<'_, W, E>, #ident #unnamed_lifetime) -> Result<(), E>, 26 | > { 27 | f: F, 28 | _ph: core::marker::PhantomData<(W, E)>, 29 | } 30 | 31 | impl< 32 | W: _io::Write, 33 | E: _io::Error, 34 | F: FnMut(&mut _cli::cli::CliHandle<'_, W, E>, #ident #unnamed_lifetime) -> Result<(), E>, 35 | > _cli::service::CommandProcessor for Processor 36 | { 37 | fn process<'a>( 38 | &mut self, 39 | cli: &mut _cli::cli::CliHandle<'_, W, E>, 40 | raw: _cli::command::RawCommand<'a>, 41 | ) -> Result<(), _cli::service::ProcessError<'a, E>> { 42 | let cmd = <#ident #unnamed_lifetime as _cli::service::FromRaw>::parse(raw)?; 43 | (self.f)(cli, cmd)?; 44 | Ok(()) 45 | } 46 | } 47 | 48 | Processor { 49 | f, 50 | _ph: core::marker::PhantomData, 51 | } 52 | } 53 | } 54 | }; 55 | 56 | Ok(output) 57 | } 58 | -------------------------------------------------------------------------------- /embedded-cli-macros/src/utils.rs: -------------------------------------------------------------------------------- 1 | use syn::{Generics, PathArguments, Type, TypePath}; 2 | 3 | use darling::{usage::GenericsExt, Error, Result}; 4 | use proc_macro2::{Ident, TokenStream}; 5 | use quote::quote; 6 | 7 | pub struct TargetType { 8 | has_lifetime: bool, 9 | ident: Ident, 10 | } 11 | 12 | impl TargetType { 13 | pub fn parse(ident: Ident, generics: Generics) -> Result { 14 | if generics.declared_lifetimes().len() > 1 { 15 | let mut accum = Error::accumulator(); 16 | accum.extend(generics.lifetimes().skip(1).map(|param| { 17 | Error::custom( 18 | "More than one lifetime parameter specified. Try removing this lifetime param.", 19 | ) 20 | .with_span(param) 21 | })); 22 | accum.finish()?; 23 | } 24 | 25 | if !generics.declared_type_params().is_empty() { 26 | let mut accum = Error::accumulator(); 27 | accum.extend(generics.type_params().map(|param| { 28 | Error::custom( 29 | "Target type must not be generic over any type. Try removing thus type param.", 30 | ) 31 | .with_span(param) 32 | })); 33 | accum.finish()?; 34 | } 35 | 36 | let has_lifetime = !generics.declared_lifetimes().is_empty(); 37 | 38 | Ok(Self { 39 | has_lifetime, 40 | ident, 41 | }) 42 | } 43 | 44 | pub fn ident(&self) -> &Ident { 45 | &self.ident 46 | } 47 | 48 | pub fn named_lifetime(&self) -> TokenStream { 49 | if self.has_lifetime { 50 | quote! { 51 | <'a> 52 | } 53 | } else { 54 | quote! {} 55 | } 56 | } 57 | 58 | pub fn unnamed_lifetime(&self) -> TokenStream { 59 | if self.has_lifetime { 60 | quote! { 61 | <'_> 62 | } 63 | } else { 64 | quote! {} 65 | } 66 | } 67 | } 68 | 69 | pub fn extract_generic_type<'a>(ty: &'a Type, expected_container: &[&str]) -> Option<&'a Type> { 70 | //TODO: rewrite 71 | // If it is not `TypePath`, it is not possible to be `Option`, return `None` 72 | if let Type::Path(TypePath { qself: None, path }) = ty { 73 | // We have limited the 5 ways to write `Option`, and we can see that after `Option`, 74 | // there will be no `PathSegment` of the same level 75 | // Therefore, we only need to take out the highest level `PathSegment` and splice it into a string 76 | // for comparison with the analysis result 77 | let segments_str = &path 78 | .segments 79 | .iter() 80 | .map(|segment| segment.ident.to_string()) 81 | .collect::>() 82 | .join(":"); 83 | // Concatenate `PathSegment` into a string, compare and take out the `PathSegment` where `Option` is located 84 | let option_segment = expected_container 85 | .iter() 86 | .find(|s| segments_str == *s) 87 | .and_then(|_| path.segments.last()); 88 | let inner_type = option_segment 89 | // Take out the generic parameters of the `PathSegment` where `Option` is located 90 | // If it is not generic, it is not possible to be `Option`, return `None` 91 | // But this situation may not occur 92 | .and_then(|path_seg| match &path_seg.arguments { 93 | PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments { 94 | args, .. 95 | }) => args.first(), 96 | _ => None, 97 | }) 98 | // Take out the type information in the generic parameter 99 | // If it is not a type, it is not possible to be `Option`, return `None` 100 | // But this situation may not occur 101 | .and_then(|generic_arg| match generic_arg { 102 | syn::GenericArgument::Type(ty) => Some(ty), 103 | _ => None, 104 | }); 105 | // Return `T` in `Option` 106 | return inner_type; 107 | } 108 | None 109 | } 110 | -------------------------------------------------------------------------------- /embedded-cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "embedded-cli" 3 | version = "0.2.1" 4 | authors = ["Sviatoslav Kokurin "] 5 | description = """ 6 | CLI with autocompletion, help and history for embedded systems (like Arduino or STM32) 7 | """ 8 | repository = "https://github.com/funbiscuit/embedded-cli-rs" 9 | readme = "../README.md" 10 | keywords = ["no_std", "cli", "embedded"] 11 | license = "MIT OR Apache-2.0" 12 | categories = ["command-line-interface", "embedded", "no-std"] 13 | edition = "2021" 14 | 15 | [features] 16 | default = ["macros", "autocomplete", "help", "history"] 17 | 18 | macros = ["embedded-cli-macros"] 19 | autocomplete = ["embedded-cli-macros/autocomplete"] 20 | help = ["embedded-cli-macros/help"] 21 | history = [] 22 | 23 | [dependencies] 24 | embedded-cli-macros = { version = "0.2.1", path = "../embedded-cli-macros", optional = true } 25 | 26 | bitflags = "2.5.0" 27 | embedded-io = "0.6.1" 28 | ufmt = "0.2.0" 29 | 30 | [dev-dependencies] 31 | regex = "1.10.4" 32 | rstest = "0.19.0" 33 | -------------------------------------------------------------------------------- /embedded-cli/src/arguments.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | token::{Tokens, TokensIter}, 3 | utils, 4 | }; 5 | 6 | #[derive(Debug, Eq, PartialEq)] 7 | pub enum Arg<'a> { 8 | /// Used to represent `--`. 9 | /// After double dash all other args 10 | /// will always be `ArgToken::Value` 11 | DoubleDash, 12 | 13 | /// Long option. Only name is stored (without `--`) 14 | /// 15 | /// In `get --config normal -f file -vs` 16 | /// `--config` will be a long option with name `config` 17 | LongOption(&'a str), 18 | 19 | /// Short option. Only single UTF-8 char is stored (without `-`). 20 | /// 21 | /// In `get --config normal -f file -vs` 22 | /// `-f` and `-vs` will be short options. 23 | /// `v` and `s` are treated as written separately (as '-v -s`) 24 | ShortOption(char), 25 | 26 | /// Value of an option or an argument. 27 | /// 28 | /// In `get --config normal -v file` 29 | /// `normal` and `file` will be a value 30 | Value(&'a str), 31 | } 32 | 33 | #[derive(Clone, Debug, Eq)] 34 | pub struct ArgList<'a> { 35 | tokens: Tokens<'a>, 36 | } 37 | 38 | impl<'a> ArgList<'a> { 39 | /// Create new arg list from given tokens 40 | pub fn new(tokens: Tokens<'a>) -> Self { 41 | Self { tokens } 42 | } 43 | 44 | pub fn args(&self) -> ArgsIter<'a> { 45 | ArgsIter::new(self.tokens.iter()) 46 | } 47 | } 48 | 49 | impl PartialEq for ArgList<'_> { 50 | fn eq(&self, other: &Self) -> bool { 51 | self.args().eq(other.args()) 52 | } 53 | } 54 | 55 | #[derive(Debug)] 56 | pub struct ArgsIter<'a> { 57 | values_only: bool, 58 | 59 | /// Short options (utf8 chars) that 60 | /// are left from previous iteration 61 | leftover: &'a str, 62 | 63 | tokens: TokensIter<'a>, 64 | } 65 | 66 | impl<'a> ArgsIter<'a> { 67 | fn new(tokens: TokensIter<'a>) -> Self { 68 | Self { 69 | values_only: false, 70 | leftover: "", 71 | tokens, 72 | } 73 | } 74 | 75 | /// Converts whats left in this iterator back to `ArgList` 76 | /// 77 | /// If iterator was in the middle of iterating of collapsed 78 | /// short options (like `-vhs`), non iterated options are discarded 79 | pub fn into_args(self) -> ArgList<'a> { 80 | ArgList::new(self.tokens.into_tokens()) 81 | } 82 | } 83 | 84 | impl<'a> Iterator for ArgsIter<'a> { 85 | type Item = Arg<'a>; 86 | 87 | fn next(&mut self) -> Option { 88 | if let Some((opt, leftover)) = utils::char_pop_front(self.leftover) { 89 | self.leftover = leftover; 90 | return Some(Arg::ShortOption(opt)); 91 | } 92 | 93 | let raw = self.tokens.next()?; 94 | let bytes = raw.as_bytes(); 95 | 96 | if self.values_only { 97 | return Some(Arg::Value(raw)); 98 | } 99 | 100 | let token = if bytes.len() > 1 && bytes[0] == b'-' { 101 | if bytes[1] == b'-' { 102 | if bytes.len() == 2 { 103 | self.values_only = true; 104 | Arg::DoubleDash 105 | } else { 106 | Arg::LongOption(unsafe { raw.get_unchecked(2..) }) 107 | } 108 | } else { 109 | let (opt, leftover) = 110 | unsafe { utils::char_pop_front(raw.get_unchecked(1..)).unwrap_unchecked() }; 111 | self.leftover = leftover; 112 | 113 | return Some(Arg::ShortOption(opt)); 114 | } 115 | } else { 116 | Arg::Value(raw) 117 | }; 118 | 119 | Some(token) 120 | } 121 | } 122 | 123 | #[derive(Debug)] 124 | pub struct FromArgumentError<'a> { 125 | pub value: &'a str, 126 | pub expected: &'static str, 127 | } 128 | 129 | pub trait FromArgument<'a> { 130 | fn from_arg(arg: &'a str) -> Result> 131 | where 132 | Self: Sized; 133 | } 134 | 135 | impl<'a> FromArgument<'a> for &'a str { 136 | fn from_arg(arg: &'a str) -> Result> { 137 | Ok(arg) 138 | } 139 | } 140 | 141 | macro_rules! impl_arg_fromstr { 142 | ($id:ident) => ( 143 | impl<'a> FromArgument<'a> for $id { 144 | fn from_arg(arg: &'a str) -> Result> { 145 | arg.parse().map_err(|_| FromArgumentError { 146 | value: arg, 147 | expected: stringify!($id), 148 | }) 149 | } 150 | } 151 | ); 152 | 153 | ($id:ident, $($ids:ident),+) => ( 154 | impl_arg_fromstr!{$id} 155 | impl_arg_fromstr!{$($ids),+} 156 | ) 157 | } 158 | 159 | impl_arg_fromstr! {char, bool, u8, i8, u16, i16, u32, i32, u64, i64, u128, i128, usize, isize, f32, f64} 160 | 161 | #[cfg(test)] 162 | mod tests { 163 | use rstest::rstest; 164 | 165 | use crate::{arguments::ArgList, token::Tokens}; 166 | 167 | use super::Arg; 168 | 169 | #[rstest] 170 | #[case("arg1 --option1 val1 -f val2 -vs", &[ 171 | Arg::Value("arg1"), 172 | Arg::LongOption("option1"), 173 | Arg::Value("val1"), 174 | Arg::ShortOption('f'), 175 | Arg::Value("val2"), 176 | Arg::ShortOption('v'), 177 | Arg::ShortOption('s'), 178 | ])] 179 | #[case("arg1 --option1 -- val1 -f val2 -vs", &[ 180 | Arg::Value("arg1"), 181 | Arg::LongOption("option1"), 182 | Arg::DoubleDash, 183 | Arg::Value("val1"), 184 | Arg::Value("-f"), 185 | Arg::Value("val2"), 186 | Arg::Value("-vs"), 187 | ])] 188 | #[case("arg1 -бj佗𑿌", &[ 189 | Arg::Value("arg1"), 190 | Arg::ShortOption('б'), 191 | Arg::ShortOption('j'), 192 | Arg::ShortOption('佗'), 193 | Arg::ShortOption('𑿌'), 194 | ])] 195 | fn arg_tokens(#[case] input: &str, #[case] expected: &[Arg<'_>]) { 196 | let mut input = input.as_bytes().to_vec(); 197 | let input = core::str::from_utf8_mut(&mut input).unwrap(); 198 | let tokens = Tokens::new(input); 199 | let args = ArgList::new(tokens); 200 | let mut iter = args.args(); 201 | 202 | for arg in expected { 203 | let actual = iter.next().unwrap(); 204 | assert_eq!(&actual, arg); 205 | } 206 | assert_eq!(iter.next(), None); 207 | assert_eq!(iter.next(), None); 208 | } 209 | 210 | #[test] 211 | fn test_eq() { 212 | let mut input = b"arg1 arg2".to_vec(); 213 | let input = core::str::from_utf8_mut(&mut input).unwrap(); 214 | let tokens = Tokens::new(input); 215 | let args1 = ArgList::new(tokens); 216 | 217 | let mut input = b" arg1 arg2 ".to_vec(); 218 | let input = core::str::from_utf8_mut(&mut input).unwrap(); 219 | let tokens = Tokens::new(input); 220 | let args2 = ArgList::new(tokens); 221 | 222 | assert_eq!(args1, args2) 223 | } 224 | } 225 | -------------------------------------------------------------------------------- /embedded-cli/src/autocomplete.rs: -------------------------------------------------------------------------------- 1 | use crate::utils; 2 | 3 | #[derive(Clone, Debug)] 4 | #[non_exhaustive] 5 | pub enum Request<'a> { 6 | /// Request to autocomplete given text to command name 7 | CommandName(&'a str), 8 | } 9 | 10 | impl<'a> Request<'a> { 11 | pub fn from_input(input: &'a str) -> Option { 12 | let input = utils::trim_start(input); 13 | 14 | if input.is_empty() { 15 | return None; 16 | } 17 | 18 | // if no space given, then only command name is entered so we complete it 19 | if !input.contains(' ') { 20 | Some(Request::CommandName(input)) 21 | } else { 22 | None 23 | } 24 | } 25 | } 26 | 27 | #[derive(Debug)] 28 | pub struct Autocompletion<'a> { 29 | autocompleted: Option, 30 | buffer: &'a mut [u8], 31 | partial: bool, 32 | } 33 | 34 | impl<'a> Autocompletion<'a> { 35 | pub fn new(buffer: &'a mut [u8]) -> Self { 36 | Self { 37 | autocompleted: None, 38 | buffer, 39 | partial: false, 40 | } 41 | } 42 | 43 | pub fn autocompleted(&self) -> Option<&str> { 44 | self.autocompleted.map(|len| { 45 | // SAFETY: we store only &str in this buffer, so it is a valid utf-8 sequence 46 | unsafe { core::str::from_utf8_unchecked(&self.buffer[..len]) } 47 | }) 48 | } 49 | 50 | /// Whether autocompletion is partial 51 | /// and further input is required 52 | pub fn is_partial(&self) -> bool { 53 | self.partial 54 | } 55 | 56 | /// Mark this autocompletion as partial 57 | pub fn mark_partial(&mut self) { 58 | self.partial = true; 59 | } 60 | 61 | /// Merge this autocompletion with another one 62 | pub fn merge_autocompletion(&mut self, autocompletion: &str) { 63 | if autocompletion.is_empty() || self.buffer.is_empty() { 64 | self.partial = self.partial 65 | || self.autocompleted.is_some() 66 | || (self.buffer.is_empty() && !autocompletion.is_empty()); 67 | self.autocompleted = Some(0); 68 | return; 69 | } 70 | 71 | // compare new autocompletion to existing and keep 72 | // only common prefix 73 | let len = match self.autocompleted() { 74 | Some(current) => utils::common_prefix_len(autocompletion, current), 75 | None => autocompletion.len(), 76 | }; 77 | 78 | if len > self.buffer.len() { 79 | // if buffer is full with this autocompletion, there is not much sense in doing it 80 | // since user will not be able to type anything else 81 | // so just do nothing with it 82 | } else { 83 | self.partial = 84 | self.partial || len < autocompletion.len() || self.autocompleted.is_some(); 85 | // SAFETY: we checked that len is no longer than buffer len (and is at most autocompleted len) 86 | // and these two buffers do not overlap since mutable reference to buffer is exclusive 87 | unsafe { 88 | utils::copy_nonoverlapping(autocompletion.as_bytes(), self.buffer, len); 89 | } 90 | self.autocompleted = Some(len); 91 | }; 92 | } 93 | } 94 | 95 | #[cfg(test)] 96 | mod tests { 97 | use rstest::rstest; 98 | 99 | use crate::autocomplete::Autocompletion; 100 | 101 | #[test] 102 | fn no_merge() { 103 | let mut input = [0; 64]; 104 | 105 | let autocompletion = Autocompletion::new(&mut input); 106 | 107 | assert!(!autocompletion.is_partial()); 108 | assert_eq!(autocompletion.autocompleted(), None); 109 | } 110 | 111 | #[rstest] 112 | #[case("abc", "abc")] 113 | #[case("", "")] 114 | fn merge_single(#[case] text: &str, #[case] expected: &str) { 115 | let mut input = [0; 64]; 116 | 117 | let mut autocompletion = Autocompletion::new(&mut input); 118 | 119 | autocompletion.merge_autocompletion(text); 120 | 121 | assert!(!autocompletion.is_partial()); 122 | assert_eq!(autocompletion.autocompleted(), Some(expected)); 123 | assert_eq!(&input[..expected.len()], expected.as_bytes()); 124 | } 125 | 126 | #[rstest] 127 | #[case("abc1", "abc2", "abc")] 128 | #[case("ab", "abc", "ab")] 129 | #[case("abc", "ab", "ab")] 130 | #[case("", "ab", "")] 131 | #[case("ab", "", "")] 132 | #[case("abc", "def", "")] 133 | fn merge_multiple(#[case] text1: &str, #[case] text2: &str, #[case] expected: &str) { 134 | let mut input = [0; 64]; 135 | 136 | let mut autocompletion = Autocompletion::new(&mut input); 137 | 138 | autocompletion.merge_autocompletion(text1); 139 | autocompletion.merge_autocompletion(text2); 140 | 141 | assert!(autocompletion.is_partial()); 142 | assert_eq!(autocompletion.autocompleted(), Some(expected)); 143 | assert_eq!(&input[..expected.len()], expected.as_bytes()); 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /embedded-cli/src/buffer.rs: -------------------------------------------------------------------------------- 1 | pub trait Buffer { 2 | fn as_slice(&self) -> &[u8]; 3 | 4 | fn as_slice_mut(&mut self) -> &mut [u8]; 5 | 6 | #[allow(unused_variables)] 7 | fn grow(&mut self, new_size: usize) { 8 | // noop, can't grow 9 | } 10 | 11 | fn is_empty(&self) -> bool { 12 | self.as_slice().is_empty() 13 | } 14 | 15 | fn len(&self) -> usize { 16 | self.as_slice().len() 17 | } 18 | } 19 | 20 | impl Buffer for [u8; SIZE] { 21 | fn as_slice(&self) -> &[u8] { 22 | self 23 | } 24 | 25 | fn as_slice_mut(&mut self) -> &mut [u8] { 26 | self 27 | } 28 | } 29 | 30 | impl Buffer for &mut [u8] { 31 | fn as_slice(&self) -> &[u8] { 32 | self 33 | } 34 | 35 | fn as_slice_mut(&mut self) -> &mut [u8] { 36 | self 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /embedded-cli/src/builder.rs: -------------------------------------------------------------------------------- 1 | use core::{convert::Infallible, fmt::Debug}; 2 | 3 | use embedded_io::{Error, Write}; 4 | 5 | use crate::{buffer::Buffer, cli::Cli, writer::EmptyWriter}; 6 | 7 | pub const DEFAULT_CMD_LEN: usize = 40; 8 | pub const DEFAULT_HISTORY_LEN: usize = 100; 9 | pub const DEFAULT_PROMPT: &str = "$ "; 10 | 11 | pub struct CliBuilder, E: Error, CommandBuffer: Buffer, HistoryBuffer: Buffer> { 12 | pub(crate) command_buffer: CommandBuffer, 13 | pub(crate) history_buffer: HistoryBuffer, 14 | pub(crate) prompt: &'static str, 15 | pub(crate) writer: W, 16 | } 17 | 18 | impl Debug for CliBuilder 19 | where 20 | W: Write, 21 | E: Error, 22 | CommandBuffer: Buffer, 23 | HistoryBuffer: Buffer, 24 | { 25 | fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 26 | f.debug_struct("CliBuilder") 27 | .field("command_buffer", &self.command_buffer.as_slice()) 28 | .field("history_buffer", &self.history_buffer.as_slice()) 29 | .finish() 30 | } 31 | } 32 | 33 | impl CliBuilder 34 | where 35 | W: Write, 36 | E: Error, 37 | CommandBuffer: Buffer, 38 | HistoryBuffer: Buffer, 39 | { 40 | pub fn build(self) -> Result, E> { 41 | Cli::from_builder(self) 42 | } 43 | 44 | pub fn command_buffer( 45 | self, 46 | command_buffer: B, 47 | ) -> CliBuilder { 48 | CliBuilder { 49 | command_buffer, 50 | history_buffer: self.history_buffer, 51 | writer: self.writer, 52 | prompt: self.prompt, 53 | } 54 | } 55 | 56 | pub fn history_buffer( 57 | self, 58 | history_buffer: B, 59 | ) -> CliBuilder { 60 | CliBuilder { 61 | command_buffer: self.command_buffer, 62 | history_buffer, 63 | writer: self.writer, 64 | prompt: self.prompt, 65 | } 66 | } 67 | 68 | pub fn prompt(self, prompt: &'static str) -> Self { 69 | CliBuilder { 70 | command_buffer: self.command_buffer, 71 | history_buffer: self.history_buffer, 72 | writer: self.writer, 73 | prompt, 74 | } 75 | } 76 | 77 | pub fn writer, TE: Error>( 78 | self, 79 | writer: T, 80 | ) -> CliBuilder { 81 | CliBuilder { 82 | command_buffer: self.command_buffer, 83 | history_buffer: self.history_buffer, 84 | writer, 85 | prompt: self.prompt, 86 | } 87 | } 88 | } 89 | 90 | impl Default 91 | for CliBuilder 92 | { 93 | fn default() -> Self { 94 | Self { 95 | command_buffer: [0; DEFAULT_CMD_LEN], 96 | history_buffer: [0; DEFAULT_HISTORY_LEN], 97 | writer: EmptyWriter, 98 | prompt: DEFAULT_PROMPT, 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /embedded-cli/src/codes.rs: -------------------------------------------------------------------------------- 1 | pub const BACKSPACE: u8 = 0x08; 2 | pub const TABULATION: u8 = 0x09; 3 | pub const LINE_FEED: u8 = 0x0A; 4 | pub const CARRIAGE_RETURN: u8 = 0x0D; 5 | pub const ESCAPE: u8 = 0x1B; 6 | 7 | pub const CRLF: &str = "\r\n"; 8 | 9 | // escape sequence reference: https://ecma-international.org/publications-and-standards/standards/ecma-48 10 | pub const CURSOR_FORWARD: &[u8] = b"\x1B[C"; 11 | pub const CURSOR_BACKWARD: &[u8] = b"\x1B[D"; 12 | pub const CLEAR_LINE: &[u8] = b"\x1B[2K"; 13 | pub const INSERT_CHAR: &[u8] = b"\x1B[@"; 14 | pub const DELETE_CHAR: &[u8] = b"\x1B[P"; 15 | -------------------------------------------------------------------------------- /embedded-cli/src/command.rs: -------------------------------------------------------------------------------- 1 | use core::marker::PhantomData; 2 | 3 | use embedded_io::Write; 4 | 5 | use crate::{ 6 | arguments::ArgList, 7 | cli::CliHandle, 8 | service::{Autocomplete, CommandProcessor, FromRaw, Help, ParseError, ProcessError}, 9 | token::Tokens, 10 | }; 11 | 12 | #[cfg(feature = "autocomplete")] 13 | use crate::autocomplete::{Autocompletion, Request}; 14 | 15 | #[cfg(feature = "help")] 16 | use crate::service::HelpError; 17 | 18 | #[derive(Clone, Debug, Eq, PartialEq)] 19 | pub struct RawCommand<'a> { 20 | /// Name of the command. 21 | /// 22 | /// In `set led 1 1` name is `set` 23 | name: &'a str, 24 | 25 | /// Argument list of the command 26 | /// 27 | /// In `set led 1 1` arguments is `led 1 1` 28 | args: ArgList<'a>, 29 | } 30 | 31 | impl<'a> RawCommand<'a> { 32 | /// Crate raw command from input tokens 33 | pub(crate) fn from_tokens(tokens: &Tokens<'a>) -> Option { 34 | let mut iter = tokens.iter(); 35 | let name = iter.next()?; 36 | let tokens = iter.into_tokens(); 37 | 38 | Some(RawCommand { 39 | name, 40 | args: ArgList::new(tokens), 41 | }) 42 | } 43 | 44 | pub fn new(name: &'a str, args: ArgList<'a>) -> Self { 45 | Self { name, args } 46 | } 47 | 48 | pub fn args(&self) -> ArgList<'a> { 49 | self.args.clone() 50 | } 51 | 52 | pub fn name(&self) -> &'a str { 53 | self.name 54 | } 55 | 56 | pub fn processor< 57 | W: Write, 58 | E: embedded_io::Error, 59 | F: FnMut(&mut CliHandle<'_, W, E>, RawCommand<'_>) -> Result<(), E>, 60 | >( 61 | f: F, 62 | ) -> impl CommandProcessor { 63 | struct Processor< 64 | W: Write, 65 | E: embedded_io::Error, 66 | F: FnMut(&mut CliHandle<'_, W, E>, RawCommand<'_>) -> Result<(), E>, 67 | > { 68 | f: F, 69 | _ph: PhantomData<(W, E)>, 70 | } 71 | 72 | impl< 73 | W: Write, 74 | E: embedded_io::Error, 75 | F: FnMut(&mut CliHandle<'_, W, E>, RawCommand<'_>) -> Result<(), E>, 76 | > CommandProcessor for Processor 77 | { 78 | fn process<'a>( 79 | &mut self, 80 | cli: &mut CliHandle<'_, W, E>, 81 | raw: RawCommand<'a>, 82 | ) -> Result<(), ProcessError<'a, E>> { 83 | (self.f)(cli, raw)?; 84 | Ok(()) 85 | } 86 | } 87 | 88 | Processor { 89 | f, 90 | _ph: PhantomData, 91 | } 92 | } 93 | } 94 | 95 | impl Autocomplete for RawCommand<'_> { 96 | #[cfg(feature = "autocomplete")] 97 | fn autocomplete(_: Request<'_>, _: &mut Autocompletion<'_>) { 98 | // noop 99 | } 100 | } 101 | 102 | impl Help for RawCommand<'_> { 103 | #[cfg(feature = "help")] 104 | fn command_count() -> usize { 105 | 0 106 | } 107 | 108 | #[cfg(feature = "help")] 109 | fn list_commands, E: embedded_io::Error>( 110 | _: &mut crate::writer::Writer<'_, W, E>, 111 | ) -> Result<(), E> { 112 | // noop 113 | Ok(()) 114 | } 115 | 116 | #[cfg(feature = "help")] 117 | fn command_help< 118 | W: Write, 119 | E: embedded_io::Error, 120 | F: FnMut(&mut crate::writer::Writer<'_, W, E>) -> Result<(), E>, 121 | >( 122 | _: &mut F, 123 | _: RawCommand<'_>, 124 | _: &mut crate::writer::Writer<'_, W, E>, 125 | ) -> Result<(), HelpError> { 126 | // noop 127 | Err(HelpError::UnknownCommand) 128 | } 129 | } 130 | 131 | impl<'a> FromRaw<'a> for RawCommand<'a> { 132 | fn parse(raw: RawCommand<'a>) -> Result> { 133 | Ok(raw) 134 | } 135 | } 136 | 137 | #[cfg(test)] 138 | mod tests { 139 | use rstest::rstest; 140 | 141 | use crate::{arguments::ArgList, command::RawCommand, token::Tokens}; 142 | 143 | #[rstest] 144 | #[case("set led 1", "set", "led 1")] 145 | #[case(" get led 2 ", "get", "led 2")] 146 | #[case("get", "get", "")] 147 | #[case("set led 1", "set", "led 1")] 148 | fn parsing_some(#[case] input: &str, #[case] name: &str, #[case] args: &str) { 149 | let mut input = input.as_bytes().to_vec(); 150 | let input = core::str::from_utf8_mut(&mut input).unwrap(); 151 | let input_tokens = Tokens::new(input); 152 | let mut args = args.as_bytes().to_vec(); 153 | let args = core::str::from_utf8_mut(&mut args).unwrap(); 154 | let arg_tokens = Tokens::new(args); 155 | 156 | assert_eq!( 157 | RawCommand::from_tokens(&input_tokens).unwrap(), 158 | RawCommand { 159 | name, 160 | args: ArgList::new(arg_tokens) 161 | } 162 | ); 163 | } 164 | 165 | #[rstest] 166 | #[case(" ")] 167 | #[case("")] 168 | fn parsing_none(#[case] input: &str) { 169 | let mut input = input.as_bytes().to_vec(); 170 | let input = core::str::from_utf8_mut(&mut input).unwrap(); 171 | let tokens = Tokens::new(input); 172 | 173 | assert!(RawCommand::from_tokens(&tokens).is_none()); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /embedded-cli/src/help.rs: -------------------------------------------------------------------------------- 1 | use crate::{arguments::Arg, command::RawCommand}; 2 | 3 | #[derive(Clone, Debug, Eq, PartialEq)] 4 | pub enum HelpRequest<'a> { 5 | /// Show list of all available commands 6 | All, 7 | 8 | /// Show help for specific command with arguments 9 | /// One of command arguments might be -h or --help 10 | Command(RawCommand<'a>), 11 | } 12 | 13 | impl<'a> HelpRequest<'a> { 14 | /// Tries to create new help request from raw command 15 | pub fn from_command(command: &RawCommand<'a>) -> Option { 16 | let mut args = command.args().args(); 17 | if command.name() == "help" { 18 | match args.next() { 19 | Some(Arg::Value(name)) => { 20 | let command = RawCommand::new(name, args.into_args()); 21 | Some(HelpRequest::Command(command)) 22 | } 23 | None => Some(HelpRequest::All), 24 | _ => None, 25 | } 26 | } 27 | // check if any other option is -h or --help 28 | else if args.any(|arg| arg == Arg::LongOption("help") || arg == Arg::ShortOption('h')) { 29 | Some(HelpRequest::Command(command.clone())) 30 | } else { 31 | None 32 | } 33 | } 34 | } 35 | 36 | #[cfg(test)] 37 | mod tests { 38 | use rstest::rstest; 39 | 40 | use crate::{arguments::ArgList, command::RawCommand, token::Tokens}; 41 | 42 | use super::HelpRequest; 43 | 44 | fn help_command(name: &'static str, args: &'static str) -> HelpRequest<'static> { 45 | HelpRequest::Command(RawCommand::new( 46 | name, 47 | ArgList::new(Tokens::from_raw(args, args.is_empty())), 48 | )) 49 | } 50 | 51 | #[rstest] 52 | #[case("help", HelpRequest::All)] 53 | #[case("help cmd1", help_command("cmd1", ""))] 54 | #[case("cmd2 --help", help_command("cmd2", "--help"))] 55 | #[case( 56 | "cmd3 -v --opt --help --some", 57 | help_command("cmd3", "-v\0--opt\0--help\0--some") 58 | )] 59 | #[case("cmd3 -vh --opt --some", help_command("cmd3", "-vh\0--opt\0--some"))] 60 | #[case("cmd3 -hv --opt --some", help_command("cmd3", "-hv\0--opt\0--some"))] 61 | fn parsing_ok(#[case] input: &str, #[case] expected: HelpRequest<'_>) { 62 | let mut input = input.as_bytes().to_vec(); 63 | let input = core::str::from_utf8_mut(&mut input).unwrap(); 64 | let tokens = Tokens::new(input); 65 | let command = RawCommand::from_tokens(&tokens).unwrap(); 66 | 67 | assert_eq!(HelpRequest::from_command(&command), Some(expected)); 68 | } 69 | 70 | #[rstest] 71 | #[case("cmd1")] 72 | #[case("cmd1 help")] 73 | #[case("--help")] 74 | fn parsing_err(#[case] input: &str) { 75 | let mut input = input.as_bytes().to_vec(); 76 | let input = core::str::from_utf8_mut(&mut input).unwrap(); 77 | let tokens = Tokens::new(input); 78 | let command = RawCommand::from_tokens(&tokens).unwrap(); 79 | let res = HelpRequest::from_command(&command); 80 | 81 | assert!(res.is_none()); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /embedded-cli/src/history.rs: -------------------------------------------------------------------------------- 1 | use crate::{buffer::Buffer, utils}; 2 | 3 | #[derive(Debug)] 4 | pub struct History { 5 | /// Buffer that stores element bytes. 6 | /// Elements are stored null separated, thus no null 7 | /// bytes are allowed in elements themselves 8 | /// Newer elements are placed to the right of previous element 9 | buffer: B, 10 | 11 | /// Index of first byte of currently selected element 12 | cursor: Option, 13 | 14 | /// How many bytes of buffer are already used by elements 15 | used: usize, 16 | } 17 | 18 | impl History { 19 | pub fn new(buffer: B) -> Self { 20 | Self { 21 | buffer, 22 | cursor: None, 23 | used: 0, 24 | } 25 | } 26 | 27 | /// Return next element from history, that is newer, than currently selected. 28 | /// Return None if there is no newer elements 29 | pub fn next_newer(&mut self) -> Option<&str> { 30 | match self.cursor { 31 | Some(cursor) => { 32 | let new_cursor = self.buffer.as_slice()[cursor..self.used - 1] 33 | .iter() 34 | .position(|b| b == &0) 35 | .map(|pos| cursor + pos + 1); 36 | // null byte of last element was not included 37 | // so if we found 0, it means there is at least 38 | // one more element after current 39 | if let Some(new_cursor) = new_cursor { 40 | // new_cursor is pointing to first byte of next element 41 | let new_len = self.buffer.as_slice()[new_cursor..] 42 | .iter() 43 | .position(|b| b == &0); 44 | 45 | // SAFETY: All elements are null terminated, including last element 46 | let element_end = unsafe { new_cursor + new_len.unwrap_unchecked() }; 47 | 48 | let element = unsafe { 49 | core::str::from_utf8_unchecked( 50 | &self.buffer.as_slice()[new_cursor..element_end], 51 | ) 52 | }; 53 | self.cursor = Some(new_cursor); 54 | Some(element) 55 | } else { 56 | self.cursor = None; 57 | None 58 | } 59 | } 60 | _ => None, 61 | } 62 | } 63 | 64 | /// Return next element from history, that is older, than currently selected. 65 | /// Return None if there is no older elements 66 | pub fn next_older(&mut self) -> Option<&str> { 67 | let cursor = match self.cursor { 68 | Some(cursor) if cursor > 0 => cursor, 69 | None if self.used > 0 => self.used, 70 | _ => return None, 71 | }; 72 | 73 | let new_cursor = self.buffer.as_slice()[..cursor - 1] 74 | .iter() 75 | .rev() 76 | .position(|b| b == &0) 77 | .map(|pos| cursor - 1 - pos) 78 | .unwrap_or(0); 79 | let element = unsafe { 80 | core::str::from_utf8_unchecked(&self.buffer.as_slice()[new_cursor..cursor - 1]) 81 | }; 82 | self.cursor = Some(new_cursor); 83 | Some(element) 84 | } 85 | 86 | /// Push given text to history. Text must not contain any null bytes. Otherwise 87 | /// text is not pushed to history and just ignored. 88 | pub fn push(&mut self, text: &str) { 89 | // extra byte is added to text len since we need to null terminate it 90 | if text.as_bytes().contains(&0) || text.len() + 1 > self.buffer.len() || text.is_empty() { 91 | return; 92 | } 93 | 94 | self.cursor = None; 95 | 96 | // check if duplicate is given, then we should remove it first 97 | // this is a bit slower than manually comparing all bytes, but easier to write 98 | match self.next_older() { 99 | Some(existing) if existing == text => { 100 | // element already is added and is newest among others 101 | // so we have nothing to do 102 | self.cursor = None; 103 | return; 104 | } 105 | _ => {} 106 | } 107 | 108 | while let Some(existing) = self.next_older() { 109 | if existing == text { 110 | // SAFETY: if next_older() returned Some, then 111 | // cursor is also Some and points to returned element 112 | let removing_start = unsafe { self.cursor.unwrap_unchecked() }; 113 | let removing_end = removing_start + text.len() + 1; 114 | 115 | self.buffer 116 | .as_slice_mut() 117 | .copy_within(removing_end..self.used, removing_start); 118 | self.used -= text.len() + 1; 119 | break; 120 | } 121 | } 122 | self.cursor = None; 123 | 124 | // remove old commands to free space if its not enough 125 | if self.buffer.len() < self.used + text.len() + 1 { 126 | // self.used is at least 2 bytes (1 for element and 1 for null terminator) 127 | // how many bytes we should free, this is at least 1 byte 128 | let required = self.used + text.len() + 1 - self.buffer.len(); 129 | if required >= self.used { 130 | self.used = 0; 131 | } else { 132 | // how many bytes we are removing, so whole command is removed 133 | // SAFETY: Last used byte is always 0 134 | let removing = unsafe { 135 | required 136 | + self.buffer.as_slice()[required - 1..self.used] 137 | .iter() 138 | .position(|b| b == &0) 139 | .unwrap_unchecked() 140 | }; 141 | 142 | if removing < self.used { 143 | self.buffer 144 | .as_slice_mut() 145 | .copy_within(removing..self.used, 0); 146 | self.used -= removing; 147 | } else { 148 | self.used = 0; 149 | } 150 | } 151 | } 152 | 153 | // now we have enough space after self.used to insert element 154 | let null_pos = self.used + text.len(); 155 | // SAFETY: we ensured that buffer contains len + 1 bytes after self.used position 156 | // and two buffers do not overlap since mutable reference to buffer is exclusive 157 | unsafe { 158 | utils::copy_nonoverlapping( 159 | text.as_bytes(), 160 | &mut self.buffer.as_slice_mut()[self.used..], 161 | text.len(), 162 | ); 163 | } 164 | self.buffer.as_slice_mut()[null_pos] = 0; 165 | self.used += text.len() + 1; 166 | } 167 | } 168 | 169 | #[cfg(test)] 170 | mod tests { 171 | use crate::history::History; 172 | 173 | #[test] 174 | fn empty() { 175 | let mut history = History::new([0; 64]); 176 | 177 | assert_eq!(history.next_newer(), None); 178 | assert_eq!(history.next_older(), None); 179 | } 180 | 181 | #[test] 182 | fn text_with_nulls() { 183 | let mut history = History::new([0; 64]); 184 | 185 | history.push("ab\0c"); 186 | 187 | assert_eq!(history.next_newer(), None); 188 | assert_eq!(history.next_older(), None); 189 | } 190 | 191 | #[test] 192 | fn navigation() { 193 | let mut history = History::new([0; 32]); 194 | 195 | history.push("abc"); 196 | history.push("def"); 197 | history.push("ghi"); 198 | 199 | assert_eq!(history.next_newer(), None); 200 | assert_eq!(history.next_older(), Some("ghi")); 201 | assert_eq!(history.next_older(), Some("def")); 202 | assert_eq!(history.next_older(), Some("abc")); 203 | assert_eq!(history.next_older(), None); 204 | assert_eq!(history.next_newer(), Some("def")); 205 | assert_eq!(history.next_newer(), Some("ghi")); 206 | assert_eq!(history.next_newer(), None); 207 | assert_eq!(history.next_older(), Some("ghi")); 208 | assert_eq!(history.next_older(), Some("def")); 209 | 210 | history.push("jkl"); 211 | 212 | assert_eq!(history.next_newer(), None); 213 | assert_eq!(history.next_older(), Some("jkl")); 214 | assert_eq!(history.next_older(), Some("ghi")); 215 | assert_eq!(history.next_older(), Some("def")); 216 | 217 | history.push("ghi"); 218 | 219 | assert_eq!(history.next_older(), Some("ghi")); 220 | assert_eq!(history.next_older(), Some("jkl")); 221 | assert_eq!(history.next_older(), Some("def")); 222 | assert_eq!(history.next_older(), Some("abc")); 223 | assert_eq!(history.next_older(), None); 224 | } 225 | 226 | #[test] 227 | fn overflow_small() { 228 | let mut history = History::new([0; 12]); 229 | 230 | history.push("abc"); 231 | history.push("def"); 232 | history.push("ghi"); 233 | history.push("jkl"); 234 | 235 | assert_eq!(history.next_newer(), None); 236 | assert_eq!(history.next_older(), Some("jkl")); 237 | assert_eq!(history.next_older(), Some("ghi")); 238 | assert_eq!(history.next_older(), Some("def")); 239 | assert_eq!(history.next_older(), None); 240 | } 241 | 242 | #[test] 243 | fn overflow_big() { 244 | let mut history = History::new([0; 10]); 245 | 246 | history.push("abc"); 247 | history.push("def"); 248 | history.push("ghijklm"); 249 | 250 | assert_eq!(history.next_newer(), None); 251 | assert_eq!(history.next_older(), Some("ghijklm")); 252 | assert_eq!(history.next_older(), None); 253 | } 254 | 255 | #[test] 256 | fn duplicate_when_full() { 257 | let mut history = History::new([0; 10]); 258 | 259 | history.push("abc"); 260 | history.push("defgh"); 261 | 262 | assert_eq!(history.next_older(), Some("defgh")); 263 | assert_eq!(history.next_older(), Some("abc")); 264 | assert_eq!(history.next_older(), None); 265 | 266 | history.push("abc"); 267 | 268 | assert_eq!(history.next_older(), Some("abc")); 269 | assert_eq!(history.next_older(), Some("defgh")); 270 | assert_eq!(history.next_older(), None); 271 | 272 | history.push("abc"); 273 | 274 | assert_eq!(history.next_older(), Some("abc")); 275 | assert_eq!(history.next_older(), Some("defgh")); 276 | assert_eq!(history.next_older(), None); 277 | } 278 | } 279 | -------------------------------------------------------------------------------- /embedded-cli/src/input.rs: -------------------------------------------------------------------------------- 1 | use bitflags::bitflags; 2 | 3 | use crate::{codes, utf8::Utf8Accum}; 4 | 5 | #[derive(Clone, Copy, Debug, Eq, PartialEq)] 6 | pub enum ControlInput { 7 | Backspace, 8 | Down, 9 | Enter, 10 | Back, 11 | Forward, 12 | Tab, 13 | Up, 14 | } 15 | 16 | #[derive(Debug, Eq, PartialEq)] 17 | pub enum Input<'a> { 18 | Control(ControlInput), 19 | 20 | /// Input is a single utf8 char. 21 | /// Slice is used to skip conversion from char to byte slice 22 | Char(&'a str), 23 | } 24 | 25 | bitflags! { 26 | #[derive(Debug)] 27 | struct Flags: u8 { 28 | const CSI_STARTED = 1; 29 | } 30 | } 31 | 32 | #[derive(Debug)] 33 | pub struct InputGenerator { 34 | flags: Flags, 35 | last_byte: u8, 36 | utf8: Utf8Accum, 37 | } 38 | 39 | impl InputGenerator { 40 | pub fn new() -> Self { 41 | // last byte matters only when its Esc, \r or \n, so can set it to just 0 42 | Self { 43 | flags: Flags::empty(), 44 | last_byte: 0, 45 | utf8: Utf8Accum::default(), 46 | } 47 | } 48 | 49 | pub fn accept(&mut self, byte: u8) -> Option> { 50 | let last_byte = self.last_byte; 51 | self.last_byte = byte; 52 | if self.flags.contains(Flags::CSI_STARTED) { 53 | self.process_csi(byte).map(Input::Control) 54 | } else if last_byte == codes::ESCAPE && byte == b'[' { 55 | self.flags.set(Flags::CSI_STARTED, true); 56 | None 57 | } else { 58 | self.process_single(byte, last_byte) 59 | } 60 | } 61 | 62 | fn process_csi(&mut self, byte: u8) -> Option { 63 | // skip all parameter bytes and process only last byte in CSI sequence 64 | if (0x40..=0x7E).contains(&byte) { 65 | self.flags.set(Flags::CSI_STARTED, false); 66 | let control = match byte { 67 | b'A' => ControlInput::Up, 68 | b'B' => ControlInput::Down, 69 | b'C' => ControlInput::Forward, 70 | b'D' => ControlInput::Back, 71 | _ => return None, 72 | }; 73 | Some(control) 74 | } else { 75 | None 76 | } 77 | } 78 | 79 | fn process_single(&mut self, byte: u8, last_byte: u8) -> Option> { 80 | let control = match byte { 81 | codes::BACKSPACE => ControlInput::Backspace, 82 | 83 | // ignore \r if \n already received (and converted to Enter) 84 | codes::CARRIAGE_RETURN if last_byte != codes::LINE_FEED => ControlInput::Enter, 85 | 86 | // ignore \n if \r already received (and converted to Enter) 87 | codes::LINE_FEED if last_byte != codes::CARRIAGE_RETURN => ControlInput::Enter, 88 | 89 | codes::TABULATION => ControlInput::Tab, 90 | 91 | // process only non control ascii chars (and utf8) 92 | byte if byte >= 0x20 => return self.utf8.push_byte(byte).map(Input::Char), 93 | 94 | _ => return None, 95 | }; 96 | Some(Input::Control(control)) 97 | } 98 | } 99 | 100 | #[cfg(test)] 101 | mod tests { 102 | use rstest::rstest; 103 | 104 | use super::{ControlInput, Input, InputGenerator}; 105 | 106 | #[rstest] 107 | #[case(b"\x1B[A", ControlInput::Up)] 108 | #[case(b"\x1B[B", ControlInput::Down)] 109 | #[case(b"\x1B[24B", ControlInput::Down)] 110 | #[case(b"\x1B[C", ControlInput::Forward)] 111 | #[case(b"\x1B[D", ControlInput::Back)] 112 | fn process_csi_control(#[case] bytes: &[u8], #[case] expected: ControlInput) { 113 | let mut accum = InputGenerator::new(); 114 | 115 | for &b in &bytes[..bytes.len() - 1] { 116 | assert_eq!(accum.accept(b), None); 117 | } 118 | 119 | assert_eq!( 120 | accum.accept(*bytes.last().unwrap()), 121 | Some(Input::Control(expected)) 122 | ) 123 | } 124 | 125 | #[rstest] 126 | #[case(0x08, ControlInput::Backspace)] 127 | #[case(b'\t', ControlInput::Tab)] 128 | #[case(b'\r', ControlInput::Enter)] 129 | #[case(b'\n', ControlInput::Enter)] 130 | fn process_c0_control(#[case] byte: u8, #[case] expected: ControlInput) { 131 | assert_eq!( 132 | InputGenerator::new().accept(byte), 133 | Some(Input::Control(expected)) 134 | ) 135 | } 136 | 137 | #[test] 138 | fn process_crlf() { 139 | let mut accum = InputGenerator::new(); 140 | accum.accept(b'\r'); 141 | 142 | assert_eq!(accum.accept(b'\n'), None); 143 | assert_eq!(accum.accept(b'a'), Some(Input::Char("a"))); 144 | } 145 | 146 | #[test] 147 | fn process_lfcr() { 148 | let mut accum = InputGenerator::new(); 149 | accum.accept(b'\n'); 150 | 151 | assert_eq!(accum.accept(b'\r'), None); 152 | assert_eq!(accum.accept(b'a'), Some(Input::Char("a"))); 153 | } 154 | 155 | #[test] 156 | fn process_input() { 157 | let mut accum = InputGenerator::new(); 158 | 159 | assert_eq!(accum.accept(b'a'), Some(Input::Char("a"))); 160 | assert_eq!(accum.accept(b'b'), Some(Input::Char("b"))); 161 | assert_eq!(accum.accept("б".as_bytes()[0]), None); 162 | assert_eq!(accum.accept("б".as_bytes()[1]), Some(Input::Char("б"))); 163 | assert_eq!( 164 | accum.accept(b'\n'), 165 | Some(Input::Control(ControlInput::Enter)) 166 | ); 167 | assert_eq!(accum.accept(b'a'), Some(Input::Char("a"))); 168 | assert_eq!(accum.accept(b'b'), Some(Input::Char("b"))); 169 | assert_eq!(accum.accept(b'\t'), Some(Input::Control(ControlInput::Tab))); 170 | assert_eq!(accum.accept(0x1B), None); 171 | assert_eq!(accum.accept(b'['), None); 172 | assert_eq!(accum.accept(b'A'), Some(Input::Control(ControlInput::Up))); 173 | assert_eq!(accum.accept(0x1B), None); 174 | assert_eq!(accum.accept(b'['), None); 175 | assert_eq!(accum.accept(b'B'), Some(Input::Control(ControlInput::Down))); 176 | assert_eq!(accum.accept(b'a'), Some(Input::Char("a"))); 177 | assert_eq!(accum.accept(b'b'), Some(Input::Char("b"))); 178 | assert_eq!(accum.accept(0x1B), None); 179 | assert_eq!(accum.accept(b'['), None); 180 | assert_eq!(accum.accept(b'B'), Some(Input::Control(ControlInput::Down))); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /embedded-cli/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![warn(rust_2018_idioms, missing_debug_implementations)] 2 | #![no_std] 3 | 4 | // std used for simpler testing 5 | #[cfg(test)] 6 | extern crate std; 7 | 8 | pub mod arguments; 9 | pub mod autocomplete; 10 | pub mod buffer; 11 | mod builder; 12 | pub mod cli; 13 | pub mod codes; 14 | pub mod command; 15 | mod editor; 16 | pub mod help; 17 | #[cfg(feature = "history")] 18 | mod history; 19 | mod input; 20 | pub mod service; 21 | mod token; 22 | mod utf8; 23 | mod utils; 24 | pub mod writer; 25 | 26 | /// Macro available if embedded-cli is built with `features = ["macros"]`. 27 | #[cfg(feature = "embedded-cli-macros")] 28 | pub use embedded_cli_macros::{Command, CommandGroup}; 29 | 30 | // Used by generated code. Not public API. 31 | #[doc(hidden)] 32 | #[path = "private/mod.rs"] 33 | pub mod __private; 34 | 35 | //TODO: organize pub uses better 36 | -------------------------------------------------------------------------------- /embedded-cli/src/private/mod.rs: -------------------------------------------------------------------------------- 1 | pub use embedded_io as io; 2 | -------------------------------------------------------------------------------- /embedded-cli/src/service.rs: -------------------------------------------------------------------------------- 1 | use embedded_io::Write; 2 | 3 | use crate::{arguments::FromArgumentError, cli::CliHandle, command::RawCommand}; 4 | 5 | #[cfg(feature = "autocomplete")] 6 | use crate::autocomplete::{Autocompletion, Request}; 7 | 8 | #[cfg(feature = "help")] 9 | use crate::writer::Writer; 10 | 11 | #[derive(Debug)] 12 | pub enum ProcessError<'a, E: embedded_io::Error> { 13 | ParseError(ParseError<'a>), 14 | WriteError(E), 15 | } 16 | 17 | #[derive(Debug)] 18 | #[non_exhaustive] 19 | pub enum ParseError<'a> { 20 | MissingRequiredArgument { 21 | /// Name of the argument. For example ``, `-f `, `--file ` 22 | name: &'a str, 23 | }, 24 | 25 | ParseValueError { 26 | value: &'a str, 27 | expected: &'static str, 28 | }, 29 | 30 | UnexpectedArgument { 31 | value: &'a str, 32 | }, 33 | 34 | UnexpectedLongOption { 35 | name: &'a str, 36 | }, 37 | 38 | UnexpectedShortOption { 39 | name: char, 40 | }, 41 | 42 | UnknownCommand, 43 | } 44 | 45 | impl<'a> From> for ParseError<'a> { 46 | fn from(error: FromArgumentError<'a>) -> Self { 47 | Self::ParseValueError { 48 | value: error.value, 49 | expected: error.expected, 50 | } 51 | } 52 | } 53 | 54 | impl From for ProcessError<'_, E> { 55 | fn from(value: E) -> Self { 56 | Self::WriteError(value) 57 | } 58 | } 59 | 60 | impl<'a, E: embedded_io::Error> From> for ProcessError<'a, E> { 61 | fn from(value: ParseError<'a>) -> Self { 62 | Self::ParseError(value) 63 | } 64 | } 65 | 66 | #[derive(Debug)] 67 | pub enum HelpError { 68 | WriteError(E), 69 | UnknownCommand, 70 | } 71 | 72 | impl From for HelpError { 73 | fn from(value: E) -> Self { 74 | Self::WriteError(value) 75 | } 76 | } 77 | 78 | pub trait Autocomplete { 79 | // trait is kept available so it's possible to use same where clause 80 | #[cfg(feature = "autocomplete")] 81 | /// Try to process autocompletion request 82 | /// Autocompleted bytes (not present in request) should be written to 83 | /// given autocompletion. 84 | fn autocomplete(request: Request<'_>, autocompletion: &mut Autocompletion<'_>); 85 | } 86 | 87 | // trait is kept available so it's possible to use same where clause 88 | pub trait Help { 89 | #[cfg(feature = "help")] 90 | /// How many commands are known 91 | fn command_count() -> usize; 92 | 93 | #[cfg(feature = "help")] 94 | /// Print all commands and short description of each 95 | fn list_commands, E: embedded_io::Error>( 96 | writer: &mut Writer<'_, W, E>, 97 | ) -> Result<(), E>; 98 | 99 | #[cfg(feature = "help")] 100 | /// Print help for given command. Command might contain -h or --help options 101 | /// Use given writer to print help text 102 | /// If help request cannot be processed by this object, 103 | /// Err(HelpError::UnknownCommand) must be returned 104 | fn command_help< 105 | W: Write, 106 | E: embedded_io::Error, 107 | F: FnMut(&mut Writer<'_, W, E>) -> Result<(), E>, 108 | >( 109 | parent: &mut F, 110 | command: RawCommand<'_>, 111 | writer: &mut Writer<'_, W, E>, 112 | ) -> Result<(), HelpError>; 113 | } 114 | 115 | pub trait FromRaw<'a>: Sized { 116 | /// Parse raw command into typed command 117 | fn parse(raw: RawCommand<'a>) -> Result>; 118 | } 119 | 120 | pub trait CommandProcessor, E: embedded_io::Error> { 121 | fn process<'a>( 122 | &mut self, 123 | cli: &mut CliHandle<'_, W, E>, 124 | raw: RawCommand<'a>, 125 | ) -> Result<(), ProcessError<'a, E>>; 126 | } 127 | 128 | impl CommandProcessor for F 129 | where 130 | W: Write, 131 | E: embedded_io::Error, 132 | F: for<'a> FnMut(&mut CliHandle<'_, W, E>, RawCommand<'a>) -> Result<(), ProcessError<'a, E>>, 133 | { 134 | fn process<'a>( 135 | &mut self, 136 | cli: &mut CliHandle<'_, W, E>, 137 | command: RawCommand<'a>, 138 | ) -> Result<(), ProcessError<'a, E>> { 139 | self(cli, command) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /embedded-cli/src/token.rs: -------------------------------------------------------------------------------- 1 | #[derive(Clone, Debug, Eq, PartialEq)] 2 | pub struct Tokens<'a> { 3 | empty: bool, 4 | tokens: &'a str, 5 | } 6 | 7 | impl<'a> Tokens<'a> { 8 | pub fn new(input: &'a mut str) -> Self { 9 | // SAFETY: bytes are modified correctly, so they remain utf8 10 | let bytes = unsafe { input.as_bytes_mut() }; 11 | 12 | let mut insert = 0; 13 | let mut empty = true; 14 | 15 | enum Mode { 16 | Space, 17 | Normal, 18 | Quoted, 19 | Unescape, 20 | } 21 | 22 | let mut mode = Mode::Space; 23 | 24 | for cursor_pos in 0..bytes.len() { 25 | let byte = bytes[cursor_pos]; 26 | match mode { 27 | Mode::Space => { 28 | if byte == b'"' { 29 | mode = Mode::Quoted; 30 | empty = false; 31 | if insert > 0 { 32 | bytes[insert] = 0; 33 | insert += 1; 34 | } 35 | } else if byte != b' ' && byte != 0 { 36 | mode = Mode::Normal; 37 | empty = false; 38 | if insert > 0 { 39 | bytes[insert] = 0; 40 | insert += 1; 41 | } 42 | bytes[insert] = byte; 43 | insert += 1; 44 | } 45 | } 46 | Mode::Normal => { 47 | if byte == b' ' || byte == 0 { 48 | mode = Mode::Space; 49 | } else { 50 | bytes[insert] = byte; 51 | insert += 1; 52 | } 53 | } 54 | Mode::Quoted => { 55 | if byte == b'"' || byte == 0 { 56 | mode = Mode::Space; 57 | } else if byte == b'\\' { 58 | mode = Mode::Unescape; 59 | } else { 60 | bytes[insert] = byte; 61 | insert += 1; 62 | } 63 | } 64 | Mode::Unescape => { 65 | bytes[insert] = byte; 66 | insert += 1; 67 | mode = Mode::Quoted; 68 | } 69 | } 70 | } 71 | 72 | // SAFETY: bytes are still a valid utf8 sequence 73 | // insert is inside bytes slice 74 | let tokens = unsafe { core::str::from_utf8_unchecked(bytes.get_unchecked(..insert)) }; 75 | Self { empty, tokens } 76 | } 77 | 78 | pub fn from_raw(tokens: &'a str, is_empty: bool) -> Self { 79 | Self { 80 | empty: is_empty, 81 | tokens, 82 | } 83 | } 84 | 85 | /// Returns raw representation of tokens (delimited with 0) 86 | pub fn into_raw(self) -> &'a str { 87 | self.tokens 88 | } 89 | 90 | pub fn iter(&self) -> TokensIter<'a> { 91 | TokensIter::new(self.tokens, self.empty) 92 | } 93 | 94 | pub fn is_empty(&self) -> bool { 95 | self.empty 96 | } 97 | } 98 | 99 | #[derive(Clone, Debug)] 100 | pub struct TokensIter<'a> { 101 | tokens: &'a str, 102 | empty: bool, 103 | } 104 | 105 | impl<'a> TokensIter<'a> { 106 | pub fn new(tokens: &'a str, empty: bool) -> Self { 107 | Self { tokens, empty } 108 | } 109 | 110 | pub fn into_tokens(self) -> Tokens<'a> { 111 | Tokens { 112 | empty: self.empty, 113 | tokens: self.tokens, 114 | } 115 | } 116 | } 117 | 118 | impl<'a> Iterator for TokensIter<'a> { 119 | type Item = &'a str; 120 | 121 | fn next(&mut self) -> Option { 122 | if self.empty { 123 | return None; 124 | } 125 | if let Some(pos) = self.tokens.as_bytes().iter().position(|&b| b == 0) { 126 | // SAFETY: pos is inside args slice 127 | let (arg, other) = unsafe { 128 | ( 129 | self.tokens.get_unchecked(..pos), 130 | self.tokens.get_unchecked(pos + 1..), 131 | ) 132 | }; 133 | self.tokens = other; 134 | Some(arg) 135 | } else { 136 | self.empty = true; 137 | Some(self.tokens) 138 | } 139 | } 140 | } 141 | 142 | #[cfg(test)] 143 | mod tests { 144 | use rstest::rstest; 145 | 146 | use crate::token::Tokens; 147 | 148 | #[rstest] 149 | #[case("", "")] 150 | #[case(" ", "")] 151 | #[case("abc", "abc")] 152 | #[case(" abc ", "abc")] 153 | #[case(" abc def ", "abc\0def")] 154 | #[case(" abc def gh ", "abc\0def\0gh")] 155 | #[case("abc def gh", "abc\0def\0gh")] 156 | #[case(r#""abc""#, "abc")] 157 | #[case(r#" "abc" "#, "abc")] 158 | #[case(r#" " abc " "#, " abc ")] 159 | #[case(r#" " abc "#, " abc ")] 160 | #[case(r#" " abc" "de fg " " he yw""#, " abc\0de fg \0 he yw")] 161 | #[case(r#" "ab \"c\\d\" " "#, r#"ab "c\d" "#)] 162 | #[case(r#""abc\\""#, r#"abc\"#)] 163 | fn create(#[case] input: &str, #[case] expected: &str) { 164 | let mut input = input.as_bytes().to_vec(); 165 | let input = core::str::from_utf8_mut(&mut input).unwrap(); 166 | let result = Tokens::new(input); 167 | 168 | assert_eq!(result.tokens, expected); 169 | let len = result.tokens.len(); 170 | assert_eq!(&mut input[..len], expected); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /embedded-cli/src/utf8.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Default)] 2 | pub struct Utf8Accum { 3 | /// Buffer for utf8 octets aggregation until full utf-8 char is received 4 | buffer: [u8; 4], 5 | 6 | /// How many more utf8 octets are expected 7 | expected: u8, 8 | 9 | /// How many utf8 octets are in the buffer 10 | partial: u8, 11 | } 12 | 13 | impl Utf8Accum { 14 | pub fn push_byte(&mut self, byte: u8) -> Option<&str> { 15 | // Plain and stupid utf-8 validation 16 | // Bytes are supposed to be human input so it's okay to be not blazing fast 17 | 18 | if byte >= 0xF8 { 19 | return None; 20 | } else if byte >= 0xF0 { 21 | // this is first octet of 4-byte value 22 | self.buffer[0] = byte; 23 | self.partial = 1; 24 | self.expected = 3; 25 | } else if byte >= 0xE0 { 26 | // this is first octet of 3-byte value 27 | self.buffer[0] = byte; 28 | self.partial = 1; 29 | self.expected = 2; 30 | } else if byte >= 0xC0 { 31 | // this is first octet of 2-byte value 32 | self.buffer[0] = byte; 33 | self.partial = 1; 34 | self.expected = 1; 35 | } else if byte >= 0x80 { 36 | if self.expected > 0 { 37 | // this is one of other octets of multi-byte value 38 | self.buffer[self.partial as usize] = byte; 39 | self.partial += 1; 40 | self.expected -= 1; 41 | if self.expected == 0 { 42 | let len = self.partial as usize; 43 | // SAFETY: we checked previously that buffer contains valid utf8 44 | unsafe { 45 | return Some(core::str::from_utf8_unchecked(&self.buffer[..len])); 46 | } 47 | } 48 | } 49 | } else { 50 | self.expected = 0; 51 | self.buffer[0] = byte; 52 | // SAFETY: ascii chars are all valid utf-8 chars 53 | unsafe { 54 | return Some(core::str::from_utf8_unchecked(&self.buffer[..1])); 55 | } 56 | } 57 | 58 | None 59 | } 60 | } 61 | 62 | #[cfg(test)] 63 | mod tests { 64 | use std::string::String; 65 | 66 | use crate::utf8::Utf8Accum; 67 | 68 | #[test] 69 | fn utf8_support() { 70 | let mut accum = Utf8Accum::default(); 71 | 72 | let expected_str = "abcdабвг佐佗佟𑿁𑿆𑿌"; 73 | 74 | let mut text = String::new(); 75 | 76 | for &b in expected_str.as_bytes() { 77 | if let Some(t) = accum.push_byte(b) { 78 | text.push_str(t); 79 | } 80 | } 81 | 82 | assert_eq!(text, expected_str); 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /embedded-cli/src/utils.rs: -------------------------------------------------------------------------------- 1 | use crate::utf8::Utf8Accum; 2 | 3 | /// Returns byte index of given char index 4 | /// If text doesn't have that many chars, returns None 5 | /// For example, in text `abc` `b` has both char and byte index of `1`. 6 | /// But in text `вгд` `г` has char index of 1, but byte index of `2` (`в` is 2 bytes long) 7 | pub fn char_byte_index(text: &str, char_index: usize) -> Option { 8 | let mut accum = Utf8Accum::default(); 9 | let mut byte_index = 0; 10 | let mut current = 0; 11 | for &b in text.as_bytes() { 12 | if char_index == current { 13 | return Some(byte_index); 14 | } 15 | if accum.push_byte(b).is_some() { 16 | current += 1; 17 | } 18 | byte_index += 1; 19 | } 20 | if char_index == current && byte_index < text.len() { 21 | return Some(byte_index); 22 | } 23 | None 24 | } 25 | 26 | pub fn char_count(text: &str) -> usize { 27 | let mut accum = Utf8Accum::default(); 28 | let mut count = 0; 29 | for &b in text.as_bytes() { 30 | if accum.push_byte(b).is_some() { 31 | count += 1; 32 | } 33 | } 34 | count 35 | } 36 | 37 | pub fn char_pop_front(text: &str) -> Option<(char, &str)> { 38 | if text.is_empty() { 39 | None 40 | } else { 41 | let bytes = text.as_bytes(); 42 | let first = bytes[0]; 43 | 44 | let mut codepoint = if first < 0x80 { 45 | first as u32 46 | } else if (first & 0xE0) == 0xC0 { 47 | (first & 0x1F) as u32 48 | } else { 49 | (first & 0x0F) as u32 50 | }; 51 | 52 | let mut bytes = &bytes[1..]; 53 | // go over all other bytes and add merge into codepoint 54 | while !bytes.is_empty() && (bytes[0] & 0xC0) == 0x80 { 55 | codepoint <<= 6; 56 | codepoint |= bytes[0] as u32 & 0x3F; 57 | bytes = &bytes[1..]; 58 | } 59 | 60 | // SAFETY: after all modifications codepoint is valid u32 char 61 | // and bytes contains valid utf-8 sequence 62 | unsafe { 63 | Some(( 64 | char::from_u32_unchecked(codepoint), 65 | core::str::from_utf8_unchecked(bytes), 66 | )) 67 | } 68 | } 69 | } 70 | 71 | /// Returns length (in bytes) of longest common prefix 72 | pub fn common_prefix_len(left: &str, right: &str) -> usize { 73 | let mut accum1 = Utf8Accum::default(); 74 | 75 | let mut pos = 0; 76 | let mut byte_counter = 0; 77 | 78 | for (&b1, &b2) in left.as_bytes().iter().zip(right.as_bytes().iter()) { 79 | if b1 != b2 { 80 | break; 81 | } 82 | let c1 = accum1.push_byte(b1); 83 | byte_counter += 1; 84 | if c1.is_some() { 85 | pos = byte_counter; 86 | } 87 | } 88 | 89 | pos 90 | } 91 | 92 | /// Encodes given character as UTF-8 into the provided byte buffer, 93 | /// and then returns the subslice of the buffer that contains the encoded character. 94 | pub fn encode_utf8(ch: char, buf: &mut [u8]) -> &str { 95 | let mut code = ch as u32; 96 | 97 | if code < 0x80 { 98 | buf[0] = ch as u8; 99 | unsafe { 100 | return core::str::from_utf8_unchecked(&buf[..1]); 101 | } 102 | } 103 | 104 | let mut counter = if code < 0x800 { 105 | // 2-byte char 106 | 1 107 | } else if code < 0x10000 { 108 | // 3-byte char 109 | 2 110 | } else { 111 | // 4-byte char 112 | 3 113 | }; 114 | 115 | let first_b_mask = (0x780 >> counter) as u8; 116 | 117 | let len = counter + 1; 118 | while counter > 0 { 119 | buf[counter] = ((code as u8) & 0b0011_1111) | 0b1000_0000; 120 | code >>= 6; 121 | counter -= 1; 122 | } 123 | 124 | buf[0] = code as u8 | first_b_mask; 125 | 126 | unsafe { core::str::from_utf8_unchecked(&buf[..len]) } 127 | } 128 | 129 | pub fn trim_start(input: &str) -> &str { 130 | if let Some(pos) = input.as_bytes().iter().position(|b| *b != b' ') { 131 | input.get(pos..).unwrap_or("") 132 | } else { 133 | "" 134 | } 135 | } 136 | 137 | /// Copies content from one slice to another (equivalent of memcpy) 138 | /// 139 | /// # Safety 140 | /// Length of both slices must be at least `len` 141 | pub unsafe fn copy_nonoverlapping(src: &[u8], dst: &mut [u8], len: usize) { 142 | debug_assert!(src.len() >= len); 143 | debug_assert!(dst.len() >= len); 144 | 145 | // SAFETY: Caller has to check that slices have len bytes 146 | // and two buffers can't overlap since mutable ref is exlusive 147 | unsafe { 148 | core::ptr::copy_nonoverlapping(src.as_ptr(), dst.as_mut_ptr(), len); 149 | } 150 | } 151 | 152 | /// Splits given mutable slice into two parts 153 | /// 154 | /// # Safety 155 | /// mid must be <= slice.len() 156 | #[cfg(feature = "autocomplete")] 157 | pub unsafe fn split_at_mut(buf: &mut [u8], mid: usize) -> (&mut [u8], &mut [u8]) { 158 | // this exists only because slice::split_at_unchecked is not stable: 159 | // https://github.com/rust-lang/rust/issues/76014 160 | let len = buf.len(); 161 | let ptr = buf.as_mut_ptr(); 162 | 163 | // SAFETY: Caller has to check that `mid <= self.len()` 164 | unsafe { 165 | debug_assert!(mid <= len); 166 | ( 167 | core::slice::from_raw_parts_mut(ptr, mid), 168 | core::slice::from_raw_parts_mut(ptr.add(mid), len - mid), 169 | ) 170 | } 171 | } 172 | 173 | #[cfg(test)] 174 | mod tests { 175 | use rstest::rstest; 176 | use std::format; 177 | 178 | use crate::utils; 179 | 180 | #[rstest] 181 | #[case::no_spaces("abc", "abc")] 182 | #[case::leading_spaces(" abc", "abc")] 183 | #[case::trailing_spaces("abc ", "abc ")] 184 | #[case::both_spaces(" abc ", "abc ")] 185 | #[case::space_inside(" abc def ", "abc def ")] 186 | #[case::multiple_spaces_inside(" abc def ", "abc def ")] 187 | #[case::utf8(" abc dабвг佐 佗佟𑿁 𑿆𑿌 ", "abc dабвг佐 佗佟𑿁 𑿆𑿌 ")] 188 | fn trim_start(#[case] input: &str, #[case] expected: &str) { 189 | assert_eq!(utils::trim_start(input), expected); 190 | } 191 | 192 | #[rstest] 193 | #[case("abcdef")] 194 | #[case("abcd абв 佐佗佟𑿁 𑿆𑿌")] 195 | fn char_byte_pos(#[case] text: &str) { 196 | // last iteration will check for None 197 | for pos in 0..=text.chars().count() { 198 | let expected = text.char_indices().map(|(pos, _)| pos).nth(pos); 199 | 200 | assert_eq!(utils::char_byte_index(text, pos), expected) 201 | } 202 | } 203 | 204 | #[rstest] 205 | #[case("abcdef")] 206 | #[case("abcd абв 佐佗佟𑿁 𑿆𑿌")] 207 | fn char_count(#[case] text: &str) { 208 | assert_eq!(utils::char_count(text), text.chars().count()) 209 | } 210 | 211 | #[test] 212 | fn char_pop_front() { 213 | let text = "abcd абв 佐佗佟𑿁 𑿆𑿌"; 214 | for (i, ch) in text.char_indices() { 215 | let (popped_ch, left) = utils::char_pop_front(&text[i..]).unwrap(); 216 | assert_eq!(popped_ch, ch); 217 | assert_eq!(&text[i..], format!("{}{}", ch, left).as_str()); 218 | } 219 | assert!(utils::char_pop_front("").is_none()) 220 | } 221 | 222 | #[test] 223 | fn char_encode() { 224 | let text = "abcd абв 佐佗佟𑿁 𑿆𑿌"; 225 | for ch in text.chars() { 226 | let mut buf1 = [0; 4]; 227 | let mut buf2 = [0; 4]; 228 | assert_eq!(ch.encode_utf8(&mut buf1), utils::encode_utf8(ch, &mut buf2)); 229 | } 230 | assert!(utils::char_pop_front("").is_none()) 231 | } 232 | 233 | #[rstest] 234 | #[case("abcdef", "abcdef")] 235 | #[case("abcdef", "abc")] 236 | #[case("abcdef", "abc ghf")] 237 | #[case("abcdef", "")] 238 | #[case("", "")] 239 | #[case("абв 佐佗佟𑿁", "абв 佐佗佟𑿁")] 240 | #[case("абв 佐佗佟𑿁𑿆𑿌", "абв 佐佗佟𑿁")] 241 | #[case("абв 佐佗佟𑿁 𑿆𑿌", "абв 佐佗𑿁佟")] 242 | fn common_prefix(#[case] left: &str, #[case] right: &str) { 243 | let expected = left 244 | .char_indices() 245 | .zip(right.char_indices()) 246 | .find(|((_, a1), (_, a2))| a1 != a2) 247 | .map(|((pos, _), _)| pos) 248 | .unwrap_or(right.len().min(left.len())); 249 | 250 | assert_eq!(utils::common_prefix_len(left, right), expected); 251 | assert_eq!(utils::common_prefix_len(right, left), expected); 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /embedded-cli/src/writer.rs: -------------------------------------------------------------------------------- 1 | use core::{convert::Infallible, fmt::Debug}; 2 | 3 | use embedded_io::{Error, ErrorType, Write}; 4 | use ufmt::uWrite; 5 | 6 | use crate::codes; 7 | 8 | pub struct Writer<'a, W: Write, E: Error> { 9 | last_bytes: [u8; 2], 10 | dirty: bool, 11 | writer: &'a mut W, 12 | } 13 | 14 | impl, E: Error> Debug for Writer<'_, W, E> { 15 | fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { 16 | f.debug_struct("Writer") 17 | .field("last_bytes", &self.last_bytes) 18 | .field("dirty", &self.dirty) 19 | .finish() 20 | } 21 | } 22 | 23 | impl<'a, W: Write, E: Error> Writer<'a, W, E> { 24 | pub fn new(writer: &'a mut W) -> Self { 25 | Self { 26 | last_bytes: [0; 2], 27 | dirty: false, 28 | writer, 29 | } 30 | } 31 | 32 | pub(crate) fn is_dirty(&self) -> bool { 33 | self.dirty 34 | && (self.last_bytes[0] != codes::CARRIAGE_RETURN 35 | || self.last_bytes[1] != codes::LINE_FEED) 36 | } 37 | 38 | pub fn write_str(&mut self, mut text: &str) -> Result<(), E> { 39 | while !text.is_empty() { 40 | if let Some(pos) = text.as_bytes().iter().position(|&b| b == codes::LINE_FEED) { 41 | // SAFETY: pos is inside text slice 42 | let line = unsafe { text.get_unchecked(..pos) }; 43 | 44 | self.writer.write_str(line)?; 45 | self.writer.write_str(codes::CRLF)?; 46 | // SAFETY: pos is index of existing element so pos + 1 in worst case will be 47 | // outside of slice by 1, which is safe (will give empty slice as result) 48 | text = unsafe { text.get_unchecked(pos + 1..) }; 49 | self.dirty = false; 50 | self.last_bytes = [0; 2]; 51 | } else { 52 | self.writer.write_str(text)?; 53 | self.dirty = true; 54 | 55 | if text.len() > 1 { 56 | self.last_bytes[0] = text.as_bytes()[text.len() - 2]; 57 | self.last_bytes[1] = text.as_bytes()[text.len() - 1]; 58 | } else { 59 | self.last_bytes[0] = self.last_bytes[1]; 60 | self.last_bytes[1] = text.as_bytes()[text.len() - 1]; 61 | } 62 | break; 63 | } 64 | } 65 | Ok(()) 66 | } 67 | 68 | pub fn writeln_str(&mut self, text: &str) -> Result<(), E> { 69 | self.writer.write_str(text)?; 70 | self.writer.write_str(codes::CRLF)?; 71 | self.dirty = false; 72 | Ok(()) 73 | } 74 | 75 | pub fn write_list_element( 76 | &mut self, 77 | name: &str, 78 | description: &str, 79 | longest_name: usize, 80 | ) -> Result<(), E> { 81 | self.write_str(" ")?; 82 | self.write_str(name)?; 83 | if name.len() < longest_name { 84 | for _ in 0..longest_name - name.len() { 85 | self.write_str(" ")?; 86 | } 87 | } 88 | self.write_str(" ")?; 89 | self.writeln_str(description)?; 90 | 91 | Ok(()) 92 | } 93 | 94 | pub fn write_title(&mut self, title: &str) -> Result<(), E> { 95 | //TODO: add formatting 96 | self.write_str(title)?; 97 | Ok(()) 98 | } 99 | } 100 | 101 | impl, E: Error> uWrite for Writer<'_, W, E> { 102 | type Error = E; 103 | 104 | fn write_str(&mut self, s: &str) -> Result<(), E> { 105 | self.write_str(s) 106 | } 107 | } 108 | 109 | impl, E: Error> core::fmt::Write for Writer<'_, W, E> { 110 | fn write_str(&mut self, s: &str) -> core::fmt::Result { 111 | self.write_str(s).map_err(|_| core::fmt::Error)?; 112 | Ok(()) 113 | } 114 | } 115 | 116 | pub(crate) trait WriteExt: ErrorType { 117 | /// Write and flush all given bytes 118 | fn flush_bytes(&mut self, bytes: &[u8]) -> Result<(), Self::Error>; 119 | 120 | fn flush_str(&mut self, text: &str) -> Result<(), Self::Error>; 121 | 122 | fn write_bytes(&mut self, bytes: &[u8]) -> Result<(), Self::Error>; 123 | 124 | fn write_str(&mut self, text: &str) -> Result<(), Self::Error>; 125 | } 126 | 127 | impl WriteExt for W { 128 | fn flush_bytes(&mut self, bytes: &[u8]) -> Result<(), Self::Error> { 129 | self.write_bytes(bytes)?; 130 | self.flush() 131 | } 132 | 133 | fn flush_str(&mut self, text: &str) -> Result<(), Self::Error> { 134 | self.flush_bytes(text.as_bytes()) 135 | } 136 | 137 | fn write_bytes(&mut self, bytes: &[u8]) -> Result<(), Self::Error> { 138 | self.write_all(bytes) 139 | } 140 | 141 | fn write_str(&mut self, text: &str) -> Result<(), Self::Error> { 142 | self.write_bytes(text.as_bytes()) 143 | } 144 | } 145 | 146 | #[derive(Debug)] 147 | pub struct EmptyWriter; 148 | 149 | impl ErrorType for EmptyWriter { 150 | type Error = Infallible; 151 | } 152 | 153 | impl Write for EmptyWriter { 154 | fn write(&mut self, buf: &[u8]) -> Result { 155 | Ok(buf.len()) 156 | } 157 | 158 | fn flush(&mut self) -> Result<(), Self::Error> { 159 | Ok(()) 160 | } 161 | } 162 | 163 | #[cfg(test)] 164 | mod tests { 165 | use crate::writer::{EmptyWriter, Writer}; 166 | 167 | #[test] 168 | fn detect_dirty() { 169 | let mut writer = EmptyWriter; 170 | let mut writer = Writer::new(&mut writer); 171 | 172 | assert!(!writer.is_dirty()); 173 | 174 | writer.write_str("abc").unwrap(); 175 | assert!(writer.is_dirty()); 176 | 177 | writer.write_str("\r").unwrap(); 178 | assert!(writer.is_dirty()); 179 | 180 | writer.write_str("\n").unwrap(); 181 | assert!(!writer.is_dirty()); 182 | 183 | writer.write_str("abc\r\n").unwrap(); 184 | assert!(!writer.is_dirty()); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /embedded-cli/tests/cli/autocomplete.rs: -------------------------------------------------------------------------------- 1 | use embedded_cli::command::RawCommand; 2 | use embedded_cli::service::FromRaw; 3 | use embedded_cli::Command; 4 | 5 | use crate::wrapper::{CliWrapper, CommandConvert, ParseError}; 6 | 7 | use crate::terminal::assert_terminal; 8 | 9 | #[derive(Debug, Clone, Command, PartialEq)] 10 | enum TestCommand { 11 | GetLed, 12 | GetAdc, 13 | Exit, 14 | } 15 | 16 | impl CommandConvert for TestCommand { 17 | fn convert(cmd: RawCommand<'_>) -> Result { 18 | Ok(TestCommand::parse(cmd)?) 19 | } 20 | } 21 | 22 | #[test] 23 | fn complete_when_single_variant() { 24 | let mut cli = CliWrapper::new(); 25 | 26 | cli.process_str("e"); 27 | cli.send_tab(); 28 | 29 | assert_terminal!(cli.terminal(), 7, vec!["$ exit"]); 30 | 31 | cli.send_enter(); 32 | 33 | assert_eq!(cli.received_commands(), vec![Ok(TestCommand::Exit)]); 34 | 35 | assert_terminal!(cli.terminal(), 2, vec!["$ exit", "$"]); 36 | } 37 | 38 | #[test] 39 | fn complete_when_multiple_variants() { 40 | let mut cli = CliWrapper::new(); 41 | 42 | cli.process_str("g"); 43 | cli.send_tab(); 44 | 45 | assert_terminal!(cli.terminal(), 6, vec!["$ get-"]); 46 | 47 | cli.process_str("a"); 48 | cli.send_tab(); 49 | 50 | assert_terminal!(cli.terminal(), 10, vec!["$ get-adc"]); 51 | 52 | cli.send_enter(); 53 | assert_terminal!(cli.terminal(), 2, vec!["$ get-adc", "$"]); 54 | assert_eq!(cli.received_commands(), vec![Ok(TestCommand::GetAdc)]); 55 | } 56 | 57 | #[test] 58 | fn complete_when_name_finished() { 59 | let mut cli = CliWrapper::new(); 60 | 61 | cli.process_str("exit"); 62 | cli.send_tab(); 63 | 64 | assert_terminal!(cli.terminal(), 7, vec!["$ exit"]); 65 | 66 | cli.send_enter(); 67 | 68 | assert_eq!(cli.received_commands(), vec![Ok(TestCommand::Exit)]); 69 | } 70 | 71 | #[test] 72 | fn complete_with_leading_spaces() { 73 | let mut cli = CliWrapper::new(); 74 | 75 | cli.process_str(" ex"); 76 | cli.send_tab(); 77 | 78 | assert_terminal!(cli.terminal(), 9, vec!["$ exit"]); 79 | 80 | cli.send_enter(); 81 | 82 | assert_eq!(cli.received_commands(), vec![Ok(TestCommand::Exit)]); 83 | } 84 | 85 | #[test] 86 | fn complete_with_trailing_spaces() { 87 | let mut cli = CliWrapper::::new(); 88 | 89 | cli.process_str("ex "); 90 | cli.send_tab(); 91 | 92 | assert_terminal!(cli.terminal(), 5, vec!["$ ex"]); 93 | } 94 | 95 | #[test] 96 | fn complete_when_inside() { 97 | let mut cli = CliWrapper::::new(); 98 | 99 | cli.process_str("ex"); 100 | cli.send_left(); 101 | assert_terminal!(cli.terminal(), 3, vec!["$ ex"]); 102 | 103 | cli.send_tab(); 104 | assert_terminal!(cli.terminal(), 7, vec!["$ exit"]); 105 | 106 | cli.send_enter(); 107 | assert_eq!(cli.received_commands(), vec![Ok(TestCommand::Exit)]); 108 | } 109 | 110 | #[test] 111 | fn complete_when_inside_with_trailing_spaces() { 112 | let mut cli = CliWrapper::::new(); 113 | 114 | cli.process_str("ex "); 115 | cli.send_left(); 116 | cli.send_left(); 117 | assert_terminal!(cli.terminal(), 3, vec!["$ ex"]); 118 | 119 | cli.send_tab(); 120 | assert_terminal!(cli.terminal(), 7, vec!["$ exit"]); 121 | 122 | cli.send_enter(); 123 | assert_eq!(cli.received_commands(), vec![Ok(TestCommand::Exit)]); 124 | } 125 | 126 | #[test] 127 | fn complete_when_inside_after_complete() { 128 | let mut cli = CliWrapper::::new(); 129 | 130 | cli.process_str("e"); 131 | cli.send_tab(); 132 | assert_terminal!(cli.terminal(), 7, vec!["$ exit"]); 133 | 134 | cli.send_left(); 135 | cli.send_left(); 136 | cli.send_left(); 137 | assert_terminal!(cli.terminal(), 4, vec!["$ exit"]); 138 | 139 | cli.send_tab(); 140 | assert_terminal!(cli.terminal(), 7, vec!["$ exit"]); 141 | 142 | cli.send_enter(); 143 | assert_eq!(cli.received_commands(), vec![Ok(TestCommand::Exit)]); 144 | } 145 | 146 | #[test] 147 | fn complete_when_inside_without_variants() { 148 | let mut cli = CliWrapper::::new(); 149 | 150 | cli.process_str("do"); 151 | cli.send_left(); 152 | assert_terminal!(cli.terminal(), 3, vec!["$ do"]); 153 | 154 | cli.send_tab(); 155 | assert_terminal!(cli.terminal(), 3, vec!["$ do"]); 156 | } 157 | 158 | #[test] 159 | fn complete_when_inside_and_empty_completion() { 160 | let mut cli = CliWrapper::::new(); 161 | 162 | cli.process_str("g"); 163 | cli.send_tab(); 164 | assert_terminal!(cli.terminal(), 6, vec!["$ get-"]); 165 | 166 | cli.send_left(); 167 | cli.send_left(); 168 | assert_terminal!(cli.terminal(), 4, vec!["$ get-"]); 169 | 170 | cli.send_tab(); 171 | assert_terminal!(cli.terminal(), 6, vec!["$ get-"]); 172 | } 173 | -------------------------------------------------------------------------------- /embedded-cli/tests/cli/autocomplete_disabled.rs: -------------------------------------------------------------------------------- 1 | use embedded_cli::command::RawCommand; 2 | use embedded_cli::service::FromRaw; 3 | use embedded_cli::Command; 4 | 5 | use crate::wrapper::{CliWrapper, CommandConvert, ParseError}; 6 | 7 | use crate::terminal::assert_terminal; 8 | 9 | #[derive(Debug, Clone, Command, PartialEq)] 10 | enum TestCommand { 11 | GetLed, 12 | GetAdc, 13 | Exit, 14 | } 15 | 16 | impl CommandConvert for TestCommand { 17 | fn convert(cmd: RawCommand<'_>) -> Result { 18 | Ok(TestCommand::parse(cmd)?) 19 | } 20 | } 21 | 22 | #[test] 23 | fn autocomplete_disabled() { 24 | let mut cli = CliWrapper::::new(); 25 | 26 | cli.process_str("e"); 27 | 28 | cli.send_tab(); 29 | 30 | assert_terminal!(cli.terminal(), 3, vec!["$ e"]); 31 | } 32 | -------------------------------------------------------------------------------- /embedded-cli/tests/cli/base.rs: -------------------------------------------------------------------------------- 1 | use rstest::rstest; 2 | 3 | use crate::wrapper::{Arg, CliWrapper, RawCommand}; 4 | 5 | use crate::terminal::assert_terminal; 6 | 7 | #[test] 8 | fn simple_input() { 9 | let mut cli = CliWrapper::default(); 10 | 11 | assert_terminal!(cli.terminal(), 2, vec!["$"]); 12 | 13 | cli.process_str("set"); 14 | 15 | assert_terminal!(cli.terminal(), 5, vec!["$ set"]); 16 | 17 | cli.process_str(" led"); 18 | 19 | assert_terminal!(cli.terminal(), 9, vec!["$ set led"]); 20 | 21 | assert!(cli.received_commands().is_empty()); 22 | 23 | cli.send_enter(); 24 | assert_terminal!(cli.terminal(), 2, vec!["$ set led", "$"]); 25 | assert_eq!( 26 | cli.received_commands(), 27 | vec![Ok(RawCommand { 28 | name: "set".to_string(), 29 | args: vec![Arg::Value("led".to_string())], 30 | })] 31 | ); 32 | } 33 | 34 | #[test] 35 | fn delete_with_backspace() { 36 | let mut cli = CliWrapper::default(); 37 | 38 | cli.process_str("set"); 39 | 40 | assert_terminal!(cli.terminal(), 5, vec!["$ set"]); 41 | 42 | cli.send_backspace(); 43 | 44 | assert_terminal!(cli.terminal(), 4, vec!["$ se"]); 45 | 46 | cli.send_backspace(); 47 | 48 | assert_terminal!(cli.terminal(), 3, vec!["$ s"]); 49 | 50 | cli.send_backspace(); 51 | cli.send_backspace(); 52 | cli.send_backspace(); 53 | 54 | assert_terminal!(cli.terminal(), 2, vec!["$"]); 55 | } 56 | 57 | #[test] 58 | fn move_insert() { 59 | let mut cli = CliWrapper::default(); 60 | 61 | cli.process_str("set"); 62 | assert_terminal!(cli.terminal(), 5, vec!["$ set"]); 63 | 64 | cli.send_left(); 65 | assert_terminal!(cli.terminal(), 4, vec!["$ set"]); 66 | 67 | cli.send_left(); 68 | assert_terminal!(cli.terminal(), 3, vec!["$ set"]); 69 | 70 | cli.process_str("up-d"); 71 | assert_terminal!(cli.terminal(), 7, vec!["$ sup-det"]); 72 | 73 | cli.send_backspace(); 74 | assert_terminal!(cli.terminal(), 6, vec!["$ sup-et"]); 75 | 76 | cli.send_right(); 77 | assert_terminal!(cli.terminal(), 7, vec!["$ sup-et"]); 78 | 79 | cli.process_str("d"); 80 | assert_terminal!(cli.terminal(), 8, vec!["$ sup-edt"]); 81 | 82 | cli.send_enter(); 83 | assert_terminal!(cli.terminal(), 2, vec!["$ sup-edt", "$"]); 84 | assert_eq!( 85 | cli.received_commands(), 86 | vec![Ok(RawCommand { 87 | name: "sup-edt".to_string(), 88 | args: vec![], 89 | })] 90 | ); 91 | } 92 | 93 | #[rstest] 94 | #[case("#")] 95 | #[case("###> ")] 96 | #[case("")] 97 | fn set_prompt_dynamic(#[case] prompt: &'static str) { 98 | let mut cli = CliWrapper::default(); 99 | assert_terminal!(cli.terminal(), 2, vec!["$"]); 100 | 101 | cli.set_prompt(prompt); 102 | assert_terminal!(cli.terminal(), prompt.len(), vec![prompt.trim()]); 103 | 104 | cli.set_prompt("$ "); 105 | assert_terminal!(cli.terminal(), 2, vec!["$"]); 106 | 107 | cli.set_prompt(prompt); 108 | assert_terminal!(cli.terminal(), prompt.len(), vec![prompt.trim()]); 109 | 110 | cli.process_str("set"); 111 | assert_terminal!( 112 | cli.terminal(), 113 | prompt.len() + 3, 114 | vec![format!("{}set", prompt)] 115 | ); 116 | 117 | cli.set_prompt("$ "); 118 | assert_terminal!(cli.terminal(), 5, vec!["$ set"]); 119 | 120 | cli.set_handler(move |cli, _| { 121 | cli.set_prompt(prompt); 122 | Ok(()) 123 | }); 124 | cli.send_enter(); 125 | assert_terminal!(cli.terminal(), prompt.len(), vec!["$ set", prompt.trim()]); 126 | 127 | cli.set_handler(move |cli, _| { 128 | cli.set_prompt("$ "); 129 | Ok(()) 130 | }); 131 | cli.process_str("get"); 132 | cli.send_enter(); 133 | assert_terminal!( 134 | cli.terminal(), 135 | 2, 136 | vec![ 137 | "$ set".to_string(), 138 | format!("{}get", prompt), 139 | "$".to_string() 140 | ] 141 | ); 142 | 143 | assert_eq!( 144 | cli.received_commands(), 145 | vec![ 146 | Ok(RawCommand { 147 | name: "set".to_string(), 148 | args: vec![], 149 | }), 150 | Ok(RawCommand { 151 | name: "get".to_string(), 152 | args: vec![], 153 | }) 154 | ] 155 | ); 156 | } 157 | 158 | #[rstest] 159 | #[case("#")] 160 | #[case("###> ")] 161 | #[case("")] 162 | fn set_prompt_static(#[case] prompt: &'static str) { 163 | let mut cli = CliWrapper::builder().prompt(prompt).build(); 164 | assert_terminal!(cli.terminal(), prompt.len(), vec![prompt.trim_end()]); 165 | 166 | cli.process_str("set"); 167 | assert_terminal!( 168 | cli.terminal(), 169 | prompt.len() + 3, 170 | vec![format!("{}set", prompt)] 171 | ); 172 | 173 | cli.send_enter(); 174 | assert_terminal!( 175 | cli.terminal(), 176 | prompt.len(), 177 | vec![format!("{}set", prompt), prompt.trim().to_string()] 178 | ); 179 | 180 | assert_eq!( 181 | cli.received_commands(), 182 | vec![Ok(RawCommand { 183 | name: "set".to_string(), 184 | args: vec![], 185 | })] 186 | ); 187 | } 188 | 189 | #[test] 190 | fn try_move_outside() { 191 | let mut cli = CliWrapper::default(); 192 | 193 | cli.process_str("set"); 194 | assert_terminal!(cli.terminal(), 5, vec!["$ set"]); 195 | 196 | cli.send_right(); 197 | assert_terminal!(cli.terminal(), 5, vec!["$ set"]); 198 | 199 | cli.send_left(); 200 | cli.send_left(); 201 | cli.send_left(); 202 | assert_terminal!(cli.terminal(), 2, vec!["$ set"]); 203 | 204 | cli.send_left(); 205 | assert_terminal!(cli.terminal(), 2, vec!["$ set"]); 206 | 207 | cli.process_str("d-"); 208 | assert_terminal!(cli.terminal(), 4, vec!["$ d-set"]); 209 | 210 | cli.send_enter(); 211 | assert_terminal!(cli.terminal(), 2, vec!["$ d-set", "$"]); 212 | assert_eq!( 213 | cli.received_commands(), 214 | vec![Ok(RawCommand { 215 | name: "d-set".to_string(), 216 | args: vec![], 217 | })] 218 | ); 219 | } 220 | -------------------------------------------------------------------------------- /embedded-cli/tests/cli/defaults.rs: -------------------------------------------------------------------------------- 1 | use embedded_cli::Command; 2 | use rstest::rstest; 3 | 4 | use crate::impl_convert; 5 | use crate::wrapper::CliWrapper; 6 | 7 | use crate::terminal::assert_terminal; 8 | 9 | #[derive(Debug, Clone, Command, PartialEq)] 10 | enum CliTestCommand<'a> { 11 | Cmd { 12 | #[arg(long, default_value = "default name")] 13 | name: &'a str, 14 | 15 | #[arg(long, default_value = "8")] 16 | level: u8, 17 | 18 | #[arg(long, default_value_t = 9)] 19 | level2: u8, 20 | 21 | #[arg(long, default_value_t)] 22 | level3: u8, 23 | }, 24 | } 25 | 26 | #[derive(Debug, Clone, PartialEq)] 27 | enum TestCommand { 28 | Cmd { 29 | name: String, 30 | level: u8, 31 | level2: u8, 32 | level3: u8, 33 | }, 34 | } 35 | 36 | impl_convert! {CliTestCommand<'_> => TestCommand, command, { 37 | match command { 38 | cmd => cmd.into(), 39 | } 40 | }} 41 | 42 | impl<'a> From> for TestCommand { 43 | fn from(value: CliTestCommand<'a>) -> Self { 44 | match value { 45 | CliTestCommand::Cmd { 46 | name, 47 | level, 48 | level2, 49 | level3, 50 | } => Self::Cmd { 51 | name: name.to_string(), 52 | level, 53 | level2, 54 | level3, 55 | }, 56 | } 57 | } 58 | } 59 | 60 | #[rstest] 61 | #[case("cmd --name test-name --level 1 --level2 2 --level3 3", TestCommand::Cmd { 62 | name: "test-name".to_string(), 63 | level: 1, 64 | level2: 2, 65 | level3: 3, 66 | })] 67 | #[case("cmd", TestCommand::Cmd { 68 | name: "default name".to_string(), 69 | level: 8, 70 | level2: 9, 71 | level3: 0, 72 | })] 73 | fn options_parsing(#[case] command: &str, #[case] expected: TestCommand) { 74 | let mut cli = CliWrapper::new(); 75 | 76 | cli.process_str(command); 77 | 78 | cli.send_enter(); 79 | 80 | assert_terminal!( 81 | cli.terminal(), 82 | 2, 83 | vec![format!("$ {}", command), "$".to_string()] 84 | ); 85 | 86 | assert_eq!(cli.received_commands(), vec![Ok(expected)]); 87 | } 88 | -------------------------------------------------------------------------------- /embedded-cli/tests/cli/help_simple.rs: -------------------------------------------------------------------------------- 1 | use embedded_cli::Command; 2 | use rstest::rstest; 3 | 4 | use crate::impl_convert; 5 | use crate::wrapper::CliWrapper; 6 | 7 | use crate::terminal::assert_terminal; 8 | 9 | #[derive(Debug, Clone, Command, PartialEq)] 10 | enum CliBase<'a> { 11 | /// Base command 12 | #[command(name = "base1")] 13 | Base1 { 14 | /// Optional argument 15 | #[arg(short, long)] 16 | name: Option<&'a str>, 17 | 18 | /// Make things verbose 19 | #[arg(short)] 20 | verbose: bool, 21 | }, 22 | 23 | /// Another base command 24 | #[command(name = "base2")] 25 | Base2 { 26 | /// Some level 27 | #[arg(short, long, value_name = "lvl")] 28 | level: u8, 29 | }, 30 | 31 | /// Test command 32 | Test { 33 | /// Some task job 34 | #[arg(short = 'j', long = "job")] 35 | task: &'a str, 36 | 37 | /// Source file 38 | #[arg(value_name = "FILE")] 39 | file1: &'a str, 40 | 41 | /// Destination file 42 | file2: &'a str, 43 | }, 44 | } 45 | 46 | #[derive(Debug, Clone, PartialEq)] 47 | enum Base { 48 | Base1 { 49 | name: Option, 50 | verbose: bool, 51 | }, 52 | Base2 { 53 | level: u8, 54 | }, 55 | Test { 56 | task: String, 57 | 58 | file1: String, 59 | 60 | file2: String, 61 | }, 62 | } 63 | 64 | impl_convert! {CliBase<'_> => Base, command, { command.into() }} 65 | 66 | impl<'a> From> for Base { 67 | fn from(value: CliBase<'a>) -> Self { 68 | match value { 69 | CliBase::Base1 { name, verbose } => Self::Base1 { 70 | name: name.map(|n| n.to_string()), 71 | verbose, 72 | }, 73 | CliBase::Base2 { level } => Self::Base2 { level }, 74 | CliBase::Test { task, file1, file2 } => Self::Test { 75 | task: task.to_string(), 76 | file1: file1.to_string(), 77 | file2: file2.to_string(), 78 | }, 79 | } 80 | } 81 | } 82 | 83 | #[rstest] 84 | #[case("base1 --help", &[ 85 | "Base command", 86 | "", 87 | "Usage: base1 [OPTIONS]", 88 | "", 89 | "Options:", 90 | " -n, --name [NAME] Optional argument", 91 | " -v Make things verbose", 92 | " -h, --help Print help", 93 | ])] 94 | #[case("base1 -n name -v --help", &[ 95 | "Base command", 96 | "", 97 | "Usage: base1 [OPTIONS]", 98 | "", 99 | "Options:", 100 | " -n, --name [NAME] Optional argument", 101 | " -v Make things verbose", 102 | " -h, --help Print help", 103 | ])] 104 | #[case("base2 --help", &[ 105 | "Another base command", 106 | "", 107 | "Usage: base2 [OPTIONS]", 108 | "", 109 | "Options:", 110 | " -l, --level Some level", 111 | " -h, --help Print help", 112 | ])] 113 | #[case("test --help", &[ 114 | "Test command", 115 | "", 116 | "Usage: test [OPTIONS] ", 117 | "", 118 | "Arguments:", 119 | " Source file", 120 | " Destination file", 121 | "", 122 | "Options:", 123 | " -j, --job Some task job", 124 | " -h, --help Print help", 125 | ])] 126 | fn help(#[case] command: &str, #[case] expected: &[&str]) { 127 | let mut cli = CliWrapper::::new(); 128 | let all_lines = [format!("$ {}", command)] 129 | .into_iter() 130 | .chain(expected.iter().map(|s| s.to_string())) 131 | .chain(Some("$".to_string())) 132 | .collect::>(); 133 | 134 | cli.process_str(command); 135 | 136 | cli.send_enter(); 137 | 138 | assert_terminal!(cli.terminal(), 2, all_lines); 139 | 140 | assert!(cli.received_commands().is_empty()); 141 | } 142 | -------------------------------------------------------------------------------- /embedded-cli/tests/cli/help_subcommand.rs: -------------------------------------------------------------------------------- 1 | use embedded_cli::Command; 2 | use rstest::rstest; 3 | 4 | use crate::impl_convert; 5 | use crate::wrapper::CliWrapper; 6 | 7 | use crate::terminal::assert_terminal; 8 | 9 | #[derive(Debug, Clone, Command, PartialEq)] 10 | enum CliBase<'a> { 11 | /// Base command 12 | #[command(name = "base1")] 13 | Base1 { 14 | /// Optional argument 15 | #[arg(short, long)] 16 | name: Option<&'a str>, 17 | 18 | /// Some level 19 | #[arg(short, long)] 20 | level: u8, 21 | 22 | /// Make things verbose 23 | #[arg(short)] 24 | verbose: bool, 25 | 26 | #[command(subcommand)] 27 | command: CliBase1Sub<'a>, 28 | }, 29 | 30 | /// Another base command 31 | #[command(name = "base2", subcommand)] 32 | Base2(CliBase2Sub<'a>), 33 | } 34 | 35 | #[derive(Debug, Clone, Command, PartialEq)] 36 | enum CliBase1Sub<'a> { 37 | /// Get something 38 | Get { 39 | /// Optional item 40 | #[arg(short, long)] 41 | item: Option<&'a str>, 42 | 43 | /// Another verbose flag 44 | #[arg(short, long)] 45 | verbose: bool, 46 | 47 | #[command(subcommand)] 48 | command: CliBase1SubSub<'a>, 49 | }, 50 | /// Set something 51 | Set { 52 | /// Another required value 53 | value: &'a str, 54 | }, 55 | } 56 | 57 | #[derive(Debug, Clone, Command, PartialEq)] 58 | enum CliBase1SubSub<'a> { 59 | /// Command something 60 | Cmd { 61 | /// Very optional item 62 | #[arg(short, long)] 63 | item: Option<&'a str>, 64 | 65 | /// Third verbose flag 66 | #[arg(short, long)] 67 | verbose: bool, 68 | 69 | /// Required positional 70 | file: &'a str, 71 | }, 72 | /// Test something 73 | Test { 74 | /// Test verbose flag 75 | #[arg(short, long)] 76 | verbose: bool, 77 | 78 | /// Tested required value 79 | value: &'a str, 80 | }, 81 | } 82 | 83 | #[derive(Debug, Clone, Command, PartialEq)] 84 | enum CliBase2Sub<'a> { 85 | /// Get something but differently 86 | Get { 87 | /// Also optional item 88 | #[arg(short, long)] 89 | item: Option<&'a str>, 90 | 91 | /// Third verbose flag 92 | #[arg(short, long)] 93 | verbose: bool, 94 | 95 | /// Required file 96 | file: &'a str, 97 | }, 98 | /// Write something 99 | Write { 100 | /// Required line to write 101 | line: &'a str, 102 | }, 103 | } 104 | 105 | #[derive(Debug, Clone, PartialEq)] 106 | enum Base { 107 | Base1 { 108 | name: Option, 109 | 110 | level: u8, 111 | 112 | verbose: bool, 113 | 114 | command: Base1Sub, 115 | }, 116 | Base2(Base2Sub), 117 | } 118 | 119 | #[derive(Debug, Clone, PartialEq)] 120 | enum Base1Sub { 121 | Get { 122 | item: Option, 123 | 124 | verbose: bool, 125 | 126 | command: Base1SubSub, 127 | }, 128 | Set { 129 | value: String, 130 | }, 131 | } 132 | 133 | #[derive(Debug, Clone, PartialEq)] 134 | enum Base1SubSub { 135 | Cmd { 136 | item: Option, 137 | 138 | verbose: bool, 139 | 140 | file: String, 141 | }, 142 | Test { 143 | verbose: bool, 144 | 145 | value: String, 146 | }, 147 | } 148 | 149 | #[derive(Debug, Clone, PartialEq)] 150 | enum Base2Sub { 151 | Get { 152 | item: Option, 153 | 154 | verbose: bool, 155 | 156 | file: String, 157 | }, 158 | Write { 159 | line: String, 160 | }, 161 | } 162 | 163 | impl_convert! {CliBase<'_> => Base, command, { command.into() }} 164 | 165 | impl<'a> From> for Base { 166 | fn from(value: CliBase<'a>) -> Self { 167 | match value { 168 | CliBase::Base1 { 169 | name, 170 | level, 171 | verbose, 172 | command, 173 | } => Self::Base1 { 174 | name: name.map(|n| n.to_string()), 175 | level, 176 | verbose, 177 | command: command.into(), 178 | }, 179 | CliBase::Base2(command) => Self::Base2(command.into()), 180 | } 181 | } 182 | } 183 | 184 | impl<'a> From> for Base1Sub { 185 | fn from(value: CliBase1Sub<'a>) -> Self { 186 | match value { 187 | CliBase1Sub::Get { 188 | item, 189 | verbose, 190 | command, 191 | } => Self::Get { 192 | item: item.map(|n| n.to_string()), 193 | verbose, 194 | command: command.into(), 195 | }, 196 | CliBase1Sub::Set { value } => Self::Set { 197 | value: value.to_string(), 198 | }, 199 | } 200 | } 201 | } 202 | 203 | impl<'a> From> for Base1SubSub { 204 | fn from(value: CliBase1SubSub<'a>) -> Self { 205 | match value { 206 | CliBase1SubSub::Cmd { 207 | item, 208 | verbose, 209 | file, 210 | } => Self::Cmd { 211 | item: item.map(|n| n.to_string()), 212 | verbose, 213 | file: file.into(), 214 | }, 215 | CliBase1SubSub::Test { verbose, value } => Self::Test { 216 | verbose, 217 | value: value.to_string(), 218 | }, 219 | } 220 | } 221 | } 222 | 223 | impl<'a> From> for Base2Sub { 224 | fn from(value: CliBase2Sub<'a>) -> Self { 225 | match value { 226 | CliBase2Sub::Get { 227 | item, 228 | verbose, 229 | file, 230 | } => Self::Get { 231 | item: item.map(|n| n.to_string()), 232 | verbose, 233 | file: file.to_string(), 234 | }, 235 | CliBase2Sub::Write { line } => Self::Write { 236 | line: line.to_string(), 237 | }, 238 | } 239 | } 240 | } 241 | 242 | #[rstest] 243 | #[case("base1 --help", &[ 244 | "Base command", 245 | "", 246 | "Usage: base1 [OPTIONS] ", 247 | "", 248 | "Options:", 249 | " -n, --name [NAME] Optional argument", 250 | " -l, --level Some level", 251 | " -v Make things verbose", 252 | " -h, --help Print help", 253 | "", 254 | "Commands:", 255 | " get Get something", 256 | " set Set something", 257 | ])] 258 | #[case("base1 get --help", &[ 259 | "Get something", 260 | "", 261 | "Usage: base1 get [OPTIONS] ", 262 | "", 263 | "Options:", 264 | " -i, --item [ITEM] Optional item", 265 | " -v, --verbose Another verbose flag", 266 | " -h, --help Print help", 267 | "", 268 | "Commands:", 269 | " cmd Command something", 270 | " test Test something", 271 | ])] 272 | #[case("base1 --name some -v get --help", &[ 273 | "Get something", 274 | "", 275 | "Usage: base1 get [OPTIONS] ", 276 | "", 277 | "Options:", 278 | " -i, --item [ITEM] Optional item", 279 | " -v, --verbose Another verbose flag", 280 | " -h, --help Print help", 281 | "", 282 | "Commands:", 283 | " cmd Command something", 284 | " test Test something", 285 | ])] 286 | #[case("base1 set --help", &[ 287 | "Set something", 288 | "", 289 | "Usage: base1 set ", 290 | "", 291 | "Arguments:", 292 | " Another required value", 293 | "", 294 | "Options:", 295 | " -h, --help Print help", 296 | ])] 297 | #[case("base1 get cmd --help", &[ 298 | "Command something", 299 | "", 300 | "Usage: base1 get cmd [OPTIONS] ", 301 | "", 302 | "Arguments:", 303 | " Required positional", 304 | "", 305 | "Options:", 306 | " -i, --item [ITEM] Very optional item", 307 | " -v, --verbose Third verbose flag", 308 | " -h, --help Print help", 309 | ])] 310 | #[case("base1 --name some -v get --verbose cmd --help", &[ 311 | "Command something", 312 | "", 313 | "Usage: base1 get cmd [OPTIONS] ", 314 | "", 315 | "Arguments:", 316 | " Required positional", 317 | "", 318 | "Options:", 319 | " -i, --item [ITEM] Very optional item", 320 | " -v, --verbose Third verbose flag", 321 | " -h, --help Print help", 322 | ])] 323 | #[case("base1 get test --help", &[ 324 | "Test something", 325 | "", 326 | "Usage: base1 get test [OPTIONS] ", 327 | "", 328 | "Arguments:", 329 | " Tested required value", 330 | "", 331 | "Options:", 332 | " -v, --verbose Test verbose flag", 333 | " -h, --help Print help", 334 | ])] 335 | #[case("base2 --help", &[ 336 | "Another base command", 337 | "", 338 | "Usage: base2 ", 339 | "", 340 | "Options:", 341 | " -h, --help Print help", 342 | "", 343 | "Commands:", 344 | " get Get something but differently", 345 | " write Write something", 346 | ])] 347 | #[case("base2 get --help", &[ 348 | "Get something but differently", 349 | "", 350 | "Usage: base2 get [OPTIONS] ", 351 | "", 352 | "Arguments:", 353 | " Required file", 354 | "", 355 | "Options:", 356 | " -i, --item [ITEM] Also optional item", 357 | " -v, --verbose Third verbose flag", 358 | " -h, --help Print help", 359 | ])] 360 | #[case("base2 write --help", &[ 361 | "Write something", 362 | "", 363 | "Usage: base2 write ", 364 | "", 365 | "Arguments:", 366 | " Required line to write", 367 | "", 368 | "Options:", 369 | " -h, --help Print help", 370 | ])] 371 | fn help(#[case] command: &str, #[case] expected: &[&str]) { 372 | let mut cli = CliWrapper::::new(); 373 | let all_lines = [format!("$ {}", command)] 374 | .into_iter() 375 | .chain(expected.iter().map(|s| s.to_string())) 376 | .chain(Some("$".to_string())) 377 | .collect::>(); 378 | 379 | cli.process_str(command); 380 | 381 | cli.send_enter(); 382 | 383 | assert_terminal!(cli.terminal(), 2, all_lines); 384 | 385 | assert!(cli.received_commands().is_empty()); 386 | } 387 | -------------------------------------------------------------------------------- /embedded-cli/tests/cli/history.rs: -------------------------------------------------------------------------------- 1 | use crate::wrapper::{CliWrapper, RawCommand}; 2 | 3 | use crate::terminal::assert_terminal; 4 | 5 | #[test] 6 | fn navigation() { 7 | let mut cli = CliWrapper::default(); 8 | 9 | cli.process_str("abc"); 10 | cli.send_enter(); 11 | cli.process_str("test1"); 12 | cli.send_enter(); 13 | cli.process_str("def"); 14 | cli.send_enter(); 15 | 16 | cli.send_up(); 17 | assert_terminal!( 18 | cli.terminal(), 19 | 5, 20 | vec!["$ abc", "$ test1", "$ def", "$ def"] 21 | ); 22 | 23 | cli.send_up(); 24 | assert_terminal!( 25 | cli.terminal(), 26 | 7, 27 | vec!["$ abc", "$ test1", "$ def", "$ test1"] 28 | ); 29 | 30 | cli.send_up(); 31 | assert_terminal!( 32 | cli.terminal(), 33 | 5, 34 | vec!["$ abc", "$ test1", "$ def", "$ abc"] 35 | ); 36 | 37 | cli.send_up(); 38 | assert_terminal!( 39 | cli.terminal(), 40 | 5, 41 | vec!["$ abc", "$ test1", "$ def", "$ abc"] 42 | ); 43 | 44 | cli.send_down(); 45 | assert_terminal!( 46 | cli.terminal(), 47 | 7, 48 | vec!["$ abc", "$ test1", "$ def", "$ test1"] 49 | ); 50 | 51 | cli.send_down(); 52 | assert_terminal!( 53 | cli.terminal(), 54 | 5, 55 | vec!["$ abc", "$ test1", "$ def", "$ def"] 56 | ); 57 | 58 | cli.send_down(); 59 | assert_terminal!(cli.terminal(), 2, vec!["$ abc", "$ test1", "$ def", "$"]); 60 | 61 | cli.send_up(); 62 | cli.send_up(); 63 | assert_terminal!( 64 | cli.terminal(), 65 | 7, 66 | vec!["$ abc", "$ test1", "$ def", "$ test1"] 67 | ); 68 | 69 | cli.send_enter(); 70 | assert_terminal!( 71 | cli.terminal(), 72 | 2, 73 | vec!["$ abc", "$ test1", "$ def", "$ test1", "$"] 74 | ); 75 | assert_eq!( 76 | cli.received_commands().last().unwrap(), 77 | &Ok(RawCommand { 78 | name: "test1".to_string(), 79 | args: vec![], 80 | }) 81 | ); 82 | } 83 | 84 | #[test] 85 | fn modify_when_in_history() { 86 | let mut cli = CliWrapper::default(); 87 | 88 | cli.process_str("abc"); 89 | cli.send_enter(); 90 | cli.process_str("test1"); 91 | cli.send_enter(); 92 | cli.process_str("def"); 93 | cli.send_enter(); 94 | 95 | cli.send_up(); 96 | cli.send_up(); 97 | assert_terminal!( 98 | cli.terminal(), 99 | 7, 100 | vec!["$ abc", "$ test1", "$ def", "$ test1"] 101 | ); 102 | 103 | cli.send_backspace(); 104 | cli.send_backspace(); 105 | cli.process_str("a"); 106 | 107 | assert_terminal!( 108 | cli.terminal(), 109 | 6, 110 | vec!["$ abc", "$ test1", "$ def", "$ tesa"] 111 | ); 112 | 113 | cli.send_up(); 114 | assert_terminal!( 115 | cli.terminal(), 116 | 5, 117 | vec!["$ abc", "$ test1", "$ def", "$ abc"] 118 | ); 119 | 120 | cli.send_down(); 121 | assert_terminal!( 122 | cli.terminal(), 123 | 7, 124 | vec!["$ abc", "$ test1", "$ def", "$ test1"] 125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /embedded-cli/tests/cli/history_disabled.rs: -------------------------------------------------------------------------------- 1 | use crate::wrapper::CliWrapper; 2 | 3 | use crate::terminal::assert_terminal; 4 | 5 | #[test] 6 | fn history_disabled() { 7 | let mut cli = CliWrapper::default(); 8 | 9 | cli.process_str("abc"); 10 | cli.send_enter(); 11 | cli.process_str("test1"); 12 | cli.send_enter(); 13 | cli.process_str("def"); 14 | cli.send_enter(); 15 | 16 | cli.send_up(); 17 | assert_terminal!(cli.terminal(), 2, vec!["$ abc", "$ test1", "$ def", "$"]); 18 | 19 | cli.send_down(); 20 | assert_terminal!(cli.terminal(), 2, vec!["$ abc", "$ test1", "$ def", "$"]); 21 | } 22 | -------------------------------------------------------------------------------- /embedded-cli/tests/cli/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(rust_2018_idioms)] 2 | 3 | #[cfg(feature = "autocomplete")] 4 | mod autocomplete; 5 | #[cfg(not(feature = "autocomplete"))] 6 | mod autocomplete_disabled; 7 | mod base; 8 | mod defaults; 9 | #[cfg(feature = "help")] 10 | mod help_simple; 11 | #[cfg(feature = "help")] 12 | mod help_subcommand; 13 | #[cfg(feature = "history")] 14 | mod history; 15 | #[cfg(not(feature = "history"))] 16 | mod history_disabled; 17 | mod options; 18 | mod subcommand; 19 | mod terminal; 20 | mod wrapper; 21 | mod writer; 22 | -------------------------------------------------------------------------------- /embedded-cli/tests/cli/options.rs: -------------------------------------------------------------------------------- 1 | use embedded_cli::Command; 2 | use rstest::rstest; 3 | 4 | use crate::impl_convert; 5 | use crate::wrapper::CliWrapper; 6 | 7 | use crate::terminal::assert_terminal; 8 | 9 | #[derive(Debug, Clone, Command, PartialEq)] 10 | enum CliTestCommand<'a> { 11 | Cmd { 12 | #[arg(short, long)] 13 | name: Option<&'a str>, 14 | 15 | #[arg(long = "конф")] 16 | config: &'a str, 17 | 18 | #[arg(short)] 19 | level: u8, 20 | 21 | #[arg(short = 'Ю', long)] 22 | verbose: bool, 23 | 24 | file: &'a str, 25 | }, 26 | } 27 | 28 | #[derive(Debug, Clone, PartialEq)] 29 | enum TestCommand { 30 | Cmd { 31 | name: Option, 32 | config: String, 33 | level: u8, 34 | verbose: bool, 35 | file: String, 36 | }, 37 | } 38 | 39 | impl_convert! {CliTestCommand<'_> => TestCommand, command, { 40 | match command { 41 | cmd => cmd.into(), 42 | } 43 | }} 44 | 45 | impl<'a> From> for TestCommand { 46 | fn from(value: CliTestCommand<'a>) -> Self { 47 | match value { 48 | CliTestCommand::Cmd { 49 | name, 50 | config, 51 | level, 52 | verbose, 53 | file, 54 | } => Self::Cmd { 55 | name: name.map(|n| n.to_string()), 56 | config: config.to_string(), 57 | level, 58 | verbose, 59 | file: file.to_string(), 60 | }, 61 | } 62 | } 63 | } 64 | 65 | #[rstest] 66 | #[case("cmd --name test-name --конф config -l 5 -Ю some-file", TestCommand::Cmd { 67 | name: Some("test-name".to_string()), 68 | config: "config".to_string(), 69 | level: 5, 70 | verbose: true, 71 | file: "some-file".to_string(), 72 | })] 73 | #[case("cmd --конф config -l 35 --verbose some-file", TestCommand::Cmd { 74 | name: None, 75 | config: "config".to_string(), 76 | level: 35, 77 | verbose: true, 78 | file: "some-file".to_string(), 79 | })] 80 | #[case("cmd --конф conf2 file -n name2 -Юl 25", TestCommand::Cmd { 81 | name: Some("name2".to_string()), 82 | config: "conf2".to_string(), 83 | level: 25, 84 | verbose: true, 85 | file: "file".to_string(), 86 | })] 87 | #[case("cmd file3 --конф conf3 -l 17", TestCommand::Cmd { 88 | name: None, 89 | config: "conf3".to_string(), 90 | level: 17, 91 | verbose: false, 92 | file: "file3".to_string(), 93 | })] 94 | fn options_parsing(#[case] command: &str, #[case] expected: TestCommand) { 95 | let mut cli = CliWrapper::new(); 96 | 97 | cli.process_str(command); 98 | 99 | cli.send_enter(); 100 | 101 | assert_terminal!( 102 | cli.terminal(), 103 | 2, 104 | vec![format!("$ {}", command), "$".to_string()] 105 | ); 106 | 107 | assert_eq!(cli.received_commands(), vec![Ok(expected)]); 108 | } 109 | -------------------------------------------------------------------------------- /embedded-cli/tests/cli/subcommand.rs: -------------------------------------------------------------------------------- 1 | use embedded_cli::Command; 2 | use rstest::rstest; 3 | 4 | use crate::impl_convert; 5 | use crate::wrapper::CliWrapper; 6 | 7 | use crate::terminal::assert_terminal; 8 | 9 | #[derive(Debug, Clone, Command, PartialEq)] 10 | enum CliBase<'a> { 11 | #[command(name = "base1")] 12 | Base1 { 13 | #[arg(short, long)] 14 | name: Option<&'a str>, 15 | 16 | #[arg(short, long)] 17 | level: u8, 18 | 19 | #[arg(short)] 20 | verbose: bool, 21 | 22 | #[command(subcommand)] 23 | command: CliBase1Sub<'a>, 24 | }, 25 | #[command(name = "base2", subcommand)] 26 | Base2(CliBase2Sub<'a>), 27 | } 28 | 29 | #[derive(Debug, Clone, Command, PartialEq)] 30 | enum CliBase1Sub<'a> { 31 | Get { 32 | #[arg(short, long)] 33 | item: Option<&'a str>, 34 | 35 | #[arg(short, long)] 36 | verbose: bool, 37 | 38 | file: &'a str, 39 | }, 40 | Set { 41 | value: &'a str, 42 | }, 43 | } 44 | 45 | #[derive(Debug, Clone, Command, PartialEq)] 46 | enum CliBase2Sub<'a> { 47 | Get { 48 | #[arg(short, long)] 49 | item: Option<&'a str>, 50 | 51 | #[arg(short, long)] 52 | verbose: bool, 53 | 54 | file: &'a str, 55 | }, 56 | Write { 57 | line: &'a str, 58 | }, 59 | } 60 | 61 | #[derive(Debug, Clone, PartialEq)] 62 | enum Base { 63 | Base1 { 64 | name: Option, 65 | 66 | level: u8, 67 | 68 | verbose: bool, 69 | 70 | command: Base1Sub, 71 | }, 72 | Base2(Base2Sub), 73 | } 74 | 75 | #[derive(Debug, Clone, PartialEq)] 76 | enum Base1Sub { 77 | Get { 78 | item: Option, 79 | 80 | verbose: bool, 81 | 82 | file: String, 83 | }, 84 | Set { 85 | value: String, 86 | }, 87 | } 88 | 89 | #[derive(Debug, Clone, PartialEq)] 90 | enum Base2Sub { 91 | Get { 92 | item: Option, 93 | 94 | verbose: bool, 95 | 96 | file: String, 97 | }, 98 | Write { 99 | line: String, 100 | }, 101 | } 102 | 103 | impl_convert! {CliBase<'_> => Base, command, { command.into() }} 104 | 105 | impl<'a> From> for Base { 106 | fn from(value: CliBase<'a>) -> Self { 107 | match value { 108 | CliBase::Base1 { 109 | name, 110 | level, 111 | verbose, 112 | command, 113 | } => Self::Base1 { 114 | name: name.map(|n| n.to_string()), 115 | level, 116 | verbose, 117 | command: command.into(), 118 | }, 119 | CliBase::Base2(command) => Self::Base2(command.into()), 120 | } 121 | } 122 | } 123 | 124 | impl<'a> From> for Base1Sub { 125 | fn from(value: CliBase1Sub<'a>) -> Self { 126 | match value { 127 | CliBase1Sub::Get { 128 | item, 129 | verbose, 130 | file, 131 | } => Self::Get { 132 | item: item.map(|n| n.to_string()), 133 | verbose, 134 | file: file.to_string(), 135 | }, 136 | CliBase1Sub::Set { value } => Self::Set { 137 | value: value.to_string(), 138 | }, 139 | } 140 | } 141 | } 142 | 143 | impl<'a> From> for Base2Sub { 144 | fn from(value: CliBase2Sub<'a>) -> Self { 145 | match value { 146 | CliBase2Sub::Get { 147 | item, 148 | verbose, 149 | file, 150 | } => Self::Get { 151 | item: item.map(|n| n.to_string()), 152 | verbose, 153 | file: file.to_string(), 154 | }, 155 | CliBase2Sub::Write { line } => Self::Write { 156 | line: line.to_string(), 157 | }, 158 | } 159 | } 160 | } 161 | 162 | #[rstest] 163 | #[case("base1 --name test-name --level 23 -v get --item config -v some-file", Base::Base1 { 164 | name: Some("test-name".to_string()), 165 | level: 23, 166 | verbose: true, 167 | command: Base1Sub::Get { 168 | item: Some("config".to_string()), 169 | verbose: true, 170 | file: "some-file".to_string(), 171 | } 172 | })] 173 | #[case("base1 -v --level 24 --name test-name set some-value", Base::Base1 { 174 | name: Some("test-name".to_string()), 175 | level: 24, 176 | verbose: true, 177 | command: Base1Sub::Set { 178 | value: "some-value".to_string(), 179 | } 180 | })] 181 | #[case("base2 get --item config -v some-file", Base::Base2 ( 182 | Base2Sub::Get { 183 | item: Some("config".to_string()), 184 | verbose: true, 185 | file: "some-file".to_string(), 186 | } 187 | ))] 188 | #[case("base2 write lines", Base::Base2 ( 189 | Base2Sub::Write { 190 | line: "lines".to_string(), 191 | } 192 | ))] 193 | fn options_parsing(#[case] command: &str, #[case] expected: Base) { 194 | let mut cli = CliWrapper::new(); 195 | 196 | cli.process_str(command); 197 | 198 | cli.send_enter(); 199 | 200 | assert_terminal!( 201 | cli.terminal(), 202 | 2, 203 | vec![format!("$ {}", command), "$".to_string()] 204 | ); 205 | 206 | assert_eq!(cli.received_commands(), vec![Ok(expected)]); 207 | } 208 | -------------------------------------------------------------------------------- /embedded-cli/tests/cli/terminal.rs: -------------------------------------------------------------------------------- 1 | macro_rules! assert_terminal { 2 | ($terminal:expr, $curs:expr, $b:expr) => { 3 | let expected = $b; 4 | let terminal = $terminal; 5 | let (lines, cursor) = terminal.view(); 6 | 7 | assert_eq!(lines, expected); 8 | assert_eq!(cursor, $curs); 9 | }; 10 | } 11 | 12 | pub(crate) use assert_terminal; 13 | use regex::Regex; 14 | 15 | #[derive(Debug)] 16 | pub struct Terminal { 17 | /// All received bytes 18 | received: Vec, 19 | } 20 | 21 | impl Terminal { 22 | pub fn new() -> Self { 23 | Self { received: vec![] } 24 | } 25 | 26 | pub fn receive_byte(&mut self, byte: u8) { 27 | self.received.push(byte); 28 | } 29 | 30 | pub fn receive_bytes(&mut self, bytes: &[u8]) { 31 | for &byte in bytes { 32 | self.receive_byte(byte) 33 | } 34 | } 35 | 36 | /// Returns vector of terminal lines 37 | /// and current cursor position (cursor column) 38 | /// 39 | /// end of lines is trimmed so input "ab " is displayed as "ab" (not "ab ") 40 | pub fn view(&self) -> (Vec, usize) { 41 | let mut output = vec!["".to_string()]; 42 | 43 | // cursor is char position (not utf8 byte position) 44 | let mut cursor = 0; 45 | 46 | let mut received = std::str::from_utf8(&self.received) 47 | .expect("Received bytes must form utf8 string") 48 | .to_string(); 49 | 50 | // Simple regex for CSI sequences 51 | let seq_re = Regex::new(r"\x1b\[([\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e])").unwrap(); 52 | 53 | while !received.is_empty() { 54 | let (normal, seq) = if let Some(seq_match) = seq_re.find(&received) { 55 | let seq = seq_match.as_str().to_string(); 56 | if seq_match.start() > 0 { 57 | let normal = received[..seq_match.start()].to_string(); 58 | received = received[seq_match.end()..].to_string(); 59 | (Some(normal), Some(seq)) 60 | } else { 61 | received = received[seq_match.end()..].to_string(); 62 | (None, Some(seq)) 63 | } 64 | } else { 65 | let normal = received; 66 | received = "".to_string(); 67 | (Some(normal), None) 68 | }; 69 | 70 | if let Some(normal) = normal { 71 | for c in normal.chars() { 72 | match c { 73 | '\r' => { 74 | cursor = 0; 75 | } 76 | '\n' => { 77 | // start new line (but keep cursor position) 78 | output.push("".to_string()); 79 | } 80 | c if c >= ' ' => { 81 | let current = output.last_mut().unwrap(); 82 | if current.chars().count() > cursor { 83 | current.remove(current.char_indices().nth(cursor).unwrap().0); 84 | } else { 85 | while current.chars().count() < cursor { 86 | current.push(' '); 87 | } 88 | } 89 | if let Some((insert_pos, _)) = current.char_indices().nth(cursor) { 90 | current.insert(insert_pos, c); 91 | } else { 92 | current.push(c); 93 | } 94 | cursor += 1; 95 | } 96 | _ => unimplemented!(), 97 | } 98 | } 99 | } 100 | 101 | if let Some(seq) = seq { 102 | let current = output.last_mut().unwrap(); 103 | match seq.as_str() { 104 | // cursor forward 105 | "\x1B[C" => { 106 | cursor += 1; 107 | } 108 | // cursor backward 109 | "\x1B[D" => { 110 | cursor = cursor.saturating_sub(1); 111 | } 112 | // delete char 113 | "\x1B[P" => { 114 | if current.chars().count() > cursor { 115 | current.remove(current.char_indices().nth(cursor).unwrap().0); 116 | } 117 | } 118 | // insert char 119 | "\x1B[@" => { 120 | if current.chars().count() > cursor { 121 | current.insert(current.char_indices().nth(cursor).unwrap().0, ' '); 122 | } 123 | } 124 | // clear whole line 125 | "\x1B[2K" => { 126 | // cursor position does not change 127 | current.clear(); 128 | } 129 | _ => unimplemented!(), 130 | } 131 | } 132 | } 133 | 134 | let output = output 135 | .into_iter() 136 | .map(|l| l.trim_end().to_string()) 137 | .collect(); 138 | 139 | (output, cursor) 140 | } 141 | } 142 | 143 | #[cfg(test)] 144 | mod tests { 145 | use embedded_cli::codes; 146 | 147 | use super::Terminal; 148 | 149 | #[test] 150 | fn simple() { 151 | let mut terminal = Terminal::new(); 152 | 153 | assert_terminal!(&terminal, 0, vec![""]); 154 | 155 | terminal.receive_byte(b'a'); 156 | terminal.receive_byte(b'b'); 157 | terminal.receive_byte(b'c'); 158 | 159 | assert_terminal!(terminal, 3, vec!["abc"]); 160 | } 161 | 162 | #[test] 163 | fn line_feeds() { 164 | let mut terminal = Terminal::new(); 165 | 166 | terminal.receive_byte(b'a'); 167 | terminal.receive_byte(b'b'); 168 | terminal.receive_byte(codes::LINE_FEED); 169 | 170 | // line feed doesn't reset cursor position 171 | assert_terminal!(&terminal, 2, vec!["ab", ""]); 172 | 173 | terminal.receive_byte(b'c'); 174 | assert_terminal!(&terminal, 3, vec!["ab", " c"]); 175 | } 176 | 177 | #[test] 178 | fn carriage_return() { 179 | let mut terminal = Terminal::new(); 180 | 181 | terminal.receive_byte(b'a'); 182 | terminal.receive_byte(b'b'); 183 | terminal.receive_byte(codes::CARRIAGE_RETURN); 184 | 185 | assert_terminal!(&terminal, 0, vec!["ab"]); 186 | 187 | terminal.receive_byte(b'c'); 188 | 189 | assert_terminal!(terminal, 1, vec!["cb"]); 190 | } 191 | 192 | #[test] 193 | fn move_forward_backward() { 194 | let mut terminal = Terminal::new(); 195 | 196 | terminal.receive_bytes(b"abc"); 197 | terminal.receive_bytes(codes::CURSOR_BACKWARD); 198 | assert_terminal!(&terminal, 2, vec!["abc"]); 199 | 200 | terminal.receive_bytes(codes::CURSOR_BACKWARD); 201 | assert_terminal!(&terminal, 1, vec!["abc"]); 202 | 203 | terminal.receive_byte(b'd'); 204 | assert_terminal!(&terminal, 2, vec!["adc"]); 205 | 206 | terminal.receive_byte(b'e'); 207 | terminal.receive_byte(b'f'); 208 | 209 | assert_terminal!(&terminal, 4, vec!["adef"]); 210 | 211 | terminal.receive_bytes(codes::CURSOR_BACKWARD); 212 | terminal.receive_bytes(codes::CURSOR_BACKWARD); 213 | terminal.receive_bytes(codes::CURSOR_BACKWARD); 214 | 215 | assert_terminal!(&terminal, 1, vec!["adef"]); 216 | 217 | terminal.receive_bytes(codes::CURSOR_FORWARD); 218 | 219 | assert_terminal!(&terminal, 2, vec!["adef"]); 220 | 221 | terminal.receive_byte(b'b'); 222 | 223 | assert_terminal!(&terminal, 3, vec!["adbf"]); 224 | } 225 | 226 | #[test] 227 | fn delete_chars() { 228 | let mut terminal = Terminal::new(); 229 | 230 | terminal.receive_bytes(b"abc"); 231 | terminal.receive_bytes(codes::CURSOR_BACKWARD); 232 | terminal.receive_bytes(codes::DELETE_CHAR); 233 | assert_terminal!(&terminal, 2, vec!["ab"]); 234 | 235 | terminal.receive_bytes(b"def"); 236 | terminal.receive_bytes(codes::CURSOR_BACKWARD); 237 | terminal.receive_bytes(codes::CURSOR_BACKWARD); 238 | terminal.receive_bytes(codes::CURSOR_BACKWARD); 239 | assert_terminal!(&terminal, 2, vec!["abdef"]); 240 | 241 | terminal.receive_bytes(codes::DELETE_CHAR); 242 | assert_terminal!(&terminal, 2, vec!["abef"]); 243 | 244 | terminal.receive_bytes(codes::DELETE_CHAR); 245 | assert_terminal!(&terminal, 2, vec!["abf"]); 246 | 247 | terminal.receive_byte(b'e'); 248 | assert_terminal!(&terminal, 3, vec!["abe"]); 249 | } 250 | 251 | #[test] 252 | fn insert_chars() { 253 | let mut terminal = Terminal::new(); 254 | 255 | terminal.receive_bytes(b"abc"); 256 | terminal.receive_bytes(codes::CURSOR_BACKWARD); 257 | terminal.receive_bytes(codes::INSERT_CHAR); 258 | assert_terminal!(&terminal, 2, vec!["ab c"]); 259 | 260 | terminal.receive_byte(b'd'); 261 | assert_terminal!(&terminal, 3, vec!["abdc"]); 262 | 263 | terminal.receive_bytes(codes::CURSOR_BACKWARD); 264 | terminal.receive_bytes(codes::CURSOR_BACKWARD); 265 | terminal.receive_bytes(codes::INSERT_CHAR); 266 | assert_terminal!(&terminal, 1, vec!["a bdc"]); 267 | 268 | terminal.receive_bytes(codes::INSERT_CHAR); 269 | assert_terminal!(&terminal, 1, vec!["a bdc"]); 270 | } 271 | 272 | #[test] 273 | fn clear_line() { 274 | let mut terminal = Terminal::new(); 275 | 276 | terminal.receive_bytes(b"abc"); 277 | terminal.receive_bytes(codes::CLEAR_LINE); 278 | assert_terminal!(&terminal, 3, vec![""]); 279 | 280 | terminal.receive_byte(b'd'); 281 | assert_terminal!(&terminal, 4, vec![" d"]); 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /embedded-cli/tests/cli/wrapper.rs: -------------------------------------------------------------------------------- 1 | use std::{cell::RefCell, convert::Infallible, fmt::Debug, marker::PhantomData, rc::Rc}; 2 | 3 | use embedded_cli::{ 4 | arguments::Arg as CliArg, 5 | cli::{Cli, CliBuilder, CliHandle}, 6 | command::RawCommand as CliRawCommand, 7 | service::{Autocomplete, CommandProcessor, Help, ParseError as CliParseError, ProcessError}, 8 | }; 9 | use embedded_io::ErrorType; 10 | 11 | use crate::terminal::Terminal; 12 | 13 | /// Helper trait to wrap parsed command or error with lifetime into owned command 14 | pub trait CommandConvert: Sized { 15 | fn convert(cmd: CliRawCommand<'_>) -> Result; 16 | } 17 | 18 | #[macro_export] 19 | macro_rules! impl_convert { 20 | ($from_ty:ty => $to_ty:ty, $var_name:ident, $conversion:block) => { 21 | impl embedded_cli::service::Autocomplete for $to_ty { 22 | #[cfg(feature = "autocomplete")] 23 | fn autocomplete( 24 | request: embedded_cli::autocomplete::Request<'_>, 25 | autocompletion: &mut embedded_cli::autocomplete::Autocompletion<'_>, 26 | ) { 27 | <$from_ty>::autocomplete(request, autocompletion) 28 | } 29 | } 30 | 31 | impl embedded_cli::service::Help for $to_ty { 32 | #[cfg(feature = "help")] 33 | fn command_count() -> usize { 34 | <$from_ty>::command_count() 35 | } 36 | 37 | #[cfg(feature = "help")] 38 | fn list_commands, E: embedded_io::Error>( 39 | writer: &mut embedded_cli::writer::Writer<'_, W, E>, 40 | ) -> Result<(), E> { 41 | <$from_ty>::list_commands(writer) 42 | } 43 | 44 | #[cfg(feature = "help")] 45 | fn command_help< 46 | W: embedded_io::Write, 47 | E: embedded_io::Error, 48 | F: FnMut(&mut embedded_cli::writer::Writer<'_, W, E>) -> Result<(), E>, 49 | >( 50 | parent: &mut F, 51 | command: embedded_cli::command::RawCommand<'_>, 52 | writer: &mut embedded_cli::writer::Writer<'_, W, E>, 53 | ) -> Result<(), embedded_cli::service::HelpError> { 54 | <$from_ty>::command_help(parent, command, writer) 55 | } 56 | } 57 | 58 | impl $crate::wrapper::CommandConvert for $to_ty { 59 | fn convert( 60 | cmd: embedded_cli::command::RawCommand<'_>, 61 | ) -> Result { 62 | let $var_name = <$from_ty as embedded_cli::service::FromRaw>::parse(cmd)?; 63 | let cmd = $conversion; 64 | Ok(cmd) 65 | } 66 | } 67 | }; 68 | } 69 | 70 | #[derive(Clone, Debug, Eq, PartialEq)] 71 | pub enum Arg { 72 | DoubleDash, 73 | LongOption(String), 74 | ShortOption(char), 75 | Value(String), 76 | } 77 | 78 | #[derive(Clone, Debug, Eq, PartialEq)] 79 | pub struct RawCommand { 80 | pub name: String, 81 | pub args: Vec, 82 | } 83 | 84 | impl_convert! {CliRawCommand<'_> => RawCommand, command, { 85 | match command { 86 | cmd => cmd.into(), 87 | } 88 | }} 89 | 90 | impl<'a> From> for RawCommand { 91 | fn from(value: CliRawCommand<'a>) -> Self { 92 | Self { 93 | name: value.name().to_string(), 94 | args: value 95 | .args() 96 | .args() 97 | .map(|arg| match arg { 98 | CliArg::DoubleDash => Arg::DoubleDash, 99 | CliArg::LongOption(name) => Arg::LongOption(name.to_string()), 100 | CliArg::ShortOption(name) => Arg::ShortOption(name), 101 | CliArg::Value(value) => Arg::Value(value.to_string()), 102 | }) 103 | .collect(), 104 | } 105 | } 106 | } 107 | 108 | #[derive(Debug)] 109 | pub struct State { 110 | written: Vec, 111 | commands: Vec>, 112 | } 113 | 114 | impl Default for State { 115 | fn default() -> Self { 116 | Self { 117 | written: Default::default(), 118 | commands: Default::default(), 119 | } 120 | } 121 | } 122 | 123 | #[derive(Clone, Debug, Eq, PartialEq)] 124 | pub enum ParseError { 125 | MissingRequiredArgument { name: String }, 126 | 127 | ParseValueError { value: String, expected: String }, 128 | 129 | UnexpectedArgument { value: String }, 130 | 131 | UnexpectedLongOption { name: String }, 132 | 133 | UnexpectedShortOption { name: char }, 134 | 135 | UnknownCommand, 136 | Other, 137 | } 138 | 139 | impl<'a> From> for ParseError { 140 | fn from(value: CliParseError<'a>) -> Self { 141 | match value { 142 | CliParseError::MissingRequiredArgument { name } => { 143 | Self::MissingRequiredArgument { name: name.into() } 144 | } 145 | CliParseError::ParseValueError { value, expected } => Self::ParseValueError { 146 | value: value.into(), 147 | expected: expected.into(), 148 | }, 149 | CliParseError::UnexpectedArgument { value } => Self::UnexpectedArgument { 150 | value: value.into(), 151 | }, 152 | CliParseError::UnexpectedLongOption { name } => { 153 | Self::UnexpectedLongOption { name: name.into() } 154 | } 155 | CliParseError::UnexpectedShortOption { name } => { 156 | Self::UnexpectedShortOption { name: name } 157 | } 158 | CliParseError::UnknownCommand => Self::UnknownCommand, 159 | _ => Self::Other, 160 | } 161 | } 162 | } 163 | 164 | pub struct CliWrapper { 165 | /// Actual cli object 166 | cli: Cli, Infallible, &'static mut [u8], &'static mut [u8]>, 167 | 168 | handler: Option< 169 | Box, Infallible>, T) -> Result<(), Infallible>>, 170 | >, 171 | 172 | state: Rc>>, 173 | 174 | terminal: Terminal, 175 | } 176 | 177 | struct App { 178 | handler: Option< 179 | Box, Infallible>, T) -> Result<(), Infallible>>, 180 | >, 181 | state: Rc>>, 182 | } 183 | 184 | impl CommandProcessor, Infallible> for App { 185 | fn process<'a>( 186 | &mut self, 187 | cli: &mut CliHandle<'_, Writer, Infallible>, 188 | command: CliRawCommand<'a>, 189 | ) -> Result<(), ProcessError<'a, Infallible>> { 190 | let command = T::convert(command); 191 | 192 | self.state.borrow_mut().commands.push(command.clone()); 193 | if let (Some(handler), Ok(command)) = (&mut self.handler, command) { 194 | handler(cli, command)?; 195 | } 196 | Ok(()) 197 | } 198 | } 199 | 200 | impl Default for CliWrapper { 201 | fn default() -> Self { 202 | Self::new() 203 | } 204 | } 205 | 206 | impl CliWrapper { 207 | pub fn builder() -> CliWrapperBuilder { 208 | CliWrapperBuilder { 209 | command_size: 80, 210 | history_size: 500, 211 | prompt: None, 212 | _ph: PhantomData, 213 | } 214 | } 215 | 216 | pub fn new() -> Self { 217 | Self::builder().build() 218 | } 219 | 220 | pub fn process_str(&mut self, text: &str) { 221 | let mut app = App { 222 | handler: self.handler.take(), 223 | state: self.state.clone(), 224 | }; 225 | for b in text.as_bytes() { 226 | self.cli.process_byte::(*b, &mut app).unwrap(); 227 | } 228 | 229 | self.handler = app.handler.take(); 230 | self.update_terminal(); 231 | } 232 | 233 | pub fn send_backspace(&mut self) { 234 | self.process_str("\x08") 235 | } 236 | 237 | pub fn send_down(&mut self) { 238 | self.process_str("\x1B[B") 239 | } 240 | 241 | pub fn send_enter(&mut self) { 242 | self.process_str("\n") 243 | } 244 | 245 | pub fn send_left(&mut self) { 246 | self.process_str("\x1B[D") 247 | } 248 | 249 | pub fn send_right(&mut self) { 250 | self.process_str("\x1B[C") 251 | } 252 | 253 | pub fn send_tab(&mut self) { 254 | self.process_str("\t") 255 | } 256 | 257 | pub fn send_up(&mut self) { 258 | self.process_str("\x1B[A") 259 | } 260 | 261 | pub fn set_handler( 262 | &mut self, 263 | handler: impl FnMut(&mut CliHandle<'_, Writer, Infallible>, T) -> Result<(), Infallible> 264 | + 'static, 265 | ) { 266 | self.handler = Some(Box::new(handler)); 267 | } 268 | 269 | pub fn set_prompt(&mut self, prompt: &'static str) { 270 | self.cli.set_prompt(prompt).unwrap(); 271 | self.update_terminal(); 272 | } 273 | 274 | pub fn received_commands(&self) -> Vec> { 275 | self.state.borrow().commands.to_vec() 276 | } 277 | 278 | pub fn terminal(&self) -> &Terminal { 279 | &self.terminal 280 | } 281 | 282 | pub fn write_str(&mut self, text: &str) { 283 | self.cli.write(|writer| writer.write_str(text)).unwrap(); 284 | self.update_terminal(); 285 | } 286 | 287 | fn update_terminal(&mut self) { 288 | for byte in self.state.borrow_mut().written.drain(..) { 289 | self.terminal.receive_byte(byte) 290 | } 291 | } 292 | } 293 | 294 | #[derive(Debug)] 295 | pub struct CliWrapperBuilder { 296 | command_size: usize, 297 | history_size: usize, 298 | prompt: Option<&'static str>, 299 | _ph: PhantomData, 300 | } 301 | 302 | impl CliWrapperBuilder { 303 | pub fn build(self) -> CliWrapper { 304 | let state = Rc::new(RefCell::new(State::default())); 305 | 306 | let writer = Writer { 307 | state: state.clone(), 308 | }; 309 | 310 | //TODO: impl Buffer for Vec so no need to leak 311 | let builder = CliBuilder::default() 312 | .writer(writer) 313 | .command_buffer(vec![0; self.command_size].leak()) 314 | .history_buffer(vec![0; self.history_size].leak()); 315 | let builder = if let Some(prompt) = self.prompt { 316 | builder.prompt(prompt) 317 | } else { 318 | builder 319 | }; 320 | let cli = builder.build().unwrap(); 321 | 322 | let terminal = Terminal::new(); 323 | let mut wrapper = CliWrapper { 324 | cli, 325 | handler: None, 326 | state, 327 | terminal, 328 | }; 329 | wrapper.update_terminal(); 330 | wrapper 331 | } 332 | 333 | pub fn prompt(mut self, prompt: &'static str) -> Self { 334 | self.prompt = Some(prompt); 335 | self 336 | } 337 | } 338 | 339 | pub struct Writer { 340 | state: Rc>>, 341 | } 342 | 343 | impl ErrorType for Writer { 344 | type Error = Infallible; 345 | } 346 | 347 | impl embedded_io::Write for Writer { 348 | fn write(&mut self, buf: &[u8]) -> Result { 349 | self.state.borrow_mut().written.extend_from_slice(buf); 350 | Ok(buf.len()) 351 | } 352 | 353 | fn flush(&mut self) -> Result<(), Self::Error> { 354 | Ok(()) 355 | } 356 | } 357 | -------------------------------------------------------------------------------- /embedded-cli/tests/cli/writer.rs: -------------------------------------------------------------------------------- 1 | use crate::wrapper::CliWrapper; 2 | 3 | use crate::terminal::assert_terminal; 4 | 5 | #[test] 6 | fn write_external() { 7 | let mut cli = CliWrapper::default(); 8 | 9 | assert_terminal!(cli.terminal(), 2, vec!["$"]); 10 | 11 | cli.write_str("test"); 12 | 13 | assert_terminal!(cli.terminal(), 2, vec!["test", "$"]); 14 | 15 | cli.process_str("set"); 16 | 17 | assert_terminal!(cli.terminal(), 5, vec!["test", "$ set"]); 18 | 19 | cli.write_str("abc"); 20 | 21 | assert_terminal!(cli.terminal(), 5, vec!["test", "abc", "$ set"]); 22 | 23 | cli.write_str("def\r\n"); 24 | 25 | assert_terminal!(cli.terminal(), 5, vec!["test", "abc", "def", "$ set"]); 26 | 27 | cli.write_str("gh\r\n\r\n"); 28 | 29 | assert_terminal!( 30 | cli.terminal(), 31 | 5, 32 | vec!["test", "abc", "def", "gh", "", "$ set"] 33 | ); 34 | } 35 | 36 | #[test] 37 | fn write_from_service() { 38 | let mut cli = CliWrapper::default(); 39 | 40 | cli.set_handler(|cli, cmd| { 41 | cli.writer().write_str(r#"from command ""#)?; 42 | cli.writer().write_str(&cmd.name)?; 43 | cli.writer().writeln_str(r#"""#)?; 44 | cli.writer().write_str("another line")?; 45 | Ok(()) 46 | }); 47 | 48 | assert_terminal!(cli.terminal(), 2, vec!["$"]); 49 | 50 | cli.process_str("set 123"); 51 | cli.send_enter(); 52 | 53 | assert_terminal!( 54 | cli.terminal(), 55 | 2, 56 | vec!["$ set 123", r#"from command "set""#, "another line", "$"] 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /examples/arduino/.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | target = 'avr-none' 3 | rustflags = ["-C", "target-cpu=atmega328p"] 4 | 5 | [target.'cfg(target_arch = "avr")'] 6 | runner = "ravedude nano -cb 115200" 7 | 8 | [unstable] 9 | build-std = ["core"] 10 | -------------------------------------------------------------------------------- /examples/arduino/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /examples/arduino/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "arduino-cli" 3 | version = "0.1.0" 4 | authors = ["funbiscuit "] 5 | edition = "2021" 6 | license = "MIT OR Apache-2.0" 7 | 8 | [[bin]] 9 | name = "arduino-cli" 10 | test = false 11 | bench = false 12 | 13 | [dependencies] 14 | embedded-cli = { path = "../../embedded-cli", features = ["macros"] } 15 | 16 | avr-progmem = "0.4.0" 17 | embedded-io = "0.6.1" 18 | panic-halt = "0.2.0" 19 | ufmt = "0.2.0" 20 | nb = "1.1.0" 21 | embedded-hal = "1.0.0" 22 | 23 | [dependencies.arduino-hal] 24 | git = "https://github.com/rahix/avr-hal" 25 | rev = "4c9c44c314eb061ee20556ef10d45dea36e75ee4" 26 | features = ["arduino-nano"] 27 | 28 | # Configure the build for minimal size - AVRs have very little program memory 29 | [profile.dev] 30 | panic = "abort" 31 | lto = true 32 | opt-level = "s" 33 | 34 | [profile.release] 35 | panic = "abort" 36 | codegen-units = 1 37 | debug = true 38 | lto = true 39 | opt-level = "s" 40 | -------------------------------------------------------------------------------- /examples/arduino/README.md: -------------------------------------------------------------------------------- 1 | # Arduino example 2 | 3 | This example shows how to build cli with Arduino Nano. 4 | Another Arduino can also be used, but you will have to tweak configs. 5 | Example uses ~16KiB of ROM and ~0.6KiB of static RAM. 6 | Most of RAM is taken by derived implementations for help and autocomplete 7 | that don't use progmem. In future this should be fixed. 8 | 9 | # Running 10 | ## Linux 11 | 12 | Run with: 13 | ```shell 14 | RAVEDUDE_PORT=/dev/ttyUSB0 cargo run --release 15 | ``` 16 | 17 | After flashing is completed, disconnect and reconnect with more 18 | appropriate terminal. For example [tio](https://github.com/tio/tio): 19 | 20 | ```shell 21 | tio /dev/ttyUSB0 --map ODELBS 22 | ``` 23 | 24 | # Memory usage 25 | 26 | Memory usage might vary depending on compiler version, build environment and library version. 27 | You can run `memory.sh` script to calculate memory usage of this arduino example with different activated features 28 | of cli library. 29 | 30 | To analyze ROM usage: 31 | 32 | ```shell 33 | cargo bloat --release 34 | ``` 35 | 36 | To analyze static RAM: 37 | ```shell 38 | avr-objdump -s -j .data target/avr-atmega328p/release/arduino-cli.elf 39 | ``` 40 | -------------------------------------------------------------------------------- /examples/arduino/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/funbiscuit/embedded-cli-rs/643cd6c69840f34a6681f6dd5409cea4f8ba2ff0/examples/arduino/demo.gif -------------------------------------------------------------------------------- /examples/arduino/memory.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # This script measures ROM and static RAM memory usage of Arduino example with different features enabled 3 | # Memory info should be updated at following places: 4 | # /README.md in Demo section 5 | # /README.md in Memory section 6 | # /examples/arduino/README.md in example description 7 | 8 | memory_file="target/MEMORY.md" 9 | 10 | echo "## Memory usage" >$memory_file 11 | 12 | echo "| Features | ROM, bytes | Static RAM, bytes |" >>$memory_file 13 | echo "|----------|:----------:|:-----------------:|" >>$memory_file 14 | 15 | array=("autocomplete" "history" "help") 16 | n=${#array[@]} 17 | for ((i = 0; i < (1 << n); i++)); do 18 | list=() 19 | list_md=() 20 | for ((j = 0; j < n; j++)); do 21 | if (((1 << j) & i)); then 22 | list+=("${array[j]}") 23 | list_md+=("\`${array[j]}\`") 24 | fi 25 | done 26 | features=$( 27 | IFS=, 28 | echo "${list[*]}" 29 | ) 30 | features_md=$( 31 | IFS=' ' 32 | echo "${list_md[*]}" 33 | ) 34 | echo "Measuring features: $features" 35 | 36 | cp Cargo.toml Cargo.toml.bak 37 | 38 | cargo remove embedded-cli 39 | cargo add embedded-cli --path "../../embedded-cli" \ 40 | --no-default-features \ 41 | --features "macros, $features" 42 | 43 | cargo build --release 44 | 45 | ram_usage=$(avr-nm -Crtd --size-sort \ 46 | target/avr-atmega328p/release/arduino-cli.elf | 47 | grep -i ' [dbvr] ' | 48 | awk -F " " '{Total=Total+$1} END{print Total}' -) 49 | 50 | rom_usage=$(cargo bloat --release --message-format json | jq -cs '.[0]["text-section-size"]') 51 | 52 | echo "| $features_md | $rom_usage | $ram_usage |" >>$memory_file 53 | echo "$features: ROM=$rom_usage RAM=$ram_usage" 54 | 55 | mv Cargo.toml.bak Cargo.toml 56 | done 57 | -------------------------------------------------------------------------------- /examples/arduino/rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "nightly" 3 | components = ["rust-src"] 4 | -------------------------------------------------------------------------------- /examples/arduino/src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(rust_2018_idioms)] 2 | #![no_std] 3 | #![no_main] 4 | 5 | use core::convert::Infallible; 6 | 7 | use arduino_hal::hal::port; 8 | use arduino_hal::pac::USART0; 9 | use arduino_hal::port::mode; 10 | use arduino_hal::port::Pin; 11 | use arduino_hal::prelude::*; 12 | use arduino_hal::usart::UsartWriter; 13 | use avr_progmem::progmem_str as F; 14 | use embedded_cli::cli::CliBuilder; 15 | use embedded_cli::cli::CliHandle; 16 | use embedded_cli::Command; 17 | use embedded_io::ErrorType; 18 | use panic_halt as _; 19 | use ufmt::uwrite; 20 | use ufmt::uwriteln; 21 | 22 | #[derive(Debug, Command)] 23 | enum BaseCommand<'a> { 24 | /// Control LEDs 25 | Led { 26 | /// LED id 27 | #[arg(long)] 28 | id: u8, 29 | 30 | #[command(subcommand)] 31 | command: LedCommand, 32 | }, 33 | 34 | /// Control ADC 35 | Adc { 36 | /// ADC id 37 | #[arg(long)] 38 | id: u8, 39 | 40 | #[command(subcommand)] 41 | command: AdcCommand<'a>, 42 | }, 43 | 44 | /// Show some status 45 | Status, 46 | } 47 | 48 | #[derive(Debug, Command)] 49 | enum LedCommand { 50 | /// Get current LED value 51 | Get, 52 | 53 | /// Set LED value 54 | Set { 55 | /// LED brightness 56 | value: u8, 57 | }, 58 | } 59 | 60 | #[derive(Debug, Command)] 61 | enum AdcCommand<'a> { 62 | /// Read ADC value 63 | Read { 64 | /// Print extra info 65 | #[arg(short = 'V', long)] 66 | verbose: bool, 67 | 68 | /// Sample count (16 by default) 69 | #[arg(long)] 70 | samples: Option, 71 | 72 | #[arg(long)] 73 | sampler: &'a str, 74 | }, 75 | } 76 | 77 | /// Wrapper around usart so we can impl embedded_io::Write 78 | /// which is required for cli 79 | struct Writer(UsartWriter, Pin>); 80 | 81 | impl ErrorType for Writer { 82 | type Error = Infallible; 83 | } 84 | 85 | impl embedded_io::Write for Writer { 86 | fn write(&mut self, buf: &[u8]) -> Result { 87 | for &b in buf { 88 | nb::block!(self.0.write(b)).void_unwrap(); 89 | } 90 | Ok(buf.len()) 91 | } 92 | 93 | fn flush(&mut self) -> Result<(), Self::Error> { 94 | nb::block!(self.0.flush()).void_unwrap(); 95 | Ok(()) 96 | } 97 | } 98 | 99 | struct AppState { 100 | led_brightness: [u8; 4], 101 | num_commands: usize, 102 | } 103 | 104 | fn on_led( 105 | cli: &mut CliHandle<'_, Writer, Infallible>, 106 | state: &mut AppState, 107 | id: u8, 108 | command: LedCommand, 109 | ) -> Result<(), Infallible> { 110 | state.num_commands += 1; 111 | 112 | if id as usize > state.led_brightness.len() { 113 | uwrite!(cli.writer(), "{}{}{}", F!("LED"), id, F!(" not found"))?; 114 | } else { 115 | match command { 116 | LedCommand::Get => { 117 | uwrite!( 118 | cli.writer(), 119 | "{}{}{}{}", 120 | F!("Current LED"), 121 | id, 122 | F!(" brightness: "), 123 | state.led_brightness[id as usize] 124 | )?; 125 | } 126 | LedCommand::Set { value } => { 127 | state.led_brightness[id as usize] = value; 128 | uwrite!( 129 | cli.writer(), 130 | "{}{}{}{}", 131 | F!("Setting LED"), 132 | id, 133 | F!(" brightness to "), 134 | state.led_brightness[id as usize] 135 | )?; 136 | } 137 | } 138 | } 139 | 140 | Ok(()) 141 | } 142 | 143 | fn on_adc( 144 | cli: &mut CliHandle<'_, Writer, Infallible>, 145 | state: &mut AppState, 146 | id: u8, 147 | command: AdcCommand<'_>, 148 | ) -> Result<(), Infallible> { 149 | state.num_commands += 1; 150 | 151 | match command { 152 | AdcCommand::Read { 153 | verbose, 154 | samples, 155 | sampler, 156 | } => { 157 | let samples = samples.unwrap_or(16); 158 | if verbose { 159 | cli.writer().write_str(F!("Performing sampling with "))?; 160 | cli.writer().write_str(sampler)?; 161 | uwriteln!( 162 | cli.writer(), 163 | "{}{}{}", 164 | F!("\nUsing "), 165 | samples, 166 | F!(" samples") 167 | )?; 168 | } 169 | uwrite!( 170 | cli.writer(), 171 | "{}{}{}{}", 172 | F!("Current ADC"), 173 | id, 174 | F!(" readings: "), 175 | 43 176 | )?; 177 | } 178 | } 179 | Ok(()) 180 | } 181 | 182 | fn on_status( 183 | cli: &mut CliHandle<'_, Writer, Infallible>, 184 | state: &mut AppState, 185 | ) -> Result<(), Infallible> { 186 | state.num_commands += 1; 187 | uwriteln!(cli.writer(), "{}{}", F!("Received: "), state.num_commands)?; 188 | Ok(()) 189 | } 190 | 191 | #[arduino_hal::entry] 192 | fn main() -> ! { 193 | try_run(); 194 | 195 | // if run failed, stop execution 196 | panic!() 197 | } 198 | 199 | fn try_run() -> Option<()> { 200 | let dp = arduino_hal::Peripherals::take()?; 201 | let pins = arduino_hal::pins!(dp); 202 | 203 | let mut led = pins.d13.into_output(); 204 | let serial = arduino_hal::default_serial!(dp, pins, 115200); 205 | let (mut rx, tx) = serial.split(); 206 | 207 | let writer = Writer(tx); 208 | 209 | led.set_low(); 210 | 211 | // create static buffers for use in cli (so we're not using stack memory) 212 | // History buffer is 1 byte longer so max command fits in it (it requires extra byte at end) 213 | // SAFETY: buffers are passed to cli and are used by cli only 214 | let (command_buffer, history_buffer) = unsafe { 215 | static mut COMMAND_BUFFER: [u8; 40] = [0; 40]; 216 | static mut HISTORY_BUFFER: [u8; 41] = [0; 41]; 217 | #[allow(static_mut_refs)] 218 | (COMMAND_BUFFER.as_mut(), HISTORY_BUFFER.as_mut()) 219 | }; 220 | let mut cli = CliBuilder::default() 221 | .writer(writer) 222 | .command_buffer(command_buffer) 223 | .history_buffer(history_buffer) 224 | .build() 225 | .ok()?; 226 | 227 | // Create global state, that will be used for entire application 228 | let mut state = AppState { 229 | led_brightness: [0; 4], 230 | num_commands: 0, 231 | }; 232 | 233 | let _ = cli.write(|writer| { 234 | // storing big text in progmem 235 | // for small text it's usually better to use normal &str literals 236 | uwrite!( 237 | writer, 238 | "{}", 239 | F!("Cli is running. 240 | Type \"help\" for a list of commands. 241 | Use backspace and tab to remove chars and autocomplete. 242 | Use up and down for history navigation. 243 | Use left and right to move inside input.") 244 | )?; 245 | Ok(()) 246 | }); 247 | 248 | loop { 249 | arduino_hal::delay_ms(10); 250 | led.toggle(); 251 | 252 | let byte = nb::block!(rx.read()).void_unwrap(); 253 | // Process incoming byte 254 | // Command type is specified for autocompletion and help 255 | // Processor accepts closure where we can process parsed command 256 | // we can use different command and processor with each call 257 | let _ = cli.process_byte::, _>( 258 | byte, 259 | &mut BaseCommand::processor(|cli, command| match command { 260 | BaseCommand::Led { id, command } => on_led(cli, &mut state, id, command), 261 | BaseCommand::Adc { id, command } => on_adc(cli, &mut state, id, command), 262 | BaseCommand::Status => on_status(cli, &mut state), 263 | }), 264 | ); 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /examples/desktop/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "desktop" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | embedded-cli = { path = "../../embedded-cli" } 8 | embedded-io = "0.6.1" 9 | rand = "0.8.5" 10 | termion = "3.0.0" 11 | ufmt = "0.2.0" 12 | -------------------------------------------------------------------------------- /examples/desktop/README.md: -------------------------------------------------------------------------------- 1 | # Desktop example 2 | 3 | This example used to quickly demonstrate CLI features without using actual device. 4 | 5 | # Running 6 | 7 | While inside `examples/desktop` directory: 8 | ```shell 9 | cargo run 10 | ``` 11 | -------------------------------------------------------------------------------- /examples/desktop/src/main.rs: -------------------------------------------------------------------------------- 1 | #![warn(rust_2018_idioms)] 2 | 3 | use embedded_cli::cli::{CliBuilder, CliHandle}; 4 | use embedded_cli::codes; 5 | use embedded_cli::Command; 6 | use embedded_io::{ErrorType, Write}; 7 | use std::convert::Infallible; 8 | use std::io::{stdin, stdout, Stdout, Write as _}; 9 | use termion::event::{Event, Key}; 10 | use termion::input::TermRead; 11 | use termion::raw::{IntoRawMode, RawTerminal}; 12 | 13 | use ufmt::{uwrite, uwriteln}; 14 | 15 | #[derive(Debug, Command)] 16 | enum BaseCommand<'a> { 17 | /// Control LEDs 18 | Led { 19 | /// LED id 20 | #[arg(long)] 21 | id: u8, 22 | 23 | #[command(subcommand)] 24 | command: LedCommand, 25 | }, 26 | 27 | /// Control ADC 28 | Adc { 29 | /// ADC id 30 | #[arg(long)] 31 | id: u8, 32 | 33 | #[command(subcommand)] 34 | command: AdcCommand<'a>, 35 | }, 36 | 37 | /// Show some status 38 | Status, 39 | 40 | /// Stop CLI and exit 41 | Exit, 42 | } 43 | 44 | #[derive(Debug, Command)] 45 | enum LedCommand { 46 | /// Get current LED value 47 | Get, 48 | 49 | /// Set LED value 50 | Set { 51 | /// LED brightness 52 | value: u8, 53 | }, 54 | } 55 | 56 | #[derive(Debug, Command)] 57 | enum AdcCommand<'a> { 58 | /// Read ADC value 59 | Read { 60 | /// Print extra info 61 | #[arg(short = 'V', long)] 62 | verbose: bool, 63 | 64 | /// Sample count (16 by default) 65 | #[arg(long)] 66 | samples: Option, 67 | 68 | #[arg(long)] 69 | sampler: &'a str, 70 | }, 71 | } 72 | 73 | pub struct Writer { 74 | stdout: RawTerminal, 75 | } 76 | 77 | impl ErrorType for Writer { 78 | type Error = Infallible; 79 | } 80 | 81 | impl Write for Writer { 82 | fn write(&mut self, buf: &[u8]) -> Result { 83 | self.stdout.write_all(buf).unwrap(); 84 | Ok(buf.len()) 85 | } 86 | 87 | fn flush(&mut self) -> Result<(), Self::Error> { 88 | self.stdout.flush().unwrap(); 89 | Ok(()) 90 | } 91 | } 92 | 93 | struct AppState { 94 | led_brightness: [u8; 4], 95 | num_commands: usize, 96 | should_exit: bool, 97 | } 98 | 99 | fn on_led( 100 | cli: &mut CliHandle<'_, Writer, Infallible>, 101 | state: &mut AppState, 102 | id: u8, 103 | command: LedCommand, 104 | ) -> Result<(), Infallible> { 105 | state.num_commands += 1; 106 | 107 | if id as usize > state.led_brightness.len() { 108 | uwrite!(cli.writer(), "LED{} not found", id)?; 109 | } else { 110 | match command { 111 | LedCommand::Get => { 112 | uwrite!( 113 | cli.writer(), 114 | "Current LED{} brightness: {}", 115 | id, 116 | state.led_brightness[id as usize] 117 | )?; 118 | } 119 | LedCommand::Set { value } => { 120 | state.led_brightness[id as usize] = value; 121 | uwrite!(cli.writer(), "Setting LED{} brightness to {}", id, value)?; 122 | } 123 | } 124 | } 125 | 126 | Ok(()) 127 | } 128 | 129 | fn on_adc( 130 | cli: &mut CliHandle<'_, Writer, Infallible>, 131 | state: &mut AppState, 132 | id: u8, 133 | command: AdcCommand<'_>, 134 | ) -> Result<(), Infallible> { 135 | state.num_commands += 1; 136 | 137 | match command { 138 | AdcCommand::Read { 139 | verbose, 140 | samples, 141 | sampler, 142 | } => { 143 | let samples = samples.unwrap_or(16); 144 | if verbose { 145 | cli.writer().write_str("Performing sampling with ")?; 146 | cli.writer().write_str(sampler)?; 147 | uwriteln!(cli.writer(), "\nUsing {} samples", samples)?; 148 | } 149 | uwrite!( 150 | cli.writer(), 151 | "Current ADC{} readings: {}", 152 | id, 153 | rand::random::() 154 | )?; 155 | } 156 | } 157 | Ok(()) 158 | } 159 | 160 | fn on_status( 161 | cli: &mut CliHandle<'_, Writer, Infallible>, 162 | state: &mut AppState, 163 | ) -> Result<(), Infallible> { 164 | state.num_commands += 1; 165 | uwriteln!(cli.writer(), "Received: {}", state.num_commands)?; 166 | Ok(()) 167 | } 168 | 169 | fn main() { 170 | let stdout = stdout().into_raw_mode().unwrap(); 171 | 172 | let writer = Writer { stdout }; 173 | 174 | const CMD_BUFFER_SIZE: usize = 1024; 175 | const HISTORY_BUFFER_SIZE: usize = 2048; 176 | 177 | // Creating static buffers for command history to avoid using stack memory 178 | let (command_buffer, history_buffer) = unsafe { 179 | static mut COMMAND_BUFFER: [u8; CMD_BUFFER_SIZE] = [0; CMD_BUFFER_SIZE]; 180 | static mut HISTORY_BUFFER: [u8; HISTORY_BUFFER_SIZE] = [0; HISTORY_BUFFER_SIZE]; 181 | #[allow(static_mut_refs)] 182 | (COMMAND_BUFFER.as_mut(), HISTORY_BUFFER.as_mut()) 183 | }; 184 | 185 | // Setup CLI with command buffer, history buffer, and a writer for output 186 | let mut cli = CliBuilder::default() 187 | .writer(writer) 188 | .command_buffer(command_buffer) 189 | .history_buffer(history_buffer) 190 | .build() 191 | .expect("Failed to build CLI"); 192 | 193 | // Setting the CLI prompt 194 | let _ = cli.set_prompt("main$ "); 195 | 196 | // Create global state, that will be used for entire application 197 | let mut state = AppState { 198 | led_brightness: rand::random(), 199 | num_commands: 0, 200 | should_exit: false, 201 | }; 202 | 203 | cli.write(|writer| { 204 | uwrite!( 205 | writer, 206 | "Cli is running. Press 'Esc' to exit 207 | Type \"help\" for a list of commands. 208 | Use backspace and tab to remove chars and autocomplete. 209 | Use up and down for history navigation. 210 | Use left and right to move inside input." 211 | )?; 212 | Ok(()) 213 | }) 214 | .unwrap(); 215 | 216 | let stdin = stdin(); 217 | for c in stdin.events() { 218 | let evt = c.unwrap(); 219 | let bytes = match evt { 220 | Event::Key(Key::Esc) => break, 221 | Event::Key(Key::Up) => vec![codes::ESCAPE, b'[', b'A'], 222 | Event::Key(Key::Down) => vec![codes::ESCAPE, b'[', b'B'], 223 | Event::Key(Key::Right) => vec![codes::ESCAPE, b'[', b'C'], 224 | Event::Key(Key::Left) => vec![codes::ESCAPE, b'[', b'D'], 225 | Event::Key(Key::BackTab) => vec![codes::TABULATION], 226 | Event::Key(Key::Backspace) => vec![codes::BACKSPACE], 227 | Event::Key(Key::Char(c)) => { 228 | let mut buf = [0; 4]; 229 | c.encode_utf8(&mut buf).as_bytes().to_vec() 230 | } 231 | _ => continue, 232 | }; 233 | // Process incoming byte 234 | // Command type is specified for autocompletion and help 235 | // Processor accepts closure where we can process parsed command 236 | // we can use different command and processor with each call 237 | // TODO: add example of login that uses different states 238 | for byte in bytes { 239 | cli.process_byte::, _>( 240 | byte, 241 | &mut BaseCommand::processor(|cli, command| match command { 242 | BaseCommand::Led { id, command } => on_led(cli, &mut state, id, command), 243 | BaseCommand::Adc { id, command } => on_adc(cli, &mut state, id, command), 244 | BaseCommand::Status => on_status(cli, &mut state), 245 | BaseCommand::Exit => { 246 | state.should_exit = true; 247 | cli.writer().write_str("Cli will shutdown now") 248 | } 249 | }), 250 | ) 251 | .unwrap(); 252 | } 253 | 254 | if state.should_exit { 255 | break; 256 | } 257 | } 258 | } 259 | --------------------------------------------------------------------------------