use crate::ast;
use crate::generate_test_program;
use crate::get_basedir_from;
use crate::visitor;
use crate::EmbeddedFile;
use crate::EmbeddedFiles;
use crate::LintingVisitor;
use crate::MatchedScenario;
use crate::Metadata;
use crate::PartialStep;
use crate::Scenario;
use crate::ScenarioStep;
use crate::Style;
use crate::SubplotError;
use crate::YamlMetadata;
use crate::{bindings::CaptureType, parser::parse_scenario_snippet};
use crate::{Warning, Warnings};
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 pandoc_ast::{MutVisitor, Pandoc};
use log::{error, trace};
static SPECIAL_CLASSES: &[&str] = &[
"scenario", "file", "example", "dot", "pikchr", "plantuml", "roadmap",
];
static KNOWN_FILE_CLASSES: &[&str] = &["rust", "yaml", "python", "sh", "shell", "markdown", "bash"];
static KNOWN_PANDOC_CLASSES: &[&str] = &["numberLines", "noNumberLines"];
#[derive(Debug)]
pub struct Document {
subplot: PathBuf,
markdowns: Vec<PathBuf>,
ast: Pandoc,
meta: Metadata,
files: EmbeddedFiles,
style: Style,
warnings: Warnings,
}
impl Document {
fn new(
subplot: PathBuf,
markdowns: Vec<PathBuf>,
ast: Pandoc,
meta: Metadata,
files: EmbeddedFiles,
style: Style,
) -> Document {
Document {
subplot,
markdowns,
ast,
meta,
files,
style,
warnings: Warnings::default(),
}
}
pub fn warnings(&self) -> &[Warning] {
self.warnings.warnings()
}
fn from_ast<P>(
basedir: P,
subplot: PathBuf,
markdowns: Vec<PathBuf>,
yamlmeta: &ast::YamlMetadata,
mut ast: Pandoc,
style: Style,
template: Option<&str>,
) -> Result<Document, SubplotError>
where
P: AsRef<Path> + Debug,
{
let meta = Metadata::new(basedir, yamlmeta, template)?;
let mut linter = LintingVisitor::default();
trace!("Walking AST for linting...");
linter.walk_pandoc(&mut ast);
if !linter.issues.is_empty() {
return Err(linter.issues.remove(0));
}
let files = EmbeddedFiles::new(&mut ast);
let doc = Document::new(subplot, markdowns, ast, meta, files, style);
trace!("Loaded from JSON OK");
Ok(doc)
}
pub fn from_file(
basedir: &Path,
filename: &Path,
style: Style,
template: Option<&str>,
) -> Result<Document, SubplotError> {
trace!(
"Document::from_file: basedir={} filename={}",
basedir.display(),
filename.display()
);
let meta = load_metadata_from_yaml_file(filename)?;
let mdfile = meta.markdown();
let mdfile = basedir.join(mdfile);
let markdowns = vec![mdfile.clone()];
let mut pandoc = pandoc::new();
pandoc.add_input(&mdfile);
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);
crate::policy::add_citeproc(&mut pandoc);
trace!(
"Invoking Pandoc to parse document {:?} into AST as JSON",
mdfile,
);
let json = match pandoc.execute().map_err(SubplotError::Pandoc)? {
pandoc::PandocOutput::ToBuffer(o) => o,
_ => return Err(SubplotError::NotJson),
};
trace!("Pandoc was happy");
trace!("Parsing document AST as JSON...");
let mut ast: Pandoc = serde_json::from_str(&json).map_err(SubplotError::AstJson)?;
ast.meta = meta.to_map();
let doc = Self::from_ast(
basedir,
filename.into(),
markdowns,
&meta,
ast,
style,
template,
)?;
trace!("Loaded document OK");
Ok(doc)
}
pub fn from_file_with_pullmark(
basedir: &Path,
filename: &Path,
style: Style,
template: Option<&str>,
) -> Result<Document, SubplotError> {
trace!("Parsing document with pullmark-cmark from {:?}", filename);
let meta = load_metadata_from_yaml_file(filename)?;
let mdfile = meta.markdown();
let mdfile = basedir.join(mdfile);
let markdown = std::fs::read_to_string(&mdfile)
.map_err(|err| SubplotError::ReadFile(mdfile.clone(), err))?;
let ast = ast::AbstractSyntaxTree::new(meta.clone(), &markdown);
trace!("Parsed document OK");
Self::from_ast(
basedir,
filename.into(),
vec![mdfile],
&meta,
ast.to_pandoc(),
style,
template,
)
}
pub fn ast(&self) -> Result<String, SubplotError> {
let json = serde_json::to_string(&self.ast).map_err(SubplotError::AstJson)?;
Ok(json)
}
pub fn meta(&self) -> &Metadata {
&self.meta
}
pub fn sources(&mut self, template: Option<&str>) -> Vec<PathBuf> {
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 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
}
pub fn files(&self) -> &[EmbeddedFile] {
self.files.files()
}
pub fn lint(&self) -> Result<(), SubplotError> {
trace!("Linting document");
self.check_doc_has_title()?;
self.check_filenames_are_unique()?;
self.check_block_classes()?;
trace!("No linting problems found");
Ok(())
}
fn check_filenames_are_unique(&self) -> Result<(), SubplotError> {
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(())
}
fn check_doc_has_title(&self) -> Result<(), SubplotError> {
if self.meta().title().is_empty() {
Err(SubplotError::NoTitle)
} else {
Ok(())
}
}
fn check_block_classes(&self) -> Result<(), SubplotError> {
let mut visitor = visitor::BlockClassVisitor::default();
visitor.walk_pandoc(&mut self.ast.clone());
let mut known_classes: HashSet<String> = 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());
}
let unknown_classes: Vec<_> = visitor
.classes
.difference(&known_classes)
.cloned()
.collect();
if !unknown_classes.is_empty() {
Err(SubplotError::UnknownClasses(unknown_classes.join(", ")))
} else {
Ok(())
}
}
pub fn check_named_files_exist(&mut self, template: &str) -> Result<bool, SubplotError> {
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), };
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)
}
pub fn check_embedded_files_are_used(&mut self, template: &str) -> Result<bool, SubplotError> {
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), };
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()));
}
Ok(true)
}
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, };
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
}
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());
}
pub fn scenarios(&mut self) -> Result<Vec<Scenario>, SubplotError> {
let mut visitor = visitor::StructureVisitor::new();
visitor.walk_pandoc(&mut self.ast);
let mut scenarios: Vec<Scenario> = 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)
}
pub fn matched_scenarios(
&mut self,
template: &str,
) -> Result<Vec<MatchedScenario>, 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()
}
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<YamlMetadata, SubplotError> {
let yaml = read(filename).map_err(|e| SubplotError::ReadFile(filename.into(), e))?;
trace!("Parsing YAML metadata from {}", filename.display());
let meta: ast::YamlMetadata = serde_yaml::from_slice(&yaml)
.map_err(|e| SubplotError::MetadataFile(filename.into(), e))?;
Ok(meta)
}
pub fn load_document<P>(
filename: P,
style: Style,
template: Option<&str>,
) -> Result<Document, SubplotError>
where
P: AsRef<Path> + 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)
}
pub fn load_document_with_pullmark<P>(
filename: P,
style: Style,
template: Option<&str>,
) -> Result<Document, SubplotError>
where
P: AsRef<Path> + 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)
}
pub fn codegen(
filename: &Path,
output: &Path,
template: Option<&str>,
) -> Result<CodegenOutput, SubplotError> {
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<Scenario>, usize), SubplotError> {
if e.is_empty() {
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::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<Scenario>, usize), SubplotError>,
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<Element> = 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),
}
}
}