summaryrefslogtreecommitdiff
path: root/src/md.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/md.rs')
-rw-r--r--src/md.rs390
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),
+ }
+ }
+}