use colored::Colorize;
use error_stack::{Report, Result, ResultExt};
use crate::batbelt::evm::metadata::bat_metadata::{ContractMetadata, EvmBatMetadata};
use crate::batbelt::evm::miro::auto_deploy::{beside_cluster, below_everything, REFERENCE_FONT};
use crate::batbelt::evm::miro::EvmMiroError;
use crate::batbelt::miro::client::{ArrowEnd, ConnectorStroke, ConnectorStyle, MiroClient};
use crate::batbelt::path::BatFolder;
use crate::batbelt::silicon;
const GUTTER_MIN: f64 = 300.0;
const GUTTER_MAX: f64 = 600.0;
const MARGIN_MIN: f64 = 120.0;
const MARGIN_MAX: f64 = 400.0;
pub struct StorageOptions {
pub contract: String,
pub dry_run: bool,
pub anchor: Option<(f64, f64)>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub(crate) struct Slot {
pub(crate) slot: String,
pub(crate) offset: u64,
pub(crate) label: String,
pub(crate) type_label: String,
pub(crate) bytes: u64,
pub(crate) declared_by: String,
pub(crate) declared_at: Option<(String, usize)>,
}
#[derive(Debug, Clone)]
struct Unstored {
name: String,
type_name: String,
kind: &'static str,
line: usize,
}
#[derive(Debug, Clone)]
struct Writer {
slot_label: String,
function: String,
path: String,
line: usize,
lvalue: String,
}
pub async fn run(options: StorageOptions) -> Result<Option<String>, EvmMiroError> {
let started = std::time::Instant::now();
let mut phase = started;
let mut mark = |what: &str, since: &mut std::time::Instant| {
println!(" {:>6.1}s {what}", since.elapsed().as_secs_f64());
*since = std::time::Instant::now();
};
let metadata = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
mark("reading the metadata", &mut phase);
let prepared = prepare(&metadata, &options.contract)?;
mark("the layout, forge and the two captures", &mut phase);
for line in prepared.table.iter().chain(prepared.sidebar.iter()) {
println!("{line}");
}
if options.dry_run {
prepared.discard();
return Ok(None);
}
let client = MiroClient::new_refreshed().await.change_context(EvmMiroError)?;
mark("opening the Miro session", &mut phase);
let (left, top) = match options.anchor {
Some(spot) => spot,
None => {
let board_frames = client.list_frames().await.change_context(EvmMiroError)?;
match beside_cluster(
&metadata,
&prepared.contract,
&board_frames,
prepared.width,
prepared.height,
) {
Some((x, y)) => (x - prepared.width / 2.0, y - prepared.height / 2.0),
None => below_everything(&board_frames),
}
}
};
mark("deciding where it goes", &mut phase);
let drawn = draw(prepared, &client, left, top).await.map(Some);
mark("drawing it", &mut phase);
println!(" {:>6.1}s in total", started.elapsed().as_secs_f64());
drawn
}
pub(crate) struct PreparedStorage {
contract: String,
drawn_from: String,
slots: Vec<Slot>,
slot_rows: Vec<usize>,
table: Vec<String>,
sidebar: Vec<String>,
declaration_line: usize,
declarations: Vec<String>,
table_png: Rendered,
code_png: Rendered,
margin: f64,
gutter: f64,
table_offset: f64,
pub(crate) width: f64,
pub(crate) height: f64,
}
impl PreparedStorage {
pub(crate) fn discard(&self) {
let _ = std::fs::remove_file(&self.table_png.png_path);
let _ = std::fs::remove_file(&self.code_png.png_path);
}
}
fn aligning_offset(mut travel: Vec<f64>) -> f64 {
travel.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
travel.get(travel.len() / 2).copied().unwrap_or(0.0).max(0.0)
}
pub(crate) fn prepare(
metadata: &EvmBatMetadata,
contract_name: &str,
) -> Result<PreparedStorage, EvmMiroError> {
let contract = pick_contract(metadata, contract_name)?;
let mut slots = forge_layout(&contract.name)?;
attribute(metadata, contract, &mut slots);
if slots.is_empty() {
println!(
" {} {} occupies no storage slot at all",
"note:".yellow(),
contract.name
);
}
let unstored = unstored_variables(metadata, contract);
let writers = writers_of(metadata, contract, &slots);
let (table, slot_rows) = slot_table(&contract.name, &slots, &writers);
let sidebar = sidebar(&contract.name, &unstored);
BatFolder::Figures.create_folder().change_context(EvmMiroError)?;
let destination = BatFolder::Figures.get_path(true).change_context(EvmMiroError)?;
let declaring = declaring_contract(metadata, contract, &slots);
let drawn_from = declaring.as_ref().unwrap_or(&contract.name).clone();
let file_path = match &declaring {
Some(base) => metadata
.contracts
.iter()
.find(|other| &other.name == base)
.map(|other| other.file_path.clone())
.unwrap_or_else(|| contract.file_path.clone()),
None => contract.file_path.clone(),
};
let source = std::fs::read_to_string(&file_path)
.change_context(EvmMiroError)
.attach_printable_lazy(|| format!("cannot read {file_path}"))?;
let all: Vec<String> = source.lines().map(|line| line.to_string()).collect();
let declares = metadata
.contracts
.iter()
.find(|other| other.name == drawn_from)
.unwrap_or(contract);
let (first, last) = declaration_span(declares, all.len()).ok_or_else(|| {
Report::new(EvmMiroError)
.attach_printable(format!(
"{} occupies no storage slot and declares no state variable, here or in a \
base, so there is no declaration block to draw beside its slot table",
contract.name
))
.attach(crate::Suggestion(
"deploy a contract that holds state, or read this one's bases instead".to_string(),
))
})?;
if declaring.is_some() {
println!(
" {} {} declares no state of its own — the block drawn is {}'s",
"note:".yellow(),
contract.name,
drawn_from
);
}
let declarations: Vec<String> = all[first - 1..last].to_vec();
let table_png = render(
&table,
&destination,
&format!("storage_{}_table", contract.name),
0,
&[],
)?;
let code_png = render(
&declarations,
&destination,
&format!("storage_{}_code", contract.name),
first,
&unstored_marks(&unstored),
)?;
let tallest = code_png.height.max(table_png.height);
let margin = crate::batbelt::miro::layout::breathing_room(tallest, MARGIN_MIN, MARGIN_MAX);
let lanes = slots
.iter()
.filter(|slot| {
slot.declared_by == drawn_from
&& slot
.declared_at
.as_ref()
.is_some_and(|(_, line)| (first..first + declarations.len()).contains(line))
})
.count();
let gutter = crate::batbelt::miro::layout::breathing_room(
code_png.width + table_png.width,
GUTTER_MIN,
GUTTER_MAX,
)
.max(crate::batbelt::miro::layout::gutter_for(lanes));
let geometry = silicon::line_geometry(Some(REFERENCE_FONT));
let drawn_span = first..first + declarations.len();
let mut travel: Vec<f64> = slots
.iter()
.enumerate()
.filter_map(|(index, slot)| {
let (_, line) = slot.declared_at.as_ref()?;
if slot.declared_by != drawn_from || !drawn_span.contains(line) {
return None;
}
let row = *slot_rows.get(index)?;
let line_y =
code_png.height * geometry.line_center_fraction(line - first, code_png.height as u32);
let row_y =
table_png.height * geometry.line_center_fraction(row, table_png.height as u32);
Some(line_y - row_y)
})
.collect();
let table_offset = aligning_offset(travel);
let width = code_png.width + gutter + table_png.width + margin * 2.0;
let height = tallest.max(table_offset + table_png.height) + margin * 2.0;
Ok(PreparedStorage {
contract: contract.name.clone(),
slots,
slot_rows,
table,
sidebar,
declaration_line: first,
declarations,
table_png,
code_png,
margin,
gutter,
table_offset,
drawn_from,
width,
height,
})
}
fn pick_contract<'a>(
metadata: &'a EvmBatMetadata,
wanted: &str,
) -> Result<&'a ContractMetadata, EvmMiroError> {
let matches: Vec<&ContractMetadata> = metadata
.contracts
.iter()
.filter(|contract| contract.name == wanted && !contract.vendored)
.collect();
match matches.len() {
1 => Ok(matches[0]),
0 => Err(Report::new(EvmMiroError).attach_printable(format!(
"no in-scope contract called `{wanted}`; run `bat-cli sonar` if the scan is stale"
))),
_ => Err(Report::new(EvmMiroError).attach_printable(format!(
"`{wanted}` names {} contracts in scope:\n {}",
matches.len(),
matches
.iter()
.map(|contract| contract.file_path.clone())
.collect::<Vec<_>>()
.join("\n ")
))),
}
}
fn is_stale_artifact(stderr: &str) -> bool {
stderr.contains("storage layout missing")
}
fn forge_layout(contract: &str) -> Result<Vec<Slot>, EvmMiroError> {
if let Some(cached) = cached_layout(contract) {
return Ok(cached);
}
let slots = ask_forge(contract)?;
remember_layout(contract, &slots);
Ok(slots)
}
fn cached_layout(contract: &str) -> Option<Vec<Slot>> {
let metadata = EvmBatMetadata::read_metadata().ok()?;
metadata
.storage_layouts
.iter()
.find(|entry| entry.contract == contract)
.map(|entry| entry.slots.clone())
}
fn remember_layout(contract: &str, slots: &[Slot]) {
use crate::batbelt::evm::metadata::bat_metadata::StorageLayoutCache;
let entry = StorageLayoutCache {
contract: contract.to_string(),
slots: slots.to_vec(),
};
let _ = EvmBatMetadata::update_metadata(move |m| {
m.storage_layouts.retain(|existing| existing.contract != entry.contract);
m.storage_layouts.push(entry.clone());
});
}
pub fn forget_layouts() -> Result<usize, EvmMiroError> {
let mut count = 0;
EvmBatMetadata::update_metadata(|m| {
count = m.storage_layouts.len();
m.storage_layouts.clear();
})
.change_context(EvmMiroError)?;
Ok(count)
}
fn ask_forge(contract: &str) -> Result<Vec<Slot>, EvmMiroError> {
let output = std::process::Command::new("forge")
.args(["inspect", contract, "storage-layout", "--json"])
.output()
.map_err(|error| {
Report::new(EvmMiroError)
.attach_printable(format!(
"could not run `forge inspect {contract} storage-layout --json`: {error}"
))
.attach(crate::Suggestion(
"install Foundry, or run this from the Foundry root where `forge` works"
.to_string(),
))
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let report = Report::new(EvmMiroError);
return Err(if is_stale_artifact(&stderr) {
report
.attach_printable(format!(
"no storage layout for {contract}: its COMPILED ARTIFACT carries none. \
This is Foundry's cache, not a contract without storage — a contract that occupies no slot \
answers with an EMPTY layout, and this one did not answer at all."
))
.attach(crate::Suggestion(format!(
"rebuild the artifact and deploy again: `forge build --force`, or ask \
without touching the cache: `forge inspect {contract} storage-layout --json --no-cache`"
)))
} else {
report
.attach_printable(format!(
"`forge inspect {contract} storage-layout --json` failed:\n{}",
stderr.trim()
))
.attach(crate::Suggestion(
"the layout comes from the compiler, so the contract has to compile: run \
`forge build`, fix what it reports, and deploy again"
.to_string(),
))
});
}
let value: serde_json::Value = serde_json::from_slice(&output.stdout)
.map_err(|error| {
Report::new(EvmMiroError)
.attach_printable(format!("forge printed something that is not JSON: {error}"))
})?;
let types = &value["types"];
let mut slots = Vec::new();
for entry in value["storage"].as_array().into_iter().flatten() {
let type_key = entry["type"].as_str().unwrap_or_default();
let described = &types[type_key];
slots.push(Slot {
slot: entry["slot"].as_str().unwrap_or_default().to_string(),
offset: entry["offset"].as_u64().unwrap_or_default(),
label: entry["label"].as_str().unwrap_or_default().to_string(),
type_label: described["label"]
.as_str()
.unwrap_or(type_key)
.to_string(),
bytes: described["numberOfBytes"]
.as_str()
.and_then(|n| n.parse().ok())
.or_else(|| described["numberOfBytes"].as_u64())
.unwrap_or_default(),
declared_by: String::new(),
declared_at: None,
});
}
Ok(slots)
}
fn attribute(metadata: &EvmBatMetadata, contract: &ContractMetadata, slots: &mut [Slot]) {
let chain = chain_of(metadata, contract);
for slot in slots.iter_mut() {
let found = chain.iter().find_map(|source| {
source
.state_variables
.iter()
.find(|variable| variable.name == slot.label)
.map(|variable| (source, variable))
});
match found {
Some((source, variable)) => {
slot.declared_by = source.name.clone();
slot.declared_at = Some((source.file_path.clone(), variable.line));
}
None => slot.declared_by = "?".to_string(),
}
}
}
fn unstored_variables(metadata: &EvmBatMetadata, contract: &ContractMetadata) -> Vec<Unstored> {
let mut out = Vec::new();
for source in chain_of(metadata, contract) {
for variable in &source.state_variables {
let kind = match (variable.is_constant, variable.is_immutable) {
(true, _) => "constant",
(_, true) => "immutable",
_ => continue,
};
out.push(Unstored {
name: variable.name.clone(),
type_name: variable.type_name.clone(),
kind,
line: variable.line,
});
}
}
out
}
fn chain_of<'a>(
metadata: &'a EvmBatMetadata,
contract: &'a ContractMetadata,
) -> Vec<&'a ContractMetadata> {
let mut out = vec![contract];
let mut pending: Vec<String> = contract.base_contracts.clone();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
seen.insert(contract.name.clone());
while let Some(name) = pending.pop() {
if !seen.insert(name.clone()) {
continue;
}
let Some(base) = metadata
.contract_in_scope(&name, &contract.file_path)
.or_else(|| metadata.contracts.iter().find(|c| c.name == name))
else {
continue;
};
pending.extend(base.base_contracts.clone());
out.push(base);
}
out
}
fn writers_of(
metadata: &EvmBatMetadata,
contract: &ContractMetadata,
slots: &[Slot],
) -> Vec<Writer> {
let labels: std::collections::HashSet<&str> =
slots.iter().map(|slot| slot.label.as_str()).collect();
let mut out = Vec::new();
for source in chain_of(metadata, contract) {
for function in &source.functions {
for site in &function.storage_write_sites {
let root = site
.name
.split(['.', '['])
.next()
.unwrap_or(&site.name)
.to_string();
if !labels.contains(root.as_str()) {
continue;
}
out.push(Writer {
slot_label: root,
function: format!("{}.{}", source.name, function.name),
path: prettify(&source.file_path),
line: site.line,
lvalue: site.name.clone(),
});
}
}
}
out.sort_by(|a, b| {
(&a.slot_label, &a.function, a.line).cmp(&(&b.slot_label, &b.function, b.line))
});
out.dedup_by(|a, b| a.slot_label == b.slot_label && a.line == b.line && a.path == b.path);
out
}
fn slot_table(contract: &str, slots: &[Slot], writers: &[Writer]) -> (Vec<String>, Vec<usize>) {
let mut lines = vec![
format!("{contract} — storage layout ({} slot entries)", slots.len()),
"from `forge inspect`; a repeated slot number is packing".to_string(),
"beside it: a purple name is a constant, a red one an immutable — neither holds a slot"
.to_string(),
String::new(),
];
let mut rows = Vec::new();
if slots.is_empty() {
lines.push(" this contract occupies no storage".to_string());
return (lines, rows);
}
for slot in slots {
let mine: Vec<&Writer> = writers
.iter()
.filter(|writer| writer.slot_label == slot.label)
.collect();
let indexed = mine.iter().any(|writer| writer.lvalue.contains('['));
let inherited = if slot.declared_by != contract && !slot.declared_by.is_empty() {
format!(" ({})", slot.declared_by)
} else {
String::new()
};
if !rows.is_empty() {
lines.push(String::new());
}
rows.push(lines.len());
lines.push(format!(
" slot {:>3}+{} {} {}{}",
slot.slot,
slot.offset,
slot.type_label,
format!("{}{}", slot.label, if indexed { "[]" } else { "" }),
inherited,
));
let mut by_function: Vec<(&str, Vec<&Writer>)> = Vec::new();
for writer in &mine {
match by_function.last_mut() {
Some((name, group)) if *name == writer.function => group.push(writer),
_ => by_function.push((&writer.function, vec![writer])),
}
}
let widest = mine.iter().map(|writer| writer.lvalue.len()).max().unwrap_or(0);
let last = by_function.len().saturating_sub(1);
for (index, (function, written)) in by_function.iter().enumerate() {
let elbow = if index == last { "└─" } else { "├─" };
lines.push(format!(" {elbow} {function}"));
let stem = if index == last { " " } else { "│ " };
for writer in written {
lines.push(format!(
" {stem} {:<widest$} {}:{}",
writer.lvalue, writer.path, writer.line
));
}
}
}
(lines, rows)
}
fn sidebar(contract: &str, unstored: &[Unstored]) -> Vec<String> {
let mut lines = vec![
format!("{contract} — in the bytecode, not in storage ({})", unstored.len()),
"declared among the state variables; occupies no slot".to_string(),
String::new(),
];
if unstored.is_empty() {
lines.push(" none".to_string());
}
for variable in unstored {
lines.push(format!(
" {:<10} {:<28} {:<24} L{}",
variable.kind, variable.name, variable.type_name, variable.line
));
}
lines
}
fn prettify(path: &str) -> String {
crate::batbelt::path::prettify_source_code_path(path).unwrap_or_else(|_| path.to_string())
}
pub(crate) async fn draw(
prepared: PreparedStorage,
client: &MiroClient,
left: f64,
top: f64,
) -> Result<String, EvmMiroError> {
let PreparedStorage {
contract,
drawn_from,
slots,
slot_rows,
table,
declaration_line: first,
declarations,
table_png,
code_png,
margin,
gutter,
table_offset,
width: frame_width,
height: frame_height,
..
} = prepared;
let columns = [&code_png, &table_png];
let frame_id = client
.create_frame(
&format!("storage: {contract}"),
left + frame_width / 2.0,
top + frame_height / 2.0,
frame_width,
frame_height,
None,
)
.await
.change_context(EvmMiroError)?;
let mut cursor = margin;
let mut uploading = tokio::task::JoinSet::new();
let mut placed: Vec<f64> = Vec::new();
for (index, column) in columns.into_iter().enumerate() {
let top = margin + if index == 0 { 0.0 } else { table_offset };
let (x, y) = (cursor + column.width / 2.0, top + column.height / 2.0);
placed.push(cursor);
cursor += column.width + gutter;
let client = client.clone();
let frame_id = frame_id.clone();
let png = column.png_path.clone();
let label = contract.clone();
let width = column.width;
uploading.spawn(async move {
let item = client
.create_image_in_frame(&png, &frame_id, &label, x, y, width)
.await;
let _ = std::fs::remove_file(&png);
item
});
}
while let Some(joined) = uploading.join_next().await {
joined
.map_err(|error| Report::new(EvmMiroError).attach_printable(error.to_string()))?
.change_context(EvmMiroError)?;
}
println!(" {} {} column(s) uploaded", "✓".green(), columns.len());
let code_left = placed[0];
let table_left = placed[1];
let code_right = code_left + code_png.width;
let geometry = silicon::line_geometry(Some(REFERENCE_FONT));
let drawn_span = first..first + declarations.len();
let aimed: Vec<(usize, &Slot, usize)> = slots
.iter()
.enumerate()
.filter_map(|(index, slot)| {
let (_, line) = slot.declared_at.as_ref()?;
(slot.declared_by == drawn_from && drawn_span.contains(line)).then_some((index, slot, *line))
})
.collect();
let mut aimed: Vec<(usize, usize)> = aimed
.iter()
.filter_map(|(index, _, declared_line)| Some((*slot_rows.get(*index)?, *declared_line)))
.collect();
aimed.sort_by(|a, b| a.1.cmp(&b.1));
let lane_gutter = (table_left - code_right).max(1.0);
let lane_inset = 100.0_f64.min(lane_gutter / 4.0);
let usable = (lane_gutter - 2.0 * lane_inset).max(0.0);
let pitch = if aimed.len() > 1 {
usable / (aimed.len() - 1) as f64
} else {
0.0
};
use crate::batbelt::evm::miro::auto_deploy::{plan_lane_arrow, LaneEnd, LaneSide};
let mut planned: Vec<(crate::batbelt::evm::miro::auto_deploy::PlannedLaneArrow, ConnectorStyle)> =
Vec::new();
let drawn = aimed.len();
for (order, (row, declared_line)) in aimed.iter().enumerate() {
let row_y = margin
+ table_offset
+ table_png.height * geometry.line_center_fraction(*row, table_png.height as u32);
let code_y = margin
+ code_png.height
* geometry.line_center_fraction(declared_line - first, code_png.height as u32);
let row_start = table_left
+ silicon::line_end_x(Some(REFERENCE_FONT), false, table.len(), 0, "") as f64;
let declaration_end = code_left
+ silicon::line_end_x(
Some(REFERENCE_FONT),
true,
declarations.len(),
first,
&declarations[declared_line - first],
) as f64;
let lane_x = code_right + lane_inset + order as f64 * pitch;
planned.push((
plan_lane_arrow(
&LaneEnd::Point { x: row_start, y: row_y },
&LaneEnd::Point { x: declaration_end, y: code_y },
lane_x,
),
ConnectorStyle {
stroke_color: silicon::BAT_PALETTE[order % silicon::BAT_PALETTE.len()].to_string(),
stroke_width: "8".to_string(),
stroke: match order / silicon::BAT_PALETTE.len() {
0 => ConnectorStroke::Solid,
1 => ConnectorStroke::Dashed,
_ => ConnectorStroke::Dotted,
},
caption: None,
arrow: ArrowEnd::End,
},
));
}
let mut drawing = tokio::task::JoinSet::new();
for (index, (arrow, _)) in planned.iter().enumerate() {
for (point, (x, y)) in arrow.points.iter().enumerate() {
let client = client.clone();
let frame_id = frame_id.clone();
let (x, y) = (*x, *y);
drawing.spawn(async move {
client
.create_anchor_marker(&frame_id, x, y, 8.0)
.await
.map(|id| (index, point, id))
});
}
}
let mut markers: std::collections::BTreeMap<(usize, usize), String> =
std::collections::BTreeMap::new();
while let Some(joined) = drawing.join_next().await {
let (index, point, id) = joined
.map_err(|error| Report::new(EvmMiroError).attach_printable(error.to_string()))?
.change_context(EvmMiroError)?;
markers.insert((index, point), id);
}
for (index, (arrow, style)) in planned.iter().enumerate() {
for (from_side, from_anchor, to_side, to_anchor, head) in &arrow.legs {
let resolve = |side: &LaneSide| match side {
LaneSide::Marker(point) => markers.get(&(index, *point)).cloned(),
LaneSide::Item(id) => Some(id.clone()),
};
let (Some(from_id), Some(to_id)) = (resolve(from_side), resolve(to_side)) else {
continue;
};
let mut leg = style.clone();
leg.arrow = if *head { ArrowEnd::End } else { ArrowEnd::None };
let (from_anchor, to_anchor) = (*from_anchor, *to_anchor);
let client = client.clone();
drawing.spawn(async move {
client
.create_connector(&from_id, from_anchor, &to_id, to_anchor, leg)
.await
.map(|id| (0usize, 0usize, id))
});
}
}
while let Some(joined) = drawing.join_next().await {
joined
.map_err(|error| Report::new(EvmMiroError).attach_printable(error.to_string()))?
.change_context(EvmMiroError)?;
}
println!(" {} {drawn} slot(s) pointed at their declaration", "✓".green());
println!(" {}", client.frame_url(&frame_id).blue());
Ok(frame_id)
}
pub(crate) struct Rendered {
pub(crate) png_path: String,
pub(crate) width: f64,
pub(crate) height: f64,
}
fn declaring_contract(
metadata: &EvmBatMetadata,
contract: &ContractMetadata,
slots: &[Slot],
) -> Option<String> {
if !contract.state_variables.is_empty() {
return None;
}
let mut count: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
for slot in slots {
if slot.declared_by.is_empty() || slot.declared_by == "?" || slot.declared_by == contract.name
{
continue;
}
if metadata
.contracts
.iter()
.any(|other| other.name == slot.declared_by && !other.vendored)
{
*count.entry(slot.declared_by.as_str()).or_default() += 1;
}
}
count
.into_iter()
.max_by(|a, b| a.1.cmp(&b.1).then_with(|| b.0.cmp(a.0)))
.map(|(name, _)| name.to_string())
}
fn declaration_span(contract: &ContractMetadata, file_lines: usize) -> Option<(usize, usize)> {
let first = contract
.state_variables
.iter()
.map(|variable| variable.line)
.min()?;
let last = contract
.state_variables
.iter()
.map(|variable| variable.line)
.max()?;
(first <= file_lines).then(|| (first.max(1), last.min(file_lines)))
}
fn unstored_marks(unstored: &[Unstored]) -> Vec<silicon::TracedName> {
use silicon::{TraceKind, TracedName};
let index_of = |hex: &str| {
silicon::TRACE_COLORS
.iter()
.position(|colour| *colour == hex)
.unwrap_or(0)
};
let immutable = index_of("#ff6e6e");
let constant = index_of("#d6acff");
unstored
.iter()
.map(|variable| TracedName {
name: variable.name.clone(),
kind: TraceKind::Parameter,
color: if variable.kind == "constant" { constant } else { immutable },
dotted: false,
})
.collect()
}
pub(crate) fn render(
lines: &[String],
destination: &str,
name: &str,
line_offset: usize,
traced: &[silicon::TracedName],
) -> Result<Rendered, EvmMiroError> {
let png_path = silicon::create_figure_tracing(
&lines.join("\n"),
destination,
&format!("{name}.{}", if line_offset > 0 { "sol" } else { "txt" }),
line_offset,
Some(REFERENCE_FONT),
line_offset > 0,
traced,
);
let (width, height) = image::image_dimensions(&png_path).change_context(EvmMiroError)?;
Ok(Rendered {
png_path,
width: width as f64,
height: height as f64,
})
}
#[cfg(test)]
mod layout_failure_test {
use super::is_stale_artifact;
#[test]
fn a_missing_layout_in_the_artifact_is_a_cache_problem() {
assert!(is_stale_artifact(
"Error: storage layout missing from artifact; this could be a spurious caching \
issue, consider running `forge clean`"
));
}
#[test]
fn a_compiler_error_is_not_a_cache_problem() {
assert!(!is_stale_artifact(
"Error: Compiler run failed:\nError (7576): Undeclared identifier."
));
}
}
#[cfg(test)]
mod storage_test {
use super::*;
fn slot(label: &str, slot: &str, offset: u64, declared_by: &str) -> Slot {
Slot {
slot: slot.to_string(),
offset,
label: label.to_string(),
type_label: "address".to_string(),
bytes: 20,
declared_by: declared_by.to_string(),
declared_at: Some(("src/C.sol".to_string(), 7)),
}
}
#[test]
fn packing_is_two_rows_with_one_slot_number() {
let slots = vec![slot("a", "0", 0, "C"), slot("b", "0", 20, "C")];
let (table, rows) = slot_table("C", &slots, &[]);
assert_eq!(rows.len(), 2, "one row per entry, both on slot 0");
assert!(table.iter().any(|line| line.contains("slot 0+0")));
assert!(table.iter().any(|line| line.contains("slot 0+20")));
}
#[test]
fn a_slot_with_no_writer_says_so() {
let (table, _) = slot_table("C", &[slot("a", "0", 0, "C")], &[]);
assert_eq!(table.iter().filter(|line| line.contains("├─") || line.contains("└─")).count(), 0);
}
}
#[cfg(test)]
mod arrow_anchor_test {
use crate::batbelt::silicon;
#[test]
fn a_row_is_anchored_from_the_frames_margin() {
let geometry = silicon::line_geometry(Some(32));
let png_height = 1_000u32;
let margin = 120.0;
let lane_inset = 100.0;
let row = 3usize;
let from_margin =
margin + png_height as f64 * geometry.line_center_fraction(row, png_height);
let from_inset =
lane_inset + png_height as f64 * geometry.line_center_fraction(row, png_height);
assert_ne!(from_margin, from_inset);
assert_eq!(from_margin - from_inset, margin - lane_inset);
}
}
#[cfg(test)]
mod arrow_stroke_test {
use crate::batbelt::miro::client::ConnectorStroke;
use crate::batbelt::silicon;
fn stroke_for(order: usize) -> ConnectorStroke {
match order / silicon::BAT_PALETTE.len() {
0 => ConnectorStroke::Solid,
1 => ConnectorStroke::Dashed,
_ => ConnectorStroke::Dotted,
}
}
#[test]
fn a_repeated_colour_comes_with_a_different_stroke() {
let palette = silicon::BAT_PALETTE.len();
assert_eq!(stroke_for(0), ConnectorStroke::Solid);
assert_eq!(stroke_for(palette - 1), ConnectorStroke::Solid);
assert_eq!(stroke_for(palette), ConnectorStroke::Dashed);
assert_eq!(stroke_for(palette * 2), ConnectorStroke::Dotted);
assert_eq!(
silicon::BAT_PALETTE[0],
silicon::BAT_PALETTE[palette % silicon::BAT_PALETTE.len()]
);
}
}
#[cfg(test)]
mod table_offset_test {
use super::aligning_offset;
#[test]
fn the_table_hangs_where_its_rows_meet_their_lines() {
let mut travel = vec![2_000.0; 6];
travel.push(-4_000.0);
let mean = travel.iter().sum::<f64>() / travel.len() as f64;
let offset = aligning_offset(travel);
assert_eq!(offset, 2_000.0, "the six are level with their lines");
assert!(offset > mean, "and the outlier does not drag it: mean was {mean}");
}
#[test]
fn the_table_is_never_pushed_above_the_frame() {
assert_eq!(aligning_offset(vec![-500.0, -900.0]), 0.0);
assert_eq!(aligning_offset(Vec::new()), 0.0, "nothing to point at, nothing to align");
}
}
#[cfg(test)]
mod inherited_declarations_test {
use super::{declaring_contract, Slot};
use crate::batbelt::evm::metadata::bat_metadata::{ContractMetadata, EvmBatMetadata};
use crate::batbelt::evm::types::EvmContractType;
fn contract(name: &str, vendored: bool, declares: usize) -> ContractMetadata {
ContractMetadata {
metadata_id: name.to_string(),
name: name.to_string(),
using_libraries: Vec::new(),
file_path: format!("src/{name}.sol"),
contract_type: EvmContractType::Contract,
base_contracts: Vec::new(),
functions: Vec::new(),
state_variables: (0..declares)
.map(|index| crate::batbelt::evm::types::StorageVariable {
name: format!("var{index}"),
type_name: "uint256".to_string(),
visibility: crate::batbelt::evm::types::EvmVisibility::Public,
is_constant: false,
is_immutable: false,
line: 10 + index,
})
.collect(),
events: Vec::new(),
modifiers: Vec::new(),
line: 1,
vendored,
}
}
fn slot(declared_by: &str) -> Slot {
Slot {
slot: "0".to_string(),
offset: 0,
label: "owner".to_string(),
type_label: "address".to_string(),
bytes: 20,
declared_by: declared_by.to_string(),
declared_at: Some((format!("src/{declared_by}.sol"), 38)),
}
}
#[test]
fn a_wrapper_draws_its_bases_declarations() {
let metadata = EvmBatMetadata {
contracts: vec![contract("Treasury", false, 0), contract("TwoStepOwned", false, 2)],
..Default::default()
};
let slots = [slot("TwoStepOwned"), slot("TwoStepOwned")];
let treasury = &metadata.contracts[0];
assert_eq!(
declaring_contract(&metadata, treasury, &slots),
Some("TwoStepOwned".to_string())
);
}
#[test]
fn a_contract_with_state_draws_its_own() {
let metadata = EvmBatMetadata {
contracts: vec![contract("YieldRouter", false, 3), contract("TwoStepOwned", false, 2)],
..Default::default()
};
assert_eq!(
declaring_contract(&metadata, &metadata.contracts[0], &[slot("TwoStepOwned")]),
None
);
}
#[test]
fn a_vendored_base_is_not_drawn() {
let metadata = EvmBatMetadata {
contracts: vec![contract("Thin", false, 0), contract("ReentrancyGuard", true, 1)],
..Default::default()
};
assert_eq!(
declaring_contract(&metadata, &metadata.contracts[0], &[slot("ReentrancyGuard")]),
None
);
}
#[test]
fn the_base_with_the_most_slots_wins() {
let metadata = EvmBatMetadata {
contracts: vec![
contract("Thin", false, 0),
contract("Owned", false, 1),
contract("Pausable", false, 2),
],
..Default::default()
};
let slots = [slot("Owned"), slot("Pausable"), slot("Pausable")];
assert_eq!(
declaring_contract(&metadata, &metadata.contracts[0], &slots),
Some("Pausable".to_string())
);
}
}
#[cfg(test)]
mod layout_cache_test {
use super::Slot;
#[test]
fn a_cached_slot_keeps_everything_the_drawing_needs() {
let slot = Slot {
slot: "3".to_string(),
offset: 12,
label: "pendingOwner".to_string(),
type_label: "address".to_string(),
bytes: 20,
declared_by: "TwoStepOwned".to_string(),
declared_at: Some(("src/base/TwoStepOwned.sol".to_string(), 39)),
};
let json = serde_json::to_string(&slot).expect("writes");
let back: Slot = serde_json::from_str(&json).expect("reads");
assert_eq!(back.slot, slot.slot);
assert_eq!(back.offset, slot.offset);
assert_eq!(back.label, slot.label);
assert_eq!(back.type_label, slot.type_label);
assert_eq!(back.bytes, slot.bytes);
assert_eq!(back.declared_by, slot.declared_by);
assert_eq!(back.declared_at, slot.declared_at);
}
}