proptesting

This commit is contained in:
Jana Dönszelmann 2026-04-01 13:59:56 +02:00
parent e3648ad8e6
commit bedaa49754
No known key found for this signature in database
4 changed files with 393 additions and 31 deletions

174
Cargo.lock generated
View file

@ -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"

View file

@ -22,3 +22,5 @@ regex = "1"
crossterm = "*"
dumpster = "2.1"
winnow = {version="1", features=["parser"]}
proptest = "1"
proptest-derive = "0.8"

View file

@ -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("") }

View file

@ -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<Value = Self> {
let prefix = "\\w*";
let ty = any::<QuoteType>();
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<E: ParserError<&'a str>>() -> 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<Value = Self> {
" *".prop_map(|spaces| Self(spaces.into()))
}
}
impl<'a> Space<'a> {
fn parse<E: ParserError<&'a str>>() -> 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<Value = Self> {
(any::<PathSep>(), "\\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<Cow<'a, str>>,
}
impl FileLocation<'static> {
#[cfg(test)]
fn arb() -> impl Strategy<Value = Self> {
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<FileLocation<'a>>,
}
impl FileName<'static> {
#[cfg(test)]
fn arb() -> impl Strategy<Value = Self> {
use proptest::option::*;
(
any::<PathSep>(),
"\\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<char>,
segments: Vec<PathSegment<'a>>,
filename: FileName<'a>,
}
impl Path<'static> {
#[cfg(test)]
fn arb() -> impl Strategy<Value = Self> {
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<E: ParserError<&'a str>>() -> 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<Value = Self> {
prop_oneof![
any::<i64>().prop_map(|number| Self(number.to_string().into())),
any::<f64>().prop_map(|number| Self(number.to_string().into()))
]
}
}
impl<'a> Number<'a> {
fn parse<E: ParserError<&'a str>>() -> 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<Value = Self> {
"\\w*".prop_map(|i| Self::Text(i.into()))
}
}
impl<'a> Atom<'a> {
fn parse<E: ParserError<&'a str>>() -> 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<Value = Self> {
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<E: ParserError<&'a str>>() -> impl Parser<&'a str, Self, E> {
fn parse<E: ParserError<&'a str> + 'a>() -> impl Parser<&'a str, Self, E> {
use winnow::{combinator::*, prelude::*};
let delimited: Box<dyn Parser<&'a str, Self, E>> =
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<Value = Token<'static>>) -> impl Strategy<Value = Self> {
(Atom::arb(), any::<Delimiter>(), Segments::arb(token)).prop_map(
|(prefix, delimiter, contents)| Self {
prefix,
delimiter,
contents,
},
)
}
}
impl<'a> Delimited<'a> {
fn parse<E: ParserError<&'a str>>() -> impl Parser<&'a str, Self, E> {
fn parse<E: ParserError<&'a str> + '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<Value = Token<'static>>) -> impl Strategy<Value = Self> {
(Space::arb(), token).prop_map(|(leading_space, token)| Self {
leading_space,
token,
})
}
}
impl<'a> Segment<'a> {
fn parse<E: ParserError<&'a str>>() -> impl Parser<&'a str, Self, E> {
fn parse<E: ParserError<&'a str> + '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<Segment<'a>>,
trailing_space: Space<'a>,
}
impl Segments<'static> {
#[cfg(test)]
fn arb(token: impl Strategy<Value = Token<'static>>) -> impl Strategy<Value = Self> {
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<E: ParserError<&'a str>, End: 'a>(
fn parse<E: ParserError<&'a str> + '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<Segments<'a>, 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}`");
}
}
}