From 9972ba7f7c798e12741e9310a258df64c9310c05 Mon Sep 17 00:00:00 2001 From: Lars Wirzenius Date: Fri, 1 Dec 2023 18:01:44 +0200 Subject: feat: typeset scenarios by typesetting steps Typeset each step in a scenario, including captures in the steps. Previously the scenario was just one
 element, now things can be
styled with CSS.

Signed-off-by: Lars Wirzenius 
Sponsored-by: author
---
 src/bin/subplot.rs |   2 +-
 src/bindings.rs    |  14 +++++--
 src/doc.rs         |  23 +++++++++--
 src/html.rs        |   3 +-
 src/lib.rs         |   3 --
 src/matches.rs     |  37 ++++++++++++------
 src/md.rs          | 110 ++++++++++++++++++++++++++++++++++++++++-------------
 src/parser.rs      |  43 ---------------------
 src/steps.rs       |  79 ++++++++++++++++++++++++++++++++++++++
 9 files changed, 220 insertions(+), 94 deletions(-)
 delete mode 100644 src/parser.rs

diff --git a/src/bin/subplot.rs b/src/bin/subplot.rs
index 37545a5..fd9500d 100644
--- a/src/bin/subplot.rs
+++ b/src/bin/subplot.rs
@@ -288,7 +288,7 @@ impl Docgen {
             Self::mtime_formatted(newest.unwrap())
         };
 
-        doc.typeset(&mut Warnings::default());
+        doc.typeset(&mut Warnings::default(), self.template.as_deref())?;
         std::fs::write(&self.output, doc.to_html(&date)?)
             .map_err(|e| SubplotError::WriteFile(self.output.clone(), e))?;
 
diff --git a/src/bindings.rs b/src/bindings.rs
index 380b0ed..b937edb 100644
--- a/src/bindings.rs
+++ b/src/bindings.rs
@@ -290,7 +290,8 @@ impl Binding {
                         // valid for this binding.
                         return None;
                     }
-                    PartialStep::text(name, cap)
+                    let kind = self.types.get(name).unwrap_or(&CaptureType::Text);
+                    PartialStep::text(name, cap, *kind)
                 }
             };
 
