summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLars Wirzenius <liw@liw.fi>2023-12-01 18:01:44 +0200
committerLars Wirzenius <liw@liw.fi>2023-12-25 10:17:07 +0200
commit9972ba7f7c798e12741e9310a258df64c9310c05 (patch)
tree0084856ea6a9c3dbb4c4cedac83d3b24aa48bdc0
parentd6ba90c694fe8905aadf02acbbb0ff7e24f30b4e (diff)
downloadsubplot-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.rs2
-rw-r--r--src/bindings.rs14
-rw-r--r--src/doc.rs23
-rw-r--r--src/html.rs3
-rw-r--r--src/lib.rs3
-rw-r--r--src/matches.rs37
-rw-r--r--src/md.rs110
-rw-r--r--src/parser.rs43
-rw-r--r--src/steps.rs79
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),
}
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<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),
}
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<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