use once_cell::sync::Lazy;
use silicon::assets::HighlightingAssets;
use silicon::formatter::ImageFormatterBuilder;
use silicon::utils::{Background, ShadowAdder};
use syntect::easy::HighlightLines;
use syntect::util::LinesWithEndings;
use std::collections::HashMap;
use std::fs;
static ASSETS: Lazy<HighlightingAssets> = Lazy::new(HighlightingAssets::new);
const BG: image::Rgba<u8> = image::Rgba([0x28, 0x2a, 0x36, 0xff]);
const DEFAULT_FONT_SIZE: f32 = 20.0;
const PAD: u32 = 10;
const LINE_PAD: u32 = 2;
const CODE_PAD: u32 = 25;
const LINE_NUMBER_PAD: u32 = 6;
const TAB_WIDTH: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LineGeometry {
pub first_line_y: u32,
pub line_height: u32,
}
impl LineGeometry {
pub fn line_center_y(&self, line_index: usize) -> u32 {
self.first_line_y + line_index as u32 * self.line_height + self.line_height / 2
}
pub fn line_center_fraction(&self, line_index: usize, image_height_px: u32) -> f64 {
if image_height_px == 0 {
return 0.5;
}
let y = self.line_center_y(line_index) as f64 / image_height_px as f64;
y.clamp(0.0, 1.0)
}
}
pub fn line_end_x(
font_size: Option<usize>,
show_line_number: bool,
total_lines: usize,
line_offset: usize,
line_text: &str,
) -> u32 {
let size = font_size.map(|s| s as f32).unwrap_or(DEFAULT_FONT_SIZE);
let font = silicon::font::FontCollection::new(&[("Hack", size)])
.expect("Hack font not available for silicon");
let left_pad = CODE_PAD
+ if show_line_number {
let line_number_chars =
(((total_lines + line_offset) as f32).log10() + 1.0).floor() as usize;
let widest = format!("{:>width$}", 0, width = line_number_chars);
2 * LINE_NUMBER_PAD + font.get_text_len(&widest)
} else {
0
};
let expanded = line_text
.trim_end_matches('\n')
.replace('\t', &" ".repeat(TAB_WIDTH));
PAD + left_pad + font.get_text_len(&expanded)
}
pub fn line_geometry(font_size: Option<usize>) -> LineGeometry {
let size = font_size.map(|s| s as f32).unwrap_or(DEFAULT_FONT_SIZE);
let font = silicon::font::FontCollection::new(&[("Hack", size)])
.expect("Hack font not available for silicon");
LineGeometry {
first_line_y: PAD + CODE_PAD,
line_height: font.get_font_height() + LINE_PAD,
}
}
pub const BAT_PALETTE: &[&str] = &[
"#2d9bf0", "#f24726", "#8fd14f", "#fac710", "#a259ff", "#12cdd4", "#ff8c00", "#e6007a",
];
pub const TRACE_COLORS: &[&str] = &[
"#ff6e6e", "#69ff94", "#d6acff", "#ffffa5", "#a4ffff", "#ff92df", "#ffb86c", "#8be9fd",
];
pub const UNDERLINED_TRACE_COLORS: &[&str] = &[
"#ff6e6e", "#d6acff", "#ffffa5", "#a4ffff", "#ff92df", "#ffb86c", "#8be9fd",
];
pub fn palette(kind: TraceKind) -> &'static [&'static str] {
match kind {
TraceKind::Parameter => TRACE_COLORS,
TraceKind::Local | TraceKind::NamedReturn => UNDERLINED_TRACE_COLORS,
}
}
pub fn count_word(text: &str, name: &str) -> usize {
let mut count = 0;
let mut from = 0usize;
while let Some(at) = find_word(&text[from..], name) {
count += 1;
from += at + name.len();
}
count
}
fn find_word(haystack: &str, name: &str) -> Option<usize> {
let is_ident = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
let mut from = 0usize;
while let Some(at) = haystack[from..].find(name) {
let at = from + at;
let before_ok = at == 0 || !haystack[..at].chars().next_back().is_some_and(is_ident);
let after = at + name.len();
let after_ok = after >= haystack.len() || !haystack[after..].chars().next().is_some_and(is_ident);
if before_ok && after_ok {
return Some(at);
}
from = at + name.len().max(1);
}
None
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TraceKind {
Parameter,
Local,
NamedReturn,
}
#[derive(Debug, Clone)]
pub struct TracedName {
pub name: String,
pub kind: TraceKind,
pub color: usize,
pub dotted: bool,
}
struct Occurrence {
row: usize,
at: usize,
len: usize,
color: usize,
kind: TraceKind,
dotted: bool,
}
fn occurrences(content: &str, traced: &[TracedName]) -> Vec<Occurrence> {
let code = code_spans(content);
let mut found = Vec::new();
for (row, line) in content.lines().enumerate() {
let spans = &code[row];
for traced_name in traced.iter() {
let name = &traced_name.name;
let mut from = 0usize;
while let Some(at) = find_word(&line[from..], name) {
let at = from + at;
from = at + name.len();
let is_code = spans.iter().any(|(s, e)| at >= *s && at + name.len() <= *e);
if !is_code || is_field_key(line, at, name.len()) || is_member(line, at) {
continue;
}
let tabs = line[..at].matches('\t').count();
found.push(Occurrence {
row,
at: at + tabs * (TAB_WIDTH - 1),
len: name.len(),
color: traced_name.color,
kind: traced_name.kind,
dotted: traced_name.dotted,
});
}
}
}
found
}
fn traced_rects(
content: &str,
traced: &[TracedName],
font_size: Option<usize>,
show_line_number: bool,
line_offset: usize,
) -> Vec<TracedRect> {
if traced.is_empty() {
return Vec::new();
}
let size = font_size.map(|s| s as f32).unwrap_or(DEFAULT_FONT_SIZE);
let font = silicon::font::FontCollection::new(&[("Hack", size)])
.expect("Hack font not available for silicon");
let geometry = line_geometry(font_size);
let lines: Vec<&str> = content.lines().collect();
let left_pad = CODE_PAD
+ if show_line_number {
let line_number_chars =
(((lines.len() + line_offset) as f32).log10() + 1.0).floor() as usize;
let widest = format!("{:>width$}", 0, width = line_number_chars);
2 * LINE_NUMBER_PAD + font.get_text_len(&widest)
} else {
0
};
occurrences(content, traced)
.into_iter()
.map(|found| {
let expanded = lines[found.row].replace('\t', &" ".repeat(TAB_WIDTH));
TracedRect {
x: PAD + left_pad + font.get_text_len(&expanded[..found.at]),
y: geometry.first_line_y + found.row as u32 * geometry.line_height,
width: font.get_text_len(&expanded[found.at..found.at + found.len]),
height: geometry.line_height,
color: found.color,
kind: found.kind,
dotted: found.dotted,
}
})
.collect()
}
struct TracedRect {
x: u32,
y: u32,
width: u32,
height: u32,
color: usize,
kind: TraceKind,
dotted: bool,
}
fn is_member(line: &str, at: usize) -> bool {
line[..at].trim_end().ends_with('.')
}
fn code_spans(content: &str) -> Vec<Vec<(usize, usize)>> {
let mut spans = Vec::new();
let mut in_block = false;
for line in content.lines() {
let bytes = line.as_bytes();
let mut line_spans: Vec<(usize, usize)> = Vec::new();
let mut start = 0usize;
let mut index = 0usize;
let mut quote: Option<u8> = None;
while index < bytes.len() {
if in_block {
if bytes[index..].starts_with(b"*/") {
in_block = false;
index += 2;
start = index;
continue;
}
index += 1;
continue;
}
match quote {
Some(closing) => {
if bytes[index] == b'\\' {
index += 2;
continue;
}
if bytes[index] == closing {
quote = None;
index += 1;
start = index;
continue;
}
index += 1;
}
None => {
if bytes[index..].starts_with(b"//") {
if index > start {
line_spans.push((start, index));
}
start = line.len();
index = line.len();
break;
}
if bytes[index..].starts_with(b"/*") {
if index > start {
line_spans.push((start, index));
}
in_block = true;
index += 2;
continue;
}
if bytes[index] == b'"' || bytes[index] == b'\'' {
if index > start {
line_spans.push((start, index));
}
quote = Some(bytes[index]);
index += 1;
continue;
}
index += 1;
}
}
}
if quote.is_none() && !in_block && start < line.len() {
line_spans.push((start, line.len()));
}
spans.push(line_spans);
}
spans
}
fn is_field_key(line: &str, at: usize, len: usize) -> bool {
let starts_the_line = line[..at].trim().is_empty();
let after = line[at + len..].trim_start();
let followed_by_colon = after.starts_with(':') && !after.starts_with(":=");
starts_the_line && followed_by_colon
}
fn paint_traces(image: &mut image::DynamicImage, rects: &[TracedRect]) {
use image::GenericImageView;
const ALPHA: f32 = 0.30;
const RULE: u32 = 3;
let mut buffer = image.to_rgba8();
let (width, height) = image.dimensions();
for rect in rects {
let colors = palette(rect.kind);
let tint = parse_hex(colors[rect.color % colors.len()]);
let blend = |under: u8, over: u8, alpha: f32| {
(under as f32 * (1.0 - alpha) + over as f32 * alpha).round() as u8
};
for py in rect.y..(rect.y + rect.height).min(height) {
let on_rule = py + RULE >= rect.y + rect.height;
let paint = match rect.kind {
TraceKind::Parameter if on_rule && rect.dotted => Some(1.0),
TraceKind::Parameter => Some(ALPHA),
TraceKind::NamedReturn if on_rule => Some(1.0),
TraceKind::NamedReturn => Some(ALPHA),
TraceKind::Local if on_rule => Some(1.0),
TraceKind::Local => None,
};
let Some(alpha) = paint else { continue };
for px in rect.x..(rect.x + rect.width).min(width) {
if rect.dotted && on_rule && (px / RULE) % 2 == 0 {
continue;
}
let pixel = buffer.get_pixel_mut(px, py);
pixel[0] = blend(pixel[0], tint.r, alpha);
pixel[1] = blend(pixel[1], tint.g, alpha);
pixel[2] = blend(pixel[2], tint.b, alpha);
}
}
}
*image = image::DynamicImage::ImageRgba8(buffer);
}
fn recolor_glyphs<'a>(
highlight: &mut [Vec<(syntect::highlighting::Style, &'a str)>],
content: &str,
traced: &[TracedName],
) {
if traced.is_empty() {
return;
}
let mut by_row: HashMap<usize, Vec<(usize, usize, syntect::highlighting::Color)>> =
HashMap::new();
for found in occurrences(content, traced) {
let colors = palette(found.kind);
by_row.entry(found.row).or_default().push((
found.at,
found.len,
parse_hex(colors[found.color % colors.len()]),
));
}
for (row, line) in highlight.iter_mut().enumerate() {
let Some(spots) = by_row.get(&row) else { continue };
let mut rebuilt: Vec<(syntect::highlighting::Style, &'a str)> = Vec::new();
let mut consumed = 0usize;
for (style, text) in line.iter() {
let span_start = consumed;
consumed += text.len();
let mut cursor = 0usize;
for (at, len, color) in spots.iter() {
if *at < span_start || at + len > span_start + text.len() {
continue;
}
let local_at = at - span_start;
if local_at < cursor {
continue;
}
if local_at > cursor {
rebuilt.push((*style, &text[cursor..local_at]));
}
let mut painted = *style;
painted.foreground = *color;
rebuilt.push((painted, &text[local_at..local_at + len]));
cursor = local_at + len;
}
if cursor < text.len() {
rebuilt.push((*style, &text[cursor..]));
}
}
*line = rebuilt;
}
}
fn parse_hex(hex: &str) -> syntect::highlighting::Color {
let value = hex.trim_start_matches('#');
let byte = |i: usize| u8::from_str_radix(&value[i..i + 2], 16).unwrap_or(0xff);
syntect::highlighting::Color { r: byte(0), g: byte(2), b: byte(4), a: 0xff }
}
pub fn create_figure(
content: &str,
dest_folder_path: &str,
file_name: &str,
offset: usize,
font_size: Option<usize>,
show_line_number: bool,
) -> String {
create_figure_tracing(
content,
dest_folder_path,
file_name,
offset,
font_size,
show_line_number,
&[],
)
}
#[allow(clippy::too_many_arguments)]
pub fn create_figure_tracing(
content: &str,
dest_folder_path: &str,
file_name: &str,
offset: usize,
font_size: Option<usize>,
show_line_number: bool,
traced: &[TracedName],
) -> String {
let dest_png_path = format!("{dest_folder_path}/{file_name}.png");
let size = font_size.map(|s| s as f32).unwrap_or(DEFAULT_FONT_SIZE);
let ps = &ASSETS.syntax_set;
let theme = &ASSETS.theme_set.themes["Dracula"];
let ext = file_name.rsplit('.').next().unwrap_or("rs");
let syntax = match ext {
"sol" => ps
.find_syntax_by_extension("js")
.or_else(|| ps.find_syntax_by_extension("rs"))
.expect("Syntax not found in syntect"),
other => ps
.find_syntax_by_extension(other)
.or_else(|| ps.find_syntax_by_extension("rs"))
.expect("Syntax not found in syntect"),
};
let mut highlighter = HighlightLines::new(syntax, theme);
let mut highlight: Vec<Vec<(syntect::highlighting::Style, &str)>> =
LinesWithEndings::from(content)
.map(|line| highlighter.highlight_line(line, &ps).unwrap())
.collect();
recolor_glyphs(&mut highlight, content, traced);
let shadow = ShadowAdder::default()
.background(Background::Solid(BG))
.shadow_color(image::Rgba([0, 0, 0, 0]))
.blur_radius(0.0)
.pad_horiz(PAD)
.pad_vert(PAD)
.offset_x(0)
.offset_y(0);
let mut formatter = ImageFormatterBuilder::new()
.font(vec![("Hack".to_string(), size)])
.line_number(show_line_number)
.line_offset(offset as u32)
.tab_width(4)
.window_controls(false)
.round_corner(false)
.shadow_adder(shadow)
.build()
.expect("Failed to build silicon ImageFormatter");
let mut image = formatter.format(&highlight, theme);
paint_traces(
&mut image,
&traced_rects(content, traced, font_size, show_line_number, offset),
);
image
.save(&dest_png_path)
.expect("Failed to save screenshot PNG");
dest_png_path
}
pub fn delete_png_file(path: String) {
fs::remove_file(path).unwrap();
}
pub fn check_silicon_installed() -> bool {
true
}
#[cfg(test)]
mod line_geometry_test {
use super::*;
#[test]
fn test_line_geometry_matches_rendered_png() {
let dir = std::env::temp_dir().join("bat_cli_line_geometry_test");
std::fs::create_dir_all(&dir).unwrap();
let dir_str = dir.to_str().unwrap();
for font_size in [16usize, 20, 28] {
let geometry = line_geometry(Some(font_size));
let render = |n: usize, name: &str| -> (u32, u32) {
let content = (0..n)
.map(|i| format!("let line_{i} = {i};"))
.collect::<Vec<_>>()
.join("\n");
let path = create_figure(&content, dir_str, name, 1, Some(font_size), true);
let dims = image::image_dimensions(&path).unwrap();
std::fs::remove_file(&path).unwrap();
dims
};
let (_, height_10) = render(10, &format!("probe_10_{font_size}.rs"));
let (_, height_30) = render(30, &format!("probe_30_{font_size}.rs"));
assert_eq!(
height_30 - height_10,
20 * geometry.line_height,
"line_height mismatch at font size {font_size}"
);
let expected_10 = 10 * geometry.line_height + 2 * CODE_PAD + 2 * PAD;
assert_eq!(
height_10, expected_10,
"absolute height mismatch at font size {font_size}"
);
let last_center = geometry.line_center_y(9);
assert!(last_center < height_10 - PAD, "last line center out of bounds");
let fraction = geometry.line_center_fraction(9, height_10);
assert!(
fraction > 0.0 && fraction < 1.0,
"fraction out of range: {fraction}"
);
}
}
#[test]
fn test_line_end_x_matches_rendered_png() {
let dir = std::env::temp_dir().join("bat_cli_line_end_x_test");
std::fs::create_dir_all(&dir).unwrap();
let dir_str = dir.to_str().unwrap();
let font_size = 20usize;
let offset = 1usize;
let geometry = line_geometry(Some(font_size));
let lines = vec![
"let very_long_line_to_widen_the_whole_image = compute(a, b, c, d, e);",
"let short = 1;",
"self.rewarder.accrue(account, shares);",
"",
];
let content = lines.join("\n");
let path = create_figure(&content, dir_str, "line_end_x.rs", offset, Some(font_size), true);
let img = image::open(&path).unwrap().to_rgba8();
let (width, _height) = img.dimensions();
for (line_index, line_text) in lines.iter().enumerate() {
if line_text.is_empty() {
continue;
}
let expected = line_end_x(Some(font_size), true, lines.len(), offset, line_text);
let top = geometry.first_line_y + line_index as u32 * geometry.line_height;
let mut measured = 0u32;
for y in top..(top + geometry.line_height) {
for x in (0..width).rev() {
if img.get_pixel(x, y) != &BG {
measured = measured.max(x);
break;
}
}
}
let char_width = line_end_x(Some(font_size), true, lines.len(), offset, "a")
- line_end_x(Some(font_size), true, lines.len(), offset, "");
let delta = expected as i64 - measured as i64;
assert!(
delta >= 0 && delta <= char_width as i64,
"line {line_index} ({line_text:?}): predicted end x {expected}, \
measured {measured}, char width {char_width}"
);
}
std::fs::remove_file(&path).unwrap();
}
}
#[cfg(test)]
mod trace_test {
use super::*;
fn local(name: &str) -> TracedName {
TracedName { name: name.to_string(), kind: TraceKind::Local, color: 0, dotted: false }
}
#[test]
fn a_name_in_a_comment_is_not_marked() {
let content = "// path.sol\n\n // the pair band bounds it\n uint b = band;";
let rects = traced_rects(content, &[local("band")], Some(20), true, 0);
assert_eq!(rects.len(), 1, "only the use on the last line");
}
#[test]
fn a_named_return_carries_both_marks() {
let content = "// path.sol\n\n p = 1;";
let name = |kind| TracedName { name: "p".to_string(), kind, color: 0, dotted: false };
for kind in [TraceKind::Parameter, TraceKind::Local, TraceKind::NamedReturn] {
let rects = traced_rects(content, &[name(kind)], Some(20), true, 0);
assert_eq!(rects.len(), 1);
assert_eq!(rects[0].kind, kind);
}
}
#[test]
fn green_is_only_in_the_palette_that_has_a_background() {
assert!(palette(TraceKind::Parameter).contains(&"#69ff94"));
assert!(!palette(TraceKind::Local).contains(&"#69ff94"));
assert!(!palette(TraceKind::NamedReturn).contains(&"#69ff94"));
}
#[test]
fn a_rect_is_produced_per_occurrence_and_lines_up_with_the_text() {
let content = "// path.sol\n\nuint a = b + amountIn;\nx = amountIn * amountIn;";
let rects = traced_rects(content, &[local("amountIn")], Some(20), true, 0);
assert_eq!(rects.len(), 3, "one on line 3, two on line 4");
let geometry = line_geometry(Some(20));
assert_eq!(rects[0].y, geometry.first_line_y + 2 * geometry.line_height);
assert!(rects[0].width > 0 && rects[0].height == geometry.line_height);
assert!(rects[2].x > rects[1].x);
assert_eq!(rects[1].y, rects[2].y, "same line, same row");
}
#[test]
fn a_struct_field_key_is_not_marked() {
let content = "// path.sol\n\n amountIn: amountIn,";
let rects = traced_rects(content, &[local("amountIn")], Some(20), true, 0);
assert_eq!(rects.len(), 1, "only the value on the right is a use");
}
#[test]
fn only_code_is_marked() {
let cases = [
(" // the pair band bounds it\n uint a = band;", 1, "line comment"),
(" /* band is\n the band */\n uint a = band;", 1, "block comment"),
(" revert(\"band too wide\");\n uint a = band;", 1, "string"),
(" uint a = band; // band again", 1, "trailing comment"),
(" uint a = band + band;", 2, "plain code"),
];
for (code, expected, what) in cases {
let content = format!("// path.sol\n\n{code}");
let rects = traced_rects(&content, &[local("band")], Some(20), true, 0);
assert_eq!(rects.len(), expected, "{what}: {rects:?}", rects = rects.len());
}
}
#[test]
fn a_member_is_not_the_variable() {
let content = "// path.sol\n\n p.poolAsset = poolAsset;";
let rects = traced_rects(content, &[local("poolAsset")], Some(20), true, 0);
assert_eq!(rects.len(), 1, "only the value on the right");
}
#[test]
fn a_yul_assignment_is_not_a_field_key() {
let content = "// path.sol\n\n pool := create2(0, p, n, salt)";
let rects = traced_rects(content, &[local("pool")], Some(20), true, 0);
assert_eq!(rects.len(), 1, "the assignment is the use that matters");
}
#[test]
fn a_ternary_is_not_mistaken_for_a_field_key() {
let content = "// path.sol\n\n uint a = x > y ? amountIn : other;";
let rects = traced_rects(content, &[local("amountIn")], Some(20), true, 0);
assert_eq!(rects.len(), 1);
}
#[test]
fn nothing_is_painted_when_nothing_is_traced() {
assert!(traced_rects("uint a = b;", &[], Some(20), true, 0).is_empty());
}
#[test]
fn a_traced_name_matches_whole_words_only() {
assert_eq!(find_word("uint256 supply", "p"), None);
assert_eq!(find_word("p.from = x", "p"), Some(0));
assert_eq!(find_word("$.loans[p.from]", "$"), Some(0));
assert_eq!(find_word("cin.token", "token"), Some(4));
assert_eq!(find_word("maxSwapNotional", "Swap"), None);
}
}