diff --git a/Cargo.lock b/Cargo.lock index 6d5ba70..5b3195d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -95,14 +95,22 @@ checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6" [[package]] name = "lexarg" version = "0.1.0" +dependencies = [ + "lexarg-error", + "lexarg-parser", +] [[package]] name = "lexarg-error" version = "0.1.0" dependencies = [ - "lexarg", + "lexarg-parser", ] +[[package]] +name = "lexarg-parser" +version = "0.1.0" + [[package]] name = "libtest-lexarg" version = "0.1.0" @@ -130,8 +138,8 @@ version = "0.1.0" dependencies = [ "anstream", "anstyle", - "lexarg", "lexarg-error", + "lexarg-parser", "libtest-lexarg", "serde", "serde_json", diff --git a/crates/lexarg-error/Cargo.toml b/crates/lexarg-error/Cargo.toml index 77a1c43..34618bb 100644 --- a/crates/lexarg-error/Cargo.toml +++ b/crates/lexarg-error/Cargo.toml @@ -27,7 +27,7 @@ pre-release-replacements = [ default = [] [dependencies] -lexarg = { "version" = "0.1.0", path = "../lexarg" } +lexarg-parser = { "version" = "0.1.0", path = "../lexarg-parser" } [dev-dependencies] diff --git a/crates/lexarg-error/examples/hello-error.rs b/crates/lexarg-error/examples/hello-error.rs index d75d4e7..d995ac1 100644 --- a/crates/lexarg-error/examples/hello-error.rs +++ b/crates/lexarg-error/examples/hello-error.rs @@ -1,5 +1,4 @@ use lexarg_error::ErrorContext; -use lexarg_error::Result; struct Args { thing: String, @@ -7,15 +6,15 @@ struct Args { shout: bool, } -fn parse_args() -> Result { +fn parse_args() -> Result { #![allow(clippy::enum_glob_use)] - use lexarg::Arg::*; + use lexarg_parser::Arg::*; let mut thing = None; let mut number = 1; let mut shout = false; let raw = std::env::args_os().collect::>(); - let mut parser = lexarg::Parser::new(&raw); + let mut parser = lexarg_parser::Parser::new(&raw); let bin_name = parser .next_raw() .expect("nothing parsed yet so no attached lingering") @@ -23,27 +22,36 @@ fn parse_args() -> Result { while let Some(arg) = parser.next_arg() { match arg { Short("n") | Long("number") => { - let value = parser - .next_flag_value() - .ok_or_else(|| ErrorContext::msg("missing required value").within(arg))?; + let value = parser.next_flag_value().ok_or_else(|| { + ErrorContext::msg("missing required value") + .within(arg) + .to_string() + })?; number = value .to_str() .ok_or_else(|| { ErrorContext::msg("invalid number") .unexpected(Value(value)) .within(arg) + .to_string() })? .parse() - .map_err(|e| ErrorContext::msg(e).unexpected(Value(value)).within(arg))?; + .map_err(|e| { + ErrorContext::msg(e) + .unexpected(Value(value)) + .within(arg) + .to_string() + })?; } Long("shout") => { shout = true; } Value(val) if thing.is_none() => { - thing = Some( - val.to_str() - .ok_or_else(|| ErrorContext::msg("invalid string").unexpected(arg))?, - ); + thing = Some(val.to_str().ok_or_else(|| { + ErrorContext::msg("invalid string") + .unexpected(arg) + .to_string() + })?); } Short("h") | Long("help") => { println!("Usage: hello [-n|--number=NUM] [--shout] THING"); @@ -52,22 +60,25 @@ fn parse_args() -> Result { _ => { return Err(ErrorContext::msg("unexpected argument") .unexpected(arg) - .within(Value(bin_name)) - .into()); + .to_string()); } } } Ok(Args { thing: thing - .ok_or_else(|| ErrorContext::msg("missing argument THING").within(Value(bin_name)))? + .ok_or_else(|| { + ErrorContext::msg("missing argument THING") + .within(Value(bin_name)) + .to_string() + })? .to_owned(), number, shout, }) } -fn main() -> Result<()> { +fn main() -> Result<(), String> { let args = parse_args()?; let mut message = format!("Hello {}", args.thing); if args.shout { diff --git a/crates/lexarg-error/src/lib.rs b/crates/lexarg-error/src/lib.rs index facc35a..aaeae46 100644 --- a/crates/lexarg-error/src/lib.rs +++ b/crates/lexarg-error/src/lib.rs @@ -19,52 +19,12 @@ #[cfg(doctest)] pub struct ReadmeDoctests; -/// `Result` that defaults to [`Error`] -pub type Result = std::result::Result; - -/// Argument error type for use with lexarg -pub struct Error { - msg: String, -} - -impl Error { - /// Create a new error object from a printable error message. - #[cold] - pub fn msg(message: M) -> Self - where - M: std::fmt::Display, - { - Self { - msg: message.to_string(), - } - } -} - -impl From> for Error { - #[cold] - fn from(error: ErrorContext<'_>) -> Self { - Self::msg(error.to_string()) - } -} - -impl std::fmt::Debug for Error { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.msg.fmt(formatter) - } -} - -impl std::fmt::Display for Error { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - self.msg.fmt(formatter) - } -} - -/// Collect context for creating an [`Error`] +/// Collect context for creating an error #[derive(Debug)] pub struct ErrorContext<'a> { msg: String, - within: Option>, - unexpected: Option>, + within: Option>, + unexpected: Option>, } impl<'a> ErrorContext<'a> { @@ -81,16 +41,16 @@ impl<'a> ErrorContext<'a> { } } - /// [`Arg`][lexarg::Arg] the error occurred within + /// [`Arg`][lexarg_parser::Arg] the error occurred within #[cold] - pub fn within(mut self, within: lexarg::Arg<'a>) -> Self { + pub fn within(mut self, within: lexarg_parser::Arg<'a>) -> Self { self.within = Some(within); self } - /// The failing [`Arg`][lexarg::Arg] + /// The failing [`Arg`][lexarg_parser::Arg] #[cold] - pub fn unexpected(mut self, unexpected: lexarg::Arg<'a>) -> Self { + pub fn unexpected(mut self, unexpected: lexarg_parser::Arg<'a>) -> Self { self.unexpected = Some(unexpected); self } @@ -112,10 +72,10 @@ impl std::fmt::Display for ErrorContext<'_> { if let Some(unexpected) = &self.unexpected { write!(formatter, ", found `")?; match unexpected { - lexarg::Arg::Short(short) => write!(formatter, "-{short}")?, - lexarg::Arg::Long(long) => write!(formatter, "--{long}")?, - lexarg::Arg::Escape(value) => write!(formatter, "{value}")?, - lexarg::Arg::Value(value) | lexarg::Arg::Unexpected(value) => { + lexarg_parser::Arg::Short(short) => write!(formatter, "-{short}")?, + lexarg_parser::Arg::Long(long) => write!(formatter, "--{long}")?, + lexarg_parser::Arg::Escape(value) => write!(formatter, "{value}")?, + lexarg_parser::Arg::Value(value) | lexarg_parser::Arg::Unexpected(value) => { write!(formatter, "{}", value.to_string_lossy())?; } } @@ -124,10 +84,10 @@ impl std::fmt::Display for ErrorContext<'_> { if let Some(within) = &self.within { write!(formatter, " when parsing `")?; match within { - lexarg::Arg::Short(short) => write!(formatter, "-{short}")?, - lexarg::Arg::Long(long) => write!(formatter, "--{long}")?, - lexarg::Arg::Escape(value) => write!(formatter, "{value}")?, - lexarg::Arg::Value(value) | lexarg::Arg::Unexpected(value) => { + lexarg_parser::Arg::Short(short) => write!(formatter, "-{short}")?, + lexarg_parser::Arg::Long(long) => write!(formatter, "--{long}")?, + lexarg_parser::Arg::Escape(value) => write!(formatter, "{value}")?, + lexarg_parser::Arg::Value(value) | lexarg_parser::Arg::Unexpected(value) => { write!(formatter, "{}", value.to_string_lossy())?; } } diff --git a/crates/lexarg-parser/CHANGELOG.md b/crates/lexarg-parser/CHANGELOG.md new file mode 100644 index 0000000..06b4239 --- /dev/null +++ b/crates/lexarg-parser/CHANGELOG.md @@ -0,0 +1,11 @@ +# Change Log +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/) +and this project adheres to [Semantic Versioning](http://semver.org/). + + +## [Unreleased] - ReleaseDate + + +[Unreleased]: https://github.com/rust-cli/argfile/compare/716170eaa853ddf3032baa9b107eb3e44d6a4124...HEAD diff --git a/crates/lexarg-parser/Cargo.toml b/crates/lexarg-parser/Cargo.toml new file mode 100644 index 0000000..bdd76e2 --- /dev/null +++ b/crates/lexarg-parser/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "lexarg-parser" +version = "0.1.0" +description = "Minimal, API stable CLI parser" +categories = ["command-line-interface"] +keywords = ["args", "arguments", "cli", "parser", "getopt"] +repository.workspace = true +license.workspace = true +edition.workspace = true +rust-version.workspace = true +include.workspace = true + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs", "--generate-link-to-definition"] + +[package.metadata.release] +pre-release-replacements = [ + {file="CHANGELOG.md", search="Unreleased", replace="{{version}}", min=1}, + {file="CHANGELOG.md", search="\\.\\.\\.HEAD", replace="...{{tag_name}}", exactly=1}, + {file="CHANGELOG.md", search="ReleaseDate", replace="{{date}}", min=1}, + {file="CHANGELOG.md", search="", replace="\n## [Unreleased] - ReleaseDate\n", exactly=1}, + {file="CHANGELOG.md", search="", replace="\n[Unreleased]: https://github.com/epage/pytest-rs/compare/{{tag_name}}...HEAD", exactly=1}, +] + +[features] +default = [] + +[dependencies] + +[dev-dependencies] + +[lints] +workspace = true diff --git a/crates/lexarg-parser/LICENSE-APACHE b/crates/lexarg-parser/LICENSE-APACHE new file mode 100644 index 0000000..8f71f43 --- /dev/null +++ b/crates/lexarg-parser/LICENSE-APACHE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/crates/lexarg-parser/LICENSE-MIT b/crates/lexarg-parser/LICENSE-MIT new file mode 100644 index 0000000..a2d0108 --- /dev/null +++ b/crates/lexarg-parser/LICENSE-MIT @@ -0,0 +1,19 @@ +Copyright (c) Individual contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/lexarg-parser/README.md b/crates/lexarg-parser/README.md new file mode 100644 index 0000000..a96a870 --- /dev/null +++ b/crates/lexarg-parser/README.md @@ -0,0 +1,26 @@ +# lexarg + +> Minimal, API stable CLI parser" + +[![Documentation](https://img.shields.io/badge/docs-master-blue.svg)][Documentation] +![License](https://img.shields.io/crates/l/lexarg.svg) +[![Crates Status](https://img.shields.io/crates/v/lexarg.svg)](https://crates.io/crates/lexarg) + +## License + +Licensed under either of + +* Apache License, Version 2.0, ([LICENSE-APACHE](LICENSE-APACHE) or ) +* MIT license ([LICENSE-MIT](LICENSE-MIT) or ) + +at your option. + +### Contribution + +Unless you explicitly state otherwise, any contribution intentionally +submitted for inclusion in the work by you, as defined in the Apache-2.0 +license, shall be dual-licensed as above, without any additional terms or +conditions. + +[Crates.io]: https://crates.io/crates/lexarg +[Documentation]: https://docs.rs/lexarg diff --git a/crates/lexarg-parser/examples/hello-parser.rs b/crates/lexarg-parser/examples/hello-parser.rs new file mode 100644 index 0000000..338b785 --- /dev/null +++ b/crates/lexarg-parser/examples/hello-parser.rs @@ -0,0 +1,61 @@ +struct Args { + thing: String, + number: u32, + shout: bool, +} + +fn parse_args() -> Result { + #![allow(clippy::enum_glob_use)] + use lexarg_parser::Arg::*; + + let mut thing = None; + let mut number = 1; + let mut shout = false; + let raw = std::env::args_os().collect::>(); + let mut parser = lexarg_parser::Parser::new(&raw); + let _bin_name = parser.next_raw(); + while let Some(arg) = parser.next_arg() { + match arg { + Short("n") | Long("number") => { + number = parser + .next_flag_value() + .ok_or("`--number` requires a value")? + .to_str() + .ok_or("invalid number")? + .parse() + .map_err(|_e| "invalid number")?; + } + Long("shout") => { + shout = true; + } + Value(val) if thing.is_none() => { + thing = Some(val.to_str().ok_or("invalid string")?); + } + Short("h") | Long("help") => { + println!("Usage: hello [-n|--number=NUM] [--shout] THING"); + std::process::exit(0); + } + _ => { + return Err("unexpected argument"); + } + } + } + + Ok(Args { + thing: thing.ok_or("missing argument THING")?.to_owned(), + number, + shout, + }) +} + +fn main() -> Result<(), String> { + let args = parse_args()?; + let mut message = format!("Hello {}", args.thing); + if args.shout { + message = message.to_uppercase(); + } + for _ in 0..args.number { + println!("{message}"); + } + Ok(()) +} diff --git a/crates/lexarg/src/ext.rs b/crates/lexarg-parser/src/ext.rs similarity index 100% rename from crates/lexarg/src/ext.rs rename to crates/lexarg-parser/src/ext.rs diff --git a/crates/lexarg-parser/src/lib.rs b/crates/lexarg-parser/src/lib.rs new file mode 100644 index 0000000..0fb62e4 --- /dev/null +++ b/crates/lexarg-parser/src/lib.rs @@ -0,0 +1,875 @@ +//! Minimal, API stable CLI parser +//! +//! Inspired by [lexopt](https://crates.io/crates/lexopt), `lexarg` simplifies the formula down +//! further so it can be used for CLI plugin systems. +//! +//! ## Example +//! +//! ```no_run +#![doc = include_str!("../examples/hello-parser.rs")] +//! ``` + +#![cfg_attr(docsrs, feature(doc_auto_cfg))] +#![allow(clippy::result_unit_err)] +#![warn(missing_debug_implementations)] +#![warn(missing_docs)] +#![warn(clippy::print_stderr)] +#![warn(clippy::print_stdout)] + +#[doc = include_str!("../README.md")] +#[cfg(doctest)] +pub struct ReadmeDoctests; + +mod ext; + +use std::ffi::OsStr; + +use ext::OsStrExt as _; + +/// A parser for command line arguments. +#[derive(Debug, Clone)] +pub struct Parser<'a> { + raw: &'a dyn RawArgs, + current: usize, + state: Option>, + was_attached: bool, +} + +impl<'a> Parser<'a> { + /// Create a parser from an iterator. This is useful for testing among other things. + /// + /// The first item from the iterator **must** be the binary name, as from [`std::env::args_os`]. + /// + /// The iterator is consumed immediately. + /// + /// # Example + /// ``` + /// let args = ["myapp", "-n", "10", "./foo.bar"]; + /// let mut parser = lexarg_parser::Parser::new(&&args[1..]); + /// ``` + pub fn new(raw: &'a dyn RawArgs) -> Self { + Parser { + raw, + current: 0, + state: None, + was_attached: false, + } + } + + /// Get the next option or positional [`Arg`]. + /// + /// Returns `None` if the command line has been exhausted. + /// + /// Returns [`Arg::Unexpected`] on failure + /// + /// Notes: + /// - `=` is always accepted as a [`Arg::Short("=")`]. If that isn't the case in your + /// application, you may want to special case the error for that. + pub fn next_arg(&mut self) -> Option> { + // Always reset + self.was_attached = false; + + match self.state { + Some(State::PendingValue(attached)) => { + // Last time we got `--long=value`, and `value` hasn't been used. + self.state = None; + self.current += 1; + Some(Arg::Unexpected(attached)) + } + Some(State::PendingShorts(valid, invalid, index)) => { + // We're somewhere inside a `-abc` chain. Because we're in `.next_arg()`, not `.next_flag_value()`, we + // can assume that the next character is another option. + if let Some(next_index) = ceil_char_boundary(valid, index) { + if next_index < valid.len() { + self.state = Some(State::PendingShorts(valid, invalid, next_index)); + } else if !invalid.is_empty() { + self.state = Some(State::PendingValue(invalid)); + } else { + // No more flags + self.state = None; + self.current += 1; + } + let flag = &valid[index..next_index]; + Some(Arg::Short(flag)) + } else { + debug_assert_ne!(invalid, ""); + if index == 0 { + panic!("there should have been a `-`") + } else if index == 1 { + // Like long flags, include `-` + let arg = self + .raw + .get(self.current) + .expect("`current` is valid if state is `Shorts`"); + self.state = None; + self.current += 1; + Some(Arg::Unexpected(arg)) + } else { + self.state = None; + self.current += 1; + Some(Arg::Unexpected(invalid)) + } + } + } + Some(State::Escaped) => { + self.state = Some(State::Escaped); + self.next_raw_().map(Arg::Value) + } + None => { + let arg = self.raw.get(self.current)?; + if arg == "--" { + self.state = Some(State::Escaped); + self.current += 1; + Some(Arg::Escape(arg.to_str().expect("`--` is valid UTF-8"))) + } else if arg == "-" { + self.state = None; + self.current += 1; + Some(Arg::Value(arg)) + } else if let Some(long) = arg.strip_prefix("--") { + let (name, value) = long + .split_once("=") + .map(|(n, v)| (n, Some(v))) + .unwrap_or((long, None)); + if name.is_empty() { + self.state = None; + self.current += 1; + Some(Arg::Unexpected(arg)) + } else if let Ok(name) = name.try_str() { + if let Some(value) = value { + self.state = Some(State::PendingValue(value)); + } else { + self.state = None; + self.current += 1; + } + Some(Arg::Long(name)) + } else { + self.state = None; + self.current += 1; + Some(Arg::Unexpected(arg)) + } + } else if arg.starts_with("-") { + let (valid, invalid) = split_nonutf8_once(arg); + let invalid = invalid.unwrap_or_default(); + self.state = Some(State::PendingShorts(valid, invalid, 1)); + self.next_arg() + } else { + self.state = None; + self.current += 1; + Some(Arg::Value(arg)) + } + } + } + } + + /// Get a flag's value + /// + /// This function should normally be called right after seeing a flag that expects a value; + /// positional arguments should be collected with [`Parser::next_arg()`]. + /// + /// A value is collected even if it looks like an option (i.e., starts with `-`). + /// + /// `None` is returned if there is not another applicable flag value, including: + /// - No more arguments are present + /// - `--` was encountered, meaning all remaining arguments are positional + /// - Being called again when the first value was attached (`--flag=value`, `-Fvalue`, `-F=value`) + pub fn next_flag_value(&mut self) -> Option<&'a OsStr> { + if self.was_attached { + debug_assert!(!self.has_pending()); + None + } else if let Some(value) = self.next_attached_value() { + Some(value) + } else { + self.next_detached_value() + } + } + + /// Get a flag's attached value (`--flag=value`, `-Fvalue`, `-F=value`) + /// + /// This is a more specialized variant of [`Parser::next_flag_value`] for when only attached + /// values are allowed, e.g. `--color[=]`. + pub fn next_attached_value(&mut self) -> Option<&'a OsStr> { + match self.state? { + State::PendingValue(attached) => { + self.state = None; + self.current += 1; + self.was_attached = true; + Some(attached) + } + State::PendingShorts(_, _, index) => { + let arg = self + .raw + .get(self.current) + .expect("`current` is valid if state is `Shorts`"); + self.state = None; + self.current += 1; + if index == arg.len() { + None + } else { + // SAFETY: everything preceding `index` were a short flags, making them valid UTF-8 + let remainder = unsafe { ext::split_at(arg, index) }.1; + let remainder = remainder.strip_prefix("=").unwrap_or(remainder); + self.was_attached = true; + Some(remainder) + } + } + State::Escaped => None, + } + } + + fn next_detached_value(&mut self) -> Option<&'a OsStr> { + if self.state == Some(State::Escaped) { + // Escaped values are positional-only + return None; + } + + if self.peek_raw_()? == "--" { + None + } else { + self.next_raw_() + } + } + + /// Get the next argument, independent of what it looks like + /// + /// Returns `Err(())` if an [attached value][Parser::next_attached_value] is present + pub fn next_raw(&mut self) -> Result, ()> { + if self.has_pending() { + Err(()) + } else { + self.was_attached = false; + Ok(self.next_raw_()) + } + } + + /// Collect all remaining arguments, independent of what they look like + /// + /// Returns `Err(())` if an [attached value][Parser::next_attached_value] is present + pub fn remaining_raw(&mut self) -> Result + '_, ()> { + if self.has_pending() { + Err(()) + } else { + self.was_attached = false; + Ok(std::iter::from_fn(|| self.next_raw_())) + } + } + + /// Get the next argument, independent of what it looks like + /// + /// Returns `Err(())` if an [attached value][Parser::next_attached_value] is present + pub fn peek_raw(&self) -> Result, ()> { + if self.has_pending() { + Err(()) + } else { + Ok(self.peek_raw_()) + } + } + + fn peek_raw_(&self) -> Option<&'a OsStr> { + self.raw.get(self.current) + } + + fn next_raw_(&mut self) -> Option<&'a OsStr> { + debug_assert!(!self.has_pending()); + debug_assert!(!self.was_attached); + + let next = self.raw.get(self.current)?; + self.current += 1; + Some(next) + } + + fn has_pending(&self) -> bool { + self.state.as_ref().map(State::has_pending).unwrap_or(false) + } +} + +/// Accessor for unparsed arguments +pub trait RawArgs: std::fmt::Debug + private::Sealed { + /// Returns a reference to an element or subslice depending on the type of index. + /// + /// - If given a position, returns a reference to the element at that position or None if out + /// of bounds. + /// - If given a range, returns the subslice corresponding to that range, or None if out + /// of bounds. + fn get(&self, index: usize) -> Option<&OsStr>; + + /// Returns the number of elements in the slice. + fn len(&self) -> usize; + + /// Returns `true` if the slice has a length of 0. + fn is_empty(&self) -> bool; +} + +impl RawArgs for [S; C] +where + S: AsRef + std::fmt::Debug, +{ + #[inline] + fn get(&self, index: usize) -> Option<&OsStr> { + self.as_slice().get(index).map(|s| s.as_ref()) + } + + #[inline] + fn len(&self) -> usize { + C + } + + #[inline] + fn is_empty(&self) -> bool { + C != 0 + } +} + +impl RawArgs for &'_ [S] +where + S: AsRef + std::fmt::Debug, +{ + #[inline] + fn get(&self, index: usize) -> Option<&OsStr> { + (*self).get(index).map(|s| s.as_ref()) + } + + #[inline] + fn len(&self) -> usize { + (*self).len() + } + + #[inline] + fn is_empty(&self) -> bool { + (*self).is_empty() + } +} + +impl RawArgs for Vec +where + S: AsRef + std::fmt::Debug, +{ + #[inline] + fn get(&self, index: usize) -> Option<&OsStr> { + self.as_slice().get(index).map(|s| s.as_ref()) + } + + #[inline] + fn len(&self) -> usize { + self.len() + } + + #[inline] + fn is_empty(&self) -> bool { + self.is_empty() + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +enum State<'a> { + /// We have a value left over from `--option=value` + PendingValue(&'a OsStr), + /// We're in the middle of `-abc` + /// + /// On Windows and other non-UTF8-OsString platforms this Vec should + /// only ever contain valid UTF-8 (and could instead be a String). + PendingShorts(&'a str, &'a OsStr, usize), + /// We saw `--` and know no more options are coming. + Escaped, +} + +impl State<'_> { + fn has_pending(&self) -> bool { + match self { + Self::PendingValue(_) | Self::PendingShorts(_, _, _) => true, + Self::Escaped => false, + } + } +} + +/// A command line argument found by [`Parser`], either an option or a positional argument +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum Arg<'a> { + /// A short option, e.g. `Short("q")` for `-q` + Short(&'a str), + /// A long option, e.g. `Long("verbose")` for `--verbose` + /// + /// The dashes are not included + Long(&'a str), + /// A positional argument, e.g. `/dev/null` + Value(&'a OsStr), + /// Marks the following values have been escaped with `--` + Escape(&'a str), + /// User passed something in that doesn't work + Unexpected(&'a OsStr), +} + +fn split_nonutf8_once(b: &OsStr) -> (&str, Option<&OsStr>) { + match b.try_str() { + Ok(s) => (s, None), + Err(err) => { + // SAFETY: `char_indices` ensures `index` is at a valid UTF-8 boundary + let (valid, after_valid) = unsafe { ext::split_at(b, err.valid_up_to()) }; + let valid = valid.try_str().unwrap(); + (valid, Some(after_valid)) + } + } +} + +fn ceil_char_boundary(s: &str, curr_boundary: usize) -> Option { + (curr_boundary + 1..=s.len()).find(|i| s.is_char_boundary(*i)) +} + +mod private { + use super::OsStr; + + pub trait Sealed {} + impl Sealed for [S; C] where S: AsRef + std::fmt::Debug {} + impl Sealed for &'_ [S] where S: AsRef + std::fmt::Debug {} + impl Sealed for Vec where S: AsRef + std::fmt::Debug {} +} + +#[cfg(test)] +mod tests { + use super::Arg::*; + use super::*; + + #[test] + fn test_basic() { + let mut p = Parser::new(&["-n", "10", "foo", "-", "--", "baz", "-qux"]); + assert_eq!(p.next_arg().unwrap(), Short("n")); + assert_eq!(p.next_flag_value().unwrap(), "10"); + assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("foo"))); + assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("-"))); + assert_eq!(p.next_arg().unwrap(), Escape("--")); + assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("baz"))); + assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("-qux"))); + assert_eq!(p.next_arg(), None); + assert_eq!(p.next_arg(), None); + assert_eq!(p.next_arg(), None); + } + + #[test] + fn test_combined() { + let mut p = Parser::new(&["-abc", "-fvalue", "-xfvalue"]); + assert_eq!(p.next_arg().unwrap(), Short("a")); + assert_eq!(p.next_arg().unwrap(), Short("b")); + assert_eq!(p.next_arg().unwrap(), Short("c")); + assert_eq!(p.next_arg().unwrap(), Short("f")); + assert_eq!(p.next_flag_value().unwrap(), "value"); + assert_eq!(p.next_arg().unwrap(), Short("x")); + assert_eq!(p.next_arg().unwrap(), Short("f")); + assert_eq!(p.next_flag_value().unwrap(), "value"); + assert_eq!(p.next_arg(), None); + } + + #[test] + fn test_long() { + let mut p = Parser::new(&["--foo", "--bar=qux", "--foobar=qux=baz"]); + assert_eq!(p.next_arg().unwrap(), Long("foo")); + assert_eq!(p.next_arg().unwrap(), Long("bar")); + assert_eq!(p.next_flag_value().unwrap(), "qux"); + assert_eq!(p.next_flag_value(), None); + assert_eq!(p.next_arg().unwrap(), Long("foobar")); + assert_eq!(p.next_arg().unwrap(), Unexpected(OsStr::new("qux=baz"))); + assert_eq!(p.next_arg(), None); + } + + #[test] + fn test_dash_args() { + // "--" should indicate the end of the options + let mut p = Parser::new(&["-x", "--", "-y"]); + assert_eq!(p.next_arg().unwrap(), Short("x")); + assert_eq!(p.next_arg().unwrap(), Escape("--")); + assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("-y"))); + assert_eq!(p.next_arg(), None); + + // ...even if it's an argument of an option + let mut p = Parser::new(&["-x", "--", "-y"]); + assert_eq!(p.next_arg().unwrap(), Short("x")); + assert_eq!(p.next_flag_value(), None); + assert_eq!(p.next_arg().unwrap(), Escape("--")); + assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("-y"))); + assert_eq!(p.next_arg(), None); + + // "-" is a valid value that should not be treated as an option + let mut p = Parser::new(&["-x", "-", "-y"]); + assert_eq!(p.next_arg().unwrap(), Short("x")); + assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("-"))); + assert_eq!(p.next_arg().unwrap(), Short("y")); + assert_eq!(p.next_arg(), None); + + // '-' is a silly and hard to use short option, but other parsers treat + // it like an option in this position + let mut p = Parser::new(&["-x-y"]); + assert_eq!(p.next_arg().unwrap(), Short("x")); + assert_eq!(p.next_arg().unwrap(), Short("-")); + assert_eq!(p.next_arg().unwrap(), Short("y")); + assert_eq!(p.next_arg(), None); + } + + #[test] + fn test_missing_value() { + let mut p = Parser::new(&["-o"]); + assert_eq!(p.next_arg().unwrap(), Short("o")); + assert_eq!(p.next_flag_value(), None); + + let mut q = Parser::new(&["--out"]); + assert_eq!(q.next_arg().unwrap(), Long("out")); + assert_eq!(q.next_flag_value(), None); + + let args: [&OsStr; 0] = []; + let mut r = Parser::new(&args); + assert_eq!(r.next_flag_value(), None); + } + + #[test] + fn test_weird_args() { + let mut p = Parser::new(&[ + "--=", "--=3", "-", "-x", "--", "-", "-x", "--", "", "-", "-x", + ]); + assert_eq!(p.next_arg().unwrap(), Unexpected(OsStr::new("--="))); + assert_eq!(p.next_arg().unwrap(), Unexpected(OsStr::new("--=3"))); + assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("-"))); + assert_eq!(p.next_arg().unwrap(), Short("x")); + assert_eq!(p.next_arg().unwrap(), Escape("--")); + assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("-"))); + assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("-x"))); + assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("--"))); + assert_eq!(p.next_arg().unwrap(), Value(OsStr::new(""))); + assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("-"))); + assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("-x"))); + assert_eq!(p.next_arg(), None); + + let bad = bad_string("--=@"); + let args = [&bad]; + let mut q = Parser::new(&args); + assert_eq!(q.next_arg().unwrap(), Unexpected(OsStr::new(&bad))); + + let mut r = Parser::new(&[""]); + assert_eq!(r.next_arg().unwrap(), Value(OsStr::new(""))); + } + + #[test] + fn test_unicode() { + let mut p = Parser::new(&["-aµ", "--µ=10", "µ", "--foo=µ"]); + assert_eq!(p.next_arg().unwrap(), Short("a")); + assert_eq!(p.next_arg().unwrap(), Short("µ")); + assert_eq!(p.next_arg().unwrap(), Long("µ")); + assert_eq!(p.next_flag_value().unwrap(), "10"); + assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("µ"))); + assert_eq!(p.next_arg().unwrap(), Long("foo")); + assert_eq!(p.next_flag_value().unwrap(), "µ"); + } + + #[cfg(any(unix, target_os = "wasi", windows))] + #[test] + fn test_mixed_invalid() { + let args = [bad_string("--foo=@@@")]; + let mut p = Parser::new(&args); + assert_eq!(p.next_arg().unwrap(), Long("foo")); + assert_eq!(p.next_flag_value().unwrap(), bad_string("@@@")); + + let args = [bad_string("-💣@@@")]; + let mut q = Parser::new(&args); + assert_eq!(q.next_arg().unwrap(), Short("💣")); + assert_eq!(q.next_flag_value().unwrap(), bad_string("@@@")); + + let args = [bad_string("-f@@@")]; + let mut r = Parser::new(&args); + assert_eq!(r.next_arg().unwrap(), Short("f")); + assert_eq!(r.next_arg().unwrap(), Unexpected(&bad_string("@@@"))); + assert_eq!(r.next_arg(), None); + + let args = [bad_string("--foo=bar=@@@")]; + let mut s = Parser::new(&args); + assert_eq!(s.next_arg().unwrap(), Long("foo")); + assert_eq!(s.next_flag_value().unwrap(), bad_string("bar=@@@")); + } + + #[cfg(any(unix, target_os = "wasi", windows))] + #[test] + fn test_separate_invalid() { + let args = [bad_string("--foo"), bad_string("@@@")]; + let mut p = Parser::new(&args); + assert_eq!(p.next_arg().unwrap(), Long("foo")); + assert_eq!(p.next_flag_value().unwrap(), bad_string("@@@")); + } + + #[cfg(any(unix, target_os = "wasi", windows))] + #[test] + fn test_invalid_long_option() { + let args = [bad_string("--@=10")]; + let mut p = Parser::new(&args); + assert_eq!(p.next_arg().unwrap(), Unexpected(&args[0])); + assert_eq!(p.next_arg(), None); + + let args = [bad_string("--@")]; + let mut p = Parser::new(&args); + assert_eq!(p.next_arg().unwrap(), Unexpected(&args[0])); + assert_eq!(p.next_arg(), None); + } + + #[cfg(any(unix, target_os = "wasi", windows))] + #[test] + fn test_invalid_short_option() { + let args = [bad_string("-@")]; + let mut p = Parser::new(&args); + assert_eq!(p.next_arg().unwrap(), Unexpected(&args[0])); + assert_eq!(p.next_arg(), None); + } + + #[test] + fn short_opt_equals_sign() { + let mut p = Parser::new(&["-a=b"]); + assert_eq!(p.next_arg().unwrap(), Short("a")); + assert_eq!(p.next_flag_value().unwrap(), OsStr::new("b")); + assert_eq!(p.next_arg(), None); + + let mut p = Parser::new(&["-a=b", "c"]); + assert_eq!(p.next_arg().unwrap(), Short("a")); + assert_eq!(p.next_flag_value().unwrap(), OsStr::new("b")); + assert_eq!(p.next_flag_value(), None); + assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("c"))); + assert_eq!(p.next_arg(), None); + + let mut p = Parser::new(&["-a=b"]); + assert_eq!(p.next_arg().unwrap(), Short("a")); + assert_eq!(p.next_arg().unwrap(), Short("=")); + assert_eq!(p.next_arg().unwrap(), Short("b")); + assert_eq!(p.next_arg(), None); + + let mut p = Parser::new(&["-a="]); + assert_eq!(p.next_arg().unwrap(), Short("a")); + assert_eq!(p.next_flag_value().unwrap(), OsStr::new("")); + assert_eq!(p.next_arg(), None); + + let mut p = Parser::new(&["-a=="]); + assert_eq!(p.next_arg().unwrap(), Short("a")); + assert_eq!(p.next_flag_value().unwrap(), OsStr::new("=")); + assert_eq!(p.next_arg(), None); + + let mut p = Parser::new(&["-abc=de"]); + assert_eq!(p.next_arg().unwrap(), Short("a")); + assert_eq!(p.next_flag_value().unwrap(), OsStr::new("bc=de")); + assert_eq!(p.next_arg(), None); + + let mut p = Parser::new(&["-abc==de"]); + assert_eq!(p.next_arg().unwrap(), Short("a")); + assert_eq!(p.next_arg().unwrap(), Short("b")); + assert_eq!(p.next_arg().unwrap(), Short("c")); + assert_eq!(p.next_flag_value().unwrap(), OsStr::new("=de")); + assert_eq!(p.next_arg(), None); + + let mut p = Parser::new(&["-a="]); + assert_eq!(p.next_arg().unwrap(), Short("a")); + assert_eq!(p.next_arg().unwrap(), Short("=")); + assert_eq!(p.next_arg(), None); + + let mut p = Parser::new(&["-="]); + assert_eq!(p.next_arg().unwrap(), Short("=")); + assert_eq!(p.next_arg(), None); + + let mut p = Parser::new(&["-=a"]); + assert_eq!(p.next_arg().unwrap(), Short("=")); + assert_eq!(p.next_arg().unwrap(), Short("a")); + assert_eq!(p.next_arg(), None); + } + + #[cfg(any(unix, target_os = "wasi", windows))] + #[test] + fn short_opt_equals_sign_invalid() { + let bad = bad_string("@"); + let args = [bad_string("-a=@")]; + let mut p = Parser::new(&args); + assert_eq!(p.next_arg().unwrap(), Short("a")); + assert_eq!(p.next_flag_value().unwrap(), bad_string("@")); + assert_eq!(p.next_arg(), None); + + let mut p = Parser::new(&args); + assert_eq!(p.next_arg().unwrap(), Short("a")); + assert_eq!(p.next_arg().unwrap(), Short("=")); + assert_eq!(p.next_arg().unwrap(), Unexpected(&bad)); + assert_eq!(p.next_arg(), None); + } + + #[test] + fn remaining_raw() { + let mut p = Parser::new(&["-a", "b", "c", "d"]); + assert_eq!( + p.remaining_raw().unwrap().collect::>(), + &["-a", "b", "c", "d"] + ); + // Consumed all + assert!(p.next_arg().is_none()); + assert!(p.remaining_raw().is_ok()); + assert_eq!(p.remaining_raw().unwrap().collect::>().len(), 0); + + let mut p = Parser::new(&["-ab", "c", "d"]); + p.next_arg().unwrap(); + // Attached value + assert!(p.remaining_raw().is_err()); + p.next_attached_value().unwrap(); + assert_eq!(p.remaining_raw().unwrap().collect::>(), &["c", "d"]); + // Consumed all + assert!(p.next_arg().is_none()); + assert_eq!(p.remaining_raw().unwrap().collect::>().len(), 0); + } + + /// Transform @ characters into invalid unicode. + fn bad_string(text: &str) -> std::ffi::OsString { + #[cfg(any(unix, target_os = "wasi"))] + { + #[cfg(unix)] + use std::os::unix::ffi::OsStringExt; + #[cfg(target_os = "wasi")] + use std::os::wasi::ffi::OsStringExt; + let mut text = text.as_bytes().to_vec(); + for ch in &mut text { + if *ch == b'@' { + *ch = b'\xFF'; + } + } + std::ffi::OsString::from_vec(text) + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStringExt; + let mut out = Vec::new(); + for ch in text.chars() { + if ch == '@' { + out.push(0xD800); + } else { + let mut buf = [0; 2]; + out.extend(&*ch.encode_utf16(&mut buf)); + } + } + std::ffi::OsString::from_wide(&out) + } + #[cfg(not(any(unix, target_os = "wasi", windows)))] + { + if text.contains('@') { + unimplemented!("Don't know how to create invalid OsStrings on this platform"); + } + text.into() + } + } + + /// Basic exhaustive testing of short combinations of "interesting" + /// arguments. They should not panic, not hang, and pass some checks. + /// + /// The advantage compared to full fuzzing is that it runs on all platforms + /// and together with the other tests. cargo-fuzz doesn't work on Windows + /// and requires a special incantation. + /// + /// A disadvantage is that it's still limited by arguments I could think of + /// and only does very short sequences. Another is that it's bad at + /// reporting failure, though the println!() helps. + /// + /// This test takes a while to run. + #[test] + fn basic_fuzz() { + #[cfg(any(windows, unix, target_os = "wasi"))] + const VOCABULARY: &[&str] = &[ + "", "-", "--", "---", "a", "-a", "-aa", "@", "-@", "-a@", "-@a", "--a", "--@", "--a=a", + "--a=", "--a=@", "--@=a", "--=", "--=@", "--=a", "-@@", "-a=a", "-a=", "-=", "-a-", + ]; + #[cfg(not(any(windows, unix, target_os = "wasi")))] + const VOCABULARY: &[&str] = &[ + "", "-", "--", "---", "a", "-a", "-aa", "--a", "--a=a", "--a=", "--=", "--=a", "-a=a", + "-a=", "-=", "-a-", + ]; + let args: [&OsStr; 0] = []; + exhaust(Parser::new(&args), vec![]); + let vocabulary: Vec = + VOCABULARY.iter().map(|&s| bad_string(s)).collect(); + let mut permutations = vec![vec![]]; + for _ in 0..3 { + let mut new = Vec::new(); + for old in permutations { + for word in &vocabulary { + let mut extended = old.clone(); + extended.push(word); + new.push(extended); + } + } + permutations = new; + for permutation in &permutations { + println!("Starting {permutation:?}"); + let p = Parser::new(permutation); + exhaust(p, vec![]); + } + } + } + + /// Run many sequences of methods on a Parser. + fn exhaust(parser: Parser<'_>, path: Vec) { + if path.len() > 100 { + panic!("Stuck in loop: {path:?}"); + } + + if parser.has_pending() { + { + let mut parser = parser.clone(); + let next = parser.next_arg(); + assert!( + matches!(next, Some(Unexpected(_)) | Some(Short(_))), + "{next:?} via {path:?}", + ); + let mut path = path.clone(); + path.push(format!("pending-next-{next:?}")); + exhaust(parser, path); + } + + { + let mut parser = parser.clone(); + let next = parser.next_flag_value(); + assert!(next.is_some(), "{next:?} via {path:?}",); + let mut path = path; + path.push(format!("pending-value-{next:?}")); + exhaust(parser, path); + } + } else { + { + let mut parser = parser.clone(); + let next = parser.next_arg(); + match &next { + None => { + assert!( + matches!(parser.state, None | Some(State::Escaped)), + "{next:?} via {path:?}", + ); + assert_eq!(parser.current, parser.raw.len(), "{next:?} via {path:?}",); + } + _ => { + let mut path = path.clone(); + path.push(format!("next-{next:?}")); + exhaust(parser, path); + } + } + } + + { + let mut parser = parser.clone(); + let next = parser.next_flag_value(); + match &next { + None => { + assert!( + matches!(parser.state, None | Some(State::Escaped)), + "{next:?} via {path:?}", + ); + if parser.state.is_none() + && !parser.was_attached + && parser.peek_raw_() != Some(OsStr::new("--")) + { + assert_eq!(parser.current, parser.raw.len(), "{next:?} via {path:?}",); + } + } + Some(_) => { + assert!( + matches!(parser.state, None | Some(State::Escaped)), + "{next:?} via {path:?}", + ); + let mut path = path; + path.push(format!("value-{next:?}")); + exhaust(parser, path); + } + } + } + } + } +} diff --git a/crates/lexarg/Cargo.toml b/crates/lexarg/Cargo.toml index ee73cf0..5e544dd 100644 --- a/crates/lexarg/Cargo.toml +++ b/crates/lexarg/Cargo.toml @@ -27,6 +27,8 @@ pre-release-replacements = [ default = [] [dependencies] +lexarg-parser = { "version" = "0.1.0", path = "../lexarg-parser" } +lexarg-error = { "version" = "0.1.0", path = "../lexarg-error" } [dev-dependencies] diff --git a/crates/lexarg/examples/hello.rs b/crates/lexarg/examples/hello.rs index 25fda8e..84d4e90 100644 --- a/crates/lexarg/examples/hello.rs +++ b/crates/lexarg/examples/hello.rs @@ -1,54 +1,70 @@ +use lexarg::ErrorContext; +use lexarg::Result; + struct Args { thing: String, number: u32, shout: bool, } -fn parse_args() -> Result { - #![allow(clippy::enum_glob_use)] - use lexarg::Arg::*; +fn parse_args() -> Result { + use lexarg::prelude::*; let mut thing = None; let mut number = 1; let mut shout = false; let raw = std::env::args_os().collect::>(); let mut parser = lexarg::Parser::new(&raw); - let _bin_name = parser.next_raw(); + let bin_name = parser + .next_raw() + .expect("nothing parsed yet so no attached lingering") + .expect("always at least one"); + let mut prev_arg = Value(bin_name); while let Some(arg) = parser.next_arg() { match arg { Short("n") | Long("number") => { number = parser .next_flag_value() - .ok_or("`--number` requires a value")? - .to_str() - .ok_or("invalid number")? + .ok_or_missing(Value(std::ffi::OsStr::new("NUM"))) .parse() - .map_err(|_e| "invalid number")?; + .within(arg)?; } Long("shout") => { shout = true; } Value(val) if thing.is_none() => { - thing = Some(val.to_str().ok_or("invalid string")?); + thing = Some(val.string("THING")?); } Short("h") | Long("help") => { println!("Usage: hello [-n|--number=NUM] [--shout] THING"); std::process::exit(0); } + Unexpected(_) => { + return Err(ErrorContext::msg("unexpected value") + .unexpected(arg) + .within(prev_arg) + .into()); + } _ => { - return Err("unexpected argument"); + return Err(ErrorContext::msg("unexpected argument") + .unexpected(arg) + .into()); } } + prev_arg = arg; } Ok(Args { - thing: thing.ok_or("missing argument THING")?.to_owned(), + thing: thing + .ok_or_missing(Value(std::ffi::OsStr::new("THING"))) + .within(Value(bin_name))? + .to_owned(), number, shout, }) } -fn main() -> Result<(), String> { +fn main() -> Result<()> { let args = parse_args()?; let mut message = format!("Hello {}", args.thing); if args.shout { diff --git a/crates/lexarg/src/lib.rs b/crates/lexarg/src/lib.rs index 91d68db..c8e8333 100644 --- a/crates/lexarg/src/lib.rs +++ b/crates/lexarg/src/lib.rs @@ -20,856 +20,173 @@ #[cfg(doctest)] pub struct ReadmeDoctests; -mod ext; - -use std::ffi::OsStr; - -use ext::OsStrExt as _; - -/// A parser for command line arguments. -#[derive(Debug, Clone)] -pub struct Parser<'a> { - raw: &'a dyn RawArgs, - current: usize, - state: Option>, - was_attached: bool, +/// Simplify parsing of arguments +pub mod prelude { + pub use crate::Arg::*; + pub use crate::OptionContextExt as _; + pub use crate::ResultContextExt as _; + pub use crate::ValueExt as _; } -impl<'a> Parser<'a> { - /// Create a parser from an iterator. This is useful for testing among other things. - /// - /// The first item from the iterator **must** be the binary name, as from [`std::env::args_os`]. - /// - /// The iterator is consumed immediately. - /// - /// # Example - /// ``` - /// let args = ["myapp", "-n", "10", "./foo.bar"]; - /// let mut parser = lexarg::Parser::new(&&args[1..]); - /// ``` - pub fn new(raw: &'a dyn RawArgs) -> Self { - Parser { - raw, - current: 0, - state: None, - was_attached: false, - } - } - - /// Get the next option or positional [`Arg`]. - /// - /// Returns `None` if the command line has been exhausted. - /// - /// Returns [`Arg::Unexpected`] on failure - /// - /// Notes: - /// - `=` is always accepted as a [`Arg::Short("=")`]. If that isn't the case in your - /// application, you may want to special case the error for that. - pub fn next_arg(&mut self) -> Option> { - // Always reset - self.was_attached = false; - - match self.state { - Some(State::PendingValue(attached)) => { - // Last time we got `--long=value`, and `value` hasn't been used. - self.state = None; - self.current += 1; - Some(Arg::Unexpected(attached)) - } - Some(State::PendingShorts(valid, invalid, index)) => { - // We're somewhere inside a `-abc` chain. Because we're in `.next_arg()`, not `.next_flag_value()`, we - // can assume that the next character is another option. - if let Some(next_index) = ceil_char_boundary(valid, index) { - if next_index < valid.len() { - self.state = Some(State::PendingShorts(valid, invalid, next_index)); - } else if !invalid.is_empty() { - self.state = Some(State::PendingValue(invalid)); - } else { - // No more flags - self.state = None; - self.current += 1; - } - let flag = &valid[index..next_index]; - Some(Arg::Short(flag)) - } else { - debug_assert_ne!(invalid, ""); - if index == 0 { - panic!("there should have been a `-`") - } else if index == 1 { - // Like long flags, include `-` - let arg = self - .raw - .get(self.current) - .expect("`current` is valid if state is `Shorts`"); - self.state = None; - self.current += 1; - Some(Arg::Unexpected(arg)) - } else { - self.state = None; - self.current += 1; - Some(Arg::Unexpected(invalid)) - } - } - } - Some(State::Escaped) => { - self.state = Some(State::Escaped); - self.next_raw_().map(Arg::Value) - } - None => { - let arg = self.raw.get(self.current)?; - if arg == "--" { - self.state = Some(State::Escaped); - self.current += 1; - Some(Arg::Escape(arg.to_str().expect("`--` is valid UTF-8"))) - } else if arg == "-" { - self.state = None; - self.current += 1; - Some(Arg::Value(arg)) - } else if let Some(long) = arg.strip_prefix("--") { - let (name, value) = long - .split_once("=") - .map(|(n, v)| (n, Some(v))) - .unwrap_or((long, None)); - if name.is_empty() { - self.state = None; - self.current += 1; - Some(Arg::Unexpected(arg)) - } else if let Ok(name) = name.try_str() { - if let Some(value) = value { - self.state = Some(State::PendingValue(value)); - } else { - self.state = None; - self.current += 1; - } - Some(Arg::Long(name)) - } else { - self.state = None; - self.current += 1; - Some(Arg::Unexpected(arg)) - } - } else if arg.starts_with("-") { - let (valid, invalid) = split_nonutf8_once(arg); - let invalid = invalid.unwrap_or_default(); - self.state = Some(State::PendingShorts(valid, invalid, 1)); - self.next_arg() - } else { - self.state = None; - self.current += 1; - Some(Arg::Value(arg)) - } - } - } - } - - /// Get a flag's value - /// - /// This function should normally be called right after seeing a flag that expects a value; - /// positional arguments should be collected with [`Parser::next_arg()`]. - /// - /// A value is collected even if it looks like an option (i.e., starts with `-`). - /// - /// `None` is returned if there is not another applicable flag value, including: - /// - No more arguments are present - /// - `--` was encountered, meaning all remaining arguments are positional - /// - Being called again when the first value was attached (`--flag=value`, `-Fvalue`, `-F=value`) - pub fn next_flag_value(&mut self) -> Option<&'a OsStr> { - if self.was_attached { - debug_assert!(!self.has_pending()); - None - } else if let Some(value) = self.next_attached_value() { - Some(value) - } else { - self.next_detached_value() - } - } - - /// Get a flag's attached value (`--flag=value`, `-Fvalue`, `-F=value`) - /// - /// This is a more specialized variant of [`Parser::next_flag_value`] for when only attached - /// values are allowed, e.g. `--color[=]`. - pub fn next_attached_value(&mut self) -> Option<&'a OsStr> { - match self.state? { - State::PendingValue(attached) => { - self.state = None; - self.current += 1; - self.was_attached = true; - Some(attached) - } - State::PendingShorts(_, _, index) => { - let arg = self - .raw - .get(self.current) - .expect("`current` is valid if state is `Shorts`"); - self.state = None; - self.current += 1; - if index == arg.len() { - None - } else { - // SAFETY: everything preceding `index` were a short flags, making them valid UTF-8 - let remainder = unsafe { ext::split_at(arg, index) }.1; - let remainder = remainder.strip_prefix("=").unwrap_or(remainder); - self.was_attached = true; - Some(remainder) - } - } - State::Escaped => None, - } - } - - fn next_detached_value(&mut self) -> Option<&'a OsStr> { - if self.state == Some(State::Escaped) { - // Escaped values are positional-only - return None; - } +pub use lexarg_error::ErrorContext; +pub use lexarg_parser::Arg; +pub use lexarg_parser::Parser; +pub use lexarg_parser::RawArgs; - if self.peek_raw_()? == "--" { - None - } else { - self.next_raw_() - } - } - - /// Get the next argument, independent of what it looks like - /// - /// Returns `Err(())` if an [attached value][Parser::next_attached_value] is present - pub fn next_raw(&mut self) -> Result, ()> { - if self.has_pending() { - Err(()) - } else { - self.was_attached = false; - Ok(self.next_raw_()) - } - } +/// `Result` that defaults to [`Error`] +pub type Result = std::result::Result; - /// Collect all remaining arguments, independent of what they look like - /// - /// Returns `Err(())` if an [attached value][Parser::next_attached_value] is present - pub fn remaining_raw(&mut self) -> Result + '_, ()> { - if self.has_pending() { - Err(()) - } else { - self.was_attached = false; - Ok(std::iter::from_fn(|| self.next_raw_())) - } - } +/// Argument error type for use with lexarg +pub struct Error { + msg: String, +} - /// Get the next argument, independent of what it looks like - /// - /// Returns `Err(())` if an [attached value][Parser::next_attached_value] is present - pub fn peek_raw(&self) -> Result, ()> { - if self.has_pending() { - Err(()) - } else { - Ok(self.peek_raw_()) +impl Error { + /// Create a new error object from a printable error message. + #[cold] + pub fn msg(message: M) -> Self + where + M: std::fmt::Display, + { + Self { + msg: message.to_string(), } } +} - fn peek_raw_(&self) -> Option<&'a OsStr> { - self.raw.get(self.current) +impl From> for Error { + #[cold] + fn from(error: ErrorContext<'_>) -> Self { + Self::msg(error.to_string()) } +} - fn next_raw_(&mut self) -> Option<&'a OsStr> { - debug_assert!(!self.has_pending()); - debug_assert!(!self.was_attached); - - let next = self.raw.get(self.current)?; - self.current += 1; - Some(next) +impl std::fmt::Debug for Error { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}", self.msg) } +} - fn has_pending(&self) -> bool { - self.state.as_ref().map(State::has_pending).unwrap_or(false) +impl std::fmt::Display for Error { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.msg.fmt(formatter) } } -/// Accessor for unparsed arguments -pub trait RawArgs: std::fmt::Debug + private::Sealed { - /// Returns a reference to an element or subslice depending on the type of index. - /// - /// - If given a position, returns a reference to the element at that position or None if out - /// of bounds. - /// - If given a range, returns the subslice corresponding to that range, or None if out - /// of bounds. - fn get(&self, index: usize) -> Option<&OsStr>; - - /// Returns the number of elements in the slice. - fn len(&self) -> usize; - - /// Returns `true` if the slice has a length of 0. - fn is_empty(&self) -> bool; +/// Extensions for parsing [`Arg::Value`] +pub trait ValueExt<'a> { + /// Convert [`Arg::Value`] + fn path(self) -> Result<&'a std::path::Path, ErrorContext<'a>>; + /// Convert [`Arg::Value`] with a description of the intended format + fn string(self, description: &str) -> Result<&'a str, ErrorContext<'a>>; + /// Ensure [`Arg::Value`] is from a closed set of values + fn one_of(self, possible: &[&str]) -> Result<&'a str, ErrorContext<'a>>; + /// Parse [`Arg::Value`] + fn parse(self) -> Result> + where + T::Err: std::fmt::Display; + /// Custom conversion for [`Arg::Value`] + fn try_map(self, op: F) -> Result> + where + F: FnOnce(&'a std::ffi::OsStr) -> Result, + E: std::fmt::Display; } -impl RawArgs for [S; C] -where - S: AsRef + std::fmt::Debug, -{ - #[inline] - fn get(&self, index: usize) -> Option<&OsStr> { - self.as_slice().get(index).map(|s| s.as_ref()) - } - - #[inline] - fn len(&self) -> usize { - C - } - - #[inline] - fn is_empty(&self) -> bool { - C != 0 +impl<'a> ValueExt<'a> for &'a std::ffi::OsStr { + fn path(self) -> Result<&'a std::path::Path, ErrorContext<'a>> { + Ok(std::path::Path::new(self)) + } + fn string(self, description: &str) -> Result<&'a str, ErrorContext<'a>> { + self.to_str().ok_or_else(|| { + ErrorContext::msg(format_args!("invalid {description}")).unexpected(Arg::Value(self)) + }) + } + fn one_of(self, possible: &[&str]) -> Result<&'a str, ErrorContext<'a>> { + self.to_str() + .filter(|v| possible.contains(v)) + .ok_or_else(|| { + let mut possible = possible.iter(); + let first = possible.next().expect("at least one possible value"); + let mut error = format!("expected one of `{first}`"); + for possible in possible { + use std::fmt::Write as _; + let _ = write!(&mut error, ", `{possible}`"); + } + ErrorContext::msg(error) + }) + } + fn parse(self) -> Result> + where + T::Err: std::fmt::Display, + { + self.string(std::any::type_name::())? + .parse::() + .map_err(|err| ErrorContext::msg(err).unexpected(Arg::Value(self))) + } + fn try_map(self, op: F) -> Result> + where + F: FnOnce(&'a std::ffi::OsStr) -> Result, + E: std::fmt::Display, + { + op(self).map_err(|err| ErrorContext::msg(err).unexpected(Arg::Value(self))) } } -impl RawArgs for &'_ [S] -where - S: AsRef + std::fmt::Debug, -{ - #[inline] - fn get(&self, index: usize) -> Option<&OsStr> { - (*self).get(index).map(|s| s.as_ref()) - } - - #[inline] - fn len(&self) -> usize { - (*self).len() +impl<'a> ValueExt<'a> for Result<&'a std::ffi::OsStr, ErrorContext<'a>> { + fn path(self) -> Result<&'a std::path::Path, ErrorContext<'a>> { + self.and_then(|os| os.path()) } - - #[inline] - fn is_empty(&self) -> bool { - (*self).is_empty() + fn string(self, description: &str) -> Result<&'a str, ErrorContext<'a>> { + self.and_then(|os| os.string(description)) } -} - -impl RawArgs for Vec -where - S: AsRef + std::fmt::Debug, -{ - #[inline] - fn get(&self, index: usize) -> Option<&OsStr> { - self.as_slice().get(index).map(|s| s.as_ref()) + fn one_of(self, possible: &[&str]) -> Result<&'a str, ErrorContext<'a>> { + self.and_then(|os| os.one_of(possible)) } - - #[inline] - fn len(&self) -> usize { - self.len() + fn parse(self) -> Result> + where + T::Err: std::fmt::Display, + { + self.and_then(|os| os.parse()) } - - #[inline] - fn is_empty(&self) -> bool { - self.is_empty() + fn try_map(self, op: F) -> Result> + where + F: FnOnce(&'a std::ffi::OsStr) -> Result, + E: std::fmt::Display, + { + self.and_then(|os| os.try_map(op)) } } -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -enum State<'a> { - /// We have a value left over from `--option=value` - PendingValue(&'a OsStr), - /// We're in the middle of `-abc` - /// - /// On Windows and other non-UTF8-OsString platforms this Vec should - /// only ever contain valid UTF-8 (and could instead be a String). - PendingShorts(&'a str, &'a OsStr, usize), - /// We saw `--` and know no more options are coming. - Escaped, +/// Extensions for extending [`ErrorContext`] +pub trait ResultContextExt<'a> { + /// [`Arg`] the error occurred within + fn within(self, within: Arg<'a>) -> Self; } -impl State<'_> { - fn has_pending(&self) -> bool { - match self { - Self::PendingValue(_) | Self::PendingShorts(_, _, _) => true, - Self::Escaped => false, - } +impl<'a, T> ResultContextExt<'a> for Result> { + fn within(self, within: Arg<'a>) -> Self { + self.map_err(|err| err.within(within)) } } -/// A command line argument found by [`Parser`], either an option or a positional argument -#[derive(Debug, Copy, Clone, PartialEq, Eq)] -pub enum Arg<'a> { - /// A short option, e.g. `Short("q")` for `-q` - Short(&'a str), - /// A long option, e.g. `Long("verbose")` for `--verbose` +/// Extensions for creating an [`ErrorContext`] +pub trait OptionContextExt { + /// [`Arg`] that was expected /// - /// The dashes are not included - Long(&'a str), - /// A positional argument, e.g. `/dev/null` - Value(&'a OsStr), - /// Marks the following values have been escaped with `--` - Escape(&'a str), - /// User passed something in that doesn't work - Unexpected(&'a OsStr), -} - -fn split_nonutf8_once(b: &OsStr) -> (&str, Option<&OsStr>) { - match b.try_str() { - Ok(s) => (s, None), - Err(err) => { - // SAFETY: `char_indices` ensures `index` is at a valid UTF-8 boundary - let (valid, after_valid) = unsafe { ext::split_at(b, err.valid_up_to()) }; - let valid = valid.try_str().unwrap(); - (valid, Some(after_valid)) - } - } -} - -fn ceil_char_boundary(s: &str, curr_boundary: usize) -> Option { - (curr_boundary + 1..=s.len()).find(|i| s.is_char_boundary(*i)) + /// For [`Arg::Value`], the contents are assumed to be a placeholder + fn ok_or_missing(self, expected: Arg<'static>) -> Result>; } -mod private { - use super::OsStr; - - pub trait Sealed {} - impl Sealed for [S; C] where S: AsRef + std::fmt::Debug {} - impl Sealed for &'_ [S] where S: AsRef + std::fmt::Debug {} - impl Sealed for Vec where S: AsRef + std::fmt::Debug {} -} - -#[cfg(test)] -mod tests { - use super::Arg::*; - use super::*; - - #[test] - fn test_basic() { - let mut p = Parser::new(&["-n", "10", "foo", "-", "--", "baz", "-qux"]); - assert_eq!(p.next_arg().unwrap(), Short("n")); - assert_eq!(p.next_flag_value().unwrap(), "10"); - assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("foo"))); - assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("-"))); - assert_eq!(p.next_arg().unwrap(), Escape("--")); - assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("baz"))); - assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("-qux"))); - assert_eq!(p.next_arg(), None); - assert_eq!(p.next_arg(), None); - assert_eq!(p.next_arg(), None); - } - - #[test] - fn test_combined() { - let mut p = Parser::new(&["-abc", "-fvalue", "-xfvalue"]); - assert_eq!(p.next_arg().unwrap(), Short("a")); - assert_eq!(p.next_arg().unwrap(), Short("b")); - assert_eq!(p.next_arg().unwrap(), Short("c")); - assert_eq!(p.next_arg().unwrap(), Short("f")); - assert_eq!(p.next_flag_value().unwrap(), "value"); - assert_eq!(p.next_arg().unwrap(), Short("x")); - assert_eq!(p.next_arg().unwrap(), Short("f")); - assert_eq!(p.next_flag_value().unwrap(), "value"); - assert_eq!(p.next_arg(), None); - } - - #[test] - fn test_long() { - let mut p = Parser::new(&["--foo", "--bar=qux", "--foobar=qux=baz"]); - assert_eq!(p.next_arg().unwrap(), Long("foo")); - assert_eq!(p.next_arg().unwrap(), Long("bar")); - assert_eq!(p.next_flag_value().unwrap(), "qux"); - assert_eq!(p.next_flag_value(), None); - assert_eq!(p.next_arg().unwrap(), Long("foobar")); - assert_eq!(p.next_arg().unwrap(), Unexpected(OsStr::new("qux=baz"))); - assert_eq!(p.next_arg(), None); - } - - #[test] - fn test_dash_args() { - // "--" should indicate the end of the options - let mut p = Parser::new(&["-x", "--", "-y"]); - assert_eq!(p.next_arg().unwrap(), Short("x")); - assert_eq!(p.next_arg().unwrap(), Escape("--")); - assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("-y"))); - assert_eq!(p.next_arg(), None); - - // ...even if it's an argument of an option - let mut p = Parser::new(&["-x", "--", "-y"]); - assert_eq!(p.next_arg().unwrap(), Short("x")); - assert_eq!(p.next_flag_value(), None); - assert_eq!(p.next_arg().unwrap(), Escape("--")); - assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("-y"))); - assert_eq!(p.next_arg(), None); - - // "-" is a valid value that should not be treated as an option - let mut p = Parser::new(&["-x", "-", "-y"]); - assert_eq!(p.next_arg().unwrap(), Short("x")); - assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("-"))); - assert_eq!(p.next_arg().unwrap(), Short("y")); - assert_eq!(p.next_arg(), None); - - // '-' is a silly and hard to use short option, but other parsers treat - // it like an option in this position - let mut p = Parser::new(&["-x-y"]); - assert_eq!(p.next_arg().unwrap(), Short("x")); - assert_eq!(p.next_arg().unwrap(), Short("-")); - assert_eq!(p.next_arg().unwrap(), Short("y")); - assert_eq!(p.next_arg(), None); - } - - #[test] - fn test_missing_value() { - let mut p = Parser::new(&["-o"]); - assert_eq!(p.next_arg().unwrap(), Short("o")); - assert_eq!(p.next_flag_value(), None); - - let mut q = Parser::new(&["--out"]); - assert_eq!(q.next_arg().unwrap(), Long("out")); - assert_eq!(q.next_flag_value(), None); - - let args: [&OsStr; 0] = []; - let mut r = Parser::new(&args); - assert_eq!(r.next_flag_value(), None); - } - - #[test] - fn test_weird_args() { - let mut p = Parser::new(&[ - "--=", "--=3", "-", "-x", "--", "-", "-x", "--", "", "-", "-x", - ]); - assert_eq!(p.next_arg().unwrap(), Unexpected(OsStr::new("--="))); - assert_eq!(p.next_arg().unwrap(), Unexpected(OsStr::new("--=3"))); - assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("-"))); - assert_eq!(p.next_arg().unwrap(), Short("x")); - assert_eq!(p.next_arg().unwrap(), Escape("--")); - assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("-"))); - assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("-x"))); - assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("--"))); - assert_eq!(p.next_arg().unwrap(), Value(OsStr::new(""))); - assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("-"))); - assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("-x"))); - assert_eq!(p.next_arg(), None); - - let bad = bad_string("--=@"); - let args = [&bad]; - let mut q = Parser::new(&args); - assert_eq!(q.next_arg().unwrap(), Unexpected(OsStr::new(&bad))); - - let mut r = Parser::new(&[""]); - assert_eq!(r.next_arg().unwrap(), Value(OsStr::new(""))); - } - - #[test] - fn test_unicode() { - let mut p = Parser::new(&["-aµ", "--µ=10", "µ", "--foo=µ"]); - assert_eq!(p.next_arg().unwrap(), Short("a")); - assert_eq!(p.next_arg().unwrap(), Short("µ")); - assert_eq!(p.next_arg().unwrap(), Long("µ")); - assert_eq!(p.next_flag_value().unwrap(), "10"); - assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("µ"))); - assert_eq!(p.next_arg().unwrap(), Long("foo")); - assert_eq!(p.next_flag_value().unwrap(), "µ"); - } - - #[cfg(any(unix, target_os = "wasi", windows))] - #[test] - fn test_mixed_invalid() { - let args = [bad_string("--foo=@@@")]; - let mut p = Parser::new(&args); - assert_eq!(p.next_arg().unwrap(), Long("foo")); - assert_eq!(p.next_flag_value().unwrap(), bad_string("@@@")); - - let args = [bad_string("-💣@@@")]; - let mut q = Parser::new(&args); - assert_eq!(q.next_arg().unwrap(), Short("💣")); - assert_eq!(q.next_flag_value().unwrap(), bad_string("@@@")); - - let args = [bad_string("-f@@@")]; - let mut r = Parser::new(&args); - assert_eq!(r.next_arg().unwrap(), Short("f")); - assert_eq!(r.next_arg().unwrap(), Unexpected(&bad_string("@@@"))); - assert_eq!(r.next_arg(), None); - - let args = [bad_string("--foo=bar=@@@")]; - let mut s = Parser::new(&args); - assert_eq!(s.next_arg().unwrap(), Long("foo")); - assert_eq!(s.next_flag_value().unwrap(), bad_string("bar=@@@")); - } - - #[cfg(any(unix, target_os = "wasi", windows))] - #[test] - fn test_separate_invalid() { - let args = [bad_string("--foo"), bad_string("@@@")]; - let mut p = Parser::new(&args); - assert_eq!(p.next_arg().unwrap(), Long("foo")); - assert_eq!(p.next_flag_value().unwrap(), bad_string("@@@")); - } - - #[cfg(any(unix, target_os = "wasi", windows))] - #[test] - fn test_invalid_long_option() { - let args = [bad_string("--@=10")]; - let mut p = Parser::new(&args); - assert_eq!(p.next_arg().unwrap(), Unexpected(&args[0])); - assert_eq!(p.next_arg(), None); - - let args = [bad_string("--@")]; - let mut p = Parser::new(&args); - assert_eq!(p.next_arg().unwrap(), Unexpected(&args[0])); - assert_eq!(p.next_arg(), None); - } - - #[cfg(any(unix, target_os = "wasi", windows))] - #[test] - fn test_invalid_short_option() { - let args = [bad_string("-@")]; - let mut p = Parser::new(&args); - assert_eq!(p.next_arg().unwrap(), Unexpected(&args[0])); - assert_eq!(p.next_arg(), None); - } - - #[test] - fn short_opt_equals_sign() { - let mut p = Parser::new(&["-a=b"]); - assert_eq!(p.next_arg().unwrap(), Short("a")); - assert_eq!(p.next_flag_value().unwrap(), OsStr::new("b")); - assert_eq!(p.next_arg(), None); - - let mut p = Parser::new(&["-a=b", "c"]); - assert_eq!(p.next_arg().unwrap(), Short("a")); - assert_eq!(p.next_flag_value().unwrap(), OsStr::new("b")); - assert_eq!(p.next_flag_value(), None); - assert_eq!(p.next_arg().unwrap(), Value(OsStr::new("c"))); - assert_eq!(p.next_arg(), None); - - let mut p = Parser::new(&["-a=b"]); - assert_eq!(p.next_arg().unwrap(), Short("a")); - assert_eq!(p.next_arg().unwrap(), Short("=")); - assert_eq!(p.next_arg().unwrap(), Short("b")); - assert_eq!(p.next_arg(), None); - - let mut p = Parser::new(&["-a="]); - assert_eq!(p.next_arg().unwrap(), Short("a")); - assert_eq!(p.next_flag_value().unwrap(), OsStr::new("")); - assert_eq!(p.next_arg(), None); - - let mut p = Parser::new(&["-a=="]); - assert_eq!(p.next_arg().unwrap(), Short("a")); - assert_eq!(p.next_flag_value().unwrap(), OsStr::new("=")); - assert_eq!(p.next_arg(), None); - - let mut p = Parser::new(&["-abc=de"]); - assert_eq!(p.next_arg().unwrap(), Short("a")); - assert_eq!(p.next_flag_value().unwrap(), OsStr::new("bc=de")); - assert_eq!(p.next_arg(), None); - - let mut p = Parser::new(&["-abc==de"]); - assert_eq!(p.next_arg().unwrap(), Short("a")); - assert_eq!(p.next_arg().unwrap(), Short("b")); - assert_eq!(p.next_arg().unwrap(), Short("c")); - assert_eq!(p.next_flag_value().unwrap(), OsStr::new("=de")); - assert_eq!(p.next_arg(), None); - - let mut p = Parser::new(&["-a="]); - assert_eq!(p.next_arg().unwrap(), Short("a")); - assert_eq!(p.next_arg().unwrap(), Short("=")); - assert_eq!(p.next_arg(), None); - - let mut p = Parser::new(&["-="]); - assert_eq!(p.next_arg().unwrap(), Short("=")); - assert_eq!(p.next_arg(), None); - - let mut p = Parser::new(&["-=a"]); - assert_eq!(p.next_arg().unwrap(), Short("=")); - assert_eq!(p.next_arg().unwrap(), Short("a")); - assert_eq!(p.next_arg(), None); - } - - #[cfg(any(unix, target_os = "wasi", windows))] - #[test] - fn short_opt_equals_sign_invalid() { - let bad = bad_string("@"); - let args = [bad_string("-a=@")]; - let mut p = Parser::new(&args); - assert_eq!(p.next_arg().unwrap(), Short("a")); - assert_eq!(p.next_flag_value().unwrap(), bad_string("@")); - assert_eq!(p.next_arg(), None); - - let mut p = Parser::new(&args); - assert_eq!(p.next_arg().unwrap(), Short("a")); - assert_eq!(p.next_arg().unwrap(), Short("=")); - assert_eq!(p.next_arg().unwrap(), Unexpected(&bad)); - assert_eq!(p.next_arg(), None); - } - - #[test] - fn remaining_raw() { - let mut p = Parser::new(&["-a", "b", "c", "d"]); - assert_eq!( - p.remaining_raw().unwrap().collect::>(), - &["-a", "b", "c", "d"] - ); - // Consumed all - assert!(p.next_arg().is_none()); - assert!(p.remaining_raw().is_ok()); - assert_eq!(p.remaining_raw().unwrap().collect::>().len(), 0); - - let mut p = Parser::new(&["-ab", "c", "d"]); - p.next_arg().unwrap(); - // Attached value - assert!(p.remaining_raw().is_err()); - p.next_attached_value().unwrap(); - assert_eq!(p.remaining_raw().unwrap().collect::>(), &["c", "d"]); - // Consumed all - assert!(p.next_arg().is_none()); - assert_eq!(p.remaining_raw().unwrap().collect::>().len(), 0); - } - - /// Transform @ characters into invalid unicode. - fn bad_string(text: &str) -> std::ffi::OsString { - #[cfg(any(unix, target_os = "wasi"))] - { - #[cfg(unix)] - use std::os::unix::ffi::OsStringExt; - #[cfg(target_os = "wasi")] - use std::os::wasi::ffi::OsStringExt; - let mut text = text.as_bytes().to_vec(); - for ch in &mut text { - if *ch == b'@' { - *ch = b'\xFF'; - } - } - std::ffi::OsString::from_vec(text) - } - #[cfg(windows)] - { - use std::os::windows::ffi::OsStringExt; - let mut out = Vec::new(); - for ch in text.chars() { - if ch == '@' { - out.push(0xD800); - } else { - let mut buf = [0; 2]; - out.extend(&*ch.encode_utf16(&mut buf)); - } - } - std::ffi::OsString::from_wide(&out) - } - #[cfg(not(any(unix, target_os = "wasi", windows)))] - { - if text.contains('@') { - unimplemented!("Don't know how to create invalid OsStrings on this platform"); - } - text.into() - } - } - - /// Basic exhaustive testing of short combinations of "interesting" - /// arguments. They should not panic, not hang, and pass some checks. - /// - /// The advantage compared to full fuzzing is that it runs on all platforms - /// and together with the other tests. cargo-fuzz doesn't work on Windows - /// and requires a special incantation. - /// - /// A disadvantage is that it's still limited by arguments I could think of - /// and only does very short sequences. Another is that it's bad at - /// reporting failure, though the println!() helps. - /// - /// This test takes a while to run. - #[test] - fn basic_fuzz() { - #[cfg(any(windows, unix, target_os = "wasi"))] - const VOCABULARY: &[&str] = &[ - "", "-", "--", "---", "a", "-a", "-aa", "@", "-@", "-a@", "-@a", "--a", "--@", "--a=a", - "--a=", "--a=@", "--@=a", "--=", "--=@", "--=a", "-@@", "-a=a", "-a=", "-=", "-a-", - ]; - #[cfg(not(any(windows, unix, target_os = "wasi")))] - const VOCABULARY: &[&str] = &[ - "", "-", "--", "---", "a", "-a", "-aa", "--a", "--a=a", "--a=", "--=", "--=a", "-a=a", - "-a=", "-=", "-a-", - ]; - let args: [&OsStr; 0] = []; - exhaust(Parser::new(&args), vec![]); - let vocabulary: Vec = - VOCABULARY.iter().map(|&s| bad_string(s)).collect(); - let mut permutations = vec![vec![]]; - for _ in 0..3 { - let mut new = Vec::new(); - for old in permutations { - for word in &vocabulary { - let mut extended = old.clone(); - extended.push(word); - new.push(extended); - } - } - permutations = new; - for permutation in &permutations { - println!("Starting {permutation:?}"); - let p = Parser::new(permutation); - exhaust(p, vec![]); - } - } - } - - /// Run many sequences of methods on a Parser. - fn exhaust(parser: Parser<'_>, path: Vec) { - if path.len() > 100 { - panic!("Stuck in loop: {path:?}"); - } - - if parser.has_pending() { - { - let mut parser = parser.clone(); - let next = parser.next_arg(); - assert!( - matches!(next, Some(Unexpected(_)) | Some(Short(_))), - "{next:?} via {path:?}", - ); - let mut path = path.clone(); - path.push(format!("pending-next-{next:?}")); - exhaust(parser, path); - } - - { - let mut parser = parser.clone(); - let next = parser.next_flag_value(); - assert!(next.is_some(), "{next:?} via {path:?}",); - let mut path = path; - path.push(format!("pending-value-{next:?}")); - exhaust(parser, path); - } - } else { - { - let mut parser = parser.clone(); - let next = parser.next_arg(); - match &next { - None => { - assert!( - matches!(parser.state, None | Some(State::Escaped)), - "{next:?} via {path:?}", - ); - assert_eq!(parser.current, parser.raw.len(), "{next:?} via {path:?}",); - } - _ => { - let mut path = path.clone(); - path.push(format!("next-{next:?}")); - exhaust(parser, path); - } - } - } - - { - let mut parser = parser.clone(); - let next = parser.next_flag_value(); - match &next { - None => { - assert!( - matches!(parser.state, None | Some(State::Escaped)), - "{next:?} via {path:?}", - ); - if parser.state.is_none() - && !parser.was_attached - && parser.peek_raw_() != Some(OsStr::new("--")) - { - assert_eq!(parser.current, parser.raw.len(), "{next:?} via {path:?}",); - } - } - Some(_) => { - assert!( - matches!(parser.state, None | Some(State::Escaped)), - "{next:?} via {path:?}", - ); - let mut path = path; - path.push(format!("value-{next:?}")); - exhaust(parser, path); - } - } - } - } +impl OptionContextExt for Option { + fn ok_or_missing(self, expected: Arg<'static>) -> Result> { + self.ok_or_else(|| match expected { + Arg::Short(short) => ErrorContext::msg(format_args!("missing required `-{short}`")), + Arg::Long(long) => ErrorContext::msg(format_args!("missing required `--{long}`")), + Arg::Escape(escape) => ErrorContext::msg(format_args!("missing required `{escape}`")), + Arg::Value(value) | Arg::Unexpected(value) => ErrorContext::msg(format_args!( + "missing required `{}`", + value.to_string_lossy() + )), + }) } } diff --git a/crates/libtest-lexarg/src/lib.rs b/crates/libtest-lexarg/src/lib.rs index 5591f82..d31bd56 100644 --- a/crates/libtest-lexarg/src/lib.rs +++ b/crates/libtest-lexarg/src/lib.rs @@ -8,8 +8,7 @@ #![warn(missing_debug_implementations, elided_lifetimes_in_paths)] use lexarg::Arg; -use lexarg_error::Error; -use lexarg_error::Result; +use lexarg_error::ErrorContext; /// Parsed command-line options /// @@ -137,7 +136,7 @@ impl TimeThreshold { /// /// Panics if variable with provided name is set but contains inappropriate /// value. - fn from_env_var(env_var_name: &str) -> Result> { + fn from_env_var(env_var_name: &str) -> Result, ErrorContext<'static>> { use std::str::FromStr; let durations_str = match std::env::var(env_var_name) { @@ -147,14 +146,14 @@ impl TimeThreshold { } }; let (warn_str, critical_str) = durations_str.split_once(',').ok_or_else(|| { - Error::msg(format_args!( + ErrorContext::msg(format_args!( "Duration variable {env_var_name} expected to have 2 numbers separated by comma, but got {durations_str}" )) })?; let parse_u64 = |v| { u64::from_str(v).map_err(|_err| { - Error::msg(format_args!( + ErrorContext::msg(format_args!( "Duration value in variable {env_var_name} is expected to be a number, but got {v}" )) }) @@ -305,123 +304,110 @@ impl TestOptsParseState { &mut self, parser: &mut lexarg::Parser<'a>, arg: Arg<'a>, - ) -> Result>> { + ) -> Result>, ErrorContext<'a>> { + use lexarg::prelude::*; + match arg { - Arg::Long("include-ignored") => { + Long("include-ignored") => { self.include_ignored = true; } - Arg::Long("ignored") => self.ignored = true, - Arg::Long("force-run-in-process") => { + Long("ignored") => self.ignored = true, + Long("force-run-in-process") => { self.opts.force_run_in_process = true; } - Arg::Long("exclude-should-panic") => { + Long("exclude-should-panic") => { self.opts.exclude_should_panic = true; } - Arg::Long("test") => { + Long("test") => { self.opts.run_tests = true; } - Arg::Long("bench") => { + Long("bench") => { self.opts.bench_benchmarks = true; } - Arg::Long("list") => { + Long("list") => { self.opts.list = true; } - Arg::Long("logfile") => { + Long("logfile") => { let path = parser .next_flag_value() - .ok_or_else(|| Error::msg("`--logfile` requires a path"))?; - self.opts.logfile = Some(std::path::PathBuf::from(path)); + .ok_or_missing(Value(std::ffi::OsStr::new("PATH"))) + .path() + .within(arg)?; + self.opts.logfile = Some(path.to_owned()); } - Arg::Long("nocapture") => { + Long("nocapture") => { self.opts.nocapture = true; } - Arg::Long("test-threads") => { + Long("test-threads") => { let test_threads = parser .next_flag_value() - .ok_or_else(|| Error::msg("`--test-threads` requires a positive integer"))? - .to_str() - .ok_or_else(|| Error::msg("unsupported value"))?; - self.opts.test_threads = match test_threads.parse::() { - Ok(n) => Some(n), - Err(_) => { - return Err(Error::msg("`--test-threads` must be a positive integer")); - } - }; + .ok_or_missing(Value(std::ffi::OsStr::new("NUM"))) + .parse() + .within(arg)?; + self.opts.test_threads = Some(test_threads); } - Arg::Long("skip") => { + Long("skip") => { let filter = parser .next_flag_value() - .ok_or_else(|| Error::msg("`--skip` requires a value"))? - .to_str() - .ok_or_else(|| Error::msg("unsupported value"))?; + .ok_or_missing(Value(std::ffi::OsStr::new("NAME"))) + .string("NAME") + .within(arg)?; self.opts.skip.push(filter.to_owned()); } - Arg::Long("exact") => { + Long("exact") => { self.opts.filter_exact = true; } - Arg::Long("color") => { + Long("color") => { let color = parser .next_flag_value() - .ok_or_else(|| { - Error::msg("`--color` requires one of `auto`, `always`, or `never`") - })? - .to_str() - .ok_or_else(|| Error::msg("unsupported value"))?; + .ok_or_missing(Value(std::ffi::OsStr::new("WHEN"))) + .one_of(&["auto", "always", "never"]) + .within(arg)?; self.opts.color = match color { "auto" => ColorConfig::AutoColor, "always" => ColorConfig::AlwaysColor, "never" => ColorConfig::NeverColor, - _ => { - return Err(Error::msg("`--color` accepts `auto`, `always`, or `never`")); - } + _ => unreachable!("`one_of` should prevent this"), }; } - Arg::Short("q") | Arg::Long("quiet") => { + Short("q") | Long("quiet") => { self.format = None; self.quiet = true; } - Arg::Long("format") => { + Long("format") => { self.quiet = false; let format = parser .next_flag_value() - .ok_or_else(|| { - Error::msg( - "`--format` requires one of `pretty`, `terse`, `json`, or `junit`", - ) - })? - .to_str() - .ok_or_else(|| Error::msg("unsupported value"))?; + .ok_or_missing(Value(std::ffi::OsStr::new("FORMAT"))) + .one_of(&["pretty", "terse", "json", "junit"]) + .within(arg)?; self.format = Some(match format { "pretty" => OutputFormat::Pretty, "terse" => OutputFormat::Terse, "json" => OutputFormat::Json, "junit" => OutputFormat::Junit, - _ => { - return Err(Error::msg( - "`--format` accepts `pretty`, `terse`, `json`, or `junit`", - )); - } + _ => unreachable!("`one_of` should prevent this"), }); } - Arg::Long("show-output") => { + Long("show-output") => { self.opts.options.display_output = true; } - Arg::Short("Z") => { + Short("Z") => { let feature = parser .next_flag_value() - .ok_or_else(|| Error::msg("`-Z` requires a feature name"))? - .to_str() - .ok_or_else(|| Error::msg("unsupported value"))?; + .ok_or_missing(Value(std::ffi::OsStr::new("FEATURE"))) + .string("FEATURE") + .within(arg)?; if !is_nightly() { - return Err(Error::msg("`-Z` is only accepted on the nightly compiler")); + return Err(ErrorContext::msg("expected nightly compiler").unexpected(arg)); } // Don't validate `feature` as other parsers might provide values self.opts.allowed_unstable.push(feature.to_owned()); } - Arg::Long("report-time") => { + Long("report-time") => { self.opts.time_options.get_or_insert_with(Default::default); } - Arg::Long("ensure-time") => { + Long("ensure-time") => { let time = self.opts.time_options.get_or_insert_with(Default::default); time.error_on_excess = true; if let Some(threshold) = TimeThreshold::from_env_var("RUST_TEST_TIME_UNIT")? { @@ -435,25 +421,19 @@ impl TestOptsParseState { time.doctest_threshold = threshold; } } - Arg::Long("shuffle") => { + Long("shuffle") => { self.opts.shuffle = true; } - Arg::Long("shuffle-seed") => { + Long("shuffle-seed") => { let seed = parser .next_flag_value() - .ok_or_else(|| Error::msg("`--shuffle-seed` requires a value"))? - .to_str() - .ok_or_else(|| Error::msg("unsupported value"))? - .parse::() - .map_err(Error::msg)?; + .ok_or_missing(Value(std::ffi::OsStr::new("SEED"))) + .parse() + .within(arg)?; self.opts.shuffle_seed = Some(seed); } - // All values are the same, whether escaped or not, so its a no-op - Arg::Escape(_) => {} - Arg::Value(filter) => { - let filter = filter - .to_str() - .ok_or_else(|| Error::msg("unsupported value"))?; + Value(filter) => { + let filter = filter.string("FILTER")?; self.opts.filters.push(filter.to_owned()); } _ => { @@ -464,7 +444,7 @@ impl TestOptsParseState { } /// Finish parsing, resolving to [`TestOpts`] - pub fn finish(mut self) -> Result { + pub fn finish(mut self) -> Result> { let allow_unstable_options = self .opts .allowed_unstable @@ -472,19 +452,21 @@ impl TestOptsParseState { .any(|f| f == UNSTABLE_OPTIONS); if self.opts.force_run_in_process && !allow_unstable_options { - return Err(Error::msg( + return Err(ErrorContext::msg( "`--force-run-in-process` requires `-Zunstable-options`", )); } if self.opts.exclude_should_panic && !allow_unstable_options { - return Err(Error::msg( + return Err(ErrorContext::msg( "`--exclude-should-panic` requires `-Zunstable-options`", )); } if self.opts.shuffle && !allow_unstable_options { - return Err(Error::msg("`--shuffle` requires `-Zunstable-options`")); + return Err(ErrorContext::msg( + "`--shuffle` requires `-Zunstable-options`", + )); } if !self.opts.shuffle && allow_unstable_options { self.opts.shuffle = match std::env::var("RUST_TEST_SHUFFLE") { @@ -494,14 +476,16 @@ impl TestOptsParseState { } if self.opts.shuffle_seed.is_some() && !allow_unstable_options { - return Err(Error::msg("`--shuffle-seed` requires `-Zunstable-options`")); + return Err(ErrorContext::msg( + "`--shuffle-seed` requires `-Zunstable-options`", + )); } if self.opts.shuffle_seed.is_none() && allow_unstable_options { self.opts.shuffle_seed = match std::env::var("RUST_TEST_SHUFFLE_SEED") { Ok(val) => match val.parse::() { Ok(n) => Some(n), Err(_) => { - return Err(Error::msg( + return Err(ErrorContext::msg( "RUST_TEST_SHUFFLE_SEED is `{val}`, should be a number.", )); } @@ -518,7 +502,9 @@ impl TestOptsParseState { } if self.format.is_some() && !allow_unstable_options { - return Err(Error::msg("`--format` requires `-Zunstable-options`")); + return Err(ErrorContext::msg( + "`--format` requires `-Zunstable-options`", + )); } if let Some(format) = self.format { self.opts.format = format; @@ -531,7 +517,7 @@ impl TestOptsParseState { self.opts.run_ignored = match (self.include_ignored, self.ignored) { (true, true) => { - return Err(Error::msg( + return Err(ErrorContext::msg( "`--include-ignored` and `--ignored` are mutually exclusive", )) } @@ -544,7 +530,7 @@ impl TestOptsParseState { if let Ok(value) = std::env::var("RUST_TEST_THREADS") { self.opts.test_threads = Some(value.parse::().map_err(|_e| { - Error::msg(format!( + ErrorContext::msg(format!( "RUST_TEST_THREADS is `{value}`, should be a positive integer." )) })?); diff --git a/crates/libtest2-harness/Cargo.toml b/crates/libtest2-harness/Cargo.toml index 7a5026a..6f0b74d 100644 --- a/crates/libtest2-harness/Cargo.toml +++ b/crates/libtest2-harness/Cargo.toml @@ -32,7 +32,7 @@ threads = [] [dependencies] anstream = "0.6.4" anstyle = "1.0.0" -lexarg = { version = "0.1.0", path = "../lexarg" } +lexarg-parser = { version = "0.1.0", path = "../lexarg-parser" } lexarg-error = { version = "0.1.0", path = "../lexarg-error" } libtest-lexarg = { version = "0.1.0", path = "../libtest-lexarg" } serde = { version = "1.0.160", features = ["derive"], optional = true } diff --git a/crates/libtest2-harness/src/cli.rs b/crates/libtest2-harness/src/cli.rs index 4818d5c..54d82ff 100644 --- a/crates/libtest2-harness/src/cli.rs +++ b/crates/libtest2-harness/src/cli.rs @@ -1,2 +1,2 @@ -pub use lexarg::*; pub use lexarg_error::*; +pub use lexarg_parser::*; diff --git a/crates/libtest2-harness/src/harness.rs b/crates/libtest2-harness/src/harness.rs index d84527d..7b67146 100644 --- a/crates/libtest2-harness/src/harness.rs +++ b/crates/libtest2-harness/src/harness.rs @@ -70,16 +70,20 @@ impl Harness { const ERROR_EXIT_CODE: i32 = 101; -fn parse(parser: &mut cli::Parser<'_>) -> cli::Result { +fn parse<'p>( + parser: &mut cli::Parser<'p>, +) -> Result> { let mut test_opts = libtest_lexarg::TestOptsParseState::new(); - let bin = parser.next_raw().expect("first arg, no pending values"); + let bin = parser + .next_raw() + .expect("first arg, no pending values") + .unwrap_or(std::ffi::OsStr::new("test")); + let mut prev_arg = cli::Arg::Value(bin); while let Some(arg) = parser.next_arg() { match arg { cli::Arg::Short("h") | cli::Arg::Long("help") => { - let bin = bin - .unwrap_or_else(|| std::ffi::OsStr::new("test")) - .to_string_lossy(); + let bin = bin.to_string_lossy(); let options_help = libtest_lexarg::OPTIONS_HELP.trim(); let after_help = libtest_lexarg::AFTER_HELP.trim(); println!( @@ -91,28 +95,24 @@ fn parse(parser: &mut cli::Parser<'_>) -> cli::Result ); std::process::exit(0); } + // All values are the same, whether escaped or not, so its a no-op + cli::Arg::Escape(_) => { + prev_arg = arg; + continue; + } + cli::Arg::Unexpected(_) => { + return Err(cli::ErrorContext::msg("unexpected value") + .unexpected(arg) + .within(prev_arg)); + } _ => {} } + prev_arg = arg; let arg = test_opts.parse_next(parser, arg)?; if let Some(arg) = arg { - let msg = match arg { - cli::Arg::Short(v) => { - format!("unrecognized `-{v}` flag") - } - cli::Arg::Long(v) => { - format!("unrecognized `--{v}` flag") - } - cli::Arg::Escape(_) => "handled `--`".to_owned(), - cli::Arg::Value(v) => { - format!("unrecognized `{}` value", v.to_string_lossy()) - } - cli::Arg::Unexpected(v) => { - format!("unexpected `{}` value", v.to_string_lossy()) - } - }; - return Err(cli::Error::msg(msg)); + return Err(cli::ErrorContext::msg("unexpected argument").unexpected(arg)); } }