Week 47 of 2025

Development log of Tad Better Behavior

40 items
  1. Introduce an intentionally wrong step for testing
  2. Setup default package output from Nix flake
  3. Update the roadmap
  4. Use anyhow to manage errors
  5. Provide more context when failing
  6. Bump the patch version
  7. More context for message passing errors
  8. Bump the patch version
  9. Use Python unittest assertion for better messages
  10. Implement automatic argument parsing in python
  11. Update the roadmap
  12. Let basic.py chill with the logging
  13. Implement code block passing
  14. Allow interpreters to hint about failed steps
  15. Implement the remaining step variants in basic.py
  16. Improve report indentation
  17. Bump minor version
  18. Fix Python interpreter: leading coma when 0 args
  19. Implement support for tables
  20. Silence some noisy logging
  21. Update the roadmap
  22. Bump the minor version
  23. Promise to support BSD
  24. Separate library code from basic.py to tbb.py
  25. Fix tbb.py: wrong path in the implementation hint
  26. Format python code, clarify a comment
  27. Tell git to ignore pycache
  28. Mark python library as done
  29. Initiate the spec for tbb itself
  30. Specify the --version behavior
  31. Move indent_tail to tbb.py, write some tests
  32. Update the roadmap; improve regexp assert output
  33. Specify and implement the list subcommand
  34. Write a spec for the run subcommand
  35. Signal failed evaluation with exit code
  36. Print failed steps after the report as errors
  37. Bump version to 0.4.0 (breaking)
  38. Write some ideas in the readme
  39. Update spec re commands in --help output
  40. Reorganize spec.rs - structs before traits

Introduce an intentionally wrong step for testing

On by Tad Lispy

index c36e714..101d5af 100644
--- a/samples/basic.md
+++ b/samples/basic.md
@@ -25,4 +25,8 @@ Content outside of bullet points (like this) won't have any effect on the progra
=  * The word `blocks` has `6` characters
=  * There are `3` `r`s in the word `strawberry`
=  * The reverse of `abc` is `cba`
+  * The reverse of `CIA` is `KGB`
+  
+    This step is intentionally wrong to allow demonstrate that TBB will not proceed with following steps.
+    
=  * There are `2` `o`s in the word `boost`

Setup default package output from Nix flake

On by Tad Lispy

So that this wonder can be used in other projects.

index 98507b2..727883b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,6 @@
=.devenv
=.direnv/
+result
=
=# Added by cargo
=
index b93a9d3..3807d0f 100644
--- a/flake.nix
+++ b/flake.nix
@@ -40,5 +40,22 @@
=              ];
=            };
=          });
+
+      packages = forEachSystem
+        (system:
+          let
+            pkgs = nixpkgs.legacyPackages.${system};
+            cargo-config = builtins.fromTOML (builtins.readFile ./Cargo.toml);
+          in
+          {
+            default = pkgs.rustPlatform.buildRustPackage {
+              pname = cargo-config.package.name;
+              version = cargo-config.package.version;
+              src = ./.;
+              cargoLock = {
+                lockFile = ./Cargo.lock;
+              };
+            };
+          });
=    };
=}

Update the roadmap

On by Tad Lispy

index cb1c00d..aed3aea 100644
--- a/README.md
+++ b/README.md
@@ -35,16 +35,17 @@ Support for Linux, OS X and Web (WASM).
=  - [x] Interpretter in a different language (Python)
=  - [x] Report why steps fail
=- [ ] More readable report
-  - [ ] The emojis are misaligned and lack color (at least in my terminal)
+  - [x] The emojis are misaligned and lack color (at least in my terminal)
=  - [ ] A summary at the bottom (esp. list of errors)
-  - [ ] Use colors
+  - [x] Use colors
+  - [ ] More instructive error messages
=- [ ] Pass more step data to interpreters
=    - [ ] Code blocks
=    - [ ] Lists
=    - [ ] Tables
=    - [ ] Definition lists
=    - [ ] Original markdown fragment
-- [ ] Nix package (from Flake)
+- [x] Nix package (from Flake)
=- [ ] Use for evaluating Jewiet's Form to Mail specification
=- [ ] Helper libraries
=    - [ ] for Python

Use anyhow to manage errors

On by Tad Lispy

