use std::collections::{HashMap, HashSet, VecDeque};
use colored::Colorize;
use error_stack::{IntoReport, Report, ResultExt};
use indicatif::{ProgressBar, ProgressStyle};
use crate::batbelt::evm::metadata::bat_metadata::{
AutoDeployedFrame, ContractMetadata, EvmBatMetadata, FunctionMetadata, ShelfState,
};
use crate::batbelt::evm::miro::EvmMiroError;
use crate::batbelt::evm::parser::call_resolver::{body_only, extract_call_sites_from_source};
use crate::batbelt::evm::types::EvmContractType;
use crate::batbelt::miro::client::{ArrowEnd, ConnectorStyle, MiroClient, RelativeAnchor};
use crate::batbelt::miro::layout::{
layout_graph, GraphLayout, LayoutConfig, LayoutEdge, LayoutNode, ShelfAllocator,
};
use crate::batbelt::bat_dialoguer::BatDialoguer;
use crate::batbelt::path::BatFolder;
use crate::batbelt::silicon;
use rayon::prelude::*;
type Result<T> = error_stack::Result<T, EvmMiroError>;
const ANCHOR_GAP_CHARS: f64 = 1.0;
const SIGNATURE_LINE_INDEX: usize = 2;
const PATH_HEADER_LINES: usize = 2;
const REGION_MARGIN: f64 = 5_000.0;
const MAX_CUT_PASSES: usize = 5;
const ANCHOR_MARKER_SIZE: f64 = 24.0;
fn phase_bar(label: &str, total: usize) -> ProgressBar {
let bar = ProgressBar::new(total as u64);
bar.set_style(
ProgressStyle::with_template(" {spinner:.blue} {msg} {pos}/{len} {wide_bar:.blue}")
.unwrap()
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ "),
);
bar.set_message(label.to_string());
bar.enable_steady_tick(std::time::Duration::from_millis(100));
bar
}
const DEPTH_COLORS: &[&str] = &[
"#2d9bf0", "#f24726", "#8fd14f", "#fac710", "#a259ff", "#12cdd4", "#ff8c00", "#e6007a",
];
#[derive(Debug, Clone)]
pub struct AutoDeployOptions {
pub entry_point: Option<String>,
pub all: bool,
pub max_depth: Option<usize>,
pub max_nodes: Option<usize>,
pub dry_run: bool,
pub include_external: bool,
pub preview: Option<String>,
pub stroke_width: u32,
pub assume_yes: bool,
pub allow_unresolved: bool,
}
impl Default for AutoDeployOptions {
fn default() -> Self {
Self {
entry_point: None,
all: false,
max_depth: None,
max_nodes: None,
dry_run: false,
include_external: false,
preview: None,
stroke_width: 8,
assume_yes: false,
allow_unresolved: false,
}
}
}
#[derive(Debug, Clone, PartialEq)]
enum NodeKind {
Screenshot,
Link { target: String },
}
const LINK_CARD_WIDTH: f64 = 900.0;
const LINK_CARD_HEIGHT: f64 = 240.0;
#[derive(Debug, Clone)]
struct GraphNode {
id: String,
label: String,
kind: NodeKind,
file_path: String,
start_line: usize,
end_line: usize,
depth: usize,
font_size: usize,
png_path: String,
png_width: u32,
png_height: u32,
rendered_lines: Vec<String>,
line_offset: usize,
writes_storage: bool,
write_lines: Vec<(usize, String)>,
external_call_lines: Vec<usize>,
}
impl GraphNode {
fn board_width(&self) -> f64 {
self.png_width as f64 * BOARD_UNITS_PER_PIXEL
}
fn board_height(&self) -> f64 {
self.png_height as f64 * BOARD_UNITS_PER_PIXEL
}
}
#[derive(Debug, Clone)]
struct GraphEdge {
from: String,
to: String,
line_in_slice: usize,
column: usize,
symbol: String,
}
const BOARD_UNITS_PER_PIXEL: f64 = 1.0;
fn font_for_depth(depth: usize) -> usize {
match depth {
0 => 32,
1 => 26,
_ => 22,
}
}
pub async fn run(options: AutoDeployOptions) -> Result<()> {
let metadata = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
let targets = select_targets(&metadata, &options)?;
if targets.is_empty() {
return Err(Report::new(EvmMiroError)
.attach_printable("no entry point matched; run `bat-cli sonar` first"));
}
if options.all && !options.dry_run && targets.len() > 1 {
println!(
"{} deploying {} entry points at once puts thousands of objects on the\nboard, which Miro starts to slow down past a thousand. Reviewing happens one\nentry point at a time, so consider deploying on demand instead.",
"warning:".yellow(),
targets.len()
);
if !BatDialoguer::select_yes_or_no("Deploy all of them anyway?".to_string())
.change_context(EvmMiroError)?
{
return Ok(());
}
}
println!(
"Auto-deploying {} entry point(s){}",
targets.len().to_string().green(),
if options.dry_run {
" (dry run, nothing is sent to Miro)".yellow().to_string()
} else {
String::new()
}
);
let client = if options.dry_run {
None
} else {
Some(
MiroClient::new_refreshed()
.await
.change_context(EvmMiroError)?,
)
};
let mut allocator = if options.dry_run {
ShelfAllocator::new(0.0, 0.0)
} else {
resolve_allocator(client.as_ref().unwrap(), &metadata).await?
};
for (contract_name, function_name) in targets {
deploy_one(
&metadata,
&contract_name,
&function_name,
&options,
client.as_ref(),
&mut allocator,
true,
)
.await?;
if !options.dry_run {
let state = ShelfState::from(&allocator);
EvmBatMetadata::update_metadata(|m| m.miro.auto.region = Some(state.clone()))
.change_context(EvmMiroError)?;
}
}
Ok(())
}
fn select_targets(
metadata: &EvmBatMetadata,
options: &AutoDeployOptions,
) -> Result<Vec<(String, String)>> {
let mut entry_points: Vec<(String, String)> = metadata
.entry_points
.iter()
.map(|ep| {
let function = ep
.name
.strip_prefix(&format!("{}.", ep.contract_name))
.unwrap_or(&ep.name)
.to_string();
(ep.contract_name.clone(), function)
})
.collect();
entry_points.sort();
entry_points.dedup();
if options.all {
return Ok(entry_points);
}
let is_entry_point: HashSet<(String, String)> = entry_points.iter().cloned().collect();
let mut others: Vec<(String, String)> = metadata
.contracts
.iter()
.filter(|contract| !contract.external)
.flat_map(|contract| {
contract
.functions
.iter()
.filter(|function| !function.is_constructor)
.map(|function| (contract.name.clone(), function.name.clone()))
})
.filter(|target| !is_entry_point.contains(target))
.collect();
others.sort();
others.dedup();
if let Some(wanted) = &options.entry_point {
return Ok(entry_points
.into_iter()
.chain(others)
.filter(|(contract, function)| {
*function == *wanted || format!("{contract}.{function}") == *wanted
})
.take(1)
.collect());
}
let deployed: HashSet<String> = metadata
.miro
.auto
.frames
.iter()
.map(|frame| frame.entry_point.clone())
.collect();
let all: Vec<(String, String)> = entry_points
.iter()
.cloned()
.chain(others.iter().cloned())
.collect();
if all.is_empty() {
return Ok(all);
}
let entry_point_count = entry_points.len();
let labels: Vec<String> = all
.iter()
.enumerate()
.map(|(index, (contract, function))| {
let title = format!("{contract}.{function}");
let mut label = if index < entry_point_count {
format!("{title} {}", "[entry point]".blue())
} else {
title.clone()
};
if deployed.contains(&title) {
label = format!("{label} {}", "(deployed)".green());
}
label
})
.collect();
let selection =
BatDialoguer::fuzzy_select("Select what to deploy:".to_string(), labels)
.change_context(EvmMiroError)?;
Ok(vec![all[selection].clone()])
}
async fn resolve_allocator(
client: &MiroClient,
metadata: &EvmBatMetadata,
) -> Result<ShelfAllocator> {
if let Some(state) = &metadata.miro.auto.region {
return Ok(state.to_allocator());
}
println!("Scanning the board once to reserve a region for automatic frames...");
let frames = client.list_frames().await.change_context(EvmMiroError)?;
let (origin_x, origin_y) = if frames.is_empty() {
(0.0, 0.0)
} else {
let bottom = frames.iter().map(|f| f.bottom()).fold(f64::MIN, f64::max);
let left = frames.iter().map(|f| f.left()).fold(f64::MAX, f64::min);
(left, bottom + REGION_MARGIN)
};
println!(
" region origin: ({}, {}) — below {} existing frame(s)",
origin_x.round(),
origin_y.round(),
frames.len()
);
Ok(ShelfAllocator::new(origin_x, origin_y))
}
async fn deploy_one(
metadata: &EvmBatMetadata,
contract_name: &str,
function_name: &str,
options: &AutoDeployOptions,
client: Option<&MiroClient>,
allocator: &mut ShelfAllocator,
is_primary: bool,
) -> Result<()> {
let title = format!("{contract_name}.{function_name}");
println!("\n{} {}", "▸".blue(), title.bold());
let mut reused_frame: Option<AutoDeployedFrame> = None;
if !options.dry_run {
if let Some(url) = live_frame_url(&title, client).await? {
if !is_primary {
return Ok(());
}
if let Some(client) = client {
println!(" {} recycling the existing frame", "↻".yellow());
reused_frame = recycle_recorded_frame(&title, client).await?;
} else {
let _ = url;
}
}
}
let (mut nodes, mut edges, truncated, unresolved) =
build_graph(metadata, contract_name, function_name, options)?;
if nodes.is_empty() {
println!(" no function metadata found, skipping");
return Ok(());
}
let deployed_titles: HashSet<String> = {
let meta = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
meta.miro
.auto
.frames
.iter()
.map(|frame| frame.entry_point.clone())
.filter(|entry_point| *entry_point != title)
.collect()
};
if !deployed_titles.is_empty() {
let root_id = nodes[0].id.clone();
let mut linked = 0usize;
while let Some(index) = edges.iter().position(|edge| {
edge.to != root_id
&& nodes.iter().any(|node| {
node.id == edge.to
&& node.kind == NodeKind::Screenshot
&& deployed_titles.contains(&node.label)
})
}) {
cut_edge(&mut nodes, &mut edges, index);
linked += 1;
}
if linked > 0 {
println!(
" {} linked {} call(s) to already-deployed frames",
"↻".yellow(),
linked
);
}
}
if !unresolved.is_empty() && !options.allow_unresolved {
println!(
"\n {} {} interface call(s) in this tree are unresolved — their downstream\n functions (and any storage changes) are NOT in the graph yet:",
"⚠".yellow(),
unresolved.len()
);
for u in &unresolved {
let ty = if u.inferred_type.is_empty() {
String::new()
} else {
format!(" [{}]", u.inferred_type)
};
println!(
" {}.{}{} → candidates: {}",
u.receiver,
u.method,
ty,
if u.candidates.is_empty() {
"(none in scope)".to_string()
} else {
u.candidates.join(", ")
}
);
if !u.assigned_in.is_empty() {
println!(" wired in: {}", u.assigned_in.join(", ").dimmed());
}
}
println!(
"\n Resolve each interface to its concrete contract, then deploy again:\n {}\n (or pass {} to draw the partial graph as-is.)",
"bat-cli resolve <INTERFACE> <CONTRACT>".green(),
"--allow-unresolved".green()
);
return Err(Report::new(EvmMiroError).attach_printable(format!(
"{} unresolved interface call(s) in {}.{} — see the list above",
unresolved.len(),
contract_name,
function_name
)));
}
let depth = nodes.iter().map(|node| node.depth).max().unwrap_or(0);
println!(
" {} screenshots, {} connectors, {} levels deep",
nodes.len().to_string().green(),
edges.len().to_string().green(),
(depth + 1).to_string().green()
);
if truncated > 0 {
println!(
" {} {} call site(s) left out by --max-nodes {}",
"note:".yellow(),
truncated,
options.max_nodes.unwrap_or_default()
);
}
render_and_measure(&mut nodes, &title)?;
let layout_nodes: Vec<LayoutNode> = nodes
.iter()
.map(|node| LayoutNode {
id: node.id.clone(),
width: node.board_width(),
height: node.board_height(),
})
.collect();
let anchors = compute_anchors(&nodes, &edges);
let layout_edges: Vec<LayoutEdge> = edges
.iter()
.zip(anchors.iter())
.map(|(edge, anchor)| LayoutEdge {
from: edge.from.clone(),
to: edge.to.clone(),
from_line_fraction: anchor.y_fraction,
})
.collect();
let root_id = nodes[0].id.clone();
let mut layout = layout_graph(&root_id, &layout_nodes, &layout_edges, LayoutConfig::default());
let mut anchors = anchors;
for _ in 0..MAX_CUT_PASSES {
if screenshot_count(&nodes) <= READABLE_SCREENSHOTS {
break;
}
let Some((cut_nodes, cut_edges)) = best_cut(&nodes, &edges) else {
break;
};
println!(
" {} {} screenshots is more than reads well; linking a branch out to\n its own frame instead",
"note:".yellow(),
screenshot_count(&nodes)
);
nodes = cut_nodes;
edges = cut_edges;
anchors = compute_anchors(&nodes, &edges);
let layout_nodes: Vec<LayoutNode> = nodes
.iter()
.map(|node| LayoutNode {
id: node.id.clone(),
width: node.board_width(),
height: node.board_height(),
})
.collect();
let layout_edges: Vec<LayoutEdge> = edges
.iter()
.zip(anchors.iter())
.map(|(edge, anchor)| LayoutEdge {
from: edge.from.clone(),
to: edge.to.clone(),
from_line_fraction: anchor.y_fraction,
})
.collect();
layout = layout_graph(&root_id, &layout_nodes, &layout_edges, LayoutConfig::default());
}
let by_id: HashMap<&str, &GraphNode> = nodes.iter().map(|n| (n.id.as_str(), n)).collect();
let (frame_x, frame_y) = match &reused_frame {
Some(record) => (record.x, record.y),
None => allocator.place(layout.frame_width, layout.frame_height),
};
if let Some(preview_path) = &options.preview {
let path = if options.all {
let safe = title.replace(['.', '/'], "_");
format!("{}/{}.png", preview_path.trim_end_matches('/'), safe)
} else {
preview_path.clone()
};
render_preview(&nodes, &edges, &anchors, &layout, &path)?;
println!(" preview written to {}", path.blue());
}
if options.dry_run {
print_dry_run(&nodes, &edges, &anchors, &layout, (frame_x, frame_y));
cleanup(&nodes);
return Ok(());
}
let client = client.expect("client is present when not in dry-run mode");
let frame_id = match &reused_frame {
Some(record) => {
client
.update_frame(
&record.frame_id,
&format!("auto: {title}"),
frame_x,
frame_y,
layout.frame_width,
layout.frame_height,
)
.await
.change_context(EvmMiroError)?;
record.frame_id.clone()
}
None => client
.create_frame(
&format!("auto: {title}"),
frame_x,
frame_y,
layout.frame_width,
layout.frame_height,
)
.await
.change_context(EvmMiroError)?,
};
println!(
" frame {} ({}x{}) at ({}, {})",
frame_id.green(),
layout.frame_width.round(),
layout.frame_height.round(),
frame_x.round(),
frame_y.round()
);
let target_frames = ensure_target_frames(&nodes, options, client, allocator).await?;
let frame_url = client.frame_url(&frame_id);
let mut record = AutoDeployedFrame {
entry_point: title.clone(),
frame_id: frame_id.clone(),
frame_url: frame_url.clone(),
x: frame_x,
y: frame_y,
width: layout.frame_width,
height: layout.frame_height,
images: Vec::new(),
connector_ids: Vec::new(),
marker_ids: Vec::new(),
border_ids: Vec::new(),
};
save_frame_record(&record)?;
let uploads: Vec<_> = nodes
.iter()
.filter_map(|node| layout.node(&node.id).map(|placed| (node, placed)))
.collect();
let bar = phase_bar("uploading screenshots", uploads.len());
let mut upload_tasks = tokio::task::JoinSet::new();
for (node, placed) in uploads {
let client = client.clone();
let frame_id = frame_id.clone();
let node_id = node.id.clone();
let png_path = node.png_path.clone();
let label = node.label.clone();
let kind = node.kind.clone();
let target_url = match &node.kind {
NodeKind::Link { target } => target_frames.get(target).cloned().unwrap_or_default(),
NodeKind::Screenshot => String::new(),
};
let (x, y, width, height) = (placed.x, placed.y, placed.width, placed.height);
let bar = bar.clone();
upload_tasks.spawn(async move {
let result = match kind {
NodeKind::Link { .. } => {
client
.create_link_card(&frame_id, &label, &target_url, x, y, width, height)
.await
}
NodeKind::Screenshot => client
.create_image_in_frame(&png_path, &frame_id, &label, x, y, width)
.await,
};
bar.inc(1);
result.map(|image_id| (node_id, image_id))
});
}
let mut image_ids: HashMap<String, String> = HashMap::new();
while let Some(joined) = upload_tasks.join_next().await {
let (node_id, image_id) = joined
.into_report()
.change_context(EvmMiroError)?
.change_context(EvmMiroError)?;
image_ids.insert(node_id, image_id);
}
bar.finish_and_clear();
println!(" {} {} screenshots uploaded", "✓".green(), image_ids.len());
let borders: Vec<(f64, f64, f64, f64)> = nodes
.iter()
.filter(|n| n.writes_storage)
.filter_map(|n| layout.node(&n.id).map(|p| (p.x, p.y, p.width, p.height)))
.collect();
if !borders.is_empty() {
let n_borders = borders.len();
let bar = phase_bar("storage markers", n_borders);
let mut border_tasks = tokio::task::JoinSet::new();
for (x, y, width, height) in borders {
let client = client.clone();
let frame_id = frame_id.clone();
let bar = bar.clone();
border_tasks.spawn(async move {
let result = client
.create_storage_border(&frame_id, x, y, width, height)
.await;
bar.inc(1);
result
});
}
while let Some(joined) = border_tasks.join_next().await {
let id = joined
.into_report()
.change_context(EvmMiroError)?
.change_context(EvmMiroError)?;
record.border_ids.push(id);
}
bar.finish_and_clear();
println!(" {} {} storage markers", "✓".green(), n_borders);
}
let ext_borders: Vec<(f64, f64, f64, f64)> = nodes
.iter()
.filter(|n| !n.external_call_lines.is_empty() && !n.writes_storage)
.filter_map(|n| layout.node(&n.id).map(|p| (p.x, p.y, p.width, p.height)))
.collect();
if !ext_borders.is_empty() {
let n_ext_borders = ext_borders.len();
let bar = phase_bar("external markers", n_ext_borders);
let mut border_tasks = tokio::task::JoinSet::new();
for (x, y, width, height) in ext_borders {
let client = client.clone();
let frame_id = frame_id.clone();
let bar = bar.clone();
border_tasks.spawn(async move {
let result = client
.create_external_border(&frame_id, x, y, width, height)
.await;
bar.inc(1);
result
});
}
while let Some(joined) = border_tasks.join_next().await {
let id = joined
.into_report()
.change_context(EvmMiroError)?
.change_context(EvmMiroError)?;
record.border_ids.push(id);
}
bar.finish_and_clear();
println!(" {} {} external markers", "✓".green(), n_ext_borders);
}
let mut highlights: Vec<(f64, f64, f64, f64)> = Vec::new();
for node in nodes.iter() {
if node.write_lines.is_empty() || node.png_height == 0 {
continue;
}
let Some(p) = layout.node(&node.id) else {
continue;
};
let geom = silicon::line_geometry(Some(node.font_size));
let line_h = (p.height * geom.line_height as f64 / node.png_height as f64).max(1.0);
let mut lines: Vec<usize> = node
.write_lines
.iter()
.map(|(line, _)| *line)
.filter(|line| *line >= node.start_line && *line <= node.end_line)
.collect();
lines.sort_unstable();
lines.dedup();
for line in lines {
let rendered_index = PATH_HEADER_LINES + (line - node.start_line);
let y_fraction = geom.line_center_fraction(rendered_index, node.png_height);
let cy = p.y - p.height / 2.0 + p.height * y_fraction;
highlights.push((p.x, cy, p.width, line_h));
}
}
if !highlights.is_empty() {
let n_highlights = highlights.len();
let bar = phase_bar("storage lines", n_highlights);
let mut highlight_tasks = tokio::task::JoinSet::new();
for (x, y, width, height) in highlights {
let client = client.clone();
let frame_id = frame_id.clone();
let bar = bar.clone();
highlight_tasks.spawn(async move {
let result = client
.create_line_highlight(&frame_id, x, y, width, height)
.await;
bar.inc(1);
result
});
}
while let Some(joined) = highlight_tasks.join_next().await {
let id = joined
.into_report()
.change_context(EvmMiroError)?
.change_context(EvmMiroError)?;
record.border_ids.push(id);
}
bar.finish_and_clear();
println!(" {} {} storage lines", "✓".green(), n_highlights);
}
let mut ext_bands: Vec<(f64, f64, f64, f64)> = Vec::new();
for node in nodes.iter() {
if node.external_call_lines.is_empty() || node.png_height == 0 {
continue;
}
let Some(p) = layout.node(&node.id) else {
continue;
};
let geom = silicon::line_geometry(Some(node.font_size));
let line_h = (p.height * geom.line_height as f64 / node.png_height as f64).max(1.0);
for line in &node.external_call_lines {
if *line < node.start_line || *line > node.end_line {
continue;
}
let rendered_index = PATH_HEADER_LINES + (line - node.start_line);
let y_fraction = geom.line_center_fraction(rendered_index, node.png_height);
let cy = p.y - p.height / 2.0 + p.height * y_fraction;
ext_bands.push((p.x, cy, p.width, line_h));
}
}
if !ext_bands.is_empty() {
let n_ext = ext_bands.len();
let bar = phase_bar("external boundaries", n_ext);
let mut ext_tasks = tokio::task::JoinSet::new();
for (x, y, width, height) in ext_bands {
let client = client.clone();
let frame_id = frame_id.clone();
let bar = bar.clone();
ext_tasks.spawn(async move {
let result = client
.create_external_marker(&frame_id, x, y, width, height)
.await;
bar.inc(1);
result
});
}
while let Some(joined) = ext_tasks.join_next().await {
let id = joined
.into_report()
.change_context(EvmMiroError)?
.change_context(EvmMiroError)?;
record.border_ids.push(id);
}
bar.finish_and_clear();
println!(" {} {} external boundaries", "✓".green(), n_ext);
}
let back_edges: HashSet<(String, String)> = layout.back_edges.iter().cloned().collect();
let _ = anchors;
struct CalleeLink {
end_id: String,
end_anchor: RelativeAnchor,
end_point: (f64, f64),
}
struct PendingGroup {
token_x: f64,
token_y: f64,
edge_x: f64,
exit_right: bool,
style: ConnectorStyle,
callees: Vec<CalleeLink>,
}
let mut groups: HashMap<(String, usize, bool), PendingGroup> = HashMap::new();
for edge in edges.iter() {
let (Some(_start_id), Some(end_id)) =
(image_ids.get(&edge.from), image_ids.get(&edge.to))
else {
continue;
};
let (Some(caller), Some(callee)) =
(by_id.get(edge.from.as_str()), by_id.get(edge.to.as_str()))
else {
continue;
};
let Some(caller_placed) = layout.node(&edge.from) else {
continue;
};
let callee_x = layout
.node(&edge.to)
.map(|placed| placed.x)
.unwrap_or(caller_placed.x + 1.0);
let exit_right = callee_x >= caller_placed.x;
let callee_fraction = silicon::line_geometry(Some(callee.font_size))
.line_center_fraction(SIGNATURE_LINE_INDEX, callee.png_height);
let end_point = match layout.node(&edge.to) {
Some(placed) => (
if exit_right {
placed.x - placed.width / 2.0
} else {
placed.x + placed.width / 2.0
},
placed.y - placed.height / 2.0 + placed.height * callee_fraction,
),
None => (0.0, 0.0),
};
let link = CalleeLink {
end_id: end_id.clone(),
end_anchor: RelativeAnchor::new(if exit_right { 0.0 } else { 1.0 }, callee_fraction),
end_point,
};
let dashed = back_edges.contains(&(edge.from.clone(), edge.to.clone()));
let group = groups
.entry((edge.from.clone(), edge.line_in_slice, exit_right))
.or_insert_with(|| {
let line_index = edge.line_in_slice.saturating_sub(1) + PATH_HEADER_LINES;
let line_text = caller
.rendered_lines
.get(line_index)
.cloned()
.unwrap_or_default();
let text_width = |text: &str| {
silicon::line_end_x(
Some(caller.font_size),
true,
caller.rendered_lines.len(),
caller.line_offset,
text,
) as f64
};
let y_fraction = silicon::line_geometry(Some(caller.font_size))
.line_center_fraction(line_index, caller.png_height);
let token_frac = if caller.png_width > 0 {
let width = caller.png_width as f64;
if exit_right {
let gap = (text_width("a") - text_width("")) * ANCHOR_GAP_CHARS;
((text_width(&line_text) + gap) / width).min(1.0)
} else {
(text_width("") / width).max(0.0)
}
} else if exit_right {
1.0
} else {
0.0
};
let frame_edge = if exit_right {
caller_placed.x + caller_placed.width / 2.0
} else {
caller_placed.x - caller_placed.width / 2.0
};
let raw_token_x =
caller_placed.x - caller_placed.width / 2.0 + caller_placed.width * token_frac;
let min_stub = 60.0_f64;
let edge_gap = 200.0_f64;
let reaches_edge = exit_right && raw_token_x > frame_edge - min_stub * 2.0;
let edge_x = if reaches_edge {
frame_edge + edge_gap
} else {
frame_edge
};
let token_x = if exit_right {
raw_token_x.min(edge_x - min_stub)
} else {
raw_token_x.max(edge_x + min_stub)
};
PendingGroup {
token_x,
token_y: caller_placed.y - caller_placed.height / 2.0
+ caller_placed.height * y_fraction,
edge_x,
exit_right,
style: ConnectorStyle {
stroke_color: DEPTH_COLORS[caller.depth % DEPTH_COLORS.len()].to_string(),
stroke_width: options.stroke_width.to_string(),
dashed: false,
caption: None,
arrow: ArrowEnd::Start,
},
callees: Vec::new(),
}
});
group.style.dashed = group.style.dashed || dashed;
group.callees.push(link);
}
let bar = phase_bar("drawing connectors", groups.len());
let mut connector_tasks = tokio::task::JoinSet::new();
for (_key, group) in groups {
let client = client.clone();
let frame_id = frame_id.clone();
let bar = bar.clone();
connector_tasks.spawn(async move {
let mut markers = Vec::new();
let mut connectors = Vec::new();
let token_marker = client
.create_anchor_marker(&frame_id, group.token_x, group.token_y, ANCHOR_MARKER_SIZE)
.await?;
markers.push(token_marker.clone());
let edge_marker = client
.create_anchor_marker(&frame_id, group.edge_x, group.token_y, ANCHOR_MARKER_SIZE)
.await?;
markers.push(edge_marker.clone());
let (edge_side, token_side) = if group.exit_right {
(RelativeAnchor::new(0.0, 0.5), RelativeAnchor::new(1.0, 0.5))
} else {
(RelativeAnchor::new(1.0, 0.5), RelativeAnchor::new(0.0, 0.5))
};
let mut stub_style = group.style.clone();
stub_style.arrow = ArrowEnd::End;
connectors.push(
client
.create_connector(&edge_marker, edge_side, &token_marker, token_side, stub_style)
.await?,
);
for link in &group.callees {
let mut route_style = group.style.clone();
route_style.arrow = ArrowEnd::None;
connectors.push(
client
.create_connector(
&link.end_id,
link.end_anchor,
&edge_marker,
facing_anchor((group.edge_x, group.token_y), link.end_point),
route_style,
)
.await?,
);
}
bar.inc(1);
Ok::<_, error_stack::Report<crate::batbelt::miro::MiroError>>((markers, connectors))
});
}
let mut connector_ids = Vec::new();
let mut marker_ids = Vec::new();
while let Some(joined) = connector_tasks.join_next().await {
let (markers, connectors) = joined
.into_report()
.change_context(EvmMiroError)?
.change_context(EvmMiroError)?;
marker_ids.extend(markers);
connector_ids.extend(connectors);
}
bar.finish_and_clear();
println!(" {} {} connector(s)", "✓".green(), connector_ids.len());
record.images = image_ids.into_iter().collect();
record.connector_ids = connector_ids;
record.marker_ids = marker_ids;
save_frame_record(&record)?;
println!(" {}", frame_url.blue());
cleanup(&nodes);
Ok(())
}
async fn recycle_recorded_frame(
title: &str,
client: &MiroClient,
) -> Result<Option<AutoDeployedFrame>> {
let record = {
let metadata = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
metadata
.miro
.auto
.frames
.iter()
.find(|frame| frame.entry_point == title)
.cloned()
};
let Some(record) = record else {
return Ok(None);
};
let mut delete_tasks = tokio::task::JoinSet::new();
for id in record.connector_ids.clone() {
let client = client.clone();
delete_tasks.spawn(async move { client.delete_connector(&id).await });
}
let item_ids = record
.marker_ids
.iter()
.cloned()
.chain(record.border_ids.iter().cloned())
.chain(record.images.iter().map(|(_, image_id)| image_id.clone()));
for id in item_ids {
let client = client.clone();
delete_tasks.spawn(async move { client.delete_item(&id).await });
}
while delete_tasks.join_next().await.is_some() {}
let owner = title.to_string();
EvmBatMetadata::update_metadata(move |metadata| {
metadata
.miro
.auto
.frames
.retain(|frame| frame.entry_point != owner);
})
.change_context(EvmMiroError)?;
Ok(Some(record))
}
fn save_frame_record(record: &AutoDeployedFrame) -> Result<()> {
let record = record.clone();
EvmBatMetadata::update_metadata(move |metadata| {
metadata
.miro
.auto
.frames
.retain(|frame| frame.entry_point != record.entry_point);
metadata.miro.auto.frames.push(record.clone());
})
.change_context(EvmMiroError)
}
fn compute_anchors(nodes: &[GraphNode], edges: &[GraphEdge]) -> Vec<RelativeAnchor> {
let by_id: HashMap<&str, &GraphNode> = nodes.iter().map(|n| (n.id.as_str(), n)).collect();
let mut occurrences: HashMap<(&str, usize, usize), usize> = HashMap::new();
let mut per_line: HashMap<(&str, usize), usize> = HashMap::new();
for edge in edges {
*occurrences
.entry((edge.from.as_str(), edge.line_in_slice, edge.column))
.or_insert(0) += 1;
*per_line
.entry((edge.from.as_str(), edge.line_in_slice))
.or_insert(0) += 1;
}
let mut seen: HashMap<(&str, usize, usize), usize> = HashMap::new();
edges
.iter()
.map(|edge| {
let Some(node) = by_id.get(edge.from.as_str()) else {
return RelativeAnchor::new(1.0, 0.5);
};
let key = (edge.from.as_str(), edge.line_in_slice, edge.column);
let index = seen.entry(key).or_insert(0);
let position = *index;
*index += 1;
let total = occurrences.get(&key).copied().unwrap_or(1);
let alone_on_line = per_line
.get(&(edge.from.as_str(), edge.line_in_slice))
.copied()
.unwrap_or(1)
== 1;
let mut anchor = caller_anchor(node, edge, alone_on_line);
if total > 1 && node.png_height > 0 {
let line_height = silicon::line_geometry(Some(node.font_size)).line_height as f64;
let spread = line_height * 0.6 / node.png_height as f64;
let offset = (position as f64 - (total as f64 - 1.0) / 2.0) * spread
/ (total as f64 - 1.0).max(1.0);
anchor = RelativeAnchor::new(anchor.x_fraction, anchor.y_fraction + offset);
}
anchor
})
.collect()
}
fn caller_anchor(node: &GraphNode, edge: &GraphEdge, alone_on_line: bool) -> RelativeAnchor {
let line_index = edge.line_in_slice - 1 + PATH_HEADER_LINES;
let geometry = silicon::line_geometry(Some(node.font_size));
let y_fraction = geometry.line_center_fraction(line_index, node.png_height);
let line_text = node
.rendered_lines
.get(line_index)
.cloned()
.unwrap_or_default();
let start = if line_text
.get(edge.column..edge.column + edge.symbol.len())
.map(|found| found == edge.symbol)
.unwrap_or(false)
{
Some(edge.column)
} else {
line_text.find(&edge.symbol)
};
let text_width = |text: &str| {
silicon::line_end_x(
Some(node.font_size),
true,
node.rendered_lines.len(),
node.line_offset,
text,
) as f64
};
let x_fraction = match (start, node.png_width) {
(_, width) if alone_on_line && width > 0 => {
let gap = (text_width("a") - text_width("")) * ANCHOR_GAP_CHARS;
(text_width(&line_text) + gap) / width as f64
}
(Some(column), width) if width > 0 => {
let before = text_width(&line_text[..column]);
let through = text_width(&line_text[..column + edge.symbol.len()]);
(before + through) / 2.0 / width as f64
}
(_, width) if width > 0 => {
silicon::line_end_x(
Some(node.font_size),
true,
node.rendered_lines.len(),
node.line_offset,
&line_text,
) as f64
/ width as f64
}
_ => 1.0,
};
RelativeAnchor::new(x_fraction, y_fraction)
}
fn build_graph(
metadata: &EvmBatMetadata,
contract_name: &str,
function_name: &str,
options: &AutoDeployOptions,
) -> Result<(
Vec<GraphNode>,
Vec<GraphEdge>,
usize,
Vec<crate::batbelt::evm::metadata::bat_metadata::UnresolvedCall>,
)> {
let Some((root_contract, root_function)) =
find_function(metadata, contract_name, function_name)
else {
return Ok((Vec::new(), Vec::new(), 0, Vec::new()));
};
let mut definer_map: HashMap<String, Vec<String>> = HashMap::new();
for contract in &metadata.contracts {
if (contract.external && !options.include_external)
|| contract.contract_type == EvmContractType::Interface
{
continue;
}
for function in &contract.functions {
if !function.is_stub {
definer_map
.entry(function.name.clone())
.or_default()
.push(contract.name.clone());
}
}
}
let mut nodes: Vec<GraphNode> = Vec::new();
let mut edges: Vec<GraphEdge> = Vec::new();
let mut truncated = 0usize;
let mut unresolved: Vec<crate::batbelt::evm::metadata::bat_metadata::UnresolvedCall> =
Vec::new();
let mut drawn: HashMap<String, String> = HashMap::new();
let root_id = node_key(contract_name, function_name);
drawn.insert(root_id.clone(), root_id.clone());
nodes.push(make_node(
root_id.clone(),
format!("{contract_name}.{function_name}"),
root_contract,
&root_function,
0,
));
struct Pending {
node_id: String,
contract: String,
function: String,
depth: usize,
}
let mut stack = vec![Pending {
node_id: root_id,
contract: contract_name.to_string(),
function: function_name.to_string(),
depth: 0,
}];
while let Some(current) = stack.pop() {
if options.max_depth.is_some_and(|limit| current.depth >= limit) {
continue;
}
let Some((contract, function)) =
find_function(metadata, ¤t.contract, ¤t.function)
else {
continue;
};
let slice = read_slice(
&contract.file_path,
function.line,
function_end(&function, contract),
);
let mut children: Vec<Pending> = Vec::new();
for modifier_name in &function.modifiers {
let Some((owner, definition)) =
find_modifier(metadata, ¤t.contract, modifier_name)
else {
continue;
};
if options.max_nodes.is_some_and(|cap| nodes.len() >= cap) {
truncated += 1;
continue;
}
let line_in_slice = slice
.iter()
.position(|line| line.contains(modifier_name))
.map(|index| index + 1)
.unwrap_or(1);
let target_id = node_key(&owner.name, &definition.name);
edges.push(GraphEdge {
from: current.node_id.clone(),
to: target_id.clone(),
line_in_slice,
column: slice
.get(line_in_slice - 1)
.and_then(|line| line.find(modifier_name.as_str()))
.unwrap_or(0),
symbol: modifier_name.clone(),
});
if drawn.insert(target_id.clone(), target_id.clone()).is_none() {
nodes.push(make_modifier_node(
target_id,
owner,
definition.name.clone(),
definition.line,
if definition.end_line > 0 {
definition.end_line
} else {
definition.line + 6
},
current.depth + 1,
));
}
}
for call in extract_call_sites_from_source(&body_only(&slice).join("\n")) {
let Some((target_contract, target_function)) =
resolve_call(metadata, contract, &call.name, options, &definer_map)
else {
continue;
};
let target_id = node_key(&target_contract.name, &target_function.name);
if target_id == current.node_id {
continue; }
if options.max_nodes.is_some_and(|cap| nodes.len() >= cap) {
truncated += 1;
continue;
}
edges.push(GraphEdge {
from: current.node_id.clone(),
to: target_id.clone(),
line_in_slice: call.line,
column: call.column,
symbol: call.symbol.clone(),
});
if drawn.insert(target_id.clone(), target_id.clone()).is_some() {
continue;
}
nodes.push(make_node(
target_id.clone(),
format!("{}.{}", target_contract.name, target_function.name),
target_contract,
&target_function,
current.depth + 1,
));
children.push(Pending {
node_id: target_id,
contract: target_contract.name.clone(),
function: target_function.name.clone(),
depth: current.depth + 1,
});
}
for u in &function.unresolved_calls {
let concrete = if u.inferred_type.is_empty() {
None
} else {
metadata.resolutions.get(&u.inferred_type)
};
let Some(concrete) = concrete else {
unresolved.push(u.clone());
continue;
};
let Some((tc, tf)) = find_function(metadata, concrete, &u.method) else {
unresolved.push(u.clone());
continue;
};
let Some((tc, tf)) = destub(metadata, (tc, tf), options) else {
continue;
};
if !options.include_external && tc.external {
continue;
}
let target_id = node_key(&tc.name, &tf.name);
if target_id == current.node_id {
continue;
}
if options.max_nodes.is_some_and(|cap| nodes.len() >= cap) {
truncated += 1;
continue;
}
let line_in_slice = slice
.iter()
.position(|l| l.contains(&u.method))
.map(|i| i + 1)
.unwrap_or(1);
edges.push(GraphEdge {
from: current.node_id.clone(),
to: target_id.clone(),
line_in_slice,
column: slice
.get(line_in_slice - 1)
.and_then(|l| l.find(u.method.as_str()))
.unwrap_or(0),
symbol: u.method.clone(),
});
if drawn.insert(target_id.clone(), target_id.clone()).is_some() {
continue;
}
nodes.push(make_node(
target_id.clone(),
format!("{}.{}", tc.name, tf.name),
tc,
&tf,
current.depth + 1,
));
children.push(Pending {
node_id: target_id,
contract: tc.name.clone(),
function: tf.name.clone(),
depth: current.depth + 1,
});
}
let mut external_lines: Vec<usize> = Vec::new();
for uec in &function.unknown_external_calls {
let read_only = find_function(metadata, &uec.inferred_type, &uec.method)
.map(|(_, f)| {
matches!(
f.mutability,
crate::batbelt::evm::types::EvmMutability::View
| crate::batbelt::evm::types::EvmMutability::Pure
)
})
.unwrap_or(false);
if read_only {
continue;
}
if let Some(pos) = slice.iter().position(|l| l.contains(&uec.method)) {
external_lines.push(function.line + pos);
}
}
if !external_lines.is_empty() {
external_lines.sort_unstable();
external_lines.dedup();
if let Some(node) = nodes.iter_mut().find(|n| n.id == current.node_id) {
node.external_call_lines = external_lines;
}
}
for child in children.into_iter().rev() {
stack.push(child);
}
}
let unresolved = expand_unresolved(metadata, unresolved);
split_shared_leaves(&mut nodes, &mut edges);
Ok((nodes, edges, truncated, unresolved))
}
fn expand_unresolved(
metadata: &EvmBatMetadata,
seed: Vec<crate::batbelt::evm::metadata::bat_metadata::UnresolvedCall>,
) -> Vec<crate::batbelt::evm::metadata::bat_metadata::UnresolvedCall> {
let mut out = Vec::new();
let mut seen_calls: HashSet<(String, String)> = HashSet::new();
let mut visited_fns: HashSet<(String, String)> = HashSet::new();
let mut frontier = seed;
while let Some(u) = frontier.pop() {
if !seen_calls.insert((u.receiver.clone(), u.method.clone())) {
continue;
}
let target = if !u.inferred_type.is_empty() {
metadata.resolutions.get(&u.inferred_type).cloned()
} else {
None
}
.or_else(|| {
if u.candidates.len() == 1 {
Some(u.candidates[0].clone())
} else {
None
}
});
if let Some(contract) = target {
if visited_fns.insert((contract.clone(), u.method.clone())) {
if let Some((_, f)) = find_function(metadata, &contract, &u.method) {
for du in &f.unresolved_calls {
frontier.push(du.clone());
}
}
}
}
out.push(u);
}
out.sort_by(|a, b| (&a.receiver, &a.method).cmp(&(&b.receiver, &b.method)));
out
}
fn node_key(contract: &str, function: &str) -> String {
format!("{contract}::{function}")
}
fn node_id(contract: &str, function: &str) -> String {
format!("{contract}::{function}")
}
fn make_node(
id: String,
label: String,
contract: &ContractMetadata,
function: &FunctionMetadata,
depth: usize,
) -> GraphNode {
GraphNode {
kind: NodeKind::Screenshot,
id,
label,
file_path: contract.file_path.clone(),
start_line: function.line,
end_line: function_end(function, contract),
depth,
font_size: font_for_depth(depth),
png_path: String::new(),
png_width: 0,
png_height: 0,
rendered_lines: Vec::new(),
line_offset: 0,
writes_storage: !function.storage_writes.is_empty(),
write_lines: function
.storage_write_sites
.iter()
.map(|s| (s.line, s.name.clone()))
.collect(),
external_call_lines: Vec::new(),
}
}
fn make_modifier_node(
id: String,
contract: &ContractMetadata,
name: String,
start_line: usize,
end_line: usize,
depth: usize,
) -> GraphNode {
GraphNode {
kind: NodeKind::Screenshot,
id,
label: format!("{}.{} (modifier)", contract.name, name),
file_path: contract.file_path.clone(),
start_line,
end_line,
depth,
font_size: font_for_depth(depth),
png_path: String::new(),
png_width: 0,
png_height: 0,
rendered_lines: Vec::new(),
line_offset: 0,
writes_storage: false,
write_lines: Vec::new(),
external_call_lines: Vec::new(),
}
}
fn function_end(function: &FunctionMetadata, contract: &ContractMetadata) -> usize {
if function.end_line > 0 {
return function.end_line;
}
let content = std::fs::read_to_string(&contract.file_path).unwrap_or_default();
let lines: Vec<&str> = content.lines().collect();
let mut depth = 0i32;
let mut started = false;
for (index, line) in lines.iter().enumerate().skip(function.line.saturating_sub(1)) {
for character in line.chars() {
match character {
'{' => {
depth += 1;
started = true;
}
'}' => depth -= 1,
_ => {}
}
}
if started && depth <= 0 {
return index + 1;
}
}
function.line
}
fn find_function<'a>(
metadata: &'a EvmBatMetadata,
contract_name: &str,
function_name: &str,
) -> Option<(&'a ContractMetadata, FunctionMetadata)> {
let contract = metadata.get_contract_by_name(contract_name)?;
if let Some(function) = contract.functions.iter().find(|f| f.name == function_name) {
return Some((contract, function.clone()));
}
for base in &contract.base_contracts {
if let Some(found) = find_function(metadata, base, function_name) {
return Some(found);
}
}
None
}
fn find_modifier<'a>(
metadata: &'a EvmBatMetadata,
contract_name: &str,
modifier_name: &str,
) -> Option<(&'a ContractMetadata, crate::batbelt::evm::types::EvmModifierDef)> {
let contract = metadata.get_contract_by_name(contract_name)?;
if let Some(definition) = contract.modifiers.iter().find(|m| m.name == modifier_name) {
return Some((contract, definition.clone()));
}
for base in &contract.base_contracts {
if let Some(found) = find_modifier(metadata, base, modifier_name) {
return Some(found);
}
}
None
}
fn resolve_call<'a>(
metadata: &'a EvmBatMetadata,
caller_contract: &ContractMetadata,
call_name: &str,
options: &AutoDeployOptions,
definer_map: &HashMap<String, Vec<String>>,
) -> Option<(&'a ContractMetadata, FunctionMetadata)> {
let keep = |contract: &ContractMetadata| options.include_external || !contract.external;
let (target_name, method) = match call_name.split_once('.') {
Some((target, method)) => (Some(target), method),
None => (None, call_name),
};
let candidates: Vec<String> = match target_name {
None => {
let mut chain = vec![caller_contract.name.clone()];
chain.extend(caller_contract.base_contracts.iter().cloned());
chain
}
Some("super") | Some("this") => {
let mut chain = caller_contract.base_contracts.clone();
chain.push(caller_contract.name.clone());
chain
}
Some(target) => {
if metadata.get_contract_by_name(target).is_some() {
vec![target.to_string()]
} else if let Some(variable) = caller_contract
.state_variables
.iter()
.find(|v| v.name == target)
{
implementations_of(metadata, &variable.type_name)
} else {
Vec::new()
}
}
};
for candidate in candidates {
let Some(contract) = metadata.get_contract_by_name(&candidate) else {
continue;
};
if contract.contract_type == EvmContractType::Interface {
for implementation in implementations_of(metadata, &contract.name) {
if let Some(target) = metadata.get_contract_by_name(&implementation) {
if !keep(target) {
continue;
}
if let Some(found) = find_function(metadata, &target.name, method) {
if let Some(resolved) = destub(metadata, found, options) {
return Some(resolved);
}
}
}
}
continue;
}
if !keep(contract) {
continue;
}
if let Some(found) = find_function(metadata, &contract.name, method) {
if let Some(resolved) = destub(metadata, found, options) {
return Some(resolved);
}
}
}
if matches!(target_name, Some(t) if t != "super" && t != "this") {
if let Some(definers) = definer_map.get(method) {
if definers.len() == 1 {
return find_function(metadata, &definers[0], method);
}
}
}
None
}
fn destub<'a>(
metadata: &'a EvmBatMetadata,
found: (&'a ContractMetadata, FunctionMetadata),
options: &AutoDeployOptions,
) -> Option<(&'a ContractMetadata, FunctionMetadata)> {
let (contract, function) = found;
if !function.is_stub {
return Some((contract, function));
}
let keep = |c: &ContractMetadata| options.include_external || !c.external;
let mut overrides: Vec<(&'a ContractMetadata, FunctionMetadata)> = Vec::new();
for impl_name in implementations_of(metadata, &contract.name) {
if impl_name == contract.name {
continue;
}
if let Some((tc, tf)) = find_function(metadata, &impl_name, &function.name) {
if !tf.is_stub && keep(tc) {
overrides.push((tc, tf));
}
}
}
if overrides.len() == 1 {
return overrides.pop();
}
None
}
fn implementations_of(metadata: &EvmBatMetadata, type_name: &str) -> Vec<String> {
let clean = type_name.trim();
if let Some(interface) = metadata.interfaces.iter().find(|i| i.name == clean) {
if !interface.implemented_by.is_empty() {
return interface.implemented_by.clone();
}
}
let derived: Vec<String> = metadata
.contracts
.iter()
.filter(|c| c.base_contracts.iter().any(|b| b == clean))
.map(|c| c.name.clone())
.collect();
if !derived.is_empty() {
return derived;
}
vec![clean.to_string()]
}
fn read_slice(file_path: &str, start_line: usize, end_line: usize) -> Vec<String> {
let content = std::fs::read_to_string(file_path).unwrap_or_default();
let lines: Vec<&str> = content.lines().collect();
let start = start_line.saturating_sub(1);
let end = end_line.min(lines.len());
if start >= end {
return Vec::new();
}
lines[start..end].iter().map(|l| l.to_string()).collect()
}
fn render_and_measure(nodes: &mut [GraphNode], owner: &str) -> Result<()> {
BatFolder::Figures
.create_folder()
.change_context(EvmMiroError)?;
let destination = BatFolder::Figures
.get_path(true)
.change_context(EvmMiroError)?;
let bar = phase_bar("rendering screenshots", nodes.len());
let outcome: std::result::Result<(), String> = nodes
.par_iter_mut()
.map(|node| {
let code = read_slice(&node.file_path, node.start_line, node.end_line);
if code.is_empty() {
bar.inc(1);
return Ok(());
}
let pretty_path = crate::batbelt::path::prettify_source_code_path(&node.file_path)
.unwrap_or_else(|_| node.file_path.clone());
let mut rendered = vec![format!("// {pretty_path}"), String::new()];
rendered.extend(code.iter().cloned());
node.line_offset = node.start_line.saturating_sub(PATH_HEADER_LINES);
let file_name = format!(
"{}__{}.js",
owner.replace([':', '.', '/'], "_"),
node.id.replace([':', '.', '/'], "_")
);
let png_path = silicon::create_figure(
&rendered.join("\n"),
&destination,
&file_name,
node.line_offset,
Some(node.font_size),
true,
);
let (width, height) = image::image_dimensions(&png_path)
.map_err(|e| format!("cannot measure {png_path}: {e}"))?;
if width > 8192 || height > 8192 {
log::warn!(
"{} renders to {}x{}, above Miro's 8192 px limit",
node.label,
width,
height
);
}
node.png_path = png_path;
node.png_width = width;
node.png_height = height;
node.rendered_lines = rendered;
bar.inc(1);
Ok(())
})
.collect();
outcome.map_err(|message| Report::new(EvmMiroError).attach_printable(message))?;
bar.finish_and_clear();
println!(" {} {} screenshots rendered", "✓".green(), nodes.len());
Ok(())
}
fn render_preview(
nodes: &[GraphNode],
edges: &[GraphEdge],
anchors: &[RelativeAnchor],
layout: &GraphLayout,
path: &str,
) -> Result<()> {
use image::{Rgba, RgbaImage};
let scale = (2600.0 / layout.frame_width).min(1.0);
let width = (layout.frame_width * scale).round().max(1.0) as u32;
let height = (layout.frame_height * scale).round().max(1.0) as u32;
let mut canvas = RgbaImage::from_pixel(width, height, Rgba([24, 25, 33, 255]));
for node in nodes {
let Some(placed) = layout.node(&node.id) else {
continue;
};
if node.png_path.is_empty() {
continue;
}
let Ok(screenshot) = image::open(&node.png_path) else {
continue;
};
let target_width = (placed.width * scale).round().max(1.0) as u32;
let target_height = (placed.height * scale).round().max(1.0) as u32;
let resized = screenshot.resize_exact(
target_width,
target_height,
image::imageops::FilterType::Triangle,
);
let left = ((placed.x - placed.width / 2.0) * scale).round() as i64;
let top = ((placed.y - placed.height / 2.0) * scale).round() as i64;
image::imageops::overlay(&mut canvas, &resized, left, top);
}
let by_id: HashMap<&str, &GraphNode> = nodes.iter().map(|n| (n.id.as_str(), n)).collect();
for (index, (edge, anchor)) in edges.iter().zip(anchors.iter()).enumerate() {
let (Some(from), Some(to)) = (layout.node(&edge.from), layout.node(&edge.to)) else {
continue;
};
let start = (
((from.x - from.width / 2.0 + from.width * anchor.x_fraction) * scale) as i64,
((from.y - from.height / 2.0 + from.height * anchor.y_fraction) * scale) as i64,
);
let callee_fraction = by_id
.get(edge.to.as_str())
.map(|node| {
silicon::line_geometry(Some(node.font_size))
.line_center_fraction(SIGNATURE_LINE_INDEX, node.png_height)
})
.unwrap_or(0.5);
let end = (
((to.x - to.width / 2.0) * scale) as i64,
((to.y - to.height / 2.0 + to.height * callee_fraction) * scale) as i64,
);
let hex = DEPTH_COLORS[from.layer % DEPTH_COLORS.len()];
let color = parse_hex(hex);
draw_line(&mut canvas, start, end, color);
draw_disc(&mut canvas, start, 5, color);
draw_disc(&mut canvas, end, 2, color);
let _ = index;
}
if let Some(parent) = std::path::Path::new(path).parent() {
if !parent.as_os_str().is_empty() {
let _ = std::fs::create_dir_all(parent);
}
}
canvas
.save(path)
.into_report()
.change_context(EvmMiroError)
.attach_printable_lazy(|| format!("cannot write the preview to {path}"))?;
Ok(())
}
fn parse_hex(hex: &str) -> image::Rgba<u8> {
let clean = hex.trim_start_matches('#');
let value = u32::from_str_radix(clean, 16).unwrap_or(0xffffff);
image::Rgba([
((value >> 16) & 0xff) as u8,
((value >> 8) & 0xff) as u8,
(value & 0xff) as u8,
255,
])
}
fn draw_line(
canvas: &mut image::RgbaImage,
from: (i64, i64),
to: (i64, i64),
color: image::Rgba<u8>,
) {
let (mut x, mut y) = from;
let dx = (to.0 - x).abs();
let dy = -(to.1 - y).abs();
let step_x = if x < to.0 { 1 } else { -1 };
let step_y = if y < to.1 { 1 } else { -1 };
let mut error = dx + dy;
loop {
draw_disc(canvas, (x, y), 1, color);
if x == to.0 && y == to.1 {
break;
}
let double = 2 * error;
if double >= dy {
error += dy;
x += step_x;
}
if double <= dx {
error += dx;
y += step_y;
}
}
}
fn draw_disc(canvas: &mut image::RgbaImage, center: (i64, i64), radius: i64, color: image::Rgba<u8>) {
for offset_y in -radius..=radius {
for offset_x in -radius..=radius {
if offset_x * offset_x + offset_y * offset_y > radius * radius {
continue;
}
let x = center.0 + offset_x;
let y = center.1 + offset_y;
if x >= 0 && y >= 0 && (x as u32) < canvas.width() && (y as u32) < canvas.height() {
canvas.put_pixel(x as u32, y as u32, color);
}
}
}
}
fn cleanup(nodes: &[GraphNode]) {
for node in nodes {
if !node.png_path.is_empty() {
let _ = std::fs::remove_file(&node.png_path);
}
}
}
fn print_dry_run(
nodes: &[GraphNode],
edges: &[GraphEdge],
anchors: &[RelativeAnchor],
layout: &GraphLayout,
(frame_x, frame_y): (f64, f64),
) {
println!(
" frame {}x{} at ({}, {})",
layout.frame_width.round(),
layout.frame_height.round(),
frame_x.round(),
frame_y.round()
);
println!(
" {:<38} {:>5} {:>9} {:>9} {:>7} {:>7} {}",
"node", "layer", "x", "y", "w", "h", "png"
);
let mut placed: Vec<_> = layout.nodes.iter().collect();
placed.sort_by_key(|node| (node.layer, node.y as i64));
for node in placed {
let source = nodes.iter().find(|n| n.id == node.id);
println!(
" {:<38} {:>5} {:>9.0} {:>9.0} {:>7.0} {:>7.0} {}",
truncate(&source.map(|n| n.label.clone()).unwrap_or_default(), 38),
node.layer,
node.x,
node.y,
node.width,
node.height,
source
.map(|n| format!("{}x{}", n.png_width, n.png_height))
.unwrap_or_default()
);
}
println!(" {} connector(s):", edges.len());
for (edge, anchor) in edges.iter().zip(anchors.iter()) {
let Some(caller) = nodes.iter().find(|n| n.id == edge.from) else {
continue;
};
let callee_label = nodes
.iter()
.find(|n| n.id == edge.to)
.map(|n| n.label.clone())
.unwrap_or_default();
println!(
" {:<34} L{:<5} → {:<34} start ({:.2}%, {:.2}%)",
truncate(&caller.label, 34),
caller.start_line + edge.line_in_slice - 1,
truncate(&callee_label, 34),
anchor.x_fraction * 100.0,
anchor.y_fraction * 100.0
);
}
if !layout.back_edges.is_empty() {
println!(
" {} cycle(s) will be drawn dashed: {:?}",
layout.back_edges.len(),
layout.back_edges
);
}
}
fn truncate(text: &str, width: usize) -> String {
if text.len() <= width {
return text.to_string();
}
format!("{}…", &text[..width.saturating_sub(1)])
}
fn facing_anchor(from: (f64, f64), toward: (f64, f64)) -> RelativeAnchor {
let dx = toward.0 - from.0;
let dy = toward.1 - from.1;
if dx > 0.0 {
if dy.abs() > dx.abs() * 3.0 {
if dy > 0.0 {
RelativeAnchor::new(0.5, 1.0)
} else {
RelativeAnchor::new(0.5, 0.0)
}
} else {
RelativeAnchor::new(1.0, 0.5)
}
} else {
if dy.abs() > dx.abs() {
if dy > 0.0 {
RelativeAnchor::new(0.5, 1.0)
} else {
RelativeAnchor::new(0.5, 0.0)
}
} else {
RelativeAnchor::new(0.0, 0.5)
}
}
}
#[cfg(test)]
mod facing_anchor_test {
use super::*;
#[test]
fn test_the_side_faces_the_other_end() {
let origin = (100.0, 100.0);
let right = facing_anchor(origin, (500.0, 120.0));
assert_eq!((right.x_fraction, right.y_fraction), (1.0, 0.5));
let below = facing_anchor(origin, (120.0, 900.0));
assert_eq!((below.x_fraction, below.y_fraction), (0.5, 1.0));
let above = facing_anchor(origin, (120.0, -400.0));
assert_eq!((above.x_fraction, above.y_fraction), (0.5, 0.0));
let left = facing_anchor(origin, (-300.0, 110.0));
assert_eq!((left.x_fraction, left.y_fraction), (0.0, 0.5));
}
#[test]
fn test_both_ends_of_a_hop_face_each_other() {
let a = (0.0, 0.0);
let b = (0.0, 500.0);
let from_a = facing_anchor(a, b);
let from_b = facing_anchor(b, a);
assert_eq!((from_a.x_fraction, from_a.y_fraction), (0.5, 1.0));
assert_eq!((from_b.x_fraction, from_b.y_fraction), (0.5, 0.0));
}
}
fn split_shared_leaves(nodes: &mut Vec<GraphNode>, edges: &mut [GraphEdge]) {
let mut outgoing: HashMap<&str, usize> = HashMap::new();
let mut incoming: HashMap<&str, usize> = HashMap::new();
for edge in edges.iter() {
*outgoing.entry(edge.from.as_str()).or_insert(0) += 1;
*incoming.entry(edge.to.as_str()).or_insert(0) += 1;
}
let shared_leaves: HashSet<String> = nodes
.iter()
.filter(|node| {
outgoing.get(node.id.as_str()).copied().unwrap_or(0) == 0
&& incoming.get(node.id.as_str()).copied().unwrap_or(0) > 1
})
.map(|node| node.id.clone())
.collect();
if shared_leaves.is_empty() {
return;
}
let template: HashMap<String, GraphNode> = nodes
.iter()
.filter(|node| shared_leaves.contains(&node.id))
.map(|node| (node.id.clone(), node.clone()))
.collect();
let mut used: HashSet<String> = HashSet::new();
let mut copies: Vec<GraphNode> = Vec::new();
for edge in edges.iter_mut() {
if !shared_leaves.contains(&edge.to) {
continue;
}
if used.insert(edge.to.clone()) {
continue;
}
let Some(original) = template.get(&edge.to) else {
continue;
};
let copy_id = format!("{}#{}", edge.to, copies.len());
let mut copy = original.clone();
copy.id = copy_id.clone();
copies.push(copy);
edge.to = copy_id;
}
nodes.extend(copies);
}
#[cfg(test)]
mod split_shared_leaves_test {
use super::*;
fn node(id: &str) -> GraphNode {
GraphNode {
kind: NodeKind::Screenshot,
id: id.to_string(),
label: id.to_string(),
file_path: String::new(),
start_line: 1,
end_line: 2,
depth: 0,
font_size: 22,
png_path: String::new(),
png_width: 0,
png_height: 0,
rendered_lines: Vec::new(),
line_offset: 0,
writes_storage: false,
write_lines: Vec::new(),
external_call_lines: Vec::new(),
}
}
fn edge(from: &str, to: &str) -> GraphEdge {
GraphEdge {
from: from.to_string(),
to: to.to_string(),
line_in_slice: 1,
column: 0,
symbol: to.to_string(),
}
}
#[test]
fn test_a_leaf_with_several_callers_is_split() {
let mut nodes = vec![node("a"), node("b"), node("leaf")];
let mut edges = vec![edge("a", "leaf"), edge("b", "leaf")];
split_shared_leaves(&mut nodes, &mut edges);
assert_eq!(nodes.len(), 4, "the leaf should have gained a copy");
assert_ne!(edges[0].to, edges[1].to, "each caller gets its own");
assert_eq!(edges[0].to, "leaf", "the first caller keeps the original");
}
#[test]
fn test_a_shared_node_with_children_is_left_shared() {
let mut nodes = vec![node("a"), node("b"), node("mid"), node("deep")];
let mut edges = vec![edge("a", "mid"), edge("b", "mid"), edge("mid", "deep")];
split_shared_leaves(&mut nodes, &mut edges);
assert_eq!(nodes.len(), 4, "nothing should have been copied");
assert_eq!(edges[0].to, "mid");
assert_eq!(edges[1].to, "mid");
}
#[test]
fn test_a_leaf_with_one_caller_is_untouched() {
let mut nodes = vec![node("a"), node("leaf")];
let mut edges = vec![edge("a", "leaf")];
split_shared_leaves(&mut nodes, &mut edges);
assert_eq!(nodes.len(), 2);
assert_eq!(edges[0].to, "leaf");
}
}
const READABLE_SCREENSHOTS: usize = 45;
fn cut_edge(nodes: &mut Vec<GraphNode>, edges: &mut Vec<GraphEdge>, index: usize) {
let Some(label) = nodes
.iter()
.find(|node| node.id == edges[index].to)
.map(|node| node.label.clone())
else {
return;
};
let card_id = format!("\u{0}link{index}");
nodes.push(GraphNode {
id: card_id.clone(),
label: label.clone(),
kind: NodeKind::Link { target: label },
file_path: String::new(),
start_line: 0,
end_line: 0,
depth: 0,
font_size: 22,
png_path: String::new(),
png_width: LINK_CARD_WIDTH as u32,
png_height: LINK_CARD_HEIGHT as u32,
rendered_lines: Vec::new(),
line_offset: 0,
writes_storage: false,
write_lines: Vec::new(),
external_call_lines: Vec::new(),
});
edges[index].to = card_id;
prune_unreachable(nodes, edges);
}
fn screenshot_count(nodes: &[GraphNode]) -> usize {
nodes
.iter()
.filter(|node| node.kind == NodeKind::Screenshot)
.count()
}
fn best_cut(
nodes: &[GraphNode],
edges: &[GraphEdge],
) -> Option<(Vec<GraphNode>, Vec<GraphEdge>)> {
let has_children: HashSet<&str> = edges.iter().map(|edge| edge.from.as_str()).collect();
let before = screenshot_count(nodes);
let mut best: Option<(usize, Vec<GraphNode>, Vec<GraphEdge>)> = None;
for (index, edge) in edges.iter().enumerate() {
if !has_children.contains(edge.to.as_str()) {
continue;
}
let mut candidate_nodes = nodes.to_vec();
let mut candidate_edges = edges.to_vec();
cut_edge(&mut candidate_nodes, &mut candidate_edges, index);
let saved = before.saturating_sub(screenshot_count(&candidate_nodes));
if saved == 0 {
continue;
}
if best.as_ref().map(|(most, _, _)| saved > *most).unwrap_or(true) {
best = Some((saved, candidate_nodes, candidate_edges));
}
}
best.map(|(_, nodes, edges)| (nodes, edges))
}
fn prune_unreachable(nodes: &mut Vec<GraphNode>, edges: &mut Vec<GraphEdge>) {
let Some(root) = nodes.first().map(|node| node.id.clone()) else {
return;
};
let mut adjacency: HashMap<&str, Vec<&str>> = HashMap::new();
for edge in edges.iter() {
adjacency
.entry(edge.from.as_str())
.or_default()
.push(edge.to.as_str());
}
let mut reachable: HashSet<String> = HashSet::new();
let mut stack = vec![root];
while let Some(id) = stack.pop() {
if !reachable.insert(id.clone()) {
continue;
}
for next in adjacency.get(id.as_str()).cloned().unwrap_or_default() {
stack.push(next.to_string());
}
}
nodes.retain(|node| reachable.contains(&node.id));
edges.retain(|edge| reachable.contains(&edge.from) && reachable.contains(&edge.to));
}
#[cfg(test)]
mod cut_test {
use super::*;
use crate::batbelt::miro::layout::{layout_graph, LayoutConfig, LayoutEdge, LayoutNode};
fn node(id: &str) -> GraphNode {
GraphNode {
id: id.to_string(),
label: id.to_string(),
kind: NodeKind::Screenshot,
file_path: String::new(),
start_line: 1,
end_line: 2,
depth: 0,
font_size: 22,
png_path: String::new(),
png_width: 1000,
png_height: 300,
rendered_lines: Vec::new(),
line_offset: 0,
writes_storage: false,
write_lines: Vec::new(),
external_call_lines: Vec::new(),
}
}
fn edge(from: &str, to: &str) -> GraphEdge {
GraphEdge {
from: from.to_string(),
to: to.to_string(),
line_in_slice: 1,
column: 0,
symbol: to.to_string(),
}
}
fn lay(nodes: &[GraphNode], edges: &[GraphEdge]) -> GraphLayout {
let layout_nodes: Vec<LayoutNode> = nodes
.iter()
.map(|n| LayoutNode {
id: n.id.clone(),
width: n.board_width(),
height: n.board_height(),
})
.collect();
let layout_edges: Vec<LayoutEdge> = edges
.iter()
.map(|e| LayoutEdge {
from: e.from.clone(),
to: e.to.clone(),
from_line_fraction: 0.5,
})
.collect();
layout_graph(
&nodes[0].id,
&layout_nodes,
&layout_edges,
LayoutConfig::default(),
)
}
#[test]
fn test_a_cut_that_saves_nothing_is_refused() {
let nodes = vec![
node("root"),
node("far"),
node("near"),
node("shared"),
node("child"),
];
let edges = vec![
edge("root", "far"),
edge("far", "near"),
edge("near", "shared"),
edge("far", "shared"),
edge("shared", "child"),
];
if let Some((cut_nodes, _)) = best_cut(&nodes, &edges) {
assert!(
screenshot_count(&cut_nodes) < screenshot_count(&nodes),
"a cut that is taken has to free something"
);
}
}
#[test]
fn test_a_long_call_always_has_a_nearer_caller() {
for extra in 0..4 {
let mut nodes = vec![node("root"), node("mid"), node("target"), node("child")];
let mut edges = vec![
edge("root", "mid"),
edge("root", "target"),
edge("mid", "target"),
edge("target", "child"),
];
for step in 0..extra {
let id = format!("step{step}");
nodes.push(node(&id));
edges.push(edge("mid", &id));
edges.push(edge(&id, "target"));
}
let long = edges
.iter()
.position(|e| e.from == "root" && e.to == "target")
.expect("the long call");
let mut cut_nodes = nodes.clone();
let mut cut_edges = edges.clone();
cut_edge(&mut cut_nodes, &mut cut_edges, long);
assert_eq!(
screenshot_count(&cut_nodes),
screenshot_count(&nodes),
"with {extra} extra hops, cutting the long call freed a screenshot"
);
}
}
#[test]
fn test_a_clean_graph_has_no_candidate() {
let nodes = vec![node("root"), node("a"), node("b")];
let edges = vec![edge("root", "a"), edge("a", "b")];
let (cut_nodes, _) = best_cut(&nodes, &edges).expect("cutting root->a strands b");
assert!(screenshot_count(&cut_nodes) < screenshot_count(&nodes));
}
}
async fn ensure_target_frames(
nodes: &[GraphNode],
options: &AutoDeployOptions,
client: &MiroClient,
allocator: &mut ShelfAllocator,
) -> Result<HashMap<String, String>> {
let wanted: Vec<String> = {
let mut seen = HashSet::new();
nodes
.iter()
.filter_map(|node| match &node.kind {
NodeKind::Link { target } => Some(target.clone()),
NodeKind::Screenshot => None,
})
.filter(|target| seen.insert(target.clone()))
.collect()
};
if wanted.is_empty() {
return Ok(HashMap::new());
}
let mut resolved = HashMap::new();
for target in wanted {
if let Some(url) = live_frame_url(&target, Some(client)).await? {
println!(" {} reuses its frame", target.blue());
resolved.insert(target, url);
continue;
}
let metadata = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
let Some((contract, function)) = target.split_once('.') else {
continue;
};
println!(" {} needs a frame of its own, deploying it first", target.blue());
Box::pin(deploy_one(
&metadata,
contract,
function,
options,
Some(client),
allocator,
false,
))
.await?;
let metadata = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
if let Some(created) = metadata
.miro
.auto
.frames
.iter()
.find(|frame| frame.entry_point == target)
{
resolved.insert(target, created.frame_url.clone());
}
}
Ok(resolved)
}
async fn live_frame_url(title: &str, client: Option<&MiroClient>) -> Result<Option<String>> {
let metadata = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
let Some(record) = metadata
.miro
.auto
.frames
.iter()
.find(|frame| frame.entry_point == title)
else {
return Ok(None);
};
let (frame_id, url) = (record.frame_id.clone(), record.frame_url.clone());
let Some(client) = client else {
return Ok(Some(url));
};
if client.item_exists(&frame_id).await {
return Ok(Some(url));
}
println!(
" {} the frame recorded for {} is gone from the board; forgetting it",
"note:".yellow(),
title
);
let owner = title.to_string();
EvmBatMetadata::update_metadata(move |metadata| {
metadata
.miro
.auto
.frames
.retain(|frame| frame.entry_point != owner);
})
.change_context(EvmMiroError)?;
Ok(None)
}