fluidattacks_blends/
svg.rs1use std::ffi::OsString;
5use std::fmt;
6use std::fs;
7use std::io::Write as _;
8use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10use std::thread;
11
12use blends_domain::graph_set::GraphSet;
13
14use crate::dot::DotGraph;
15
16const DOT_BINARY: &str = "dot";
17
18#[derive(Clone, PartialEq, Eq, Debug)]
21pub struct SvgRenderError(String);
22
23impl fmt::Display for SvgRenderError {
24 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25 f.write_str(&self.0)
26 }
27}
28
29impl std::error::Error for SvgRenderError {}
30
31fn missing_binary() -> SvgRenderError {
32 SvgRenderError(format!(
33 "The '{DOT_BINARY}' executable was not found. Graph rendering is a development-only \
34 feature: install Graphviz (brew install graphviz, apt install graphviz) or use the \
35 blends development environment"
36 ))
37}
38
39fn run(program: &str, source: &str) -> Result<String, SvgRenderError> {
42 let mut child = Command::new(program)
43 .arg("-Tsvg")
44 .stdin(Stdio::piped())
45 .stdout(Stdio::piped())
46 .stderr(Stdio::piped())
47 .spawn()
48 .map_err(|error| match error.kind() {
49 std::io::ErrorKind::NotFound => missing_binary(),
50 _ => SvgRenderError(format!("could not run {program}: {error}")),
51 })?;
52
53 let mut stdin = child
54 .stdin
55 .take()
56 .ok_or_else(|| SvgRenderError(format!("{program} exposed no stdin")))?;
57
58 let (output, written) = thread::scope(|scope| {
62 let writer = scope.spawn(move || stdin.write_all(source.as_bytes()));
64 let output = child.wait_with_output();
66
67 (output, writer.join())
68 });
69
70 let output = output
71 .map_err(|error| SvgRenderError(format!("could not read from {program}: {error}")))?;
72
73 if !output.status.success() {
74 let code = output
75 .status
76 .code()
77 .map_or_else(|| "signal".to_owned(), |code| code.to_string());
78 let stderr = String::from_utf8_lossy(&output.stderr);
79
80 return Err(SvgRenderError(format!(
83 "{DOT_BINARY} exited with code {code}: {}",
84 stderr.trim()
85 )));
86 }
87
88 written
89 .map_err(|_| SvgRenderError(format!("the {program} writer thread panicked")))?
90 .map_err(|error| SvgRenderError(format!("could not write to {program}: {error}")))?;
91
92 String::from_utf8(output.stdout)
93 .map_err(|error| SvgRenderError(format!("{program} emitted invalid utf-8: {error}")))
94}
95
96fn write(svg: &str, path: &Path) -> Result<PathBuf, SvgRenderError> {
97 if let Some(parent) = path.parent() {
98 fs::create_dir_all(parent).map_err(|error| {
99 SvgRenderError(format!("could not create {}: {error}", parent.display()))
100 })?;
101 }
102
103 fs::write(path, svg)
104 .map_err(|error| SvgRenderError(format!("could not write {}: {error}", path.display())))?;
105
106 Ok(path.to_path_buf())
107}
108
109pub fn render_dot(source: &str, path: &Path) -> Result<PathBuf, SvgRenderError> {
111 write(&run(DOT_BINARY, source)?, path)
112}
113
114pub fn to_svg<G: DotGraph>(graph: &G, path: &Path) -> Result<PathBuf, SvgRenderError> {
116 let name = path
117 .file_stem()
118 .and_then(|stem| stem.to_str())
119 .unwrap_or("graph");
120
121 render_dot(&graph.to_dot(name), path)
122}
123
124fn suffixed(prefix: &Path, suffix: &str) -> PathBuf {
127 let mut name = OsString::from(prefix);
128 name.push(suffix);
129
130 PathBuf::from(name)
131}
132
133pub fn render_graph_set(
141 graphs: &GraphSet,
142 output_prefix: &Path,
143) -> Result<Vec<PathBuf>, SvgRenderError> {
144 let mut outputs = Vec::new();
145
146 if let Some(ast) = graphs.ast.as_ref() {
147 outputs.push(to_svg(ast, &suffixed(output_prefix, ".ast.svg"))?);
148 }
149
150 if let Some(syntax) = graphs.syntax.as_ref() {
151 outputs.push(to_svg(
152 syntax,
153 &suffixed(output_prefix, ".syntax_graph.svg"),
154 )?);
155 }
156
157 Ok(outputs)
158}
159
160#[cfg(test)]
161mod tests {
162 use super::{render_graph_set, run, to_svg, GraphSet, DOT_BINARY};
163 use blends_domain::ast::{AstGraph, AstNode};
164 use blends_domain::syntax::{SyntaxGraph, SyntaxNode};
165 use blends_domain::NodeId;
166 use std::path::Path;
167
168 fn graph() -> AstGraph {
169 let mut graph = AstGraph::new();
170 graph.add_node(NodeId(1), AstNode::new(1, 0, "module".to_owned()));
171 graph.add_node(NodeId(2), AstNode::new(2, 4, "identifier".to_owned()));
172 graph.add_edge(NodeId(1), NodeId(2), 0);
173
174 graph
175 }
176
177 fn syntax_graph() -> SyntaxGraph {
178 let mut graph = SyntaxGraph::new();
179 graph.add_node(NodeId(1), SyntaxNode::File);
180 graph.add_node(NodeId(2), SyntaxNode::ArgumentList);
181 graph.add_ast_edge(NodeId(1), NodeId(2));
182
183 graph
184 }
185
186 fn graphviz_available() -> bool {
187 run(DOT_BINARY, "digraph {}").is_ok()
188 }
189
190 #[test]
191 fn reports_a_missing_binary() {
192 let error = run("dot-that-does-not-exist", "digraph {}").unwrap_err();
193
194 assert!(error.to_string().contains("was not found"));
195 }
196
197 #[test]
198 fn reports_a_program_that_cannot_be_executed() {
199 let dir = tempfile::tempdir().unwrap();
200
201 let error = run(dir.path().to_str().unwrap(), "digraph {}").unwrap_err();
202
203 assert!(error.to_string().contains("could not run"));
204 }
205
206 #[cfg(unix)]
209 #[test]
210 fn does_not_deadlock_when_the_child_floods_stderr() {
211 use std::os::unix::fs::PermissionsExt as _;
212
213 let dir = tempfile::tempdir().unwrap();
214 let script = dir.path().join("flood");
215 std::fs::write(
216 &script,
217 "#!/bin/sh\nyes 'warning: bad graph' | head -n 20000 >&2\nexit 1\n",
218 )
219 .unwrap();
220 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
221
222 let source = format!("digraph {{\n{}}}\n", " \"a\" -> \"b\";\n".repeat(50_000));
223
224 let error = run(script.to_str().unwrap(), &source).unwrap_err();
225
226 assert!(error.to_string().contains("exited with code 1"));
227 }
228
229 #[test]
230 fn write_creates_missing_parent_directories() {
231 let dir = tempfile::tempdir().unwrap();
232 let path = dir.path().join("nested").join("deeper").join("out.svg");
233
234 let written = super::write("<svg/>", &path).unwrap();
235
236 assert_eq!(written, path);
237 assert_eq!(std::fs::read_to_string(&path).unwrap(), "<svg/>");
238 }
239
240 #[test]
242 fn write_reports_a_parentless_path() {
243 let error = super::write("<svg/>", std::path::Path::new("/")).unwrap_err();
244
245 assert!(error.to_string().contains("could not write"));
246 }
247
248 #[test]
249 fn write_reports_an_unusable_parent() {
250 let dir = tempfile::tempdir().unwrap();
251 let blocked = dir.path().join("blocked");
252 std::fs::write(&blocked, "i am a file, not a directory").unwrap();
253
254 let error = super::write("<svg/>", &blocked.join("out.svg")).unwrap_err();
255
256 assert!(error.to_string().contains("could not create"));
257 }
258
259 #[test]
260 fn renders_a_real_svg_when_graphviz_is_available() {
261 if !graphviz_available() {
262 return;
263 }
264
265 let dir = tempfile::tempdir().unwrap();
266 let path = dir.path().join("nested").join("python_py.ast.svg");
267
268 let written = to_svg(&graph(), &path).unwrap();
269 let content = std::fs::read_to_string(&written).unwrap();
270
271 assert_eq!(written, path);
272 assert!(content.starts_with("<?xml"));
273 assert!(content.contains("<svg"));
274 assert!(content.trim_end().ends_with("</svg>"));
275 }
276
277 #[test]
278 fn suffixes_the_prefix_as_a_sibling_not_a_child() {
279 let path = super::suffixed(Path::new("/out/python_py"), ".ast.svg");
280
281 assert_eq!(path, Path::new("/out/python_py.ast.svg"));
282 }
283
284 #[test]
285 fn renders_a_graph_set_when_graphviz_is_available() {
286 if !graphviz_available() {
287 return;
288 }
289
290 let dir = tempfile::tempdir().unwrap();
291 let graphs = GraphSet {
292 ast: Some(graph()),
293 syntax: Some(syntax_graph()),
294 };
295
296 let outputs = render_graph_set(&graphs, &dir.path().join("python_py")).unwrap();
297
298 assert_eq!(
299 outputs,
300 vec![
301 dir.path().join("python_py.ast.svg"),
302 dir.path().join("python_py.syntax_graph.svg"),
303 ]
304 );
305 assert!(outputs.iter().all(|path| path.is_file()));
306 }
307
308 #[test]
309 fn renders_a_graph_set_skipping_absent_layers_when_graphviz_is_available() {
310 if !graphviz_available() {
311 return;
312 }
313
314 let dir = tempfile::tempdir().unwrap();
315 let ast_only = GraphSet {
316 ast: Some(graph()),
317 syntax: None,
318 };
319
320 let outputs = render_graph_set(&ast_only, &dir.path().join("python_py")).unwrap();
321
322 assert_eq!(outputs, vec![dir.path().join("python_py.ast.svg")]);
323 assert!(!dir.path().join("python_py.syntax_graph.svg").exists());
324 }
325
326 #[test]
327 fn renders_nothing_for_an_empty_graph_set() {
328 let dir = tempfile::tempdir().unwrap();
329
330 let outputs = render_graph_set(&GraphSet::default(), &dir.path().join("empty")).unwrap();
331
332 assert!(outputs.is_empty());
333 }
334
335 #[test]
336 fn reports_invalid_dot_source_when_graphviz_is_available() {
337 if !graphviz_available() {
338 return;
339 }
340
341 let error = run(DOT_BINARY, "this is not the dot language").unwrap_err();
342
343 assert!(error.to_string().contains("exited with code"));
344 }
345}