use std::cell::Cell;
use std::collections::{HashMap, VecDeque};
use std::panic::AssertUnwindSafe;
use std::rc::Rc;
use std::sync::mpsc;
use std::sync::{Arc, LazyLock, Mutex};
use std::time::{Duration, Instant};
use rquickjs::{Context, Ctx, Function, Runtime};
const PELE: &str = include_str!("../assets/vendor/pele.js");
const PRELUDE: &str = r#"
globalThis.URL = class URL {
constructor(address) {
const m = /^([a-zA-Z][a-zA-Z0-9+.-]*:)\/\/(?:([^@/?#]*)@)?([^:/?#]+)(:\d*)?([^]*)$/.exec(String(address));
if (!m) throw new TypeError("Invalid URL: " + address);
this.protocol = m[1];
this.userinfo = m[2];
this.hostname = m[3];
this.port = m[4] === ":" ? "" : (m[4] ?? "");
this.rest = (m[5].startsWith("/") ? m[5] : "/" + m[5]).replace(/ /g, "%20");
}
toString() {
const user = this.userinfo === undefined ? "" : this.userinfo + "@";
return this.protocol + "//" + user + this.hostname + this.port + this.rest;
}
};
"#;
const DRAW: &str = "globalThis.__devpageDraw = (source) => pele.render(source).svg;";
#[derive(Clone, Copy, Debug)]
pub(crate) struct Limits {
pub time: Duration,
pub memory: usize,
}
impl Limits {
pub const SERVER: Limits = Limits {
time: Duration::from_secs(2),
memory: 128 << 20,
};
}
const CACHE_SIZE: usize = 1024;
pub type Drawn = Result<Arc<str>, String>;
#[derive(Debug)]
enum Failure {
Pele(String),
Engine(String),
}
struct Job {
sources: Vec<String>,
reply: mpsc::Sender<Vec<Result<String, Failure>>>,
}
pub(crate) struct Engine {
jobs: mpsc::Sender<Job>,
cache: Mutex<Cache>,
#[cfg(test)]
sent: std::sync::atomic::AtomicUsize,
}
#[derive(Default)]
struct Cache {
drawn: HashMap<String, Drawn>,
order: VecDeque<String>,
}
impl Cache {
fn insert(&mut self, source: String, drawn: Drawn) {
if self.drawn.insert(source.clone(), drawn).is_none() {
self.order.push_back(source);
}
while self.order.len() > CACHE_SIZE {
if let Some(oldest) = self.order.pop_front() {
self.drawn.remove(&oldest);
}
}
}
}
static ENGINE: LazyLock<Engine> = LazyLock::new(|| Engine::start(Limits::SERVER));
pub fn start() {
let engine = LazyLock::force(&ENGINE);
let (reply, _) = mpsc::channel();
let _ = engine.jobs.send(Job {
sources: Vec::new(),
reply,
});
}
pub fn draw_all(sources: &[String]) -> Vec<Drawn> {
ENGINE.draw_all(sources)
}
pub fn draw_fences(html: &str) -> String {
draw_fences_with(&ENGINE, html)
}
const OPEN: &str = "<pre lang=\"mermaid\"><code>";
const CLOSE: &str = "</code></pre>";
fn draw_fences_with(engine: &Engine, html: &str) -> String {
if !html.contains(OPEN) {
return html.to_string();
}
let mut blocks = Vec::new();
let mut from = 0;
while let Some(start) = html[from..].find(OPEN).map(|at| from + at) {
let body = start + OPEN.len();
let Some(end) = html[body..].find(CLOSE).map(|at| body + at) else {
break;
};
blocks.push((start, end + CLOSE.len(), unescape(&html[body..end])));
from = end + CLOSE.len();
}
let sources: Vec<String> = blocks.iter().map(|(_, _, s)| s.clone()).collect();
let drawn = engine.draw_all(&sources);
let mut out = String::with_capacity(html.len());
let mut from = 0;
for ((start, end, source), drawn) in blocks.iter().zip(drawn) {
out.push_str(&html[from..*start]);
match drawn {
Ok(svg) => {
out.push_str("<div class=\"diagram\">");
out.push_str(&svg);
out.push_str("</div>");
}
Err(why) => out.push_str(&format!(
"<pre class=\"diagram-error\">Diagram error: {}\n\n{}</pre>",
escape(&why),
escape(source)
)),
}
from = *end;
}
out.push_str(&html[from..]);
out
}
fn unescape(html: &str) -> String {
html.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("&", "&")
}
fn escape(text: &str) -> String {
text.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
}
impl Engine {
pub(crate) fn start(limits: Limits) -> Engine {
let (jobs, received) = mpsc::channel::<Job>();
std::thread::Builder::new()
.name("diagrams".into())
.stack_size(16 << 20)
.spawn(move || serve(limits, received))
.expect("the diagram thread");
Engine {
jobs,
cache: Mutex::default(),
#[cfg(test)]
sent: Default::default(),
}
}
pub(crate) fn draw_all(&self, sources: &[String]) -> Vec<Drawn> {
let mut drawn: Vec<Option<Drawn>> = {
let cache = self.cache.lock().unwrap_or_else(|p| p.into_inner());
sources
.iter()
.map(|s| cache.drawn.get(s).cloned())
.collect()
};
let missing: Vec<usize> = (0..sources.len()).filter(|&i| drawn[i].is_none()).collect();
if !missing.is_empty() {
#[cfg(test)]
self.sent
.fetch_add(missing.len(), std::sync::atomic::Ordering::Relaxed);
let (reply, answer) = mpsc::channel();
let job = Job {
sources: missing.iter().map(|&i| sources[i].clone()).collect(),
reply,
};
let answers = self
.jobs
.send(job)
.ok()
.and_then(|()| answer.recv().ok())
.unwrap_or_default();
let mut answers = answers.into_iter();
let mut cache = self.cache.lock().unwrap_or_else(|p| p.into_inner());
for i in missing {
drawn[i] = Some(match answers.next() {
Some(Ok(svg)) => {
let svg: Arc<str> = svg.into();
cache.insert(sources[i].clone(), Ok(svg.clone()));
Ok(svg)
}
Some(Err(Failure::Pele(why))) => {
cache.insert(sources[i].clone(), Err(why.clone()));
Err(why)
}
Some(Err(Failure::Engine(why))) => Err(why),
None => Err("the diagram engine stopped".to_string()),
});
}
}
drawn
.into_iter()
.map(|d| d.unwrap_or_else(|| Err("the diagram engine stopped".to_string())))
.collect()
}
}
fn serve(limits: Limits, jobs: mpsc::Receiver<Job>) {
let mut pele: Option<Pele> = None;
for job in jobs {
if pele.is_none() {
pele = std::panic::catch_unwind(|| Pele::load(limits).ok())
.ok()
.flatten();
}
let mut answers = Vec::with_capacity(job.sources.len());
for source in &job.sources {
let drawn = std::panic::catch_unwind(AssertUnwindSafe(|| {
if pele.is_none() {
pele = Some(Pele::load(limits)?);
}
let drawn = pele.as_ref().map(|p| p.draw(source));
drawn.unwrap_or_else(|| Err(Failure::Engine("no runtime".into())))
}))
.unwrap_or_else(|_| Err(Failure::Engine("the diagram engine failed".into())));
if matches!(drawn, Err(Failure::Engine(_))) {
pele = None;
}
answers.push(drawn);
}
let _ = job.reply.send(answers);
}
}
struct Pele {
context: Context,
_runtime: Runtime,
limits: Limits,
deadline: Rc<Cell<Option<Instant>>>,
interrupted: Rc<Cell<bool>>,
}
impl Pele {
fn load(limits: Limits) -> Result<Pele, Failure> {
let fail = |what: &str, error: rquickjs::Error| {
Failure::Engine(format!("the diagram engine couldn't {what}: {error}"))
};
let runtime = Runtime::new().map_err(|e| fail("start", e))?;
runtime.set_memory_limit(limits.memory);
runtime.set_max_stack_size(4 << 20);
let deadline: Rc<Cell<Option<Instant>>> = Rc::default();
let interrupted: Rc<Cell<bool>> = Rc::default();
{
let (deadline, interrupted) = (deadline.clone(), interrupted.clone());
runtime.set_interrupt_handler(Some(Box::new(move || {
let stop = deadline.get().is_some_and(|at| Instant::now() >= at);
if stop {
interrupted.set(true);
}
stop
})));
}
let context = Context::full(&runtime).map_err(|e| fail("start", e))?;
let pele = Pele {
context,
_runtime: runtime,
limits,
deadline,
interrupted,
};
pele.run(None, |ctx| {
ctx.eval::<(), _>(PRELUDE)?;
ctx.eval::<(), _>(PELE)?;
ctx.eval::<(), _>(DRAW)
})
.map_err(|failure| match failure {
Failure::Pele(why) => Failure::Engine(format!("pele didn't load: {why}")),
engine => engine,
})?;
Ok(pele)
}
fn draw(&self, source: &str) -> Result<String, Failure> {
self.run(Some(self.limits.time), |ctx| {
let draw: Function = ctx.globals().get("__devpageDraw")?;
draw.call((source,))
})
}
fn run<T>(
&self,
time: Option<Duration>,
f: impl FnOnce(&Ctx<'_>) -> rquickjs::Result<T>,
) -> Result<T, Failure> {
self.interrupted.set(false);
self.deadline.set(time.map(|time| Instant::now() + time));
let result = self.context.with(|ctx| {
f(&ctx).map_err(|error| match error {
rquickjs::Error::Exception => {
let thrown = ctx.catch();
if let Some(exception) = thrown.as_exception() {
Some(exception.message().unwrap_or_default())
} else if let Some(text) = thrown.as_string() {
text.to_string().ok()
} else if thrown.is_null() || thrown.is_undefined() {
None
} else {
Some(format!("{thrown:?}"))
}
}
rquickjs::Error::Allocation => None,
other => Some(other.to_string()),
})
});
self.deadline.set(None);
result.map_err(|why| match why {
_ if self.interrupted.get() => Failure::Engine(format!(
"took longer than {} to draw",
seconds(self.limits.time)
)),
None => Failure::Engine("ran out of memory".into()),
Some(why) if why == "out of memory" => Failure::Engine("ran out of memory".into()),
Some(why) => Failure::Pele(why),
})
}
}
fn seconds(time: Duration) -> String {
match time.as_secs() {
1 if time.subsec_nanos() == 0 => "1 second".into(),
n if time.subsec_nanos() == 0 => format!("{n} seconds"),
_ => format!("{} ms", time.as_millis()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::Ordering;
fn engine(time: Duration, memory: usize) -> Engine {
Engine::start(Limits { time, memory })
}
fn one(engine: &Engine, source: &str) -> Drawn {
engine.draw_all(&[source.to_string()]).remove(0)
}
fn chain(n: usize) -> String {
let mut source = String::from("flowchart TD\n");
for i in 0..n {
source.push_str(&format!(" n{i}[Node {i}] --> n{}\n", i + 1));
}
source
}
#[test]
fn a_diagram_is_drawn_to_svg_with_its_links() {
let engine = Engine::start(Limits::SERVER);
let svg = one(
&engine,
"flowchart LR\n a[A] --> b\n click a href \"./x.md\" \"Ex\"\n click b href \"https://Example.COM/x y\"\n",
)
.unwrap();
assert!(
svg.starts_with(
"<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"pele pele-flowchart\""
),
"{svg}"
);
assert!(
svg.contains("<a href=\"./x.md\" rel=\"noopener\"><title>Ex</title>"),
"{svg}"
);
assert!(
svg.contains("<a href=\"https://example.com/x%20y\" rel=\"noopener\">"),
"{svg}"
);
assert!(svg.contains("var(--pele-fg,"), "{svg}");
}
#[test]
fn pele_s_error_is_the_diagram_s_and_the_engine_goes_on() {
let engine = Engine::start(Limits::SERVER);
let error = one(&engine, "sequenceDiagram\n A->>B: one; two\n").unwrap_err();
assert!(error.starts_with("Parse error on line 2:"), "{error}");
assert_eq!(
one(&engine, "nonsense").unwrap_err(),
"No diagram type detected for the given text."
);
assert!(one(&engine, "flowchart LR\n a --> b\n").is_ok());
}
#[test]
fn a_diagram_drawn_before_comes_from_the_cache() {
let engine = Engine::start(Limits::SERVER);
let good = "flowchart LR\n a --> b\n".to_string();
let bad = "nonsense".to_string();
let first = engine.draw_all(&[good.clone(), bad.clone()]);
assert_eq!(engine.sent.load(Ordering::Relaxed), 2);
let again = engine.draw_all(&[bad.clone(), good.clone(), "pie\n \"x\": 1\n".into()]);
assert_eq!(engine.sent.load(Ordering::Relaxed), 3);
assert_eq!(again[0], first[1]);
assert!(Arc::ptr_eq(
again[1].as_ref().unwrap(),
first[0].as_ref().unwrap()
));
}
#[test]
fn a_diagram_past_the_time_limit_fails_and_is_tried_again() {
let engine = engine(Duration::from_millis(1), 128 << 20);
let big = chain(500);
assert_eq!(
one(&engine, &big).unwrap_err(),
"took longer than 1 ms to draw"
);
assert_eq!(
one(&engine, &big).unwrap_err(),
"took longer than 1 ms to draw"
);
assert_eq!(engine.sent.load(Ordering::Relaxed), 2);
}
#[test]
fn a_diagram_past_the_memory_limit_fails_and_the_next_gets_a_fresh_runtime() {
let engine = engine(Duration::from_secs(10), 10 << 20);
assert_eq!(one(&engine, &chain(1600)).unwrap_err(), "ran out of memory");
assert!(one(&engine, "flowchart LR\n a --> b\n").is_ok());
}
#[test]
fn fences_become_drawings_or_error_blocks() {
let engine = Engine::start(Limits::SERVER);
let html = "<p>x</p>\n<pre lang=\"mermaid\"><code>flowchart LR\n a["A & B"] --> b\n</code></pre>\n<pre lang=\"mermaid\"><code>nope <b>\n</code></pre>\n<pre><code>plain</code></pre>\n";
let drawn = draw_fences_with(&engine, html);
assert!(
drawn.starts_with("<p>x</p>\n<div class=\"diagram\"><svg"),
"{drawn}"
);
assert!(drawn.contains(">A & B</text>"), "{drawn}");
assert!(
drawn.contains("</svg></div>\n<pre class=\"diagram-error\">Diagram error: No diagram type detected for the given text.\n\nnope <b>\n</pre>\n<pre><code>plain</code></pre>\n"),
"{drawn}"
);
}
}