index 9c1b9d0..2811a83 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -61,6 +61,12 @@ dependencies = [
= "windows-sys 0.61.2",
=]
=
+[[package]]
+name = "anyhow"
+version = "1.0.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
+
=[[package]]
=name = "clap"
=version = "4.5.51"
@@ -381,6 +387,7 @@ dependencies = [
=name = "tad-better-behavior"
=version = "0.1.0"
=dependencies = [
+ "anyhow",
= "clap",
= "colored",
= "env_logger",
index 3ffa046..c76fbd2 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,6 +4,7 @@ version = "0.1.0"
=edition = "2024"
=
=[dependencies]
+anyhow = "1.0.100"
=clap = { version = "4.5.51", features = ["derive"] }
=colored = "3.0.0"
=env_logger = "0.11.8"
index 787b9bc..a187408 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -1,7 +1,7 @@
=use crate::spec::{Scenario, Spec, Step, Suite};
+use anyhow::anyhow;
=use colored::Colorize;
=use serde::{Deserialize, Serialize};
-use std::error::Error;
=use std::fmt::Display;
=use std::io::{BufRead, BufReader, LineWriter, Write};
=use std::process::{Command, Stdio};
@@ -58,7 +58,7 @@ pub struct ScenarioReport<'a> {
=}
=impl<'a> ScenarioReport<'a> {
=    // TODO: The ScenarioReport::run method is very effectful. I think it should live in it's own module.
-    fn run(&mut self, interpreter: &str, verbose: bool) -> Result<(), Box<dyn Error>> {
+    fn run(&mut self, interpreter: &str, verbose: bool) -> anyhow::Result<()> {
=        log::debug!(
=            "Running scenario '{}' using '{interpreter}' as an interpreter",
=            self.scenario.title
@@ -77,11 +77,15 @@ impl<'a> ScenarioReport<'a> {
=
=        let Some(input) = process.stdin.take() else {
=            // TODO: Avoid string errors!
-            return Err("Failed to take the stdin stream of the interpreter process.".into());
+            return Err(anyhow!(
+                "Failed to take the stdin stream of the interpreter process."
+            ));
=        };
=
=        let Some(output) = process.stdout.take() else {
-            return Err("Failed to take the stdout stream of the interpreter process.".into());
+            return Err(anyhow!(
+                "Failed to take the stdout stream of the interpreter process."
+            ));
=        };
=
=        let mut reader = BufReader::new(output);
@@ -91,9 +95,9 @@ impl<'a> ScenarioReport<'a> {
=            let ready = match Self::receive(&mut reader)? {
=                InterpreterMessage::InterpreterState { ready } => ready,
=                message => {
-                    let complaint =
-                        format!("Unexpected message received from the interpreter: {message:#?}");
-                    return Err(complaint.into());
+                    return Err(anyhow!(
+                        "Unexpected message received from the interpreter: {message:#?}"
+                    ));
=                }
=            };
=            if ready {
@@ -134,10 +138,9 @@ impl<'a> ScenarioReport<'a> {
=                    break;
=                }
=                unexpected => {
-                    let message = format!(
+                    return Err(anyhow!(
=                        "Unexpected message received from the interpreter: {unexpected:#?}"
-                    );
-                    return Err(message.into());
+                    ));
=                }
=            };
=        }
@@ -146,15 +149,14 @@ impl<'a> ScenarioReport<'a> {
=
=        process
=            .wait()
-            .map_err(|io_error| Box::new(io_error).into())  // No idea why it's needed.
+            .map_err(|io_error| { anyhow::Error::from(io_error) })
=            .and_then(|exit_status| {
=                log::debug!("Interpreter closed with exit status {exit_status:?}");
=                let exit_code = exit_status.code().unwrap_or(0); // Is that right?
=                if exit_code == 0 {
=                    Ok(())
=                } else {
-                    let message = format!("Interpreter process {interpreter} exited abnormally. Exit code: {exit_code}");
-                    Err(message.into())
+                    Err(anyhow!("Interpreter process {interpreter} exited abnormally. Exit code: {exit_code}"))
=                 }
=            })
=    }
@@ -163,14 +165,14 @@ impl<'a> ScenarioReport<'a> {
=    fn send(
=        writer: &mut LineWriter<std::process::ChildStdin>,
=        message: &ControlMessage,
-    ) -> Result<(), Box<dyn Error>> {
+    ) -> anyhow::Result<()> {
=        let json = serde_json::to_string(&message)?;
=        Self::write_line(writer, &json)
=    }
=
=    fn receive(
=        reader: &mut BufReader<std::process::ChildStdout>,
-    ) -> Result<InterpreterMessage, Box<dyn Error>> {
+    ) -> anyhow::Result<InterpreterMessage> {
=        let buffer = Self::read_line(reader)?;
=        let message = serde_json::from_str(&buffer)?;
=
@@ -180,7 +182,7 @@ impl<'a> ScenarioReport<'a> {
=    fn write_line(
=        writer: &mut LineWriter<std::process::ChildStdin>,
=        line: &str,
-    ) -> Result<(), Box<dyn Error>> {
+    ) -> anyhow::Result<()> {
=        let buffer = [line, "\n"].concat();
=
=        log::debug!("Sending: {}", buffer);
@@ -188,12 +190,10 @@ impl<'a> ScenarioReport<'a> {
=        Ok(())
=    }
=
-    fn read_line(
-        reader: &mut BufReader<std::process::ChildStdout>,
-    ) -> Result<String, Box<dyn Error>> {
+    fn read_line(reader: &mut BufReader<std::process::ChildStdout>) -> anyhow::Result<String> {
=        let mut buffer = String::new();
=        if reader.read_line(&mut buffer)? == 0 {
-            return Err("Can't read from the interpreter process.".into());
+            return Err(anyhow!("Can't read from the interpreter process."));
=        };
=        Ok(buffer)
=    }
@@ -213,7 +213,7 @@ pub enum StepStatus {
=pub enum ScenarioStatus {
=    Done,
=    Pending,
-    FailedToRun { error: Box<dyn Error> },
+    FailedToRun { error: anyhow::Error },
=}
=
=impl Display for EvaluationReport<'_> {
index 4249efd..f58e607 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -1,5 +1,5 @@
+use anyhow::anyhow;
=use serde::{Deserialize, Serialize};
-use std::error::Error;
=use std::fmt::Display;
=
=/// Spec is a collection of suites that together describe a system
@@ -13,7 +13,7 @@ pub struct Spec {
=
=impl Spec {
=    /// Load suites from a markdown document
-    pub fn load_document(&mut self, md: &str) -> Result<(), Box<dyn Error>> {
+    pub fn load_document(&mut self, md: &str) -> anyhow::Result<()> {
=        // TODO: Support loading multiple suits from a single document (demarcated by h1)
=        let suite = Suite::from_markdown(md)?;
=
@@ -98,7 +98,7 @@ pub struct FrontMatter {
=}
=
=impl Suite {
-    pub fn from_markdown(md: &str) -> Result<Self, Box<dyn Error>> {
+    pub fn from_markdown(md: &str) -> anyhow::Result<Self> {
=        let mdast = markdown::to_mdast(
=            md,
=            &markdown::ParseOptions {
@@ -109,7 +109,9 @@ impl Suite {
=                ..Default::default()
=            },
=        )
-        .map_err(|message| message.to_string())?;
+        .map_err(|message| {
+            anyhow!("Failed to parse markdown input").context(message.to_string())
+        })?;
=        log::debug!("Markdown parsed:\n\n{:#?}", mdast);
=
=        Self::try_from(mdast)
@@ -117,17 +119,29 @@ impl Suite {
=}
=
=impl TryFrom<markdown::mdast::Node> for Suite {
-    type Error = Box<dyn Error>;
+    type Error = anyhow::Error;
=
-    fn try_from(mdast: markdown::mdast::Node) -> Result<Self, Self::Error> {
+    fn try_from(mdast: markdown::mdast::Node) -> anyhow::Result<Self> {
=        // Find the YAML front-matter and extract the interpreter field
-        let children = mdast.children().ok_or("The given node has no children.")?;
+        let children = mdast
+            .children()
+            .ok_or(anyhow!("The given node has no children."))?;
=        let first = children
=            .first()
-            .ok_or("There is no first child in the given node.")?;
+            .ok_or(anyhow!("There is no first child in the given node."))?;
=
=        let markdown::mdast::Node::Yaml(markdown::mdast::Yaml { value: yaml, .. }) = first else {
-            return Err("First child is not a YAML front matter.".into());
+            return Err(
+                (anyhow!("First child is not a YAML front matter.")) .context(concat!(
+                    "At the top of your markdown document you need to provide a YAML front-matter that looks something like this:\n",
+                    "\n",
+                    "---\n",
+                    "interpreter: ./my-interpreter.sh --any arguments you like\n",
+                    "---\n",
+                    "\n",
+                    "The rest of the markdown should follow.\n"
+                )),
+            );
=        };
=        let frontmatter: FrontMatter = serde_yaml::from_str(yaml)?;
=
@@ -145,7 +159,9 @@ impl TryFrom<markdown::mdast::Node> for Suite {
=                }
=            })
=            .next()
-            .ok_or("There is no level 1 heading inside the given node.")?
+            .ok_or(anyhow!(
+                "There is no level 1 heading inside the given node."
+            ))?
=            .to_string();
=
=        // Extract scenarios and steps
@@ -195,19 +211,23 @@ impl TryFrom<markdown::mdast::Node> for Suite {
=}
=
=impl TryFrom<&markdown::mdast::ListItem> for Step {
-    type Error = Box<dyn Error>;
+    type Error = anyhow::Error;
=
=    fn try_from(item: &markdown::mdast::ListItem) -> Result<Self, Self::Error> {
=        let headline = item
=            .children
=            .first()
-            .ok_or("Encountered a list item without any children. Impossible?")?
+            .ok_or(anyhow!(
+                "Encountered a list item without any children. Impossible?"
+            ))?
=            .clone();
=        let mut arguments = Vec::new();
=        let mut variant_headline = headline.clone();
=        for child in variant_headline
=            .children_mut()
-            .ok_or("Encountered a list heading with an empty first child. Impossible?")?
+            .ok_or(anyhow!(
+                "Encountered a list heading with an empty first child. Impossible?"
+            ))?
=            .iter_mut()
=        {
=            if let markdown::mdast::Node::InlineCode(inline_code) = child {

Provide more context when failing

On by Tad Lispy

Using anyhow context feature.

index 2811a83..177a8fb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -179,6 +179,15 @@ dependencies = [
= "hashbrown",
=]
=
+[[package]]
+name = "indoc"
+version = "2.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706"
+dependencies = [
+ "rustversion",
+]
+
=[[package]]
=name = "is_terminal_polyfill"
=version = "1.70.2"
@@ -304,6 +313,12 @@ version = "0.8.8"
=source = "registry+https://github.com/rust-lang/crates.io-index"
=checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
=
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
=[[package]]
=name = "ryu"
=version = "1.0.20"
@@ -392,6 +407,7 @@ dependencies = [
= "colored",
= "env_logger",
= "glob",
+ "indoc",
= "log",
= "markdown",
= "serde",
index c76fbd2..7e168bb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -9,6 +9,7 @@ clap = { version = "4.5.51", features = ["derive"] }
=colored = "3.0.0"
=env_logger = "0.11.8"
=glob = "0.3.3"
+indoc = "2.0.7"
=log = "0.4.28"
=markdown = "1.0.0"
=serde = { version = "1.0.228", features = ["serde_derive"] }
index b5da4fa..1e3dab7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,13 +1,13 @@
=mod report;
=mod spec;
=
+use anyhow::{Context, Error, bail};
=use clap::Parser;
=use env_logger;
=use glob::glob;
=use log;
=use report::EvaluationReport;
=use spec::Spec;
-use std::error::Error;
=use std::path::PathBuf;
=
=#[derive(Parser)]
@@ -22,7 +22,7 @@ struct Cli {
=    verbose: bool,
=}
=
-fn main() -> Result<(), Box<dyn Error>> {
+fn main() -> Result<(), Error> {
=    let cli = Cli::parse();
=    let log_env = env_logger::Env::default()
=        .filter_or("RUST_LOG", if cli.verbose { "trace" } else { "info" });
@@ -41,15 +41,20 @@ fn main() -> Result<(), Box<dyn Error>> {
=        let pattern = format!("{}/**/*.md", input.display());
=
=        for path in glob(&pattern)? {
-            let md = std::fs::read_to_string(path?)?;
-            spec.load_document(&md)?;
+            let path = path.context(format!("resolving {pattern}"))?;
+            let md = std::fs::read_to_string(&path)
+                .context(format!("reading a file at {}", path.display()))?;
+            spec.load_document(&md)
+                .context(format!("loading a document from {}", path.display()))?;
=        }
=    } else if input.is_file() {
=        log::debug!("The {} is a file. Reading...", input.display());
-        let md = std::fs::read_to_string(input)?;
-        spec.load_document(&md)?;
+        let md = std::fs::read_to_string(&input)
+            .context(format!("reading a file at {}", input.display()))?;
+        spec.load_document(&md)
+            .context(format!("loading a document from {}", input.display()))?;
=    } else {
-        return Err(format!("The {} is neither a file nor directory", input.display()).into());
+        bail!("The {} is neither a file nor directory", input.display());
=    };
=
=    log::info!("Collected {} suites.", spec.suites.len());
index a187408..5ccc2b2 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -1,6 +1,7 @@
=use crate::spec::{Scenario, Spec, Step, Suite};
-use anyhow::anyhow;
+use anyhow::{Context, anyhow};
=use colored::Colorize;
+use indoc::formatdoc;
=use serde::{Deserialize, Serialize};
=use std::fmt::Display;
=use std::io::{BufRead, BufReader, LineWriter, Write};
@@ -41,7 +42,9 @@ impl<'a> SuiteReport<'a> {
=        log::debug!("Evaluating suite {}", self.suite.title);
=
=        for scenario in self.scenarios.iter_mut() {
-            let result = scenario.run(&self.suite.interpreter, verbose);
+            let result = scenario
+                .run(&self.suite.interpreter, verbose)
+                .context(format!("running scenario: {}", scenario.scenario.title));
=            if let Err(error) = result {
=                scenario.status = ScenarioStatus::FailedToRun { error }
=            } else {
@@ -73,18 +76,19 @@ impl<'a> ScenarioReport<'a> {
=                "tbb_interpreter_log",
=                if verbose { "debug" } else { "info" },
=            )
-            .spawn()?;
+            .spawn()
+            .context(format!("spawning interpreter from {interpreter}"))?;
=
=        let Some(input) = process.stdin.take() else {
=            // TODO: Avoid string errors!
=            return Err(anyhow!(
-                "Failed to take the stdin stream of the interpreter process."
+                "failed to take the stdin stream of the interpreter process"
=            ));
=        };
=
=        let Some(output) = process.stdout.take() else {
=            return Err(anyhow!(
-                "Failed to take the stdout stream of the interpreter process."
+                "failed to take the stdout stream of the interpreter process"
=            ));
=        };
=
@@ -92,11 +96,11 @@ impl<'a> ScenarioReport<'a> {
=        let mut writer = LineWriter::new(input);
=
=        loop {
-            let ready = match Self::receive(&mut reader)? {
+            let ready = match Self::receive(&mut reader).context("awaiting for the interpreter")? {
=                InterpreterMessage::InterpreterState { ready } => ready,
=                message => {
=                    return Err(anyhow!(
-                        "Unexpected message received from the interpreter: {message:#?}"
+                        "unexpected message received from the interpreter: {message:#?}"
=                    ));
=                }
=            };
@@ -139,7 +143,7 @@ impl<'a> ScenarioReport<'a> {
=                }
=                unexpected => {
=                    return Err(anyhow!(
-                        "Unexpected message received from the interpreter: {unexpected:#?}"
+                        "unexpected message received from the interpreter: {unexpected:#?}"
=                    ));
=                }
=            };
@@ -149,15 +153,20 @@ impl<'a> ScenarioReport<'a> {
=
=        process
=            .wait()
-            .map_err(|io_error| { anyhow::Error::from(io_error) })
+            .map_err(|io_error| anyhow::Error::from(io_error))
=            .and_then(|exit_status| {
=                log::debug!("Interpreter closed with exit status {exit_status:?}");
=                let exit_code = exit_status.code().unwrap_or(0); // Is that right?
=                if exit_code == 0 {
=                    Ok(())
=                } else {
-                    Err(anyhow!("Interpreter process {interpreter} exited abnormally. Exit code: {exit_code}"))
-                 }
+                    Err(anyhow!(formatdoc! {r#"
+                        interpreter process  exited abnormally
+
+                        Interpreter: {interpreter}
+                        Exit code: {exit_code}"
+                    "#}))
+                }
=            })
=    }
=
@@ -193,7 +202,7 @@ impl<'a> ScenarioReport<'a> {
=    fn read_line(reader: &mut BufReader<std::process::ChildStdout>) -> anyhow::Result<String> {
=        let mut buffer = String::new();
=        if reader.read_line(&mut buffer)? == 0 {
-            return Err(anyhow!("Can't read from the interpreter process."));
+            return Err(anyhow!("can't read from the interpreter process"));
=        };
=        Ok(buffer)
=    }
@@ -239,7 +248,11 @@ impl Display for EvaluationReport<'_> {
=                };
=                writeln!(f, "\n  {sigil} {title}\n", title = scenario.title)?;
=                if let ScenarioStatus::FailedToRun { error } = status {
-                    writeln!(f, "    {}\n", error.to_string().red())?;
+                    writeln!(f, "    {}\n", error.root_cause().to_string().red())?;
+                    for cause in error.chain() {
+                        writeln!(f, "      * {}", cause)?;
+                    }
+                    writeln!(f, "\n")?;
=                }
=
=                for StepReport { step, status } in steps.iter() {
index f58e607..9da0529 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -1,4 +1,5 @@
-use anyhow::anyhow;
+use anyhow::{Context, anyhow, bail};
+use indoc::indoc;
=use serde::{Deserialize, Serialize};
=use std::fmt::Display;
=
@@ -15,7 +16,7 @@ impl Spec {
=    /// Load suites from a markdown document
=    pub fn load_document(&mut self, md: &str) -> anyhow::Result<()> {
=        // TODO: Support loading multiple suits from a single document (demarcated by h1)
-        let suite = Suite::from_markdown(md)?;
+        let suite = Suite::from_markdown(md).context("loading a markdown document")?;
=
=        self.suites.push(suite);
=        Ok(())
@@ -110,11 +111,11 @@ impl Suite {
=            },
=        )
=        .map_err(|message| {
-            anyhow!("Failed to parse markdown input").context(message.to_string())
+            anyhow!("failed to parse markdown input").context(message.to_string())
=        })?;
=        log::debug!("Markdown parsed:\n\n{:#?}", mdast);
=
-        Self::try_from(mdast)
+        Self::try_from(mdast).context("extracting a suite from a markdown document")
=    }
=}
=
@@ -123,27 +124,24 @@ impl TryFrom<markdown::mdast::Node> for Suite {
=
=    fn try_from(mdast: markdown::mdast::Node) -> anyhow::Result<Self> {
=        // Find the YAML front-matter and extract the interpreter field
-        let children = mdast
-            .children()
-            .ok_or(anyhow!("The given node has no children."))?;
-        let first = children
-            .first()
-            .ok_or(anyhow!("There is no first child in the given node."))?;
+        let children = mdast.children().context("the markdown document is empty")?;
+        let first = children.first().context("the markdown document is empty")?;
=
=        let markdown::mdast::Node::Yaml(markdown::mdast::Yaml { value: yaml, .. }) = first else {
-            return Err(
-                (anyhow!("First child is not a YAML front matter.")) .context(concat!(
-                    "At the top of your markdown document you need to provide a YAML front-matter that looks something like this:\n",
-                    "\n",
-                    "---\n",
-                    "interpreter: ./my-interpreter.sh --any arguments you like\n",
-                    "---\n",
-                    "\n",
-                    "The rest of the markdown should follow.\n"
-                )),
-            );
+            bail!(indoc! {r#"
+                missing YAML front-matter
+ 
+                At the top of your markdown document you need to provide a YAML front-matter that looks something like this:
+
+                ---
+                interpreter: ./my-interpreter.sh --any arguments you like
+                ---
+
+                The rest of the markdown should follow.
+            "#});
=        };
-        let frontmatter: FrontMatter = serde_yaml::from_str(yaml)?;
+        let frontmatter: FrontMatter =
+            serde_yaml::from_str(yaml).context("Failed to parse front-matter as YAML")?;
=
=        // Find h1 and use it as title (
=        // TODO: make sure there's only one and it's before any h2
@@ -159,9 +157,15 @@ impl TryFrom<markdown::mdast::Node> for Suite {
=                }
=            })
=            .next()
-            .ok_or(anyhow!(
-                "There is no level 1 heading inside the given node."
-            ))?
+            .context(indoc! {r#"
+                no h1 heading found
+
+                Each markdown document must have at least one h1 heading, like this:
+
+                # Some important aspect of the spec
+
+                This will be used as a title of a suite.
+            "#})?
=            .to_string();
=
=        // Extract scenarios and steps
@@ -217,17 +221,13 @@ impl TryFrom<&markdown::mdast::ListItem> for Step {
=        let headline = item
=            .children
=            .first()
-            .ok_or(anyhow!(
-                "Encountered a list item without any children. Impossible?"
-            ))?
+            .context("a list without children (how is that even possible?)")?
=            .clone();
=        let mut arguments = Vec::new();
=        let mut variant_headline = headline.clone();
=        for child in variant_headline
=            .children_mut()
-            .ok_or(anyhow!(
-                "Encountered a list heading with an empty first child. Impossible?"
-            ))?
+            .context("a list heading with an empty first child (how is that even possible?)")?
=            .iter_mut()
=        {
=            if let markdown::mdast::Node::InlineCode(inline_code) = child {

Bump the patch version

On by Tad Lispy

index 177a8fb..3abc786 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -400,7 +400,7 @@ dependencies = [
=
=[[package]]
=name = "tad-better-behavior"
-version = "0.1.0"
+version = "0.1.1"
=dependencies = [
= "anyhow",
= "clap",
index 7e168bb..3601fd4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
=[package]
=name = "tad-better-behavior"
-version = "0.1.0"
+version = "0.1.1"
=edition = "2024"
=
=[dependencies]

More context for message passing errors

On by Tad Lispy

index 5ccc2b2..4eed1ef 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -175,7 +175,7 @@ impl<'a> ScenarioReport<'a> {
=        writer: &mut LineWriter<std::process::ChildStdin>,
=        message: &ControlMessage,
=    ) -> anyhow::Result<()> {
-        let json = serde_json::to_string(&message)?;
+        let json = serde_json::to_string(&message).context(format!("sending data: {message:?}"))?;
=        Self::write_line(writer, &json)
=    }
=
@@ -183,7 +183,8 @@ impl<'a> ScenarioReport<'a> {
=        reader: &mut BufReader<std::process::ChildStdout>,
=    ) -> anyhow::Result<InterpreterMessage> {
=        let buffer = Self::read_line(reader)?;
-        let message = serde_json::from_str(&buffer)?;
+        let message =
+            serde_json::from_str(&buffer).context(format!("received string: {buffer:?}"))?;
=
=        Ok(message)
=    }
@@ -195,7 +196,9 @@ impl<'a> ScenarioReport<'a> {
=        let buffer = [line, "\n"].concat();
=
=        log::debug!("Sending: {}", buffer);
-        writer.write_all(buffer.as_bytes())?;
+        writer
+            .write_all(buffer.as_bytes())
+            .context(format!("writing data: {buffer:?}"))?;
=        Ok(())
=    }
=

Bump the patch version

On by Tad Lispy

index 3abc786..922de52 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -400,7 +400,7 @@ dependencies = [
=
=[[package]]
=name = "tad-better-behavior"
-version = "0.1.1"
+version = "0.1.2"
=dependencies = [
= "anyhow",
= "clap",
index 3601fd4..b7c5dcd 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
=[package]
=name = "tad-better-behavior"
-version = "0.1.1"
+version = "0.1.2"
=edition = "2024"
=
=[dependencies]

Use Python unittest assertion for better messages

On by Tad Lispy

It has specialized functions for different assertions, and includes compared values in assertion errors.

index f5bbff3..52c10db 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -1,5 +1,6 @@
=#!/usr/bin/env python3
=
+import unittest
=import json
=import logging
=import os
@@ -84,7 +85,9 @@ def ready():
=
=# User code
=# =========
-        
+
+tester = unittest.TestCase()
+
=@step("Add {0} and {1} to get {2}")
=def add_and_verify(a, b, expected, **kwargs):
=    log.debug(f"{ a } + { b } = { expected }?")
@@ -94,7 +97,7 @@ def add_and_verify(a, b, expected, **kwargs):
=    b = float(b)
=    expected = float(expected)
=    
-    assert a + b == expected, f"{ a } + { b } = { a + b }, not { expected }!"
+    tester.assertEqual(expected, a + b)
=
=
=@step("Divide {0} by {1} to get {2}")
@@ -106,7 +109,7 @@ def divide_and_verify(a, b, expected, **kwargs):
=    b = float(b)
=    expected = float(expected)
=    
-    assert a / b == expected, f"{ a } / { b } = { a / b }, not { expected }!"
+    tester.assertAlmostEqual (expected, a / b)
=
=
=@step("The word {0} has {1} characters")
@@ -115,7 +118,7 @@ def verify_characters_count(word: str, expected_length, **kwargs):
=    expected_length = int(expected_length)
=    actual_length = len(word)
=
-    assert expected_length == actual_length, f"The word '{word}' is {actual_length} long, not {expected_length}"
+    tester.assertEqual(expected_length, actual_length)
=
=    
=

Implement automatic argument parsing in python

On by Tad Lispy

The python interpreter now can parse arguments into types indicated by step function annotation, i.e. if a function looks like this:

def fun(a: float, b):
    # ...

Then the first argument coming from tbb will be converted to float. Second parameter is not annotated, so the argument will be passed as string.

This will only work for a type with a constructor that accepts a string.

index aed3aea..e7d619c 100644
--- a/README.md
+++ b/README.md
@@ -49,6 +49,11 @@ Support for Linux, OS X and Web (WASM).
=- [ ] Use for evaluating Jewiet's Form to Mail specification
=- [ ] Helper libraries
=    - [ ] for Python
+      - [x] Proof-of-concept
+      - [x] `@step` decorator
+      - [x] Automatic arguments conversion (casting)
+      - [x] Insightful assertion errors
+      - [ ] Split library code from the basic interpreter
=    - [ ] for Clojure
=- [ ] Capture more data in reports
=  - [ ] Attachments (screenshots, videos, datasets, etc.)
index 52c10db..b472e3c 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -1,6 +1,7 @@
=#!/usr/bin/env python3
=
=import unittest
+import inspect
=import json
=import logging
=import os
@@ -65,6 +66,18 @@ def ready():
=            if implementation:
=                log.debug(f"Found an implementation of '{ variant }'")
=                try:
+                    signature = inspect.signature(implementation)
+                    log.info(f"Signature: {signature}")
+                    for index, key in enumerate(signature.parameters):
+                        parameter = signature.parameters[key]
+                        log.info(f"Processing parameter {index} ({parameter.annotation})")
+                        if parameter.annotation == inspect._empty:
+                            log.info (f"No annotation for parameter {index} ({parameter.name}). Skipping.")
+                            continue 
+
+                        log.info (f"Converting argument {index} to {parameter.annotation.__name__}")
+                        arguments[index] = parameter.annotation(arguments[index])
+
=                    implementation(*arguments, **step)
=                    send({ "type": 'Success' })
=                except Exception as error:
@@ -89,37 +102,25 @@ def ready():
=tester = unittest.TestCase()
=
=@step("Add {0} and {1} to get {2}")
-def add_and_verify(a, b, expected, **kwargs):
+def add_and_verify(a: float, b: float, expected: float, **kwargs):
=    log.debug(f"{ a } + { b } = { expected }?")
=
-    # TODO: Can we do this conversion automatically based on typing information?
-    a = float(a)
-    b = float(b)
-    expected = float(expected)
-    
=    tester.assertEqual(expected, a + b)
=
=
=@step("Divide {0} by {1} to get {2}")
-def divide_and_verify(a, b, expected, **kwargs):
+def divide_and_verify(a: float, b: float, expected: float, **kwargs):
=    log.debug(f"{ a } / { b } = { expected }?")
-
-    # TODO: Can we do this conversion automatically based on typing information?
-    a = float(a)
-    b = float(b)
-    expected = float(expected)
=    
=    tester.assertAlmostEqual (expected, a / b)
=
=
=@step("The word {0} has {1} characters")
-def verify_characters_count(word: str, expected_length, **kwargs):
-    log.info(word)
-    expected_length = int(expected_length)
+def verify_characters_count(word, expected_length: int, **kwargs):
+    log.info(f"Is '{word}' of length {expected_length}?")
=    actual_length = len(word)
=
=    tester.assertEqual(expected_length, actual_length)
=
-    
=  
=ready()

Update the roadmap

On by Tad Lispy

index e7d619c..142bd8a 100644
--- a/README.md
+++ b/README.md
@@ -39,6 +39,7 @@ Support for Linux, OS X and Web (WASM).
=  - [ ] A summary at the bottom (esp. list of errors)
=  - [x] Use colors
=  - [ ] More instructive error messages
+  - [ ] Indent multiline error messages
=- [ ] Pass more step data to interpreters
=    - [ ] Code blocks
=    - [ ] Lists

Let basic.py chill with the logging

On by Tad Lispy

index b472e3c..08a839a 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -67,15 +67,15 @@ def ready():
=                log.debug(f"Found an implementation of '{ variant }'")
=                try:
=                    signature = inspect.signature(implementation)
-                    log.info(f"Signature: {signature}")
+                    log.debug(f"Signature: {signature}")
=                    for index, key in enumerate(signature.parameters):
=                        parameter = signature.parameters[key]
-                        log.info(f"Processing parameter {index} ({parameter.annotation})")
+                        log.debug(f"Processing parameter {index} ({parameter.annotation})")
=                        if parameter.annotation == inspect._empty:
-                            log.info (f"No annotation for parameter {index} ({parameter.name}). Skipping.")
+                            log.debug (f"No annotation for parameter {index} ({parameter.name}). Skipping.")
=                            continue 
=
-                        log.info (f"Converting argument {index} to {parameter.annotation.__name__}")
+                        log.debug (f"Converting argument {index} to {parameter.annotation.__name__}")
=                        arguments[index] = parameter.annotation(arguments[index])
=
=                    implementation(*arguments, **step)
@@ -117,7 +117,7 @@ def divide_and_verify(a: float, b: float, expected: float, **kwargs):
=
=@step("The word {0} has {1} characters")
=def verify_characters_count(word, expected_length: int, **kwargs):
-    log.info(f"Is '{word}' of length {expected_length}?")
+    log.debug(f"Is '{word}' of length {expected_length}?")
=    actual_length = len(word)
=
=    tester.assertEqual(expected_length, actual_length)

Implement code block passing

On by Tad Lispy

Now interpreters can access code blocks at .step.code_blocks.

index 101d5af..c347baa 100644
--- a/samples/basic.md
+++ b/samples/basic.md
@@ -23,6 +23,18 @@ Content outside of bullet points (like this) won't have any effect on the progra
=## Text
=
=  * The word `blocks` has `6` characters
+  * There are `3` properties in the following JSON
+  
+    This step includes a code block. All code blocks, together with their (optional) language and metadata (text following the language on the same line) will be passed to interpreters as `step.code_blocks`. It's an array, so you can have multiple blocks.
+
+    ``` json
+    {
+        "one": "Hello",
+        "two": [ 1, 2, 1, 2 ],
+        "three": null
+    }
+    ```
+
=  * There are `3` `r`s in the word `strawberry`
=  * The reverse of `abc` is `cba`
=  * The reverse of `CIA` is `KGB`
index 08a839a..f5a28bd 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -122,5 +122,13 @@ def verify_characters_count(word, expected_length: int, **kwargs):
=
=    tester.assertEqual(expected_length, actual_length)
=
-  
+
+@step("There are {0} properties in the following JSON")
+def count_json_properties(expected_count: int, **kwargs):
+    code = kwargs['code_blocks'][0]['value']
+    log.debug(f"Are there {expected_count} properties in the following?\n{code}")
+    data = json.loads(code)
+    tester.assertEqual(expected_count, len(data))
+
+
=ready()
index 9da0529..8788fab 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -91,6 +91,26 @@ pub struct Step {
=
=    /// Values of the arguments
=    pub arguments: Vec<String>,
+
+    /// List of code blocks
+    pub code_blocks: Vec<CodeBlock>,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct CodeBlock {
+    value: String,
+    language: Option<String>,
+    meta: Option<String>,
+}
+
+impl From<&markdown::mdast::Code> for CodeBlock {
+    fn from(code: &markdown::mdast::Code) -> Self {
+        Self {
+            language: code.lang.to_owned(),
+            value: code.value.to_owned(),
+            meta: code.meta.to_owned(),
+        }
+    }
=}
=
=#[derive(Deserialize)]
@@ -242,11 +262,29 @@ impl TryFrom<&markdown::mdast::ListItem> for Step {
=        }
=
=        // TODO: Extract list, tables, code blocks etc. from subsequent children of the list item
+        let mut code_blocks = vec![];
+
+        for child in item.children.iter() {
+            match child {
+                markdown::mdast::Node::Code(code) => {
+                    log::info!(
+                        "Found a code block: {language} ({meta})",
+                        language = code.lang.as_deref().unwrap_or("unspecified"),
+                        meta = code.meta.as_deref().unwrap_or_default(),
+                    );
+
+                    code_blocks.push(CodeBlock::from(code))
+                }
+
+                _ => continue,
+            }
+        }
=
=        Ok(Self {
=            description: headline.to_string(),
=            variant: variant_headline.to_string(),
=            arguments,
+            code_blocks,
=        })
=    }
=}

Allow interpreters to hint about failed steps

On by Tad Lispy

The hint is an optional field of the Failure message. It is supposed to provide advice to user about a potential solution to a problem. If present, it will be printed below the reason.

An example of hint is in the basic.py interpreter.

index f5a28bd..5b9f78a 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -1,12 +1,15 @@
=#!/usr/bin/env python3
=
-import unittest
=import inspect
=import json
=import logging
=import os
+import re
+import unittest
+from textwrap import dedent
=from typing import Any, Callable
=
+
=# Library code
=# ============
=
@@ -84,10 +87,26 @@ def ready():
=                    send({ "type": "Failure", "reason": str(error) })
=
=            else:
+                # Generate helpful hint
+                indices = re.findall(r"\{(\d)\}", variant)
+                params = map(lambda index: f"arg_{index}: str", indices)
+                name = f"step_implementation_{len(steps_implementation):02}"
+
+                hint = dedent(f"""
+                To get started, place the following in {__file__}:
+
+                ``` python
+                @step("{variant}")
+                def {name}({str.join(", ", params)}, **kwargs):
+                    assert "cat" == "dog", "Obviously you need to replace this!"
+                ```
+                """)
+
=                log.warning(f"Not implemented: {variant}")
=                send({
=                    "type": 'Failure',
-                    'reason': 'Step not implemented'
+                    "reason": "step not implemented",
+                    "hint": hint
=                })
=
=        except EOFError:
index 4eed1ef..99d105a 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -128,9 +128,9 @@ impl<'a> ScenarioReport<'a> {
=                    log::debug!("Step executed successfully: {step:#?}");
=                    *status = StepStatus::Ok;
=                }
-                InterpreterMessage::Failure { reason } => {
+                InterpreterMessage::Failure { reason, hint } => {
=                    log::debug!("Step failed:\n\n {step:#?} \n\n {reason:#?}");
-                    *status = StepStatus::Failed { reason };
+                    *status = StepStatus::Failed { reason, hint };
=
=                    // Do not run subsequent steps.
=                    //
@@ -218,7 +218,10 @@ pub struct StepReport<'a> {
=
=pub enum StepStatus {
=    Ok,
-    Failed { reason: String },
+    Failed {
+        reason: String,
+        hint: Option<String>,
+    },
=    NotEvaluated,
=}
=
@@ -271,8 +274,12 @@ impl Display for EvaluationReport<'_> {
=                        arguments = step.arguments
=                    )?;
=
-                    if let StepStatus::Failed { reason } = status {
+                    if let StepStatus::Failed { reason, hint } = status {
=                        writeln!(f, "\n      {}\n", reason.red())?;
+                        if let Some(hint) = hint {
+                            // TODO: Indent the hint properly
+                            writeln!(f, "\n\n{}\n", hint)?;
+                        }
=                    }
=                }
=            }
@@ -314,9 +321,14 @@ impl<'a> From<&'a Step> for StepReport<'a> {
=#[derive(Deserialize, Serialize, Debug)]
=#[serde(tag = "type")]
=pub enum InterpreterMessage {
-    InterpreterState { ready: bool },
+    InterpreterState {
+        ready: bool,
+    },
=    Success,
-    Failure { reason: String },
+    Failure {
+        reason: String,
+        hint: Option<String>,
+    },
=}
=
=/// Messages from this (control) program to an interpreter

Implement the remaining step variants in basic.py

On by Tad Lispy

Only the CIA <-> KGB step fails now, but that's intentional.

index 5b9f78a..484bdc3 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -149,5 +149,19 @@ def count_json_properties(expected_count: int, **kwargs):
=    data = json.loads(code)
=    tester.assertEqual(expected_count, len(data))
=
+@step("Subtract {0} from {1} to get {2}")
+def step_implementation_04(a: float, b: float, expected: float, **kwargs):
+    tester.assertAlmostEqual(expected, b - a) 
+
+
+@step("There are {0} {1}s in the word {2}")
+def step_implementation_05(count: int, letter: str, word: str, **kwargs):
+    actual_count = word.count(letter)
+    tester.assertEqual(count, actual_count)
+
+@step("The reverse of {0} is {1}")
+def step_implementation_06(word: str, reverse: str, **kwargs):
+    actual_reverse = word[::-1]
+    tester.assertEqual(reverse, actual_reverse)
=
=ready()

Improve report indentation

On by Tad Lispy

Make sure that multiline strings are indented correctly.

For this, I made the Indentable trait and implemented it for &str.

Also, the arguments are now printed in a dimmed style.

new file mode 100644
index 0000000..f655e1d
--- /dev/null
+++ b/src/indentable.rs
@@ -0,0 +1,120 @@
+pub trait Indentable {
+    fn indent(self, width: usize) -> String;
+}
+
+impl Indentable for &str {
+    fn indent(self, width: usize) -> String {
+        let spaces = " ".repeat(width);
+        self.replace("\r\n", "\n")
+            .split("\n")
+            .map(|line| spaces.clone() + line)
+            .reduce(|accu, line| accu + "\n" + &line)
+            .unwrap_or(spaces)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_single_line() {
+        let input = "hello";
+        let expected = "    hello";
+        // 4 spaces
+
+        assert_eq!(input.indent(4), expected);
+    }
+
+    #[test]
+    fn test_single_line_2() {
+        let input = "hello";
+        let expected = "  hello";
+        // 2 spaces
+
+        assert_eq!(input.indent(2), expected);
+    }
+
+    #[test]
+    fn test_multiple_lines() {
+        let input = "hello\nworld";
+        let expected = "  hello\n  world";
+
+        assert_eq!(input.indent(2), expected);
+    }
+
+    #[test]
+    fn test_multiple_lines_with_cariege_return() {
+        let input = "hello\r\nworld\r\nand\r\nmoon";
+        let expected = "  hello\n  world\n  and\n  moon";
+        // \r\n will be replaced with \n
+
+        assert_eq!(input.indent(2), expected);
+    }
+
+    #[test]
+    fn test_empty_string() {
+        let input = "";
+        let expected = "    ";
+        // 4 spaces
+
+        assert_eq!(input.indent(4), expected);
+    }
+
+    #[test]
+    fn test_leading_line() {
+        let input = "\nhello";
+        let expected = "    \n    hello";
+        // 4 spaces
+
+        assert_eq!(input.indent(4), expected);
+    }
+
+    #[test]
+    fn test_trailing_newline() {
+        let input = "hello\n";
+        let expected = "    hello\n    ";
+
+        assert_eq!(input.indent(4), expected);
+    }
+
+    #[test]
+    fn test_multiple_empty_lines() {
+        let input = "\n\n\n";
+        let expected = "    \n    \n    \n    ";
+        // four lines each containing four spaces
+
+        assert_eq!(input.indent(4), expected);
+    }
+
+    #[test]
+    fn test_whitespace_in_content() {
+        let input = "  leading spaces  \n   trailing spaces ";
+        let expected = "      leading spaces  \n       trailing spaces ";
+        // leading and trailing spaces will be preserved, so:
+        // line 1: 4 (indentation) + 2 (original) leading spaces
+        //         2 trailing spaces untouched
+        // line 2: 4 + 3 leading spaces
+        //         1 trailing space
+        //
+
+        assert_eq!(input.indent(4), expected);
+    }
+
+    #[test]
+    fn test_large_indent() {
+        let input = "a";
+        let expected = "                                        a";
+        // 40 spaces
+
+        assert_eq!(input.indent(40), expected);
+    }
+
+    #[test]
+    fn test_no_newline_in_string() {
+        let input = "abc";
+        let expected = "    abc";
+
+        assert_eq!(input.indent(4), expected);
+    }
+}
index 1e3dab7..b539e3c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,4 @@
+mod indentable;
=mod report;
=mod spec;
=
index 99d105a..b3df963 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -1,3 +1,4 @@
+use crate::indentable::Indentable;
=use crate::spec::{Scenario, Spec, Step, Suite};
=use anyhow::{Context, anyhow};
=use colored::Colorize;
@@ -252,11 +253,20 @@ impl Display for EvaluationReport<'_> {
=                    ScenarioStatus::Pending => "?".to_string().bold().dimmed(),
=                    ScenarioStatus::FailedToRun { .. } => "x".to_string().bold().red(),
=                };
-                writeln!(f, "\n  {sigil} {title}\n", title = scenario.title)?;
+                writeln!(
+                    f,
+                    "\n{indentation}{sigil} {title}\n",
+                    indentation = "".indent(0),
+                    title = scenario.title
+                )?;
=                if let ScenarioStatus::FailedToRun { error } = status {
-                    writeln!(f, "    {}\n", error.root_cause().to_string().red())?;
+                    writeln!(
+                        f,
+                        "{cause}\n",
+                        cause = error.root_cause().to_string().red().indent(2)
+                    )?;
=                    for cause in error.chain() {
-                        writeln!(f, "      * {}", cause)?;
+                        writeln!(f, "  * {}", cause.to_string().indent(2))?
=                    }
=                    writeln!(f, "\n")?;
=                }
@@ -269,16 +279,16 @@ impl Display for EvaluationReport<'_> {
=                    };
=                    writeln!(
=                        f,
-                        "    {sigil} {description} {arguments:?}",
+                        "{indentation}{sigil} {description} {arguments}",
+                        indentation = "".indent(2),
=                        description = step.description,
-                        arguments = step.arguments
+                        arguments = format!("{:?}", step.arguments).dimmed()
=                    )?;
=
=                    if let StepStatus::Failed { reason, hint } = status {
-                        writeln!(f, "\n      {}\n", reason.red())?;
+                        writeln!(f, "\n{}\n", reason.indent(4).red())?;
=                        if let Some(hint) = hint {
-                            // TODO: Indent the hint properly
-                            writeln!(f, "\n\n{}\n", hint)?;
+                            writeln!(f, "{}\n", hint.as_str().indent(4))?;
=                        }
=                    }
=                }

Bump minor version

On by Tad Lispy

To celebrate the code blocks feature.

index 922de52..c2e992f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -400,7 +400,7 @@ dependencies = [
=
=[[package]]
=name = "tad-better-behavior"
-version = "0.1.2"
+version = "0.2.0"
=dependencies = [
= "anyhow",
= "clap",
index b7c5dcd..9297cf6 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
=[package]
=name = "tad-better-behavior"
-version = "0.1.2"
+version = "0.2.0"
=edition = "2024"
=
=[dependencies]

Fix Python interpreter: leading coma when 0 args

On by Tad Lispy

When giving a hint to implement a step with no parameters, the generated code would contain a leading coma, like this:

@step("The following table maps words to their length")
def step_implementation_07(, **kwargs):
    assert "cat" == "dog", "Obviously you need to replace this!"

Now it's smarter.

index 484bdc3..594dc1a 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -89,7 +89,7 @@ def ready():
=            else:
=                # Generate helpful hint
=                indices = re.findall(r"\{(\d)\}", variant)
-                params = map(lambda index: f"arg_{index}: str", indices)
+                params = list(map(lambda index: f"arg_{index}: str", indices))
=                name = f"step_implementation_{len(steps_implementation):02}"
=
=                hint = dedent(f"""
@@ -97,7 +97,7 @@ def ready():
=
=                ``` python
=                @step("{variant}")
-                def {name}({str.join(", ", params)}, **kwargs):
+                def {name}({str.join(", ", params + [ "**kwargs" ])}):
=                    assert "cat" == "dog", "Obviously you need to replace this!"
=                ```
=                """)

Implement support for tables

On by Tad Lispy

index c347baa..6536f82 100644
--- a/samples/basic.md
+++ b/samples/basic.md
@@ -36,6 +36,32 @@ Content outside of bullet points (like this) won't have any effect on the progra
=    ```
=
=  * There are `3` `r`s in the word `strawberry`
+  * The following table maps words to their lengths
+  
+    | word   | length |
+    |--------|--------|
+    | cat    | 3      |
+    | stork  | 5      |
+    | rabbit | 6      |
+    | snake  | 5      |
+    | minx   | 4      |
+
+    Any table within a step will be passed to the interpreter as a 2d array, like so:
+    
+    ``` json
+    [
+        [ "word"   , "length" ],
+        [ "cat"    , "3"      ],
+        [ "stork"  , "5"      ],
+        [ "rabbit" , "6"      ],
+        [ "snake"  , "5"      ],
+        [ "minx"   , "4"      ]
+    ]
+    ```
+
+    Note that heading row is not treated in any special way, and all cells are strings. It's up to an interpreter to deal with it.
+   
+   
=  * The reverse of `abc` is `cba`
=  * The reverse of `CIA` is `KGB`
=
index 594dc1a..4d16408 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -164,4 +164,13 @@ def step_implementation_06(word: str, reverse: str, **kwargs):
=    actual_reverse = word[::-1]
=    tester.assertEqual(reverse, actual_reverse)
=
+@step("The following table maps words to their lengths")
+def step_implementation_07(**kwargs):
+    for table in kwargs["tables"]:
+        # Skip the first row - it's a heading
+        for [word, length] in table[1:]:
+            actual_length = len(word)
+            tester.assertEqual(actual_length, int(length), f"the length of {word=}")
+
+
=ready()
index 8788fab..550d8c0 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -94,6 +94,9 @@ pub struct Step {
=
=    /// List of code blocks
=    pub code_blocks: Vec<CodeBlock>,
+
+    /// List of tables
+    pub tables: Vec<Table>,
=}
=
=#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -113,6 +116,39 @@ impl From<&markdown::mdast::Code> for CodeBlock {
=    }
=}
=
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct Table(Vec<Vec<String>>);
+
+impl From<&markdown::mdast::Table> for Table {
+    fn from(table: &markdown::mdast::Table) -> Self {
+        let rows: Vec<Vec<String>> = table
+            .children
+            .iter()
+            .filter_map(|child| {
+                let markdown::mdast::Node::TableRow(_) = child else {
+                    return None;
+                };
+                let rows: Vec<String> = child
+                    .children()
+                    .cloned()
+                    .unwrap_or_default()
+                    .iter()
+                    .filter_map(|grandchild| {
+                        let markdown::mdast::Node::TableCell(_) = grandchild else {
+                            return None;
+                        };
+                        grandchild.to_string().into()
+                    })
+                    .collect();
+
+                Some(rows)
+            })
+            .collect();
+
+        Self(rows)
+    }
+}
+
=#[derive(Deserialize)]
=pub struct FrontMatter {
=    pub interpreter: String,
@@ -125,6 +161,7 @@ impl Suite {
=            &markdown::ParseOptions {
=                constructs: markdown::Constructs {
=                    frontmatter: true,
+                    gfm_table: true,
=                    ..Default::default()
=                },
=                ..Default::default()
@@ -263,6 +300,7 @@ impl TryFrom<&markdown::mdast::ListItem> for Step {
=
=        // TODO: Extract list, tables, code blocks etc. from subsequent children of the list item
=        let mut code_blocks = vec![];
+        let mut tables = vec![];
=
=        for child in item.children.iter() {
=            match child {
@@ -276,6 +314,11 @@ impl TryFrom<&markdown::mdast::ListItem> for Step {
=                    code_blocks.push(CodeBlock::from(code))
=                }
=
+                markdown::mdast::Node::Table(table) => {
+                    log::info!("Found a table");
+                    tables.push(Table::from(table));
+                }
+
=                _ => continue,
=            }
=        }
@@ -285,6 +328,7 @@ impl TryFrom<&markdown::mdast::ListItem> for Step {
=            variant: variant_headline.to_string(),
=            arguments,
=            code_blocks,
+            tables,
=        })
=    }
=}

Silence some noisy logging

On by Tad Lispy

index 550d8c0..5bf24d7 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -305,7 +305,7 @@ impl TryFrom<&markdown::mdast::ListItem> for Step {
=        for child in item.children.iter() {
=            match child {
=                markdown::mdast::Node::Code(code) => {
-                    log::info!(
+                    log::debug!(
=                        "Found a code block: {language} ({meta})",
=                        language = code.lang.as_deref().unwrap_or("unspecified"),
=                        meta = code.meta.as_deref().unwrap_or_default(),
@@ -315,7 +315,7 @@ impl TryFrom<&markdown::mdast::ListItem> for Step {
=                }
=
=                markdown::mdast::Node::Table(table) => {
-                    log::info!("Found a table");
+                    log::debug!("Found a table");
=                    tables.push(Table::from(table));
=                }
=

Update the roadmap

On by Tad Lispy

index 142bd8a..bc73be3 100644
--- a/README.md
+++ b/README.md
@@ -39,13 +39,14 @@ Support for Linux, OS X and Web (WASM).
=  - [ ] A summary at the bottom (esp. list of errors)
=  - [x] Use colors
=  - [ ] More instructive error messages
-  - [ ] Indent multiline error messages
+  - [x] Indent multiline error messages
=- [ ] Pass more step data to interpreters
-    - [ ] Code blocks
+    - [x] Code blocks
+    - [x] Tables
=    - [ ] Lists
-    - [ ] Tables
=    - [ ] Definition lists
=    - [ ] Original markdown fragment
+    - [ ] Block quotes
=- [x] Nix package (from Flake)
=- [ ] Use for evaluating Jewiet's Form to Mail specification
=- [ ] Helper libraries
@@ -56,6 +57,7 @@ Support for Linux, OS X and Web (WASM).
=      - [x] Insightful assertion errors
=      - [ ] Split library code from the basic interpreter
=    - [ ] for Clojure
+    - [ ] For POSIX shells
=- [ ] Capture more data in reports
=  - [ ] Attachments (screenshots, videos, datasets, etc.)
=  - [ ] Performance data (interpreters' startup times, steps' durations)

Bump the minor version

On by Tad Lispy

To celebrate tables support.

index c2e992f..a4b37ac 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -400,7 +400,7 @@ dependencies = [
=
=[[package]]
=name = "tad-better-behavior"
-version = "0.2.0"
+version = "0.3.0"
=dependencies = [
= "anyhow",
= "clap",
index 9297cf6..32ebb5f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
=[package]
=name = "tad-better-behavior"
-version = "0.2.0"
+version = "0.3.0"
=edition = "2024"
=
=[dependencies]

Promise to support BSD

On by Tad Lispy

For Daniel.

index bc73be3..3f727ab 100644
--- a/README.md
+++ b/README.md
@@ -26,7 +26,7 @@ and even the original markdown fragment itself.
=
=## Cross platform
=
-Support for Linux, OS X and Web (WASM).
+Support for Linux, BSD, OS X and Web (WASM).
=
=
=# Roadmap

Separate library code from basic.py to tbb.py

On by Tad Lispy

Write docstrings for public functions (register_step, step and ready).

index 4d16408..83e57d1 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -1,123 +1,13 @@
=#!/usr/bin/env python3
=
-import inspect
=import json
-import logging
-import os
-import re
=import unittest
-from textwrap import dedent
-from typing import Any, Callable
-
-
-# Library code
-# ============
-
-# Setup logging
-log_level = os.environ.get("tbb_interpreter_log", "warn").upper()
-logging.basicConfig(level=getattr(logging, log_level))
-log = logging.getLogger("basic_interpreter")
-
-def send(message):
-    serialized = json.dumps(message)
-    log.debug("Sending: %s", serialized)
-    print (serialized)
-
-# This will hold all step implementations
-steps_implementation = dict()
-
-# A helper to register a step implementation
-def register_step(variant: str, implementation: Callable[..., Any]):
-    log.debug("Registering step implementation for '%s'", variant)
-    steps_implementation[variant] = implementation
-
-# A decorator to register a step implementation
-def step(variant: str) -> Callable[..., Any]:
-    def decorator(implementation: Callable[..., Any]) -> Callable[..., Any]:
-        register_step(variant, implementation)
-        
-        # We don't really do anything to the implementation itself
-        return implementation
-
-    return decorator
-
-# Call this when ready, i.e. all implementations are registered
-def ready():
-    send({
-        "type": "InterpreterState",
-        "ready": True
-    })
-
-    # Loop over input
-    while True:
-        log.debug("Awaiting message...")
-
-        try:
-            received = input()
-            log.debug("Received: %s", received)
-            message = json.loads(received)
-            if not message["type"] == "Execute":
-                log.warning(f"Unexpected message received from the control program. Expected Execute type message, got {received}")
-                continue
-
-            step = message["step"] # The only message variant we expect
-            variant = step.get("variant")
-            arguments = step.get("arguments")
-            log.debug(f"Looking for implementation of '{ variant }'")
-            implementation = steps_implementation.get(variant)
-
-            if implementation:
-                log.debug(f"Found an implementation of '{ variant }'")
-                try:
-                    signature = inspect.signature(implementation)
-                    log.debug(f"Signature: {signature}")
-                    for index, key in enumerate(signature.parameters):
-                        parameter = signature.parameters[key]
-                        log.debug(f"Processing parameter {index} ({parameter.annotation})")
-                        if parameter.annotation == inspect._empty:
-                            log.debug (f"No annotation for parameter {index} ({parameter.name}). Skipping.")
-                            continue 
-
-                        log.debug (f"Converting argument {index} to {parameter.annotation.__name__}")
-                        arguments[index] = parameter.annotation(arguments[index])
-
-                    implementation(*arguments, **step)
-                    send({ "type": 'Success' })
-                except Exception as error:
-                    send({ "type": "Failure", "reason": str(error) })
-
-            else:
-                # Generate helpful hint
-                indices = re.findall(r"\{(\d)\}", variant)
-                params = list(map(lambda index: f"arg_{index}: str", indices))
-                name = f"step_implementation_{len(steps_implementation):02}"
-
-                hint = dedent(f"""
-                To get started, place the following in {__file__}:
-
-                ``` python
-                @step("{variant}")
-                def {name}({str.join(", ", params + [ "**kwargs" ])}):
-                    assert "cat" == "dog", "Obviously you need to replace this!"
-                ```
-                """)
-
-                log.warning(f"Not implemented: {variant}")
-                send({
-                    "type": 'Failure',
-                    "reason": "step not implemented",
-                    "hint": hint
-                })
-
-        except EOFError:
-            # The control program closed the stream.
-            # Most likely it indicates the end of scenario.
-            break
-
-
-# User code
-# =========
=
+import tbb
+from tbb import step, log
+
+
+# Nice assertions with helpful error messages
=tester = unittest.TestCase()
=
=@step("Add {0} and {1} to get {2}")
@@ -173,4 +63,4 @@ def step_implementation_07(**kwargs):
=            tester.assertEqual(actual_length, int(length), f"the length of {word=}")
=
=
-ready()
+tbb.ready()
new file mode 100644
index 0000000..0cbb852
--- /dev/null
+++ b/samples/tbb.py
@@ -0,0 +1,160 @@
+"""Reusable code useful to implement Python TBB interpreters.
+
+See `basic.py` for example.
+"""
+
+import inspect
+import json
+import logging
+import os
+import re
+from textwrap import dedent
+from typing import Any, Callable
+
+
+# Setup logging
+log_level = os.environ.get("tbb_interpreter_log", "warn").upper()
+logging.basicConfig(level=getattr(logging, log_level))
+log = logging.getLogger("basic_interpreter")
+
+def send(message):
+    serialized = json.dumps(message)
+    log.debug("Sending: %s", serialized)
+    print (serialized)
+
+# This will hold all step implementations
+steps_implementation = dict()
+
+def register_step(variant: str, implementation: Callable[..., Any]):
+    """A helper to register a step implementation
+
+    You probably want to use @step decorator instead.
+    """
+    log.debug("Registering step implementation for '%s'", variant)
+    steps_implementation[variant] = implementation
+
+def step(variant: str) -> Callable[..., Any]:
+    """ A decorator to register a step implementation
+
+    Each step has a variant, which is the text from the first line of the list
+    item, with arguments removed, e.g.
+
+    ``` markdown
+    * Make `coffee` for `2` people
+    ```
+
+    has a variant of `Make {0} for {1} people`. You can register an
+    implementation for this variant like so:
+
+    ``` python
+    @step("Make {0} for {1} people")
+    def serve(what, patron_count: int):
+        # Do what you got to do...
+    ```
+
+    We called this function serve, because another step my be:
+
+
+    ``` markdown
+    * Make `cheesecake` for `8` people
+    ```
+
+    The two steps will share the variant, and thus the same implementation.
+    Notice that our function has type annotation on the second argument
+    (patron_count), but not the first Notice that our function has type
+    annotation on the second argument (`patron_count`), but not the first.
+    That's because by default all arguments will be passed as `str`. If you
+    provide an annotation, the value will be parsed from that string.
+
+    BTW, this decorator is a thin wrapper around the `register_step` function.
+    You can use that instead, for example if you don't like decorators.
+    """
+    def decorator(implementation: Callable[..., Any]) -> Callable[..., Any]:
+        register_step(variant, implementation)
+        
+        # We don't really do anything to the implementation itself
+        return implementation
+
+    return decorator
+
+def ready():
+    """Call this when your interpreter is ready.
+
+    Before calling this function make sure all step implementations are
+    registered (using the `@step` decorator) and everything is set up (e.g. you
+    might want to setup some initial state, start some processes, etc).
+
+    This function will block until the spec is evaluated. Put all the cleanup
+    code (e.g. close any process previously started) after this function call.
+    """
+
+    send({
+        "type": "InterpreterState",
+        "ready": True
+    })
+
+    # Loop over input
+    while True:
+        log.debug("Awaiting message...")
+
+        try:
+            received = input()
+            log.debug("Received: %s", received)
+            message = json.loads(received)
+            if not message["type"] == "Execute":
+                log.warning(f"Unexpected message received from the control program. Expected Execute type message, got {received}")
+                continue
+
+            step = message["step"] # The only message variant we expect
+            variant = step.get("variant")
+            arguments = step.get("arguments")
+            log.debug(f"Looking for implementation of '{ variant }'")
+            implementation = steps_implementation.get(variant)
+
+            if implementation:
+                log.debug(f"Found an implementation of '{ variant }'")
+                try:
+                    signature = inspect.signature(implementation)
+                    log.debug(f"Signature: {signature}")
+                    for index, key in enumerate(signature.parameters):
+                        parameter = signature.parameters[key]
+                        log.debug(f"Processing parameter {index} ({parameter.annotation})")
+                        if parameter.annotation == inspect._empty:
+                            log.debug (f"No annotation for parameter {index} ({parameter.name}). Skipping.")
+                            continue 
+
+                        log.debug (f"Converting argument {index} to {parameter.annotation.__name__}")
+                        arguments[index] = parameter.annotation(arguments[index])
+
+                    implementation(*arguments, **step)
+                    send({ "type": 'Success' })
+                except Exception as error:
+                    send({ "type": "Failure", "reason": str(error) })
+
+            else:
+                # Generate helpful hint
+                indices = re.findall(r"\{(\d)\}", variant)
+                params = list(map(lambda index: f"arg_{index}: str", indices))
+                name = f"step_implementation_{len(steps_implementation):02}"
+
+                hint = dedent(f"""
+                To get started, place the following in {__file__}:
+
+                ``` python
+                @step("{variant}")
+                def {name}({str.join(", ", params + [ "**kwargs" ])}):
+                    assert "cat" == "dog", "Obviously you need to replace this!"
+                ```
+                """)
+
+                log.warning(f"Not implemented: {variant}")
+                send({
+                    "type": 'Failure',
+                    "reason": "step not implemented",
+                    "hint": hint
+                })
+
+        except EOFError:
+            # The control program closed the stream.
+            # Most likely it indicates the end of scenario.
+            break

Fix tbb.py: wrong path in the implementation hint

On by Tad Lispy

index 0cbb852..2a62344 100644
--- a/samples/tbb.py
+++ b/samples/tbb.py
@@ -137,8 +137,12 @@ def ready():
=                params = list(map(lambda index: f"arg_{index}: str", indices))
=                name = f"step_implementation_{len(steps_implementation):02}"
=
+                import importlib
+                main_module = importlib.import_module("__main__")
+                main_path = getattr(main_module, "__file__", "your interpreter script")
+
=                hint = dedent(f"""
-                To get started, place the following in {__file__}:
+                To get started, place the following code in {main_path}:
=
=                ``` python
=                @step("{variant}")

Format python code, clarify a comment

On by Tad Lispy

index 83e57d1..11158dd 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -16,14 +16,12 @@ def add_and_verify(a: float, b: float, expected: float, **kwargs):
=
=    tester.assertEqual(expected, a + b)
=
-
=@step("Divide {0} by {1} to get {2}")
=def divide_and_verify(a: float, b: float, expected: float, **kwargs):
=    log.debug(f"{ a } / { b } = { expected }?")
=    
=    tester.assertAlmostEqual (expected, a / b)
=
-
=@step("The word {0} has {1} characters")
=def verify_characters_count(word, expected_length: int, **kwargs):
=    log.debug(f"Is '{word}' of length {expected_length}?")
@@ -31,7 +29,6 @@ def verify_characters_count(word, expected_length: int, **kwargs):
=
=    tester.assertEqual(expected_length, actual_length)
=
-
=@step("There are {0} properties in the following JSON")
=def count_json_properties(expected_count: int, **kwargs):
=    code = kwargs['code_blocks'][0]['value']
@@ -43,7 +40,6 @@ def count_json_properties(expected_count: int, **kwargs):
=def step_implementation_04(a: float, b: float, expected: float, **kwargs):
=    tester.assertAlmostEqual(expected, b - a) 
=
-
=@step("There are {0} {1}s in the word {2}")
=def step_implementation_05(count: int, letter: str, word: str, **kwargs):
=    actual_count = word.count(letter)
index 2a62344..76d7073 100644
--- a/samples/tbb.py
+++ b/samples/tbb.py
@@ -11,12 +11,12 @@ import re
=from textwrap import dedent
=from typing import Any, Callable
=
-
=# Setup logging
=log_level = os.environ.get("tbb_interpreter_log", "warn").upper()
=logging.basicConfig(level=getattr(logging, log_level))
=log = logging.getLogger("basic_interpreter")
=
+
=def send(message):
=    serialized = json.dumps(message)
=    log.debug("Sending: %s", serialized)
@@ -161,4 +161,5 @@ def ready():
=        except EOFError:
=            # The control program closed the stream.
=            # Most likely it indicates the end of scenario.
+            # Return control to the interpreter for any cleanup.
=            break

Tell git to ignore pycache

On by Tad Lispy

index 727883b..481d8a0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,9 @@
=.direnv/
=result
=
+# For python scripting
+__pycache__
+
=# Added by cargo
=
=/target

Mark python library as done

On by Tad Lispy

index 3f727ab..d0041c3 100644
--- a/README.md
+++ b/README.md
@@ -50,12 +50,12 @@ Support for Linux, BSD, OS X and Web (WASM).
=- [x] Nix package (from Flake)
=- [ ] Use for evaluating Jewiet's Form to Mail specification
=- [ ] Helper libraries
-    - [ ] for Python
+    - [x] for Python
=      - [x] Proof-of-concept
=      - [x] `@step` decorator
=      - [x] Automatic arguments conversion (casting)
=      - [x] Insightful assertion errors
-      - [ ] Split library code from the basic interpreter
+      - [x] Split library code from the basic interpreter
=    - [ ] for Clojure
=    - [ ] For POSIX shells
=- [ ] Capture more data in reports

Initiate the spec for tbb itself

On by Tad Lispy

The spec itself is very basic for now, but it triggered a cascade of changes.

There is a new Python interpreter at spec/self-check.py, and it needs to import tbb.py (just like samples/basic.py). I don't want to maintain two copies of this file or a symbolic link, so tbb.py was moved to spec/ directory. This necessitates samples/basic.py to import it from a sibling directory. It's possible if it's run via python -m, but then the module path is spec.tbb (calculated from root of the project).

index 6536f82..f61f2ac 100644
--- a/samples/basic.md
+++ b/samples/basic.md
@@ -1,5 +1,5 @@
=---
-interpreter: "python samples/basic.py"
+interpreter: "python -m samples.basic"
=---
=
=# Basic BDD suite
index 11158dd..45dfd6f 100644
--- a/samples/basic.py
+++ b/samples/basic.py
@@ -3,8 +3,8 @@
=import json
=import unittest
=
-import tbb
-from tbb import step, log
+import spec.tbb as tbb
+from spec.tbb import step, log
=
=
=# Nice assertions with helpful error messages
new file mode 100644
index 0000000..1de6e27
--- /dev/null
+++ b/spec/basic-usage.md
@@ -0,0 +1,14 @@
+---
+interpreter: "python -m spec.self-check"
+---
+
+# Basic Usage of the `tbb` program
+
+## Getting help
+
+  * Run the program with `--help` command line arguments
+  * The exit code should be `0`
+  * The output will contain `Usage: tbb`
+  * The output will contain `-h, +--help +Print help`
+  * The output will contain `-V, +--version +Print version`
+  * The output will contain `-v, +--verbose +Enable verbose logging`
new file mode 100644
index 0000000..70d8f9b
--- /dev/null
+++ b/spec/self-check.py
@@ -0,0 +1,88 @@
+#!/usr/bin/env python3
+
+import os
+import re
+import shlex
+import subprocess
+import unittest
+from textwrap import dedent, indent
+
+import spec.tbb as tbb
+from spec.tbb import step, log
+
+
+base_command = "tbb"
+if os.environ.get ("CARGO"):
+    cargo_command = "cargo run --"
+    log.warning(f"Running inside Cargo. Will use `{cargo_command}` instead of `{base_command}`.")
+    base_command = cargo_command
+    
+tester = unittest.TestCase()
+completed = None # Hold results of running a command, for later inspection
+
+@step("Run the program with {0} command line arguments")
+def step_implementation_00(args: str, **kwargs):
+    # TODO: Consider augmenting tbb.py so it injects shared state into each step function.
+    global completed
+
+    command = shlex.split (base_command) + shlex.split (args)
+    completed = subprocess.run(command , stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+
+@step("The exit code should be {0}")
+def step_implementation_01(expected_code: int, **kwargs):
+    global completed
+
+    tester.assertEqual(expected_code, completed.returncode)  
+    # for line in completed.stdout.splitlines():
+    #     log.info(line)
+    # log.info(str(completed.stdout))
+    # completed = run()
+    # assert "cat" == "dog", "Obviously you need to replace this!"
+
+@step("The output will contain {0}")
+def step_implementation_02(pattern: str, **kwargs):
+    global completed
+
+    output = str(completed.stdout, "utf-8")
+
+    # The following gives unreadable output
+    # tester.assertRegex (output, expected_text)
+
+    assert re.search(pattern, output), dedent(f"""
+    
+    ``` regexp
+    {pattern} 
+    ```
+
+    --- not found in ---
+
+    ``` text
+    {indent_tail(output, "    ")}
+    ```
+    """)
+
+
+# TODO: Consider moving to tbb
+def indent_tail(text: str, indentation: str):
+    """Adds indentation to all lines except the first one
+
+    Useful when interpolating a multi-line string in a multiline f-string, like
+    this:
+
+    ``` python
+        dedent(f'''
+            Some static text
+            {indent(variable_multiline_text, "    ")}
+            More static text
+        ''')
+    ```
+
+    Notice that without this helper a newline in `variable_multiline_text` would
+    break `dedent` by setting the indentation level to 0.
+    """
+
+    lines = text.splitlines()
+    tail = indent(str.join("\n", lines[1:]), indentation)
+    return lines[0] + tail
+
+tbb.ready()
similarity index 100%
rename from samples/tbb.py
rename to spec/tbb.py

Specify the --version behavior

On by Tad Lispy

index 1de6e27..d691871 100644
--- a/spec/basic-usage.md
+++ b/spec/basic-usage.md
@@ -12,3 +12,11 @@ interpreter: "python -m spec.self-check"
=  * The output will contain `-h, +--help +Print help`
=  * The output will contain `-V, +--version +Print version`
=  * The output will contain `-v, +--verbose +Enable verbose logging`
+
+
+## Getting a version
+
+  * Run the program with `--version` command line arguments
+  * The exit code should be `0`
+  * The output will contain `tad-better-behavior \d+\.\d+\.\d+`
+

Move indent_tail to tbb.py, write some tests

On by Tad Lispy

To run tests:

python -m unittest spec/tbb.py
index d0041c3..0d47bfb 100644
--- a/README.md
+++ b/README.md
@@ -56,6 +56,7 @@ Support for Linux, BSD, OS X and Web (WASM).
=      - [x] Automatic arguments conversion (casting)
=      - [x] Insightful assertion errors
=      - [x] Split library code from the basic interpreter
+      - [ ] Run unit tests autmatically
=    - [ ] for Clojure
=    - [ ] For POSIX shells
=- [ ] Capture more data in reports
index 70d8f9b..6db7ecc 100644
--- a/spec/self-check.py
+++ b/spec/self-check.py
@@ -8,7 +8,7 @@ import unittest
=from textwrap import dedent, indent
=
=import spec.tbb as tbb
-from spec.tbb import step, log
+from spec.tbb import step, log, indent_tail
=
=
=base_command = "tbb"
@@ -62,27 +62,5 @@ def step_implementation_02(pattern: str, **kwargs):
=    """)
=
=
-# TODO: Consider moving to tbb
-def indent_tail(text: str, indentation: str):
-    """Adds indentation to all lines except the first one
-
-    Useful when interpolating a multi-line string in a multiline f-string, like
-    this:
-
-    ``` python
-        dedent(f'''
-            Some static text
-            {indent(variable_multiline_text, "    ")}
-            More static text
-        ''')
-    ```
-
-    Notice that without this helper a newline in `variable_multiline_text` would
-    break `dedent` by setting the indentation level to 0.
-    """
-
-    lines = text.splitlines()
-    tail = indent(str.join("\n", lines[1:]), indentation)
-    return lines[0] + tail
=
=tbb.ready()
index 76d7073..1b96e52 100644
--- a/spec/tbb.py
+++ b/spec/tbb.py
@@ -8,9 +8,11 @@ import json
=import logging
=import os
=import re
+import unittest
=from textwrap import dedent
=from typing import Any, Callable
=
+
=# Setup logging
=log_level = os.environ.get("tbb_interpreter_log", "warn").upper()
=logging.basicConfig(level=getattr(logging, log_level))
@@ -163,3 +165,77 @@ def ready():
=            # Most likely it indicates the end of scenario.
=            # Return control to the interpreter for any cleanup.
=            break
+
+
+def indent_tail(text: str, indentation: str):
+    """Adds indentation to all lines except the first one
+
+    Useful when interpolating a multi-line string in a multiline f-string, like
+    this:
+
+    ``` python
+        dedent(f'''
+            Some static text
+            {indent_tail(variable_multiline_text, "    ")}
+            More static text
+        ''')
+    ```
+
+    Notice that without this helper a newline in `variable_multiline_text` would
+    break `dedent` by setting the indentation level to 0.
+    """
+
+    return re.sub("(\r?\n)", f"\\g<1>{indentation}", text)
+
+# Unit tests
+
+class IndentTailTests(unittest.TestCase):
+    def test_basic(self):
+        input = "line 1\nline 2\nline 3"
+        indentation = "  "
+        expected = "line 1\n  line 2\n  line 3"
+        self.assertEqual(expected, indent_tail(input, indentation))
+
+    def test_one_line(self):
+        input = "line 1"
+        indentation = "  "
+        expected = "line 1"
+        self.assertEqual(expected, indent_tail(input, indentation))
+
+    def test_empty(self):
+        input = ""
+        indentation = "  "
+        expected = ""
+        self.assertEqual(expected, indent_tail(input, indentation))
+
+    def test_already_indented(self):
+        input = "  line 1\n  line 2"
+        indentation = "    "
+        expected = "  line 1\n      line 2"
+        self.assertEqual(expected, indent_tail(input, indentation))
+        
+    def test_carriege_return(self):
+        input = "line 1\r\nline 2"
+        indentation = "    "
+        expected = "line 1\r\n    line 2"
+        self.assertEqual(expected, indent_tail(input, indentation))
+
+    def test_in_f_string(self):
+        input = "line 1\nline 2"
+        actual = dedent(f'''
+            Some static text
+
+            {indent_tail(input, "            ")}
+
+            More static text
+        ''')
+        expected = dedent(f'''
+            Some static text
+
+            line 1
+            line 2
+
+            More static text
+        ''')
+
+        self.assertEqual(expected, actual)

Update the roadmap; improve regexp assert output

On by Tad Lispy

index 0d47bfb..19cf123 100644
--- a/README.md
+++ b/README.md
@@ -34,6 +34,9 @@ Support for Linux, BSD, OS X and Web (WASM).
=- [x] Proof of concept
=  - [x] Interpretter in a different language (Python)
=  - [x] Report why steps fail
+- [ ] Self-check
+  - [x] Proof of concept
+  - [ ] Comprehensive
=- [ ] More readable report
=  - [x] The emojis are misaligned and lack color (at least in my terminal)
=  - [ ] A summary at the bottom (esp. list of errors)
index 6db7ecc..68e749a 100644
--- a/spec/self-check.py
+++ b/spec/self-check.py
@@ -49,8 +49,7 @@ def step_implementation_02(pattern: str, **kwargs):
=    # tester.assertRegex (output, expected_text)
=
=    assert re.search(pattern, output), dedent(f"""
-    
-    ``` regexp
+    ``` regular-expression
=    {pattern} 
=    ```
=

Specify and implement the list subcommand

On by Tad Lispy

Running tbb will print the help message (to stderr) and exit with code 2. To evaluate the spec use the new tbb run command.

index d691871..6f0f0d0 100644
--- a/spec/basic-usage.md
+++ b/spec/basic-usage.md
@@ -20,3 +20,22 @@ interpreter: "python -m spec.self-check"
=  * The exit code should be `0`
=  * The output will contain `tad-better-behavior \d+\.\d+\.\d+`
=
+
+## Listing suites and scenarios from a single document
+
+  * Run the program with `list samples/basic.md` command line arguments
+  * The exit code should be `0`
+  * The output will contain `Basic BDD suite`
+  * The output will contain `\(python -m samples.basic\)`
+  * The output will contain `\* Arithmetic`
+  * The output will contain `\d{2}. Add 7 and 5 to get 12 \["7", "5", "12"\]`
+  * The output will contain `\d+. Divide 10 by 4 to get 2.5 \["10", "4", "2.5"\]`
+  * The output will contain `\d+. Subtract 7 from 5 to get -2 \["7", "5", "-2"\]`
+  * The output will contain `\* Text`
+  * The output will contain `\d{2}. The word blocks has 6 characters \["blocks", "6"\]`
+  * The output will contain `\d{2}. There are 3 properties in the following JSON \["3"\]`
+  * The output will contain `\d{2}. There are 3 rs in the word strawberry \["3", "r", "strawberry"\]`
+  * The output will contain `\d{2}. The following table maps words to their lengths \[\]`
+  * The output will contain `\d{2}. The reverse of abc is cba \["abc", "cba"\]`
+  * The output will contain `\d{2}. The reverse of CIA is KGB \["CIA", "KGB"\]`
+  * The output will contain `\d{2}. There are 2 os in the word boost \["2", "o", "boost"\]`
index b539e3c..6c8a214 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -3,7 +3,7 @@ mod report;
=mod spec;
=
=use anyhow::{Context, Error, bail};
-use clap::Parser;
+use clap::{Parser, Subcommand};
=use env_logger;
=use glob::glob;
=use log;
@@ -14,24 +14,79 @@ use std::path::PathBuf;
=#[derive(Parser)]
=#[command(version, about, long_about=None)]
=struct Cli {
-    /// A directory or a markdown file with the spec to evaluate
-    #[arg(value_name = "SPEC PATH", default_value = "./spec/")]
-    input: PathBuf,
-
=    /// Enable verbose logging
=    #[arg(short, long)]
=    verbose: bool,
+
+    #[command(subcommand)]
+    command: Command,
=}
=
+#[derive(Subcommand)]
+enum Command {
+    List {
+        /// A directory or a markdown file with the spec to list
+        #[arg(value_name = "SPEC PATH", default_value = "./spec/")]
+        input: PathBuf,
+    },
+
+    Run {
+        /// A directory or a markdown file with the spec to evaluate
+        #[arg(value_name = "SPEC PATH", default_value = "./spec/")]
+        input: PathBuf,
+    },
+}
=fn main() -> Result<(), Error> {
=    let cli = Cli::parse();
=    let log_env = env_logger::Env::default()
=        .filter_or("RUST_LOG", if cli.verbose { "trace" } else { "info" });
=    env_logger::init_from_env(log_env);
=
-    log::debug!("Reading the specification from {}", cli.input.display());
+    match cli.command {
+        Command::List { input } => list(input),
+        Command::Run { input } => run(input, cli.verbose),
+    }
+}
+
+fn list(input: PathBuf) -> Result<(), Error> {
+    log::debug!("Reading the specification from {}", input.display());
+
+    let input = input.canonicalize()?;
+    let mut spec = Spec::default();
+
+    if input.is_dir() {
+        log::debug!(
+            "The {} is a directory. Looking for markdown files...",
+            input.display()
+        );
+        let pattern = format!("{}/**/*.md", input.display());
+
+        for path in glob(&pattern)? {
+            let path = path.context(format!("resolving {pattern}"))?;
+            let md = std::fs::read_to_string(&path)
+                .context(format!("reading a file at {}", path.display()))?;
+            spec.load_document(&md)
+                .context(format!("loading a document from {}", path.display()))?;
+        }
+    } else if input.is_file() {
+        log::debug!("The {} is a file. Reading...", input.display());
+        let md = std::fs::read_to_string(&input)
+            .context(format!("reading a file at {}", input.display()))?;
+        spec.load_document(&md)
+            .context(format!("loading a document from {}", input.display()))?;
+    } else {
+        bail!("The {} is neither a file nor directory", input.display());
+    };
+
+    log::info!("Collected {} suites.", spec.suites.len());
+    println!("{spec}");
+    Ok(())
+}
+
+fn run(input: PathBuf, verbose: bool) -> Result<(), Error> {
+    log::debug!("Reading the specification from {}", input.display());
=
-    let input = cli.input.canonicalize()?;
+    let input = input.canonicalize()?;
=    let mut spec = Spec::default();
=
=    if input.is_dir() {
@@ -62,7 +117,7 @@ fn main() -> Result<(), Error> {
=    log::debug!("Evaluating:\n\n{spec}");
=
=    let mut report = EvaluationReport::new(&spec);
-    report.evaluate(cli.verbose);
+    report.evaluate(verbose);
=
=    log::info!("Evaluation done. Here's the result:\n\n{report}");
=

Write a spec for the run subcommand

On by Tad Lispy

The program doesn't conform to it yet.

Also write a prose spec for running tbb without arguments.

index 6f0f0d0..0e00456 100644
--- a/spec/basic-usage.md
+++ b/spec/basic-usage.md
@@ -39,3 +39,62 @@ interpreter: "python -m spec.self-check"
=  * The output will contain `\d{2}. The reverse of abc is cba \["abc", "cba"\]`
=  * The output will contain `\d{2}. The reverse of CIA is KGB \["CIA", "KGB"\]`
=  * The output will contain `\d{2}. There are 2 os in the word boost \["2", "o", "boost"\]`
+
+
+## Running a spec from a single document
+
+A complete sample output is like this:
+
+``` text
+Basic BDD suite (python -m samples.basic)
+
+✓ Arithmetic
+
+  ⊞ Add 7 and 5 to get 12 ["7", "5", "12"]
+  ⊞ Divide 10 by 4 to get 2.5 ["10", "4", "2.5"]
+  ⊞ Subtract 7 from 5 to get -2 ["7", "5", "-2"]
+
+✓ Text
+
+  ⊞ The word blocks has 6 characters ["blocks", "6"]
+  ⊞ There are 3 properties in the following JSON ["3"]
+  ⊞ There are 3 rs in the word strawberry ["3", "r", "strawberry"]
+  ⊞ The following table maps words to their lengths []
+  ⊞ The reverse of abc is cba ["abc", "cba"]
+  ⊠ The reverse of CIA is KGB ["CIA", "KGB"]
+
+    'KGB' != 'AIC'
+    - KGB
+    + AIC
+
+
+  □ There are 2 os in the word boost ["2", "o", "boost"]
+```
+
+Notice it's similar to the output of `tbb list`, but now contains unicode symbols to indicate the results of each step. Each completed scenario has a check mark `✓`. Successful steps have a squared plus `⊞` . The failing step is markd with a squared times symbol `⊠`. Once a step fails, the subsequent steps in a scenario are not exercised. In the report they are marked with a white square symbol `□`.
+
+
+  * Run the program with `run samples/basic.md` command line arguments
+  * The exit code should be `1`
+
+     The `basic.md` suit is intentionally wrong. It should be reflected in the status code.
+
+  * The output will contain `Basic BDD suite`
+  * The output will contain `\(python -m samples.basic\)`
+  * The output will contain `✓ Arithmetic`
+  * The output will contain `  ⊞ Add 7 and 5 to get 12 \["7", "5", "12"\]`
+  * The output will contain `  ⊞ Divide 10 by 4 to get 2.5 \["10", "4", "2.5"\]`
+  * The output will contain `  ⊞ Subtract 7 from 5 to get -2 \["7", "5", "-2"\]`
+  * The output will contain `✓ Text`
+  * The output will contain `  ⊞ The word blocks has 6 characters \["blocks", "6"\]`
+  * The output will contain `  ⊞ There are 3 properties in the following JSON \["3"\]`
+  * The output will contain `  ⊞ There are 3 rs in the word strawberry \["3", "r", "strawberry"\]`
+  * The output will contain `  ⊞ The following table maps words to their lengths \[\]`
+  * The output will contain `  ⊞ The reverse of abc is cba \["abc", "cba"\]`
+  * The output will contain `    The reverse of CIA is KGB \["CIA", "KGB"\]`
+  * The output will contain `  □ There are 2 os in the word boost \["2", "o", "boost"\]`
+
+
+## Running without a subcommand
+
+Running `tbb` without a subcommand will print the help message (to stderr) and exit with code 2.

Signal failed evaluation with exit code

On by Tad Lispy

Also let the evaluation report be printed on stdout.

index 0e00456..fc96550 100644
--- a/spec/basic-usage.md
+++ b/spec/basic-usage.md
@@ -13,6 +13,7 @@ interpreter: "python -m spec.self-check"
=  * The output will contain `-V, +--version +Print version`
=  * The output will contain `-v, +--verbose +Enable verbose logging`
=
+TODO: Mention `run` and `list` commands.
=
=## Getting a version
=
@@ -91,10 +92,19 @@ Notice it's similar to the output of `tbb list`, but now contains unicode symbol
=  * The output will contain `  ⊞ There are 3 rs in the word strawberry \["3", "r", "strawberry"\]`
=  * The output will contain `  ⊞ The following table maps words to their lengths \[\]`
=  * The output will contain `  ⊞ The reverse of abc is cba \["abc", "cba"\]`
-  * The output will contain `    The reverse of CIA is KGB \["CIA", "KGB"\]`
=  * The output will contain `  □ There are 2 os in the word boost \["2", "o", "boost"\]`
-
+  * The standard error will contain `\[.+ ERROR +tbb] Step failed: Basic BDD suite > Text > The reverse of CIA is KGB`
=
=## Running without a subcommand
=
=Running `tbb` without a subcommand will print the help message (to stderr) and exit with code 2.
+
+
+## A whole scenario failure
+
+Sometimes the failur is not in any particular step, but in a whole scenario, e.g. when an interpreter misbehaves. The status code and the summary should reflect it.
+
+
+## Multiple scenarios failure
+
+When several different scenarios fail, each one should be mentioned in the summary.
index 6c8a214..37457f8 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -7,9 +7,9 @@ use clap::{Parser, Subcommand};
=use env_logger;
=use glob::glob;
=use log;
-use report::EvaluationReport;
+use report::{EvaluationReport, EvaluationSummary};
=use spec::Spec;
-use std::path::PathBuf;
+use std::{path::PathBuf, process};
=
=#[derive(Parser)]
=#[command(version, about, long_about=None)]
@@ -119,7 +119,14 @@ fn run(input: PathBuf, verbose: bool) -> Result<(), Error> {
=    let mut report = EvaluationReport::new(&spec);
=    report.evaluate(verbose);
=
-    log::info!("Evaluation done. Here's the result:\n\n{report}");
+    // Print the report on STDOUT
+    println!("{report}");
=
-    Ok(())
+    match EvaluationSummary::from(report) {
+        EvaluationSummary::AllOk => Ok(()),
+        EvaluationSummary::Failed { failed_steps } => {
+            // TODO: Print errors from failing steps
+            process::exit(1)
+        }
+    }
=}
index b3df963..178d7f3 100644
--- a/src/report.rs
+++ b/src/report.rs
@@ -347,3 +347,36 @@ pub enum InterpreterMessage {
=pub enum ControlMessage {
=    Execute { step: Step },
=}
+
+pub enum EvaluationSummary {
+    AllOk,
+    Failed {
+        failed_steps: Vec<(Suite, Scenario, Step)>,
+    },
+}
+
+impl<'a> From<EvaluationReport<'a>> for EvaluationSummary {
+    fn from(report: EvaluationReport) -> Self {
+        let mut failed_steps: Vec<(Suite, Scenario, Step)> = Vec::default();
+
+        for suite in report.suites.iter() {
+            for scenario in suite.scenarios.iter() {
+                for step in scenario.steps.iter() {
+                    if let StepStatus::Failed { .. } = step.status {
+                        failed_steps.push((
+                            suite.suite.clone(),
+                            scenario.scenario.clone(),
+                            step.step.clone(),
+                        ));
+                    }
+                }
+            }
+        }
+
+        if failed_steps.is_empty() {
+            Self::AllOk
+        } else {
+            Self::Failed { failed_steps }
+        }
+    }
+}
index 5bf24d7..f0c731d 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -62,7 +62,7 @@ impl Display for Spec {
=/// From a markdown perspective, a single document can contain multiple suites,
=/// demarcated by an h1 heading. In such a case all those suites use the same
=/// interpreter, since there can be only one front-matter per document.
-#[derive(Debug)]
+#[derive(Debug, Clone)]
=pub struct Suite {
=    pub title: String,
=    pub interpreter: String,
@@ -75,7 +75,7 @@ pub struct Suite {
=/// spawned and fed steps.  Scenarios can be stateful, i.e. running a step can
=/// affect subsequent steps. It's up to the interpreter to implement state
=/// management.
-#[derive(Debug)]
+#[derive(Debug, Clone)]
=pub struct Scenario {
=    pub title: String,
=    pub steps: Vec<Step>,

On by Tad Lispy

index fc96550..74fdf17 100644
--- a/spec/basic-usage.md
+++ b/spec/basic-usage.md
@@ -93,7 +93,7 @@ Notice it's similar to the output of `tbb list`, but now contains unicode symbol
=  * The output will contain `  ⊞ The following table maps words to their lengths \[\]`
=  * The output will contain `  ⊞ The reverse of abc is cba \["abc", "cba"\]`
=  * The output will contain `  □ There are 2 os in the word boost \["2", "o", "boost"\]`
-  * The standard error will contain `\[.+ ERROR +tbb] Step failed: Basic BDD suite > Text > The reverse of CIA is KGB`
+  * The standard error will contain `\[.+ ERROR +tbb\] Step failed: Basic BDD suite ❯ Text ❯ The reverse of CIA is KGB`
=
=## Running without a subcommand
=
index 68e749a..71edcbb 100644
--- a/spec/self-check.py
+++ b/spec/self-check.py
@@ -60,6 +60,25 @@ def step_implementation_02(pattern: str, **kwargs):
=    ```
=    """)
=
+@step("The standard error will contain {0}")
+def step_implementation_03(pattern: str, **kwargs):
+    global completed
+
+    output = str(completed.stderr, "utf-8")
+
+    # The following gives unreadable output
+    # tester.assertRegex (output, expected_text)
+
+    assert re.search(pattern, output), dedent(f"""
+    ``` regular-expression
+    {pattern} 
+    ```
+
+    --- not found in ---
=
+    ``` text
+    {indent_tail(output, "    ")}
+    ```
+    """)
=
=tbb.ready()
index 37457f8..3ac8b83 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -125,6 +125,14 @@ fn run(input: PathBuf, verbose: bool) -> Result<(), Error> {
=    match EvaluationSummary::from(report) {
=        EvaluationSummary::AllOk => Ok(()),
=        EvaluationSummary::Failed { failed_steps } => {
+            for (suite, scenario, step) in failed_steps.iter() {
+                log::error!(
+                    "Step failed: {suite} ❯ {scenario} ❯ {step}",
+                    suite = suite.title,
+                    scenario = scenario.title,
+                    step = step.description
+                )
+            }
=            // TODO: Print errors from failing steps
=            process::exit(1)
=        }

Bump version to 0.4.0 (breaking)

On by Tad Lispy

To mark the introduction of run and list sub-commands (a breaking change) and printing of the report summary.

index a4b37ac..d58a07f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -400,7 +400,7 @@ dependencies = [
=
=[[package]]
=name = "tad-better-behavior"
-version = "0.3.0"
+version = "0.4.0"
=dependencies = [
= "anyhow",
= "clap",
index 32ebb5f..d7c38de 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
=[package]
=name = "tad-better-behavior"
-version = "0.3.0"
+version = "0.4.0"
=edition = "2024"
=
=[dependencies]
index 19cf123..e3c6f0a 100644
--- a/README.md
+++ b/README.md
@@ -39,7 +39,7 @@ Support for Linux, BSD, OS X and Web (WASM).
=  - [ ] Comprehensive
=- [ ] More readable report
=  - [x] The emojis are misaligned and lack color (at least in my terminal)
-  - [ ] A summary at the bottom (esp. list of errors)
+  - [x] A summary at the bottom (esp. list of errors)
=  - [x] Use colors
=  - [ ] More instructive error messages
=  - [x] Indent multiline error messages

Write some ideas in the readme

On by Tad Lispy

The big one is recursion. The important and urgent one is tags.

index e3c6f0a..e6c1b16 100644
--- a/README.md
+++ b/README.md
@@ -37,12 +37,22 @@ Support for Linux, BSD, OS X and Web (WASM).
=- [ ] Self-check
=  - [x] Proof of concept
=  - [ ] Comprehensive
+- [ ] Tags to filtering suites and scenarios
+  - [ ] Per document (in front-matter)
+  - [ ] Per suite (in `tags` codeblock under `h1`)
+  - [ ] Per scenario (in `tags` codeblock under `h2`)
+  - [ ] `--exclude` CLI option (logical *or*)
+  - [ ] `--only` CLI option (logical *and*)
+  - [ ] prefixes (like `suite:foo`, `scenario:ready`)
=- [ ] More readable report
=  - [x] The emojis are misaligned and lack color (at least in my terminal)
=  - [x] A summary at the bottom (esp. list of errors)
=  - [x] Use colors
=  - [ ] More instructive error messages
=  - [x] Indent multiline error messages
+  - [ ] Collapse ommited steps (`□□□□□□ following 6 steps skipped`)
+  - [ ] Collapse filtered out suites
+  - [ ] Collapse filtered out scenarios
=- [ ] Pass more step data to interpreters
=    - [x] Code blocks
=    - [x] Tables
@@ -62,6 +72,12 @@ Support for Linux, BSD, OS X and Web (WASM).
=      - [ ] Run unit tests autmatically
=    - [ ] for Clojure
=    - [ ] For POSIX shells
+    - [ ] For NuShell
+- [ ] Built-in interpreter (`tbb automation`)
+  - [ ] HTTP client
+  - [ ] Web Driver client
+  - [ ] E-Mail client
+  - [ ] Recursive calls (call `tbb run` and such)
=- [ ] Capture more data in reports
=  - [ ] Attachments (screenshots, videos, datasets, etc.)
=  - [ ] Performance data (interpreters' startup times, steps' durations)
@@ -70,3 +86,56 @@ Support for Linux, BSD, OS X and Web (WASM).
=    - [ ] TUI
=    - [ ] Web
=- [ ] WASM target
+
+
+# Ideas
+
+These are some ideas worth exploring. Once mature, they go on the Roadmap.
+
+### Control flow through recursion
+
+Currently there is no direct way to express "if this then that". One could implement an interpreter that would recursively run `tbb` with another suite. Something like:
+
+``` markdown
+---
+interpreter: tbb automation
+---
+
+## Plan my day
+
+``` tags
+start-here
+```
+
+  * Check current weather
+  * If `weather` is `sunny` then run scenario `Cancel all work meetings`
+  
+## Cancel all work meetings
+
+  * Email the following people
+  
+    - Alice <alice@example.com>
+    - Bob <bob@example.com>
+    - Carol <ceo@example.com>
+    
+    ``` markdown
+    Hey!
+    
+    Due to unusually nice weather I won't be available to work today. Have fun!
+    
+    Sincerily,
+    Derek
+    ```
+ 
+  * Send message to following people
+  
+    - @elen-is-fun
+    - @frank-but-fair
+    
+    ``` markdown
+    See you at the beach?
+    ```
+
+```
+
+Then run it

Update spec re commands in --help output

On by Tad Lispy

Introducing new step variant with a code block. I think I should convert other steps to use it too. It's much more readable.

index 74fdf17..c4f2996 100644
--- a/spec/basic-usage.md
+++ b/spec/basic-usage.md
@@ -12,6 +12,14 @@ interpreter: "python -m spec.self-check"
=  * The output will contain `-h, +--help +Print help`
=  * The output will contain `-V, +--version +Print version`
=  * The output will contain `-v, +--verbose +Enable verbose logging`
+  * The output will contain `a sub-commands` block
+  
+    ``` text
+    Commands:
+      list  Print the suites, scenarios and steps of the specification
+      run   Evaluate the specification
+      help  Print this message or the help of the given subcommand(s)
+    ```
=
=TODO: Mention `run` and `list` commands.
=
index 71edcbb..9d4ce31 100644
--- a/spec/self-check.py
+++ b/spec/self-check.py
@@ -81,4 +81,24 @@ def step_implementation_03(pattern: str, **kwargs):
=    ```
=    """)
=
+@step("The output will contain {0} block")
+def step_implementation_04(label: str, **kwargs):
+    global completed
+    block = kwargs['code_blocks'][0]['value']
+    output = completed.stdout.decode("utf-8")
+
+    # tester.assertIn gives unreadable output
+
+    assert block in output, dedent(f"""
+    ``` text
+    {tbb.indent_tail(block, "    ")}
+    ```
+
+    --- not found in output ---
+
+    ``` text
+    {tbb.indent_tail(output, "    ")}
+    ```
+    """)
+ 
=tbb.ready()

Reorganize spec.rs - structs before traits

On by Tad Lispy

index f0c731d..22546c8 100644
--- a/src/spec.rs
+++ b/src/spec.rs
@@ -12,46 +12,6 @@ pub struct Spec {
=    pub suites: Vec<Suite>,
=}
=
-impl Spec {
-    /// Load suites from a markdown document
-    pub fn load_document(&mut self, md: &str) -> anyhow::Result<()> {
-        // TODO: Support loading multiple suits from a single document (demarcated by h1)
-        let suite = Suite::from_markdown(md).context("loading a markdown document")?;
-
-        self.suites.push(suite);
-        Ok(())
-    }
-}
-
-impl Display for Spec {
-    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
-        for suite in self.suites.iter() {
-            writeln!(
-                f,
-                "\n{title} ({interpreter})",
-                title = suite.title,
-                interpreter = suite.interpreter
-            )?;
-
-            for scenario in suite.scenarios.iter() {
-                writeln!(f, "\n  * {title}\n", title = scenario.title,)?;
-
-                for (index, step) in scenario.steps.iter().enumerate() {
-                    writeln!(
-                        f,
-                        "    {index:02}. {description} {arguments:?}",
-                        description = step.description,
-                        arguments = step.arguments
-                    )?;
-                }
-            }
-            writeln!(f, "")?;
-        }
-
-        Ok(())
-    }
-}
-
=/// Suite is a collection of scenarios that share a common title and interpreter
=///
=/// Other BDD systems often call it "a spec" but in my mind it doesn't make
@@ -105,6 +65,48 @@ pub struct CodeBlock {
=    language: Option<String>,
=    meta: Option<String>,
=}
+impl Spec {
+    /// Load suites from a markdown document
+    pub fn load_document(&mut self, md: &str) -> anyhow::Result<()> {
+        // TODO: Support loading multiple suits from a single document (demarcated by h1)
+        let suite = Suite::from_markdown(md).context("loading a markdown document")?;
+
+        self.suites.push(suite);
+        Ok(())
+    }
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone)]
+pub struct Table(Vec<Vec<String>>);
+
+impl Display for Spec {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        for suite in self.suites.iter() {
+            writeln!(
+                f,
+                "\n{title} ({interpreter})",
+                title = suite.title,
+                interpreter = suite.interpreter
+            )?;
+
+            for scenario in suite.scenarios.iter() {
+                writeln!(f, "\n  * {title}\n", title = scenario.title,)?;
+
+                for (index, step) in scenario.steps.iter().enumerate() {
+                    writeln!(
+                        f,
+                        "    {index:02}. {description} {arguments:?}",
+                        description = step.description,
+                        arguments = step.arguments
+                    )?;
+                }
+            }
+            writeln!(f, "")?;
+        }
+
+        Ok(())
+    }
+}
=
=impl From<&markdown::mdast::Code> for CodeBlock {
=    fn from(code: &markdown::mdast::Code) -> Self {
@@ -116,9 +118,6 @@ impl From<&markdown::mdast::Code> for CodeBlock {
=    }
=}
=
-#[derive(Debug, Serialize, Deserialize, Clone)]
-pub struct Table(Vec<Vec<String>>);
-
=impl From<&markdown::mdast::Table> for Table {
=    fn from(table: &markdown::mdast::Table) -> Self {
=        let rows: Vec<Vec<String>> = table