use crate::ast; use crate::generate_test_program; use crate::get_basedir_from; use crate::visitor; use crate::DataFile; use crate::DataFiles; use crate::LintingVisitor; use crate::MatchedScenario; use crate::Metadata; use crate::PartialStep; use crate::Scenario; use crate::ScenarioStep; use crate::Style; use crate::{bindings::CaptureType, parser::parse_scenario_snippet}; use crate::{Result, SubplotError}; use crate::{Warning, Warnings}; use std::collections::HashSet; use std::default::Default; use std::fmt::Debug; use std::ops::Deref; use std::path::{Path, PathBuf}; use std::str::FromStr; use pandoc_ast::{MutVisitor, Pandoc}; use log::{error, trace}; /// The set of known (special) classes which subplot will always recognise /// as being valid. static SPECIAL_CLASSES: &[&str] = &["scenario", "file", "dot", "pikchr", "plantuml", "roadmap"]; /// The set of known (file-type) classes which subplot will always recognise /// as being valid. static KNOWN_FILE_CLASSES: &[&str] = &["rust", "yaml", "python", "sh", "shell", "markdown", "bash"]; /// The set of known (special-to-pandoc) classes which subplot will always recognise /// as being valid. We include the subplot-specific noNumberLines class which we use /// to invert the default numberLines on .file blocks. static KNOWN_PANDOC_CLASSES: &[&str] = &["numberLines", "noNumberLines"]; /// 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 basedir = std::path::Path::new("."); /// let style = subplot::Style::default(); /// let doc = subplot::Document::from_file(&basedir, filename, style, None).unwrap(); /// assert_eq!(doc.files(), &[]); /// ~~~~ #[derive(Debug)] pub struct Document { markdowns: Vec, ast: Pandoc, meta: Metadata, files: DataFiles, style: Style, warnings: Warnings, } impl<'a> Document { fn new( markdowns: Vec, ast: Pandoc, meta: Metadata, files: DataFiles, style: Style, ) -> Document { Document { markdowns, ast, meta, files, style, warnings: Warnings::default(), } } /// Return all warnings about this document. pub fn warnings(&self) -> &[Warning] { self.warnings.warnings() } fn from_ast

