summaryrefslogtreecommitdiff
path: root/src/md
diff options
context:
space:
mode:
Diffstat (limited to 'src/md')
-rw-r--r--src/md/panhelper.rs26
-rw-r--r--src/md/typeset.rs229
-rw-r--r--src/md/visitor/block_class.rs25
-rw-r--r--src/md/visitor/embedded.rs35
-rw-r--r--src/md/visitor/image.rs25
-rw-r--r--src/md/visitor/linting.rs40
-rw-r--r--src/md/visitor/mod.rs17
-rw-r--r--src/md/visitor/structure.rs100
-rw-r--r--src/md/visitor/typesetting.rs85
9 files changed, 582 insertions, 0 deletions
diff --git a/src/md/panhelper.rs b/src/md/panhelper.rs
new file mode 100644
index 0000000..f7ab801
--- /dev/null
+++ b/src/md/panhelper.rs
@@ -0,0 +1,26 @@
+use pandoc_ast::Attr;
+
+/// Is a code block marked as being of a given type?
+pub fn is_class(attr: &Attr, class: &str) -> bool {
+ let (_id, classes, _kvpairs) = attr;
+ classes.iter().any(|s| s == class)
+}
+
+/// Utility function to find key/value pairs from an attribute
+pub fn find_attr_kv<'a>(attr: &'a Attr, key: &'static str) -> impl Iterator<Item = &'a str> {
+ attr.2.iter().flat_map(move |(key_, value)| {
+ if key == key_ {
+ Some(value.as_ref())
+ } else {
+ None
+ }
+ })
+}
+
+/// Get the filename for a fenced code block tagged .file.
+///
+/// The filename is the first (and presumably only) identifier for the
+/// block.
+pub fn get_filename(attr: &Attr) -> String {
+ attr.0.to_string()
+}
diff --git a/src/md/typeset.rs b/src/md/typeset.rs
new file mode 100644
index 0000000..f63206a
--- /dev/null
+++ b/src/md/typeset.rs
@@ -0,0 +1,229 @@
+use crate::parser::parse_scenario_snippet;
+use crate::Bindings;
+use crate::PartialStep;
+use crate::ScenarioStep;
+use crate::StepKind;
+use crate::SubplotError;
+use crate::{DiagramMarkup, DotMarkup, PikchrMarkup, PlantumlMarkup, Svg};
+use crate::{Warning, Warnings};
+
+use pandoc_ast::Attr;
+use pandoc_ast::Block;
+use pandoc_ast::Inline;
+use pandoc_ast::Target;
+
+/// Typeset an error as a Pandoc AST Block element.
+pub fn error(err: SubplotError) -> Block {
+ let msg = format!("ERROR: {}", err);
+ Block::Para(error_msg(&msg))
+}
+
+/// Typeset an error message a vector of inlines.
+pub fn error_msg(msg: &str) -> Vec<Inline> {
+ vec![Inline::Strong(vec![inlinestr(msg)])]
+}
+
+/// Typeset a string as an inline element.
+pub fn inlinestr(s: &str) -> Inline {
+ Inline::Str(String::from(s))
+}
+
+/// Typeset a code block tagged as a file.
+pub fn file_block(attr: &Attr, text: &str) -> Block {
+ let filename = inlinestr(&attr.0);
+ let filename = Inline::Strong(vec![filename]);
+ let intro = Block::Para(vec![inlinestr("File:"), space(), filename]);
+ let mut cbattrs = attr.clone();
+ if cbattrs.1.iter().any(|s| s == "noNumberLines") {
+ // If the block says "noNumberLines" we remove that class
+ cbattrs.1.retain(|s| s != "noNumberLines");
+ } else if cbattrs.1.iter().all(|s| s != "numberLines") {
+ // Otherwise if it doesn't say numberLines we add that in.
+ cbattrs.1.push("numberLines".to_string());
+ }
+ // If this was an `example`, convert that class to `file`
+ if cbattrs.1.iter().any(|s| s == "example") {
+ cbattrs.1.retain(|s| s != "example");
+ cbattrs.1.push("file".into());
+ }
+ let codeblock = Block::CodeBlock(cbattrs, text.to_string());
+ let noattr = ("".to_string(), vec![], vec![]);
+ Block::Div(noattr, vec![intro, codeblock])
+}
+
+/// Typeset a scenario snippet as a Pandoc AST Block.
+///
+/// Typesetting here means producing the Pandoc abstract syntax tree
+/// nodes that result in the desired output, when Pandoc processes
+/// them.
+///
+/// The snippet is given as a text string, which is parsed. It need
+/// not be a complete scenario, but it should consist of complete steps.
+pub fn scenario_snippet(bindings: &Bindings, snippet: &str, warnings: &mut Warnings) -> Block {
+ let lines = parse_scenario_snippet(snippet);
+ let mut steps = vec![];
+ let mut prevkind: Option<StepKind> = None;
+
+ for line in lines {
+ let (this, thiskind) = step(bindings, line, prevkind, warnings);
+ steps.push(this);
+ prevkind = thiskind;
+ }
+ Block::LineBlock(steps)
+}
+
+// Typeset a single scenario step as a sequence of Pandoc AST Inlines.
+fn step(
+ bindings: &Bindings,
+ text: &str,
+ prevkind: Option<StepKind>,
+ warnings: &mut Warnings,
+) -> (Vec<Inline>, Option<StepKind>) {
+ let step = ScenarioStep::new_from_str(text, prevkind);
+ if step.is_err() {
+ return (
+ error_msg(&format!("Could not parse step: {}", text)),
+ prevkind,
+ );
+ }
+ let step = step.unwrap();
+
+ let m = match bindings.find("", &step) {
+ Ok(m) => m,
+ Err(e) => {
+ let w = Warning::UnknownBinding(format!("{}", e));
+ warnings.push(w.clone());
+ return (error_msg(&format!("{}", w)), prevkind);
+ }
+ };
+
+ let mut inlines = vec![keyword(&step, prevkind), space()];
+
+ for part in m.parts() {
+ match part {
+ PartialStep::UncapturedText(s) => inlines.push(uncaptured(s.text())),
+ PartialStep::CapturedText { text, .. } => inlines.push(captured(text)),
+ }
+ }
+
+ (inlines, Some(step.kind()))
+}
+
+// Typeset first word, which is assumed to be a keyword, of a scenario
+// step.
+fn keyword(step: &ScenarioStep, prevkind: Option<StepKind>) -> Inline {
+ let actual = inlinestr(&format!("{}", step.kind()));
+ let and = inlinestr("and");
+ let keyword = if let Some(prevkind) = prevkind {
+ if prevkind == step.kind() {
+ and
+ } else {
+ actual
+ }
+ } else {
+ actual
+ };
+ Inline::Emph(vec![keyword])
+}
+
+// Typeset a space between words.
+fn space() -> Inline {
+ Inline::Space
+}
+
+// Typeset an uncaptured part of a step.
+fn uncaptured(s: &str) -> Inline {
+ inlinestr(s)
+}
+
+// Typeset a captured part of a step.
+fn captured(s: &str) -> Inline {
+ Inline::Strong(vec![inlinestr(s)])
+}
+
+/// Typeset a link as a note.
+pub fn link_as_note(attr: Attr, text: Vec<Inline>, target: Target) -> Inline {
+ let (url, _) = target.clone();
+ let url = Inline::Code(attr.clone(), url);
+ let link = Inline::Link(attr.clone(), vec![url], target);
+ let note = Inline::Note(vec![Block::Para(vec![link])]);
+ let mut text = text;
+ text.push(note);
+ Inline::Span(attr, text)
+}
+
+/// Take a pikchr diagram, render it as SVG, and return an AST block element.
+///
+/// The `Block` will contain the SVG data. This allows the diagram to
+/// be rendered without referencing external entities.
+///
+/// If the code block which contained the pikchr contains other classes, they
+/// can be added to the SVG for use in later typesetting etc.
+pub fn pikchr_to_block(pikchr: &str, class: Option<&str>, warnings: &mut Warnings) -> Block {
+ match PikchrMarkup::new(pikchr, class).as_svg() {
+ Ok(svg) => typeset_svg(svg),
+ Err(err) => {
+ warnings.push(Warning::Pikchr(format!("{}", err)));
+ error(err)
+ }
+ }
+}
+
+// Take a dot diagram, render it as SVG, and return an AST Block
+// element. The Block will contain the SVG data. This allows the
+// diagram to be rendered without referending external entities.
+pub fn dot_to_block(dot: &str, warnings: &mut Warnings) -> Block {
+ match DotMarkup::new(dot).as_svg() {
+ Ok(svg) => typeset_svg(svg),
+ Err(err) => {
+ warnings.push(Warning::Dot(format!("{}", err)));
+ error(err)
+ }
+ }
+}
+
+// Take a PlantUML diagram, render it as SVG, and return an AST Block
+// element. The Block will contain the SVG data. This allows the
+// diagram to be rendered without referending external entities.
+pub fn plantuml_to_block(markup: &str, warnings: &mut Warnings) -> Block {
+ match PlantumlMarkup::new(markup).as_svg() {
+ Ok(svg) => typeset_svg(svg),
+ Err(err) => {
+ warnings.push(Warning::Plantuml(format!("{}", err)));
+ error(err)
+ }
+ }
+}
+
+/// Typeset a project roadmap expressed as textual YAML, and render it
+/// as an SVG image.
+pub fn roadmap_to_block(yaml: &str, warnings: &mut Warnings) -> Block {
+ match roadmap::from_yaml(yaml) {
+ Ok(ref mut roadmap) => {
+ roadmap.set_missing_statuses();
+ let width = 50;
+ match roadmap.format_as_dot(width) {
+ Ok(dot) => dot_to_block(&dot, warnings),
+ Err(e) => Block::Para(vec![inlinestr(&e.to_string())]),
+ }
+ }
+ Err(e) => Block::Para(vec![inlinestr(&e.to_string())]),
+ }
+}
+
+// Typeset an SVG, represented as a byte vector, as a Pandoc AST Block
+// element.
+fn typeset_svg(svg: Svg) -> Block {
+ let url = svg_as_data_url(svg);
+ let attr = ("".to_string(), vec![], vec![]);
+ let img = Inline::Image(attr, vec![], (url, "".to_string()));
+ Block::Para(vec![img])
+}
+
+// Convert an SVG, represented as a byte vector, into a data: URL,
+// which can be inlined so the image can be rendered without
+// referencing external files.
+fn svg_as_data_url(svg: Svg) -> String {
+ let svg = base64::encode(svg.data());
+ format!("data:image/svg+xml;base64,{}", svg)
+}
diff --git a/src/md/visitor/block_class.rs b/src/md/visitor/block_class.rs
new file mode 100644
index 0000000..303616b
--- /dev/null
+++ b/src/md/visitor/block_class.rs
@@ -0,0 +1,25 @@
+use std::collections::HashSet;
+
+use pandoc_ast::{Block, MutVisitor};
+
+#[derive(Default)]
+pub struct BlockClassVisitor {
+ pub classes: HashSet<String>,
+}
+
+impl MutVisitor for BlockClassVisitor {
+ fn visit_vec_block(&mut self, vec_block: &mut Vec<Block>) {
+ for block in vec_block {
+ match block {
+ Block::CodeBlock(attr, _) => {
+ for class in &attr.1 {
+ self.classes.insert(class.to_string());
+ }
+ }
+ _ => {
+ self.visit_block(block);
+ }
+ }
+ }
+ }
+}
diff --git a/src/md/visitor/embedded.rs b/src/md/visitor/embedded.rs
new file mode 100644
index 0000000..840d9ed
--- /dev/null
+++ b/src/md/visitor/embedded.rs
@@ -0,0 +1,35 @@
+use crate::md::panhelper;
+use crate::EmbeddedFile;
+use crate::EmbeddedFiles;
+
+use pandoc_ast::{Block, MutVisitor};
+
+impl MutVisitor for EmbeddedFiles {
+ fn visit_vec_block(&mut self, vec_block: &mut Vec<Block>) {
+ use panhelper::is_class;
+ for block in vec_block {
+ match block {
+ Block::CodeBlock(attr, contents) => {
+ if is_class(attr, "file") {
+ let add_newline = match panhelper::find_attr_kv(attr, "add-newline").next()
+ {
+ None | Some("auto") => !contents.ends_with('\n'),
+ Some("yes") => true,
+ Some("no") => false,
+ _ => unreachable!(),
+ };
+ let contents = if add_newline {
+ format!("{}\n", contents)
+ } else {
+ contents.clone()
+ };
+ self.push(EmbeddedFile::new(panhelper::get_filename(attr), contents));
+ }
+ }
+ _ => {
+ self.visit_block(block);
+ }
+ }
+ }
+ }
+}
diff --git a/src/md/visitor/image.rs b/src/md/visitor/image.rs
new file mode 100644
index 0000000..be49d66
--- /dev/null
+++ b/src/md/visitor/image.rs
@@ -0,0 +1,25 @@
+use std::path::PathBuf;
+
+use pandoc_ast::{Inline, MutVisitor};
+
+pub struct ImageVisitor {
+ images: Vec<PathBuf>,
+}
+
+impl ImageVisitor {
+ pub fn new() -> Self {
+ ImageVisitor { images: vec![] }
+ }
+
+ pub fn images(&self) -> Vec<PathBuf> {
+ self.images.clone()
+ }
+}
+
+impl MutVisitor for ImageVisitor {
+ fn visit_inline(&mut self, inline: &mut Inline) {
+ if let Inline::Image(_attr, _inlines, target) = inline {
+ self.images.push(PathBuf::from(&target.0));
+ }
+ }
+}
diff --git a/src/md/visitor/linting.rs b/src/md/visitor/linting.rs
new file mode 100644
index 0000000..d64b03e
--- /dev/null
+++ b/src/md/visitor/linting.rs
@@ -0,0 +1,40 @@
+use crate::md::panhelper;
+use crate::SubplotError;
+
+use pandoc_ast::{Block, MutVisitor};
+
+#[derive(Default)]
+pub struct LintingVisitor {
+ pub issues: Vec<SubplotError>,
+}
+
+impl MutVisitor for LintingVisitor {
+ fn visit_vec_block(&mut self, vec_block: &mut Vec<Block>) {
+ for block in vec_block {
+ match block {
+ Block::CodeBlock(attr, _) => {
+ if panhelper::is_class(attr, "file") || panhelper::is_class(attr, "example") {
+ let newlines: Vec<_> =
+ panhelper::find_attr_kv(attr, "add-newline").collect();
+ match newlines.len() {
+ 0 => {}
+ 1 => match newlines[0].to_ascii_lowercase().as_ref() {
+ "auto" | "yes" | "no" => {}
+ _ => self.issues.push(SubplotError::UnrecognisedAddNewline(
+ panhelper::get_filename(attr),
+ newlines[0].to_owned(),
+ )),
+ },
+ _ => self.issues.push(SubplotError::RepeatedAddNewlineAttribute(
+ panhelper::get_filename(attr),
+ )),
+ }
+ }
+ }
+ _ => {
+ self.visit_block(block);
+ }
+ }
+ }
+ }
+}
diff --git a/src/md/visitor/mod.rs b/src/md/visitor/mod.rs
new file mode 100644
index 0000000..1c095ac
--- /dev/null
+++ b/src/md/visitor/mod.rs
@@ -0,0 +1,17 @@
+mod block_class;
+pub use block_class::BlockClassVisitor;
+
+mod embedded;
+
+mod image;
+pub use image::ImageVisitor;
+
+mod linting;
+pub use linting::LintingVisitor;
+
+mod structure;
+pub use structure::Element;
+pub use structure::StructureVisitor;
+
+mod typesetting;
+pub use typesetting::TypesettingVisitor;
diff --git a/src/md/visitor/structure.rs b/src/md/visitor/structure.rs
new file mode 100644
index 0000000..d8faef6
--- /dev/null
+++ b/src/md/visitor/structure.rs
@@ -0,0 +1,100 @@
+use crate::md::panhelper;
+
+use pandoc_ast::{Block, Inline, MutVisitor};
+
+// A structure element in the document: a heading or a scenario snippet.
+#[derive(Debug)]
+pub enum Element {
+ // Headings consist of the text and the level of the heading.
+ Heading(String, i64),
+
+ // Scenario snippets consist just of the unparsed text.
+ Snippet(String),
+}
+
+impl Element {
+ pub fn heading(text: &str, level: i64) -> Element {
+ Element::Heading(text.to_string(), level)
+ }
+
+ pub fn snippet(text: &str) -> Element {
+ Element::Snippet(text.to_string())
+ }
+}
+
+// A MutVisitor for extracting document structure.
+pub struct StructureVisitor {
+ pub elements: Vec<Element>,
+}
+
+impl StructureVisitor {
+ pub fn new() -> Self {
+ Self { elements: vec![] }
+ }
+}
+
+impl MutVisitor for StructureVisitor {
+ fn visit_vec_block(&mut self, vec_block: &mut Vec<Block>) {
+ use panhelper::is_class;
+ for block in vec_block {
+ match block {
+ Block::Header(level, _attr, inlines) => {
+ let text = join(inlines);
+ let heading = Element::heading(&text, *level);
+ self.elements.push(heading);
+ }
+ Block::CodeBlock(attr, s) => {
+ if is_class(attr, "scenario") {
+ let snippet = Element::snippet(s);
+ self.elements.push(snippet);
+ }
+ }
+ _ => {
+ self.visit_block(block);
+ }
+ }
+ }
+ }
+}
+
+fn join(vec: &[Inline]) -> String {
+ let mut buf = String::new();
+ join_into_buffer(vec, &mut buf);
+ buf
+}
+
+fn join_into_buffer(vec: &[Inline], buf: &mut String) {
+ for item in vec {
+ match item {
+ Inline::Str(s) => buf.push_str(s),
+ Inline::Emph(v) => join_into_buffer(v, buf),
+ Inline::Strong(v) => join_into_buffer(v, buf),
+ Inline::Strikeout(v) => join_into_buffer(v, buf),
+ Inline::Superscript(v) => join_into_buffer(v, buf),
+ Inline::Subscript(v) => join_into_buffer(v, buf),
+ Inline::SmallCaps(v) => join_into_buffer(v, buf),
+ Inline::Quoted(qt, v) => {
+ let q = match qt {
+ pandoc_ast::QuoteType::SingleQuote => "'",
+ pandoc_ast::QuoteType::DoubleQuote => "\"",
+ };
+ buf.push_str(q);
+ join_into_buffer(v, buf);
+ buf.push_str(q);
+ }
+ Inline::Cite(_, v) => join_into_buffer(v, buf),
+ Inline::Code(_attr, s) => buf.push_str(s),
+ Inline::Space => buf.push(' '),
+ Inline::SoftBreak => buf.push(' '),
+ Inline::LineBreak => buf.push(' '),
+ Inline::Math(_, s) => buf.push_str(s),
+ Inline::RawInline(_, s) => buf.push_str(s),
+ Inline::Link(_, v, _) => join_into_buffer(v, buf),
+ Inline::Image(_, v, _) => join_into_buffer(v, buf),
+ Inline::Note(_) => buf.push_str(""),
+ Inline::Span(_attr, v) => join_into_buffer(v, buf),
+ #[cfg(feature = "pandoc_ast_08")]
+ Inline::Underline(v) => join_into_buffer(v, buf),
+ }
+ }
+}
diff --git a/src/md/visitor/typesetting.rs b/src/md/visitor/typesetting.rs
new file mode 100644
index 0000000..2405c03
--- /dev/null
+++ b/src/md/visitor/typesetting.rs
@@ -0,0 +1,85 @@
+use crate::md::panhelper;
+use crate::md::typeset;
+use crate::{Bindings, Style, Warnings};
+
+use pandoc_ast::{Block, Inline, MutVisitor};
+
+/// Visitor for the pandoc AST.
+///
+/// This includes rendering stuff which we find as we go
+pub struct TypesettingVisitor<'a> {
+ style: Style,
+ bindings: &'a Bindings,
+ warnings: Warnings,
+}
+
+impl<'a> TypesettingVisitor<'a> {
+ pub fn new(style: Style, bindings: &'a Bindings) -> Self {
+ TypesettingVisitor {
+ style,
+ bindings,
+ warnings: Warnings::default(),
+ }
+ }
+
+ pub fn warnings(self) -> Warnings {
+ self.warnings
+ }
+}
+
+// Visit interesting parts of the Pandoc abstract syntax tree. The
+// document top level is a vector of blocks and we visit that and
+// replace any fenced code block with the scenario tag with a typeset
+// paragraph. Also, replace fenced code blocks with known diagram
+// markup with the rendered SVG image.
+impl<'a> MutVisitor for TypesettingVisitor<'a> {
+ fn visit_vec_block(&mut self, vec_block: &mut Vec<Block>) {
+ use panhelper::is_class;
+ for block in vec_block {
+ match block {
+ Block::CodeBlock(attr, s) => {
+ if is_class(attr, "scenario") {
+ *block = typeset::scenario_snippet(self.bindings, s, &mut self.warnings)
+ } else if is_class(attr, "file") || is_class(attr, "example") {
+ *block = typeset::file_block(attr, s)
+ } else if is_class(attr, "dot") {
+ *block = typeset::dot_to_block(s, &mut self.warnings)
+ } else if is_class(attr, "plantuml") {
+ *block = typeset::plantuml_to_block(s, &mut self.warnings)
+ } else if is_class(attr, "roadmap") {
+ *block = typeset::roadmap_to_block(s, &mut self.warnings)
+ } else if is_class(attr, "pikchr") {
+ let other_classes: Vec<_> = attr
+ .1
+ .iter()
+ .map(String::as_str)
+ .filter(|s| *s != "pikchr")
+ .collect();
+ let class = if other_classes.is_empty() {
+ None
+ } else {
+ Some(other_classes.join(" "))
+ };
+ let class = class.as_deref();
+ *block = typeset::pikchr_to_block(s, class, &mut self.warnings)
+ }
+ }
+ _ => {
+ self.visit_block(block);
+ }
+ }
+ }
+ }
+ fn visit_vec_inline(&mut self, vec_inline: &mut Vec<Inline>) {
+ for inline in vec_inline {
+ match inline {
+ Inline::Link(attr, vec, target) if self.style.links_as_notes() => {
+ *inline = typeset::link_as_note(attr.clone(), vec.to_vec(), target.clone());
+ }
+ _ => {
+ self.visit_inline(inline);
+ }
+ }
+ }
+ }
+}