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(
134 graphs: &GraphSet,
135 output_prefix: &Path,
136) -> Result<Vec<PathBuf>, SvgRenderError> {
137 let mut outputs = Vec::new();
138
139 if let Some(ast) = graphs.ast.as_ref() {
140 outputs.push(to_svg(ast, &suffixed(output_prefix, ".ast.svg"))?);
141 }
142
143 if let Some(syntax) = graphs.syntax.as_ref() {
144 outputs.push(to_svg(
145 syntax,
146 &suffixed(output_prefix, ".syntax_graph.svg"),
147 )?);
148 }
149
150 Ok(outputs)
151}
152
153#[cfg(test)]
154mod tests {
155 use super::{render_graph_set, run, to_svg, GraphSet, DOT_BINARY};
156 use blends_domain::ast::{AstGraph, AstNode};
157 use blends_domain::syntax::{SyntaxGraph, SyntaxNode};
158 use blends_domain::NodeId;
159 use std::path::Path;
160
161 fn graph() -> AstGraph {
162 let mut graph = AstGraph::new();
163 graph.add_node(NodeId(1), AstNode::new(1, 0, "module".to_owned()));
164 graph.add_node(NodeId(2), AstNode::new(2, 4, "identifier".to_owned()));
165 graph.add_edge(NodeId(1), NodeId(2), 0);
166
167 graph
168 }
169
170 fn syntax_graph() -> SyntaxGraph {
171 let mut graph = SyntaxGraph::new();
172 graph.add_node(NodeId(1), SyntaxNode::File);
173 graph.add_node(NodeId(2), SyntaxNode::ArgumentList);
174 graph.add_ast_edge(NodeId(1), NodeId(2));
175
176 graph
177 }
178
179 fn graphviz_available() -> bool {
180 run(DOT_BINARY, "digraph {}").is_ok()
181 }
182
183 #[test]
184 fn reports_a_missing_binary() {
185 let error = run("dot-that-does-not-exist", "digraph {}").unwrap_err();
186
187 assert!(error.to_string().contains("was not found"));
188 }
189
190 #[test]
191 fn reports_a_program_that_cannot_be_executed() {
192 let dir = tempfile::tempdir().unwrap();
193
194 let error = run(dir.path().to_str().unwrap(), "digraph {}").unwrap_err();
195
196 assert!(error.to_string().contains("could not run"));
197 }
198
199 #[cfg(unix)]
202 #[test]
203 fn does_not_deadlock_when_the_child_floods_stderr() {
204 use std::os::unix::fs::PermissionsExt as _;
205
206 let dir = tempfile::tempdir().unwrap();
207 let script = dir.path().join("flood");
208 std::fs::write(
209 &script,
210 "#!/bin/sh\nyes 'warning: bad graph' | head -n 20000 >&2\nexit 1\n",
211 )
212 .unwrap();
213 std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
214
215 let source = format!("digraph {{\n{}}}\n", " \"a\" -> \"b\";\n".repeat(50_000));
216
217 let error = run(script.to_str().unwrap(), &source).unwrap_err();
218
219 assert!(error.to_string().contains("exited with code 1"));
220 }
221
222 #[test]
223 fn write_creates_missing_parent_directories() {
224 let dir = tempfile::tempdir().unwrap();
225 let path = dir.path().join("nested").join("deeper").join("out.svg");
226
227 let written = super::write("<svg/>", &path).unwrap();
228
229 assert_eq!(written, path);
230 assert_eq!(std::fs::read_to_string(&path).unwrap(), "<svg/>");
231 }
232
233 #[test]
235 fn write_reports_a_parentless_path() {
236 let error = super::write("<svg/>", std::path::Path::new("/")).unwrap_err();
237
238 assert!(error.to_string().contains("could not write"));
239 }
240
241 #[test]
242 fn write_reports_an_unusable_parent() {
243 let dir = tempfile::tempdir().unwrap();
244 let blocked = dir.path().join("blocked");
245 std::fs::write(&blocked, "i am a file, not a directory").unwrap();
246
247 let error = super::write("<svg/>", &blocked.join("out.svg")).unwrap_err();
248
249 assert!(error.to_string().contains("could not create"));
250 }
251
252 #[test]
253 fn renders_a_real_svg_when_graphviz_is_available() {
254 if !graphviz_available() {
255 return;
256 }
257
258 let dir = tempfile::tempdir().unwrap();
259 let path = dir.path().join("nested").join("python_py.ast.svg");
260
261 let written = to_svg(&graph(), &path).unwrap();
262 let content = std::fs::read_to_string(&written).unwrap();
263
264 assert_eq!(written, path);
265 assert!(content.starts_with("<?xml"));
266 assert!(content.contains("<svg"));
267 assert!(content.trim_end().ends_with("</svg>"));
268 }
269
270 #[test]
271 fn suffixes_the_prefix_as_a_sibling_not_a_child() {
272 let path = super::suffixed(Path::new("/out/python_py"), ".ast.svg");
273
274 assert_eq!(path, Path::new("/out/python_py.ast.svg"));
275 }
276
277 #[test]
278 fn renders_a_graph_set_when_graphviz_is_available() {
279 if !graphviz_available() {
280 return;
281 }
282
283 let dir = tempfile::tempdir().unwrap();
284 let graphs = GraphSet {
285 ast: Some(graph()),
286 syntax: Some(syntax_graph()),
287 };
288
289 let outputs = render_graph_set(&graphs, &dir.path().join("python_py")).unwrap();
290
291 assert_eq!(
292 outputs,
293 vec![
294 dir.path().join("python_py.ast.svg"),
295 dir.path().join("python_py.syntax_graph.svg"),
296 ]
297 );
298 assert!(outputs.iter().all(|path| path.is_file()));
299 }
300
301 #[test]
302 fn renders_a_graph_set_skipping_absent_layers_when_graphviz_is_available() {
303 if !graphviz_available() {
304 return;
305 }
306
307 let dir = tempfile::tempdir().unwrap();
308 let ast_only = GraphSet {
309 ast: Some(graph()),
310 syntax: None,
311 };
312
313 let outputs = render_graph_set(&ast_only, &dir.path().join("python_py")).unwrap();
314
315 assert_eq!(outputs, vec![dir.path().join("python_py.ast.svg")]);
316 assert!(!dir.path().join("python_py.syntax_graph.svg").exists());
317 }
318
319 #[test]
320 fn renders_nothing_for_an_empty_graph_set() {
321 let dir = tempfile::tempdir().unwrap();
322
323 let outputs = render_graph_set(&GraphSet::default(), &dir.path().join("empty")).unwrap();
324
325 assert!(outputs.is_empty());
326 }
327
328 #[test]
329 fn reports_invalid_dot_source_when_graphviz_is_available() {
330 if !graphviz_available() {
331 return;
332 }
333
334 let error = run(DOT_BINARY, "this is not the dot language").unwrap_err();
335
336 assert!(error.to_string().contains("exited with code"));
337 }
338}