use crate::parser::parse_scenario_snippet; use crate::Bindings; use crate::MatchedScenario; use crate::PartialStep; use crate::Scenario; use crate::ScenarioStep; use crate::StepKind; use crate::{Error, Result}; use pandoc_ast::{Attr, Block, Inline, Map, MetaValue, MutVisitor, Pandoc}; use serde::{Deserialize, Serialize}; use roadmap; use std::io::prelude::*; use std::path::Path; use std::process::{Command, Stdio}; /// A parsed Subplot document. /// /// Pandoc works by parsing its various input files and constructing /// an abstract syntax tree or AST. When Pandoc generates output, it /// works based on the AST. This way, the input parsing and output /// generation are cleanly separated. /// /// A Pandoc filter can modify the AST before output generation /// starts working. This allows the filter to make changes to what /// gets output, without having to understand the input documents at /// all. /// /// This function is a Pandoc filter, to be use with /// pandoc_ast::filter, for typesetting Subplot documents. /// /// # Example /// /// fix this example; /// ~~~~ /// let markdown = "\ /// --- /// title: Test Title /// ... /// This is a test document. /// "; /// /// use std::io::Write; /// use tempfile::NamedTempFile; /// let mut f = NamedTempFile::new().unwrap(); /// f.write_all(markdown.as_bytes()).unwrap(); /// let filename = f.path(); /// /// use subplot; /// let doc = subplot::Document::from_file(filename).unwrap(); /// assert_eq!(doc.files(), &[]); /// ~~~~ #[derive(Debug)] pub struct Document { ast: Pandoc, meta: Metadata, files: DataFiles, } impl<'a> Document { fn new(ast: Pandoc, meta: Metadata, files: DataFiles) -> Document { Document { ast, meta, files } } /// Construct a Document from a JSON AST pub fn from_json(json: &str) -> Result { let mut ast: Pandoc = serde_json::from_str(json)?; let meta = Metadata::new(&ast)?; let files = DataFiles::new(&mut ast); Ok(Document::new(ast, meta, files)) } /// Construct a Document from a named file. /// /// The file can be in any format Pandoc understands. This runs /// Pandoc to parse the file into an AST, so it can be a little /// slow. pub fn from_file(filename: &Path) -> Result { let mut pandoc = pandoc::new(); pandoc.add_input(&filename); pandoc.set_output_format(pandoc::OutputFormat::Json, vec![]); pandoc.set_output(pandoc::OutputKind::Pipe); let output = match pandoc.execute()? { pandoc::PandocOutput::ToFile(_) => return Err(Error::NotJson), pandoc::PandocOutput::ToBuffer(o) => o, }; let doc = Document::from_json(&output)?; Ok(doc) } /// Return the AST of a Document, serialized as JSON. /// /// This is useful in a Pandoc filter, so that the filter can give /// it back to Pandoc for typesetting. pub fn ast(&self) -> Result { let json = serde_json::to_string(&self.ast)?; Ok(json) } /// Return the document's metadata. pub fn meta(&self) -> &Metadata { &self.meta } /// Return list of files embeddedin the document. pub fn files(&self) -> &[DataFile] { self.files.files() } /// Check that document has a title in its metadata. pub fn has_title(&self) -> Result<()> { if self.meta().title().is_empty() { Err(Error::no_title()) } else { Ok(()) } } /// Typeset a Subplot document. pub fn typeset(&mut self) { let mut visitor = TypesettingVisitor::new(&self.meta.bindings); visitor.walk_pandoc(&mut self.ast); } /// Return all scenarios in a document. pub fn scenarios(&mut self) -> Result> { let mut visitor = StructureVisitor::new(); visitor.walk_pandoc(&mut self.ast); let mut scenarios: Vec = vec![]; let mut i = 0; while i < visitor.elements.len() { let (maybe, new_i) = extract_scenario(&visitor.elements[i..])?; if let Some(scen) = maybe { scenarios.push(scen); } i += new_i; } Ok(scenarios) } /// Return matched scenarios in a document. pub fn matched_scenarios(&mut self) -> Result> { let scenarios = self.scenarios()?; let bindings = self.meta().bindings(); let vec: Result> = scenarios .iter() .map(|scen| MatchedScenario::new(scen, bindings)) .collect(); Ok(vec?) } } fn extract_scenario(e: &[Element]) -> Result<(Option, usize)> { if e.is_empty() { // If we get here, it's a programming error. panic!("didn't expect empty list of elements"); } match &e[0] { Element::Snippet(_) => Err(Error::scenario_before_heading()), Element::Heading(title, level) => { let mut scen = Scenario::new(&title); for (i, item) in e.iter().enumerate().skip(1) { match item { Element::Heading(_, level2) => { let is_subsection = *level2 > *level; if is_subsection { if scen.has_steps() { } else { return Ok((None, i)); } } else if scen.has_steps() { return Ok((Some(scen), i)); } else { return Ok((None, i)); } } Element::Snippet(text) => { for line in parse_scenario_snippet(&text) { let step = ScenarioStep::new_from_str(line)?; scen.add(&step); } } } } if scen.has_steps() { Ok((Some(scen), e.len())) } else { Ok((None, e.len())) } } } } #[cfg(test)] mod test_extract { use super::extract_scenario; use super::Element; use crate::Error; use crate::Result; use crate::Scenario; fn h(title: &str, level: i64) -> Element { Element::Heading(title.to_string(), level) } fn s(text: &str) -> Element { Element::Snippet(text.to_string()) } fn check_result(r: Result<(Option, usize)>, title: Option<&str>, i: usize) { eprintln!("checking result: {:?}", r); assert!(r.is_ok()); let (actual_scen, actual_i) = r.unwrap(); if title.is_none() { assert!(actual_scen.is_none()); } else { assert!(actual_scen.is_some()); let scen = actual_scen.unwrap(); assert_eq!(scen.title(), title.unwrap()); } assert_eq!(actual_i, i); } #[test] fn returns_nothing_if_there_is_no_scenario() { let elements: Vec = vec![h("title", 1)]; let r = extract_scenario(&elements); check_result(r, None, 1); } #[test] fn returns_scenario_if_there_is_one() { let elements = vec![h("title", 1), s("given something")]; let r = extract_scenario(&elements); check_result(r, Some("title"), 2); } #[test] fn skips_scenarioless_section_in_favour_of_same_level() { let elements = vec![h("first", 1), h("second", 1), s("given something")]; let r = extract_scenario(&elements); check_result(r, None, 1); let r = extract_scenario(&elements[1..]); check_result(r, Some("second"), 2); } #[test] fn returns_parent_section_with_scenario_snippet() { let elements = vec![ h("1", 1), s("given something"), h("1.1", 2), s("when something"), h("2", 1), ]; let r = extract_scenario(&elements); check_result(r, Some("1"), 4); let r = extract_scenario(&elements[4..]); check_result(r, None, 1); } #[test] fn skips_scenarioless_parent_heading() { let elements = vec![h("1", 1), h("1.1", 2), s("given something"), h("2", 1)]; let r = extract_scenario(&elements); check_result(r, None, 1); let r = extract_scenario(&elements[1..]); check_result(r, Some("1.1"), 2); let r = extract_scenario(&elements[3..]); check_result(r, None, 1); } #[test] fn skips_scenarioless_deeper_headings() { let elements = vec![h("1", 1), h("1.1", 2), h("2", 1), s("given something")]; let r = extract_scenario(&elements); check_result(r, None, 1); let r = extract_scenario(&elements[1..]); check_result(r, None, 1); let r = extract_scenario(&elements[2..]); check_result(r, Some("2"), 2); } #[test] fn returns_error_if_scenario_has_no_title() { let elements = vec![s("given something")]; let r = extract_scenario(&elements); match r { Err(Error::ScenarioBeforeHeading) => (), _ => panic!("unexpected result {:?}", r), } } } /// Metadata of a document, as needed by Subplot. #[derive(Debug)] pub struct Metadata { title: String, bindings_filename: Option, bindings: Bindings, functions_filename: Option, } impl Metadata { /// Construct a Metadata from a Document, if possible. pub fn new(doc: &Pandoc) -> Result { let title = get_title(&doc.meta)?; let bindings_filename = get_bindings_filename(&doc.meta)?; let functions_filename = get_functions_filename(&doc.meta)?; let mut bindings = Bindings::new(); if let Some(ref filename) = bindings_filename { get_bindings(filename, &mut bindings)?; } Ok(Metadata { title, bindings_filename, bindings, functions_filename, }) } /// Return title of document. pub fn title(&self) -> &str { &self.title } /// Return filename where bindings are specified. pub fn bindings_filename(&self) -> Option<&str> { match &self.bindings_filename { Some(filename) => Some(&filename), None => None, } } /// Return filename where functions are specified. pub fn functions_filename(&self) -> Option<&str> { match &self.functions_filename { Some(filename) => Some(&filename), None => None, } } /// Return the bindings. pub fn bindings(&self) -> &Bindings { &self.bindings } } type Mapp = Map; fn get_title(map: &Mapp) -> Result { if let Some(s) = get_string(map, "title") { Ok(s) } else { Ok("".to_string()) } } fn get_bindings_filename(map: &Mapp) -> Result> { match get_string(map, "bindings") { Some(s) => Ok(Some(s)), None => Ok(None), } } fn get_functions_filename(map: &Mapp) -> Result> { match get_string(map, "functions") { Some(s) => Ok(Some(s)), None => Ok(None), } } fn get_string(map: &Mapp, field: &str) -> Option { let v = match map.get(field) { None => return None, Some(s) => s, }; let v = match v { pandoc_ast::MetaValue::MetaString(s) => s.to_string(), pandoc_ast::MetaValue::MetaInlines(vec) => join(&vec), _ => panic!("don't know how to handle: {:?}", v), }; Some(v) } fn join(vec: &[Inline]) -> String { let mut buf = String::new(); join_into_buffer(vec, &mut buf); buf } fn join_into_buffer(vec: &[Inline], buf: &mut String) { for item in vec { match item { pandoc_ast::Inline::Str(s) => buf.push_str(&s), pandoc_ast::Inline::Emph(v) => join_into_buffer(v, buf), pandoc_ast::Inline::Strong(v) => join_into_buffer(v, buf), pandoc_ast::Inline::Strikeout(v) => join_into_buffer(v, buf), pandoc_ast::Inline::Superscript(v) => join_into_buffer(v, buf), pandoc_ast::Inline::Subscript(v) => join_into_buffer(v, buf), pandoc_ast::Inline::SmallCaps(v) => join_into_buffer(v, buf), pandoc_ast::Inline::Space => buf.push_str(" "), pandoc_ast::Inline::SoftBreak => buf.push_str(" "), pandoc_ast::Inline::LineBreak => buf.push_str(" "), _ => panic!("unknown pandoc_ast::Inline component {:?}", item), } } } #[cfg(test)] mod test_join { use super::join; use pandoc_ast::Inline; #[test] fn join_all_kinds() { let v = vec![ Inline::Str("a".to_string()), Inline::Emph(vec![Inline::Str("b".to_string())]), Inline::Strong(vec![Inline::Str("c".to_string())]), Inline::Strikeout(vec![Inline::Str("d".to_string())]), Inline::Superscript(vec![Inline::Str("e".to_string())]), Inline::Subscript(vec![Inline::Str("f".to_string())]), Inline::SmallCaps(vec![Inline::Str("g".to_string())]), Inline::Space, Inline::SoftBreak, Inline::LineBreak, ]; assert_eq!(join(&v), "abcdefg "); } } fn get_bindings(filename: &str, bindings: &mut Bindings) -> Result<()> { bindings.add_from_file(&filename)?; Ok(()) } // We need to have something to hang an impl of MutVisitor off of. The // struct here will do, but doesn't otherwise matter and contains no // data. struct TypesettingVisitor<'a> { bindings: &'a Bindings, } impl<'a> TypesettingVisitor<'a> { fn new(bindings: &'a Bindings) -> Self { TypesettingVisitor { bindings } } } // Visit interesting parts of the Pandoc abstract syntax tree. The // document top level is a vector of blocks and we visit that and // replace any fenced code block with the scenario tag with a typeset // paragraph. Also, replace fenced code blocks with dot or roadmap // markup with the rendered SVG image. impl<'a> MutVisitor for TypesettingVisitor<'a> { fn visit_vec_block(&mut self, vec_block: &mut Vec) { for block in vec_block { match block { Block::CodeBlock(attr, s) => { if is_class(attr, "scenario") { *block = scenario_snippet(&self.bindings, s) } else if is_class(attr, "file") { *block = file_block(attr, s) } else if is_class(attr, "dot") { *block = dot(s) } else if is_class(attr, "roadmap") { *block = roadmap_block(s) } } _ => { self.visit_block(block); } } } } } // Is a code block marked as being of a given type? fn is_class(attr: &Attr, class: &str) -> bool { match attr { (_id, classes, _kvpairs) => classes.iter().any(|s| s == class), } } /// Typeset an error from dot as a Pandoc AST Block element. pub fn error(err: Error) -> Block { let msg = format!("ERROR: {}", err.to_string()); Block::Para(error_msg(&msg)) } // Typeset an error message a vector of inlines. pub fn error_msg(msg: &str) -> Vec { vec![Inline::Strong(vec![inlinestr(msg)])] } /// Typeset a code block tagged as a file. pub fn file_block(attr: &Attr, text: &str) -> Block { let filename = inlinestr(&attr.0); let filename = Inline::Strong(vec![filename]); let intro = Block::Plain(vec![inlinestr("File:"), space(), filename]); let codeblock = Block::CodeBlock(attr.clone(), text.to_string()); let noattr = ("".to_string(), vec![], vec![]); Block::Div(noattr, vec![intro, codeblock]) } /// Typeset a scenario snippet as a Pandoc AST Block. /// /// Typesetting here means producing the Pandoc abstract syntax tree /// nodes that result in the desired output, when Pandoc processes /// them. /// /// The snippet is given as a text string, which is parsed. It need /// not be a complete scenario, but it should consist of complete steps. pub fn scenario_snippet(bindings: &Bindings, snippet: &str) -> Block { let steps = parse_scenario_snippet(snippet) .map(|s| step(bindings, s)) .collect(); Block::LineBlock(steps) } // Typeset a single scenario step as a sequence of Pandoc AST Inlines. fn step(bindings: &Bindings, text: &str) -> Vec { let step = ScenarioStep::new_from_str(text); if step.is_err() { eprintln!("Could not parse step: {}", text); return error_msg(&format!("Could not parse step: {}", text)); } let step = step.unwrap(); let m = bindings.find(&step); if m.is_none() { eprintln!("Could not findind binding for: {}", text); return error_msg(&format!("Could not find binding for: {}", text)); } let m = m.unwrap(); let mut inlines = Vec::new(); inlines.push(keyword(&step)); inlines.push(space()); for part in m.parts() { #[allow(unused_variables)] match part { PartialStep::UncapturedText(s) => inlines.push(uncaptured(s.text())), PartialStep::CapturedText { name, text } => inlines.push(captured(text)), } } inlines } // Typeset first word, which is assumed to be a keyword, of a scenario // step. fn keyword(step: &ScenarioStep) -> Inline { let word = match step.kind() { StepKind::Given => "given", StepKind::When => "when", StepKind::Then => "then", }; let word = inlinestr(word); Inline::Emph(vec![word]) } fn inlinestr(s: &str) -> Inline { Inline::Str(String::from(s)) } // Typeset a space between words. fn space() -> Inline { Inline::Space } // Typeset an uncaptured part of a step. fn uncaptured(s: &str) -> Inline { inlinestr(s) } // Typeset a captured part of a step. fn captured(s: &str) -> Inline { Inline::Strong(vec![inlinestr(s)]) } /// Typeset a dot graph as SVG. pub fn dot(dot: &str) -> Block { dot_to_block(dot) } /// Typeset a project roadmap expressed as textual YAML, and render it /// as an SVG image. pub fn roadmap_block(yaml: &str) -> Block { match roadmap::from_yaml(yaml) { Ok(ref mut roadmap) => { roadmap.set_missing_statuses(); let width = 50; match roadmap.format_as_dot(width) { Ok(dot) => dot_to_block(&dot), Err(e) => Block::Para(vec![inlinestr(&e.to_string())]), } } Err(e) => Block::Para(vec![inlinestr(&e.to_string())]), } } // Take a dot graph, render it as SVG, and return an AST Block // element. The Block will contain the SVG data. This allows the graph // to be rendered without referending external entities. fn dot_to_block(dot: &str) -> Block { match dot_to_svg(dot) { Ok(svg) => typeset_svg(svg), Err(err) => { eprintln!("dot failed: {}", err); error(err) } } } // Typeset an SVG, represented as a byte vector, as a Pandoc AST Block // element. fn typeset_svg(svg: Vec) -> Block { let url = svg_as_data_url(svg); let attr = ("".to_string(), vec![], vec![]); let img = Inline::Image(attr, vec![], (url, "".to_string())); Block::Para(vec![img]) } // Convert an SVG, represented as a byte vector, into a data: URL, // which can be inlined so the image can be rendered without // referencing external files. fn svg_as_data_url(svg: Vec) -> String { let svg = base64::encode(&svg); format!("data:image/svg+xml;base64,{}", svg) } // Generate an SVG from a textual graph description using the // GraphViz dot language. // // This runs the dot command as a subprocess. That can always fail: // dot might not be installed, or the input data might be malformed, // or something else. Thus we capture and return any error from // running dot. // // ~~~~ // let dot = r#"digraph "foo" { a -> b }"#; // let svg = subplot::dot_to_svg(&dot).unwrap(); // assert!(svg.len() > 0); // ~~~~ fn dot_to_svg(dot: &str) -> Result> { let mut child = Command::new("dot") .arg("-Tsvg") .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) .spawn()?; if let Some(stdin) = child.stdin.as_mut() { stdin.write_all(dot.as_bytes())?; let output = child.wait_with_output()?; if output.status.success() { Ok(output.stdout) } else { Err(Error::child_failed("dot", &output)) } } else { Err(Error::child_no_stdin()) } } // A structure element in the document: a heading or a scenario snippet. #[derive(Debug)] enum Element { // Headings consist of the text and the level of the heading. Heading(String, i64), // Scenario snippets consist just of the unparsed text. Snippet(String), } impl Element { pub fn heading(text: &str, level: i64) -> Element { Element::Heading(text.to_string(), level) } pub fn snippet(text: &str) -> Element { Element::Snippet(text.to_string()) } } // A MutVisitor for extracting document structure. struct StructureVisitor { elements: Vec, } impl StructureVisitor { pub fn new() -> Self { Self { elements: vec![] } } } impl MutVisitor for StructureVisitor { fn visit_vec_block(&mut self, vec_block: &mut Vec) { for block in vec_block { match block { Block::Header(level, _attr, inlines) => { let text = join(inlines); let heading = Element::heading(&text, *level); self.elements.push(heading); } Block::CodeBlock(attr, s) => { if is_class(attr, "scenario") { let snippet = Element::snippet(s); self.elements.push(snippet); } } _ => { self.visit_block(block); } } } } } /// A data file embedded in the document. #[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)] pub struct DataFile { filename: String, contents: String, } impl DataFile { fn new(filename: &str, contents: &str) -> DataFile { DataFile { filename: filename.to_string(), contents: contents.to_string(), } } pub fn filename(&self) -> &str { &self.filename } pub fn contents(&self) -> &str { &self.contents } } /// A collection of data files embedded in document. #[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)] struct DataFiles { files: Vec, } impl DataFiles { fn new(ast: &mut Pandoc) -> DataFiles { let mut files = DataFiles { files: vec![] }; files.walk_pandoc(ast); files } fn files(&self) -> &[DataFile] { &self.files } } impl MutVisitor for DataFiles { fn visit_vec_block(&mut self, vec_block: &mut Vec) { for block in vec_block { match block { Block::CodeBlock(attr, contents) => { if is_class(attr, "file") { self.files .push(DataFile::new(&get_filename(attr), &contents)); } } _ => { self.visit_block(block); } } } } } fn get_filename(attr: &Attr) -> String { attr.0.to_string() }