diff options
author | Lars Wirzenius <liw@liw.fi> | 2023-12-01 18:01:44 +0200 |
---|---|---|
committer | Lars Wirzenius <liw@liw.fi> | 2023-12-25 10:17:07 +0200 |
commit | 9972ba7f7c798e12741e9310a258df64c9310c05 (patch) | |
tree | 0084856ea6a9c3dbb4c4cedac83d3b24aa48bdc0 | |
parent | d6ba90c694fe8905aadf02acbbb0ff7e24f30b4e (diff) | |
download | subplot-9972ba7f7c798e12741e9310a258df64c9310c05.tar.gz |
feat: typeset scenarios by typesetting steps
Typeset each step in a scenario, including captures in the steps.
Previously the scenario was just one <pre> element, now things can be
styled with CSS.
Signed-off-by: Lars Wirzenius <liw@liw.fi>
Sponsored-by: author
-rw-r--r-- | src/bin/subplot.rs | 2 | ||||
-rw-r--r-- | src/bindings.rs | 14 | ||||
-rw-r--r-- | src/doc.rs | 23 | ||||
-rw-r--r-- | src/html.rs | 3 | ||||
-rw-r--r-- | src/lib.rs | 3 | ||||
-rw-r--r-- | src/matches.rs | 37 | ||||
-rw-r--r-- | src/md.rs | 110 | ||||
-rw-r--r-- | src/parser.rs | 43 | ||||
-rw-r--r-- | src/steps.rs | 79 |
9 files changed, 220 insertions, 94 deletions
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<Binding, SubplotError> { #[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), } @@ -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, @@ -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<String, CaptureType> { &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), } @@ -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<Scenario>, 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<Scenario>, 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<Scenario>, 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<Element, SubplotError> { + pub(crate) fn typeset_element( + e: &Element, + template: Option<&str>, + bindings: &Bindings, + ) -> Result<Element, SubplotError> { 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<Element, SubplotError> { - Ok(e.clone()) // FIXME + fn typeset_scenario( + e: &Element, + template: Option<&str>, + bindings: &Bindings, + ) -> Result<Element, SubplotError> { + 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<Element, SubplotError> { 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<Item = &str> { - 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<Vec<ScenarioStep>, 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<Vec<ScenarioStep>, 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 |