use crate::bindings::CaptureType; use crate::generate_test_program; use crate::get_basedir_from; use crate::html::Attribute; use crate::html::HtmlPage; use crate::html::{Content, Element, ElementTag}; use crate::md::Markdown; use crate::resource; use crate::EmbeddedFile; use crate::EmbeddedFiles; use crate::MatchedScenario; use crate::PartialStep; use crate::Scenario; use crate::Style; use crate::SubplotError; use crate::{Metadata, YamlMetadata}; use crate::{Warning, Warnings}; use std::cmp::Ordering; use std::collections::HashSet; use std::default::Default; use std::fmt::Debug; use std::fs::read; use std::ops::Deref; use std::path::{Path, PathBuf}; use log::{error, trace}; /// Name of standard Subplot CSS file. const CSS: &str = "subplot.css"; /// The set of known (special) classes which subplot will always recognise /// as being valid. static SPECIAL_CLASSES: &[&str] = &[ "scenario", "file", "example", "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 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_BLOCK_CLASSES: &[&str] = &["numberLines", "noNumberLines"]; /// The set of classes which subplot will recognise as being appropriate /// for having IDs. static ID_OK_CLASSES: &[&str] = &["file", "example"]; /// A parsed Subplot document. /// /// # Example /// /// fix this example; /// ~~~~ignored /// 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 { subplot: PathBuf, markdowns: Vec, meta: Metadata, files: EmbeddedFiles, style: Style, } impl Document { fn new( subplot: PathBuf, markdowns: Vec, meta: Metadata, files: EmbeddedFiles, style: Style, ) -> Document { let doc = Document { subplot, markdowns, meta, files, style, }; trace!("Document::new -> {:#?}", doc); doc } fn all_files(markdowns: &[Markdown]) -> Result { let mut files = EmbeddedFiles::default(); for md in markdowns { for file in md.embedded_files()?.files() { files.push(file.clone()); } } Ok(files) } /// Construct a Document from a named file. 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 meta = load_metadata_from_yaml_file(filename)?; trace!("metadata from YAML file: {:#?}", meta); let mut markdowns = vec![]; for filename in meta.markdowns() { let filename = basedir.join(filename); markdowns.push(Markdown::load_file(&filename)?); } let meta = Metadata::from_yaml_metadata(basedir, &meta, template)?; trace!("metadata from YAML: {:#?}", meta); let files = Self::all_files(&markdowns)?; let doc = Document::new(filename.into(), markdowns, meta, files, style); trace!("Loaded document OK"); Ok(doc) } /// Return Document as an HTML page serialized into HTML text pub fn to_html(&mut self, date: &str) -> Result { let css_file = resource::read_as_string(CSS, None) .map_err(|e| SubplotError::CssFileNotFound(CSS.into(), e))?; let mut head = Element::new(crate::html::ElementTag::Head); let mut title = Element::new(crate::html::ElementTag::Title); title.push_child(crate::html::Content::Text(self.meta().title().into())); head.push_child(crate::html::Content::Elt(title)); let mut css = Element::new(ElementTag::Style); css.push_child(Content::Text(css_file)); for css_file in self.meta.css_embed() { css.push_child(Content::Text(css_file.into())); } head.push_child(Content::Elt(css)); for css_url in self.meta.css_urls() { let mut link = Element::new(ElementTag::Link); link.push_attribute(Attribute::new("rel", "stylesheet")); link.push_attribute(Attribute::new("type", "text/css")); link.push_attribute(Attribute::new("href", css_url)); head.push_child(Content::Elt(link)); } self.meta.set_date(date.into()); let mut body_content = Element::new(crate::html::ElementTag::Div); body_content.push_attribute(Attribute::new("class", "content")); for md in self.markdowns.iter() { body_content.push_child(Content::Elt(md.root_element().clone())); } let mut body = Element::new(crate::html::ElementTag::Div); body.push_child(Content::Elt(self.typeset_meta())); body.push_child(Content::Elt(self.typeset_toc(&body_content))); body.push_child(Content::Elt(body_content)); let page = HtmlPage::new(head, body); page.serialize().map_err(SubplotError::ParseMarkdown) } fn typeset_toc(&self, body: &Element) -> Element { let mut toc = Element::new(ElementTag::Div); toc.push_attribute(Attribute::new("class", "toc")); let mut heading = Element::new(ElementTag::H1); heading.push_child(Content::Text("Table of Contents".into())); toc.push_child(Content::Elt(heading)); let heading_elements: Vec<&Element> = crate::md::Markdown::visit(body) .iter() .filter(|e| { matches!( e.tag(), ElementTag::H1 | ElementTag::H2 | ElementTag::H3 | ElementTag::H4 | ElementTag::H5 | ElementTag::H6 ) }) .cloned() .collect(); let mut headings = vec![]; for e in heading_elements { let id = e .attr("id") .expect("heading has id") .value() .expect("id attribute has value"); match e.tag() { ElementTag::H1 => headings.push((1, e.content(), id)), ElementTag::H2 => headings.push((2, e.content(), id)), ElementTag::H3 => headings.push((3, e.content(), id)), ElementTag::H4 => headings.push((4, e.content(), id)), ElementTag::H5 => headings.push((5, e.content(), id)), ElementTag::H6 => headings.push((6, e.content(), id)), _ => (), } } let mut stack = vec![]; let mut numberer = HeadingNumberer::default(); for (level, text, id) in headings { assert!(level >= 1); assert!(level <= 6); let mut number = Element::new(ElementTag::Span); number.push_attribute(Attribute::new("class", "heading-number")); number.push_child(Content::Text(numberer.number(level))); let mut htext = Element::new(ElementTag::Span); htext.push_attribute(Attribute::new("class", "heading-text")); htext.push_child(Content::Text(text)); let mut a = Element::new(ElementTag::A); a.push_attribute(crate::html::Attribute::new("href", &format!("#{}", id))); a.push_attribute(Attribute::new("class", "toc-link")); a.push_child(Content::Elt(number)); a.push_child(Content::Text(" ".into())); a.push_child(Content::Elt(htext)); let mut li = Element::new(ElementTag::Li); li.push_child(Content::Elt(a)); match level.cmp(&stack.len()) { Ordering::Equal => (), Ordering::Greater => stack.push(Element::new(ElementTag::Ol)), Ordering::Less => { assert!(!stack.is_empty()); let child = stack.pop().unwrap(); assert!(child.tag() == ElementTag::Ol); let mut li = Element::new(ElementTag::Li); li.push_child(Content::Elt(child)); assert!(!stack.is_empty()); let mut parent = stack.pop().unwrap(); parent.push_child(Content::Elt(li)); stack.push(parent); } } assert!(!stack.is_empty()); let mut ol = stack.pop().unwrap(); ol.push_child(Content::Elt(li)); stack.push(ol); } while stack.len() > 1 { let child = stack.pop().unwrap(); assert!(child.tag() == ElementTag::Ol); let mut li = Element::new(ElementTag::Li); li.push_child(Content::Elt(child)); let mut parent = stack.pop().unwrap(); parent.push_child(Content::Elt(li)); stack.push(parent); } assert!(stack.len() <= 1); if let Some(ol) = stack.pop() { toc.push_child(Content::Elt(ol)); } toc } fn typeset_meta(&self) -> Element { let mut div = Element::new(ElementTag::Div); div.push_attribute(Attribute::new("class", "meta")); div.push_child(Content::Elt(Self::title(self.meta.title()))); if let Some(authors) = self.meta.authors() { div.push_child(Content::Elt(Self::authors(authors))); } if let Some(date) = self.meta.date() { div.push_child(Content::Elt(Self::date(date))); } div } fn title(title: &str) -> Element { let mut e = Element::new(ElementTag::H1); e.push_attribute(Attribute::new("class", "title")); e.push_child(Content::Text(title.into())); e } fn authors(authors: &[String]) -> Element { let mut list = Element::new(ElementTag::P); list.push_attribute(Attribute::new("class", "authors")); list.push_child(Content::Text("By: ".into())); let mut first = true; for a in authors { if !first { list.push_child(Content::Text(", ".into())); } list.push_child(Content::Text(a.into())); first = false; } list } fn date(date: &str) -> Element { let mut e = Element::new(ElementTag::P); e.push_attribute(Attribute::new("class", "date")); e.push_child(Content::Text(date.into())); e } /// Return the document's metadata. pub fn meta(&self) -> &Metadata { &self.meta } /// Set document date. pub fn set_date(&mut self, date: String) { self.meta.set_date(date); } /// 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(&self, template: Option<&str>) -> Vec { let mut names = vec![self.subplot.clone()]; 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 name in self.meta().markdown_filenames() { names.push(name.into()); } for md in self.markdowns.iter() { let mut images = md.images(); names.append(&mut images); } names } /// Return list of files embeddedin the document. pub fn embedded_files(&self) -> &[EmbeddedFile] { self.files.files() } /// Check the document for common problems. pub fn lint(&self) -> Result<(), SubplotError> { trace!("Linting document"); self.check_doc_has_title()?; self.check_scenarios_are_unique()?; self.check_filenames_are_unique()?; self.check_block_classes()?; trace!("No linting problems found"); Ok(()) } // Check that all titles for scenarios are unique. fn check_scenarios_are_unique(&self) -> Result<(), SubplotError> { let mut known = HashSet::new(); for title in self.scenarios()?.iter().map(|s| s.title().to_lowercase()) { if known.contains(&title) { return Err(SubplotError::DuplicateScenario(title)); } known.insert(title); } Ok(()) } // Check that all filenames for embedded files are unique. fn check_filenames_are_unique(&self) -> Result<(), SubplotError> { let mut known = HashSet::new(); for filename in self .embedded_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<(), SubplotError> { 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<(), SubplotError> { let classes_in_doc = self.all_block_classes(); // 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_BLOCK_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<_> = classes_in_doc.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(()) } } fn all_block_classes(&self) -> HashSet { let mut set = HashSet::new(); for md in self.markdowns.iter() { for class in md.block_classes() { set.insert(class); } } set } /// Check bindings for warnings pub fn check_bindings(&self, warnings: &mut Warnings) -> Result<(), SubplotError> { self.meta.bindings().check(warnings) } /// Check labelled code blocks have some appropriate class pub fn check_named_code_blocks_have_appropriate_class( &self, warnings: &mut Warnings, ) -> Result { let mut okay = true; for md in self.markdowns.iter() { for block in md.named_blocks() { if !block.all_attrs().iter().any(|attr| { attr.name() == "class" && ID_OK_CLASSES .iter() .any(|class| attr.value() == Some(class)) }) { // For now, named blocks must be files warnings.push(Warning::MissingAppropriateClassOnNamedCodeBlock( block .attr("id") .expect("Named blocks should have IDs") .value() .unwrap_or("(unknown-id)") .to_string(), block.location().to_string(), )); okay = false; } } } Ok(okay) } /// Check that all named files (in matched steps) are actually present in the /// document. pub fn check_named_files_exist( &self, template: &str, warnings: &mut Warnings, ) -> Result { let filenames: HashSet<_> = self .embedded_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, kind: _, } = captured { if matches!(step.types().get(name.as_str()), Some(CaptureType::File)) && !filenames.contains(&text.to_lowercase()) { 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( &self, template: &str, warnings: &mut Warnings, ) -> Result { let mut filenames: HashSet<_> = self .embedded_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, kind: _, } = captured { if matches!(step.types().get(name.as_str()), Some(CaptureType::File)) { filenames.remove(&text.to_lowercase()); } } } } } for filename in filenames.iter() { 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(&self, template: &str, warnings: &mut Warnings) -> 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()); warnings.push(Warning::MissingStepImplementation( scenario.title().to_string(), step.text().to_string(), )); okay = false; } } } okay } /// Typeset a Subplot document. 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(), template, self.meta.bindings())); } Ok(()) } /// Return all scenarios in a document. pub fn scenarios(&self) -> Result, SubplotError> { let mut scenarios = vec![]; for md in self.markdowns.iter() { scenarios.append(&mut md.scenarios()?); } Ok(scenarios) } /// Return matched scenarios in a document. pub fn matched_scenarios(&self, template: &str) -> Result, SubplotError> { 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, SubplotError> { 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) } } } fn load_metadata_from_yaml_file(filename: &Path) -> Result { let yaml = read(filename).map_err(|e| SubplotError::ReadFile(filename.into(), e))?; trace!("Parsing YAML metadata from {}", filename.display()); let meta: YamlMetadata = serde_yaml::from_slice(&yaml) .map_err(|e| SubplotError::MetadataFile(filename.into(), e))?; Ok(meta) } /// Load a `Document` from a file. 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(&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); } let mut warnings = Warnings::default(); if !doc.check_named_code_blocks_have_appropriate_class(&mut warnings)? || !doc.check_named_files_exist(&template, &mut warnings)? || !doc.check_matched_steps_have_impl(&template, &mut warnings) || !doc.check_embedded_files_are_used(&template, &mut warnings)? { 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 } } } #[derive(Debug, Default)] struct HeadingNumberer { prev: Vec, } impl HeadingNumberer { fn number(&mut self, level: usize) -> String { match level.cmp(&self.prev.len()) { Ordering::Equal => { if let Some(n) = self.prev.pop() { self.prev.push(n + 1); } else { self.prev.push(1); } } Ordering::Greater => { self.prev.push(1); } Ordering::Less => { assert!(!self.prev.is_empty()); self.prev.pop(); if let Some(n) = self.prev.pop() { self.prev.push(n + 1); } else { self.prev.push(1); } } } let mut s = String::new(); for i in self.prev.iter() { if !s.is_empty() { s.push('.'); } s.push_str(&i.to_string()); } s } } #[cfg(test)] mod test_numberer { use super::HeadingNumberer; #[test] fn numbering() { let mut n = HeadingNumberer::default(); assert_eq!(n.number(1), "1"); assert_eq!(n.number(2), "1.1"); assert_eq!(n.number(1), "2"); assert_eq!(n.number(2), "2.1"); } }