diff options
| -rw-r--r-- | .gitignore | 1 | ||||
| -rw-r--r-- | Cargo.lock | 289 | ||||
| -rw-r--r-- | Cargo.toml | 10 | ||||
| -rw-r--r-- | README.md | 78 | ||||
| -rw-r--r-- | src/main.rs | 340 | ||||
| -rw-r--r-- | test_data/a.txt | 9 | ||||
| -rw-r--r-- | test_data/b.txt | 5 | ||||
| -rw-r--r-- | test_data/c.txt | 5 | ||||
| -rw-r--r-- | test_data/sentences.txt | 14 |
9 files changed, 751 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..9e3897a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,289 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "anstream" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96bd03f33fe50a863e394ee9718a706f988b9079b20c3784fb726e7678b62fb" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" + +[[package]] +name = "anstyle-parse" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" +dependencies = [ + "anstyle", + "windows-sys", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "4.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b230ab84b0ffdf890d5a10abdbc8b83ae1c4918275daea1ab8801f71536b2651" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "307bc0538d5f0f83b8248db3087aa92fe504e4691294d0c96c0eabc33f47ba47" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + +[[package]] +name = "getrandom" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "proc-macro2" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "spout" +version = "0.1.0" +dependencies = [ + "clap", + "rand", +] + +[[package]] +name = "strsim" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" + +[[package]] +name = "syn" +version = "2.0.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..21804a9 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "spout" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +rand = "0.8.5" +clap = { version = "4.5.2", features = ["derive"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..861a4f0 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# spout + +A terminal application for generating nonsense. + +## Installation + +1. Clone this repo +2. `cargo build` + +## Usage + +### Command line options + +``` +Usage: spout [OPTIONS] <DIRECTORY> + +Arguments: + <DIRECTORY> Path to data directory + +Options: + -n, --sentences <N_SENTENCES> Number of sentences to generate [default: 1] + --unique Don't re-use entries in data files (program will fail upon running out) + -h, --help Print help +``` + +### Template syntax + +In order to generate a sentence, the program will fill in gaps in a sentence template. +Every randomized part of a sentence is surrounded on both sides by `%%`. +Between these markers is some kind of definition of how the gap should be filled: + +``` +Direct: %%foo%% - choose a word from the 'foo' category +Variable: %%?foo%% - choose a word from the category assigned to the 'foo' variable +Multiple: %%foo|bar%% - choose a word from either the 'foo' or 'bar' category +``` + +Categories are sets of words/phrases that are randomly chosen from during sentence generation. + +Variables are randomly assigned a category from all available categories, and will be interpreted as that category throughout a sentence. +This is useful for when you want to include multiple words from the same category, but don't care which one. + +Separating category names and variables with a `|` will choose between the given options. This can be used when you want to pick a word from "foo" or "bar", but not "baz". + +### Data files + +This program requires a directory of text files in a particular format in order to work. +The directory can be anywhere on your system that you have read access. +This directory must contain a text file named `sentences.txt`. +It must also contain a text file for every template category that is used in the sentences file. +The program does not enforce the latter restriction until it attempts to generate sentences, crashing if a category file is missing. + +The sentences file must contain exactly one sentence template per line. +Each category file also separates words by line. +Indeed, "words" can actually be phrases with spaces and punctuation (but not line breaks). +In both types of file, empty lines are ignored. + +### Examples + +These examples suppose the following files and sentence templates. + +Example directory: +``` +bar.txt +baz.txt +foo.txt +sentences.txt +``` + +Example templates: +``` +I think %%foo%% is pretty neat. +I think %%bar%% is kind of lame. +%%foo%% is certainly better than %%bar%%. +I would take %%foo|baz%% over %%bar%% any day. +%%?x%% and %%?x%% are certainly things that exist. +``` + diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..2fe5a26 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,340 @@ +use std::collections::HashMap; +use std::ffi::{OsStr, OsString}; +use std::fmt; +use std::fs; +use std::io; +use std::path::PathBuf; +use std::process; + +use rand::{thread_rng, seq::{SliceRandom, IteratorRandom}}; +use clap::Parser; + +#[derive(Debug)] +enum Error { + NoSentences, + MultipleSentences, + BadTemplate(String), + IOFail(io::Error), + OutOfSentences, + OutOfWords(OsString), + UnknownCategory(OsString), + NoCategories, + DuplicateCategory(OsString), + BadPath(PathBuf), + EmptyOption, +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + Error::NoSentences => + write!(f, "no sentences.txt file found"), + Error::MultipleSentences => + write!(f, "multiple sentences.txt files found, i'll be real i don't know how this is possible"), + Error::BadTemplate(t) => + write!(f, "unable to parse template sentence: {}", t), + Error::IOFail(e) => + write!(f, "IO error: {:?}", e), + Error::OutOfSentences => + write!(f, "ran out of sentences"), + Error::OutOfWords(c) => + write!(f, "ran out of words in category: {:?}", c), + Error::UnknownCategory(c) => + write!(f, "tried to pick word in unknown category: {:?}", c), + Error::NoCategories => + write!(f, "no categories provided"), + Error::DuplicateCategory(c) => + write!(f, "tried to create a category twice: {:?}", c), + Error::BadPath(p) => + write!(f, "failed to parse path: {:?}", p), + Error::EmptyOption => + write!(f, "multi-option variable had an empty option"), + } + } +} + +impl From<io::Error> for Error { + fn from(error: io::Error) -> Self { + Error::IOFail(error) + } +} + +type ProgResult<T> = Result<T, Error>; + +#[derive(Parser, Debug)] +#[command(name = "spout")] +#[command(about = "Generates nonsense", long_about = None)] +struct Args { + /// Path to data directory + directory: String, + + /// Number of sentences to generate + #[arg(short = 'n', long = "sentences", default_value_t = 1)] + n_sentences: u32, + + /// Don't re-use entries in data files (program will fail upon running out) + #[arg(long)] + unique: bool, +} + +#[derive(Debug, Clone)] +enum DropIn { + Basic(String), + Var(String), + OneOf(Vec<DropIn>), +} + +impl DropIn { + pub fn parse(text: &str) -> ProgResult<Self> { + if text.is_empty() { + return Err(Error::EmptyOption) + } + + if text.contains('|') { + let mut option_strs = text.split('|').collect::<Vec<&str>>(); + option_strs.dedup(); + let options = option_strs.into_iter() + .map(DropIn::parse) + .collect::<Result<Vec<_>, _>>()?; + return Ok(DropIn::OneOf(options)) + } + + if let Some(var_name) = text.strip_prefix('?') { + return Ok(DropIn::Var(var_name.to_string())); + } + + Ok(DropIn::Basic(text.to_string())) + } +} + +#[derive(Debug)] +struct CategorySet { + var_table: HashMap<String, OsString>, + categories: HashMap<OsString, Vec<String>>, + unique: bool, +} + +impl CategorySet { + pub fn new(unique: bool) -> Self { + CategorySet { + var_table: HashMap::new(), + categories: HashMap::new(), + unique, + } + } + + fn resolve_variable(&mut self, var: &str) -> ProgResult<OsString> { + if !self.var_table.contains_key(var) { + let new_cat = self.random_category()?; + self.var_table.insert(var.to_string(), new_cat); + } + Ok(self.var_table.get(var).unwrap().clone()) + } + + pub fn add_category(&mut self, name: OsString, mut words: Vec<String>) -> ProgResult<()> { + words.retain(|s| !s.is_empty()); + words.shuffle(&mut thread_rng()); + match self.categories.insert(name.clone(), words) { + None => Ok(()), + Some(_) => Err(Error::DuplicateCategory(name)), + } + } + + // not affected by --unique + pub fn random_category(&self) -> ProgResult<OsString> { + self.categories.keys() + .choose(&mut thread_rng()) + .ok_or(Error::NoCategories) + .map(|c| c.clone()) + } + + pub fn random_from_drop_in(&mut self, drop_in: &DropIn) -> ProgResult<String> { + let simple_di = if let DropIn::OneOf(ds) = drop_in { + ds.choose(&mut thread_rng()) + .expect("got a OneOf that is empty, somehow") + } else { + &drop_in + }; + + let category = match simple_di { + DropIn::Basic(s) => { + OsString::from(s) + }, + DropIn::Var(s) => { + self.resolve_variable(&s)? + }, + DropIn::OneOf(_) => { + panic!("Nested OneOf constructs are not supported") + }, + }; + + let words = self.categories.get_mut(&category) + .ok_or(Error::UnknownCategory(category.clone()))?; + + if self.unique { + words.pop() + .ok_or(Error::OutOfWords(category)) + } else { + words.choose(&mut thread_rng()) + .ok_or(Error::OutOfWords(category)) + .map(|c| c.clone()) + } + } +} + +#[derive(Debug, Clone)] +struct Sentence { + text_fragments: Vec<String>, + drop_ins: Vec<DropIn>, +} + +impl Sentence { + pub fn parse(original: String) -> ProgResult<Self> { + let mut text = original.as_str(); + let mut text_fragments = vec![]; + let mut drop_ins = vec![]; + + while let Some((front, back)) = text.split_once("%%") { + let (drop_in_body, rest) = back.split_once("%%") + .ok_or(Error::BadTemplate(original.clone()))?; + let drop_in = DropIn::parse(drop_in_body)?; + + text_fragments.push(front.to_string()); + drop_ins.push(drop_in); + text = rest; + } + text_fragments.push(text.to_string()); + + Ok(Sentence { + text_fragments, + drop_ins, + }) + } + + pub fn generate(&self, categories: &mut CategorySet) -> ProgResult<String> { + let mut text_iter = self.text_fragments.iter(); + let mut drop_ins_iter = self.drop_ins.iter(); + let mut buffer = String::new(); + + while let Some(drop_in) = drop_ins_iter.next() { + let text = text_iter.next() + .expect("invariant: number of text = number of drop-in + 1"); + buffer.push_str(text); + + let drop_in_text = categories.random_from_drop_in(&drop_in)?; + buffer.push_str(drop_in_text.as_str()); + } + + let end_text = text_iter.next() + .expect("invariant: number of text = number of drop-in + 1"); + buffer.push_str(end_text); + + Ok(buffer) + } +} + +#[derive(Debug)] +struct SentenceSet { + sentences: Vec<Sentence>, + unique: bool, +} + +impl SentenceSet { + pub fn new(mut sentence_strings: Vec<String>, unique: bool) -> ProgResult<Self> { + sentence_strings.retain(|s| !s.is_empty()); + sentence_strings.shuffle(&mut thread_rng()); + + let sentences = sentence_strings.into_iter() + .map(Sentence::parse) + .collect::<Result<Vec<_>, _>>()?; + + Ok(SentenceSet { + sentences, + unique, + }) + } + + pub fn random_sentence(&mut self) -> ProgResult<Sentence> { + if self.unique { + self.sentences.pop() + .ok_or(Error::OutOfSentences) + } else { + self.sentences.choose(&mut thread_rng()) + .ok_or(Error::OutOfSentences) + .map(|s| s.clone()) + } + } +} + +fn read_lines(path: &PathBuf) -> ProgResult<Vec<String>> { + let mut lines = vec![]; + + let text = fs::read_to_string(path)?; + for line in text.lines() { + lines.push(line.to_string()); + } + + Ok(lines) +} + +fn read_files(dir_path: &str, unique: bool) -> ProgResult<(SentenceSet, CategorySet)> { + let mut opt_sentences: Option<SentenceSet> = None; + let mut categories = CategorySet::new(unique); + + let dir = fs::read_dir(dir_path)?; + for entry in dir { + let path_buf = entry?.path(); + if path_buf.is_file() { + let extension = path_buf.extension() + .ok_or(Error::BadPath(path_buf.clone()))?; + if extension == OsStr::new("txt") { + let lines = read_lines(&path_buf)?; + let filename = path_buf.file_name() + .ok_or(Error::BadPath(path_buf.clone()))?; + if filename == OsStr::new("sentences.txt") { + if opt_sentences.is_none() { + opt_sentences = Some(SentenceSet::new(lines, unique)?); + } else { + return Err(Error::MultipleSentences); + } + } else { + let cat_name = path_buf.file_stem() + .expect("filename stem unwrapping osstr") + .to_os_string(); + categories.add_category(cat_name, lines)?; + } + } + } + } + + match opt_sentences { + None => Err(Error::NoSentences), + Some(s) => Ok((s, categories)), + } +} + +fn crash(e: Error) -> ! { + eprintln!("[ERROR] {}", e); + process::exit(1); +} + +fn sub_main(args: Args) -> ProgResult<()> { + let (mut sentences, mut categories) = read_files(&args.directory, args.unique)?; + + for _i in 0..args.n_sentences { + let sentence = sentences.random_sentence()?; + let generated = sentence.generate(&mut categories)?; + println!("{}", generated); + } + + Ok(()) +} + +fn main() { + let args = Args::parse(); + + if let Err(e) = sub_main(args) { + crash(e) + } +} + diff --git a/test_data/a.txt b/test_data/a.txt new file mode 100644 index 0000000..a9c9381 --- /dev/null +++ b/test_data/a.txt @@ -0,0 +1,9 @@ +a_one +a_two +a_three +a_four with spaces +a_five + + + +a_six lots of line breaks above this one diff --git a/test_data/b.txt b/test_data/b.txt new file mode 100644 index 0000000..b6bb927 --- /dev/null +++ b/test_data/b.txt @@ -0,0 +1,5 @@ +b_one +b_two +b_three +b_four with spaces +b_five diff --git a/test_data/c.txt b/test_data/c.txt new file mode 100644 index 0000000..76cc922 --- /dev/null +++ b/test_data/c.txt @@ -0,0 +1,5 @@ +c_one +c_two +c_three +c_four with spaces +c_five diff --git a/test_data/sentences.txt b/test_data/sentences.txt new file mode 100644 index 0000000..1538972 --- /dev/null +++ b/test_data/sentences.txt @@ -0,0 +1,14 @@ +This sentence tests type a: %%a%%. +This sentence tests type b: %%b%%. +This sentence tests type c: %%c%%. +This sentence uses types a, b, and c in reverse order: %%c%% %%b%% %%a%%. +This sentence uses a variable: %%?x%% +This sentence uses one variable once, then another twice: %%?x%% %%?y%% %%?y%% +This sentence uses four different variables: %%?w%% %%?x%% %%?y%% %%?z%% +This sentence uses four identical variables: %%?x%% %%?x%% %%?x%% %%?x%% +This sentence uses type c, then a variable: %%c%% %%?x%% +This sentence uses type c, then a variable named 'c': %%c%% %%?c%% +This sentence uses 10 identical variables: %%?x%% %%?x%% %%?x%% %%?x%% %%?x%% %%?x%% %%?x%% %%?x%% %%?x%% %%?x%% +This sentence uses either type a or b: %%a|b%% +This sentence uses a variable, then either type a or the same variable: %%?x%% %%a|?x%% +malformed sentence %%a||d%% |
