use colored::Colorize;
use error_stack::{Result, ResultExt};
use rayon::prelude::*;
use crate::batbelt::evm::metadata::bat_metadata::EvmBatMetadata;
use crate::batbelt::evm::miro::auto_deploy::{matches_ignore, BOARD_UNITS_PER_PIXEL};
use crate::batbelt::evm::miro::EvmMiroError;
use crate::batbelt::miro::client::MiroClient;
use crate::batbelt::path::BatFolder;
use crate::batbelt::silicon;
const FONT_LADDER: &[usize] = &[20, 16, 14, 12, 10, 8, 6];
const MAX_IMAGE_BYTES: u64 = 5_500_000;
const COLUMN_GAP: f64 = 240.0;
const MARGIN: f64 = 400.0;
const REGION_MARGIN: f64 = 4_000.0;
struct Column {
path: String,
lines: usize,
png_path: String,
width: f64,
height: f64,
}
pub async fn run(include_dependencies: bool) -> Result<(), EvmMiroError> {
let metadata = EvmBatMetadata::read_metadata().change_context(EvmMiroError)?;
let mut paths: Vec<String> = metadata
.contracts
.iter()
.filter(|contract| include_dependencies || !contract.vendored)
.filter(|contract| {
!metadata.ignored_contracts.iter().any(|pattern| {
matches_ignore(pattern, &contract.name, &contract.file_path)
})
})
.map(|contract| contract.file_path.clone())
.collect();
paths.sort();
paths.dedup();
if paths.is_empty() {
println!(" {} no in-scope contracts to draw", "note:".yellow());
return Ok(());
}
let sources: Vec<(String, Vec<String>)> = paths
.into_iter()
.filter_map(|path| {
let text = std::fs::read_to_string(&path).ok()?;
let mut lines = vec![format!("// {path}"), String::new()];
lines.extend(blank_imports(&text));
Some((path, lines))
})
.collect();
let total_lines: usize = sources.iter().map(|(_, lines)| lines.len() - 2).sum();
let longest = sources
.iter()
.max_by_key(|(_, lines)| lines.len())
.expect("at least one file");
println!(
"{} {} file(s), {} lines; longest is {} at {}",
"▦".blue(),
sources.len().to_string().green(),
total_lines.to_string().green(),
longest.0,
(longest.1.len() - 2).to_string().green()
);
BatFolder::Figures.create_folder().change_context(EvmMiroError)?;
let destination = BatFolder::Figures
.get_path(true)
.change_context(EvmMiroError)?;
let font = choose_font(&longest.1, &destination)?;
println!(" rendering every file at font {}", font.to_string().green());
let columns = render_columns(&sources, font, &destination)?;
let width: f64 =
columns.iter().map(|c| c.width).sum::<f64>() + COLUMN_GAP * (columns.len() as f64 - 1.0);
let height: f64 = columns.iter().fold(0.0_f64, |tallest, c| tallest.max(c.height));
let frame_width = width + MARGIN * 2.0;
let frame_height = height + MARGIN * 2.0;
let client = MiroClient::new_refreshed().await.change_context(EvmMiroError)?;
let (frame_x, frame_y) = free_spot(&client, frame_width, frame_height).await?;
let title = format!("overview: {} files, {} lines", columns.len(), total_lines);
let frame_id = client
.create_frame(&title, frame_x, frame_y, frame_width, frame_height, None)
.await
.change_context(EvmMiroError)?;
let mut cursor = MARGIN;
let mut uploads = tokio::task::JoinSet::new();
for column in &columns {
let client = client.clone();
let frame_id = frame_id.clone();
let png = column.png_path.clone();
let label = column.path.clone();
let (x, y) = (cursor + column.width / 2.0, MARGIN + column.height / 2.0);
let width = column.width;
cursor += column.width + COLUMN_GAP;
uploads.spawn(async move {
client
.create_image_in_frame(&png, &frame_id, &label, x, y, width)
.await
.map(|_| label)
});
}
let mut failed = Vec::new();
while let Some(joined) = uploads.join_next().await {
match joined {
Ok(Ok(_)) => {}
Ok(Err(report)) => failed.push(report.to_string()),
Err(join_error) => failed.push(join_error.to_string()),
}
}
for failure in &failed {
println!(" {} {failure}", "skipped:".yellow());
}
println!(" {}", client.frame_url(&frame_id).blue());
let _ = std::fs::remove_dir_all(&destination);
Ok(())
}
fn blank_imports(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut inside = false;
for line in text.lines() {
let trimmed = line.trim_start();
if !inside && trimmed.starts_with("import") {
inside = !line.contains(';');
out.push(String::new());
continue;
}
if inside {
inside = !line.contains(';');
out.push(String::new());
continue;
}
out.push(line.to_string());
}
out
}
fn choose_font(longest: &[String], destination: &str) -> Result<usize, EvmMiroError> {
let source = longest.join("\n");
for font in FONT_LADDER {
let path = silicon::create_figure(
&source,
destination,
&format!("overview_probe_{font}.js"),
0,
Some(*font),
true,
);
let bytes = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(u64::MAX);
let _ = std::fs::remove_file(&path);
if bytes <= MAX_IMAGE_BYTES {
return Ok(*font);
}
}
Ok(*FONT_LADDER.last().expect("the ladder is not empty"))
}
fn render_columns(
sources: &[(String, Vec<String>)],
font: usize,
destination: &str,
) -> Result<Vec<Column>, EvmMiroError> {
let bar = crate::batbelt::evm::miro::auto_deploy::phase_bar("rendering files", sources.len());
let rendered: std::result::Result<Vec<Column>, String> = sources
.par_iter()
.map(|(path, lines)| {
let name = format!("overview_{}.js", path.replace([':', '.', '/'], "_"));
let png_path = silicon::create_figure(
&lines.join("\n"),
destination,
&name,
0,
Some(font),
true,
);
let (png_width, png_height) = image::image_dimensions(&png_path)
.map_err(|e| format!("cannot measure {png_path}: {e}"))?;
bar.inc(1);
Ok(Column {
path: path.clone(),
lines: lines.len() - 2,
png_path,
width: png_width as f64 * BOARD_UNITS_PER_PIXEL,
height: png_height as f64 * BOARD_UNITS_PER_PIXEL,
})
})
.collect();
bar.finish_and_clear();
rendered.map_err(|message| {
error_stack::Report::new(EvmMiroError).attach_printable(message)
})
}
pub(crate) async fn free_spot(
client: &MiroClient,
width: f64,
height: f64,
) -> Result<(f64, f64), EvmMiroError> {
let frames = client.list_frames().await.change_context(EvmMiroError)?;
let (left, top) = 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!(
" below {} existing frame(s), at ({}, {})",
frames.len(),
left.round(),
top.round()
);
Ok((left + width / 2.0, top + height / 2.0))
}
#[cfg(test)]
mod import_test {
use super::*;
#[test]
fn an_import_becomes_an_empty_line_in_place() {
let source = "pragma solidity ^0.8.0;\nimport {A} from \"./A.sol\";\ncontract C {}";
let out = blank_imports(source);
assert_eq!(out, vec!["pragma solidity ^0.8.0;", "", "contract C {}"]);
}
#[test]
fn a_multi_line_import_is_blanked_to_its_semicolon() {
let source = "import {\n A,\n B\n} from \"./A.sol\";\ncontract C {}";
let out = blank_imports(source);
assert_eq!(out, vec!["", "", "", "", "contract C {}"]);
}
#[test]
fn a_word_inside_code_is_not_an_import() {
let source = " uint256 imported = 1;\n // import this\n";
let out = blank_imports(source);
assert_eq!(out, vec![" uint256 imported = 1;", " // import this"]);
}
}