use std::ffi::OsString;
use std::fmt;
use std::fs;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::thread;
use blends_domain::graph_set::GraphSet;
use crate::dot::DotGraph;
const DOT_BINARY: &str = "dot";
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct SvgRenderError(String);
impl fmt::Display for SvgRenderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for SvgRenderError {}
fn missing_binary() -> SvgRenderError {
SvgRenderError(format!(
"The '{DOT_BINARY}' executable was not found. Graph rendering is a development-only \
feature: install Graphviz (brew install graphviz, apt install graphviz) or use the \
blends development environment"
))
}
fn run(program: &str, source: &str) -> Result<String, SvgRenderError> {
let mut child = Command::new(program)
.arg("-Tsvg")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|error| match error.kind() {
std::io::ErrorKind::NotFound => missing_binary(),
_ => SvgRenderError(format!("could not run {program}: {error}")),
})?;
let mut stdin = child
.stdin
.take()
.ok_or_else(|| SvgRenderError(format!("{program} exposed no stdin")))?;
let (output, written) = thread::scope(|scope| {
let writer = scope.spawn(move || stdin.write_all(source.as_bytes()));
let output = child.wait_with_output();
(output, writer.join())
});
let output = output
.map_err(|error| SvgRenderError(format!("could not read from {program}: {error}")))?;
if !output.status.success() {
let code = output
.status
.code()
.map_or_else(|| "signal".to_owned(), |code| code.to_string());
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(SvgRenderError(format!(
"{DOT_BINARY} exited with code {code}: {}",
stderr.trim()
)));
}
written
.map_err(|_| SvgRenderError(format!("the {program} writer thread panicked")))?
.map_err(|error| SvgRenderError(format!("could not write to {program}: {error}")))?;
String::from_utf8(output.stdout)
.map_err(|error| SvgRenderError(format!("{program} emitted invalid utf-8: {error}")))
}
fn write(svg: &str, path: &Path) -> Result<PathBuf, SvgRenderError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|error| {
SvgRenderError(format!("could not create {}: {error}", parent.display()))
})?;
}
fs::write(path, svg)
.map_err(|error| SvgRenderError(format!("could not write {}: {error}", path.display())))?;
Ok(path.to_path_buf())
}
pub fn render_dot(source: &str, path: &Path) -> Result<PathBuf, SvgRenderError> {
write(&run(DOT_BINARY, source)?, path)
}
pub fn to_svg<G: DotGraph>(graph: &G, path: &Path) -> Result<PathBuf, SvgRenderError> {
let name = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or("graph");
render_dot(&graph.to_dot(name), path)
}
fn suffixed(prefix: &Path, suffix: &str) -> PathBuf {
let mut name = OsString::from(prefix);
name.push(suffix);
PathBuf::from(name)
}
pub fn render_graph_set(
graphs: &GraphSet,
output_prefix: &Path,
) -> Result<Vec<PathBuf>, SvgRenderError> {
let mut outputs = Vec::new();
if let Some(ast) = graphs.ast.as_ref() {
outputs.push(to_svg(ast, &suffixed(output_prefix, ".ast.svg"))?);
}
if let Some(syntax) = graphs.syntax.as_ref() {
outputs.push(to_svg(
syntax,
&suffixed(output_prefix, ".syntax_graph.svg"),
)?);
}
Ok(outputs)
}
#[cfg(test)]
mod tests {
use super::{render_graph_set, run, to_svg, GraphSet, DOT_BINARY};
use blends_domain::ast::{AstGraph, AstNode};
use blends_domain::syntax::{SyntaxGraph, SyntaxNode};
use blends_domain::NodeId;
use std::path::Path;
fn graph() -> AstGraph {
let mut graph = AstGraph::new();
graph.add_node(NodeId(1), AstNode::new(1, 0, "module".to_owned()));
graph.add_node(NodeId(2), AstNode::new(2, 4, "identifier".to_owned()));
graph.add_edge(NodeId(1), NodeId(2), 0);
graph
}
fn syntax_graph() -> SyntaxGraph {
let mut graph = SyntaxGraph::new();
graph.add_node(NodeId(1), SyntaxNode::File);
graph.add_node(NodeId(2), SyntaxNode::ArgumentList);
graph.add_ast_edge(NodeId(1), NodeId(2));
graph
}
fn graphviz_available() -> bool {
run(DOT_BINARY, "digraph {}").is_ok()
}
#[test]
fn reports_a_missing_binary() {
let error = run("dot-that-does-not-exist", "digraph {}").unwrap_err();
assert!(error.to_string().contains("was not found"));
}
#[test]
fn reports_a_program_that_cannot_be_executed() {
let dir = tempfile::tempdir().unwrap();
let error = run(dir.path().to_str().unwrap(), "digraph {}").unwrap_err();
assert!(error.to_string().contains("could not run"));
}
#[cfg(unix)]
#[test]
fn does_not_deadlock_when_the_child_floods_stderr() {
use std::os::unix::fs::PermissionsExt as _;
let dir = tempfile::tempdir().unwrap();
let script = dir.path().join("flood");
std::fs::write(
&script,
"#!/bin/sh\nyes 'warning: bad graph' | head -n 20000 >&2\nexit 1\n",
)
.unwrap();
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
let source = format!("digraph {{\n{}}}\n", " \"a\" -> \"b\";\n".repeat(50_000));
let error = run(script.to_str().unwrap(), &source).unwrap_err();
assert!(error.to_string().contains("exited with code 1"));
}
#[test]
fn write_creates_missing_parent_directories() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("deeper").join("out.svg");
let written = super::write("<svg/>", &path).unwrap();
assert_eq!(written, path);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "<svg/>");
}
#[test]
fn write_reports_a_parentless_path() {
let error = super::write("<svg/>", std::path::Path::new("/")).unwrap_err();
assert!(error.to_string().contains("could not write"));
}
#[test]
fn write_reports_an_unusable_parent() {
let dir = tempfile::tempdir().unwrap();
let blocked = dir.path().join("blocked");
std::fs::write(&blocked, "i am a file, not a directory").unwrap();
let error = super::write("<svg/>", &blocked.join("out.svg")).unwrap_err();
assert!(error.to_string().contains("could not create"));
}
#[test]
fn renders_a_real_svg_when_graphviz_is_available() {
if !graphviz_available() {
return;
}
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("python_py.ast.svg");
let written = to_svg(&graph(), &path).unwrap();
let content = std::fs::read_to_string(&written).unwrap();
assert_eq!(written, path);
assert!(content.starts_with("<?xml"));
assert!(content.contains("<svg"));
assert!(content.trim_end().ends_with("</svg>"));
}
#[test]
fn suffixes_the_prefix_as_a_sibling_not_a_child() {
let path = super::suffixed(Path::new("/out/python_py"), ".ast.svg");
assert_eq!(path, Path::new("/out/python_py.ast.svg"));
}
#[test]
fn renders_a_graph_set_when_graphviz_is_available() {
if !graphviz_available() {
return;
}
let dir = tempfile::tempdir().unwrap();
let graphs = GraphSet {
ast: Some(graph()),
syntax: Some(syntax_graph()),
};
let outputs = render_graph_set(&graphs, &dir.path().join("python_py")).unwrap();
assert_eq!(
outputs,
vec![
dir.path().join("python_py.ast.svg"),
dir.path().join("python_py.syntax_graph.svg"),
]
);
assert!(outputs.iter().all(|path| path.is_file()));
}
#[test]
fn renders_a_graph_set_skipping_absent_layers_when_graphviz_is_available() {
if !graphviz_available() {
return;
}
let dir = tempfile::tempdir().unwrap();
let ast_only = GraphSet {
ast: Some(graph()),
syntax: None,
};
let outputs = render_graph_set(&ast_only, &dir.path().join("python_py")).unwrap();
assert_eq!(outputs, vec![dir.path().join("python_py.ast.svg")]);
assert!(!dir.path().join("python_py.syntax_graph.svg").exists());
}
#[test]
fn renders_nothing_for_an_empty_graph_set() {
let dir = tempfile::tempdir().unwrap();
let outputs = render_graph_set(&GraphSet::default(), &dir.path().join("empty")).unwrap();
assert!(outputs.is_empty());
}
#[test]
fn reports_invalid_dot_source_when_graphviz_is_available() {
if !graphviz_available() {
return;
}
let error = run(DOT_BINARY, "this is not the dot language").unwrap_err();
assert!(error.to_string().contains("exited with code"));
}
}