Repository: pacak/bpaf
Pull request #57 (2022-09-12 14:41:31)

Catch + docs crate


Patch:
diff --git a/.github/workflows/check-and-lint.yaml b/.github/workflows/check-and-lint.yaml
index 1141c55..f3fdf7c 100644
--- a/.github/workflows/check-and-lint.yaml
+++ b/.github/workflows/check-and-lint.yaml
@@ -69,6 +69,15 @@ jobs:
           command: test
           args: --workspace --doc --no-default-features
 
+      - name: example documentation
+        uses: actions-rs/cargo@v1
+        with:
+          command: build
+          args: --all-features -p docs
+
+      - name: should not have any changes
+        run: git diff && git diff-index --quiet HEAD --
+
       - name: Rustdoc
         uses: actions-rs/cargo@v1
         with:
@@ -88,6 +97,7 @@ jobs:
           toolchain: stable
           override: true
       - run: rustup component add rustfmt
+      - run: echo "" > docs/src/lib.rs
       - uses: actions-rs/cargo@v1
         with:
           command: fmt
diff --git a/.gitignore b/.gitignore
index 8c379fe..4b63346 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
 /target
 Cargo.lock
 .xwin-cache/
+/docs/src/*.rs
diff --git a/Cargo.toml b/Cargo.toml
index f11e52d..4d83ff2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,6 +10,8 @@ readme = "README.md"
 license = "MIT OR Apache-2.0"
 repository = "https://github.com/pacak/bpaf"
 rust-version = "1.56"
+exclude = [".github/workflows"]
+
 
 [dependencies]
 bpaf_derive = { path = "./bpaf_derive", version = "0.2.2", optional = true }
@@ -46,4 +48,4 @@ name = "csample"
 required-features = ["autocomplete"]
 
 [workspace]
-members = [".", "./bpaf_derive", "./bpaf_cauwugo"]
+members = [".", "./bpaf_derive", "./bpaf_cauwugo", "docs"]
diff --git a/bpaf_derive/src/field.rs b/bpaf_derive/src/field.rs
index 9a0b383..3fce09f 100644
--- a/bpaf_derive/src/field.rs
+++ b/bpaf_derive/src/field.rs
@@ -53,6 +53,7 @@ enum PostprAttr {
     Tokens(Span, TokenStream),
     Hide(Span),
     GroupHelp(Span, Box<Expr>),
+    Catch(Span),
 }
 
 impl PostprAttr {
@@ -69,6 +70,7 @@ impl PostprAttr {
             | PostprAttr::FallbackWith(..)
             | PostprAttr::Complete(..)
             | PostprAttr::Hide(..)
+            | PostprAttr::Catch(..)
             | PostprAttr::GroupHelp(..) => true,
         }
     }
@@ -86,6 +88,7 @@ impl PostprAttr {
             | PostprAttr::Complete(span, _)
             | PostprAttr::Tokens(span, _)
             | PostprAttr::Hide(span)
+            | PostprAttr::Catch(span)
             | PostprAttr::GroupHelp(span, _) => *span,
         }
     }
@@ -208,6 +211,7 @@ impl ToTokens for PostprAttr {
             PostprAttr::FallbackWith(_span, v) => quote!(fallback_with(#v)),
             PostprAttr::Tokens(_span, t) => quote!(#t),
             PostprAttr::Hide(_span) => quote!(hide()),
+            PostprAttr::Catch(_span) => quote!(catch()),
             PostprAttr::Complete(_span, f) => quote!(complete(#f)),
             PostprAttr::GroupHelp(_span, m) => quote!(group_help(#m)),
         }
diff --git a/bpaf_derive/src/field/named_field.rs b/bpaf_derive/src/field/named_field.rs
index 0ad99e9..3a2f9e9 100644
--- a/bpaf_derive/src/field/named_field.rs
+++ b/bpaf_derive/src/field/named_field.rs
@@ -241,6 +241,9 @@ impl Field {
                     } else if keyword == "hide" {
                         check_stage(&mut stage, 4, &keyword)?;
                         res.postpr.push(PostprAttr::Hide(span));
+                    } else if keyword == "catch" {
+                        check_stage(&mut stage, 4, &keyword)?;
+                        res.postpr.push(PostprAttr::Catch(span));
                     } else if keyword == "group_help" {
                         check_stage(&mut stage, 4, &keyword)?;
                         let expr = parse_expr(input)?;
diff --git a/docs/Cargo.toml b/docs/Cargo.toml
new file mode 100644
index 0000000..cceef23
--- /dev/null
+++ b/docs/Cargo.toml
@@ -0,0 +1,15 @@
+[package]
+name = "docs"
+version = "0.1.0"
+edition = "2021"
+publish = false
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+[lib]
+path = "src/lib.rs"
+
+[dependencies]
+bpaf = { path = "../", features = ["autocomplete", "derive"] }
+
+[build-dependencies]
+shell-words = "1.1.0"
diff --git a/docs/build.rs b/docs/build.rs
new file mode 100644
index 0000000..e3aa24f
--- /dev/null
+++ b/docs/build.rs
@@ -0,0 +1,248 @@
+use std::{
+    error::Error,
+    fmt::Write,
+    fs::OpenOptions,
+    io::{BufRead, BufReader, Read, Seek},
+    path::{Path, PathBuf},
+};
+
+#[derive(Default, Debug)]
+struct TestCase {
+    descr: String,
+    args: String,
+    output: String,
+    code: Code,
+}
+
+#[derive(Debug)]
+enum Code {
+    Ok,
+    Stderr,
+    Stdout,
+}
+impl Default for Code {
+    fn default() -> Self {
+        Self::Ok
+    }
+}
+
+#[derive(Debug)]
+enum Mode {
+    Descr,
+    Args,
+    Code,
+    Output,
+}
+
+fn parse_test_cases<P: AsRef<Path>>(path: P) -> Result<Vec<TestCase>, Box<dyn Error>> {
+    let path = path.as_ref();
+    let mut res = Vec::new();
+
+    let file = std::fs::File::open(path)?;
+    let mut cur = TestCase::default();
+
+    let mut mode = Mode::Descr;
+    for line in BufReader::new(file).lines() {
+        let line = line?;
+        loop {
+            match mode {
+                Mode::Descr => {
+                    if let Some(descr) = line.strip_prefix("? ") {
+                        cur.descr.push_str(descr);
+                        cur.descr.push('\n');
+                    } else {
+                        mode = Mode::Args;
+                        continue;
+                    }
+                }
+                Mode::Args => {
+                    if let Some(args) = line.strip_prefix('>') {
+                        cur.args = args.to_owned();
+                        mode = Mode::Code;
+                    } else {
+                        panic!("Expected '> xxx', got {:?}", line);
+                    }
+                }
+                Mode::Code => {
+                    cur.code = match line.as_str() {
+                        "OK" => Code::Ok,
+                        "Stdout" => Code::Stdout,
+                        "Stderr" => Code::Stderr,
+                        _ => panic!("Unexpected code: {:?}", line),
+                    };
+                    mode = Mode::Output;
+                }
+                Mode::Output => {
+                    if line.starts_with("? ") {
+                        mode = Mode::Descr;
+                        res.push(std::mem::take(&mut cur));
+                        continue;
+                    } else {
+                        cur.output.push_str(&line);
+                        cur.output.push('\n');
+                    }
+                }
+            }
+            break;
+        }
+    }
+    res.push(std::mem::take(&mut cur));
+
+    Ok(res)
+}
+
+fn write_cases(res: &mut String, cases: &[TestCase]) -> Result<(), Box<dyn Error>> {
+    for case in cases {
+        writeln!(res)?;
+        let args = shell_words::split(&case.args)?;
+        writeln!(res, "    let args = Args::from(&{:?});", args)?;
+        write!(res, "    let r = options.run_inner(args).")?;
+
+        match case.code {
+            Code::Ok => {
+                writeln!(res, "unwrap();")?;
+                writeln!(res, "    let r = format!(\"{{:?}}\", r);")?;
+            }
+            Code::Stderr => writeln!(res, "unwrap_err().unwrap_err()")?,
+            Code::Stdout => writeln!(res, "unwrap_err().unwrap_stdout()")?,
+        }
+        writeln!(res, "    assert_eq!(r, {:?});", case.output.trim_end())?;
+    }
+    Ok(())
+}
+
+fn import_example<P: AsRef<Path>>(path: P) -> Result<(String, String), Box<dyn Error>> {
+    let path = path.as_ref();
+    let combine = path.join("combine.rs").exists();
+    let derive = path.join("derive.rs").exists();
+
+    let cases = parse_test_cases(path.join("cases.txt"))?;
+    assert!(combine || derive);
+
+    let mut t_r = String::new();
+
+    if combine {
+        writeln!(t_r, "mod combine;\n")?;
+        writeln!(t_r, "#[rustfmt::skip]")?;
+        writeln!(t_r, "#[test]\nfn combine_works() {{")?;
+        writeln!(t_r, "    use bpaf::*;")?;
+        writeln!(t_r, "    let options = combine::options();")?;
+        write_cases(&mut t_r, &cases)?;
+        writeln!(t_r, "}}")?;
+    }
+
+    if derive {
+        writeln!(t_r, "mod derive;\n")?;
+        writeln!(t_r, "#[rustfmt::skip]")?;
+        writeln!(t_r, "#[test]\nfn derive_works() {{")?;
+        writeln!(t_r, "    use bpaf::*;")?;
+        writeln!(t_r, "    let options = derive::options();")?;
+        write_cases(&mut t_r, &cases)?;
+        writeln!(t_r, "}}")?;
+    }
+
+    let mut t_d = String::new();
+
+    if combine {
+        writeln!(t_d, "<details>")?;
+        writeln!(t_d, "<summary>Combinatoric usage</summary>")?;
+        writeln!(t_d)?;
+        writeln!(t_d, "```no_run")?;
+        include_file(&mut t_d, path.join("combine.rs"))?;
+        writeln!(t_d, "```")?;
+        writeln!(t_d)?;
+        writeln!(t_d, "</details>")?;
+    }
+
+    if derive {
+        writeln!(t_d, "<details>")?;
+        writeln!(t_d, "<summary>Derive usage</summary>")?;
+        writeln!(t_d)?;
+        writeln!(t_d, "```no_run")?;
+        include_file(&mut t_d, path.join("derive.rs"))?;
+        writeln!(t_d, "```")?;
+        writeln!(t_d)?;
+        writeln!(t_d, "</details>")?;
+    }
+
+    writeln!(t_d, "<details>")?;
+    writeln!(t_d, "<summary>Examples</summary>")?;
+    writeln!(t_d)?;
+    for sample in &cases {
+        writeln!(t_d, "\n{}", sample.descr.trim())?;
+        writeln!(t_d, "```console")?;
+        writeln!(t_d, "% app {}", sample.args.trim())?;
+        writeln!(t_d, "{}", sample.output.trim())?;
+        writeln!(t_d, "```")?
+    }
+    writeln!(t_d)?;
+    writeln!(t_d, "</details>")?;
+
+    Ok((t_r, t_d))
+}
+
+fn include_file<P: AsRef<Path>>(res: &mut String, path: P) -> Result<(), Box<dyn Error>> {
+    let file = BufReader::new(std::fs::File::open(path)?);
+    let mut hash = false;
+    for line in file.lines() {
+        let line = line?;
+        if line.trim() == "//" {
+            hash = true;
+            continue;
+        }
+        if hash {
+            writeln!(res, "# {}", line)?;
+            hash = false;
+        } else {
+            writeln!(res, "{}", line)?;
+        }
+    }
+    Ok(())
+}
+
+fn write_updated<P: AsRef<Path>>(new_val: String, path: P) -> std::io::Result<()> {
+    let mut file = OpenOptions::new()
+        .write(true)
+        .read(true)
+        .create(true)
+        .open(path)?;
+    let mut current_val = String::new();
+    file.read_to_string(&mut current_val)?;
+    if current_val != new_val {
+        file.set_len(0)?;
+        file.seek(std::io::SeekFrom::Start(0))?;
+        std::io::Write::write_all(&mut file, new_val.as_bytes())?;
+    }
+    Ok(())
+}
+
+fn main() -> Result<(), Box<dyn Error>> {
+    let mut libs = Vec::new();
+
+    for file in std::fs::read_dir("./src")? {
+        let file = file?;
+        if !file.file_type()?.is_dir() || file.file_name() == ".." || file.file_name() == "." {
+            continue;
+        }
+        libs.push(format!(
+            "#[cfg(test)]\nmod {};\n",
+            file.file_name().to_str().unwrap()
+        ));
+
+        let (example, docs) = import_example(file.path())?;
+
+        let mut name = PathBuf::from(&"src").join(file.file_name());
+        name.set_extension("rs");
+        write_updated(example, name)?;
+
+        let mut name = PathBuf::from("../src/docs").join(file.file_name());
+        name.set_extension("md");
+        write_updated(docs, name)?;
+    }
+
+    libs.sort();
+    let libs_payload = libs.into_iter().collect::<String>();
+    write_updated(libs_payload, "src/lib.rs")?;
+
+    Ok(())
+}
diff --git a/docs/src/catch/cases.txt b/docs/src/catch/cases.txt
new file mode 100644
index 0000000..b20b9c2
--- /dev/null
+++ b/docs/src/catch/cases.txt
@@ -0,0 +1,23 @@
+? No argument, both parsers succeed due to [`optional`](Parser::optional) alone
+>
+OK
+Options { version: None, feature: None }
+
+? Decimal value - version parser succeeds, second parser returns None due to
+? [`optional`](Parser::optional)
+> 10
+OK
+Options { version: Some(10), feature: None }
+
+? String value - `version` parser fails, catch handles that, second parser
+? handles still available `"feature"`
+> feature
+OK
+Options { version: None, feature: Some("feature") }
+
+
+? Two values are present, both parsers succeed
+? handles still available `"feature"`
+> 1000 feature
+OK
+Options { version: Some(1000), feature: Some("feature") }
diff --git a/docs/src/catch/combine.rs b/docs/src/catch/combine.rs
new file mode 100644
index 0000000..90d7d4e
--- /dev/null
+++ b/docs/src/catch/combine.rs
@@ -0,0 +1,14 @@
+//
+use bpaf::*;
+#[derive(Debug, Clone)]
+//
+#[allow(dead_code)]
+pub struct Options {
+    version: Option<usize>,
+    feature: Option<String>,
+}
+pub fn options() -> OptionParser<Options> {
+    let version = positional("VERS").from_str::<usize>().optional().catch();
+    let feature = positional("FEAT").optional();
+    construct!(Options { version, feature }).to_options()
+}
diff --git a/docs/src/catch/derive.rs b/docs/src/catch/derive.rs
new file mode 100644
index 0000000..11f41bc
--- /dev/null
+++ b/docs/src/catch/derive.rs
@@ -0,0 +1,12 @@
+//
+use bpaf::*;
+#[derive(Debug, Clone, Bpaf)]
+#[bpaf(options)]
+//
+#[allow(dead_code)]
+pub struct Options {
+    #[bpaf(positional("VERS"), catch)]
+    version: Option<usize>,
+    #[bpaf(positional("FEAT"), catch)]
+    feature: Option<String>,
+}
diff --git a/src/docs/catch.md b/src/docs/catch.md
new file mode 100644
index 0000000..b127b65
--- /dev/null
+++ b/src/docs/catch.md
@@ -0,0 +1,68 @@
+<details>
+<summary>Combinatoric usage</summary>
+
+```no_run
+# use bpaf::*;
+#[derive(Debug, Clone)]
+# #[allow(dead_code)]
+pub struct Options {
+    version: Option<usize>,
+    feature: Option<String>,
+}
+pub fn options() -> OptionParser<Options> {
+    let version = positional("VERS").from_str::<usize>().optional().catch();
+    let feature = positional("FEAT").optional();
+    construct!(Options { version, feature }).to_options()
+}
+```
+
+</details>
+<details>
+<summary>Derive usage</summary>
+
+```no_run
+# use bpaf::*;
+#[derive(Debug, Clone, Bpaf)]
+#[bpaf(options)]
+# #[allow(dead_code)]
+pub struct Options {
+    #[bpaf(positional("VERS"), catch)]
+    version: Option<usize>,
+    #[bpaf(positional("FEAT"), catch)]
+    feature: Option<String>,
+}
+```
+
+</details>
+<details>
+<summary>Examples</summary>
+
+
+No argument, both parsers succeed due to [`optional`](Parser::optional) alone
+```console
+% app 
+Options { version: None, feature: None }
+```
+
+Decimal value - version parser succeeds, second parser returns None due to
+[`optional`](Parser::optional)
+```console
+% app 10
+Options { version: Some(10), feature: None }
+```
+
+String value - `version` parser fails, catch handles that, second parser
+handles still available `"feature"`
+```console
+% app feature
+Options { version: None, feature: Some("feature") }
+```
+
+Two values are present, both parsers succeed
+handles still available `"feature"`
+```console
+% app 1000 feature
+Options { version: Some(1000), feature: Some("feature") }
+```
+
+</details>
diff --git a/src/lib.rs b/src/lib.rs
index f79b452..e423484 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -349,8 +349,9 @@ use std::marker::PhantomData;
 pub use structs::PCon;
 
 use structs::{
-    ParseFail, ParseFallback, ParseFallbackWith, ParseFromStr, ParseGroupHelp, ParseGuard,
-    ParseHide, ParseMany, ParseMap, ParseOptional, ParseOrElse, ParsePure, ParseSome, ParseWith,
+    ParseCatch, ParseFail, ParseFallback, ParseFallbackWith, ParseFromStr, ParseGroupHelp,
+    ParseGuard, ParseHide, ParseMany, ParseMap, ParseOptional, ParseOrElse, ParsePure, ParseSome,
+    ParseWith,
 };
 
 #[cfg(feature = "autocomplete")]
@@ -1493,6 +1494,24 @@ pub trait Parser<T> {
     }
     // }}}
 
+    #[must_use]
+    /// Handle parse failures
+    ///
+    /// Can be useful to decide to skip parsing of some items on a command line
+    /// When parser succeeds - `catch` would consume items and return the value
+    /// in wrapped `Some`, if it fails - `catch` would restore all the consumed
+    /// values and return None.
+    ///
+    /// Parser transformed with `catch` needs to return `Option<T>` already, so most
+    /// likely you will be using `catch` in combination with [`optional`](Parser::optional).
+    #[doc = include_str!("docs/catch.md")]
+    fn catch(self) -> ParseCatch<Self>
+    where
+        Self: Sized + Parser<T>,
+    {
+        ParseCatch { inner: self }
+    }
+
     /// Add extra annotations to completion information
     ///
     /// Not all information is gets supported by all the shells
diff --git a/src/structs.rs b/src/structs.rs
index 25cc9db..7b2469c 100644
--- a/src/structs.rs
+++ b/src/structs.rs
@@ -390,6 +390,34 @@ where
 pub struct ParseMany<P> {
     pub(crate) inner: P,
 }
+pub struct ParseCatch<P> {
+    pub(crate) inner: P,
+}
+
+impl<P, T> Parser<Option<T>> for ParseCatch<P>
+where
+    P: Parser<Option<T>>,
+{
+    fn eval(&self, args: &mut Args) -> Result<Option<T>, Error> {
+        let mut orig_args = args.clone();
+        match self.inner.eval(args) {
+            Ok(res) => Ok(res),
+            Err(Error::Stderr(_) | Error::Missing(_)) => {
+                std::mem::swap(args, &mut orig_args);
+                #[cfg(feature = "autocomplete")]
+                if orig_args.comp.is_some() {
+                    std::mem::swap(&mut args.comp, &mut orig_args.comp);
+                }
+                Ok(None)
+            }
+            Err(err @ Error::Stdout(..)) => Err(err),
+        }
+    }
+
+    fn meta(&self) -> Meta {
+        self.inner.meta()
+    }
+}
 
 fn parse_option<P, T>(parser: &P, args: &mut Args) -> Result<Option<T>, Error>
 where


Test patch:
diff --git a/tests/tests.rs b/tests/tests.rs
index 078c74e..cefe1cd 100644
--- a/tests/tests.rs
+++ b/tests/tests.rs
@@ -1565,3 +1565,26 @@ fn custom_usage_override() {
         "Usage: hey [-p]\n\nAvailable options:\n    -p\n    -h, --help  Prints help information\n"
     );
 }
+
+#[test]
+fn catch_works() {
+    #[derive(Debug, Eq, PartialEq)]
+    enum A {
+        Num(usize),
+        Str(String),
+    }
+    let a_n = short('a')
+        .argument("A")
+        .from_str::<usize>()
+        .map(A::Num)
+        .optional()
+        .catch();
+    let a_s = short('a').argument("A").map(A::Str).hide().optional();
+    let parser = construct!([a_n, a_s]).to_options();
+
+    let r = parser.run_inner(Args::from(&["-a", "1"])).unwrap();
+    assert_eq!(r, Some(A::Num(1)));
+
+    let r = parser.run_inner(Args::from(&["-a", "x1"])).unwrap();
+    assert_eq!(r, Some(A::Str("x1".to_owned())));
+}