@@ -318,7 +319,7 @@ impl Eq for Binding {}
 
 #[cfg(test)]
 mod test_binding {
-    use super::Binding;
+    use super::{Binding, CaptureType};
     use crate::html::Location;
     use crate::PartialStep;
     use crate::ScenarioStep;
@@ -401,7 +402,10 @@ mod test_binding {
         assert_eq!(m.kind(), StepKind::Given);
         let mut parts = m.parts();
         assert_eq!(parts.next().unwrap(), &PartialStep::uncaptured("I am "));
-        assert_eq!(parts.next().unwrap(), &PartialStep::text("who", "Tomjon"));
+        assert_eq!(
+            parts.next().unwrap(),
+            &PartialStep::text("who", "Tomjon", CaptureType::Text)
+        );
         assert_eq!(parts.next().unwrap(), &PartialStep::uncaptured(", I am"));
         assert_eq!(parts.next(), None);
     }
@@ -608,6 +612,7 @@ fn from_hashmap(parsed: &ParsedBinding) -> Result {
 
 #[cfg(test)]
 mod test_bindings {
+    use crate::bindings::CaptureType;
     use crate::html::Location;
     use crate::Binding;
     use crate::Bindings;
@@ -808,9 +813,10 @@ mod test_bindings {
         }
         let p = parts.next().unwrap();
         match p {
-            PartialStep::CapturedText { name, text } => {
+            PartialStep::CapturedText { name, text, kind } => {
                 assert_eq!(name, "name");
                 assert_eq!(text, "Tomjon");
+                assert_eq!(kind, &CaptureType::Text);
             }
             _ => panic!("unexpected part: {:?}", p),
         }
diff --git a/src/doc.rs b/src/doc.rs
index 397bb75..1e48e40 100644
--- a/src/doc.rs
+++ b/src/doc.rs
@@ -526,7 +526,12 @@ impl Document {
         for scenario in scenarios {
             for step in scenario.steps() {
                 for captured in step.parts() {
-                    if let PartialStep::CapturedText { name, text } = captured {
+                    if let PartialStep::CapturedText {
+                        name,
+                        text,
+                        kind: _,
+                    } = captured
+                    {
                         if matches!(step.types().get(name.as_str()), Some(CaptureType::File))
                             && !filenames.contains(&text.to_lowercase())
                         {
@@ -562,7 +567,12 @@ impl Document {
         for scenario in scenarios {
             for step in scenario.steps() {
                 for captured in step.parts() {
-                    if let PartialStep::CapturedText { name, text } = captured {
+                    if let PartialStep::CapturedText {
+                        name,
+                        text,
+                        kind: _,
+                    } = captured
+                    {
                         if matches!(step.types().get(name.as_str()), Some(CaptureType::File)) {
                             filenames.remove(&text.to_lowercase());
                         }
@@ -607,10 +617,15 @@ impl Document {
     }
 
     /// Typeset a Subplot document.
-    pub fn typeset(&mut self, warnings: &mut Warnings) {
+    pub fn typeset(
+        &mut self,
+        warnings: &mut Warnings,
+        template: Option<&str>,
+    ) -> Result<(), SubplotError> {
         for md in self.markdowns.iter_mut() {
-            warnings.push_all(md.typeset(self.style.clone(), self.meta.bindings()));
+            warnings.push_all(md.typeset(self.style.clone(), template, self.meta.bindings()));
         }
+        Ok(())
     }
 
     /// Return all scenarios in a document.
diff --git a/src/html.rs b/src/html.rs
index 2cb8f3c..b76276b 100644
--- a/src/html.rs
+++ b/src/html.rs
@@ -641,7 +641,8 @@ pub enum Location {
 }
 
 impl Location {
-    fn new(filename: &Path, line: usize, col: usize) -> Self {
+    /// Create a new location.
+    pub fn new(filename: &Path, line: usize, col: usize) -> Self {
         Self::Known {
             filename: filename.into(),
             line,
diff --git a/src/lib.rs b/src/lib.rs
index 9c9de7d..2f55ede 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -45,9 +45,6 @@ mod bindings;
 pub use bindings::Binding;
 pub use bindings::Bindings;
 
-mod parser;
-pub use parser::parse_scenario_snippet;
-
 mod matches;
 pub use matches::MatchedScenario;
 pub use matches::MatchedStep;
diff --git a/src/matches.rs b/src/matches.rs
index f8de59a..18c9832 100644
--- a/src/matches.rs
+++ b/src/matches.rs
@@ -1,4 +1,4 @@
-use crate::html::Location;
+use crate::html::{Attribute, Content, Element, ElementTag, Location};
 use crate::Binding;
 use crate::Scenario;
 use crate::StepKind;
@@ -141,6 +141,14 @@ impl MatchedStep {
     pub fn types(&self) -> &HashMap {
         &self.types
     }
+
+    /// Render the step as HTML.
+    pub fn to_html(&self) -> Element {
+        let mut e = Element::new(ElementTag::Span);
+        e.push_attribute(Attribute::new("class", "scenario_step"));
+        e.push_child(Content::Text(self.text().into()));
+        e
+    }
 }
 
 /// Part of a scenario step, possibly captured by a pattern.
@@ -156,6 +164,8 @@ pub enum PartialStep {
         name: String,
         /// Text of the capture.
         text: String,
+        /// Type of capture.
+        kind: CaptureType,
     },
 }
 
@@ -166,10 +176,11 @@ impl PartialStep {
     }
 
     /// Construct a textual captured part of a scenario step.
-    pub fn text(name: &str, text: &str) -> PartialStep {
+    pub fn text(name: &str, text: &str, kind: CaptureType) -> PartialStep {
         PartialStep::CapturedText {
             name: name.to_string(),
             text: text.to_string(),
+            kind,
         }
     }
 
@@ -184,6 +195,7 @@ impl PartialStep {
 #[cfg(test)]
 mod test_partial_steps {
     use super::PartialStep;
+    use crate::bindings::CaptureType;
 
     #[test]
     fn identical_uncaptured_texts_match() {
@@ -201,29 +213,29 @@ mod test_partial_steps {
 
     #[test]
     fn identical_captured_texts_match() {
-        let p1 = PartialStep::text("xxx", "foo");
-        let p2 = PartialStep::text("xxx", "foo");
+        let p1 = PartialStep::text("xxx", "foo", CaptureType::Text);
+        let p2 = PartialStep::text("xxx", "foo", CaptureType::Text);
         assert_eq!(p1, p2);
     }
 
     #[test]
     fn different_captured_texts_dont_match() {
-        let p1 = PartialStep::text("xxx", "foo");
-        let p2 = PartialStep::text("xxx", "bar");
+        let p1 = PartialStep::text("xxx", "foo", CaptureType::Text);
+        let p2 = PartialStep::text("xxx", "bar", CaptureType::Text);
         assert_ne!(p1, p2);
     }
 
     #[test]
     fn differently_named_captured_texts_dont_match() {
-        let p1 = PartialStep::text("xxx", "foo");
-        let p2 = PartialStep::text("yyy", "foo");
+        let p1 = PartialStep::text("xxx", "foo", CaptureType::Text);
+        let p2 = PartialStep::text("yyy", "foo", CaptureType::Text);
         assert_ne!(p1, p2);
     }
 
     #[test]
     fn differently_captured_texts_dont_match() {
         let p1 = PartialStep::uncaptured("foo");
-        let p2 = PartialStep::text("xxx", "foo");
+        let p2 = PartialStep::text("xxx", "foo", CaptureType::Text);
         assert_ne!(p1, p2);
     }
 }
@@ -254,6 +266,8 @@ impl StepSnippet {
 
 #[cfg(test)]
 mod test {
+    use crate::bindings::CaptureType;
+
     use super::PartialStep;
 
     #[test]
@@ -267,11 +281,12 @@ mod test {
 
     #[test]
     fn returns_text() {
-        let p = PartialStep::text("xxx", "foo");
+        let p = PartialStep::text("xxx", "foo", crate::bindings::CaptureType::Text);
         match p {
-            PartialStep::CapturedText { name, text } => {
+            PartialStep::CapturedText { name, text, kind } => {
                 assert_eq!(name, "xxx");
                 assert_eq!(text, "foo");
+                assert_eq!(kind, CaptureType::Text);
             }
             _ => panic!("expected CapturedText: {:?}", p),
         }
diff --git a/src/md.rs b/src/md.rs
index 64cfc44..85fb895 100644
--- a/src/md.rs
+++ b/src/md.rs
@@ -2,8 +2,8 @@
 
 use crate::{
     html::{parse, Attribute, Content, Element, ElementTag, Location},
-    parse_scenario_snippet, Bindings, EmbeddedFile, EmbeddedFiles, Scenario, ScenarioStep, Style,
-    SubplotError, Warnings,
+    steps::parse_scenario_snippet,
+    Bindings, EmbeddedFile, EmbeddedFiles, Scenario, Style, SubplotError, Warnings,
 };
 use log::trace;
 use std::collections::HashSet;
@@ -88,8 +88,13 @@ impl Markdown {
     }
 
     /// Typeset.
-    pub fn typeset(&mut self, _style: Style, _bindings: &Bindings) -> Warnings {
-        let result = typeset::typeset_element(&self.html);
+    pub fn typeset(
+        &mut self,
+        _style: Style,
+        template: Option<&str>,
+        bindings: &Bindings,
+    ) -> Warnings {
+        let result = typeset::typeset_element(&self.html, template, bindings);
         if let Ok(html) = result {
             self.html = html;
             Warnings::default()
@@ -278,7 +283,6 @@ fn extract_scenario(e: &[StructureElement]) -> Result<(Option, usize),
         StructureElement::Snippet(_, loc) => Err(SubplotError::ScenarioBeforeHeading(loc.clone())),
         StructureElement::Heading(title, level, loc) => {
             let mut scen = Scenario::new(title, loc.clone());
-            let mut prevkind = None;
             for (i, item) in e.iter().enumerate().skip(1) {
                 match item {
                     StructureElement::Heading(_, level2, _loc) => {
@@ -295,22 +299,9 @@ fn extract_scenario(e: &[StructureElement]) -> Result<(Option, usize),
                         }
                     }
                     StructureElement::Snippet(text, loc) => {
-                        for (idx, line) in parse_scenario_snippet(text).enumerate() {
-                            let line_loc = match loc.clone() {
-                                Location::Known {
-                                    filename,
-                                    line,
-                                    col,
-                                } => Location::Known {
-                                    filename,
-                                    line: line + idx,
-                                    col,
-                                },
-                                Location::Unknown => Location::Unknown,
-                            };
-                            let step = ScenarioStep::new_from_str(line, prevkind, line_loc)?;
+                        let steps = parse_scenario_snippet(text, loc)?;
+                        for step in steps {
                             scen.add(&step);
-                            prevkind = Some(step.kind());
                         }
                     }
                 }
@@ -327,21 +318,30 @@ fn extract_scenario(e: &[StructureElement]) -> Result<(Option, usize),
 mod typeset {
     const UNWANTED_ATTRS: &[&str] = &["add-newline"];
 
-    use crate::html::{Attribute, Content, Element, ElementTag};
+    use crate::{
+        html::{Attribute, Content, Element, ElementTag, Location},
+        Bindings, PartialStep,
+    };
     // use crate::parser::parse_scenario_snippet;
     // use crate::Bindings;
     // use crate::PartialStep;
     // use crate::ScenarioStep;
     // use crate::StepKind;
     use crate::SubplotError;
-    use crate::{DiagramMarkup, DotMarkup, PikchrMarkup, PlantumlMarkup, Svg};
+    use crate::{DiagramMarkup, DotMarkup, MatchedStep, PikchrMarkup, PlantumlMarkup, Svg};
     // use crate::{Warning, Warnings};
 
     use base64::prelude::{Engine as _, BASE64_STANDARD};
 
-    pub(crate) fn typeset_element(e: &Element) -> Result {
+    pub(crate) fn typeset_element(
+        e: &Element,
+        template: Option<&str>,
+        bindings: &Bindings,
+    ) -> Result {
         let new = match e.tag() {
-            ElementTag::Pre if e.has_attr("class", "scenario") => typeset_scenario(e),
+            ElementTag::Pre if e.has_attr("class", "scenario") => {
+                typeset_scenario(e, template, bindings)
+            }
             ElementTag::Pre if e.has_attr("class", "file") => typeset_file(e),
             ElementTag::Pre if e.has_attr("class", "example") => typeset_example(e),
             ElementTag::Pre if e.has_attr("class", "dot") => typeset_dot(e),
@@ -355,7 +355,7 @@ mod typeset {
                 }
                 for child in e.children() {
                     if let Content::Elt(ce) = child {
-                        new.push_child(Content::Elt(typeset_element(ce)?));
+                        new.push_child(Content::Elt(typeset_element(ce, template, bindings)?));
                     } else {
                         new.push_child(child.clone());
                     }
@@ -368,8 +368,64 @@ mod typeset {
         Ok(new)
     }
 
-    fn typeset_scenario(e: &Element) -> Result {
-        Ok(e.clone()) // FIXME
+    fn typeset_scenario(
+        e: &Element,
+        template: Option<&str>,
+        bindings: &Bindings,
+    ) -> Result {
+        let template = template.unwrap_or("python"); // FIXME
+
+        let text = e.content();
+        let steps = crate::steps::parse_scenario_snippet(&text, &Location::Unknown)?;
+
+        let mut scenario = Element::new(ElementTag::Div);
+        scenario.push_attribute(Attribute::new("class", "scenario"));
+
+        for step in steps {
+            if let Ok(matched) = bindings.find(template, &step) {
+                scenario.push_child(Content::Elt(typeset_step(&matched)));
+            } else {
+                scenario.push_child(Content::Text(step.text().into()));
+            }
+        }
+
+        Ok(scenario)
+    }
+
+    fn typeset_step(matched: &MatchedStep) -> Element {
+        let mut e = Element::new(ElementTag::Div);
+        let mut keyword = Element::new(ElementTag::Span);
+        keyword.push_attribute(Attribute::new("class", "keyword"));
+        keyword.push_child(Content::Text(matched.kind().to_string()));
+        keyword.push_child(Content::Text(" ".into()));
+        e.push_child(Content::Elt(keyword));
+        for part in matched.parts() {
+            match part {
+                PartialStep::UncapturedText(snippet) => {
+                    let text = snippet.text();
+                    if !text.trim().is_empty() {
+                        let mut estep = Element::new(ElementTag::Span);
+                        estep.push_attribute(Attribute::new("class", "uncaptured"));
+                        estep.push_child(Content::Text(text.into()));
+                        e.push_child(Content::Elt(estep));
+                    }
+                }
+                PartialStep::CapturedText {
+                    name: _,
+                    text,
+                    kind,
+                } => {
+                    if !text.trim().is_empty() {
+                        let mut estep = Element::new(ElementTag::Span);
+                        let class = format!("capture-{}", kind.as_str());
+                        estep.push_attribute(Attribute::new("class", &class));
+                        estep.push_child(Content::Text(text.into()));
+                        e.push_child(Content::Elt(estep));
+                    }
+                }
+            }
+        }
+        e
     }
 
     fn typeset_file(e: &Element) -> Result {
diff --git a/src/parser.rs b/src/parser.rs
deleted file mode 100644
index 35cb488..0000000
--- a/src/parser.rs
+++ /dev/null
@@ -1,43 +0,0 @@
-#[deny(missing_docs)]
-/// Parse a scenario snippet into logical lines.
-///
-/// Each logical line forms a scenario step. It may be divided into
-/// multiple physical lines.
-pub fn parse_scenario_snippet(snippet: &str) -> impl Iterator {
-    snippet.lines().filter(|line| !line.trim().is_empty())
-}
-
-#[cfg(test)]
-mod test {
-    use super::parse_scenario_snippet;
-
-    fn parse_lines(snippet: &str) -> Vec<&str> {
-        parse_scenario_snippet(snippet).collect()
-    }
-
-    #[test]
-    fn parses_empty_snippet_into_no_lines() {
-        assert_eq!(parse_lines("").len(), 0);
-    }
-
-    #[test]
-    fn parses_single_line() {
-        assert_eq!(parse_lines("given I am Tomjon"), vec!["given I am Tomjon"])
-    }
-
-    #[test]
-    fn parses_two_lines() {
-        assert_eq!(
-            parse_lines("given I am Tomjon\nwhen I declare myself king"),
-            vec!["given I am Tomjon", "when I declare myself king"]
-        )
-    }
-
-    #[test]
-    fn parses_two_lines_with_empty_line() {
-        assert_eq!(
-            parse_lines("given I am Tomjon\n\nwhen I declare myself king"),
-            vec!["given I am Tomjon", "when I declare myself king"]
-        )
-    }
-}
diff --git a/src/steps.rs b/src/steps.rs
index d9d1725..7f5e7d4 100644
--- a/src/steps.rs
+++ b/src/steps.rs
@@ -96,6 +96,85 @@ impl fmt::Display for ScenarioStep {
     }
 }
 
+/// Parse a scenario snippet into a vector of steps.
+pub(crate) fn parse_scenario_snippet(
+    text: &str,
+    loc: &Location,
+) -> Result, SubplotError> {
+    let mut steps = vec![];
+    let mut prevkind = None;
+    for (idx, line) in text.lines().enumerate() {
+        let line_loc = match loc.clone() {
+            Location::Known {
+                filename,
+                line,
+                col,
+            } => Location::Known {
+                filename,
+                line: line + idx,
+                col,
+            },
+            Location::Unknown => Location::Unknown,
+        };
+        if !line.trim().is_empty() {
+            let step = ScenarioStep::new_from_str(line, prevkind, line_loc)?;
+            prevkind = Some(step.kind());
+            steps.push(step);
+        }
+    }
+    Ok(steps)
+}
+
+#[cfg(test)]
+mod test_steps_parser {
+    use super::{parse_scenario_snippet, Location, ScenarioStep, StepKind, SubplotError};
+    use std::path::Path;
+
+    fn parse(text: &str) -> Result, SubplotError> {
+        let loc = Location::new(Path::new("test"), 1, 1);
+        parse_scenario_snippet(text, &loc)
+    }
+
+    #[test]
+    fn empty_string() {
+        assert_eq!(parse("").unwrap(), vec![]);
+    }
+
+    #[test]
+    fn simple() {
+        assert_eq!(
+            parse("given foo").unwrap(),
+            vec![ScenarioStep::new(
+                StepKind::Given,
+                "given",
+                "foo",
+                Location::new(Path::new("test"), 1, 1),
+            )]
+        );
+    }
+
+    #[test]
+    fn two_simple() {
+        assert_eq!(
+            parse("given foo\nthen bar\n").unwrap(),
+            vec![
+                ScenarioStep::new(
+                    StepKind::Given,
+                    "given",
+                    "foo",
+                    Location::new(Path::new("test"), 1, 1),
+                ),
+                ScenarioStep::new(
+                    StepKind::Then,
+                    "then",
+                    "bar",
+                    Location::new(Path::new("test"), 2, 1),
+                )
+            ]
+        );
+    }
+}
+
 /// The kind of scenario step we have: given, when, or then.
 ///
 /// This needs to be extended if the Subplot language gets extended with other
-- 
cgit v1.2.1