Repository: yshavit/mdq (rust)

Issue:
add end-to-end tests
It'd be cool if I could have a dir that contains yamls, where each yaml is something like:

```yaml
given: |
  <markdown contents>
check:
  - ["# my selector"]: |
    <expected markdown for section>
  - [--some-switch, "[]()"]: |
    <expected markdown for links>
  - ...
```

... and then be able to turn that into a bunch of tests.

This would (a) be useful to have, just for testing; but also (b) be good for documentation of examples.

Ideally, each of those `check` cases would be a separate sub-test; but I think that would require parsing the yaml at compile-time (so that I can use it in a macro), and I don't know if any libraries allow that.

I do think keeping the md and checks in one file (as opposed to something like `test-1.md` + `test-1-check-1.txt`) is useful, because it lets you see the inputs and outputs together more easily.

Pull request:
add simple integ test
Add integ tests, which are data-driven off of toml files.

Additionally, makes the following fixes:

- If only one node matches the selectors, and it has a link, don't add a thematic break between that node and the link definition:
   ```diff
   [my link][1]
   
   -    -----
   - 
   [1]: https://example.com
   ```
- fix how trailing newlines are handled (it was previously printing too many)
- add a todo for changing `--link-canonicalization`

Resolves #92.

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index e8cb03c..f62349b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -106,12 +106,34 @@ version = "1.0.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "0b6a852b24ab71dffc585bcb46eaf7959d175cb865a7152e35b348d1b2960422"
 
+[[package]]
+name = "equivalent"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
+
+[[package]]
+name = "hashbrown"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
+
 [[package]]
 name = "heck"
 version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
 
+[[package]]
+name = "indexmap"
+version = "2.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "168fb715dda47215e360912c096649d23d58bf392ac62f73919e831745e40f26"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
 [[package]]
 name = "indoc"
 version = "2.0.5"
@@ -157,6 +179,7 @@ dependencies = [
  "regex",
  "serde",
  "serde_json",
+ "toml",
 ]
 
 [[package]]
@@ -255,6 +278,15 @@ dependencies = [
  "serde",
 ]
 
+[[package]]
+name = "serde_spanned"
+version = "0.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79e674e01f999af37c49f70a6ede167a8a60b2503e56c5599532a65baa5969a0"
+dependencies = [
+ "serde",
+]
+
 [[package]]
 name = "strsim"
 version = "0.11.1"
@@ -272,6 +304,40 @@ dependencies = [
  "unicode-ident",
 ]
 
+[[package]]
+name = "toml"
+version = "0.8.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f49eb2ab21d2f26bd6db7bf383edc527a7ebaee412d17af4d40fdccd442f335"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4badfd56924ae69bcc9039335b2e017639ce3f9b001c393c1b2d1ef846ce2cbf"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d59a3a72298453f564e2b111fa896f8d07fabb36f51f06d7e875fc5e0b5a3ef1"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "winnow",
+]
+
 [[package]]
 name = "unicode-id"
 version = "0.3.4"
@@ -362,3 +428,12 @@ name = "windows_x86_64_msvc"
 version = "0.52.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0"
+
+[[package]]
+name = "winnow"
+version = "0.6.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59b5e5f6c299a3c7890b876a2a587f3115162487e704907d9b6cd29473052ba1"
+dependencies = [
+ "memchr",
+]
diff --git a/Cargo.toml b/Cargo.toml
index 0619b3a..f4c1dcf 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,3 +16,8 @@ serde_json = "1.0"
 [dev-dependencies]
 indoc = "2"
 lazy_static = "1.4.0"
