1pub struct Rng {
16 state: u64,
17}
18
19impl Rng {
20 #[must_use]
22 pub fn new(seed: u64) -> Self {
23 Self {
24 state: if seed == 0 {
25 0x9E37_79B9_7F4A_7C15
26 } else {
27 seed
28 },
29 }
30 }
31
32 pub fn next_u64(&mut self) -> u64 {
34 let mut x = self.state;
35 x ^= x << 13;
36 x ^= x >> 7;
37 x ^= x << 17;
38 self.state = x;
39 x
40 }
41
42 pub fn below(&mut self, bound: u64) -> u64 {
44 self.next_u64() % bound
45 }
46}
47
48pub const FRAGMENTS: &[&str] = &[
55 "ordinary prose that wraps",
61 "a second prose line",
62 "prose\u{1c}with\u{1d}four\u{1e}C0\u{1f}separators",
63 "prose ending in a separator\u{1c}",
64 "prose ending in a unit separator\u{1f}",
65 "",
66 " ",
67 "```",
69 "````",
70 "~~~",
71 "~~~~~",
72 "```rust",
73 " ```",
74 " ```",
75 "``` ```",
76 "> quoted prose",
78 ">no space after the marker",
79 "> > deeper prose",
80 ">>> deepest",
81 ">",
82 " > indented marker",
83 "> indented code inside a quote",
84 "> ```",
85 "> <!-- quoted comment",
86 "> <div>",
87 "> | a | b |",
88 "> Alex: a quoted utterance",
92 "> Jordan: another quoted utterance",
93 "- bullet item",
96 "* star item",
97 "+ plus item",
98 "1. ordered item",
99 "12) ordered item",
100 "\u{663}. item",
101 "\u{967}\u{968}) item",
102 " continuation at the content column",
103 " deeper continuation",
104 "a. lettered subitem",
107 "b) lettered subitem",
108 "**Label:** value",
110 "**Label**: value",
111 "**Whole line bold**",
112 "Alex: an utterance",
113 "Alex Jordan Morgan Casey: four words",
114 "Alex Jordan Morgan Casey Drew: five words",
115 "[a stage direction]",
116 "[a stage direction].",
117 "| a | b |",
119 "| - | - |",
120 "a `x | y` span",
121 "a ``x | y`` span",
122 "a ```x | y``` span",
123 "an `unterminated run",
124 "a `a``` closing run",
125 "a hard break ",
127 "a hard break\\",
128 "<div>",
130 "</div>",
131 "<div>closed on its own line</div>",
132 "<br/>",
133 "<pre>",
134 "</pre>",
135 "<!-- an open comment",
136 "-->",
137 "<!-- a closed comment -->",
138 "<?php",
139 "?>",
140 "<![CDATA[",
141 "]]>",
142 "<!DOCTYPE html",
143 "<!DOCTYPE html>",
144 "---",
146 "\u{feff}---",
147 "...",
148 "title: a value",
149 "# a heading",
151 "===",
152 "- - -",
153 "***",
154 "[label]: https://example.com",
155 "[!NOTE]",
156 "[][ref]",
157 "[another](https://example.com)",
158 ":: an admonition",
159 "!!! note",
160 "{% raw %}",
161 "{{ template }}",
162 concat!(
165 "A",
166 "eeeeeeeeee",
167 "eeeeeeeeee",
168 "eeeeeeeeee",
169 "eeeeeeeee",
170 ":"
171 ),
172 concat!(
173 "A",
174 "eeeeeeeeee",
175 "eeeeeeeeee",
176 "eeeeeeeeee",
177 "eeeeeeeeee",
178 ":"
179 ),
180 "MC 0:15",
181 "MC \u{660}:\u{661}\u{665}",
182 "JR 12:34",
183];
184
185#[must_use]
192pub fn document(seed: u64) -> String {
193 let mut rng = Rng::new(seed);
194 let line_count = 1 + rng.below(24);
195 let mut out = String::new();
196 for _ in 0..line_count {
197 let index = rng.below(FRAGMENTS.len() as u64) as usize;
198 out.push_str(FRAGMENTS[index]);
199 match rng.below(16) {
202 0 => out.push_str("\r\n"),
203 1 => out.push('\r'),
204 _ => out.push('\n'),
205 }
206 }
207 if rng.below(8) == 0 {
209 while out.ends_with(['\n', '\r']) {
210 out.pop();
211 }
212 }
213 out
214}
215
216pub struct Scenario {
223 pub files: Vec<(String, String)>,
225 pub argv: Vec<String>,
227}
228
229const NAMES: [&str; 4] = ["note.md", "keep.md", "sub/nested.md", "docs/deep.md"];
231
232const PATTERNS: [&str; 10] = [
234 "*.md",
235 "!keep.md",
236 "keep.md",
237 "sub/",
238 "docs/deep.md",
239 "/note.md",
240 "**/nested.md",
241 "# a comment",
242 "no-such-file.md",
243 "*.m?",
244];
245
246#[must_use]
252pub fn scenario(seed: u64) -> Scenario {
253 let mut rng = Rng::new(seed ^ 0x5DEE_CE66_D000_0001);
254 let file_count = 1 + rng.below(NAMES.len() as u64 - 1) as usize;
255 let mut files: Vec<(String, String)> = NAMES[..file_count]
258 .iter()
259 .map(|name| ((*name).to_owned(), document(rng.next_u64())))
260 .collect();
261
262 let pattern_count = rng.below(3) as usize;
263 let patterns: Vec<&str> = (0..pattern_count)
264 .map(|_| PATTERNS[rng.below(PATTERNS.len() as u64) as usize])
265 .collect();
266 if !patterns.is_empty() {
267 files.push((
268 ".unwrapignore".to_owned(),
269 format!("{}\n", patterns.join("\n")),
270 ));
271 }
272
273 let mut argv: Vec<String> = Vec::new();
274 if rng.below(2) == 0 {
275 argv.push("--write".to_owned());
276 }
277 if rng.below(2) == 0 {
278 argv.push("--json".to_owned());
279 }
280 if rng.below(3) == 0 {
281 argv.push("--fail-on-change".to_owned());
282 }
283 let exclude = rng.below(4);
284 if exclude < PATTERNS.len() as u64 {
285 argv.push("--exclude".to_owned());
286 argv.push(PATTERNS[exclude as usize].to_owned());
287 }
288 let names: Vec<String> = files
291 .iter()
292 .map(|(name, _)| name.clone())
293 .filter(|name| name != ".unwrapignore")
294 .collect();
295 match rng.below(3) {
296 0 => argv.extend(names),
297 1 => {
298 files.push(("list.txt".to_owned(), format!("{}\n", names.join("\n"))));
299 argv.push("--files-from".to_owned());
300 argv.push("list.txt".to_owned());
301 }
302 _ => {
303 files.push(("list.txt".to_owned(), format!("{}\n", names.join("\n"))));
304 argv.push("--files-from".to_owned());
305 argv.push("list.txt".to_owned());
306 argv.extend(names);
307 }
308 }
309 Scenario { files, argv }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use crate::label::is_speaker_prefix;
316 use crate::scan::{is_list_line, match_list_marker};
317
318 #[test]
319 fn the_bank_reaches_its_hazards() {
320 let has = |predicate: fn(&str) -> bool| FRAGMENTS.iter().any(|f| predicate(f));
321 assert!(has(|f| f.contains('\u{1c}')), "no C0 separator");
322 assert!(has(|f| f.contains('\u{feff}')), "no byte order mark");
323 assert!(has(|f| f.ends_with(" ")), "no two-space hard break");
324 assert!(has(|f| f.ends_with('\\')), "no backslash hard break");
325 assert!(has(|f| f.contains("```")), "no backtick fence");
326 assert!(has(|f| f.contains("~~~")), "no tilde fence");
327 assert!(has(|f| f.contains("<![CDATA[")), "no CDATA");
328 assert!(has(|f| f.contains("<?")), "no processing instruction");
329 assert!(has(|f| f.starts_with("<!DOCTYPE")), "no declaration");
330 assert!(has(|f| f.contains('|')), "no table pipe");
331 assert!(has(|f| f.starts_with("> ")), "no blockquote");
332 assert!(has(is_speaker_prefix), "no speaker prefix");
333 assert!(has(|f| match_list_marker(f).is_some()), "no list marker");
334 }
335
336 #[test]
337 fn the_bank_carries_a_non_ascii_digit_that_is_not_a_marker() {
338 let digits: Vec<&&str> = FRAGMENTS
340 .iter()
341 .filter(|f| f.chars().any(|c| c.is_numeric() && !c.is_ascii_digit()))
342 .collect();
343 assert!(digits.len() >= 3, "found {digits:?}");
344 assert!(!is_list_line("\u{663}. item"));
345 assert!(match_list_marker("\u{967}\u{968}) item").is_none());
346 }
347
348 #[test]
349 fn the_speaker_boundary_pair_really_straddles_the_boundary() {
350 let short = FRAGMENTS
353 .iter()
354 .find(|f| f.starts_with("Ae") && f.len() == 41)
355 .expect("no 39-character heading");
356 let long = FRAGMENTS
357 .iter()
358 .find(|f| f.starts_with("Ae") && f.len() == 42)
359 .expect("no 40-character heading");
360 assert_eq!(short.matches('e').count(), 39);
361 assert_eq!(long.matches('e').count(), 40);
362 }
363
364 #[test]
365 fn the_generator_is_deterministic_and_never_empty() {
366 for seed in 1..200 {
367 assert_eq!(document(seed), document(seed));
368 }
369 assert_eq!(
371 Rng::new(0).next_u64(),
372 Rng::new(0x9E37_79B9_7F4A_7C15).next_u64()
373 );
374 let mut zero = Rng::new(0);
375 assert_ne!(zero.next_u64(), 0);
376 }
377
378 #[test]
379 fn the_generator_reaches_every_fragment() {
380 let mut seen = vec![false; FRAGMENTS.len()];
382 for seed in 1..4000 {
383 let doc = document(seed);
384 for (index, fragment) in FRAGMENTS.iter().enumerate() {
385 if !fragment.is_empty() && doc.contains(fragment) {
386 seen[index] = true;
387 }
388 }
389 }
390 let missed: Vec<&&str> = FRAGMENTS
391 .iter()
392 .zip(&seen)
393 .filter(|(fragment, hit)| !**hit && !fragment.is_empty())
394 .map(|(fragment, _)| fragment)
395 .collect();
396 assert!(missed.is_empty(), "never generated: {missed:?}");
397 }
398}