use {
anyhow::{Context, Result},
std::{
path::{Path, PathBuf},
process::Command,
},
thiserror::Error,
};
use crate::book::{config::CmdOptions, BookStructure};
#[derive(Debug, Error, Clone)]
pub enum AdocError {
#[error("Failed to convert file: {0}\n{1}")]
FailedToConvert(PathBuf, String),
}
#[derive(Debug, Clone)]
pub struct AdocRunContext {
src_dir: String,
dst_dir: String,
opts: CmdOptions,
base_url: String,
}
impl AdocRunContext {
pub fn from_book(book: &BookStructure, dst_dir: &Path) -> Self {
let src_dir = format!("{}", book.src_dir_path().display());
let dst_dir = format!("{}", dst_dir.display());
Self {
src_dir,
dst_dir,
opts: book.book_ron.adoc_opts.clone(),
base_url: book.book_ron.base_url.to_string(),
}
}
pub fn set_embedded_mode(&mut self, b: bool) {
if b {
self.opts.push(("--embedded".to_string(), vec![]));
} else {
self.opts = self
.opts
.clone()
.into_iter()
.filter(|(name, _values)| name == "--embedded")
.collect();
}
}
pub fn replace_placeholder_strings(&self, arg: &str) -> String {
let arg = arg.replace(r#"{base_url}"#, &self.base_url);
let arg = arg.replace(r#"{src_dir}"#, &self.src_dir);
let arg = arg.replace(r#"{dst_dir}"#, &self.dst_dir);
arg
}
pub fn apply_options(&self, cmd: &mut Command) {
cmd.current_dir(&self.src_dir)
.args(&["-B", &self.src_dir])
.args(&["-D", &self.dst_dir]);
for (opt, args) in &self.opts {
if args.is_empty() {
cmd.arg(opt);
continue;
}
for arg in args {
let arg = self.replace_placeholder_strings(arg);
cmd.args(&[opt, &arg]);
}
}
}
}
pub fn asciidoctor(src_file: &Path, acx: &AdocRunContext) -> Result<Command> {
ensure!(
src_file.exists(),
"Given non-existing file as conversion source"
);
let src_file = if src_file.is_absolute() {
src_file.to_path_buf()
} else {
src_file
.canonicalize()
.with_context(|| "Unable to canonicallize source file path")?
};
let mut cmd = Command::new("asciidoctor");
cmd.arg(&src_file).args(&["-o", "-"]);
cmd.args(&["-r", "asciidoctor-diagram"]);
cmd.arg("--trace").arg("--verbose");
acx.apply_options(&mut cmd);
Ok(cmd)
}
pub fn run_asciidoctor(
src_file: &Path,
dummy_dst_name: &str,
acx: &AdocRunContext,
) -> Result<std::process::Output> {
trace!(
"Converting adoc: `{}` -> `{}`",
src_file.display(),
dummy_dst_name,
);
let mut cmd =
self::asciidoctor(src_file, acx).context("when setting up `asciidoctor` options")?;
let output = cmd.output().with_context(|| {
format!(
"when running `asciidoctor`:\n src: {}\n dst: {}\n cmd: {:?}",
src_file.display(),
dummy_dst_name,
cmd
)
})?;
Ok(output)
}
pub fn run_asciidoctor_buf(
buf: &mut String,
src_file: &Path,
dummy_dst_name: &str,
acx: &AdocRunContext,
) -> Result<()> {
let output = self::run_asciidoctor(src_file, dummy_dst_name, acx)?;
ensure!(
output.status.success(),
AdocError::FailedToConvert(
src_file.to_path_buf(),
String::from_utf8(output.stderr)
.unwrap_or("<non-UTF8 stderr by `asciidoctor`>".to_string())
)
);
let text = std::str::from_utf8(&output.stdout)
.with_context(|| "Unable to decode stdout of `asciidoctor` as UTF8")?;
buf.push_str(text);
if !output.stderr.is_empty() {
eprintln!(
"Asciidoctor stderr while converting {}:",
src_file.display()
);
let err = String::from_utf8(output.stderr)
.unwrap_or("<non-UTF8 stderr by `asciidoctor`>".to_string());
eprintln!("{}", &err);
}
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdocAttr {
Deny(String),
Allow(String, String),
}
impl AdocAttr {
pub fn name(&self) -> &str {
match self {
AdocAttr::Deny(name) => name,
AdocAttr::Allow(name, _value) => name,
}
}
pub fn value(&self) -> Option<&str> {
match self {
AdocAttr::Deny(_name) => None,
AdocAttr::Allow(_name, value) => Some(value),
}
}
}
impl AdocAttr {
pub fn deny(name: impl Into<String>) -> Self {
AdocAttr::Deny(name.into())
}
pub fn allow(name: impl Into<String>, value: impl Into<String>) -> Self {
AdocAttr::Allow(name.into(), value.into())
}
pub fn from_name(name: &str) -> Self {
if name.starts_with('!') {
Self::deny(&name[1..])
} else {
Self::allow(name, "")
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AdocMetadata {
pub title: Option<String>,
attrs: Vec<AdocAttr>,
base: Option<Box<Self>>,
}
impl AdocMetadata {
pub fn find_attr(&self, name: &str) -> Option<&AdocAttr> {
if let Some(attr) = self.attrs.iter().find(|a| a.name() == name) {
return Some(attr);
}
if let Some(ref base) = self.base {
return base.find_attr(name);
}
None
}
}
impl AdocMetadata {
pub fn derive(&mut self, base: Self) {
self.base = Some(Box::new(base));
}
pub fn extract_with_base(adoc_text: &str, acx: &AdocRunContext) -> Self {
let mut meta = Self::extract(adoc_text, acx);
let base = Self::from_cmd_opts(&acx.opts, acx);
meta.derive(base);
meta
}
fn is_line_to_skip(ln: &str) -> bool {
let ln = ln.trim();
ln.is_empty() || ln.starts_with("//")
}
pub fn extract(text: &str, acx: &AdocRunContext) -> Self {
let mut lines = text.lines().filter(|ln| !Self::is_line_to_skip(ln));
let title = match lines.next() {
Some(ln) if ln.starts_with("= ") => Some(ln[2..].trim().to_string()),
_ => None,
};
let mut attrs = Vec::with_capacity(10);
while let Some(line) = lines.next() {
let mut colons = line.bytes().enumerate().filter(|(_i, c)| *c == b':');
match colons.next() {
Some(_) => {}
None => continue,
}
let pos = match colons.next() {
Some((i, _c)) => i,
None => continue,
};
let name = &line[1..pos].trim();
let value = &line[pos + 1..].trim();
if name.starts_with('!') {
attrs.push(AdocAttr::deny(&name[1..]));
} else {
let value = acx.replace_placeholder_strings(value);
attrs.push(AdocAttr::allow(*name, value));
}
}
Self {
title,
attrs,
base: None,
}
}
pub fn from_cmd_opts(opts: &CmdOptions, acx: &AdocRunContext) -> Self {
let attr_opts = match opts.iter().find(|(opt_name, _attr_opts)| opt_name == "-a") {
Some((_opt_name, opts)) => opts,
None => {
return Self {
title: None,
attrs: vec![],
base: None,
}
}
};
let mut attrs = Vec::with_capacity(10);
for opt in attr_opts.iter() {
let eq_pos = opt
.bytes()
.enumerate()
.find(|(_i, c)| *c == b'=')
.map(|(i, _c)| i)
.unwrap_or(0);
if eq_pos == 0 {
attrs.push(AdocAttr::from_name(opt));
continue;
}
let mut name = &opt[0..eq_pos];
if name.ends_with('@') {
name = &name[0..name.len() - 1];
}
let mut value = &opt[eq_pos + 1..];
if value.ends_with('@') {
value = &value[0..value.len() - 1];
}
let value = acx.replace_placeholder_strings(value);
attrs.push(AdocAttr::allow(name, &value));
}
Self {
title: None,
attrs,
base: None,
}
}
}
#[cfg(test)]
mod test {
use super::{AdocAttr, AdocMetadata, AdocRunContext};
const ARTICLE: &str = r###"
// ^ blank line
= Title here!
:revdate: Oct 23, 2020
// whitespace again
:author: someone
:!sectnums: these text are omitted
First paragraph!
"###;
#[test]
fn simple_metadata() {
let acx = AdocRunContext {
src_dir: ".".to_string(),
dst_dir: ".".to_string(),
opts: vec![],
base_url: "".to_string(),
};
let metadata = AdocMetadata::extract(ARTICLE, &acx);
assert_eq!(
metadata,
AdocMetadata {
title: Some("Title here!".to_string()),
attrs: vec![
AdocAttr::allow("revdate", "Oct 23, 2020"),
AdocAttr::allow("author", "someone"),
AdocAttr::deny("sectnums"),
],
base: None,
}
);
assert_eq!(
metadata.find_attr("author"),
Some(&AdocAttr::allow("author", "someone"))
);
}
#[test]
fn base_test() {
let mail = "someone@mail.domain";
let cmd_opts = vec![(
"-a".to_string(),
vec!["sectnums".to_string(), format!("email={}", mail)],
)];
let acx = AdocRunContext {
src_dir: ".".to_string(),
dst_dir: ".".to_string(),
opts: cmd_opts,
base_url: "".to_string(),
};
let deriving = AdocMetadata::extract_with_base(ARTICLE, &acx);
assert_eq!(
deriving.find_attr("sectnums"),
Some(&AdocAttr::deny("sectnums"))
);
assert_eq!(
deriving.find_attr("email"),
Some(&AdocAttr::allow("email", mail))
);
}
}