+
+[build-dependencies]
+indoc = "2"
+serde = { version = "1", features = ["derive"] }
+toml = "0.8"
diff --git a/build.rs b/build.rs
new file mode 100644
index 0000000..000d369
--- /dev/null
+++ b/build.rs
@@ -0,0 +1,232 @@
+use serde::Deserialize;
+use std::borrow::Borrow;
+use std::collections::HashMap;
+use std::fs::DirEntry;
+use std::path::Path;
+use std::{env, fs};
+
+const MD_CASES_PATH: &str = "tests/md_cases/";
+const CASES_WRITE: &str = "tests/integ_test_cases.rs";
+
+fn main() -> Result<(), String> {
+    println!("cargo::rerun-if-changed={MD_CASES_PATH}");
+    let out_dir = env::var("OUT_DIR").unwrap();
+
+    generate_integ_test_cases(&out_dir)?;
+
+    Ok(())
+}
+
+fn generate_integ_test_cases(out_dir: &String) -> Result<(), String> {
+    let mut out = Writer::new();
+    for md_case in fs::read_dir(MD_CASES_PATH).map_err(|e| e.to_string())? {
+        let spec_file = DirEntryHelper::new(md_case.map_err(|e| e.to_string())?);
+        if !spec_file.run(DirEntry::file_type)?.is_file() {
+            return Err(spec_file.err_string::<&str, _>("not a regular file"));
+        }
+
+        out.writes(&["mod ", &spec_file.mod_name(), " {"]).nl();
+        out.with_indent(|out| {
+            out.write("use super::*;").nl().nl();
+
+            let contents = spec_file.run(|f| fs::read_to_string(f.path())).unwrap();
+            let spec_file_parsed: TestSpecFile =
+                toml::from_str(&contents).map_err(|e| spec_file.err_string(e)).unwrap();
+
+            out.write("const MD: &str = indoc::indoc! {r#\"");
+            out.with_indent(|out| {
+                let mut iter = spec_file_parsed.given.md.trim().split('\n').peekable();
+                while let Some(line) = iter.next() {
+                    out.nl().write(line);
+                    if iter.peek().is_none() {
+                        out.writeln("\"#};");
+                    }
+                }
+            });
+
+            for case in spec_file_parsed.get_cases() {
+                case.write_test_fn_to(out);
+            }
+        });
+
+        out.writeln("}");
+    }
+
+    let out_path = Path::new(&out_dir).join(CASES_WRITE);
+    fs::create_dir_all(out_path.parent().expect("no parent dir found"))
+        .map_err(|e| format!("mkdirs on {}: {}", out_path.to_string_lossy(), e.to_string()))?;
+    fs::write(&out_path, out.get())
+        .map_err(|e| format!("writing to {}: {}", out_path.to_string_lossy(), e.to_string()))?;
+
+    Ok(())
+}
+
+struct DirEntryHelper {
+    dir_entry: DirEntry,
+    path_lossy: String,
+}
+
+impl DirEntryHelper {
+    fn new(dir_entry: DirEntry) -> Self {
+        let path_lossy = dir_entry.path().to_string_lossy().to_string();
+        Self { dir_entry, path_lossy }
+    }
+
+    fn mod_name(&self) -> String {
+        let file_name = self.dir_entry.file_name();
+        let p = Path::new(file_name.as_os_str());
+        let stem = p
+            .file_stem()
+            .expect(&format!("no file stem for {}", self.dir_entry.path().to_string_lossy()));
+        stem.to_string_lossy().to_string()
+    }
+
+    fn run<F, E: ToString, R>(&self, action: F) -> Result<R, String>
+    where
+        E: ToString,
+        F: FnOnce(&DirEntry) -> Result<R, E>,
+    {
+        action(&self.dir_entry).map_err(|e| self.err_string(e))
+    }
+
+    fn path(&self) -> &str {
+        &self.path_lossy
+    }
+
+    fn err_string<E: ToString, B: Borrow<E>>(&self, e: B) -> String {
+        format!("{}: {}", self.path(), e.borrow().to_string())
+    }
+}
+
+#[derive(Deserialize)]
+struct TestSpecFile {
+    given: TestGiven,
+    expect: HashMap<String, TestExpect>,
+}
+
+#[derive(Deserialize)]
+struct TestGiven {
+    md: String,
+}
+
+#[derive(Deserialize)]
+struct TestExpect {
+    cli_args: Vec<String>,
+    output: String,
+}
+
+impl TestSpecFile {
+    fn get_cases(self) -> Vec<Case> {
+        let mut results = Vec::with_capacity(self.expect.len());
+        for (case_name, test_expect) in self.expect {
+            results.push(Case {
+                case_name,
+                cli_args: test_expect.cli_args,
+                expect_output: test_expect.output,
+            })
+        }
+        results
+    }
+}
+
+#[derive(Debug)]
+struct Case {
+    case_name: String,
+    cli_args: Vec<String>,
+    expect_output: String,
+}
+
+impl Case {
+    fn write_test_fn_to(&self, out: &mut Writer) {
+        let fn_name = self.case_name.replace(|ch: char| !ch.is_alphanumeric(), "_");
+        out.writeln("#[test]");
+        out.writes(&["fn ", &fn_name, "() {"]);
+        out.with_indent(|out| {
+            out.write("Case {");
+            out.with_indent(|out| {
+                out.writeln(&format!("cli_args: {:?},", &self.cli_args));
+                if self.expect_output.is_empty() {
+                    out.write("expect_output: \"\",");
+                } else {
+                    out.write("expect_output: indoc::indoc! {r#\"");
+                    out.with_indent(|out| {
+                        let mut iter = self.expect_output.split('\n').peekable();
+                        while let Some(line) = iter.next() {
+                            out.write(line);
+                            if iter.peek().is_some() {
+                                out.nl();
+                            } else {
+                                out.write("\"#},");
+                            }
+                        }
+                    });
+                }
+                out.write("md: MD,");
+            });
+            out.write("}.check();");
+        });
+        out.write("}").nl().nl();
+    }
+}
+
+struct Writer {
+    out: String,
+    indent_level: usize,
+}
+
+impl Writer {
+    fn new() -> Self {
+        Self {
+            out: String::with_capacity(512),
+            indent_level: 0,
+        }
+    }
+
+    fn with_indent<F>(&mut self, block: F)
+    where
+        F: FnOnce(&mut Self),
+    {
+        self.indent_level += 1;
+        self.write("\n");
+        block(self);
+        self.indent_level -= 1;
+        self.write("\n");
+    }
+
+    fn write(&mut self, text: &str) -> &mut Self {
+        let mut iter = text.split('\n').peekable();
+        while let Some(line) = iter.next() {
+            if !line.is_empty() {
+                self.out.push_str(line);
+            }
+            if iter.peek().is_some() {
+                self.out.push('\n');
+                for _ in 0..self.indent_level {
+                    self.out.push_str("    ");
+                }
+            }
+        }
+        self
+    }
+
+    fn writes(&mut self, items: &[&str]) -> &mut Self {
+        for item in items {
+            self.write(item);
+        }
+        self
+    }
+
+    fn writeln(&mut self, text: &str) {
+        self.write(text);
+        self.write("\n");
+    }
+
+    fn nl(&mut self) -> &mut Self {
+        self.write("\n");
+        self
+    }
+
+    fn get(&self) -> &str {
+        &self.out
+    }
+}
diff --git a/src/fmt_md.rs b/src/fmt_md.rs
index 6fa5596..a17f313 100644
--- a/src/fmt_md.rs
+++ b/src/fmt_md.rs
@@ -74,11 +74,15 @@ where
         prev_was_thematic_break: false,
         inlines_writer: &mut MdInlinesWriter::new(options.inline_options),
     };
