From 5a1eb6e84e8188d98027f6e9792704935bcd09a5 Mon Sep 17 00:00:00 2001 From: cameron Date: Wed, 3 Jun 2026 11:16:22 +0100 Subject: [PATCH 1/5] bump to zed fork with cyclic diagram fix --- Cargo.lock | 21 +++++++-------------- crates/mermaid_render/Cargo.toml | 2 +- crates/mermaid_render/src/mermaid_render.rs | 21 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index beb400cd0934f4..41d5aba41d6d10 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5381,8 +5381,7 @@ dependencies = [ [[package]] name = "dugong" version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a6170733f3de44de3cf86a6b2ce4a6adfa2d0fcc546d0298896c36b4675c783" +source = "git+https://github.com/zed-industries/merman?tag=v0.6.2-with-patches#06094471f97acb10d0eebf8b92bac19ba2928eea" dependencies = [ "dugong-graphlib", "rustc-hash 2.1.1", @@ -5393,8 +5392,7 @@ dependencies = [ [[package]] name = "dugong-graphlib" version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5620a3975d89a161bbb99c2e6cf5fbfb692de86499cc1bea3e570db2e7637394" +source = "git+https://github.com/zed-industries/merman?tag=v0.6.2-with-patches#06094471f97acb10d0eebf8b92bac19ba2928eea" dependencies = [ "hashbrown 0.16.1", "rustc-hash 2.1.1", @@ -10556,8 +10554,7 @@ dependencies = [ [[package]] name = "manatee" version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6edb0293fd8cd549e79426a441354ee13e7b9b2136d2aa30ccba99861b229f14" +source = "git+https://github.com/zed-industries/merman?tag=v0.6.2-with-patches#06094471f97acb10d0eebf8b92bac19ba2928eea" dependencies = [ "indexmap 2.11.4", "nalgebra", @@ -10852,8 +10849,7 @@ dependencies = [ [[package]] name = "merman" version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7f9f0f65e455090527adc4bcc6b7c917fa2fb3d29c7d8f8fe180ae8a1295d76" +source = "git+https://github.com/zed-industries/merman?tag=v0.6.2-with-patches#06094471f97acb10d0eebf8b92bac19ba2928eea" dependencies = [ "merman-core", "merman-render", @@ -10863,8 +10859,7 @@ dependencies = [ [[package]] name = "merman-core" version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7975b707ca62dc6e0585e89c6e45c4d788b277d13c2501197d00d9b9c52350bc" +source = "git+https://github.com/zed-industries/merman?tag=v0.6.2-with-patches#06094471f97acb10d0eebf8b92bac19ba2928eea" dependencies = [ "chrono", "euclid", @@ -10890,8 +10885,7 @@ dependencies = [ [[package]] name = "merman-render" version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e155e441addf61cbdd00c1a6e4e59a199aab6401110b46ae621b035673455c2b" +source = "git+https://github.com/zed-industries/merman?tag=v0.6.2-with-patches#06094471f97acb10d0eebf8b92bac19ba2928eea" dependencies = [ "base64 0.22.1", "chrono", @@ -15373,8 +15367,7 @@ dependencies = [ [[package]] name = "roughr-merman" version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c34fd013a888d8b2119b3ed57cd3df0f1f9dd2db3fa94fdb98f58d60f9855ef0" +source = "git+https://github.com/zed-industries/merman?tag=v0.6.2-with-patches#06094471f97acb10d0eebf8b92bac19ba2928eea" dependencies = [ "derive_builder", "euclid", diff --git a/crates/mermaid_render/Cargo.toml b/crates/mermaid_render/Cargo.toml index a64be8bc2eb3fb..bf826ab6334e5e 100644 --- a/crates/mermaid_render/Cargo.toml +++ b/crates/mermaid_render/Cargo.toml @@ -18,7 +18,7 @@ test-support = [] [dependencies] anyhow.workspace = true gpui.workspace = true -merman = { version = "0.6.2", features = ["render"] } +merman = { git = "https://github.com/zed-industries/merman", tag = "v0.6.2-with-patches", features = ["render"] } quick-xml.workspace = true serde_json.workspace = true diff --git a/crates/mermaid_render/src/mermaid_render.rs b/crates/mermaid_render/src/mermaid_render.rs index eaf752ceff3714..fdd3e80faaafd5 100644 --- a/crates/mermaid_render/src/mermaid_render.rs +++ b/crates/mermaid_render/src/mermaid_render.rs @@ -180,3 +180,24 @@ pub fn render_to_svg(source: &str, theme: &MermaidTheme) -> Result { let svg = postprocess::postprocess(&svg, theme)?; Ok(svg) } + +#[cfg(test)] +mod tests { + use super::*; + + /// A flowchart with mutually nested subgraphs (`A` contains `B` and `B` + /// contains `A`) is an invalid containment cycle. Rendering it must return + /// gracefully rather than overflowing the stack and aborting the process. + #[test] + fn cyclic_subgraphs_do_not_crash() { + let source = "flowchart TD\n subgraph A\n B\n end\n subgraph B\n A\n end"; + let result = render_to_svg(source, &MermaidTheme::default()); + if let Err(err) = result { + let message = format!("{err:#}"); + assert!( + message.contains("cycle"), + "expected a cycle-related error, got: {message}" + ); + } + } +} From a2f36b952276558bc6a01afe5c9de4785e34e8c6 Mon Sep 17 00:00:00 2001 From: cameron Date: Wed, 3 Jun 2026 11:54:55 +0100 Subject: [PATCH 2/5] review --- .../src/postprocess/accent_colors.rs | 36 ++++++++++--------- .../src/postprocess/accent_colors/mindmap.rs | 5 ++- .../src/postprocess/element_fixup.rs | 3 +- .../src/postprocess/inject_css.rs | 20 +++-------- crates/mermaid_render/src/render.rs | 13 ++++--- 5 files changed, 35 insertions(+), 42 deletions(-) diff --git a/crates/mermaid_render/src/postprocess/accent_colors.rs b/crates/mermaid_render/src/postprocess/accent_colors.rs index 795abd2fdb3505..544cdf2464f3b8 100644 --- a/crates/mermaid_render/src/postprocess/accent_colors.rs +++ b/crates/mermaid_render/src/postprocess/accent_colors.rs @@ -69,15 +69,14 @@ pub(crate) fn parse_path_half_height(e: &BytesStart<'_>) -> Option { let attr = e.try_get_attribute("d").ok()??; let d = attr.unescape_value().ok()?; let rest = d.strip_prefix('M')?.trim_start(); - let mut chars = rest.chars().peekable(); - while chars.peek().is_some_and(|c| *c != ' ' && *c != ',') { - chars.next(); - } - while chars.peek().is_some_and(|c| *c == ' ' || *c == ',') { - chars.next(); - } - let y_str: String = chars.take_while(|c| *c != ' ' && *c != ',').collect(); - let y: f64 = y_str.parse().ok()?; + // The path data starts with `M x,y ...`; the y coordinate is the second + // whitespace/comma-separated token. + let y: f64 = rest + .split([' ', ',']) + .filter(|token| !token.is_empty()) + .nth(1)? + .parse() + .ok()?; Some(y.abs()) } @@ -255,9 +254,9 @@ enum Handler { Sequence(sequence_diagram::SequenceDiagramAccents), } -struct AccentColors { +struct AccentColors<'theme, I> { inner: I, - theme: MermaidTheme, + theme: &'theme MermaidTheme, handler: Handler, in_legend: bool, legend_color_idx: usize, @@ -268,11 +267,14 @@ struct AccentColors { quadrant_point_idx: usize, } -impl<'a, I: Iterator>>> AccentColors { +impl<'a, 'theme, I: Iterator>>> AccentColors<'theme, I> { fn process_chart_colors(&mut self, event: Event<'a>) -> Result> { match &event { Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"g" => { - if self.in_plot { + // Only a real opening tag increases nesting depth. Self-closing `` + // elements have no matching ``, so counting them would leave + // `plot_depth` permanently inflated and `in_plot` stuck on. + if self.in_plot && matches!(event, Event::Start(_)) { self.plot_depth += 1; } if let Some(class_attr) = e.try_get_attribute("class")? { @@ -344,7 +346,7 @@ impl<'a, I: Iterator>>> AccentColors { } } -impl<'a, I: Iterator>>> Iterator for AccentColors { +impl<'a, 'theme, I: Iterator>>> Iterator for AccentColors<'theme, I> { type Item = Result>; fn next(&mut self) -> Option { @@ -394,13 +396,13 @@ impl<'a, I: Iterator>>> Iterator for AccentColors { } } -pub(super) fn process<'a>( +pub(super) fn process<'a, 'theme>( events: impl Iterator>>, - theme: &MermaidTheme, + theme: &'theme MermaidTheme, ) -> impl Iterator>> { AccentColors { inner: events, - theme: theme.clone(), + theme, handler: Handler::Pending, in_legend: false, legend_color_idx: 0, diff --git a/crates/mermaid_render/src/postprocess/accent_colors/mindmap.rs b/crates/mermaid_render/src/postprocess/accent_colors/mindmap.rs index d7cf6901a2f7f4..f77329c6895c0f 100644 --- a/crates/mermaid_render/src/postprocess/accent_colors/mindmap.rs +++ b/crates/mermaid_render/src/postprocess/accent_colors/mindmap.rs @@ -106,10 +106,9 @@ impl MindmapAccents { None => return Ok(None), }; let class = class_attr.unescape_value()?; - let tokens: Vec<&str> = class.split_whitespace().collect(); - let is_root = tokens.contains(&"section-root"); + let is_root = class.split_whitespace().any(|t| t == "section-root"); - for token in &tokens { + for token in class.split_whitespace() { if let Some(rest) = token.strip_prefix("section-") { if rest == "-1" || rest.parse::().is_ok() { let class_name = if is_root { diff --git a/crates/mermaid_render/src/postprocess/element_fixup.rs b/crates/mermaid_render/src/postprocess/element_fixup.rs index 2f93c45b0b4c12..fabdf21e8c726e 100644 --- a/crates/mermaid_render/src/postprocess/element_fixup.rs +++ b/crates/mermaid_render/src/postprocess/element_fixup.rs @@ -120,10 +120,11 @@ fn rewrite_background_style<'a>(style: &'a str, background_css: &str) -> Cow<'a, return Cow::Borrowed(style); } + let value_len = value_end.saturating_sub(value_start); let mut rewritten = String::with_capacity( style .len() - .saturating_sub(value_end - value_start) + .saturating_sub(value_len) .saturating_add(background_css.len()), ); rewritten.push_str(&style[..value_start]); diff --git a/crates/mermaid_render/src/postprocess/inject_css.rs b/crates/mermaid_render/src/postprocess/inject_css.rs index 830459e942c923..0181592aeb73f5 100644 --- a/crates/mermaid_render/src/postprocess/inject_css.rs +++ b/crates/mermaid_render/src/postprocess/inject_css.rs @@ -108,22 +108,10 @@ pub(super) fn process<'a>( } fn mindmap_section_css(theme: &MermaidTheme) -> String { - let colors: Vec = theme - .git_branch_colors - .iter() - .map(|c| crate::css_color(*c)) - .collect(); - let fills: Vec = theme - .git_branch_colors - .iter() - .map(|c| { - crate::css_color(blend_over_background( - *c, - theme.background, - ACCENT_FILL_OPACITY, - )) - }) - .collect(); + let colors: [String; 8] = theme.git_branch_colors.map(crate::css_color); + let fills: [String; 8] = theme.git_branch_colors.map(|c| { + crate::css_color(blend_over_background(c, theme.background, ACCENT_FILL_OPACITY)) + }); let text = crate::css_color(theme.text_color); let mut css = String::with_capacity(5_400); diff --git a/crates/mermaid_render/src/render.rs b/crates/mermaid_render/src/render.rs index 086f4efa62d2ab..37864062bb5c12 100644 --- a/crates/mermaid_render/src/render.rs +++ b/crates/mermaid_render/src/render.rs @@ -110,11 +110,14 @@ fn to_merman_config(theme: &MermaidTheme) -> merman::MermaidConfig { "quadrantInternalBorderStrokeFill": primary_border, }); - let map = theme_vars.as_object_mut().expect("just created as object"); - for i in 0..8 { - map.insert(format!("cScale{i}"), git[i].clone().into()); - map.insert(format!("cScaleLabel{i}"), git_lbl[i].clone().into()); - map.insert(format!("pie{}", i + 1), git[i].clone().into()); + if let Some(map) = theme_vars.as_object_mut() { + for (((i, color), label), pie_number) in + git.iter().enumerate().zip(&git_lbl).zip(1..) + { + map.insert(format!("cScale{i}"), color.clone().into()); + map.insert(format!("cScaleLabel{i}"), label.clone().into()); + map.insert(format!("pie{pie_number}"), color.clone().into()); + } } merman::MermaidConfig::from_value(serde_json::json!({ From 1c8ff19e2b5fc25baf935da15bd3b16c9f357bb7 Mon Sep 17 00:00:00 2001 From: cameron Date: Wed, 3 Jun 2026 13:46:38 +0100 Subject: [PATCH 3/5] small perf win --- .../src/postprocess/strip_foreignobject.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/mermaid_render/src/postprocess/strip_foreignobject.rs b/crates/mermaid_render/src/postprocess/strip_foreignobject.rs index fb1ce644fc5b85..fa1bd5c53ccd11 100644 --- a/crates/mermaid_render/src/postprocess/strip_foreignobject.rs +++ b/crates/mermaid_render/src/postprocess/strip_foreignobject.rs @@ -155,7 +155,7 @@ impl<'a, I> StripForeignObject<'a, I> { fn buffer_fallback_event(&mut self, event: Event<'a>) { match &event { Event::Start(_) => self.fallback_depth += 1, - Event::End(_) => self.fallback_depth -= 1, + Event::End(_) => self.fallback_depth = self.fallback_depth.saturating_sub(1), Event::Text(t) => { if let Ok(decoded) = t.decode() { self.buffered_text.push_str(&decoded); @@ -188,10 +188,16 @@ pub(super) fn process<'a>( inner: impl Iterator>>, svg: &str, ) -> impl Iterator>> { + // if there's no foreignobjects, + let native_text_contents = if svg.contains("data-merman-foreignobject=\"fallback\"") { + collect_native_text_contents(svg) + } else { + HashSet::new() + }; StripForeignObject { inner, foreign_depth: 0, - native_text_contents: collect_native_text_contents(svg), + native_text_contents, buffer: Vec::new(), fallback_depth: 0, buffered_text: String::new(), From c9f3bd7c00b5ffc0234760bc36e69dde4d28d735 Mon Sep 17 00:00:00 2001 From: cameron Date: Wed, 3 Jun 2026 13:48:32 +0100 Subject: [PATCH 4/5] more correctness fixes --- .../mermaid_render/src/postprocess/accent_colors.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/mermaid_render/src/postprocess/accent_colors.rs b/crates/mermaid_render/src/postprocess/accent_colors.rs index 544cdf2464f3b8..2145f447c9ba39 100644 --- a/crates/mermaid_render/src/postprocess/accent_colors.rs +++ b/crates/mermaid_render/src/postprocess/accent_colors.rs @@ -271,18 +271,21 @@ impl<'a, 'theme, I: Iterator>>> AccentColors<'theme, I> fn process_chart_colors(&mut self, event: Event<'a>) -> Result> { match &event { Event::Start(e) | Event::Empty(e) if e.name().as_ref() == b"g" => { + let is_start = matches!(event, Event::Start(_)); // Only a real opening tag increases nesting depth. Self-closing `` // elements have no matching ``, so counting them would leave // `plot_depth` permanently inflated and `in_plot` stuck on. - if self.in_plot && matches!(event, Event::Start(_)) { + if self.in_plot && is_start { self.plot_depth += 1; } if let Some(class_attr) = e.try_get_attribute("class")? { let class = class_attr.unescape_value()?; if class.as_ref() == "plot" { - self.in_plot = true; - self.plot_depth = 1; - self.plot_path_done = false; + if is_start && !self.in_plot { + self.in_plot = true; + self.plot_depth = 1; + self.plot_path_done = false; + } } else if class.as_ref() == "legend" { self.in_legend = true; } else if class.as_ref() == "data-point" { @@ -299,7 +302,7 @@ impl<'a, 'theme, I: Iterator>>> AccentColors<'theme, I> Event::End(e) if e.name().as_ref() == b"g" => { if self.in_plot { - self.plot_depth -= 1; + self.plot_depth = self.plot_depth.saturating_sub(1); if self.plot_depth == 0 { self.in_plot = false; } From 64746451f6408d289f9b50ed0d39a10077e130c0 Mon Sep 17 00:00:00 2001 From: cameron Date: Wed, 3 Jun 2026 13:53:43 +0100 Subject: [PATCH 5/5] fmt --- crates/mermaid_render/src/postprocess/inject_css.rs | 6 +++++- .../mermaid_render/src/postprocess/strip_foreignobject.rs | 2 +- crates/mermaid_render/src/render.rs | 4 +--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/mermaid_render/src/postprocess/inject_css.rs b/crates/mermaid_render/src/postprocess/inject_css.rs index 0181592aeb73f5..6a34c6d1b8f655 100644 --- a/crates/mermaid_render/src/postprocess/inject_css.rs +++ b/crates/mermaid_render/src/postprocess/inject_css.rs @@ -110,7 +110,11 @@ pub(super) fn process<'a>( fn mindmap_section_css(theme: &MermaidTheme) -> String { let colors: [String; 8] = theme.git_branch_colors.map(crate::css_color); let fills: [String; 8] = theme.git_branch_colors.map(|c| { - crate::css_color(blend_over_background(c, theme.background, ACCENT_FILL_OPACITY)) + crate::css_color(blend_over_background( + c, + theme.background, + ACCENT_FILL_OPACITY, + )) }); let text = crate::css_color(theme.text_color); let mut css = String::with_capacity(5_400); diff --git a/crates/mermaid_render/src/postprocess/strip_foreignobject.rs b/crates/mermaid_render/src/postprocess/strip_foreignobject.rs index fa1bd5c53ccd11..1163c0a621f191 100644 --- a/crates/mermaid_render/src/postprocess/strip_foreignobject.rs +++ b/crates/mermaid_render/src/postprocess/strip_foreignobject.rs @@ -188,7 +188,7 @@ pub(super) fn process<'a>( inner: impl Iterator>>, svg: &str, ) -> impl Iterator>> { - // if there's no foreignobjects, + // if there's no foreignobjects, let native_text_contents = if svg.contains("data-merman-foreignobject=\"fallback\"") { collect_native_text_contents(svg) } else { diff --git a/crates/mermaid_render/src/render.rs b/crates/mermaid_render/src/render.rs index 37864062bb5c12..b32a36cbfaf55a 100644 --- a/crates/mermaid_render/src/render.rs +++ b/crates/mermaid_render/src/render.rs @@ -111,9 +111,7 @@ fn to_merman_config(theme: &MermaidTheme) -> merman::MermaidConfig { }); if let Some(map) = theme_vars.as_object_mut() { - for (((i, color), label), pie_number) in - git.iter().enumerate().zip(&git_lbl).zip(1..) - { + for (((i, color), label), pie_number) in git.iter().enumerate().zip(&git_lbl).zip(1..) { map.insert(format!("cScale{i}"), color.clone().into()); map.insert(format!("cScaleLabel{i}"), label.clone().into()); map.insert(format!("pie{pie_number}"), color.clone().into());