( basedir: P, markdowns: Vec, mut ast: Pandoc, style: Style, template: Option<&str>, ) -> Result where P: AsRef + Debug, { let meta = Metadata::new(basedir, &ast, template)?; let mut linter = LintingVisitor::default(); trace!("Walking AST for linting..."); linter.walk_pandoc(&mut ast); if !linter.issues.is_empty() { // Currently we can't really return more than one error so return one return Err(linter.issues.remove(0)); } let files = DataFiles::new(&mut ast); let doc = Document::new(markdowns, ast, meta, files, style); trace!("Loaded from JSON OK"); Ok(doc) } /// Construct a Document from a JSON AST pub fn from_json

( basedir: P, markdowns: Vec, json: &str, style: Style, template: Option<&str>, ) -> Result where P: AsRef + Debug, { trace!("Parsing document..."); let ast: Pandoc = serde_json::from_str(json)?; Self::from_ast(basedir, markdowns, ast, style, template) } /// 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( basedir: &Path, filename: &Path, style: Style, template: Option<&str>, ) -> Result { trace!( "Document::from_file: basedir={} filename={}", basedir.display(), filename.display() ); let markdowns = vec![filename.to_path_buf()]; let mut pandoc = pandoc::new(); pandoc.add_input(&filename); pandoc.set_input_format( pandoc::InputFormat::Markdown, vec![pandoc::MarkdownExtension::Citations], ); pandoc.set_output_format(pandoc::OutputFormat::Json, vec![]); pandoc.set_output(pandoc::OutputKind::Pipe); // Add external Pandoc filters. crate::policy::add_citeproc(&mut pandoc); trace!("Invoking Pandoc to parse document {:?}", filename); let output = match pandoc.execute()? { pandoc::PandocOutput::ToBuffer(o) => o, _ => return Err(SubplotError::NotJson), }; trace!("Pandoc was happy"); let doc = Document::from_json(basedir, markdowns, &output, style, template)?; trace!("Loaded document OK"); Ok(doc) } /// Construct a Document from a named file, using the pullmark_cmark crate. /// /// The file can be in the CommonMark format, with some /// extensions. This uses the pulldown-cmark crate to parse the /// file into an AST. pub fn from_file_with_pullmark( basedir: &Path, filename: &Path, style: Style, template: Option<&str>, ) -> Result { trace!("Parsing document with pullmark-cmark from {:?}", filename); let filename = filename.to_path_buf(); let markdown = std::fs::read_to_string(&filename)?; let ast = ast::AbstractSyntaxTree::from_str(&markdown)?; trace!("Parsed document OK"); Self::from_ast(basedir, vec![filename], ast.to_pandoc(), style, template) } /// 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 all source filenames for the document. /// /// The sources are any files that affect the output so that if /// the source file changes, the output needs to be re-generated. pub fn sources(&mut self, template: Option<&str>) -> Vec { let mut names = vec![]; for x in self.meta().bindings_filenames() { names.push(PathBuf::from(x)) } if let Some(template) = template { if let Some(spec) = self.meta().document_impl(template) { for x in spec.functions_filenames() { names.push(PathBuf::from(x)); } } } else { for template in self.meta().templates() { if let Some(spec) = self.meta().document_impl(template) { for x in spec.functions_filenames() { names.push(PathBuf::from(x)); } } } } for x in self.meta().bibliographies().iter() { names.push(PathBuf::from(x)) } for x in self.markdowns.iter() { names.push(x.to_path_buf()); } let mut visitor = visitor::ImageVisitor::new(); visitor.walk_pandoc(&mut self.ast); for x in visitor.images().iter() { names.push(x.to_path_buf()); } names } /// Return list of files embeddedin the document. pub fn files(&self) -> &[DataFile] { self.files.files() } /// Check the document for common problems. pub fn lint(&self) -> Result<()> { trace!("Linting document"); self.check_doc_has_title()?; self.check_filenames_are_unique()?; self.check_block_classes()?; trace!("No linting problems found"); Ok(()) } // Check that all filenames for embedded files are unique. fn check_filenames_are_unique(&self) -> Result<()> { let mut known = HashSet::new(); for filename in self.files().iter().map(|f| f.filename().to_lowercase()) { if known.contains(&filename) { return Err(SubplotError::DuplicateEmbeddedFilename(filename)); } known.insert(filename); } Ok(()) } // Check that document has a title in its metadata. fn check_doc_has_title(&self) -> Result<()> { if self.meta().title().is_empty() { Err(SubplotError::NoTitle) } else { Ok(()) } } /// Check that all the block classes in the document are known fn check_block_classes(&self) -> Result<()> { let mut visitor = visitor::BlockClassVisitor::default(); // Irritatingly we can't immutably visit the AST for some reason // This clone() is expensive and unwanted, but I'm not sure how // to get around it for now visitor.walk_pandoc(&mut self.ast.clone()); // Build the set of known good classes let mut known_classes: HashSet = HashSet::new(); for class in std::iter::empty() .chain(SPECIAL_CLASSES.iter().map(Deref::deref)) .chain(KNOWN_FILE_CLASSES.iter().map(Deref::deref)) .chain(KNOWN_PANDOC_CLASSES.iter().map(Deref::deref)) .chain(self.meta().classes()) { known_classes.insert(class.to_string()); } // Acquire the set of used names which are not known let unknown_classes: Vec<_> = visitor .classes .difference(&known_classes) .cloned() .collect(); // If the unknown classes list is not empty, we had a problem and // we will report it to the user. if !unknown_classes.is_empty() { Err(SubplotError::UnknownClasses(unknown_classes.join(", "))) } else { Ok(()) } } /// Check that all named files (in matched steps) are actually present in the /// document. pub fn check_named_files_exist(&mut self, template: &str) -> Result { let filenames: HashSet<_> = self .files() .iter() .map(|f| f.filename().to_lowercase()) .collect(); trace!("Checking that files exist"); let mut okay = true; let scenarios = match self.matched_scenarios(template) { Ok(scenarios) => scenarios, Err(_) => return Ok(true), // We can't do the check, so say it's okay. }; for scenario in scenarios { for step in scenario.steps() { for captured in step.parts() { if let PartialStep::CapturedText { name, text } = captured { if matches!(step.types().get(name.as_str()), Some(CaptureType::File)) && !filenames.contains(&text.to_lowercase()) { self.warnings.push(Warning::UnknownEmbeddedFile( scenario.title().to_string(), text.to_string(), )); okay = false; } } } } } Ok(okay) } /// Check that all embedded files are used by matched steps. pub fn check_embedded_files_are_used(&mut self, template: &str) -> Result { let mut filenames: HashSet<_> = self .files() .iter() .map(|f| f.filename().to_lowercase()) .collect(); trace!("Checking that files are used"); let scenarios = match self.matched_scenarios(template) { Ok(scenarios) => scenarios, Err(_) => return Ok(true), // We can't do the check, so say it's okay. }; for scenario in scenarios { for step in scenario.steps() { for captured in step.parts() { if let PartialStep::CapturedText { name, text } = captured { if matches!(step.types().get(name.as_str()), Some(CaptureType::File)) { filenames.remove(&text.to_lowercase()); } } } } } for filename in filenames.iter() { self.warnings .push(Warning::UnusedEmbeddedFile(filename.to_string())); } // We always succeed. Subplot's own subplot had valid cases of // an embedded file being used and we need to develop a way to // mark such uses as OK, before we can make it an error to not // use an embedded file in a scenario. Ok(true) } /// Check that all matched steps actually have function implementations pub fn check_matched_steps_have_impl(&mut self, template: &str) -> bool { trace!("Checking that steps have implementations"); let mut okay = true; let scenarios = match self.matched_scenarios(template) { Ok(scenarios) => scenarios, Err(_) => return true, // No matches means no missing impls }; trace!("Found {} scenarios", scenarios.len()); for scenario in scenarios { trace!("Checking that steps in scenario"); for step in scenario.steps() { if step.function().is_none() { trace!("Missing step implementation: {:?}", step.text()); self.warnings.push(Warning::MissingStepImplementation( scenario.title().to_string(), step.text().to_string(), )); okay = false; } } } okay } /// Typeset a Subplot document. pub fn typeset(&mut self) { let mut visitor = visitor::TypesettingVisitor::new(self.style.clone(), self.meta.bindings()); visitor.walk_pandoc(&mut self.ast); self.warnings.push_all(visitor.warnings()); } /// Return all scenarios in a document. pub fn scenarios(&mut self) -> Result> { let mut visitor = 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, template: &str) -> Result> { let scenarios = self.scenarios()?; trace!( "Found {} scenarios, checking their bindings", scenarios.len() ); let bindings = self.meta().bindings(); scenarios .iter() .map(|scen| MatchedScenario::new(template, scen, bindings)) .collect() } /// Extract a template name from this document pub fn template(&self) -> Result<&str> { let templates: Vec<_> = self.meta().templates().collect(); if templates.len() == 1 { Ok(templates[0]) } else if templates.is_empty() { Err(SubplotError::MissingTemplate) } else { Err(SubplotError::AmbiguousTemplate) } } } /// Load a `Document` from a file. /// /// This version uses Pandoc to parse the Markdown. pub fn load_document

(filename: P, style: Style, template: Option<&str>) -> Result where P: AsRef + Debug, { let filename = filename.as_ref(); let base_path = get_basedir_from(filename); trace!( "Loading document based at `{}` called `{}` with {:?}", base_path.display(), filename.display(), style ); let doc = Document::from_file(&base_path, filename, style, template)?; trace!("Loaded doc from file OK"); Ok(doc) } /// Load a `Document` from a file. /// /// This version uses the `cmark-pullmark` crate to parse Markdown. pub fn load_document_with_pullmark

( filename: P, style: Style, template: Option<&str>, ) -> Result where P: AsRef + Debug, { let filename = filename.as_ref(); let base_path = get_basedir_from(filename); trace!( "Loading document based at `{}` called `{}` with {:?} using pullmark-cmark", base_path.display(), filename.display(), style ); crate::resource::add_search_path(filename.parent().unwrap()); let doc = Document::from_file_with_pullmark(&base_path, filename, style, template)?; trace!("Loaded doc from file OK"); Ok(doc) } /// Generate code for one document. pub fn codegen(filename: &Path, output: &Path, template: Option<&str>) -> Result { let r = load_document_with_pullmark(filename, Style::default(), template); let mut doc = match r { Ok(doc) => doc, Err(err) => { return Err(err); } }; doc.lint()?; let template = template .map(Ok) .unwrap_or_else(|| doc.template())? .to_string(); trace!("Template: {:?}", template); if !doc.meta().templates().any(|t| t == template) { return Err(SubplotError::TemplateSupportNotPresent); } if !doc.check_named_files_exist(&template)? || !doc.check_matched_steps_have_impl(&template) || !doc.check_embedded_files_are_used(&template)? { error!("Found problems in document, cannot continue"); std::process::exit(1); } trace!("Generating code"); generate_test_program(&mut doc, output, &template)?; trace!("Finished generating code"); Ok(CodegenOutput::new(template, doc)) } pub struct CodegenOutput { pub template: String, pub doc: Document, } impl CodegenOutput { fn new(template: String, doc: Document) -> Self { Self { template, doc } } } fn extract_scenario(e: &[visitor::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] { visitor::Element::Snippet(_) => Err(SubplotError::ScenarioBeforeHeading), visitor::Element::Heading(title, level) => { let mut scen = Scenario::new(title); let mut prevkind = None; for (i, item) in e.iter().enumerate().skip(1) { match item { visitor::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)); } } visitor::Element::Snippet(text) => { for line in parse_scenario_snippet(text) { let step = ScenarioStep::new_from_str(line, prevkind)?; scen.add(&step); prevkind = Some(step.kind()); } } } } 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::visitor::Element; use crate::Result; use crate::Scenario; use crate::SubplotError; 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) { 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(SubplotError::ScenarioBeforeHeading) => (), _ => panic!("unexpected result {:?}", r), } } }