-    writer_state.write_md(out, nodes, true);
+    let nodes_count = writer_state.write_md(out, nodes, true);
 
     // Always write the pending definitions at the end of the doc. If there were no sections, then BottomOfSection
-    // won't have been triggered, but we still want to write them
-    writer_state.write_definitions(out, DefinitionsToWrite::Both, true);
+    // won't have been triggered, but we still want to write them. We'll add a thematic break before the links if there
+    // was more than one top-level node: if there's one node, then the whole thing looks like a single document and
+    // should just have the links there; but if there are multiple, then each would have been separated by a thematic
+    // break, and we want to add another such break so that it's clear that the reference definitions don't just go with
+    // the last item.
+    writer_state.write_definitions(out, DefinitionsToWrite::Both, nodes_count > 1);
 }
 
 pub fn write_md_inlines<'a, I, W>(out: &mut Output<W>, nodes: I, inlines_writer: &mut MdInlinesWriter<'a>)
@@ -108,18 +112,21 @@ struct MdWriterState<'s, 'a> {
 }
 
 impl<'s, 'a> MdWriterState<'s, 'a> {
-    fn write_md<I, W>(&mut self, out: &mut Output<W>, nodes: I, add_break: bool)
+    fn write_md<I, W>(&mut self, out: &mut Output<W>, nodes: I, add_break: bool) -> usize
     where
         I: Iterator<Item = MdElemRef<'a>>,
         W: SimpleWrite,
     {
+        let mut count = 0;
         let mut iter = nodes.into_iter().peekable();
         while let Some(node) = iter.next() {
+            count += 1;
             self.write_one_md(out, node);
             if add_break && iter.peek().is_some() {
                 self.write_one_md(out, MdElemRef::ThematicBreak);
             }
         }
+        count
     }
 
     pub fn write_one_md<W>(&mut self, out: &mut Output<W>, node_ref: MdElemRef<'a>)
@@ -130,7 +137,9 @@ impl<'s, 'a> MdWriterState<'s, 'a> {
         self.prev_was_thematic_break = false;
 
         match node_ref {
-            MdElemRef::Doc(items) => self.write_md(out, items.iter().map(|elem| elem.into()), false),
+            MdElemRef::Doc(items) => {
+                self.write_md(out, items.iter().map(|elem| elem.into()), false);
+            }
             MdElemRef::Section(Section { depth, title, body }) => {
                 out.with_block(Block::Plain, |out| {
                     for _ in 0..*depth {
@@ -1550,8 +1559,6 @@ pub mod tests {
                 indoc! {r#"
                     [link text][1]
 
-                       -----
-
                     [1]: https://example.com"#},
             );
         }
@@ -1643,8 +1650,6 @@ pub mod tests {
                 indoc! {r#"
                     [link text][1]
 
-                       -----
-
                     [1]: https://example.com"#},
             );
         }
@@ -1668,8 +1673,6 @@ pub mod tests {
                 indoc! {r#"
                     ![alt text][1]
 
-                       -----
-
                     [1]: https://example.com"#},
             );
         }
@@ -1731,8 +1734,6 @@ pub mod tests {
                 indoc! {r#"
                     ![alt text][1]
 
-                       -----
-
                     [1]: https://example.com"#},
             );
         }
@@ -1811,8 +1812,6 @@ pub mod tests {
                 indoc! {r#"
                     Hello, [world][1]! This is interesting[^a].
 
-                       -----
-
                     [1]: https://example.com
                     [^a]: this is my note"#},
             );
@@ -1898,8 +1897,6 @@ pub mod tests {
                 indoc! {r#"
                     [link description][1]
 
-                       -----
-
                     [1]: https://example.com"#},
             )
         }
@@ -2005,8 +2002,6 @@ pub mod tests {
                 indoc! {r#"
                     [^d][^c][b-text][b][a-text][a]
 
-                       -----
-
                     [a]: https://example.com/a
                     [b]: https://example.com/b
                     [^c]: footnote 2
diff --git a/src/lib.rs b/src/lib.rs
index ff9589b..28ef96e 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,6 +1,8 @@
 use clap::Parser;
+use output::Output;
 use std::borrow::Cow;
 use std::io;
+use std::io::{stdin, Read, Write};
 
 use crate::fmt_md::{MdOptions, ReferencePlacement};
 use crate::fmt_md_inlines::MdInlinesWriterOptions;
@@ -43,7 +45,7 @@ pub struct Cli {
     pub(crate) footnote_pos: Option<ReferencePlacement>,
 
     #[arg(long, short, value_enum, default_value_t=LinkTransform::Reference)]
-    pub(crate) link_canonicalization: LinkTransform,
+    pub(crate) link_canonicalization: LinkTransform, // TODO rename to link-format
 
     /// Output the results as a JSON object, instead of as markdown.
     #[arg(long, short, default_value_t = false)]
@@ -79,7 +81,25 @@ impl Cli {
     }
 }
 
-pub fn run_main(cli: &Cli, contents: String) -> bool {
+pub fn run_in_memory(cli: &Cli, contents: &str) -> Result<String, &'static str> {
+    let mut out = Vec::with_capacity(256); // just a guess
+
+    run(&cli, contents.to_string(), || &mut out);
+    let out_str = String::from_utf8(out).map_err(|_| "UTF-8 decode error")?;
+    Ok(out_str)
+}
+
+pub fn run_stdio(cli: &Cli) -> bool {
+    let mut contents = String::new();
+    stdin().read_to_string(&mut contents).expect("invalid input (not utf8)");
+    run(&cli, contents, || io::stdout().lock())
+}
+
+fn run<W, F>(cli: &Cli, contents: String, get_out: F) -> bool
+where
+    F: FnOnce() -> W,
+    W: Write,
+{
     let ast = markdown::to_mdast(&contents, &markdown::ParseOptions::gfm()).unwrap();
     let mdqs = MdElem::read(ast, &ReadOptions::default()).unwrap();
 
@@ -106,13 +126,12 @@ pub fn run_main(cli: &Cli, contents: String) -> bool {
         },
     };
 
-    let mut stdout = io::stdout().lock();
+    let mut stdout = get_out();
     if cli.json {
         serde_json::to_writer(&mut stdout, &SerdeDoc::new(&pipeline_nodes, md_options.inline_options)).unwrap();
     } else {
-        let mut out = output::Output::new(Stream(&mut stdout));
+        let mut out = Output::new(Stream(&mut stdout));
         fmt_md::write_md(&md_options, &mut out, pipeline_nodes.into_iter());
-        out.write_str("\n");
     }
 
     true
diff --git a/src/main.rs b/src/main.rs
index 8027476..9d73a07 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,14 +1,11 @@
 use clap::Parser;
-use mdq::{run_main, Cli};
-use std::io::{stdin, Read};
+use mdq::{run_stdio, Cli};
 use std::process::ExitCode;
 
 fn main() -> ExitCode {
     let cli = Cli::parse();
 
-    let mut contents = String::new();
-    stdin().read_to_string(&mut contents).expect("invalid input (not utf8)");
-    if run_main(&cli, contents) {
+    if run_stdio(&cli) {
         ExitCode::SUCCESS
     } else {
         ExitCode::FAILURE
diff --git a/src/output.rs b/src/output.rs
index 9bd8521..9a63f6a 100644
--- a/src/output.rs
+++ b/src/output.rs
@@ -276,6 +276,10 @@ where
 
 impl<W: SimpleWrite> Drop for Output<W> {
     fn drop(&mut self) {
+        if self.pending_newlines > 0 {
+            self.write_raw("\n");
+            self.pending_newlines = 0;
+        }
         if let Err(e) = self.stream.flush() {
             if WritingState::Error != self.writing_state {
                 eprintln!("error while writing output: {}", e);


Test patch:
diff --git a/tests/integ_test.rs b/tests/integ_test.rs
new file mode 100644
index 0000000..a8df509
--- /dev/null
+++ b/tests/integ_test.rs
@@ -0,0 +1,19 @@
+use clap::Parser;
+
+#[derive(Debug)]
+struct Case<const N: usize> {
+    cli_args: [&'static str; N],
+    expect_output: &'static str,
+    md: &'static str,
+}
+
+impl<const N: usize> Case<N> {
+    fn check(&self) {
+        let all_cli_args = ["cmd"].iter().chain(&self.cli_args);
+        let cli = mdq::Cli::try_parse_from(all_cli_args).unwrap();
+        let actual_out = mdq::run_in_memory(&cli, self.md).unwrap();
+        assert_eq!(actual_out, self.expect_output);
+    }
+}
+
+include!(concat!(env!("OUT_DIR"), "/tests/integ_test_cases.rs"));
diff --git a/tests/md_cases/link_placement.toml b/tests/md_cases/link_placement.toml
new file mode 100644
index 0000000..00a614a
--- /dev/null
+++ b/tests/md_cases/link_placement.toml
@@ -0,0 +1,42 @@
+[given]
+md = '''
+# First Section
+
+This is [an interesting link][1].
+
+# Second section
+
+Some section text.
+
+[1]: https://example.com/interesting
+'''
+
+
+[expect."standard link placement"]
+cli_args = []
+output = '''
+# First Section
+
+This is [an interesting link][1].
+
+[1]: https://example.com/interesting
+
+# Second section
+
+Some section text.
+'''
+
+
+[expect."links at bottom of doc"]
+cli_args = ["--link-pos", "doc"]
+output = '''
+# First Section
+
+This is [an interesting link][1].
+
+# Second section
+
+Some section text.
+
+[1]: https://example.com/interesting
+'''
diff --git a/tests/md_cases/links_references.toml b/tests/md_cases/links_references.toml
new file mode 100644
index 0000000..23a60c2
--- /dev/null
+++ b/tests/md_cases/links_references.toml
@@ -0,0 +1,74 @@
+[given]
+md = '''
+- an [inline link](https://example.com)
+- a [standard reference link][1]
+- a [link with a non-numeric reference id][a]
+- a [link with a non-sequential reference id][456]
+- a [collapsed link][]
+- a [shortcut link]
+- a [link with a title](https://example.com "my title")
+- a [link with a title that contains double-quotes](https://example.com 'my "alleged" title')
+
+[1]: https://example.com/1
+[a]: https://example.com/a "with a title"
+[456]: https://example.com/non-sequential
+[collapsed link]: https://example.com/collapsed
+[shortcut link]: https://example.com/shortcut
+'''
+
+
+[expect."default"]
+cli_args = []
+output = '''
+- an [inline link][1]
+- a [standard reference link][2]
+- a [link with a non-numeric reference id][a]
+- a [link with a non-sequential reference id][3]
+- a [collapsed link][collapsed link]
+- a [shortcut link][shortcut link]
+- a [link with a title][4]
+- a [link with a title that contains double-quotes][5]
+
+[1]: https://example.com
+[2]: https://example.com/1
+[3]: https://example.com/non-sequential
+[4]: https://example.com "my title"
+[5]: https://example.com 'my "alleged" title'
+[a]: https://example.com/a "with a title"
+[collapsed link]: https://example.com/collapsed
+[shortcut link]: https://example.com/shortcut
+'''
+
+
+[expect."keep original"]
+cli_args = ["--link-canonicalization", "keep"]
+output = '''
+- an [inline link](https://example.com)
+- a [standard reference link][1]
+- a [link with a non-numeric reference id][a]
+- a [link with a non-sequential reference id][456]
+- a [collapsed link][]
+- a [shortcut link]
+- a [link with a title](https://example.com "my title")
+- a [link with a title that contains double-quotes](https://example.com 'my "alleged" title')
+
+[1]: https://example.com/1
+[456]: https://example.com/non-sequential
+[a]: https://example.com/a "with a title"
+[collapsed link]: https://example.com/collapsed
+[shortcut link]: https://example.com/shortcut
+'''
+
+
+[expect."inline all"]
+cli_args = ["--link-canonicalization", "inline"]
+output = '''
+- an [inline link](https://example.com)
+- a [standard reference link](https://example.com/1)
+- a [link with a non-numeric reference id](https://example.com/a "with a title")
+- a [link with a non-sequential reference id](https://example.com/non-sequential)
+- a [collapsed link](https://example.com/collapsed)
+- a [shortcut link](https://example.com/shortcut)
+- a [link with a title](https://example.com "my title")
+- a [link with a title that contains double-quotes](https://example.com 'my "alleged" title')
+'''
diff --git a/tests/md_cases/matchers.toml b/tests/md_cases/matchers.toml
new file mode 100644
index 0000000..1d2d19e
--- /dev/null
+++ b/tests/md_cases/matchers.toml
@@ -0,0 +1,72 @@
+[given]
+md = '''
+- hello world
+- the world is my oyster
+- worldly possessions
+- lorem ipsum dolor sit amet.
+'''
+
+
+[expect."bareword"]
+cli_args = ['- world '] # note: trailing space is ignored
+output = '''
+- hello world
+
+   -----
+
+- the world is my oyster
+
+   -----
+
+- worldly possessions
+'''
+
+
+[expect."bareword with start anchor"]
+cli_args = ['- ^world']
+output = '''
+- worldly possessions
+'''
+
+
+[expect."bareword with end anchor"]
+cli_args = ['- world$']
+output = '''
+- hello world
+'''
+
+
+[expect."quoted"]
+cli_args = ['- "world "'] # note: traiing space within the quote is significant
+output = '''
+- the world is my oyster
+'''
+
+
+[expect."quoted with anchor"]
+cli_args = ['- "world "$'] # nothing matches this
+output = ''
+
+
+[expect."regex"]
+cli_args = ['- /wor.d$/']
+output = '''
+- hello world
+'''
+
+
+[expect."bareword is case-insensitive"]
+cli_args = ['- OYSTER']
+output = '''
+- the world is my oyster
+'''
+
+
+[expect."quoted is case-sensitive"]
+cli_args = ['- "OYSTER"']
+output = ''
+
+
+[expect."regex is case-sensitive"]
+cli_args = ['- /OYSTER/']
+output = ''
diff --git a/tests/md_cases/select_link.toml b/tests/md_cases/select_link.toml
new file mode 100644
index 0000000..24ca110
--- /dev/null
+++ b/tests/md_cases/select_link.toml
@@ -0,0 +1,38 @@
+[given]
+md = '''
+This text has both [an inline link](https://example.com/inline) and a [referenced link][1]. It also has an image:
+
+![image alt text](https://example.com/hylas-and-nymphs.png)
+
+[1]: https://example.com/referenced
+'''
+
+
+[expect."select all"]
+cli_args = ['[]()', '--link-canonicalization=inline']
+output = '''
+[an inline link](https://example.com/inline)
+
+   -----
+
+[referenced link](https://example.com/referenced)'''
+
+
+[expect."select link by text"]
+cli_args = ['[an inline]()', '--link-canonicalization=inline']
+output = '''
+[an inline link](https://example.com/inline)'''
+
+
+[expect."select link by url"]
+cli_args = ['[]("/referenced")', '--link-canonicalization=inline']
+output = '''
+[referenced link](https://example.com/referenced)'''
+
+
+[expect."select image"]
+cli_args = ['![]()', '--link-canonicalization=inline']
+output = '''
+![image alt text](https://example.com/hylas-and-nymphs.png)'''
+
+
diff --git a/tests/md_cases/select_lists.toml b/tests/md_cases/select_lists.toml
new file mode 100644
index 0000000..e3a9ed6
--- /dev/null
+++ b/tests/md_cases/select_lists.toml
@@ -0,0 +1,80 @@
+[given]
+md = '''
+# Ordered
+
+2. two (note: starting count at 2!)
+3. three
+
+# Unordered
+
+- a point
+- another point
+
+# Tasks
+
+- [ ] an incomplete task
+- [x] a completed task
+
+1. [ ] ordered tasks are possible, too
+'''
+
+
+[expect."select ordered"]
+cli_args = ['1.']
+output = '''
+2. two (note: starting count at 2!)
+
+   -----
+
+3. three
+'''
+
+
+[expect."select unordered"]
+cli_args = ['-']
+output = '''
+- a point
+
+   -----
+
+- another point
+'''
+
+
+[expect."select all ordered tasks"]
+cli_args = ['- [?]'] # note: space between the - and [ is required
+output = '''
+- [ ] an incomplete task
+
+   -----
+
+- [x] a completed task
+'''
+
+
+[expect."select ordered task by text"]
+cli_args = ['- [?] incomplete'] # note: space between the - and [ is required
+output = '''
+- [ ] an incomplete task
+'''
+
+
+[expect."select incomplete ordered tasks"]
+cli_args = ['- [ ]']
+output = '''
+- [ ] an incomplete task
+'''
+
+
+[expect."select completed ordered tasks"]
+cli_args = ['- [x]']
+output = '''
+- [x] a completed task
+'''
+
+
+[expect."select unordered tasks"]
+cli_args = ['1. [?]']
+output = '''
+1. [ ] ordered tasks are possible, too
+'''
diff --git a/tests/md_cases/select_sections.toml b/tests/md_cases/select_sections.toml
new file mode 100644
index 0000000..e5c226c
--- /dev/null
+++ b/tests/md_cases/select_sections.toml
@@ -0,0 +1,57 @@
+[given]
+md = '''
+# Alpha
+
+My first section.
+
+## Sub-section
+
+Hello, world.
+
+# Bravo
+
+My second section.
+'''
+
+
+[expect."select top-level section"]
+cli_args = ["# alpha"]
+output = '''
+# Alpha
+
+My first section.
+
+## Sub-section
+
+Hello, world.
+'''
+
+
+[expect."select subsection"]
+cli_args = ["# sub"]
+output = '''
+## Sub-section
+
+Hello, world.
+'''
+
+
+[expect."select all sections"]
+# note: This selects all the doc's contents, but not as a single doc: each top-level header is its own selection, so the
+# output separates them with a thematic break.
+cli_args = ["#"]
+output = '''
+# Alpha
+
+My first section.
+
+## Sub-section
+
+Hello, world.
+
+   -----
+
+# Bravo
+
+My second section.
+'''
diff --git a/tests/readme.md b/tests/readme.md
new file mode 100644
index 0000000..bce6842
--- /dev/null
+++ b/tests/readme.md
@@ -0,0 +1,26 @@
+# Integration tests
+
+Test cases are in `md_cases/` and are defined by toml files with the following format:
+
+```toml
+[given]
+md = '''
+Some input markdown
+'''
+
+[expect."test case name"]
+cli_args = ["arguments", "passed", "to", "cli"]
+output = '''
+The expected output
+'''
+
+[expect."another text case"]
+# ...
+```
+
+[`build.rs`] compiles these into test functions that construct an `integ_test::Case` and invoke its [`fn check`]. Each
+file becomes a `mod`, and each case becomes a test fn within that `mod`.
+
+[`build.rs`]: ../build.rs
+
+[`fn check`]: integ_test.rs
\ No newline at end of file