From bedaa497547a7bf84d0eecef4c855215d277392d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jana=20D=C3=B6nszelmann?= Date: Wed, 1 Apr 2026 13:59:56 +0200 Subject: [PATCH] proptesting --- Cargo.lock | 174 ++++++++++++- Cargo.toml | 2 + proptest-regressions/format_debug_output.txt | 7 + src/format_debug_output.rs | 241 ++++++++++++++++--- 4 files changed, 393 insertions(+), 31 deletions(-) create mode 100644 proptest-regressions/format_debug_output.txt diff --git a/Cargo.lock b/Cargo.lock index b88ae8e..e1afeb7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,7 +100,16 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" dependencies = [ - "bit-vec", + "bit-vec 0.6.3", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec 0.8.0", ] [[package]] @@ -109,6 +118,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bitflags" version = "1.3.2" @@ -447,10 +462,16 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" dependencies = [ - "bit-set", + "bit-set 0.5.3", "regex", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1042,7 +1063,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.5", ] [[package]] @@ -1094,6 +1115,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -1113,6 +1143,42 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set 0.8.0", + "bit-vec 0.8.0", + "bitflags 2.11.0", + "num-traits", + "rand 0.9.2", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "proptest-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c57924a81864dddafba92e1bf92f9bf82f97096c44489548a60e888e1547549b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quote" version = "1.0.44" @@ -1134,7 +1200,27 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", ] [[package]] @@ -1143,6 +1229,24 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + [[package]] name = "ratatui" version = "0.30.0" @@ -1286,6 +1390,8 @@ dependencies = [ "itertools", "jiff", "nix 0.31.1", + "proptest", + "proptest-derive", "ratatui", "ratatui-themes", "regex", @@ -1324,6 +1430,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + [[package]] name = "ryu" version = "1.0.23" @@ -1515,6 +1633,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix", + "windows-sys", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -1719,6 +1850,12 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1793,6 +1930,15 @@ dependencies = [ "utf8parse", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2111,6 +2257,26 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 87ccbe9..127af5b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,3 +22,5 @@ regex = "1" crossterm = "*" dumpster = "2.1" winnow = {version="1", features=["parser"]} +proptest = "1" +proptest-derive = "0.8" diff --git a/proptest-regressions/format_debug_output.txt b/proptest-regressions/format_debug_output.txt new file mode 100644 index 0000000..5607afe --- /dev/null +++ b/proptest-regressions/format_debug_output.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 255ec5cdb18e04e16d465fb94cbf15040fb1d5d704c53226ce3a2e9333c6de7c # shrinks to original = Segments { segments: [Segment { leading_space: Space(""), token: Path(Path { drive_excluding_colon: None, segments: [], filename: FileName { leading_separator: Slash, segment: "", ext_excluding_dot: None, location: None } }) }], trailing_space: Space("") } diff --git a/src/format_debug_output.rs b/src/format_debug_output.rs index 8e54432..9079d5e 100644 --- a/src/format_debug_output.rs +++ b/src/format_debug_output.rs @@ -3,15 +3,13 @@ use std::{ fmt::{self, Display}, }; -use winnow::{ - Parser, - ascii::dec_uint, - combinator::impls::ByRef, - error::{FromExternalError, ParserError}, - stream, -}; +#[cfg(test)] +use proptest::prelude::*; -#[derive(Copy, Clone, Debug)] +use proptest_derive::Arbitrary; +use winnow::{Parser, error::ParserError}; + +#[derive(Copy, Clone, Debug, PartialEq)] pub enum Separator { Eq, Colon, @@ -38,7 +36,7 @@ impl Display for Separator { } } -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, Arbitrary, PartialEq)] pub enum QuoteType { Single, Double, @@ -68,7 +66,7 @@ impl Display for QuoteType { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Arbitrary, PartialEq)] pub enum Delimiter { Paren, Bracket, @@ -96,7 +94,7 @@ impl Delimiter { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct AnyString<'a> { prefix: Cow<'a, str>, ty: QuoteType, @@ -105,6 +103,27 @@ pub struct AnyString<'a> { suffix: Cow<'a, str>, } +impl AnyString<'static> { + #[cfg(test)] + fn arb() -> impl Strategy { + let prefix = "\\w*"; + let ty = any::(); + let contents = "\\w*"; + let num_hashtags = 0usize..3; + let suffix = "\\w*"; + + (prefix, ty, contents, num_hashtags, suffix).prop_map( + |(prefix, ty, contents, num_hashtags, suffix)| Self { + prefix: prefix.into(), + ty, + contents: contents.into(), + num_hashtags, + suffix: suffix.into(), + }, + ) + } +} + impl<'a> AnyString<'a> { fn parse>() -> impl Parser<&'a str, Self, E> { use winnow::{combinator::*, prelude::*, token::*}; @@ -162,9 +181,16 @@ impl<'a> Display for AnyString<'a> { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct Space<'a>(Cow<'a, str>); +impl Space<'static> { + #[cfg(test)] + fn arb() -> impl Strategy { + " *".prop_map(|spaces| Self(spaces.into())) + } +} + impl<'a> Space<'a> { fn parse>() -> impl Parser<&'a str, Self, E> { use winnow::{prelude::*, token::*}; @@ -179,7 +205,7 @@ impl<'a> Display for Space<'a> { } } -#[derive(Copy, Clone, Debug)] +#[derive(Copy, Clone, Debug, PartialEq, Arbitrary)] pub enum PathSep { Slash, Backslash, @@ -202,12 +228,22 @@ impl Display for PathSep { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct PathSegment<'a> { leading_separator: PathSep, segment: Cow<'a, str>, } +impl PathSegment<'static> { + #[cfg(test)] + fn arb() -> impl Strategy { + (any::(), "\\w*").prop_map(|(leading_separator, segment)| Self { + leading_separator, + segment: segment.into(), + }) + } +} + impl<'a> Display for PathSegment<'a> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.leading_separator)?; @@ -215,12 +251,23 @@ impl<'a> Display for PathSegment<'a> { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct FileLocation<'a> { line: Cow<'a, str>, offset: Option>, } +impl FileLocation<'static> { + #[cfg(test)] + fn arb() -> impl Strategy { + use proptest::option::*; + ("[0-9]{0,4}", of("[0-9]{0,4}")).prop_map(|(line, offset)| Self { + line: line.into(), + offset: offset.map(Into::into), + }) + } +} + impl<'a> Display for FileLocation<'a> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", self.line)?; @@ -231,7 +278,7 @@ impl<'a> Display for FileLocation<'a> { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct FileName<'a> { leading_separator: PathSep, segment: Cow<'a, str>, @@ -239,6 +286,27 @@ pub struct FileName<'a> { location: Option>, } +impl FileName<'static> { + #[cfg(test)] + fn arb() -> impl Strategy { + use proptest::option::*; + ( + any::(), + "\\w*", + of(".{0,3}"), + of(FileLocation::arb()), + ) + .prop_map( + |(leading_separator, segment, ext_excluding_dot, location)| Self { + leading_separator, + segment: segment.into(), + ext_excluding_dot: ext_excluding_dot.map(Into::into), + location, + }, + ) + } +} + impl<'a> FileName<'a> { fn parse(segment: PathSegment<'a>) -> Self { fn rsplit<'a>( @@ -308,13 +376,30 @@ impl<'a> Display for FileName<'a> { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct Path<'a> { drive_excluding_colon: Option, segments: Vec>, filename: FileName<'a>, } +impl Path<'static> { + #[cfg(test)] + fn arb() -> impl Strategy { + use proptest::{char::*, collection::*, option::*}; + ( + of(range('A', 'Z')), + vec(PathSegment::arb(), 0..3), + FileName::arb(), + ) + .prop_map(|(drive_excluding_colon, segments, filename)| Self { + drive_excluding_colon, + segments, + filename, + }) + } +} + impl<'a> Path<'a> { fn parse>() -> impl Parser<&'a str, Self, E> { use winnow::{combinator::*, prelude::*, token::*}; @@ -374,9 +459,19 @@ impl<'a> Display for Path<'a> { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct Number<'a>(Cow<'a, str>); +impl Number<'static> { + #[cfg(test)] + fn arb() -> impl Strategy { + prop_oneof![ + any::().prop_map(|number| Self(number.to_string().into())), + any::().prop_map(|number| Self(number.to_string().into())) + ] + } +} + impl<'a> Number<'a> { fn parse>() -> impl Parser<&'a str, Self, E> { use winnow::{ascii::*, combinator::*, prelude::*}; @@ -393,7 +488,7 @@ impl<'a> Display for Number<'a> { /// Anything that doesn't contain spaces, and that can be a prefix of `Delimited`. /// i.e. an english word, or rust `::`-separated Path -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub enum Atom<'a> { Text(Cow<'a, str>), } @@ -406,6 +501,13 @@ impl<'a> Display for Atom<'a> { } } +impl Atom<'static> { + #[cfg(test)] + fn arb() -> impl Strategy { + "\\w*".prop_map(|i| Self::Text(i.into())) + } +} + impl<'a> Atom<'a> { fn parse>() -> impl Parser<&'a str, Self, E> { use winnow::{combinator::*, prelude::*, token::*}; @@ -414,7 +516,7 @@ impl<'a> Atom<'a> { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub enum Token<'a> { True, False, @@ -436,10 +538,32 @@ pub enum Token<'a> { Atom(Atom<'a>), } +impl Token<'static> { + #[cfg(test)] + fn arb() -> impl Strategy { + let leaf = prop_oneof![ + Just(Self::True), + Just(Self::False), + Just(Self::None), + Path::arb().prop_map(Self::Path), + AnyString::arb().prop_map(Self::String), + Number::arb().prop_map(Self::Number), + Atom::arb().prop_map(Self::Atom), + ]; + + leaf.prop_recursive(4, 64, 16, |token| { + Delimited::arb(token).prop_map(Self::Delimited).boxed() + }) + } +} + impl<'a> Token<'a> { - fn parse>() -> impl Parser<&'a str, Self, E> { + fn parse + 'a>() -> impl Parser<&'a str, Self, E> { use winnow::{combinator::*, prelude::*}; + let delimited: Box> = + Box::new(Delimited::parse().map(Self::Delimited)); + alt(( "true".value(Self::True), "false".value(Self::False), @@ -447,6 +571,8 @@ impl<'a> Token<'a> { Path::parse().map(Self::Path), AnyString::parse().map(Self::String), Number::parse().map(Self::Number), + delimited, + Atom::parse().map(Self::Atom), )) } } @@ -472,15 +598,28 @@ impl<'a> Display for Token<'a> { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct Delimited<'a> { prefix: Atom<'a>, delimiter: Delimiter, contents: Segments<'a>, } +impl Delimited<'static> { + #[cfg(test)] + fn arb(token: impl Strategy>) -> impl Strategy { + (Atom::arb(), any::(), Segments::arb(token)).prop_map( + |(prefix, delimiter, contents)| Self { + prefix, + delimiter, + contents, + }, + ) + } +} + impl<'a> Delimited<'a> { - fn parse>() -> impl Parser<&'a str, Self, E> { + fn parse + 'a>() -> impl Parser<&'a str, Self, E> { use winnow::{combinator::*, prelude::*, token::*}; ( @@ -509,14 +648,24 @@ impl<'a> Display for Delimited<'a> { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct Segment<'a> { leading_space: Space<'a>, token: Token<'a>, } +impl Segment<'static> { + #[cfg(test)] + fn arb(token: impl Strategy>) -> impl Strategy { + (Space::arb(), token).prop_map(|(leading_space, token)| Self { + leading_space, + token, + }) + } +} + impl<'a> Segment<'a> { - fn parse>() -> impl Parser<&'a str, Self, E> { + fn parse + 'a>() -> impl Parser<&'a str, Self, E> { use winnow::prelude::*; (Space::parse(), Token::parse()).map(|(leading_space, token)| Self { @@ -533,14 +682,28 @@ impl<'a> Display for Segment<'a> { } } -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub struct Segments<'a> { segments: Vec>, trailing_space: Space<'a>, } +impl Segments<'static> { + #[cfg(test)] + fn arb(token: impl Strategy>) -> impl Strategy { + use proptest::collection::*; + + (vec(Segment::arb(token), 1..10), Space::arb()).prop_map(|(segments, trailing_space)| { + Self { + segments, + trailing_space, + } + }) + } +} + impl<'a> Segments<'a> { - fn parse, End: 'a>( + fn parse + 'a, End: 'a>( end: impl Parser<&'a str, End, E>, ) -> impl Parser<&'a str, (Self, End), E> { use winnow::{combinator::*, prelude::*}; @@ -567,3 +730,27 @@ impl<'a> Display for Segments<'a> { write!(f, "{}", self.trailing_space) } } + +fn parse_input<'a>(i: &'a str) -> Result, String> { + use winnow::combinator::eof; + Segments::parse(eof::<&str, winnow::error::EmptyError>) + .map(|(segments, _)| segments) + .parse(i) + .map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use proptest::proptest; + + use crate::format_debug_output::{Segments, Token, parse_input}; + + proptest! { + #[test] + fn test_some_function(original in Segments::arb(Token::arb())) { + let stringified = original.to_string(); + let parsed = parse_input(&stringified).unwrap(); + assert_eq!(parsed, original, "left:\n `{parsed}`\nright: `{stringified}`"); + } + } +}