oxidd_dump/visualize.rs
1//! Visualization with [OxiDD-vis](https://oxidd.net/vis)
2
3use std::fmt;
4use std::io::{self, Read, Write};
5use std::net::{TcpListener, TcpStream};
6
7use oxidd_core::function::{Function, INodeOfFunc, TermOfFunc};
8use oxidd_core::HasLevel;
9
10use crate::AsciiDisplay;
11
12/// [OxiDD-vis]-compatible decision diagram exporter that
13/// serves decision diagram dumps on localhost via HTTP
14///
15/// [OxiDD-vis] is a webapp that runs locally in your browser. You can directly
16/// send decision diagrams to it via an HTTP connection. Here, the `Visualizer`
17/// acts as a small HTTP server that accepts connections once you call
18/// [`Visualizer::serve()`]. The webapp repeatedly polls on the configured port
19/// to directly display the decision diagrams then.
20///
21/// [OxiDD-vis]: https://oxidd.net/vis
22pub struct Visualizer {
23 port: u16,
24 buf: Vec<u8>,
25}
26
27impl Default for Visualizer {
28 #[inline]
29 fn default() -> Self {
30 Self::new()
31 }
32}
33
34impl Visualizer {
35 /// Create a new visualizer
36 pub fn new() -> Self {
37 Self {
38 port: 4000,
39 buf: Vec::with_capacity(2 * 1024 * 1024), // 2 MiB
40 }
41 }
42
43 /// Customize the port on which to serve the visualization data
44 ///
45 /// The default port is 4000.
46 ///
47 /// Returns `self`.
48 #[inline(always)]
49 pub fn port(mut self, port: u16) -> Self {
50 self.port = port;
51 self
52 }
53
54 fn add_diagram_start(&mut self, ty: &str, diagram_name: &str) {
55 let buf = &mut self.buf;
56 if !buf.is_empty() {
57 buf.push(b',');
58 }
59
60 buf.extend_from_slice(b"{\"type\":\"");
61 buf.extend_from_slice(ty.as_bytes());
62 buf.extend_from_slice(b"\",\"name\":\"");
63 json_escape(buf, diagram_name.as_bytes());
64 buf.extend_from_slice(b"\",\"diagram\":\"");
65 }
66
67 /// Add a decision diagram for visualization
68 ///
69 /// `diagram_name` can be used as an identifier in case you add multiple
70 /// diagrams. `functions` is an iterator over [`Function`]s.
71 ///
72 /// The visualization includes all nodes reachable from the root nodes
73 /// referenced by `functions`. If you wish to name the functions, use
74 /// [`Self::add_with_names()`].
75 ///
76 /// Returns `self` to allow chaining.
77 ///
78 /// # Example
79 ///
80 /// ```
81 /// # use oxidd_core::function::Function;
82 /// # use oxidd_dump::Visualizer;
83 /// # fn vis<'id, F: Function>(manager: &F::Manager<'id>, f0: &F, f1: &F)
84 /// # where
85 /// # oxidd_core::function::INodeOfFunc<'id, F>: oxidd_core::HasLevel,
86 /// # oxidd_core::function::TermOfFunc<'id, F>: oxidd_dump::AsciiDisplay,
87 /// # {
88 /// Visualizer::new()
89 /// .add("my_diagram", manager, [f0, f1])
90 /// .serve()
91 /// .expect("failed to serve diagram due to I/O error")
92 /// # }
93 /// ```
94 pub fn add<'id, FR: std::ops::Deref>(
95 mut self,
96 diagram_name: &str,
97 manager: &<FR::Target as Function>::Manager<'id>,
98 functions: impl IntoIterator<Item = FR>,
99 ) -> Self
100 where
101 FR::Target: Function,
102 INodeOfFunc<'id, FR::Target>: HasLevel,
103 TermOfFunc<'id, FR::Target>: AsciiDisplay,
104 {
105 self.add_diagram_start(FR::Target::REPR_ID, diagram_name);
106 crate::dddmp::ExportSettings::default()
107 .strict(false) // adjust names without error
108 .ascii()
109 .diagram_name(diagram_name)
110 .export(JsonStrWriter(&mut self.buf), manager, functions)
111 .unwrap(); // writing to a Vec<u8> should not lead to I/O errors
112 self.buf.extend_from_slice(b"\"}");
113
114 self
115 }
116
117 /// Add a decision diagram for visualization
118 ///
119 /// `diagram_name` can be used as an identifier in case you add multiple
120 /// diagrams. `functions` is an iterator over pairs of a [`Function`] and
121 /// a name.
122 ///
123 /// The visualization includes all nodes reachable from the root nodes
124 /// referenced by `functions`.
125 ///
126 /// Returns `self` to allow chaining.
127 ///
128 /// # Example
129 ///
130 /// ```
131 /// # use oxidd_core::function::Function;
132 /// # use oxidd_dump::Visualizer;
133 /// # fn vis<'id, F: Function>(manager: &F::Manager<'id>, phi: &F, res: &F)
134 /// # where
135 /// # oxidd_core::function::INodeOfFunc<'id, F>: oxidd_core::HasLevel,
136 /// # oxidd_core::function::TermOfFunc<'id, F>: oxidd_dump::AsciiDisplay,
137 /// # {
138 /// Visualizer::new()
139 /// .add_with_names("my_diagram", manager, [(phi, "ϕ"), (res, "result")])
140 /// .serve()
141 /// .expect("failed to serve diagram due to I/O error")
142 /// # }
143 /// ```
144 pub fn add_with_names<'id, FR: std::ops::Deref, D: fmt::Display>(
145 mut self,
146 diagram_name: &str,
147 manager: &<FR::Target as Function>::Manager<'id>,
148 functions: impl IntoIterator<Item = (FR, D)>,
149 ) -> Self
150 where
151 FR::Target: Function,
152 INodeOfFunc<'id, FR::Target>: HasLevel,
153 TermOfFunc<'id, FR::Target>: AsciiDisplay,
154 {
155 self.add_diagram_start(FR::Target::REPR_ID, diagram_name);
156 crate::dddmp::ExportSettings::default()
157 .strict(false) // adjust names without error
158 .ascii()
159 .diagram_name(diagram_name)
160 .export_with_names(JsonStrWriter(&mut self.buf), manager, functions)
161 .unwrap(); // writing to a Vec<u8> should not lead to I/O errors
162 self.buf.extend_from_slice(b"\"}");
163
164 self
165 }
166
167 /// Remove all previously added decision diagrams
168 #[inline(always)]
169 pub fn clear(&mut self) {
170 self.buf.clear();
171 }
172
173 /// Serve the provided decision diagram for visualization
174 ///
175 /// Blocks until the visualization has been fetched by
176 /// [OxiDD-vis](https://oxidd.net/vis) (or another compatible tool).
177 ///
178 /// On success, all previously added decision diagrams are removed from the
179 /// internal buffer. On error, the internal buffer is left as-is.
180 pub fn serve(&mut self) -> io::Result<()> {
181 let port = self.port;
182 let listener = TcpListener::bind(("localhost", port))?;
183 println!("Data can be read on http://localhost:{port}");
184
185 let mut buf = EMPTY_RECV_BUF;
186 while !self.handle_client(listener.accept()?.0, &mut buf)? {}
187 Ok(())
188 }
189
190 /// Non-blocking version of [`Self::serve()`]
191 ///
192 /// Unlike [`Self::serve()`], this method sets the [`TcpListener`] into
193 /// [non-blocking mode][TcpListener::set_nonblocking()], allowing to run
194 /// different tasks while waiting for a connection by
195 /// [OxiDD-vis](https://oxidd.net/vis) (or another compatible tool).
196 ///
197 /// Note that you need to call [`poll()`][VisualizationListener::poll()]
198 /// repeatedly on the returned [`VisualizationListener`] to accept a TCP
199 /// connection.
200 pub fn serve_nonblocking(&mut self) -> io::Result<VisualizationListener<'_>> {
201 let port = self.port;
202 let listener = TcpListener::bind(("localhost", port))?;
203 listener.set_nonblocking(true)?;
204 println!("Data can be read on http://localhost:{port}");
205
206 Ok(VisualizationListener {
207 visualizer: self,
208 listener,
209 buf: Box::new(EMPTY_RECV_BUF),
210 })
211 }
212
213 /// Returns `Ok(true)` if the visualization has been sent, `Ok(false)` if
214 /// the client did not request `/diagrams`
215 fn handle_client(&mut self, stream: TcpStream, buf: &mut RecvBuf) -> io::Result<bool> {
216 use io::ErrorKind::*;
217 let mut stream = HttpStream::new(stream, buf)?;
218 loop {
219 return match self.handle_req(&mut stream) {
220 Ok(false) => continue,
221 Err(e) if matches!(e.kind(), UnexpectedEof | WouldBlock | TimedOut) => Ok(false),
222 res => res,
223 };
224 }
225 }
226
227 /// Returns `Ok(true)` if the visualization has been sent, `Ok(false)` if
228 /// request is for a path different from `/diagrams`
229 fn handle_req(&mut self, stream: &mut HttpStream) -> io::Result<bool> {
230 // Basic routing: only handle "GET /diagrams"
231 // After the path, there should be "HTTP/1.1" (or something alike),
232 // so expecting the space is fine.
233 if !stream.next_request_starts_with(b"GET /diagrams ")? {
234 stream.conn.write_all(
235 b"HTTP/1.1 404 NOT FOUND\r\n\
236 Content-Type: text/plain\r\n\
237 Content-Length: 9\r\n\
238 Access-Control-Allow-Origin: *\r\n\
239 \r\n\
240 Not Found",
241 )?;
242 return Ok(false);
243 }
244
245 let len = self.buf.len() + 2; // 2 -> account for outer brackets
246 write!(
247 stream.conn,
248 "HTTP/1.1 200 OK\r\n\
249 Content-Type: application/json\r\n\
250 Content-Length: {len}\r\n\
251 Access-Control-Allow-Origin: *\r\n\
252 \r\n\
253 ["
254 )?;
255 self.buf.push(b']');
256 if let Err(err) = stream.conn.write_all(&self.buf) {
257 self.buf.pop();
258 return Err(err);
259 }
260 self.buf.clear();
261 println!("Visualization has been sent!");
262 Ok(true)
263 }
264}
265
266type RecvBuf = [u8; 1024];
267const EMPTY_RECV_BUF: RecvBuf = [0; 1024];
268struct HttpStream<'a> {
269 recv_buf: &'a mut RecvBuf,
270 read_bytes: usize,
271 conn: TcpStream,
272}
273
274impl<'a> HttpStream<'a> {
275 fn new(stream: TcpStream, recv_buf: &'a mut RecvBuf) -> io::Result<Self> {
276 stream.set_nonblocking(false)?;
277 stream.set_read_timeout(Some(std::time::Duration::from_secs(3)))?;
278 Ok(Self {
279 recv_buf,
280 read_bytes: 0,
281 conn: stream,
282 })
283 }
284
285 /// Read the next HTTP request and check if it starts with the expected byte
286 /// sequence (e.g., `GET /foo `).
287 fn next_request_starts_with(&mut self, expected: &[u8]) -> io::Result<bool> {
288 debug_assert!(expected.len() <= self.recv_buf.len());
289 let result = loop {
290 // check before read, because we may already have received the
291 // request on the last call
292 if self.read_bytes >= expected.len() {
293 break self.recv_buf[..expected.len()] == *expected;
294 }
295 self.read_bytes += self.conn.read(&mut self.recv_buf[self.read_bytes..])?;
296 if self.read_bytes == 0 {
297 return Err(io::ErrorKind::UnexpectedEof.into());
298 }
299 };
300 // Find the message's end, which is "\r\n\r\n" according to the RFC.
301 // Instead, we just test for the newlines.
302 let mut last_nl = self.recv_buf.len(); // dummy value
303 loop {
304 for (i, &c) in self.recv_buf[..self.read_bytes].iter().enumerate() {
305 if c == b'\n' {
306 if i == last_nl.wrapping_add(2) {
307 // there may be another message in the buffer, copy it
308 // to the beginning
309 self.recv_buf.copy_within(i + 1..self.read_bytes, 0);
310 self.read_bytes -= i + 1;
311 return Ok(result);
312 }
313 last_nl = i;
314 }
315 }
316 last_nl = last_nl.wrapping_sub(self.read_bytes);
317 self.read_bytes = self.conn.read(self.recv_buf)?;
318 if self.read_bytes == 0 {
319 return Err(io::ErrorKind::UnexpectedEof.into());
320 }
321 }
322 }
323}
324
325/// A non-blocking [`TcpListener`] for decision diagram visualization
326pub struct VisualizationListener<'a> {
327 visualizer: &'a mut Visualizer,
328 listener: TcpListener,
329 buf: Box<RecvBuf>,
330}
331
332impl VisualizationListener<'_> {
333 /// Poll for clients like [OxiDD-vis](https://oxidd.net/vis)
334 ///
335 /// If a connection was established, this method will directly handle the
336 /// client. If the decision diagram(s) were successfully sent, the return
337 /// value is `Ok(true)`. If no client was available
338 /// ([`TcpListener::accept()`] returned an error with
339 /// [`io::ErrorKind::WouldBlock`]), the return value is `Ok(false)`.
340 /// `Err(..)` signals a communication error.
341 pub fn poll(&mut self) -> io::Result<bool> {
342 loop {
343 match self.listener.accept() {
344 Ok((stream, _)) => {
345 if self.visualizer.handle_client(stream, &mut self.buf)? {
346 return Ok(true);
347 }
348 }
349 Err(e) => {
350 return if e.kind() == io::ErrorKind::WouldBlock {
351 Ok(false)
352 } else {
353 Err(e)
354 };
355 }
356 }
357 }
358 }
359}
360
361#[inline]
362fn hex_digit(c: u8) -> u8 {
363 if c >= 10 {
364 b'a' + c - 10
365 } else {
366 b'0' + c
367 }
368}
369
370fn json_escape(target: &mut Vec<u8>, data: &[u8]) {
371 for &c in data {
372 let mut seq;
373 target.extend_from_slice(match c {
374 0x08 => b"\\b",
375 b'\t' => b"\\t",
376 b'\n' => b"\\n",
377 0x0c => b"\\f",
378 b'\r' => b"\\r",
379 b'\\' => b"\\\\",
380 b'"' => b"\\\"",
381 _ if c < 0x20 => {
382 seq = *b"\\u0000";
383 seq[4] = hex_digit(c >> 4);
384 seq[5] = hex_digit(c & 0b1111);
385 &seq
386 }
387 0x7f => b"\\u007f", // ASCII DEL
388 _ => {
389 target.push(c);
390 continue;
391 }
392 })
393 }
394}
395
396struct JsonStrWriter<'a>(&'a mut Vec<u8>);
397
398impl std::io::Write for JsonStrWriter<'_> {
399 #[inline]
400 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
401 json_escape(self.0, buf);
402 Ok(buf.len())
403 }
404
405 fn flush(&mut self) -> io::Result<()> {
406 Ok(())
407 }
408}