diff options
Diffstat (limited to 'src/md.rs')
-rw-r--r-- | src/md.rs | 390 |
1 files changed, 390 insertions, 0 deletions
diff --git a/src/md.rs b/src/md.rs new file mode 100644 index 0000000..15fe57a --- /dev/null +++ b/src/md.rs @@ -0,0 +1,390 @@ +//! A parsed Markdown document. + +use crate::{ + parse_scenario_snippet, visitor, Bindings, EmbeddedFiles, LintingVisitor, Scenario, + ScenarioStep, Style, SubplotError, Warning, YamlMetadata, +}; +use log::trace; +use pandoc_ast::{Map, MetaValue, MutVisitor, Pandoc}; +use serde_yaml::{Mapping, Value}; +use std::collections::HashSet; +use std::convert::TryFrom; +use std::path::{Path, PathBuf}; + +/// A parsed Markdown document. +#[derive(Debug)] +pub struct Markdown { + pandoc: Pandoc, +} + +impl Markdown { + fn new(pandoc: Pandoc) -> Self { + Self { pandoc } + } + + fn pandoc(&mut self) -> &mut Pandoc { + &mut self.pandoc + } + + /// Set document metadata from subplot. + pub fn set_metadata(&mut self, meta: &YamlMetadata) { + self.pandoc.meta = to_pandoc_meta(meta); + } + + /// JSON representation of Pandoc AST. + pub fn to_json(&self) -> Result<String, SubplotError> { + let json = serde_json::to_string(&self.pandoc).map_err(SubplotError::AstJson)?; + Ok(json) + } + + /// Find problems. + pub fn lint(&mut self) -> Vec<SubplotError> { + let mut linter = LintingVisitor::default(); + linter.walk_pandoc(self.pandoc()); + linter.issues + } + + /// Find included images. + pub fn images(&mut self) -> Vec<PathBuf> { + let mut names = vec![]; + let mut visitor = visitor::ImageVisitor::new(); + visitor.walk_pandoc(self.pandoc()); + for x in visitor.images().iter() { + names.push(x.to_path_buf()); + } + names + } + + /// Find classes used for fenced blocks. + pub fn block_classes(&mut self) -> HashSet<String> { + 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(self.pandoc()); + visitor.classes + } + + /// Typeset. + pub fn typeset(&mut self, style: Style, bindings: &Bindings) -> Vec<Warning> { + let mut visitor = visitor::TypesettingVisitor::new(style, bindings); + visitor.walk_pandoc(self.pandoc()); + visitor.warnings().warnings().to_vec() + } + + /// Find scenarios. + pub fn scenarios(&mut self) -> Result<Vec<Scenario>, SubplotError> { + trace!( + "Metadata::scenarios: looking for scenarios: {:#?}", + self.pandoc + ); + + let mut visitor = visitor::StructureVisitor::new(); + visitor.walk_pandoc(self.pandoc()); + trace!( + "Metadata::scenarios: visitor found {} elements: {:#?}", + visitor.elements.len(), + visitor.elements + ); + + 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; + } + trace!("Metadata::scenarios: found {} scenarios", scenarios.len()); + Ok(scenarios) + } + + /// Find embedded files. + pub fn embedded_files(&mut self) -> EmbeddedFiles { + let mut files = EmbeddedFiles::default(); + files.walk_pandoc(self.pandoc()); + files + } +} + +fn to_pandoc_meta(yaml: &YamlMetadata) -> Map<String, MetaValue> { + trace!("Creating metadata map from parsed YAML: {:#?}", yaml); + + let mut map: Map<String, MetaValue> = Map::new(); + + map.insert("title".into(), meta_string(yaml.title())); + + if let Some(v) = &yaml.subtitle() { + map.insert("subtitle".into(), meta_string(v)); + } + + if let Some(authors) = yaml.authors() { + let authors: Vec<MetaValue> = authors + .iter() + .map(|s| MetaValue::MetaString(s.into())) + .collect(); + map.insert("author".into(), MetaValue::MetaList(authors)); + } + + if let Some(v) = yaml.date() { + map.insert("date".into(), meta_string(v)); + } + + if let Some(classes) = yaml.classes() { + map.insert("classes".into(), meta_strings(classes)); + } + + if !yaml.impls().is_empty() { + let impls = yaml + .impls() + .iter() + .map(|(k, v)| (k.to_owned(), Box::new(meta_path_bufs(v)))) + .collect(); + map.insert("impls".into(), MetaValue::MetaMap(impls)); + } + + if let Some(v) = yaml.bibliographies() { + map.insert("bibliography".into(), meta_path_bufs(v)); + } + + if let Some(v) = yaml.bindings_filenames() { + map.insert("bindings".into(), meta_path_bufs(v)); + } + + if let Some(v) = yaml.documentclass() { + map.insert("documentclass".into(), meta_string(v)); + } + + if let Some(pandoc) = yaml.pandoc() { + for (key, value) in pandoc.iter() { + map.insert(key.to_string(), value_to_pandoc(value)); + } + } + + trace!("Created metadata map from parsed YAML"); + map +} + +fn mapping_to_pandoc(mapping: &Mapping) -> MetaValue { + let mut map = Map::new(); + for (key, value) in mapping.iter() { + let key = if let MetaValue::MetaString(s) = value_to_pandoc(key) { + s + } else { + panic!("key not a string: {:?}", key); + }; + map.insert(key, Box::new(value_to_pandoc(value))); + } + + MetaValue::MetaMap(map) +} + +fn value_to_pandoc(data: &Value) -> MetaValue { + match data { + Value::Null => unreachable!("null not OK"), + Value::Number(_) => unreachable!("number not OK"), + Value::Sequence(_) => unreachable!("sequence not OK"), + + Value::Bool(b) => MetaValue::MetaBool(*b), + Value::String(s) => MetaValue::MetaString(s.clone()), + Value::Mapping(mapping) => mapping_to_pandoc(mapping), + } +} + +fn meta_string(s: &str) -> MetaValue { + MetaValue::MetaString(s.to_string()) +} + +fn meta_strings(v: &[String]) -> MetaValue { + MetaValue::MetaList(v.iter().map(|s| meta_string(s)).collect()) +} + +fn meta_path_buf(p: &Path) -> MetaValue { + meta_string(&p.display().to_string()) +} + +fn meta_path_bufs(v: &[PathBuf]) -> MetaValue { + MetaValue::MetaList(v.iter().map(|p| meta_path_buf(p)).collect()) +} + +impl TryFrom<&Path> for Markdown { + type Error = SubplotError; + + fn try_from(filename: &Path) -> Result<Self, Self::Error> { + trace!("parsing file as markdown: {}", filename.display()); + 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); + + let json = match pandoc.execute().map_err(SubplotError::Pandoc)? { + pandoc::PandocOutput::ToBuffer(o) => o, + _ => return Err(SubplotError::NotJson), + }; + + let ast: Pandoc = serde_json::from_str(&json).map_err(SubplotError::AstJson)?; + Ok(Self::new(ast)) + } +} + +fn extract_scenario(e: &[visitor::Element]) -> Result<(Option<Scenario>, usize), SubplotError> { + 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::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), + } + } +} |