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 rand::seq::SliceRandom;
use rayon::prelude::*;
type Result<T> = error_stack::Result<T, EvmMiroError>;
const ANCHOR_GAP_CHARS: f64 = 1.0;
pub(crate) const SIGNATURE_LINE_INDEX: usize = 2;
pub(crate) const PATH_HEADER_LINES: usize = 2;
const REGION_MARGIN: f64 = 5_000.0;
pub(crate) const ANCHOR_MARKER_SIZE: f64 = 24.0;
const LANE_PITCH: f64 = 40.0;
const FRAME_FILL_WRITES: &str = "#fff0ef";
const FRAME_FILL_MAY_WRITE: &str = "#fff7ec";
const MAX_COPIES_OF_ONE_CALLEE: usize = 3;
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 dry_run: bool,
pub with_documentation: bool,
pub preview: Option<String>,
pub stroke_width: u32,
pub allow_unresolved: bool,
pub assume_yes: bool,
pub ignore_contracts: Vec<String>,
pub inline_all: bool,
}
impl Default for AutoDeployOptions {
fn default() -> Self {
Self {
entry_point: None,
dry_run: false,
with_documentation: false,
preview: None,
stroke_width: 8,
allow_unresolved: false,
assume_yes: false,
ignore_contracts: Vec::new(),
inline_all: false,
}
}
}
#[derive(Clone)]
struct ClusterCtx {
root: String,
stale_ids: HashSet<String>,
}
#[derive(Debug, Clone, PartialEq)]
enum NodeKind {
Screenshot,
Link {
target: String,
file: 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>,
leads_to_write: bool,
write_call_lines: Vec<(usize, String, String)>,
scale: f64,
}
impl GraphNode {
fn board_width(&self) -> f64 {
self.png_width as f64 * BOARD_UNITS_PER_PIXEL * self.scale
}
fn board_height(&self) -> f64 {
self.png_height as f64 * BOARD_UNITS_PER_PIXEL * self.scale
}
}
#[derive(Debug, Clone)]
struct GraphEdge {
from: String,
to: String,
line_in_slice: usize,
column: usize,
symbol: String,
}
pub(crate) const BOARD_UNITS_PER_PIXEL: f64 = 1.0;
pub(crate) const REFERENCE_FONT: usize = 32;
fn font_for_depth(depth: usize) -> usize {
match depth {
0 => 32,
1 => 26,
_ => 22,
}
}
fn scale_for_depth(depth: usize) -> f64 {
font_for_depth(depth) as f64 / REFERENCE_FONT as f64
}
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"));
}
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)?,
)
};
if !options.dry_run {
EvmBatMetadata::update_metadata(|m| m.miro.auto.region = None)
.change_context(EvmMiroError)?;
}
let metadata = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
let mut options = options;
for pattern in &metadata.ignored_contracts {
if !options.ignore_contracts.contains(pattern) {
options.ignore_contracts.push(pattern.clone());
}
}
let options = options;
if !options.ignore_contracts.is_empty() {
println!(
" {} not drawing: {}",
"note:".yellow(),
options.ignore_contracts.join(", ")
);
}
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, root_file) in targets {
let title = format!("{contract_name}.{function_name}");
let stale_ids: HashSet<String> = {
let meta = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
meta.miro
.auto
.frames
.iter()
.filter(|f| f.cluster_root == title)
.map(|f| f.frame_id.clone())
.collect()
};
let cluster = ClusterCtx {
root: title.clone(),
stale_ids,
};
let previous: Vec<String> = {
let meta = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
meta.miro
.auto
.frames
.iter()
.filter(|f| f.cluster_root == title)
.map(|f| f.entry_point.clone())
.collect()
};
if !previous.is_empty() && !options.dry_run && !options.assume_yes {
println!(
" {} {} already has a deployment of {} frame(s). Deploying again draws a NEW\n cluster and leaves the old frames on the board for you to delete.",
"note:".yellow(),
title.bold(),
previous.len()
);
if !BatDialoguer::select_yes_or_no("Deploy it again?".to_string())
.change_context(EvmMiroError)?
{
continue;
}
}
let root_options = options.clone();
deploy_one(
&metadata,
&contract_name,
&function_name,
&root_file,
&root_options,
client.as_ref(),
&mut allocator,
true,
&cluster,
)
.await?;
if !options.dry_run {
let old_ids: HashSet<String> = cluster.stale_ids.clone();
EvmBatMetadata::update_metadata(move |m| {
m.miro.auto.frames.retain(|f| !old_ids.contains(&f.frame_id));
})
.change_context(EvmMiroError)?;
}
if !options.dry_run {
let state = ShelfState::from(&allocator);
EvmBatMetadata::update_metadata(|m| m.miro.auto.region = Some(state.clone()))
.change_context(EvmMiroError)?;
}
}
if !options.dry_run {
if let Ok(dir) = BatFolder::Figures.get_path(false) {
let _ = std::fs::remove_dir_all(&dir);
}
}
Ok(())
}
fn select_targets(
metadata: &EvmBatMetadata,
options: &AutoDeployOptions,
) -> Result<Vec<(String, String, String)>> {
let entry_names: HashSet<(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();
let mut entry_points: Vec<(String, String, String)> = Vec::new();
let mut others: Vec<(String, String, String)> = Vec::new();
for contract in metadata.contracts.iter().filter(|c| !c.external) {
for function in &contract.functions {
let target = (
contract.name.clone(),
function.name.clone(),
contract.file_path.clone(),
);
if entry_names.contains(&(contract.name.clone(), function.name.clone())) {
entry_points.push(target);
} else if !function.is_constructor {
others.push(target);
}
}
}
entry_points.sort();
entry_points.dedup();
others.sort();
others.dedup();
if let Some(wanted) = &options.entry_point {
return resolve_named_target(metadata, &entry_names, wanted);
}
let deployed: HashSet<String> = metadata
.miro
.auto
.frames
.iter()
.map(|frame| frame.entry_point.clone())
.collect();
let all: Vec<(String, String, String)> = entry_points
.iter()
.cloned()
.chain(others.iter().cloned())
.collect();
if all.is_empty() {
return Ok(all);
}
let mut title_count: HashMap<String, usize> = HashMap::new();
for (contract, function, _) in &all {
*title_count.entry(format!("{contract}.{function}")).or_default() += 1;
}
let entry_point_count = entry_points.len();
let labels: Vec<String> = all
.iter()
.enumerate()
.map(|(index, (contract, function, file))| {
let title = format!("{contract}.{function}");
let mut label = if title_count[&title] > 1 {
format!("{title} ({file})")
} else {
title.clone()
};
if index < entry_point_count {
label = format!("{label} {}", "[entry point]".blue());
}
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()])
}
fn resolve_named_target(
metadata: &EvmBatMetadata,
entry_names: &HashSet<(String, String)>,
wanted: &str,
) -> Result<Vec<(String, String, String)>> {
use crate::batbelt::evm::parser::import_graph::{normalize, reachable_from};
let (wanted_path, rest) = match wanted.rsplit_once(':') {
Some((path, rest)) if path.ends_with(".sol") => (Some(normalize(path)), rest),
_ => (None, wanted),
};
let (wanted_contract, wanted_function) = match rest.rsplit_once('.') {
Some((contract, function)) => (Some(contract), function),
None => (None, rest),
};
let mut matches: Vec<(String, String, String, bool, bool)> = Vec::new();
for contract in &metadata.contracts {
if contract.contract_type == EvmContractType::Interface {
continue;
}
if wanted_contract.is_some_and(|name| name != contract.name) {
continue;
}
if let Some(path) = &wanted_path {
let file = normalize(&contract.file_path);
if file != *path && !file.ends_with(&format!("/{path}")) {
continue;
}
}
for function in contract.functions.iter().filter(|f| f.name == wanted_function) {
let entry = !contract.external
&& entry_names.contains(&(contract.name.clone(), function.name.clone()));
matches.push((
contract.name.clone(),
function.name.clone(),
contract.file_path.clone(),
contract.external,
entry,
));
}
}
matches.sort();
matches.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1 && a.2 == b.2);
if matches.is_empty() {
return Ok(Vec::new());
}
let tier: Vec<&(String, String, String, bool, bool)> = {
let entry: Vec<_> = matches.iter().filter(|m| m.4).collect();
let own: Vec<_> = matches.iter().filter(|m| !m.3).collect();
if !entry.is_empty() {
entry
} else if !own.is_empty() {
own
} else {
matches.iter().collect()
}
};
let pick = |m: &(String, String, String, bool, bool)| vec![(m.0.clone(), m.1.clone(), m.2.clone())];
if tier.len() == 1 {
return Ok(pick(tier[0]));
}
let reachable = reachable_from(
metadata
.contracts
.iter()
.filter(|c| !c.external)
.map(|c| c.file_path.as_str()),
);
let narrowed: Vec<_> = tier
.iter()
.filter(|m| reachable.contains(&normalize(&m.2)))
.copied()
.collect();
if narrowed.len() == 1 {
return Ok(pick(narrowed[0]));
}
let listed = if narrowed.is_empty() { &tier } else { &narrowed };
let candidates = listed
.iter()
.map(|m| format!("{}:{}.{}", normalize(&m.2), m.0, m.1))
.collect::<Vec<_>>();
let why = if narrowed.is_empty() {
"nothing in the audited code imports any of them, so the code cannot decide"
} else {
"the audited code reaches more than one of them"
};
Err(Report::new(EvmMiroError)
.attach_printable(format!(
"`{wanted}` matches {} functions — {why}:\n {}",
listed.len(),
candidates.join("\n ")
))
.attach(crate::Suggestion(format!(
"pick one and pass it whole, e.g. `bat-cli deploy --entry-point {}`",
candidates[0]
))))
}
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,
root_file: &str,
options: &AutoDeployOptions,
client: Option<&MiroClient>,
allocator: &mut ShelfAllocator,
is_primary: bool,
cluster: &ClusterCtx,
) -> Result<()> {
let title = format!("{contract_name}.{function_name}");
println!("\n{} {}", "▸".blue(), title.bold());
if metadata.contracts.iter().filter(|c| c.name == contract_name).count() > 1 {
println!(" {} {}", "from".dimmed(), root_file);
}
let (mut nodes, mut edges, unresolved) =
build_graph(metadata, contract_name, function_name, root_file, options)?;
if nodes.is_empty() {
println!(" no function metadata found, skipping");
return Ok(());
}
let deployed_titles: HashSet<String> = if cluster.root.is_empty() {
HashSet::new()
} else {
let meta = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
let in_graph: HashSet<&str> = nodes.iter().map(|node| node.label.as_str()).collect();
meta.miro
.auto
.frames
.iter()
.filter(|frame| {
frame.cluster_root == cluster.root
&& !cluster.stale_ids.contains(&frame.frame_id)
&& frame.entry_point != title
&& in_graph.contains(frame.entry_point.as_str())
})
.map(|frame| frame.entry_point.clone())
.collect()
};
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()
);
let reuse: HashMap<String, (String, u32, u32)> = HashMap::new();
render_and_measure(&mut nodes, &title, &reuse)?;
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 framed: HashSet<&str> = deployed_titles.iter().map(|s| s.as_str()).collect();
let total = screenshot_count(&nodes);
let mut budget = FRAME_TARGET.max(total.div_ceil(MAX_CUTS_PER_FRAME + 1));
let mut anchors = anchors;
let cut_passes = if options.inline_all { 0 } else { nodes.len() };
for _ in 0..cut_passes {
if effective_size(&nodes) <= FRAME_MAX {
break;
}
let mut found = None;
while found.is_none() {
found = best_cut(&nodes, &edges, &framed, budget);
if found.is_some() || budget <= FRAME_MIN {
break;
}
budget = (budget * 3 / 4).max(FRAME_MIN);
}
let Some((cut_nodes, cut_edges)) = found else {
println!(
" {} {} screenshots and nothing left that can be cut into a readable\n frame — every branch is either shared or too small to be worth its own",
"warning:".yellow(),
screenshot_count(&nodes)
);
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());
}
if effective_size(&nodes) <= FRAME_MAX {
let cut_crossers = if options.inline_all {
0
} else {
cut_crossing_shared(&mut nodes, &mut edges, &root_id)
};
if cut_crossers > 0 {
println!(
" {} {} call(s) flying over a column replaced by a card to the callee's frame",
"↳".blue(),
cut_crossers
);
}
let before = screenshot_count(&nodes);
duplicate_crossing_shared(&mut nodes, &mut edges, &root_id);
if screenshot_count(&nodes) > before {
println!(
" {} localized {} crossing helper copy(ies)",
"↳".blue(),
screenshot_count(&nodes) - before
);
}
anchors = compute_anchors(&nodes, &edges);
let ln: Vec<LayoutNode> = nodes
.iter()
.map(|node| LayoutNode {
id: node.id.clone(),
width: node.board_width(),
height: node.board_height(),
})
.collect();
let le: 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, &ln, &le, LayoutConfig::default());
}
let by_id: HashMap<&str, &GraphNode> = nodes.iter().map(|n| (n.id.as_str(), n)).collect();
let (frame_x, frame_y) = allocator.place(layout.frame_width, layout.frame_height);
if let Some(preview_path) = &options.preview {
let path = preview_path.clone();
render_preview(&nodes, &edges, &anchors, &layout, &path)?;
println!(" preview written to {}", path.blue());
cleanup(&nodes);
return Ok(());
}
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_fill = if nodes
.iter()
.any(|n| n.writes_storage || !n.write_call_lines.is_empty())
{
Some(FRAME_FILL_WRITES)
} else if nodes.iter().any(|n| !n.external_call_lines.is_empty()) {
Some(FRAME_FILL_MAY_WRITE)
} else {
None
};
let frame_id = client
.create_frame(
&format!("auto: {title}"),
frame_x,
frame_y,
layout.frame_width,
layout.frame_height,
frame_fill,
)
.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, cluster).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(),
image_dims: Vec::new(),
node_positions: Vec::new(),
callee_connectors: Vec::new(),
link_cards: Vec::new(),
connector_ids: Vec::new(),
marker_ids: Vec::new(),
border_ids: Vec::new(),
screenshots: Vec::new(),
cluster_root: if cluster.root.is_empty() { title.clone() } else { cluster.root.clone() },
};
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 reused_image = reuse.get(&node.id).map(|(image_id, _, _)| image_id.clone());
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 => match reused_image {
Some(image_id) => client
.update_item_position(&image_id, x, y)
.await
.map(|_| image_id),
None => {
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 drawn_screens = drawn_screen_ids(&nodes);
let frame_writes: Vec<&GraphNode> = nodes
.iter()
.filter(|n| n.writes_storage || surviving_write_calls(n, &drawn_screens).next().is_some())
.collect();
let frame_external: Vec<&GraphNode> = nodes
.iter()
.filter(|n| !n.external_call_lines.is_empty())
.collect();
let borders: Vec<(f64, f64, f64, f64)> = nodes
.iter()
.filter(|n| {
n.writes_storage || surviving_write_calls(n, &drawn_screens).next().is_some()
})
.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.png_height == 0 {
continue;
}
let surviving: Vec<usize> = surviving_write_calls(node, &drawn_screens)
.map(|(line, _, _)| *line)
.collect();
if node.write_lines.is_empty() && surviving.is_empty() {
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)
.chain(surviving.iter().copied())
.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,
lane_x: f64,
end_y: f64,
node_id: String,
end_anchor: RelativeAnchor,
end_point: (f64, f64),
color: String,
}
struct PendingGroup {
token_x: f64,
token_y: f64,
edge_x: f64,
exit_right: bool,
style: ConnectorStyle,
callees: Vec<CalleeLink>,
}
let lane_of: HashMap<(String, String, usize), f64> = {
let mut per_layer_right: HashMap<usize, f64> = HashMap::new();
let mut per_layer_left: HashMap<usize, f64> = HashMap::new();
for placed in &layout.nodes {
let right = placed.x + placed.width / 2.0;
let left = placed.x - placed.width / 2.0;
per_layer_right
.entry(placed.layer)
.and_modify(|value| *value = value.max(right))
.or_insert(right);
per_layer_left
.entry(placed.layer)
.and_modify(|value| *value = value.min(left))
.or_insert(left);
}
let mut per_gap: HashMap<usize, Vec<((String, String, usize), f64, f64)>> = HashMap::new();
for edge in edges.iter() {
let (Some(from), Some(to)) = (layout.node(&edge.from), layout.node(&edge.to)) else {
continue;
};
if to.layer <= from.layer {
continue; }
let start_y = from.y;
let end_y = to.y;
per_gap.entry(from.layer).or_default().push((
(edge.from.clone(), edge.to.clone(), edge.line_in_slice),
start_y,
end_y,
));
}
let mut lanes = HashMap::new();
for (layer, mut arrows) in per_gap {
let right = per_layer_right.get(&layer).copied().unwrap_or(0.0);
let left = per_layer_left
.get(&(layer + 1))
.copied()
.unwrap_or(right + 550.0);
let margin = 100.0_f64.min((left - right) / 4.0);
let usable = (left - right - 2.0 * margin).max(0.0);
let pitch = if arrows.len() > 1 {
(usable / (arrows.len() - 1) as f64).min(LANE_PITCH)
} else {
0.0
};
arrows.sort_by(|a, b| {
let down = |start: f64, end: f64| end >= start;
let (a_down, b_down) = (down(a.1, a.2), down(b.1, b.2));
a_down
.cmp(&b_down)
.then_with(|| {
let key = |arrow: &((String, String, usize), f64, f64)| {
if a_down { (-arrow.1, -arrow.2) } else { (arrow.1, arrow.2) }
};
key(a).partial_cmp(&key(b)).unwrap_or(std::cmp::Ordering::Equal)
})
});
for (index, (key, _, _)) in arrows.into_iter().enumerate() {
lanes.insert(key, right + margin + index as f64 * pitch);
}
}
lanes
};
let edge_color: Vec<String> = {
let lane_index: HashMap<usize, (usize, f64)> = edges
.iter()
.enumerate()
.filter_map(|(index, edge)| {
let from = layout.node(&edge.from)?;
let lane = lane_of.get(&(edge.from.clone(), edge.to.clone(), edge.line_in_slice))?;
Some((index, (from.layer, *lane)))
})
.collect();
let mut order: Vec<usize> = (0..edges.len()).collect();
order.sort_by(|a, b| {
let key = |index: &usize| lane_index.get(index).copied().unwrap_or((usize::MAX, 0.0));
let (la, xa) = key(a);
let (lb, xb) = key(b);
la.cmp(&lb)
.then(xa.partial_cmp(&xb).unwrap_or(std::cmp::Ordering::Equal))
.then(a.cmp(b))
});
const LANE_NEIGHBOURHOOD: usize = 2;
let mut per_gap: HashMap<usize, Vec<usize>> = HashMap::new();
for index in &order {
if let Some((layer, _)) = lane_index.get(index) {
per_gap.entry(*layer).or_default().push(*index);
}
}
let mut conflicts: Vec<HashSet<usize>> = vec![HashSet::new(); edges.len()];
for arrows in per_gap.values() {
for (position, index) in arrows.iter().enumerate() {
let lower = position.saturating_sub(LANE_NEIGHBOURHOOD);
let upper = (position + LANE_NEIGHBOURHOOD + 1).min(arrows.len());
for other in &arrows[lower..upper] {
if other != index && edges[*other].to != edges[*index].to {
conflicts[*index].insert(*other);
conflicts[*other].insert(*index);
}
}
}
}
for (index, edge) in edges.iter().enumerate() {
for (other, sibling) in edges.iter().enumerate() {
if other != index && sibling.from == edge.from && sibling.to != edge.to {
conflicts[index].insert(other);
}
}
}
let mut column: HashMap<usize, Vec<(f64, String)>> = HashMap::new();
for placed in &layout.nodes {
column
.entry(placed.layer)
.or_default()
.push((placed.y, placed.id.clone()));
}
let mut neighbour_of: HashMap<&str, HashSet<String>> = HashMap::new();
for boxes in column.values_mut() {
boxes.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
for (position, (_, id)) in boxes.iter().enumerate() {
let lower = position.saturating_sub(1);
let upper = (position + 2).min(boxes.len());
neighbour_of.insert(
id.as_str(),
boxes[lower..upper]
.iter()
.filter(|(_, other)| other != id)
.map(|(_, other)| other.clone())
.collect(),
);
}
}
for (index, edge) in edges.iter().enumerate() {
let Some(neighbours) = neighbour_of.get(edge.to.as_str()) else {
continue;
};
for (other, sibling) in edges.iter().enumerate() {
if other != index && neighbours.contains(&sibling.to) {
conflicts[index].insert(other);
conflicts[other].insert(index);
}
}
}
let mut palette_of: HashMap<usize, Vec<usize>> = HashMap::new();
let mut colors: Vec<Option<usize>> = vec![None; edges.len()];
let mut by_callee: HashMap<&str, usize> = HashMap::new();
for index in order {
if let Some(taken) = by_callee.get(edges[index].to.as_str()) {
colors[index] = Some(*taken);
continue;
}
let used: HashSet<usize> = conflicts[index]
.iter()
.filter_map(|other| colors[*other])
.collect();
let gutter = lane_index.get(&index).map(|(layer, _)| *layer).unwrap_or(usize::MAX);
let palette = palette_of.entry(gutter).or_insert_with(|| {
let mut order: Vec<usize> = (0..DEPTH_COLORS.len()).collect();
order.shuffle(&mut rand::thread_rng());
order
});
let choice = palette
.iter()
.copied()
.find(|color| !used.contains(color))
.unwrap_or(index % DEPTH_COLORS.len());
colors[index] = Some(choice);
by_callee.insert(edges[index].to.as_str(), choice);
}
colors
.into_iter()
.map(|color| DEPTH_COLORS[color.unwrap_or(0)].to_string())
.collect()
};
let callee_color: HashMap<String, String> = edges
.iter()
.enumerate()
.map(|(index, edge)| (edge.to.clone(), edge_color[index].clone()))
.collect();
let mut groups: HashMap<(String, usize, bool), PendingGroup> = HashMap::new();
for edge in edges.iter() {
let edge_key = (edge.from.clone(), edge.to.clone(), edge.line_in_slice);
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(),
lane_x: lane_of.get(&edge_key).copied().unwrap_or(f64::NAN),
end_y: end_point.1,
node_id: edge.to.clone(),
end_anchor: RelativeAnchor::new(if exit_right { 0.0 } else { 1.0 }, callee_fraction),
end_point,
color: callee_color
.get(&edge.to)
.cloned()
.unwrap_or_else(|| DEPTH_COLORS[0].to_string()),
};
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 anchor = line_anchor(
(
caller_placed.x,
caller_placed.y,
caller_placed.width,
caller_placed.height,
),
caller.png_width,
caller.png_height,
caller.font_size,
&caller.rendered_lines,
caller.line_offset,
edge.line_in_slice.saturating_sub(1) + PATH_HEADER_LINES,
exit_right,
);
let (token_x, token_y, edge_x) = (anchor.token_x, anchor.token_y, anchor.edge_x);
PendingGroup {
token_x,
token_y,
edge_x,
exit_right,
style: ConnectorStyle {
stroke_color: callee_color
.get(&edge.to)
.cloned()
.unwrap_or_else(|| DEPTH_COLORS[0].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();
let owner = group.callees.first().map(|link| link.node_id.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 lane_routed = group.exit_right && group.callees.iter().all(|link| link.lane_x.is_finite());
if !lane_routed {
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;
route_style.stroke_color = link.color.clone();
if !group.exit_right || !link.lane_x.is_finite() {
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?,
);
continue;
}
let lane_top = client
.create_anchor_marker(&frame_id, link.lane_x, group.token_y, ANCHOR_MARKER_SIZE)
.await?;
let lane_end = client
.create_anchor_marker(&frame_id, link.lane_x, link.end_y, ANCHOR_MARKER_SIZE)
.await?;
markers.push(lane_top.clone());
markers.push(lane_end.clone());
let mut head_style = route_style.clone();
head_style.arrow = ArrowEnd::End;
connectors.push(
client
.create_connector(
&lane_top,
RelativeAnchor::new(0.0, 0.5),
&token_marker,
RelativeAnchor::new(1.0, 0.5),
head_style,
)
.await?,
);
let (top_side, end_side) = if link.end_y >= group.token_y {
(RelativeAnchor::new(0.5, 1.0), RelativeAnchor::new(0.5, 0.0))
} else {
(RelativeAnchor::new(0.5, 0.0), RelativeAnchor::new(0.5, 1.0))
};
connectors.push(
client
.create_connector(&lane_top, top_side, &lane_end, end_side, route_style.clone())
.await?,
);
connectors.push(
client
.create_connector(
&link.end_id,
link.end_anchor,
&lane_end,
RelativeAnchor::new(1.0, 0.5),
route_style,
)
.await?,
);
}
bar.inc(1);
Ok::<_, error_stack::Report<crate::batbelt::miro::MiroError>>((owner, markers, connectors))
});
}
let mut connector_ids = Vec::new();
let mut marker_ids = Vec::new();
let mut callee_owned: HashMap<String, Vec<String>> = HashMap::new();
while let Some(joined) = connector_tasks.join_next().await {
let (owner, markers, connectors) = joined
.into_report()
.change_context(EvmMiroError)?
.change_context(EvmMiroError)?;
if let Some(owner) = owner {
let bucket = callee_owned.entry(owner).or_default();
bucket.extend(markers.iter().cloned());
bucket.extend(connectors.iter().cloned());
}
marker_ids.extend(markers);
connector_ids.extend(connectors);
}
bar.finish_and_clear();
println!(" {} {} connector(s)", "✓".green(), connector_ids.len());
record.image_dims = nodes
.iter()
.filter(|node| node.kind == NodeKind::Screenshot && image_ids.contains_key(&node.id))
.map(|node| (node.id.clone(), node.png_width, node.png_height))
.collect();
record.node_positions = nodes
.iter()
.filter_map(|node| layout.node(&node.id).map(|placed| (node.id.clone(), placed.x, placed.y)))
.collect();
record.callee_connectors = callee_owned.into_iter().collect();
record.link_cards = nodes
.iter()
.filter_map(|node| match &node.kind {
NodeKind::Link { .. } => {
let target_id = node.label.replacen('.', "::", 1);
image_ids
.get(&node.id)
.map(|card_id| (target_id, card_id.clone(), String::new()))
}
NodeKind::Screenshot => None,
})
.collect();
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());
Ok(())
}
pub(crate) fn save_frame_record(record: &AutoDeployedFrame) -> Result<()> {
let record = record.clone();
EvmBatMetadata::update_metadata(move |metadata| {
metadata.miro.auto.frames.retain(|frame| {
frame.cluster_root != record.cluster_root || 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 ignored_contract(options: &AutoDeployOptions, contract: &ContractMetadata) -> bool {
options.ignore_contracts.iter().any(|pattern| {
let pattern = pattern.trim();
!pattern.is_empty()
&& (contract.name == pattern || contract.file_path.contains(pattern))
})
}
fn build_graph(
metadata: &EvmBatMetadata,
contract_name: &str,
function_name: &str,
root_file: &str,
options: &AutoDeployOptions,
) -> Result<(
Vec<GraphNode>,
Vec<GraphEdge>,
Vec<crate::batbelt::evm::metadata::bat_metadata::UnresolvedCall>,
)> {
let Some((root_contract, root_function)) =
find_function(metadata, contract_name, root_file, function_name, None)
else {
return Ok((Vec::new(), Vec::new(), Vec::new()));
};
let mut definer_map: HashMap<String, Vec<String>> = HashMap::new();
for contract in &metadata.contracts {
if 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 write_options = options.clone();
let mut write_definer_map: HashMap<String, Vec<String>> = HashMap::new();
for contract in &metadata.contracts {
if contract.contract_type == EvmContractType::Interface {
continue;
}
for function in &contract.functions {
if !function.is_stub {
write_definer_map
.entry(function.name.clone())
.or_default()
.push(contract.name.clone());
}
}
}
let mut write_memo: HashMap<String, bool> = HashMap::new();
let mut nodes: Vec<GraphNode> = Vec::new();
let mut edges: Vec<GraphEdge> = Vec::new();
let mut left_out_reads: std::collections::BTreeMap<(String, String), (Vec<String>, Vec<String>)> =
std::collections::BTreeMap::new();
let mut unresolved: Vec<crate::batbelt::evm::metadata::bat_metadata::UnresolvedCall> =
Vec::new();
let mut drawn: HashMap<String, String> = HashMap::new();
let root_id = overload_node_key(root_contract, &root_function);
drawn.insert(root_id.clone(), root_id.clone());
let mut root_node = make_node(
root_id.clone(),
format!("{contract_name}.{function_name}"),
root_contract,
&root_function,
0,
doc_lines_above(options, &root_contract.file_path, root_function.line),
);
root_node.leads_to_write = leads_to_write(
metadata,
root_contract,
&root_function,
&write_options,
&write_definer_map,
&mut write_memo,
&mut HashSet::new(),
);
nodes.push(root_node);
struct Pending {
node_id: String,
contract: String,
file: String,
function: String,
line: usize,
depth: usize,
}
let mut stack = vec![Pending {
node_id: root_id,
contract: root_contract.name.clone(),
file: root_contract.file_path.clone(),
function: function_name.to_string(),
line: root_function.line,
depth: 0,
}];
while let Some(current) = stack.pop() {
let Some((contract, function)) =
find_function_at(
metadata,
¤t.contract,
¤t.file,
¤t.function,
current.line,
)
else {
continue;
};
let slice = read_slice(
&contract.file_path,
function.line,
function_end(&function, contract),
);
let doc_shift = doc_lines_above(options, &contract.file_path, function.line);
let mut children: Vec<Pending> = Vec::new();
let mut caller_write_calls: Vec<(usize, String, String)> = Vec::new();
let mut lib_boundary_lines: Vec<usize> = Vec::new();
for modifier_name in &function.modifiers {
let Some((owner, definition)) =
find_modifier(metadata, ¤t.contract, ¤t.file, modifier_name)
else {
continue;
};
let line_in_slice = slice
.iter()
.position(|line| line_has_token(line, 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: line_in_slice + doc_shift,
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,
current.depth + 1,
doc_lines_above(options, &owner.file_path, definition.line),
));
}
}
for call in extract_call_sites_from_source(&body_only(&slice).join("\n")) {
let arity = (call.arg_count != usize::MAX).then_some(call.arg_count);
if let Some((reached_contract, reached_function)) = resolve_call(
metadata,
contract,
&call.name,
arity,
&write_options,
&write_definer_map,
) {
if leads_to_write(
metadata,
reached_contract,
&reached_function,
&write_options,
&write_definer_map,
&mut write_memo,
&mut HashSet::new(),
) {
let callee_id = resolve_call(
metadata,
contract,
&call.name,
arity,
options,
&definer_map,
)
.map(|(c, f)| overload_node_key(c, &f))
.unwrap_or_default();
caller_write_calls.push((
function.line + call.line - 1,
call.symbol.clone(),
callee_id,
));
}
}
let Some((target_contract, target_function)) =
resolve_call(metadata, contract, &call.name, arity, options, &definer_map)
else {
if let Some((lib_contract, lib_function)) = resolve_call(
metadata,
contract,
&call.name,
arity,
&write_options,
&write_definer_map,
) {
let read_only = matches!(
lib_function.mutability,
crate::batbelt::evm::types::EvmMutability::View
| crate::batbelt::evm::types::EvmMutability::Pure
);
if lib_contract.external && !read_only {
lib_boundary_lines.push(function.line + call.line - 1);
}
}
if let Some((receiver, method)) = call.name.split_once('.') {
if let Some(type_name) = receiver.strip_suffix("()") {
let candidates = cast_implementations(
metadata,
contract,
type_name,
method,
arity,
);
if candidates.len() > 1 && !metadata.resolutions.contains_key(type_name) {
let names: Vec<String> =
candidates.iter().map(|(c, _)| c.name.clone()).collect();
let writes = candidates.iter().any(|(c, f)| {
leads_to_write(
metadata,
c,
f,
&write_options,
&write_definer_map,
&mut write_memo,
&mut HashSet::new(),
)
});
if writes {
unresolved.push(
crate::batbelt::evm::metadata::bat_metadata::UnresolvedCall {
receiver: receiver.to_string(),
method: method.to_string(),
inferred_type: type_name.to_string(),
candidates: names,
assigned_in: Vec::new(),
},
);
} else {
let entry = left_out_reads
.entry((type_name.to_string(), method.to_string()))
.or_insert_with(|| (names.clone(), Vec::new()));
entry
.1
.push(format!("{}:{}", contract.name, function.line + call.line - 1));
}
}
}
}
continue;
};
if ignored_contract(options, target_contract) {
continue;
}
let target_id = overload_node_key(target_contract, &target_function);
if target_id == current.node_id {
continue; }
edges.push(GraphEdge {
from: current.node_id.clone(),
to: target_id.clone(),
line_in_slice: call.line + doc_shift,
column: call.column,
symbol: call.symbol.clone(),
});
if drawn.insert(target_id.clone(), target_id.clone()).is_some() {
continue;
}
let mut child = make_node(
target_id.clone(),
display_label(target_contract, &target_function),
target_contract,
&target_function,
current.depth + 1,
doc_lines_above(options, &target_contract.file_path, target_function.line),
);
child.leads_to_write = leads_to_write(
metadata,
target_contract,
&target_function,
&write_options,
&write_definer_map,
&mut write_memo,
&mut HashSet::new(),
);
nodes.push(child);
children.push(Pending {
node_id: target_id,
contract: target_contract.name.clone(),
file: target_contract.file_path.clone(),
function: target_function.name.clone(),
line: target_function.line,
depth: current.depth + 1,
});
}
if function.is_constructor {
let header_end = slice
.iter()
.position(|line| line.contains('{'))
.unwrap_or(0);
for (base, constructor) in base_constructors(metadata, contract) {
if ignored_contract(options, base) {
continue;
}
let line_in_slice = slice
.iter()
.take(header_end + 1)
.position(|line| line_has_token(line, &base.name))
.map(|index| index + 1)
.unwrap_or(1);
let target_id = overload_node_key(base, &constructor);
if target_id == current.node_id {
continue;
}
edges.push(GraphEdge {
from: current.node_id.clone(),
to: target_id.clone(),
line_in_slice: line_in_slice + doc_shift,
column: slice
.get(line_in_slice - 1)
.and_then(|line| line.find(base.name.as_str()))
.unwrap_or(0),
symbol: base.name.clone(),
});
if drawn.insert(target_id.clone(), target_id.clone()).is_some() {
continue;
}
let mut child = make_node(
target_id.clone(),
display_label(base, &constructor),
base,
&constructor,
current.depth + 1,
doc_lines_above(options, &base.file_path, constructor.line),
);
child.leads_to_write = leads_to_write(
metadata,
base,
&constructor,
&write_options,
&write_definer_map,
&mut write_memo,
&mut HashSet::new(),
);
nodes.push(child);
children.push(Pending {
node_id: target_id,
contract: base.name.clone(),
file: base.file_path.clone(),
function: constructor.name.clone(),
line: constructor.line,
depth: current.depth + 1,
});
}
}
let mut followed: Vec<(String, String, Option<&crate::batbelt::evm::metadata::bat_metadata::UnresolvedCall>)> =
Vec::new();
for u in &function.unresolved_calls {
let concrete = if u.inferred_type.is_empty() {
None
} else {
metadata.resolutions.get(&u.inferred_type)
};
match concrete {
Some(concrete) => followed.push((u.method.clone(), concrete.clone(), Some(u))),
None => unresolved.push(u.clone()),
}
}
for typed in &function.resolved_calls {
followed.push((typed.method.clone(), typed.contract.clone(), None));
}
for (method, concrete, origin) in &followed {
let Some((tc, tf)) =
find_function(metadata, concrete, &contract.file_path, method, None)
else {
if let Some(u) = origin {
unresolved.push((*u).clone());
}
continue;
};
let Some((tc, tf)) = destub(metadata, (tc, tf), options) else {
continue;
};
if ignored_contract(options, tc) {
continue;
}
let target_id = overload_node_key(tc, &tf);
if target_id == current.node_id {
continue;
}
if origin.is_none()
&& edges
.iter()
.any(|edge| edge.from == current.node_id && edge.to == target_id)
{
continue;
}
let line_in_slice = slice
.iter()
.position(|l| line_has_call(l, method))
.map(|i| i + 1)
.unwrap_or(1);
edges.push(GraphEdge {
from: current.node_id.clone(),
to: target_id.clone(),
line_in_slice: line_in_slice + doc_shift,
column: slice
.get(line_in_slice - 1)
.and_then(|l| l.find(method.as_str()))
.unwrap_or(0),
symbol: method.clone(),
});
if leads_to_write(
metadata,
tc,
&tf,
&write_options,
&write_definer_map,
&mut write_memo,
&mut HashSet::new(),
) {
caller_write_calls.push((
function.line + line_in_slice - 1,
method.clone(),
target_id.clone(),
));
}
if drawn.insert(target_id.clone(), target_id.clone()).is_some() {
continue;
}
let mut child = make_node(
target_id.clone(),
display_label(tc, &tf),
tc,
&tf,
current.depth + 1,
doc_lines_above(options, &tc.file_path, tf.line),
);
child.leads_to_write = leads_to_write(
metadata,
tc,
&tf,
&write_options,
&write_definer_map,
&mut write_memo,
&mut HashSet::new(),
);
nodes.push(child);
children.push(Pending {
node_id: target_id,
contract: tc.name.clone(),
file: tc.file_path.clone(),
function: tf.name.clone(),
line: tf.line,
depth: current.depth + 1,
});
}
let mut external_lines: Vec<usize> = std::mem::take(&mut lib_boundary_lines);
for uec in &function.unknown_external_calls {
let read_only = find_function(
metadata,
&uec.inferred_type,
&contract.file_path,
&uec.method,
None,
)
.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| line_has_call(l, &uec.method)) {
let drawn_in_scope = edges.iter().any(|e| {
e.from == current.node_id
&& e.symbol == uec.method
&& e.line_in_slice == pos + 1 + doc_shift
});
if drawn_in_scope {
continue;
}
external_lines.push(function.line + pos);
}
}
if !caller_write_calls.is_empty() {
caller_write_calls.sort_unstable();
caller_write_calls.dedup();
if let Some(node) = nodes.iter_mut().find(|n| n.id == current.node_id) {
node.write_call_lines = caller_write_calls;
}
}
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);
for ((type_name, method), (candidates, locations)) in &left_out_reads {
println!(
" {} {}.{}() is a read with {} implementations ({}), left out at {} — {} draws it",
"note:".yellow(),
type_name,
method,
candidates.len(),
candidates.join(", "),
locations.join(", "),
format!("bat-cli resolve {type_name} <CONTRACT>").green()
);
}
Ok((nodes, edges, 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, None) {
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,
doc_lines: usize,
) -> GraphNode {
GraphNode {
kind: NodeKind::Screenshot,
id,
label,
file_path: contract.file_path.clone(),
start_line: function.line - doc_lines,
end_line: function_end(function, contract),
depth,
font_size: REFERENCE_FONT,
scale: scale_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(),
leads_to_write: false,
write_call_lines: Vec::new(),
}
}
fn is_ident_byte(byte: u8) -> bool {
byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'$'
}
fn line_has_token(line: &str, token: &str) -> bool {
let bytes = line.as_bytes();
let mut from = 0;
while let Some(rel) = line[from..].find(token) {
let start = from + rel;
let end = start + token.len();
let before_ok = start == 0 || !is_ident_byte(bytes[start - 1]);
let after_ok = end >= bytes.len() || !is_ident_byte(bytes[end]);
if before_ok && after_ok {
return true;
}
from = start + 1;
}
false
}
fn line_has_call(line: &str, method: &str) -> bool {
let bytes = line.as_bytes();
let mut from = 0;
while let Some(rel) = line[from..].find(method) {
let start = from + rel;
let end = start + method.len();
let before_ok = start == 0 || !is_ident_byte(bytes[start - 1]);
let after_ident_ok = end >= bytes.len() || !is_ident_byte(bytes[end]);
let mut cursor = end;
while cursor < bytes.len() && (bytes[cursor] == b' ' || bytes[cursor] == b'\t') {
cursor += 1;
}
let is_call = cursor < bytes.len() && bytes[cursor] == b'(';
if before_ok && after_ident_ok && is_call {
return true;
}
from = start + 1;
}
false
}
fn make_modifier_node(
id: String,
contract: &ContractMetadata,
definition: &crate::batbelt::evm::types::EvmModifierDef,
depth: usize,
doc_lines: usize,
) -> GraphNode {
let end_line = if definition.end_line > 0 {
definition.end_line
} else {
definition.line + 6
};
GraphNode {
kind: NodeKind::Screenshot,
id,
label: format!("{}.{} (modifier)", contract.name, definition.name),
file_path: contract.file_path.clone(),
start_line: definition.line - doc_lines,
end_line,
depth,
font_size: REFERENCE_FONT,
scale: scale_for_depth(depth),
png_path: String::new(),
png_width: 0,
png_height: 0,
rendered_lines: Vec::new(),
line_offset: 0,
writes_storage: !definition.storage_writes.is_empty(),
write_lines: definition
.storage_write_sites
.iter()
.map(|(name, line)| (*line, name.clone()))
.collect(),
external_call_lines: Vec::new(),
leads_to_write: false,
write_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,
from_file: &str,
function_name: &str,
arg_count: Option<usize>,
) -> Option<(&'a ContractMetadata, FunctionMetadata)> {
let contract = metadata.contract_in_scope(contract_name, from_file)?;
let overloads: Vec<&FunctionMetadata> =
contract.functions.iter().filter(|f| f.name == function_name).collect();
if !overloads.is_empty() {
let chosen = match arg_count {
Some(n) if overloads.len() > 1 => overloads
.iter()
.find(|f| f.params.len() == n)
.copied()
.unwrap_or(overloads[0]),
_ => overloads[0],
};
return Some((contract, chosen.clone()));
}
for base in &contract.base_contracts {
if let Some(found) =
find_function(metadata, base, &contract.file_path, function_name, arg_count)
{
return Some(found);
}
}
None
}
fn find_function_at<'a>(
metadata: &'a EvmBatMetadata,
contract_name: &str,
from_file: &str,
function_name: &str,
line: usize,
) -> Option<(&'a ContractMetadata, FunctionMetadata)> {
let contract = metadata.contract_in_scope(contract_name, from_file)?;
if let Some(function) = contract
.functions
.iter()
.find(|f| f.name == function_name && f.line == line)
{
return Some((contract, function.clone()));
}
for base in &contract.base_contracts {
if let Some(found) =
find_function_at(metadata, base, &contract.file_path, function_name, line)
{
return Some(found);
}
}
None
}
pub(crate) struct LineAnchor {
pub token_x: f64,
pub token_y: f64,
pub edge_x: f64,
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn line_anchor(
placed: (f64, f64, f64, f64),
png_width: u32,
png_height: u32,
font_size: usize,
rendered_lines: &[String],
line_offset: usize,
line_index: usize,
exit_right: bool,
) -> LineAnchor {
let (x, y, width, height) = placed;
let line_text = rendered_lines.get(line_index).cloned().unwrap_or_default();
let text_width = |text: &str| {
silicon::line_end_x(Some(font_size), true, rendered_lines.len(), line_offset, text) as f64
};
let y_fraction = silicon::line_geometry(Some(font_size)).line_center_fraction(line_index, png_height);
let token_frac = if png_width > 0 {
let png = png_width as f64;
if exit_right {
let gap = (text_width("a") - text_width("")) * ANCHOR_GAP_CHARS;
((text_width(&line_text) + gap) / png).min(1.0)
} else {
(text_width("") / png).max(0.0)
}
} else if exit_right {
1.0
} else {
0.0
};
let frame_edge = if exit_right { x + width / 2.0 } else { x - width / 2.0 };
let raw_token_x = x - width / 2.0 + 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)
};
LineAnchor {
token_x,
token_y: y - height / 2.0 + height * y_fraction,
edge_x,
}
}
fn overload_node_key(contract: &ContractMetadata, function: &FunctionMetadata) -> String {
match overload_signature(contract, function) {
Some(signature) => format!("{}{signature}", node_key(&contract.name, &function.name)),
None => node_key(&contract.name, &function.name),
}
}
fn overload_signature(contract: &ContractMetadata, function: &FunctionMetadata) -> Option<String> {
if contract.functions.iter().filter(|f| f.name == function.name).count() <= 1 {
return None;
}
let types = function
.params
.iter()
.map(|param| param.type_name.as_str())
.collect::<Vec<_>>()
.join(",");
Some(format!("({types})"))
}
fn display_label(contract: &ContractMetadata, function: &FunctionMetadata) -> String {
match overload_signature(contract, function) {
Some(signature) => format!("{}.{}{signature}", contract.name, function.name),
None => format!("{}.{}", contract.name, function.name),
}
}
fn base_constructors<'a>(
metadata: &'a EvmBatMetadata,
contract: &ContractMetadata,
) -> Vec<(&'a ContractMetadata, FunctionMetadata)> {
let mut found: Vec<(&'a ContractMetadata, FunctionMetadata)> = Vec::new();
let mut seen: HashSet<(String, String)> = HashSet::new();
let mut pending: Vec<(String, String)> = contract
.base_contracts
.iter()
.rev()
.map(|base| (base.clone(), contract.file_path.clone()))
.collect();
while let Some((name, from_file)) = pending.pop() {
let Some(base) = metadata.contract_in_scope(&name, &from_file) else {
continue;
};
if !seen.insert((base.name.clone(), base.file_path.clone())) {
continue;
}
if let Some(constructor) = base.functions.iter().find(|f| f.is_constructor) {
found.push((base, constructor.clone()));
} else {
for grand in base.base_contracts.iter().rev() {
pending.push((grand.clone(), base.file_path.clone()));
}
}
}
found
}
fn find_modifier<'a>(
metadata: &'a EvmBatMetadata,
contract_name: &str,
from_file: &str,
modifier_name: &str,
) -> Option<(&'a ContractMetadata, crate::batbelt::evm::types::EvmModifierDef)> {
let contract = metadata.contract_in_scope(contract_name, from_file)?;
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, &contract.file_path, modifier_name) {
return Some(found);
}
}
None
}
fn surviving_write_calls<'a>(
node: &'a GraphNode,
drawn_screens: &HashSet<&str>,
) -> impl Iterator<Item = &'a (usize, String, String)> {
let drawn: HashSet<String> = drawn_screens.iter().map(|id| id.to_string()).collect();
node.write_call_lines
.iter()
.filter(move |(_, _, callee)| !drawn.contains(callee))
}
fn drawn_screen_ids(nodes: &[GraphNode]) -> HashSet<&str> {
nodes
.iter()
.filter(|node| node.kind == NodeKind::Screenshot)
.map(|node| node.id.as_str())
.collect()
}
fn leads_to_write(
metadata: &EvmBatMetadata,
contract: &ContractMetadata,
function: &FunctionMetadata,
options: &AutoDeployOptions,
definer_map: &HashMap<String, Vec<String>>,
memo: &mut HashMap<String, bool>,
stack: &mut HashSet<String>,
) -> bool {
if !function.storage_writes.is_empty() {
return true;
}
let id = function.metadata_id.clone();
if let Some(&cached) = memo.get(&id) {
return cached;
}
if !stack.insert(id.clone()) {
return false;
}
let callees = metadata
.function_dependencies
.iter()
.find(|dependency| dependency.function_metadata_id == id)
.map(|dependency| dependency.callees.clone())
.unwrap_or_default();
let mut reaches = false;
for callee in callees {
if let Some((target_contract, target_function)) =
resolve_call(metadata, contract, &callee, None, options, definer_map)
{
if leads_to_write(
metadata,
target_contract,
&target_function,
options,
definer_map,
memo,
stack,
) {
reaches = true;
break;
}
}
}
if !reaches {
let followed = function
.resolved_calls
.iter()
.map(|typed| (typed.contract.clone(), typed.method.clone()))
.chain(function.unresolved_calls.iter().filter_map(|u| {
metadata
.resolutions
.get(&u.inferred_type)
.map(|concrete| (concrete.clone(), u.method.clone()))
}))
.collect::<Vec<_>>();
for (concrete, method) in followed {
if let Some((target_contract, target_function)) =
find_function(metadata, &concrete, &contract.file_path, &method, None)
{
if leads_to_write(
metadata,
target_contract,
&target_function,
options,
definer_map,
memo,
stack,
) {
reaches = true;
break;
}
}
}
}
stack.remove(&id);
memo.insert(id, reaches);
reaches
}
fn resolve_call<'a>(
metadata: &'a EvmBatMetadata,
caller_contract: &ContractMetadata,
call_name: &str,
arg_count: Option<usize>,
options: &AutoDeployOptions,
definer_map: &HashMap<String, Vec<String>>,
) -> Option<(&'a ContractMetadata, FunctionMetadata)> {
let keep = |_contract: &ContractMetadata| true;
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 target.ends_with("()") => {
return resolve_cast(
metadata,
caller_contract,
target.trim_end_matches("()"),
method,
arg_count,
options,
);
}
Some(target) => {
if metadata.contract_in_scope(target, &caller_contract.file_path).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.contract_in_scope(&candidate, &caller_contract.file_path)
else {
continue;
};
if contract.contract_type == EvmContractType::Interface {
for implementation in implementations_of(metadata, &contract.name) {
if let Some(target) =
metadata.contract_in_scope(&implementation, &caller_contract.file_path)
{
if !keep(target) {
continue;
}
if let Some(found) =
find_function(metadata, &target.name, &target.file_path, method, arg_count)
{
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, &contract.file_path, method, arg_count)
{
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],
&caller_contract.file_path,
method,
arg_count,
);
}
}
}
None
}
fn resolve_cast<'a>(
metadata: &'a EvmBatMetadata,
caller_contract: &ContractMetadata,
type_name: &str,
method: &str,
arg_count: Option<usize>,
options: &AutoDeployOptions,
) -> Option<(&'a ContractMetadata, FunctionMetadata)> {
let keep = |_contract: &ContractMetadata| true;
let typed = metadata.contract_in_scope(type_name, &caller_contract.file_path)?;
if typed.contract_type != EvmContractType::Interface {
if !keep(typed) {
return None;
}
let found = find_function(metadata, &typed.name, &typed.file_path, method, arg_count)?;
return destub(metadata, found, options);
}
if let Some(concrete) = metadata.resolutions.get(type_name) {
let target = metadata.contract_in_scope(concrete, &caller_contract.file_path)?;
if !keep(target) {
return None;
}
let found = find_function(metadata, &target.name, &target.file_path, method, arg_count)?;
return destub(metadata, found, options);
}
let mut implementations = cast_implementations(metadata, caller_contract, type_name, method, arg_count);
implementations.retain(|(contract, _)| keep(contract));
if implementations.len() == 1 {
return implementations.pop();
}
None
}
fn cast_implementations<'a>(
metadata: &'a EvmBatMetadata,
caller_contract: &ContractMetadata,
type_name: &str,
method: &str,
arg_count: Option<usize>,
) -> Vec<(&'a ContractMetadata, FunctionMetadata)> {
let mut names: Vec<String> = implementations_of(metadata, type_name);
names.sort();
names.dedup();
let mut found = Vec::new();
for name in names {
if name == type_name {
continue;
}
let Some(contract) = metadata.contract_in_scope(&name, &caller_contract.file_path) else {
continue;
};
if contract.contract_type == EvmContractType::Interface {
continue;
}
if contract.external {
continue;
}
if let Some((defining, function)) =
find_function(metadata, &contract.name, &contract.file_path, method, arg_count)
{
if !function.is_stub {
found.push((defining, function));
}
}
}
found
}
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| true;
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,
&contract.file_path,
&function.name,
Some(function.params.len()),
)
{
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()]
}
pub(crate) fn natspec_start(file_path: &str, decl_line: usize) -> usize {
let content = std::fs::read_to_string(file_path).unwrap_or_default();
let lines: Vec<&str> = content.lines().collect();
if decl_line < 2 || decl_line > lines.len() {
return decl_line;
}
let mut index = decl_line - 2;
let above = lines[index].trim();
if above.ends_with("*/") {
loop {
let line = lines[index].trim();
if line.starts_with("/**") || line.starts_with("/*") {
return index + 1;
}
if index == 0 {
return decl_line; }
index -= 1;
}
}
if !above.starts_with("///") {
return decl_line;
}
while index > 0 && lines[index - 1].trim().starts_with("///") {
index -= 1;
}
index + 1
}
fn doc_lines_above(options: &AutoDeployOptions, file_path: &str, decl_line: usize) -> usize {
if !options.with_documentation {
return 0;
}
decl_line.saturating_sub(natspec_start(file_path, decl_line))
}
pub(crate) 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,
reuse: &HashMap<String, (String, u32, u32)>,
) -> Result<()> {
BatFolder::Figures
.create_folder()
.change_context(EvmMiroError)?;
let destination = BatFolder::Figures
.get_path(true)
.change_context(EvmMiroError)?;
type RenderKey = (String, usize, usize);
let key_of = |node: &GraphNode| -> Option<RenderKey> {
(!node.file_path.is_empty() && !reuse.contains_key(&node.id))
.then(|| (node.file_path.clone(), node.start_line, node.end_line))
};
let mut distinct: Vec<RenderKey> = Vec::new();
let mut seen: HashSet<RenderKey> = HashSet::new();
for node in nodes.iter() {
if let Some(key) = key_of(node) {
if seen.insert(key.clone()) {
distinct.push(key);
}
}
}
struct Rendered {
png_path: String,
width: u32,
height: u32,
line_offset: usize,
lines: Vec<String>,
}
let bar = phase_bar("rendering screenshots", distinct.len());
let results: std::result::Result<Vec<(RenderKey, Rendered)>, String> = distinct
.par_iter()
.map(|key| {
let (file_path, start, end) = key;
let code = read_slice(file_path, *start, *end);
let pretty_path = crate::batbelt::path::prettify_source_code_path(file_path)
.unwrap_or_else(|_| file_path.clone());
let mut lines = vec![format!("// {pretty_path}"), String::new()];
lines.extend(code.iter().cloned());
let line_offset = start.saturating_sub(PATH_HEADER_LINES);
let file_name = format!(
"fn_{}_{}_{}.js",
file_path.replace([':', '.', '/'], "_"),
start,
end
);
let png_path = format!("{destination}/{file_name}.png");
if !std::path::Path::new(&png_path).exists() {
silicon::create_figure(
&lines.join("\n"),
&destination,
&file_name,
line_offset,
Some(REFERENCE_FONT),
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!("{png_path} renders to {width}x{height}, above Miro's 8192 px limit");
}
bar.inc(1);
Ok((
key.clone(),
Rendered { png_path, width, height, line_offset, lines },
))
})
.collect();
let rendered: HashMap<RenderKey, Rendered> = results
.map_err(|message| Report::new(EvmMiroError).attach_printable(message))?
.into_iter()
.collect();
bar.finish_and_clear();
for node in nodes.iter_mut() {
if let Some((_, width, height)) = reuse.get(&node.id) {
node.png_width = *width;
node.png_height = *height;
continue;
}
let Some(key) = key_of(node) else { continue };
if let Some(r) = rendered.get(&key) {
node.png_path = r.png_path.clone();
node.png_width = r.width;
node.png_height = r.height;
node.line_offset = r.line_offset;
node.rendered_lines = r.lines.clone();
}
}
println!(
" {} {} screenshots ({} distinct rendered)",
"✓".green(),
nodes.len(),
distinct.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} {:<11} {}",
"node", "layer", "x", "y", "w", "h", "png", "state"
);
let drawn_screens = drawn_screen_ids(nodes);
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} {:<11} {}",
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(),
source
.map(|n| {
if n.writes_storage {
"write".to_string()
} else if surviving_write_calls(n, &drawn_screens).next().is_some() {
"→write".to_string()
} else {
String::new()
}
})
.unwrap_or_default()
);
}
let marked_lines: Vec<String> = nodes
.iter()
.flat_map(|node| {
surviving_write_calls(node, &drawn_screens)
.map(move |(line, symbol, _)| format!("{} L{line} → {symbol}()", node.label))
})
.collect();
if !marked_lines.is_empty() {
println!(" {} call line(s) reaching a state change:", marked_lines.len());
for line in marked_lines {
println!(" {line}");
}
}
let external_lines: Vec<String> = nodes
.iter()
.flat_map(|node| node.external_call_lines.iter().map(move |line| format!("{} L{line}", node.label)))
.collect();
if !external_lines.is_empty() {
println!(" {} external boundary line(s) (amber):", external_lines.len());
for line in external_lines {
println!(" {line}");
}
}
let mut spans: Vec<usize> = Vec::new();
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();
let span = match (layout.node(&edge.from), layout.node(&edge.to)) {
(Some(from), Some(to)) => to.layer.saturating_sub(from.layer),
_ => 0,
};
spans.push(span);
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 span > 1 { format!(" spans {span} columns") } else { String::new() }
);
}
let crossing = spans.iter().filter(|span| **span > 1).count();
println!(
" {} of {} connector(s) fly over a column (worst {})",
crossing,
spans.len(),
spans.iter().max().copied().unwrap_or(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 private_closure(
root: &str,
out: &HashMap<String, Vec<String>>,
shared: &HashSet<String>,
) -> HashSet<String> {
let mut result = HashSet::new();
result.insert(root.to_string());
let mut stack = vec![root.to_string()];
while let Some(current) = stack.pop() {
if let Some(children) = out.get(¤t) {
for child in children {
if shared.contains(child) {
continue; }
if result.insert(child.clone()) {
stack.push(child.clone());
}
}
}
}
result
}
fn cut_crossing_shared(nodes: &mut Vec<GraphNode>, edges: &mut Vec<GraphEdge>, root_id: &str) -> usize {
const CROSS_LAYERS: usize = 2;
let mut done = 0usize;
loop {
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 layout = layout_graph(root_id, &layout_nodes, &layout_edges, LayoutConfig::default());
let layer_of: HashMap<&str, usize> =
layout.nodes.iter().map(|p| (p.id.as_str(), p.layer)).collect();
let mut callers: HashMap<String, Vec<String>> = HashMap::new();
let mut out: HashMap<String, Vec<String>> = HashMap::new();
for edge in edges.iter() {
callers.entry(edge.to.clone()).or_default().push(edge.from.clone());
out.entry(edge.from.clone()).or_default().push(edge.to.clone());
}
let shared: HashSet<String> = callers
.iter()
.filter(|(_, cs)| cs.len() >= 2)
.map(|(id, _)| id.clone())
.collect();
let mut victim: Option<(usize, String)> = None;
for id in &shared {
let is_screenshot = nodes
.iter()
.any(|node| node.id == *id && matches!(node.kind, NodeKind::Screenshot));
if !is_screenshot || id == root_id {
continue;
}
let worst = callers
.get(id)
.map(|cs| {
cs.iter()
.map(|c| match (layer_of.get(id.as_str()), layer_of.get(c.as_str())) {
(Some(&vl), Some(&cl)) => vl.saturating_sub(cl),
_ => 0,
})
.max()
.unwrap_or(0)
})
.unwrap_or(0);
if worst < CROSS_LAYERS {
continue;
}
let clen = private_closure(id, &out, &shared).len();
let far = callers
.get(id)
.map(|cs| {
let mut distinct: Vec<&String> = cs.iter().collect();
distinct.sort();
distinct.dedup();
distinct
.iter()
.filter(|c| match (layer_of.get(id.as_str()), layer_of.get(c.as_str())) {
(Some(&vl), Some(&cl)) => vl.saturating_sub(cl) >= CROSS_LAYERS,
_ => false,
})
.count()
})
.unwrap_or(0);
if clen < FRAME_MIN && far <= MAX_COPIES_OF_ONE_CALLEE {
continue; }
if victim.as_ref().map_or(true, |(best, _)| clen > *best) {
victim = Some((clen, id.clone()));
}
}
let Some((_, id)) = victim else {
break;
};
let Some(index) = edges.iter().position(|edge| {
edge.to == id
&& match (layer_of.get(id.as_str()), layer_of.get(edge.from.as_str())) {
(Some(&vl), Some(&cl)) => vl.saturating_sub(cl) >= CROSS_LAYERS,
_ => false,
}
}) else {
break;
};
cut_edge(nodes, edges, index);
done += 1;
}
done
}
fn duplicate_crossing_shared(
nodes: &mut Vec<GraphNode>,
edges: &mut Vec<GraphEdge>,
root_id: &str,
) {
const CROSS_LAYERS: usize = 2; const MAX_CLOSURE: usize = FRAME_MIN - 1;
const MAX_COPIES: usize = 4096;
let budget = nodes.len() * 4;
let mut added = 0usize;
loop {
if added >= budget {
break;
}
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 layout = layout_graph(root_id, &layout_nodes, &layout_edges, LayoutConfig::default());
let layer_of: HashMap<&str, usize> =
layout.nodes.iter().map(|p| (p.id.as_str(), p.layer)).collect();
let mut callers: HashMap<String, Vec<String>> = HashMap::new();
let mut out: HashMap<String, Vec<String>> = HashMap::new();
for edge in edges.iter() {
callers.entry(edge.to.clone()).or_default().push(edge.from.clone());
out.entry(edge.from.clone()).or_default().push(edge.to.clone());
}
let shared: HashSet<String> = callers
.iter()
.filter(|(_, cs)| cs.len() >= 2)
.map(|(id, _)| id.clone())
.collect();
let skip = |v: &str, c: &str| -> usize {
match (layer_of.get(v), layer_of.get(c)) {
(Some(&vl), Some(&cl)) => vl.saturating_sub(cl),
_ => 0,
}
};
let mut best: Option<(usize, usize, String)> = None; for v in &shared {
let worst = callers
.get(v)
.map(|cs| cs.iter().map(|c| skip(v, c)).max().unwrap_or(0))
.unwrap_or(0);
if worst < CROSS_LAYERS {
continue;
}
let clen = private_closure(v, &out, &shared).len();
if clen > MAX_CLOSURE {
continue;
}
let far = callers
.get(v)
.map(|cs| {
let mut distinct: Vec<&String> = cs.iter().collect();
distinct.sort();
distinct.dedup();
distinct.iter().filter(|c| skip(v, c) >= CROSS_LAYERS).count()
})
.unwrap_or(0);
if far > MAX_COPIES_OF_ONE_CALLEE {
continue;
}
let key = (clen, usize::MAX - worst);
if best.as_ref().map_or(true, |(cl, w, _)| key < (*cl, *w)) {
best = Some((key.0, key.1, v.clone()));
}
}
let Some((_, _, victim)) = best else {
break;
};
let closure = private_closure(&victim, &out, &shared);
let mut distinct_callers: Vec<String> = callers.get(&victim).cloned().unwrap_or_default();
distinct_callers.sort();
distinct_callers.dedup();
distinct_callers.sort_by_key(|c| skip(&victim, c)); let distant: Vec<String> = distinct_callers
.iter()
.skip(1)
.filter(|c| skip(&victim, c) >= CROSS_LAYERS)
.cloned()
.collect();
if distant.is_empty() {
break;
}
let templates: HashMap<String, GraphNode> = nodes
.iter()
.filter(|node| closure.contains(&node.id))
.map(|node| (node.id.clone(), node.clone()))
.collect();
let internal: Vec<GraphEdge> = edges
.iter()
.filter(|edge| closure.contains(&edge.from) && closure.contains(&edge.to))
.cloned()
.collect();
let external: Vec<GraphEdge> = edges
.iter()
.filter(|edge| closure.contains(&edge.from) && !closure.contains(&edge.to))
.cloned()
.collect();
let mut made = 0usize;
for caller in distant.iter() {
if made >= MAX_COPIES || added + closure.len() > budget {
break;
}
let suffix = format!("dup{added}_{made}");
let id_map: HashMap<String, String> = closure
.iter()
.map(|id| (id.clone(), format!("{id}#{suffix}")))
.collect();
for id in &closure {
if let Some(template) = templates.get(id) {
let mut copy = template.clone();
copy.id = id_map[id].clone();
nodes.push(copy);
}
}
for edge in &internal {
let mut copy = edge.clone();
copy.from = id_map[&edge.from].clone();
copy.to = id_map[&edge.to].clone();
edges.push(copy);
}
for edge in &external {
let mut copy = edge.clone();
copy.from = id_map[&edge.from].clone();
edges.push(copy);
}
for edge in edges.iter_mut() {
if edge.from == *caller && edge.to == victim {
edge.to = id_map[&victim].clone();
}
}
added += closure.len();
made += 1;
}
if made == 0 {
break; }
}
}
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 instance: HashMap<(String, String), String> = HashMap::new();
let mut keeper: HashMap<String, String> = HashMap::new();
let mut copies: Vec<GraphNode> = Vec::new();
for edge in edges.iter_mut() {
if !shared_leaves.contains(&edge.to) {
continue;
}
let first = keeper
.entry(edge.to.clone())
.or_insert_with(|| edge.from.clone());
if *first == edge.from {
continue;
}
let Some(original) = template.get(&edge.to) else {
continue;
};
let copy_id = instance
.entry((edge.to.clone(), edge.from.clone()))
.or_insert_with(|| {
let copy_id = format!("{}#{}", edge.to, copies.len());
let mut copy = original.clone();
copy.id = copy_id.clone();
copies.push(copy);
copy_id
})
.clone();
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,
scale: 1.0,
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(),
leads_to_write: false,
write_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 FRAME_TARGET: usize = 15;
const FRAME_MAX: usize = 20;
const FRAME_MIN: usize = 6;
const MAX_CUTS_PER_FRAME: usize = 10;
const DEPTH_FREE_LAYERS: usize = 5;
const DEPTH_PENALTY: f64 = 0.15;
fn link_deployed_frames(
nodes: &mut Vec<GraphNode>,
edges: &mut Vec<GraphEdge>,
deployed_titles: &HashSet<String>,
) -> (usize, Vec<String>) {
let Some(root_id) = nodes.first().map(|n| n.id.clone()) else {
return (0, Vec::new());
};
let targets: Vec<String> = {
let mut seen = HashSet::new();
edges
.iter()
.filter(|edge| edge.to != root_id)
.filter(|edge| {
nodes.iter().any(|node| {
node.id == edge.to
&& node.kind == NodeKind::Screenshot
&& deployed_titles.contains(&node.label)
})
})
.filter_map(|edge| seen.insert(edge.to.clone()).then(|| edge.to.clone()))
.collect()
};
let (mut linked, mut kept) = (0usize, Vec::new());
for target in targets {
let Some(label) = nodes.iter().find(|n| n.id == target).map(|n| n.label.clone()) else {
continue;
};
let (mut trial_nodes, mut trial_edges) = (nodes.clone(), edges.clone());
let calls = trial_edges.iter().filter(|e| e.to == target).count();
cut_node(&mut trial_nodes, &mut trial_edges, &target);
if screenshot_count(&trial_nodes) < FRAME_MIN {
kept.push(label);
continue;
}
*nodes = trial_nodes;
*edges = trial_edges;
linked += calls;
}
(linked, kept)
}
fn cut_edge(nodes: &mut Vec<GraphNode>, edges: &mut Vec<GraphEdge>, index: usize) {
let Some((label, file)) = nodes
.iter()
.find(|node| node.id == edges[index].to)
.map(|node| (node.label.clone(), node.file_path.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 },
file_path: String::new(),
start_line: 0,
end_line: 0,
depth: 0,
font_size: 22,
scale: 1.0,
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(),
leads_to_write: false,
write_call_lines: Vec::new(),
});
edges[index].to = card_id;
prune_unreachable(nodes, edges);
}
fn cut_node(nodes: &mut Vec<GraphNode>, edges: &mut Vec<GraphEdge>, target_id: &str) {
let Some((label, file)) = nodes
.iter()
.find(|node| node.id == target_id)
.map(|node| (node.label.clone(), node.file_path.clone()))
else {
return;
};
let in_edges: Vec<usize> = edges
.iter()
.enumerate()
.filter(|(_, e)| e.to == target_id)
.map(|(i, _)| i)
.collect();
for (n, idx) in in_edges.into_iter().enumerate() {
let card_id = format!("\u{0}linknode_{target_id}_{n}");
nodes.push(GraphNode {
id: card_id.clone(),
label: label.clone(),
kind: NodeKind::Link { target: label.clone(), file: file.clone() },
file_path: String::new(),
start_line: 0,
end_line: 0,
depth: 0,
font_size: 22,
scale: 1.0,
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(),
leads_to_write: false,
write_call_lines: Vec::new(),
});
edges[idx].to = card_id;
}
prune_unreachable(nodes, edges);
}
fn screenshot_count(nodes: &[GraphNode]) -> usize {
nodes
.iter()
.filter(|node| node.kind == NodeKind::Screenshot)
.count()
}
fn effective_size(nodes: &[GraphNode]) -> usize {
let count = screenshot_count(nodes);
let max_layer = nodes
.iter()
.filter(|n| n.kind == NodeKind::Screenshot)
.map(|n| n.depth)
.max()
.unwrap_or(0);
let over = max_layer.saturating_sub(DEPTH_FREE_LAYERS);
(count as f64 * (1.0 + DEPTH_PENALTY * over as f64)).round() as usize
}
fn reachable_screens(root: &str, adjacency: &HashMap<&str, Vec<&str>>, screens: &HashSet<&str>) -> HashSet<String> {
let mut seen: HashSet<String> = HashSet::new();
let mut out: HashSet<String> = HashSet::new();
let mut stack = vec![root.to_string()];
while let Some(id) = stack.pop() {
if !seen.insert(id.clone()) {
continue;
}
if screens.contains(id.as_str()) {
out.insert(id.clone());
}
for next in adjacency.get(id.as_str()).cloned().unwrap_or_default() {
stack.push(next.to_string());
}
}
out
}
fn reachable_nodes(root: &str, adjacency: &HashMap<&str, Vec<&str>>) -> HashSet<String> {
let mut seen: HashSet<String> = HashSet::new();
let mut stack = vec![root.to_string()];
while let Some(id) = stack.pop() {
if !seen.insert(id.clone()) {
continue;
}
for next in adjacency.get(id.as_str()).cloned().unwrap_or_default() {
stack.push(next.to_string());
}
}
seen
}
fn best_cut(
nodes: &[GraphNode],
edges: &[GraphEdge],
framed: &HashSet<&str>,
budget: usize,
) -> Option<(Vec<GraphNode>, Vec<GraphEdge>)> {
let has_children: HashSet<&str> = edges.iter().map(|edge| edge.from.as_str()).collect();
let screens: HashSet<&str> = nodes
.iter()
.filter(|n| n.kind == NodeKind::Screenshot)
.map(|n| n.id.as_str())
.collect();
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 root = nodes.first().map(|n| n.id.as_str()).unwrap_or("");
let before = screenshot_count(nodes);
let budget_f = budget.max(1) as f64;
let mut best: Option<(f64, Vec<GraphNode>, Vec<GraphEdge>)> = None;
for node in nodes.iter() {
if node.kind != NodeKind::Screenshot
|| node.id == root
|| !has_children.contains(node.id.as_str())
{
continue;
}
let has_frame = framed.contains(node.label.as_str());
let sub = reachable_screens(&node.id, &adjacency, &screens).len();
let mut candidate_nodes = nodes.to_vec();
let mut candidate_edges = edges.to_vec();
cut_node(&mut candidate_nodes, &mut candidate_edges, &node.id);
let saved = before.saturating_sub(screenshot_count(&candidate_nodes));
if saved == 0 {
continue;
}
let residual = before.saturating_sub(saved);
if (!has_frame && sub < FRAME_MIN) || residual < FRAME_MIN {
continue;
}
let subtree = reachable_nodes(&node.id, &adjacency);
let severed = edges
.iter()
.filter(|e| subtree.contains(&e.from) != subtree.contains(&e.to))
.count()
.max(1);
let reuse = edges.iter().filter(|e| e.to == node.id).count();
let score = -(50.0 / budget_f) * (sub as f64 - budget_f).abs()
- 12.0 * (severed as f64 - 1.0)
+ 10.0 * (reuse.min(3) as f64)
+ if has_frame { 8.0 } else { 0.0 };
if best.as_ref().map(|(most, _, _)| score > *most).unwrap_or(true) {
best = Some((score, 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,
scale: 1.0,
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(),
leads_to_write: false,
write_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 linking_to_deployed_frames_never_leaves_a_husk() {
let mut nodes = vec![node("priced"), node("book"), node("priceIn")];
let mut edges = vec![edge("priced", "book"), edge("priced", "priceIn")];
for i in 1..=5 {
nodes.push(node(&format!("b{i}")));
edges.push(edge("book", &format!("b{i}")));
}
for i in 1..=8 {
nodes.push(node(&format!("p{i}")));
edges.push(edge("priceIn", &format!("p{i}")));
}
let framed: HashSet<String> = ["book", "priceIn"].iter().map(|s| s.to_string()).collect();
let (linked, kept) = link_deployed_frames(&mut nodes, &mut edges, &framed);
assert_eq!(linked, 1);
assert_eq!(kept, vec!["priceIn".to_string()]);
assert_eq!(screenshot_count(&nodes), 10);
assert!(!nodes.iter().any(|n| n.id == "b1"));
assert!(nodes.iter().any(|n| n.id == "p1"));
}
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, &HashSet::new(), FRAME_TARGET) {
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_graph_below_the_husk_floor_is_not_cut() {
let nodes = vec![node("root"), node("a"), node("b")];
let edges = vec![edge("root", "a"), edge("a", "b")];
assert!(nodes.len() < FRAME_MIN);
assert!(best_cut(&nodes, &edges, &HashSet::new(), FRAME_TARGET).is_none());
}
}
async fn ensure_target_frames(
nodes: &[GraphNode],
options: &AutoDeployOptions,
client: &MiroClient,
allocator: &mut ShelfAllocator,
cluster: &ClusterCtx,
) -> Result<HashMap<String, String>> {
let wanted: Vec<(String, String)> = {
let mut seen = HashSet::new();
nodes
.iter()
.filter_map(|node| match &node.kind {
NodeKind::Link { target, file } => Some((target.clone(), file.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, target_file) in wanted {
let reusable = {
let created_here = EvmBatMetadata::read_metadata().ok().is_some_and(|m| {
m.miro.auto.frames.iter().any(|f| {
f.entry_point == target
&& f.cluster_root == cluster.root
&& !cluster.stale_ids.contains(&f.frame_id)
})
});
if created_here {
live_frame_url(&target, Some(client)).await?
} else {
None
}
};
if let Some(url) = reusable {
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,
&target_file,
options,
Some(client),
allocator,
false,
cluster,
))
.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)
}
pub async fn run_relink(
entry_point: Option<String>,
frame_url: Option<String>,
check: bool,
) -> Result<()> {
let client = MiroClient::new_refreshed()
.await
.change_context(EvmMiroError)?;
let metadata = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
if check || entry_point.is_none() {
let frames = client.list_frames().await.change_context(EvmMiroError)?;
let mut alive = 0usize;
let mut fixable: Vec<(String, usize)> = Vec::new();
let mut gone: Vec<String> = Vec::new();
for record in &metadata.miro.auto.frames {
if frames.iter().any(|frame| frame.id == record.frame_id) {
alive += 1;
continue;
}
let wanted = format!("auto: {}", record.entry_point);
let matches = frames.iter().filter(|frame| frame.title == wanted).count();
if matches == 0 {
gone.push(record.entry_point.clone());
} else {
fixable.push((record.entry_point.clone(), matches));
}
}
println!("{} frame(s) recorded, {alive} still where the registry says", metadata.miro.auto.frames.len());
for (entry_point, matches) in &fixable {
if *matches == 1 {
println!(
" {} {} moved — {} re-anchors it",
"↻".yellow(),
entry_point,
format!("bat-cli relink {entry_point}").green()
);
} else {
println!(
" {} {} has {matches} frames with its title — {} picks one",
"?".yellow(),
entry_point,
format!("bat-cli relink {entry_point} --frame-url <url>").green()
);
}
}
for entry_point in &gone {
println!(" {} {} is not on the board at all", "✗".red(), entry_point);
}
if fixable.is_empty() && gone.is_empty() {
println!(" {} nothing to relink", "✓".green());
}
return Ok(());
}
let entry_point = entry_point.expect("checked above");
let Some(record) = metadata
.miro
.auto
.frames
.iter()
.find(|frame| frame.entry_point == entry_point)
.cloned()
else {
return Err(Report::new(EvmMiroError)
.attach_printable(format!("no frame recorded for `{entry_point}`"))
.attach(crate::Suggestion(
"run `bat-cli relink --check` to see what is recorded".to_string(),
)));
};
if let Some(url) = frame_url {
let Some(id) = url
.split("moveToWidget=")
.nth(1)
.map(|rest| rest.split(['&', '#']).next().unwrap_or(rest).to_string())
else {
return Err(Report::new(EvmMiroError)
.attach_printable(format!("no `moveToWidget=` id in `{url}`"))
.attach(crate::Suggestion(
"copy the frame's link from Miro (right-click → Copy link)".to_string(),
)));
};
let frames = client.list_frames().await.change_context(EvmMiroError)?;
let Some(frame) = frames.iter().find(|frame| frame.id == id) else {
return Err(Report::new(EvmMiroError)
.attach_printable(format!("no frame with id {id} on this board")));
};
let record = rebuild_record(record, frame, &client).await?;
println!(
"{} {} → {}",
"✓".green(),
entry_point.bold(),
record.frame_url.blue()
);
return Ok(());
}
match reanchor_frame(&entry_point, &client).await? {
Some(record) => {
println!(
"{} {} → {}",
"✓".green(),
entry_point.bold(),
record.frame_url.blue()
);
Ok(())
}
None => Err(Report::new(EvmMiroError)
.attach_printable(format!(
"no frame titled `auto: {entry_point}` on the board"
))
.attach(crate::Suggestion(
"pass --frame-url <url> if it was renamed, or deploy it again".to_string(),
))),
}
}
fn label_for_node(node_id: &str) -> Option<String> {
if node_id.starts_with('\u{0}') {
return None;
}
let base = node_id.split('@').next().unwrap_or(node_id);
Some(base.replace("::", "."))
}
async fn reanchor_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 wanted = format!("auto: {title}");
let frames = client.list_frames().await.change_context(EvmMiroError)?;
let candidates: Vec<_> = frames.iter().filter(|frame| frame.title == wanted).collect();
match candidates.len() {
0 => Ok(None),
_ if candidates.len() > 1 => Err(Report::new(EvmMiroError)
.attach_printable(format!(
"{} frames on the board are titled `{wanted}`:\n {}",
candidates.len(),
candidates
.iter()
.map(|frame| client.frame_url(&frame.id))
.collect::<Vec<_>>()
.join("\n ")
))
.attach(crate::Suggestion(format!(
"pick one: `bat-cli relink {title} --frame-url <url>`"
)))),
_ => {
let frame = candidates[0];
let record = rebuild_record(record, frame, client).await?;
println!(
" {} re-anchored {} to the frame now on the board (its id changed, so it was cut and pasted)",
"↻".yellow(),
title.bold()
);
Ok(Some(record))
}
}
}
async fn rebuild_record(
mut record: AutoDeployedFrame,
frame: &crate::batbelt::miro::client::BoardFrame,
client: &MiroClient,
) -> Result<AutoDeployedFrame> {
let children = client
.frame_children(&frame.id, (frame.x, frame.y, frame.width, frame.height))
.await
.unwrap_or_default();
let by_title: HashMap<&str, &crate::batbelt::miro::client::FrameChild> = children
.iter()
.filter(|child| !child.title.is_empty())
.map(|child| (child.title.as_str(), child))
.collect();
let mut images = Vec::new();
let mut image_dims = Vec::new();
let mut node_positions = Vec::new();
for (node_id, _) in &record.images {
let Some(label) = label_for_node(node_id) else {
continue;
};
let Some(child) = by_title.get(label.as_str()) else {
continue;
};
images.push((node_id.clone(), child.id.clone()));
image_dims.push((node_id.clone(), child.width as u32, child.height as u32));
node_positions.push((node_id.clone(), child.x, child.y));
}
let screenshots = record
.screenshots
.iter()
.filter_map(|shot| {
by_title.get(shot.label.as_str()).map(|child| {
crate::batbelt::evm::metadata::bat_metadata::ExtraScreenshot {
label: shot.label.clone(),
item_id: child.id.clone(),
x: child.x,
y: child.y,
width: child.width,
height: child.height,
}
})
})
.collect();
record.frame_id = frame.id.clone();
record.frame_url = client.frame_url(&frame.id);
record.x = frame.x;
record.y = frame.y;
record.width = frame.width;
record.height = frame.height;
record.images = images;
record.image_dims = image_dims;
record.node_positions = node_positions;
record.screenshots = screenshots;
record.link_cards.clear();
record.callee_connectors.clear();
record.connector_ids.clear();
record.marker_ids.clear();
record.border_ids.clear();
save_frame_record(&record)?;
Ok(record)
}
pub(crate) async fn ensure_frame_record(
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);
};
match client.item_status(&record.frame_id).await {
Some(true) => return Ok(Some(record)),
None => {
println!(
" {} could not check whether {}'s frame is still on the board; keeping the record",
"note:".yellow(),
title
);
return Ok(Some(record));
}
Some(false) => {}
}
if let Some(reanchored) = reanchor_frame(title, client).await? {
return Ok(Some(reanchored));
}
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)
}
async fn live_frame_url(title: &str, client: Option<&MiroClient>) -> Result<Option<String>> {
let Some(client) = client else {
let metadata = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
return Ok(metadata
.miro
.auto
.frames
.iter()
.find(|frame| frame.entry_point == title)
.map(|frame| frame.frame_url.clone()));
};
Ok(ensure_frame_record(title, client)
.await?
.map(|record| record.frame_url))
}
#[cfg(test)]
mod natspec_test {
use super::*;
fn sol_fixture(name: &str, body: &str) -> String {
let dir = std::env::temp_dir().join(format!("bat-cli-natspec-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(format!("{name}.sol"));
std::fs::write(&path, body).unwrap();
path.to_string_lossy().to_string()
}
#[test]
fn takes_a_run_of_slash_slash_slash_lines() {
let path = sol_fixture("triple", "contract C {\n /// @notice a\n /// @dev b\n function f() {}\n}\n");
assert_eq!(natspec_start(&path, 4), 2); }
#[test]
fn takes_a_block_comment_whole() {
let path = sol_fixture(
"block",
"contract C {\n /**\n * @notice a\n */\n function f() {}\n}\n",
);
assert_eq!(natspec_start(&path, 5), 2); }
#[test]
fn takes_a_one_line_block() {
let path = sol_fixture("oneline", "contract C {\n /** @notice a */\n function f() {}\n}\n");
assert_eq!(natspec_start(&path, 3), 2);
}
#[test]
fn leaves_an_ordinary_comment_out() {
let path = sol_fixture("plain", "contract C {\n // just a note\n function f() {}\n}\n");
assert_eq!(natspec_start(&path, 3), 3);
}
#[test]
fn a_blank_line_detaches_the_comment() {
let path = sol_fixture("detached", "contract C {\n /// @notice a\n\n function f() {}\n}\n");
assert_eq!(natspec_start(&path, 4), 4);
}
#[test]
fn takes_nothing_when_there_is_nothing() {
let path = sol_fixture("bare", "contract C {\n function f() {}\n}\n");
assert_eq!(natspec_start(&path, 2), 2);
assert_eq!(natspec_start(&path, 1), 1);
assert_eq!(natspec_start(&path, 999), 999);
assert_eq!(natspec_start("/no/such/file.sol", 7), 7);
}
#[test]
fn doc_lines_above_is_zero_unless_asked_for() {
let path = sol_fixture("gated", "contract C {\n /// @notice a\n function f() {}\n}\n");
let mut options = AutoDeployOptions::default();
assert_eq!(doc_lines_above(&options, &path, 3), 0);
options.with_documentation = true;
assert_eq!(doc_lines_above(&options, &path, 3), 1);
}
}