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, ConnectorStroke, 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::{self, TracedName};
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 = 8.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;
pub(crate) 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] = crate::batbelt::silicon::BAT_PALETTE;
#[derive(Debug, Clone)]
pub struct AutoDeployOptions {
pub entry_point: Option<String>,
pub dry_run: bool,
pub with_documentation: bool,
pub preview: Option<String>,
pub deploy_effects: bool,
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,
deploy_effects: false,
stroke_width: 8,
allow_unresolved: false,
assume_yes: false,
ignore_contracts: Vec::new(),
inline_all: false,
}
}
}
#[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)>,
external_call_sites: 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 effects(options: AutoDeployOptions) -> Result<()> {
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;
let targets = select_targets(&metadata, &options)?;
let Some((contract_name, function_name, root_file)) = targets.into_iter().next() else {
return Err(Report::new(EvmMiroError)
.attach_printable("no entry point matched; run `bat-cli sonar` first"));
};
let title = format!("{contract_name}.{function_name}");
let (nodes, edges, unresolved) =
build_graph(&metadata, &contract_name, &function_name, &root_file, &options)?;
if nodes.is_empty() {
return Err(Report::new(EvmMiroError)
.attach_printable(format!("no function metadata for {title}")));
}
let depth = nodes.iter().map(|node| node.depth).max().unwrap_or(0) + 1;
let mut report: Vec<String> = Vec::new();
report.push(format!(
"{title} — {} function(s) reached, {depth} level(s) deep",
nodes.len()
));
let (children, order) = call_tree(&nodes, &edges);
let by_id: HashMap<&str, &GraphNode> = nodes.iter().map(|n| (n.id.as_str(), n)).collect();
let writes_count: usize = nodes.iter().map(|n| n.write_lines.len()).sum();
let boundary_count: usize = nodes.iter().map(|n| n.external_call_lines.len()).sum();
report.push(String::new());
for note in [
"This is the UNION over every branch, not one execution: which of these happen",
"depends on the path taken, and under WHICH condition a branch is taken is not",
"something this tool knows. Branches that change nothing are left out.",
] {
report.push(note.to_string());
}
let root = nodes[0].id.clone();
let mut sections: Vec<Vec<String>> = Vec::new();
for effect in [Effect::State, Effect::Boundary] {
let mut section = vec![
match effect {
Effect::State => format!("{title} — ● state changes ({writes_count})"),
Effect::Boundary => format!(
"{title} — ▲ external boundaries ({boundary_count}), where value can move"
),
},
"the union over every branch; which happen depends on the path taken".to_string(),
String::new(),
];
let keep = branches_that_matter(&nodes, &children, effect);
if keep.is_empty() {
section.push(match effect {
Effect::State => " nothing here writes contract storage".to_string(),
Effect::Boundary => " nothing here leaves the code in scope".to_string(),
});
} else {
write_subtree(&root, "", "", &children, &by_id, &keep, &order, effect, &mut section);
}
report.push(String::new());
report.extend(section.iter().cloned());
sections.push(section);
}
if !unresolved.is_empty() {
report.push(String::new());
report.push(format!(
"{} interface call(s) were NOT followed, so the list above is a floor:",
unresolved.len()
));
for call in &unresolved {
let kind = if call.inferred_type.is_empty() {
String::new()
} else {
format!(" ({})", call.inferred_type)
};
report.push(format!(" {}.{}{}", call.receiver, call.method, kind));
}
report.push(" fix: bat-cli resolve <INTERFACE> <CONTRACT>, then run this again".to_string());
}
println!();
for line in &report {
println!("{line}");
}
if options.deploy_effects {
deploy_effects(&title, §ions).await?;
}
Ok(())
}
async fn deploy_effects(title: &str, sections: &[Vec<String>]) -> Result<()> {
use crate::batbelt::path::BatFolder;
BatFolder::Figures.create_folder().change_context(EvmMiroError)?;
let destination = BatFolder::Figures.get_path(true).change_context(EvmMiroError)?;
const MARGIN: f64 = 400.0;
const GAP: f64 = 400.0;
let mut rendered: Vec<(String, f64, f64)> = Vec::new();
for (index, section) in sections.iter().enumerate() {
let png_path = crate::batbelt::silicon::create_figure(
§ion.join("\n"),
&destination,
&format!("effects_{}_{index}.txt", title.replace('.', "_")),
0,
Some(REFERENCE_FONT),
false,
);
let (png_width, png_height) =
image::image_dimensions(&png_path).change_context(EvmMiroError)?;
rendered.push((png_path, png_width as f64, png_height as f64));
}
let width: f64 = rendered.iter().map(|(_, w, _)| w).sum::<f64>()
+ GAP * (rendered.len() as f64 - 1.0);
let height: f64 = rendered.iter().fold(0.0_f64, |tallest, (_, _, h)| tallest.max(*h));
let client = MiroClient::new_refreshed().await.change_context(EvmMiroError)?;
let (frame_x, frame_y) = crate::batbelt::evm::miro::overview::free_spot(
&client,
width + MARGIN * 2.0,
height + MARGIN * 2.0,
)
.await?;
let frame_id = client
.create_frame(
&format!("effects: {title}"),
frame_x,
frame_y,
width + MARGIN * 2.0,
height + MARGIN * 2.0,
None,
)
.await
.change_context(EvmMiroError)?;
let mut cursor = MARGIN;
for (png_path, png_width, png_height) in &rendered {
client
.create_image_in_frame(
png_path,
&frame_id,
title,
cursor + png_width / 2.0,
MARGIN + png_height / 2.0,
*png_width,
)
.await
.change_context(EvmMiroError)?;
cursor += png_width + GAP;
let _ = std::fs::remove_file(png_path);
}
println!(" {}", client.frame_url(&frame_id).blue());
Ok(())
}
fn call_tree(
nodes: &[GraphNode],
edges: &[GraphEdge],
) -> (HashMap<String, Vec<String>>, HashMap<String, usize>) {
let mut children: HashMap<String, Vec<String>> = HashMap::new();
let mut order: HashMap<String, usize> = HashMap::new();
let Some(root) = nodes.first() else {
return (children, order);
};
let mut callees: HashMap<&str, Vec<&str>> = HashMap::new();
for edge in edges {
callees.entry(edge.from.as_str()).or_default().push(edge.to.as_str());
}
let mut seen: HashSet<&str> = HashSet::new();
seen.insert(root.id.as_str());
order.insert(root.id.clone(), 0);
let mut queue: VecDeque<&str> = VecDeque::new();
queue.push_back(root.id.as_str());
while let Some(id) = queue.pop_front() {
for callee in callees.get(id).into_iter().flatten() {
if !seen.insert(callee) {
continue;
}
order.insert((*callee).to_string(), order.len());
children.entry(id.to_string()).or_default().push((*callee).to_string());
queue.push_back(callee);
}
}
(children, order)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Effect {
State,
Boundary,
}
impl Effect {
fn present_in(self, node: &GraphNode) -> bool {
match self {
Self::State => !node.write_lines.is_empty(),
Self::Boundary => !node.external_call_lines.is_empty(),
}
}
}
fn branches_that_matter(
nodes: &[GraphNode],
children: &HashMap<String, Vec<String>>,
effect: Effect,
) -> HashSet<String> {
let has_effect: HashSet<&str> = nodes
.iter()
.filter(|n| effect.present_in(n))
.map(|n| n.id.as_str())
.collect();
let mut keep: HashSet<String> = HashSet::new();
let mut by_depth: Vec<&GraphNode> = nodes.iter().collect();
by_depth.sort_by(|a, b| b.depth.cmp(&a.depth));
for node in by_depth {
let child_matters = children
.get(&node.id)
.into_iter()
.flatten()
.any(|child| keep.contains(child));
if child_matters || has_effect.contains(node.id.as_str()) {
keep.insert(node.id.clone());
}
}
keep
}
#[allow(clippy::too_many_arguments)]
fn write_subtree(
id: &str,
prefix: &str,
continuation: &str,
children: &HashMap<String, Vec<String>>,
by_id: &HashMap<&str, &GraphNode>,
keep: &HashSet<String>,
order: &HashMap<String, usize>,
effect: Effect,
report: &mut Vec<String>,
) {
let Some(node) = by_id.get(id) else { return };
let marks: Vec<String> = match effect {
Effect::State => node
.write_lines
.iter()
.map(|(line, lvalue)| format!("{lvalue} {}:{line}", prettify(&node.file_path)))
.collect(),
Effect::Boundary => node
.external_call_lines
.iter()
.map(|line| format!("{}:{line}", prettify(&node.file_path)))
.collect(),
};
report.push(format!("{prefix}{}", node.label));
let mut kids: Vec<&String> = children
.get(id)
.into_iter()
.flatten()
.filter(|child| keep.contains(*child))
.collect();
kids.sort_by_key(|child| order.get(*child).copied().unwrap_or(usize::MAX));
let stem = if kids.is_empty() { " " } else { "│ " };
for mark in &marks {
report.push(format!("{continuation}{stem}{}{mark}", if effect == Effect::State { "● " } else { "▲ " }));
}
for (index, child) in kids.iter().enumerate() {
let last = index + 1 == kids.len();
let elbow = if last { "└─ " } else { "├─ " };
let carry = if last { " " } else { "│ " };
write_subtree(
child,
&format!("{continuation}{elbow}"),
&format!("{continuation}{carry}"),
children,
by_id,
keep,
order,
effect,
report,
);
}
}
fn prettify(path: &str) -> String {
crate::batbelt::path::prettify_source_code_path(path).unwrap_or_else(|_| path.to_string())
}
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 previous_extras: Vec<(String, String, bool)> = {
let meta = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
meta.miro
.auto
.frames
.iter()
.filter(|f| f.cluster_root == title && !f.type_frame)
.flat_map(|f| {
f.screenshots.iter().map(|shot| {
(f.entry_point.clone(), shot.label.clone(), shot.with_documentation)
})
})
.collect()
};
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 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();
let plans = plan_cluster(
&metadata,
&contract_name,
&function_name,
&root_file,
&root_options,
&mut allocator,
)?;
println!(
"\n{} {} frame(s) planned",
"▦".blue(),
plans.len().to_string().green()
);
if options.dry_run {
for plan in &plans {
println!(
"\n{} {}{}",
"▸".blue(),
" ".repeat(plan.cluster_depth),
plan.title.bold()
);
print_dry_run(
&plan.nodes,
&plan.edges,
&plan.anchors,
&plan.layout,
(plan.frame_x, plan.frame_y),
);
}
for plan in &plans {
cleanup(&plan.nodes);
}
continue;
}
let client = client.as_ref().expect("client is present when not in dry-run mode");
let bar = phase_bar("creating frames", plans.len());
let mut creating = tokio::task::JoinSet::new();
for (index, plan) in plans.iter().enumerate() {
let client = client.clone();
let title = format!("auto: {}", plan.title);
let fill = frame_fill_for(&plan.nodes);
let (x, y) = (plan.frame_x, plan.frame_y);
let (width, height) = (plan.layout.frame_width, plan.layout.frame_height);
let bar = bar.clone();
creating.spawn(async move {
let frame_id = client
.create_frame(&title, x, y, width, height, fill)
.await
.map_err(|report| report.change_context(EvmMiroError))?;
bar.inc(1);
Ok::<(usize, String), Report<EvmMiroError>>((index, frame_id))
});
}
let mut created: Vec<Option<String>> = vec![None; plans.len()];
while let Some(joined) = creating.join_next().await {
let (index, frame_id) = joined
.map_err(|e| Report::new(EvmMiroError).attach_printable(e.to_string()))??;
created[index] = Some(frame_id);
}
bar.finish_and_clear();
let frame_ids: Vec<String> = created
.into_iter()
.map(|id| id.expect("every planned frame was created or the run failed"))
.collect();
let urls: HashMap<String, String> = plans
.iter()
.zip(frame_ids.iter())
.map(|(plan, frame_id)| (plan.title.clone(), client.frame_url(frame_id)))
.collect();
let urls = std::sync::Arc::new(urls);
let plans: Vec<std::sync::Arc<FramePlan>> =
plans.into_iter().map(std::sync::Arc::new).collect();
let mut pending = plans.iter().cloned().zip(frame_ids.into_iter());
let mut drawing = tokio::task::JoinSet::new();
let mut failure: Option<Report<EvmMiroError>> = None;
loop {
while drawing.len() < CONCURRENT_FRAMES {
let Some((plan, frame_id)) = pending.next() else {
break;
};
let urls = urls.clone();
let options = root_options.clone();
let client = client.clone();
let cluster_root = title.clone();
drawing.spawn(async move {
draw_one(&plan, &frame_id, &cluster_root, &urls, &options, &client).await
});
}
let Some(joined) = drawing.join_next().await else {
break;
};
match joined {
Ok(Ok(())) => {}
Ok(Err(report)) => failure = failure.or(Some(report)),
Err(join_error) => {
failure = failure.or_else(|| {
Some(Report::new(EvmMiroError).attach_printable(join_error.to_string()))
})
}
}
}
if let Some(report) = failure {
return Err(report);
}
if !options.dry_run {
let old_ids: HashSet<String> = 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 && !previous_extras.is_empty() {
println!(
"\n {} putting back {} drawing(s) from the previous deployment",
"↻".yellow(),
previous_extras.len()
);
for (frame, symbol, with_documentation) in previous_extras {
let dependency = (frame != title).then(|| frame.clone());
let outcome = crate::batbelt::evm::miro::screenshot::run(
crate::batbelt::evm::miro::screenshot::ScreenshotOptions {
name: Some(symbol.clone()),
deployment: Some(title.clone()),
dependency,
file: None,
lines: None,
with_documentation,
grow: false,
},
)
.await;
if let Err(report) = outcome {
println!(
" {} {} on {}: {}",
"skipped".yellow(),
symbol,
frame,
report.current_context()
);
}
}
}
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))
}
fn back_card_slot(
frame_width: f64,
frame_height: f64,
occupied: &[(f64, f64, f64, f64)],
index: usize,
) -> Option<(f64, f64)> {
const MARGIN: f64 = 200.0;
let x = frame_width - MARGIN - LINK_CARD_WIDTH / 2.0;
let y = frame_height
- MARGIN
- LINK_CARD_HEIGHT / 2.0
- index as f64 * (LINK_CARD_HEIGHT + MARGIN / 2.0);
if y - LINK_CARD_HEIGHT / 2.0 < MARGIN {
return None; }
let clear = occupied.iter().all(|(ox, oy, ow, oh)| {
(x - ox).abs() * 2.0 >= LINK_CARD_WIDTH + ow
|| (y - oy).abs() * 2.0 >= LINK_CARD_HEIGHT + oh
});
clear.then_some((x, y))
}
fn plan_one(
metadata: &EvmBatMetadata,
contract_name: &str,
function_name: &str,
root_file: &str,
options: &AutoDeployOptions,
allocator: &mut ShelfAllocator,
cluster_depth: usize,
framed: &HashSet<String>,
) -> Result<Option<FramePlan>> {
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(None);
}
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 graph_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(),
(graph_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> = framed.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);
let late_cuts = if options.inline_all {
0
} else {
cut_crossing_shared(&mut nodes, &mut edges, &root_id)
};
if late_cuts > 0 {
println!(
" {} {} more call(s) flying over a column replaced by a card",
"↳".blue(),
late_cuts
);
}
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 drawn_for_amber = drawn_screen_ids(&nodes);
let amber_lines: HashMap<String, Vec<usize>> = nodes
.iter()
.map(|node| {
let mut lines = node.external_call_lines.clone();
lines.extend(surviving_external_calls(node, &drawn_for_amber).map(|(line, _, _)| *line));
lines.sort_unstable();
lines.dedup();
(node.id.clone(), lines)
})
.collect();
for node in nodes.iter_mut() {
if let Some(lines) = amber_lines.get(&node.id) {
node.external_call_lines = lines.clone();
}
}
let card_order = card_reading_order(&nodes, &edges, &root_id);
let (frame_x, frame_y) = allocator.place_in_outline(
layout.frame_width,
layout.frame_height,
cluster_depth,
!card_order.is_empty(),
);
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(None);
}
let plan = FramePlan {
title,
cluster_depth,
nodes,
edges,
anchors,
layout,
frame_x,
frame_y,
card_order,
origins: Vec::new(),
};
Ok(Some(plan))
}
const CONCURRENT_FRAMES: usize = 16;
fn traced_names(lines: &[String]) -> Vec<TracedName> {
use crate::batbelt::silicon::{TraceKind, TRACE_COLORS};
let limit = TRACE_COLORS.len();
let parameters = signature_parameters(lines);
let body = lines.join("\n");
let mut carried: Vec<String> = named_returns(lines)
.into_iter()
.filter(|name| !parameters.contains(name))
.collect();
let counters = crate::batbelt::evm::parser::call_resolver::extract_loop_variables(&body);
let mut locals: Vec<String> =
crate::batbelt::evm::parser::call_resolver::extract_local_types(&body)
.into_iter()
.map(|(name, _)| name)
.chain(yul_locals(&body))
.filter(|name| {
name != "$"
&& !counters.contains(name)
&& !parameters.contains(name)
&& !carried.contains(name)
})
.collect();
locals.sort();
locals.dedup();
let uses = |name: &str| crate::batbelt::silicon::count_word(&body, name);
locals.sort_by(|a, b| uses(b).cmp(&uses(a)).then(a.cmp(b)));
let returns = carried.len();
carried.extend(locals);
let parameter_count = parameters.len();
let palette = crate::batbelt::silicon::TRACE_COLORS.len();
let mut traced: Vec<TracedName> = parameters
.into_iter()
.enumerate()
.map(|(index, name)| TracedName {
name,
kind: TraceKind::Parameter,
color: index % palette,
dotted: (index / palette) % 2 == 1,
})
.collect();
let underlined = crate::batbelt::silicon::UNDERLINED_TRACE_COLORS.len();
traced.extend(carried.into_iter().enumerate().map(|(index, name)| TracedName {
name,
kind: if index < returns { TraceKind::NamedReturn } else { TraceKind::Local },
color: (parameter_count + index) % underlined,
dotted: (index / underlined) % 2 == 1,
}));
traced
}
fn yul_locals(body: &str) -> Vec<String> {
let mut names = Vec::new();
for line in body.lines() {
let line = line.split("//").next().unwrap_or(line);
let Some(at) = line.find("let ") else { continue };
let before_ok = line[..at]
.chars()
.next_back()
.is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '$'));
if !before_ok {
continue;
}
let rest = &line[at + 4..];
let declared = rest.split(":=").next().unwrap_or(rest);
for part in declared.split(',') {
let name = part.trim();
if !name.is_empty()
&& name.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '$')
&& !name.chars().next().is_some_and(|c| c.is_ascii_digit())
{
names.push(name.to_string());
}
}
}
names
}
fn named_returns(lines: &[String]) -> Vec<String> {
let joined = lines.join("\n");
let Some(at) = joined.find("returns") else {
return Vec::new();
};
let Some(open) = joined[at..].find('(').map(|p| at + p) else {
return Vec::new();
};
let Some(close) = matching_paren(&joined, open) else {
return Vec::new();
};
split_parameters(&joined[open + 1..close])
}
fn signature_parameters(lines: &[String]) -> Vec<String> {
let joined = lines.join("\n");
let Some(open) = joined.find("function ").and_then(|at| joined[at..].find('(').map(|p| at + p))
else {
return Vec::new();
};
let Some(close) = matching_paren(&joined, open) else {
return Vec::new();
};
split_parameters(&joined[open + 1..close])
}
fn matching_paren(text: &str, open: usize) -> Option<usize> {
let mut depth = 0usize;
for (index, character) in text[open..].char_indices() {
match character {
'(' => depth += 1,
')' => {
depth -= 1;
if depth == 0 {
return Some(open + index);
}
}
_ => {}
}
}
None
}
fn split_parameters(list: &str) -> Vec<String> {
let mut names = Vec::new();
let mut depth = 0usize;
let mut current = String::new();
for character in list.chars() {
match character {
'(' | '[' => {
depth += 1;
current.push(character);
}
')' | ']' => {
depth = depth.saturating_sub(1);
current.push(character);
}
',' if depth == 0 => {
names.extend(parameter_name(¤t));
current.clear();
}
_ => current.push(character),
}
}
names.extend(parameter_name(¤t));
names
}
fn parameter_name(declaration: &str) -> Option<String> {
let words: Vec<&str> = declaration.split_whitespace().collect();
if words.len() < 2 {
return None;
}
let word = words.last()?;
let word = word.trim_matches(|c: char| !(c.is_alphanumeric() || c == '_' || c == '$'));
let is_type = |w: &str| {
matches!(w, "memory" | "calldata" | "storage" | "payable" | "indexed")
|| w.contains('.')
};
(!word.is_empty() && word != "$" && !is_type(word)).then(|| word.to_string())
}
fn partial_render_name(file_name: &str, pid: u32) -> String {
format!("{}.part{pid}.js", file_name.trim_end_matches(".js"))
}
fn frame_fill_for(nodes: &[GraphNode]) -> Option<&'static str> {
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
}
}
fn plan_cluster(
metadata: &EvmBatMetadata,
contract_name: &str,
function_name: &str,
root_file: &str,
options: &AutoDeployOptions,
allocator: &mut ShelfAllocator,
) -> Result<Vec<FramePlan>> {
let mut plans: Vec<FramePlan> = Vec::new();
let mut framed: HashSet<String> = HashSet::new();
plan_subtree(
metadata,
contract_name,
function_name,
root_file,
options,
allocator,
0,
&mut framed,
&mut plans,
None,
)?;
Ok(plans)
}
#[allow(clippy::too_many_arguments)]
fn plan_subtree(
metadata: &EvmBatMetadata,
contract_name: &str,
function_name: &str,
root_file: &str,
options: &AutoDeployOptions,
allocator: &mut ShelfAllocator,
cluster_depth: usize,
framed: &mut HashSet<String>,
plans: &mut Vec<FramePlan>,
origin: Option<&str>,
) -> Result<()> {
let title = format!("{contract_name}.{function_name}");
if let Some(existing) = plans.iter_mut().find(|plan| plan.title == title) {
if let Some(origin) = origin {
existing.origins.push(origin.to_string());
}
println!(" {} is already in the plan", title.blue());
return Ok(());
}
let Some(plan) = plan_one(
metadata,
contract_name,
function_name,
root_file,
options,
allocator,
cluster_depth,
framed,
)?
else {
return Ok(());
};
framed.insert(title.clone());
let cards = plan.card_order.clone();
let index = plans.len();
plans.push(plan);
if let Some(origin) = origin {
plans[index].origins.push(origin.to_string());
}
for (target, target_file) in cards {
let Some((contract, function)) = target.split_once('.') else {
continue;
};
plan_subtree(
metadata,
contract,
function,
&target_file,
options,
allocator,
cluster_depth + 1,
framed,
plans,
Some(&title),
)?;
}
Ok(())
}
struct FramePlan {
title: String,
cluster_depth: usize,
nodes: Vec<GraphNode>,
edges: Vec<GraphEdge>,
anchors: Vec<RelativeAnchor>,
layout: GraphLayout,
frame_x: f64,
frame_y: f64,
card_order: Vec<(String, String)>,
origins: Vec<String>,
}
async fn draw_one(
plan: &FramePlan,
frame_id: &str,
cluster_root: &str,
urls: &HashMap<String, String>,
options: &AutoDeployOptions,
client: &MiroClient,
) -> Result<()> {
let FramePlan {
title,
cluster_depth: _,
nodes,
edges,
anchors,
layout,
frame_x,
frame_y,
card_order: _,
origins,
} = plan;
let (frame_x, frame_y) = (*frame_x, *frame_y);
let by_id: HashMap<&str, &GraphNode> = nodes.iter().map(|n| (n.id.as_str(), n)).collect();
let reuse: HashMap<String, (String, u32, u32)> = HashMap::new();
let frame_id = frame_id.to_string();
println!("\n{} {}", "▸".blue(), title.bold());
let target_frames = urls;
let mut back_cards: Vec<(String, String)> = Vec::new();
let occupied: Vec<(f64, f64, f64, f64)> = layout
.nodes
.iter()
.map(|placed| (placed.x, placed.y, placed.width, placed.height))
.collect();
for (index, origin_title) in origins.iter().enumerate() {
let Some(origin_url) = urls.get(origin_title) else {
continue;
};
match back_card_slot(layout.frame_width, layout.frame_height, &occupied, index) {
Some((x, y)) => {
let id = client
.create_back_card(
&frame_id,
&format!("↑ {origin_title}"),
origin_url,
x,
y,
LINK_CARD_WIDTH,
LINK_CARD_HEIGHT,
)
.await
.change_context(EvmMiroError)?;
back_cards.push((origin_title.clone(), id));
}
None => println!(
" {} no room for the way back to {}; it is in the registry",
"note:".yellow(),
origin_title
),
}
}
let frame_url = client.frame_url(&frame_id);
let mut record = AutoDeployedFrame {
entry_point: title.clone(),
back_cards,
type_frame: false,
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: cluster_root.to_string(),
};
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,
stroke: ConnectorStroke,
}
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>, HashMap<String, ConnectorStroke>) = {
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 callee_conflicts: HashMap<&str, HashSet<&str>> = HashMap::new();
for (index, edge) in edges.iter().enumerate() {
let entry = callee_conflicts.entry(edge.to.as_str()).or_default();
for other in &conflicts[index] {
let rival = edges[*other].to.as_str();
if rival != edge.to.as_str() {
entry.insert(rival);
}
}
}
let first_gutter: HashMap<&str, usize> = {
let mut first: HashMap<&str, usize> = HashMap::new();
for index in &order {
if let Some((gutter, _)) = lane_index.get(index) {
first.entry(edges[*index].to.as_str()).or_insert(*gutter);
}
}
first
};
let mut palette_of: HashMap<usize, Vec<usize>> = HashMap::new();
let by_callee = color_callees(&callee_conflicts, |callee| {
let gutter = first_gutter.get(callee).copied().unwrap_or(usize::MAX);
palette_of
.entry(gutter)
.or_insert_with(|| {
let mut order: Vec<usize> = (0..DEPTH_COLORS.len()).collect();
order.shuffle(&mut rand::thread_rng());
order
})
.clone()
});
let strokes: HashMap<String, ConnectorStroke> = by_callee
.iter()
.map(|(callee, (_, stroke))| (callee.to_string(), *stroke))
.collect();
let colors: Vec<String> = edges
.iter()
.map(|edge| {
let (color, _) = by_callee.get(edge.to.as_str()).copied().unwrap_or_default();
DEPTH_COLORS[color].to_string()
})
.collect();
(colors, strokes)
};
let (edge_color, callee_stroke) = edge_color;
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()),
stroke: callee_stroke.get(&edge.to).copied().unwrap_or_default(),
};
let stroke = if back_edges.contains(&(edge.from.clone(), edge.to.clone())) {
ConnectorStroke::Dashed
} else {
ConnectorStroke::Solid
};
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(),
stroke: ConnectorStroke::Solid,
caption: None,
arrow: ArrowEnd::Start,
},
callees: Vec::new(),
}
});
group.style.stroke = group.style.stroke.strongest(stroke);
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();
route_style.stroke = route_style.stroke.strongest(link.stroke);
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| matches_ignore(pattern, &contract.name, &contract.file_path))
}
pub(crate) fn matches_ignore(pattern: &str, name: &str, file_path: &str) -> bool {
let pattern = pattern.trim().trim_matches('/');
if pattern.is_empty() {
return false;
}
if name == pattern {
return true;
}
let wanted: Vec<&str> = pattern.split('/').filter(|part| !part.is_empty()).collect();
let segments: Vec<&str> = file_path
.split('/')
.filter(|part| !part.is_empty() && *part != ".")
.map(|part| part.strip_suffix(".sol").unwrap_or(part))
.collect();
!wanted.is_empty() && segments.windows(wanted.len()).any(|window| window == wanted)
}
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 external_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 skipped_by_ignore: HashMap<String, usize> = HashMap::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 caller_external_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,
));
} else if leads_to_external(
metadata,
reached_contract,
&reached_function,
&write_options,
&write_definer_map,
&mut external_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_external_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));
}
}
}
else if let Some(variable) =
contract.state_variables.iter().find(|v| v.name == receiver)
{
let type_name = variable.type_name.trim().to_string();
let is_interface =
metadata.interfaces.iter().any(|i| i.name == type_name)
|| metadata.contracts.iter().any(|c| {
c.name == type_name
&& c.contract_type == EvmContractType::Interface
});
if is_interface && !metadata.resolutions.contains_key(&type_name) {
let names: Vec<String> =
definer_map.get(method).cloned().unwrap_or_default();
let writes = names.iter().any(|name| {
metadata
.contract_in_scope(name, &contract.file_path)
.and_then(|c| {
find_function(metadata, &c.name, &c.file_path, method, arity)
})
.is_some_and(|(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.clone(),
candidates: names,
assigned_in: Vec::new(),
},
);
} else {
let entry = left_out_reads
.entry((type_name, 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) {
*skipped_by_ignore.entry(target_contract.name.clone()).or_insert(0) += 1;
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) = boundary_line_index(&slice, &uec.receiver, &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 !caller_external_calls.is_empty() {
caller_external_calls.sort_unstable();
caller_external_calls.dedup();
if let Some(node) = nodes.iter_mut().find(|n| n.id == current.node_id) {
node.external_call_sites = caller_external_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()
);
}
if !skipped_by_ignore.is_empty() {
let mut listed: Vec<(String, usize)> = skipped_by_ignore.into_iter().collect();
listed.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
println!(
" {} left out {}",
"note:".yellow(),
listed
.iter()
.map(|(name, count)| format!("{name} ({count} call site(s))"))
.collect::<Vec<_>>()
.join(", ")
);
}
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(),
external_call_sites: 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 boundary_line_index(slice: &[String], receiver: &str, method: &str) -> Option<usize> {
slice
.iter()
.position(|line| {
line_has_call(line, method) && !receiver.is_empty() && line.contains(receiver)
})
.or_else(|| {
slice
.iter()
.enumerate()
.skip(1)
.find(|(_, line)| line_has_call(line, method))
.map(|(index, _)| index)
})
}
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'(' || 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(),
external_call_sites: 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 color_callees<'a>(
conflicts: &HashMap<&'a str, HashSet<&'a str>>,
mut palette_for: impl FnMut(&'a str) -> Vec<usize>,
) -> HashMap<&'a str, (usize, ConnectorStroke)> {
let mut callees: Vec<&'a str> = conflicts.keys().copied().collect();
callees.sort_by(|a, b| {
let degree = |id: &&str| conflicts.get(*id).map(|set| set.len()).unwrap_or(0);
degree(b).cmp(°ree(a)).then(a.cmp(b))
});
let mut chosen: HashMap<&'a str, (usize, ConnectorStroke)> = HashMap::new();
for callee in callees {
let used: HashSet<(usize, ConnectorStroke)> = conflicts
.get(callee)
.map(|rivals| rivals.iter().filter_map(|rival| chosen.get(rival).copied()).collect())
.unwrap_or_default();
let palette = palette_for(callee);
let combinations = [ConnectorStroke::Solid, ConnectorStroke::Dotted]
.into_iter()
.flat_map(|stroke| palette.iter().copied().map(move |color| (color, stroke)));
let free = combinations.clone().find(|pair| !used.contains(pair));
chosen.insert(
callee,
free.or_else(|| combinations.into_iter().next())
.unwrap_or((0, ConnectorStroke::Solid)),
);
}
chosen
}
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 surviving_external_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.external_call_sites
.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_external(
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.unknown_external_calls.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 {
let method = callee.split('.').next_back().unwrap_or(&callee);
let mut reached: Vec<(&ContractMetadata, FunctionMetadata)> =
match resolve_call(metadata, contract, &callee, None, options, definer_map) {
Some((found_contract, _)) => found_contract
.functions
.iter()
.filter(|f| f.name == method)
.map(|f| (found_contract, f.clone()))
.collect(),
None => definer_map
.get(callee.split('.').next_back().unwrap_or(&callee))
.map(|definers| {
definers
.iter()
.filter_map(|name| {
let target =
metadata.contract_in_scope(name, &contract.file_path)?;
find_function(
metadata,
&target.name,
&target.file_path,
callee.split('.').next_back().unwrap_or(&callee),
None,
)
})
.collect()
})
.unwrap_or_default(),
};
for (next_contract, next_function) in reached.drain(..) {
if leads_to_external(
metadata,
next_contract,
&next_function,
options,
definer_map,
memo,
stack,
) {
reaches = true;
break;
}
}
if reaches {
break;
}
}
stack.remove(&id);
memo.insert(id, reaches);
reaches
}
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);
}
}
}
for library in &caller_contract.using_libraries {
let Some(contract) = metadata.contract_in_scope(library, &caller_contract.file_path) else {
continue;
};
for arity in [arg_count.map(|count| count + 1), arg_count, None] {
if let Some(found) =
find_function(metadata, &contract.name, &contract.file_path, method, arity)
{
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(bound) = metadata.resolutions.get(clean) {
return vec![bound.clone()];
}
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() {
let traced = traced_names(&lines);
let partial = partial_render_name(&file_name, std::process::id());
silicon::create_figure_tracing(
&lines.join("\n"),
&destination,
&partial,
line_offset,
Some(REFERENCE_FONT),
true,
&traced,
);
let partial_path = format!("{destination}/{partial}.png");
std::fs::rename(&partial_path, &png_path)
.map_err(|e| format!("cannot store {png_path}: {e}"))?;
}
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);
}
}
}
}
const KEEP_FIGURES_ENV: &str = "BAT_CLI_KEEP_FIGURES";
fn cleanup(nodes: &[GraphNode]) {
if std::env::var_os(KEEP_FIGURES_ENV).is_some() {
return;
}
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;
let mut exhausted: HashSet<String> = HashSet::new();
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 {
if exhausted.contains(v) {
continue;
}
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() {
exhausted.insert(victim.clone());
continue;
}
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(),
external_call_sites: 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(),
external_call_sites: 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(),
external_call_sites: 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(),
external_call_sites: 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());
}
}
fn card_reading_order(
nodes: &[GraphNode],
edges: &[GraphEdge],
root_id: &str,
) -> Vec<(String, String)> {
let mut out_edges: HashMap<&str, Vec<&GraphEdge>> = HashMap::new();
for edge in edges {
out_edges.entry(edge.from.as_str()).or_default().push(edge);
}
for calls in out_edges.values_mut() {
calls.sort_by_key(|edge| (edge.line_in_slice, edge.column));
}
let by_id: HashMap<&str, &GraphNode> = nodes.iter().map(|n| (n.id.as_str(), n)).collect();
let mut ordered: Vec<(String, String)> = Vec::new();
let mut listed: HashSet<String> = HashSet::new();
let mut visited: HashSet<String> = HashSet::new();
fn walk(
id: &str,
out_edges: &HashMap<&str, Vec<&GraphEdge>>,
by_id: &HashMap<&str, &GraphNode>,
visited: &mut HashSet<String>,
listed: &mut HashSet<String>,
ordered: &mut Vec<(String, String)>,
) {
if !visited.insert(id.to_string()) {
return;
}
let Some(calls) = out_edges.get(id) else {
return;
};
for edge in calls {
match by_id.get(edge.to.as_str()).map(|node| &node.kind) {
Some(NodeKind::Link { target, file }) => {
if listed.insert(target.clone()) {
ordered.push((target.clone(), file.clone()));
}
}
Some(NodeKind::Screenshot) => {
walk(&edge.to, out_edges, by_id, visited, listed, ordered)
}
None => {}
}
}
}
walk(root_id, &out_edges, &by_id, &mut visited, &mut listed, &mut ordered);
for node in nodes {
if let NodeKind::Link { target, file } = &node.kind {
if listed.insert(target.clone()) {
ordered.push((target.clone(), file.clone()));
}
}
}
ordered
}
pub async fn run_relink(
entry_point: Option<String>,
deployment: 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 candidates: Vec<&AutoDeployedFrame> = metadata
.miro
.auto
.frames
.iter()
.filter(|frame| {
frame.entry_point == entry_point
&& deployment.as_ref().is_none_or(|root| &frame.cluster_root == root)
})
.collect();
if deployment.is_none() && candidates.len() > 1 {
let listed = candidates
.iter()
.map(|frame| format!(" {} ({})", frame.cluster_root, frame.frame_url))
.collect::<Vec<_>>()
.join("\n");
return Err(Report::new(EvmMiroError)
.attach_printable(format!(
"`{entry_point}` was drawn by {} deployments:\n{listed}",
candidates.len()
))
.attach(crate::Suggestion(
"say which one, with --deployment <entry point>".to_string(),
)));
}
let Some(record) = candidates.first().map(|frame| (*frame).clone()) 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, deployment.as_deref(), &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,
cluster_root: Option<&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
&& cluster_root.is_none_or(|root| frame.cluster_root == root)
})
.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,
with_documentation: shot.with_documentation,
}
})
})
.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,
cluster_root: Option<&str>,
client: &MiroClient,
) -> Result<Option<AutoDeployedFrame>> {
let record = {
let metadata = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
let matching: Vec<&AutoDeployedFrame> = metadata
.miro
.auto
.frames
.iter()
.filter(|frame| {
frame.entry_point == title
&& cluster_root.is_none_or(|root| frame.cluster_root == root)
})
.collect();
if cluster_root.is_none() && matching.len() > 1 {
let candidates = matching
.iter()
.map(|frame| format!(" {} ({})", frame.cluster_root, frame.frame_url))
.collect::<Vec<_>>()
.join("\n");
return Err(Report::new(EvmMiroError)
.attach_printable(format!(
"`{title}` was drawn by {} deployments:\n{candidates}",
matching.len()
))
.attach(crate::Suggestion(
"say which one, with --deployment <entry point>".to_string(),
)));
}
matching.first().map(|frame| (*frame).clone())
};
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, cluster_root, 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();
let deployment = cluster_root.map(|root| root.to_string());
EvmBatMetadata::update_metadata(move |metadata| {
metadata.miro.auto.frames.retain(|frame| {
frame.entry_point != owner
|| deployment.as_ref().is_some_and(|root| &frame.cluster_root != root)
});
})
.change_context(EvmMiroError)?;
Ok(None)
}
#[cfg(test)]
mod ignore_test {
use super::matches_ignore;
#[test]
fn a_name_matches_exactly_and_a_path_by_whole_segments() {
assert!(matches_ignore("Math", "Math", "./lib/oz/contracts/utils/math/Math.sol"));
assert!(matches_ignore(
"openzeppelin-contracts/contracts/utils",
"Math",
"./lib/openzeppelin-contracts/contracts/utils/math/Math.sol"
));
assert!(matches_ignore("utils/math", "Math", "./lib/oz/contracts/utils/math/Math.sol"));
assert!(!matches_ignore(
"Math",
"CollRebalancerMath",
"./src/hooks/everlong/lev/CollRebalancerMath.sol"
));
assert!(!matches_ignore("Curve", "AlmCurve", "./src/hooks/everlong/AlmCurve.sol"));
assert!(!matches_ignore("math", "X", "./src/mathlib/X.sol"));
assert!(!matches_ignore("", "X", "./src/X.sol"));
}
}
#[cfg(test)]
mod color_test {
use super::*;
fn conflicts<'a>(pairs: &[(&'a str, &'a str)]) -> HashMap<&'a str, HashSet<&'a str>> {
let mut map: HashMap<&str, HashSet<&str>> = HashMap::new();
for (a, b) in pairs {
map.entry(a).or_default().insert(b);
map.entry(b).or_default().insert(a);
}
map
}
#[test]
fn conflicting_callees_never_share_a_colour() {
let graph = conflicts(&[("a", "b"), ("b", "c"), ("c", "a"), ("c", "d")]);
let chosen = color_callees(&graph, |_| (0..DEPTH_COLORS.len()).collect());
assert!(
chosen.values().all(|(_, stroke)| *stroke == ConnectorStroke::Solid),
"four callees fit in a palette of {}",
DEPTH_COLORS.len()
);
for (callee, rivals) in &graph {
for rival in rivals {
assert_ne!(
chosen[callee], chosen[rival],
"{callee} and {rival} are compared and share a colour"
);
}
}
}
#[test]
fn a_callee_has_exactly_one_colour() {
let graph = conflicts(&[("helper", "a"), ("helper", "b")]);
let chosen = color_callees(&graph, |_| (0..DEPTH_COLORS.len()).collect());
assert!(chosen.contains_key("helper"));
assert_eq!(chosen.len(), 3);
}
#[test]
fn a_palette_that_runs_out_keeps_every_pair_distinct() {
let names: Vec<String> = (0..DEPTH_COLORS.len() + 2).map(|i| format!("f{i}")).collect();
let mut pairs: Vec<(&str, &str)> = Vec::new();
for i in 0..names.len() {
for j in (i + 1)..names.len() {
pairs.push((names[i].as_str(), names[j].as_str()));
}
}
let graph = conflicts(&pairs);
let chosen = color_callees(&graph, |_| (0..DEPTH_COLORS.len()).collect());
assert_eq!(chosen.len(), DEPTH_COLORS.len() + 2);
let marks: HashSet<(usize, ConnectorStroke)> = chosen.values().copied().collect();
assert_eq!(marks.len(), chosen.len(), "two callees share a colour AND a stroke");
assert_eq!(
chosen.values().filter(|(_, s)| *s == ConnectorStroke::Dotted).count(),
2,
"a clique of {} in a palette of {} spills exactly twice",
names.len(),
DEPTH_COLORS.len()
);
}
#[test]
fn the_overflow_never_uses_the_cycle_stroke() {
let names: Vec<String> = (0..DEPTH_COLORS.len() + 1).map(|i| format!("f{i}")).collect();
let mut pairs: Vec<(&str, &str)> = Vec::new();
for i in 0..names.len() {
for j in (i + 1)..names.len() {
pairs.push((names[i].as_str(), names[j].as_str()));
}
}
let chosen = color_callees(&conflicts(&pairs), |_| (0..DEPTH_COLORS.len()).collect());
assert!(chosen.values().all(|(_, s)| *s != ConnectorStroke::Dashed));
}
}
#[cfg(test)]
mod reading_order_test {
use super::*;
fn screenshot(id: &str) -> GraphNode {
GraphNode {
id: id.to_string(),
label: id.replace("::", "."),
kind: NodeKind::Screenshot,
file_path: String::new(),
start_line: 1,
end_line: 2,
depth: 0,
font_size: 32,
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(),
external_call_sites: Vec::new(),
}
}
fn card(id: &str, target: &str) -> GraphNode {
let mut node = screenshot(id);
node.kind = NodeKind::Link {
target: target.to_string(),
file: format!("./src/{target}.sol"),
};
node
}
fn edge(from: &str, to: &str, line: usize) -> GraphEdge {
GraphEdge {
from: from.to_string(),
to: to.to_string(),
line_in_slice: line,
column: 0,
symbol: to.to_string(),
}
}
#[test]
fn cards_come_in_the_order_a_reader_meets_them() {
let nodes = vec![
screenshot("R"),
screenshot("A"),
card("c_late", "Late"),
card("c_deep", "Deep"),
];
let edges = vec![
edge("R", "A", 10),
edge("R", "c_late", 20),
edge("A", "c_deep", 5),
];
let order = card_reading_order(&nodes, &edges, "R");
assert_eq!(
order.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(),
vec!["Deep", "Late"],
"the card inside the first callee is met before the root's later call"
);
}
#[test]
fn calls_are_read_top_to_bottom() {
let nodes = vec![screenshot("R"), card("c1", "Third"), card("c2", "First"), card("c3", "Second")];
let edges = vec![edge("R", "c1", 30), edge("R", "c2", 10), edge("R", "c3", 20)];
let order = card_reading_order(&nodes, &edges, "R");
assert_eq!(
order.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(),
vec!["First", "Second", "Third"]
);
}
#[test]
fn a_target_is_listed_once_and_a_cycle_ends() {
let nodes = vec![
screenshot("R"),
screenshot("A"),
card("c1", "Shared"),
card("c2", "Shared"),
];
let edges = vec![
edge("R", "A", 10),
edge("A", "R", 1), edge("R", "c1", 20),
edge("A", "c2", 5),
];
let order = card_reading_order(&nodes, &edges, "R");
assert_eq!(order.len(), 1, "one frame for one target: {order:?}");
assert_eq!(order[0].0, "Shared");
}
#[test]
fn an_unreachable_card_is_not_dropped() {
let nodes = vec![screenshot("R"), card("orphan", "Orphan")];
let order = card_reading_order(&nodes, &[], "R");
assert_eq!(order.iter().map(|(t, _)| t.as_str()).collect::<Vec<_>>(), vec!["Orphan"]);
}
}
#[cfg(test)]
mod signature_test {
use super::*;
fn lines(text: &str) -> Vec<String> {
text.lines().map(|l| l.to_string()).collect()
}
#[test]
fn locals_fill_the_slots_the_parameters_leave_busiest_first() {
let slice = lines(
" function f(uint256 amount) internal {\n uint256 rare = 1;\n uint256 often = 2;\n often = often + often + rare;\n }",
);
let traced = traced_names(&slice);
let names: Vec<&str> = traced.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["amount", "often", "rare"]);
assert_eq!(traced[0].kind, crate::batbelt::silicon::TraceKind::Parameter);
assert_eq!(traced[1].kind, crate::batbelt::silicon::TraceKind::Local);
}
#[test]
fn a_tuple_declaration_is_traced_too() {
let slice = lines(
" function f() internal {\n (uint256 p0, uint48 ts) = Store.price();\n use(p0, ts, p0);\n }",
);
let names: Vec<String> = traced_names(&slice).into_iter().map(|t| t.name).collect();
assert!(names.contains(&"p0".to_string()), "{names:?}");
assert!(names.contains(&"ts".to_string()), "{names:?}");
}
#[test]
fn a_loop_counter_is_not_traced() {
let slice = lines(
" function f() internal {\n uint256 n = size();\n for (uint256 i = 0; i < n; ++i) {\n use(i, n);\n }\n }",
);
let names: Vec<String> = traced_names(&slice).into_iter().map(|t| t.name).collect();
assert!(!names.contains(&"i".to_string()), "{names:?}");
assert!(names.contains(&"n".to_string()), "{names:?}");
}
#[test]
fn a_variable_named_like_a_counter_is_still_traced() {
let slice = lines(
" function f() internal {\n uint256 i = pick();\n use(i, i);\n }",
);
let names: Vec<String> = traced_names(&slice).into_iter().map(|t| t.name).collect();
assert!(names.contains(&"i".to_string()), "{names:?}");
}
#[test]
fn more_names_than_colours_are_all_still_marked() {
use crate::batbelt::silicon::{TraceKind, UNDERLINED_TRACE_COLORS};
let count = UNDERLINED_TRACE_COLORS.len() * 2 + 3;
let mut body = String::from(" function f() internal {\n");
for i in 0..count {
body.push_str(&format!(" uint256 v{i} = {i};\n use(v{i}, v{i});\n"));
}
body.push_str(" }");
let traced = traced_names(&lines(&body));
assert_eq!(traced.len(), count, "every local is marked");
let solid = traced.iter().filter(|t| !t.dotted).count();
assert_eq!(solid, UNDERLINED_TRACE_COLORS.len() + 3);
assert_eq!(traced.iter().filter(|t| t.dotted).count(), UNDERLINED_TRACE_COLORS.len());
assert!(traced.iter().all(|t| t.color < UNDERLINED_TRACE_COLORS.len()));
let _ = TraceKind::Local;
}
#[test]
fn more_parameters_than_colours_are_all_still_marked() {
use crate::batbelt::silicon::TRACE_COLORS;
let count = TRACE_COLORS.len() + 2;
let params: Vec<String> = (0..count).map(|i| format!("uint256 a{i}")).collect();
let slice = lines(&format!(" function f({}) internal {{}}", params.join(", ")));
let traced = traced_names(&slice);
assert_eq!(traced.len(), count, "every parameter is marked");
assert!(traced.iter().all(|t| t.color < TRACE_COLORS.len()));
}
#[test]
fn a_yul_declaration_is_traced() {
let slice = lines(
" function f(int256 x) internal pure returns (int256 r) {\n assembly {\n let p := sub(x, 1)\n p := mul(p, p)\n r := p\n }\n }",
);
let names: Vec<String> = traced_names(&slice).into_iter().map(|t| t.name).collect();
assert!(names.contains(&"p".to_string()), "{names:?}");
}
#[test]
fn several_names_declared_at_once_in_yul_are_all_traced() {
let slice = lines(
" function f() internal {\n assembly {\n let a, b := g()\n use(a, b)\n }\n }",
);
let names: Vec<String> = traced_names(&slice).into_iter().map(|t| t.name).collect();
assert!(names.contains(&"a".to_string()) && names.contains(&"b".to_string()), "{names:?}");
}
#[test]
fn the_underlined_sequence_starts_past_the_parameters() {
let slice = lines(
" function f(int256 x) internal pure returns (int256 r) {\n r = x;\n }",
);
let traced = traced_names(&slice);
let parameter = traced.iter().find(|t| t.name == "x").expect("x is traced");
let carried = traced.iter().find(|t| t.name == "r").expect("r is traced");
assert_ne!(
crate::batbelt::silicon::palette(parameter.kind)[parameter.color],
crate::batbelt::silicon::palette(carried.kind)[carried.color],
);
}
#[test]
fn a_named_return_is_traced_apart_from_the_locals() {
use crate::batbelt::silicon::TraceKind;
let slice = lines(
" function f(uint256 a) internal returns (Plan memory p) {\n uint256 x = a;\n p.one = x;\n p.two = x;\n }",
);
let traced = traced_names(&slice);
let carried = traced
.iter()
.find(|t| t.kind == TraceKind::NamedReturn)
.expect("the named return is traced");
assert_eq!(carried.name, "p");
for local in traced.iter().filter(|t| t.kind == TraceKind::Local) {
assert_ne!(local.color, carried.color, "{} reuses the return's colour", local.name);
}
}
#[test]
fn a_parameter_and_a_local_may_share_a_colour() {
use crate::batbelt::silicon::TraceKind;
let slice = lines(
" function f(uint256 a) internal {\n uint256 b = a;\n use(b, b);\n }",
);
let traced = traced_names(&slice);
assert_eq!(traced.iter().filter(|t| t.kind == TraceKind::Parameter).count(), 1);
assert_eq!(traced.iter().filter(|t| t.kind == TraceKind::Local).count(), 1);
}
#[test]
fn parameters_come_out_in_order_with_their_names_only() {
let slice = lines(
"// src/core/flamm/FLAMMLoanSwapLib.sol\n\n function _plan(FLAMMStore.S storage $, address hook, address assetIn,\n uint256 amountIn, uint256 supply)\n private\n view\n returns (Plan memory p)\n {",
);
assert_eq!(
signature_parameters(&slice),
vec!["hook", "assetIn", "amountIn", "supply"]
);
}
#[test]
fn a_nested_type_does_not_end_the_parameter_list() {
let slice = lines(" function f(mapping(uint256 => uint256) storage book, uint256[] memory legs) internal {");
assert_eq!(signature_parameters(&slice), vec!["book", "legs"]);
}
#[test]
fn an_unnamed_parameter_contributes_nothing_to_follow() {
let slice = lines(" function f(address, uint256 amount) external {");
assert_eq!(signature_parameters(&slice), vec!["amount"]);
}
#[test]
fn a_bare_type_is_not_a_name() {
let slice = lines(
" function initcodeHash(address a) internal pure returns (bytes32) {\n return keccak256(x);\n }",
);
assert_eq!(signature_parameters(&slice), vec!["a"]);
assert!(named_returns(&slice).is_empty(), "bytes32 is the type, not a name");
}
#[test]
fn a_user_defined_type_without_a_name_is_not_a_name_either() {
let slice = lines(" function f() internal returns (Plan) {");
assert!(named_returns(&slice).is_empty());
}
#[test]
fn a_named_return_of_a_user_type_still_counts() {
let slice = lines(" function f() internal returns (Plan memory p) {");
assert_eq!(named_returns(&slice), vec!["p"]);
}
#[test]
fn a_data_location_standing_last_is_not_a_name() {
let slice = lines(" function f(address payable, bytes32 salt) external {");
assert_eq!(signature_parameters(&slice), vec!["salt"]);
}
#[test]
fn a_slice_with_no_signature_traces_nothing() {
assert!(signature_parameters(&lines(" p.feeWad = f.feeWad;")).is_empty());
}
#[test]
fn the_list_is_capped_at_the_palette() {
let slice = lines(
" function f(uint a, uint b, uint c, uint d, uint e, uint g, uint h, uint i) internal {",
);
assert_eq!(
signature_parameters(&slice).len(),
crate::batbelt::silicon::TRACE_COLORS.len()
);
}
}
#[cfg(test)]
mod render_name_test {
use super::*;
#[test]
fn partial_render_name_keeps_the_extension_last() {
let name = partial_render_name("fn__src_core_flamm_FLAMMGateLib_sol_222_245.js", 4242);
assert!(name.ends_with(".js"), "{name} would be highlighted as Rust");
assert!(name.contains("part4242"), "{name} is not unique to this process");
assert_ne!(name, "fn__src_core_flamm_FLAMMGateLib_sol_222_245.js");
}
#[test]
fn partial_render_name_adds_the_extension_when_missing() {
assert!(partial_render_name("fn_whatever", 7).ends_with(".js"));
}
}
#[cfg(test)]
mod back_card_test {
use super::*;
#[test]
fn the_way_back_goes_in_the_free_corner() {
let (w, h) = (10_000.0, 4_000.0);
let content = [(2_000.0, 1_000.0, 3_000.0, 1_500.0)];
let first = back_card_slot(w, h, &content, 0).expect("the corner is free");
assert!(first.0 > w / 2.0 && first.1 > h / 2.0, "bottom-right: {first:?}");
let second = back_card_slot(w, h, &content, 1).expect("room for two");
assert!((first.0 - second.0).abs() < 1.0, "same column");
assert!(second.1 < first.1 - LINK_CARD_HEIGHT, "clear of the first: {second:?}");
}
#[test]
fn content_in_the_corner_refuses_the_card() {
let (w, h) = (3_000.0, 2_000.0);
let occupying = [(w - 400.0, h - 300.0, 1_200.0, 800.0)];
assert!(back_card_slot(w, h, &occupying, 0).is_none());
}
#[test]
fn stacking_stops_at_the_top() {
let (w, h) = (3_000.0, 1_200.0);
assert!(back_card_slot(w, h, &[], 0).is_some());
assert!(back_card_slot(w, h, &[], 9).is_none());
}
}
#[cfg(test)]
mod marking_test {
use super::*;
use crate::batbelt::evm::metadata::bat_metadata::ExternalUnknownCall;
use crate::batbelt::evm::types::{EvmContractType, EvmMutability, EvmParam, EvmVisibility};
fn function(name: &str, params: usize, external: &[(&str, &str)]) -> FunctionMetadata {
FunctionMetadata {
metadata_id: format!("f_{name}_{params}"),
name: name.to_string(),
contract_name: String::new(),
visibility: EvmVisibility::Internal,
mutability: EvmMutability::NonPayable,
modifiers: Vec::new(),
params: (0..params)
.map(|i| EvmParam {
name: format!("p{i}"),
type_name: "uint256".to_string(),
storage_location: None,
})
.collect(),
returns: Vec::new(),
line: 1,
end_line: 2,
is_constructor: false,
is_stub: false,
storage_writes: Vec::new(),
storage_write_sites: Vec::new(),
unresolved_calls: Vec::new(),
unknown_external_calls: external
.iter()
.map(|(receiver, method)| ExternalUnknownCall {
receiver: receiver.to_string(),
method: method.to_string(),
inferred_type: String::new(),
})
.collect(),
resolved_calls: Vec::new(),
}
}
fn contract(name: &str, using: &[&str], functions: Vec<FunctionMetadata>) -> ContractMetadata {
ContractMetadata {
metadata_id: name.to_string(),
name: name.to_string(),
using_libraries: using.iter().map(|u| u.to_string()).collect(),
file_path: format!("./src/{name}.sol"),
contract_type: EvmContractType::Contract,
base_contracts: Vec::new(),
functions,
state_variables: Vec::new(),
events: Vec::new(),
modifiers: Vec::new(),
line: 1,
external: false,
}
}
#[test]
fn a_recorded_resolution_binds_an_interface_nothing_declares() {
let metadata = EvmBatMetadata {
resolutions: std::collections::HashMap::from([(
"ITrancheController".to_string(),
"TrancheController".to_string(),
)]),
..Default::default()
};
assert_eq!(
implementations_of(&metadata, "ITrancheController"),
vec!["TrancheController".to_string()]
);
}
#[test]
fn without_a_resolution_an_undeclared_interface_stays_unbound() {
let metadata = EvmBatMetadata::default();
assert_eq!(
implementations_of(&metadata, "ITrancheController"),
vec!["ITrancheController".to_string()]
);
}
#[test]
fn a_using_library_resolves_with_the_receiver_as_first_parameter() {
let library = contract("Address", &[], vec![function("functionCall", 3, &[])]);
let caller = contract("Store", &["Address"], vec![function("pull", 1, &[])]);
let metadata = EvmBatMetadata {
contracts: vec![library, caller.clone()],
..Default::default()
};
let definers: HashMap<String, Vec<String>> =
HashMap::from([("functionCall".to_string(), vec!["Address".to_string()])]);
let resolved = resolve_call(
&metadata,
&caller,
"functionCall",
Some(2),
&AutoDeployOptions::default(),
&definers,
);
assert!(resolved.is_some(), "the using directive should bind the call");
assert_eq!(resolved.unwrap().0.name, "Address");
}
#[test]
fn every_overload_is_walked_when_looking_for_a_boundary() {
let library = contract(
"Address",
&[],
vec![
function("functionCall", 2, &[]),
function("functionCall", 3, &[("assembly", "call")]),
],
);
let caller = contract("Store", &["Address"], vec![function("pull", 1, &[])]);
let metadata = EvmBatMetadata {
contracts: vec![library.clone(), caller.clone()],
function_dependencies: vec![crate::batbelt::evm::metadata::bat_metadata::FunctionDependency {
function_metadata_id: "f_pull_1".to_string(),
callees: vec!["functionCall".to_string()],
}],
..Default::default()
};
let definers: HashMap<String, Vec<String>> =
HashMap::from([("functionCall".to_string(), vec!["Address".to_string()])]);
let reaches = leads_to_external(
&metadata,
&caller,
&caller.functions[0],
&AutoDeployOptions::default(),
&definers,
&mut HashMap::new(),
&mut HashSet::new(),
);
assert!(reaches, "the boundary is in the overload the first one delegates to");
}
#[test]
fn a_marked_call_survives_only_when_its_callee_is_not_drawn() {
let mut node = GraphNode {
id: "C::f".to_string(),
label: "C.f".to_string(),
kind: NodeKind::Screenshot,
file_path: String::new(),
start_line: 1,
end_line: 2,
depth: 0,
font_size: 32,
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(),
external_call_sites: vec![
(10, "drawn".to_string(), "C::drawn".to_string()),
(20, "absent".to_string(), "C::absent".to_string()),
],
};
let drawn: HashSet<&str> = HashSet::from(["C::drawn"]);
let kept: Vec<usize> = surviving_external_calls(&node, &drawn)
.map(|(line, _, _)| *line)
.collect();
assert_eq!(kept, vec![20]);
node.external_call_sites.clear();
assert_eq!(surviving_external_calls(&node, &drawn).count(), 0);
}
#[test]
fn the_band_skips_a_signature_that_shares_the_callees_name() {
let slice: Vec<String> = vec![
" function borrow(bytes32 id, uint256 assets, address to) external onlyRouter {"
.to_string(),
" if (to != POOL) revert BadReceiver();".to_string(),
" (uint256 borrowed,) = MORPHO.borrow(m, assets, 0, address(this), to);"
.to_string(),
" }".to_string(),
];
assert_eq!(boundary_line_index(&slice, "MORPHO", "borrow"), Some(2));
assert_eq!(boundary_line_index(&slice, "", "borrow"), Some(2));
assert_eq!(boundary_line_index(&slice, "MORPHO", "nothingHere"), None);
}
#[test]
fn a_call_with_a_value_block_is_found_on_its_line() {
assert!(line_has_call("(bool ok, ) = target.call{value: v}(data);", "call"));
assert!(line_has_call("let s := call(gas(), token, 0, 0, 0, 0, 0)", "call"));
assert!(!line_has_call("_callOptionalReturn(token, data);", "call"));
}
}
#[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);
}
}