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