bat-cli 0.26.40

Blockchain Auditor Toolkit (BAT)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
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;

/// Syntax definitions and themes, loaded once.
///
/// `HighlightingAssets::new()` reads and decodes the bundled syntax and theme
/// dumps every time it is called. Doing that per screenshot dominated the
/// render phase of a deployment, which produces dozens of them.
static ASSETS: Lazy<HighlightingAssets> = Lazy::new(HighlightingAssets::new);

/// Dracula background color.
const BG: image::Rgba<u8> = image::Rgba([0x28, 0x2a, 0x36, 0xff]);

/// Default font size when none is specified.
const DEFAULT_FONT_SIZE: f32 = 20.0;

/// Horizontal and vertical padding around the code image.
const PAD: u32 = 10;

/// silicon's `ImageFormatter::line_pad` default (see `ImageFormatterBuilder`).
const LINE_PAD: u32 = 2;

/// silicon's `ImageFormatter::code_pad`, the gap between the image border and
/// the first line of code.
const CODE_PAD: u32 = 25;

/// silicon's `ImageFormatter::line_number_pad`, applied on both sides of the
/// line number gutter.
const LINE_NUMBER_PAD: u32 = 6;

/// Tab width passed to the `ImageFormatterBuilder` in [`create_figure`].
const TAB_WIDTH: usize = 4;

/// Vertical geometry of a rendered screenshot, in PNG pixels.
///
/// silicon draws line `i` (0-based) at `y = i * line_height + code_pad + code_pad_top`
/// (`ImageFormatter::get_line_y`), and the `ShadowAdder` then offsets the whole
/// image by `pad_vert`. We build with `window_controls(false)` and no window
/// title, so `code_pad_top` is 0 and the origin of the first line is simply
/// `PAD + CODE_PAD`.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct LineGeometry {
    /// Distance from the top of the PNG to the top of line 0.
    pub first_line_y: u32,
    /// Distance between the tops of two consecutive lines.
    pub line_height: u32,
}

impl LineGeometry {
    /// Y coordinate, in PNG pixels, of the vertical center of line `line_index`
    /// (0-based, counting every line of the rendered content).
    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
    }

    /// Vertical center of `line_index` as a fraction (0.0 = top, 1.0 = bottom)
    /// of a PNG that is `image_height_px` tall. Clamped to the image bounds so a
    /// call site outside the captured range still yields a usable anchor.
    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)
    }
}

/// X coordinate, in PNG pixels, just past the last character of `line_text`.
///
/// Mirrors silicon's `create_drawables`: text starts at `get_left_pad()` (which
/// is `code_pad` plus the line number gutter when line numbers are on) and
/// advances by `FontCollection::get_text_len`. Tabs are expanded first, exactly
/// as silicon does. The `ShadowAdder` offset (`PAD`) is added on top.
///
/// `total_lines` and `line_offset` are needed because the width of the line
/// number gutter depends on how many digits the largest line number has —
/// silicon computes it as `floor(log10(total_lines + line_offset)) + 1`.
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)
}

/// Vertical geometry of a screenshot rendered by [`create_figure`] at `font_size`.
///
/// Depends only on the font metrics, so it can be computed before (or without)
/// rendering anything.
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,
    }
}

/// bat-cli's palette for everything drawn ON the board: the arrows between screenshots,
/// the cards, the tints. Its job is to separate shapes from each other against white.
pub const BAT_PALETTE: &[&str] = &[
    "#2d9bf0", "#f24726", "#8fd14f", "#fac710", "#a259ff", "#12cdd4", "#ff8c00", "#e6007a",
];

/// Colours for tracing a name INSIDE a screenshot. A reader cannot click an identifier on
/// a PNG the way they can in an editor, so each traced name is painted in its own colour
/// wherever it appears and the signature becomes the legend: read `address assetIn` in
/// salmon, then sweep the body for salmon.
///
/// Deliberately NOT `BAT_PALETTE`, and the difference is not cosmetic: the two palettes
/// answer different questions. On the board a colour has to separate one arrow from the
/// next against white. Inside a screenshot it has to stand out from a syntax theme that
/// already uses green for calls, orange for types and yellow for fields — `BAT_PALETTE`
/// was tried there and three of its eight colours were lost in the highlighting.
///
/// These are Dracula's own BRIGHT variants: built for `#282a36`, and distinct from each
/// other at the low alpha a mark is drawn with.
///
/// Green is in the list, which it could not be while the TEXT was being recoloured — green
/// is what the theme gives function names, and a Solidity body is mostly calls. Marking the
/// background instead of the glyphs took that constraint away, and the extra slots are what
/// let local variables be followed at all: a function with five parameters would otherwise
/// spend the whole palette before reaching them.
pub const TRACE_COLORS: &[&str] = &[
    "#ff6e6e", "#69ff94", "#d6acff", "#ffffa5", "#a4ffff", "#ff92df", "#ffb86c", "#8be9fd",
];

/// The same palette for a name marked with a RULE instead of a background — minus green.
///
/// A parameter is unmistakable whatever its hue, because it sits on a block of colour. An
/// underlined name is recognised by its glyphs alone, and green is what the theme gives
/// function names in a body that is mostly calls: `cin` in green was hunted among thirty
/// others. The background is what buys the extra colour, so only the kind that has one
/// keeps it.
pub const UNDERLINED_TRACE_COLORS: &[&str] = &[
    "#ff6e6e", "#d6acff", "#ffffa5", "#a4ffff", "#ff92df", "#ffb86c", "#8be9fd",
];

/// The palette a mark of this kind draws from.
pub fn palette(kind: TraceKind) -> &'static [&'static str] {
    match kind {
        TraceKind::Parameter => TRACE_COLORS,
        // Both are underlined, so neither can lean on a background to survive green.
        TraceKind::Local | TraceKind::NamedReturn => UNDERLINED_TRACE_COLORS,
    }
}

/// How many times `name` appears in `text` as a WHOLE word. Ranking by `str::matches`
/// instead counts `f` inside `if` and `feeWad`, which put one-letter names at the top of
/// every function.
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
}

/// `name` as a WHOLE word: `p` must not match the `p` inside `supply`, and `from` must not
/// match `p.from`'s field when the traced name is the variable `from` — a word boundary is
/// anything that cannot be part of a Solidity identifier.
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
}

/// What kind of name a mark stands for. The decoration says which, so the SAME colour can
/// serve one of each: eight colours become sixteen distinguishable marks.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TraceKind {
    /// What the caller chose. Marked with a background behind the text.
    Parameter,
    /// What this function made of it. Marked with a rule under the text.
    Local,
    /// What the function hands back — `returns (Plan memory p)`. Carries BOTH marks,
    /// because it is the one name a reader wants to recognise without working out which
    /// register it belongs to: it is the answer the whole screenshot is building.
    NamedReturn,
}

/// A name to follow through a screenshot: how to mark it, and in which colour.
#[derive(Debug, Clone)]
pub struct TracedName {
    pub name: String,
    pub kind: TraceKind,
    /// Index into this kind's palette. Assigned by the caller, because which names may
    /// share a colour is a question about the function, not about drawing.
    pub color: usize,
    /// Set for a name that had to reuse a colour: its rule is drawn broken instead of
    /// solid, which doubles how many names a kind can carry. A function with eleven locals
    /// in a palette of seven otherwise left four of them unmarked — and the ones it dropped
    /// were the ones worth following, since the ranking favours a loop counter.
    pub dotted: bool,
}

/// One occurrence of a traced name in the rendered text: which line, where in it, and how
/// long. Found once and used by every pass, so the glyph recolouring and the decoration can
/// never disagree about what is marked.
struct Occurrence {
    row: usize,
    at: usize,
    len: usize,
    color: usize,
    kind: TraceKind,
    dotted: bool,
}

/// Every occurrence of every traced name, in the EXPANDED text of each line (tabs already
/// turned into spaces, exactly as silicon draws them).
///
/// Each name carries the colour it was given.
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;
                }
                // Positions are reported in the EXPANDED text, because that is what silicon
                // draws: each tab before this point is already four spaces wide there.
                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
}

/// Where each traced name sits in the rendered PNG: one rectangle per occurrence, in
/// pixels, with the index of the colour it is traced in.
///
/// Mirrors silicon's own layout — text starts at `get_left_pad()` and advances by
/// `FontCollection::get_text_len`, tabs expanded first — which is the same arithmetic
/// `line_end_x` uses to anchor a connector on a token. A monospaced font would let us
/// multiply by a character width; measuring the prefix instead is what keeps this correct
/// if the font ever changes.
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]),
                // `first_line_y` already carries the ShadowAdder's padding.
                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()
}

/// One mark: where it goes, what colour it is, and which kind of name it stands for.
struct TracedRect {
    x: u32,
    y: u32,
    width: u32,
    height: u32,
    color: usize,
    kind: TraceKind,
    dotted: bool,
}

/// Whether this occurrence is a MEMBER of something else rather than the variable itself.
///
/// `p.poolAsset = poolAsset` assigns the parameter to a field that happens to share its
/// name, and marking the field says the value came from itself. A member is always written
/// after a dot; a variable never is. (The exact version of this reads the AST, which calls
/// one a `Member` and the other an `Ident` — this rule agrees with it on everything except
/// a dot separated from its name by whitespace.)
fn is_member(line: &str, at: usize) -> bool {
    line[..at].trim_end().ends_with('.')
}

/// The byte ranges of each line that are CODE — everything outside a comment or a string
/// literal.
///
/// A name is only a use of a variable where the compiler would read it as one. Prose
/// mentions it ("the pair band bounds the whole concession"), and so does a message
/// (`revert("amountIn too large")`); neither is the variable. Scanning once for the three
/// things that suspend code — `//` to end of line, `/* */` across lines, and a quoted
/// string — covers them together, instead of a rule per case.
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
}

/// Whether this occurrence is a struct literal's FIELD NAME rather than a use of the
/// variable — `amountIn: amountIn` names the field on the left and passes the variable on
/// the right, and marking both says the value flows into itself.
///
/// A field key is the first thing on its line and is followed by a colon. A ternary's
/// colon also follows a value, which is why the rule is anchored at the start of the line:
/// `a ? b : c` never has `b` there.
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();
    // `:` and not `:=`. In Yul an assignment is written `pool := create2(…)`, which starts
    // its line and is followed by a colon exactly like a field key — and it is the single
    // place a named return is given its value, so dropping it hid the one line that matters.
    let followed_by_colon = after.starts_with(':') && !after.starts_with(":=");
    starts_the_line && followed_by_colon
}

/// Paint each traced occurrence's own colour BEHIND it, like a marker pen.
///
/// The foreground is left to the syntax theme on purpose. The theme already spends every
/// hue it has — green on calls, orange on types, yellow on fields, pink on keywords — so
/// recolouring an identifier makes it compete with that; the background is the one register
/// nothing else uses. It is also what an editor does when you click a name.
///
/// Drawn OVER the finished image at low alpha rather than under the text, because silicon
/// composes the text itself and ignores a span's background (`formatter.rs`, which reads
/// only `style.foreground`).
fn paint_traces(image: &mut image::DynamicImage, rects: &[TracedRect]) {
    use image::GenericImageView;
    /// How much of the mark's colour a parameter's background carries. Enough to find by
    /// sweeping, light enough to read the code through.
    const ALPHA: f32 = 0.30;
    /// Thickness of a local's rule, in pixels.
    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) {
            // The decoration says which KIND of name this is, and that is what lets the two
            // kinds share a colour: a parameter sits on a block of it, a local carries a
            // rule under it. Eight colours, sixteen marks that cannot be confused.
            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) {
                // A reused colour draws its rule broken. The gaps are what tell two names
                // on one hue apart, the same way a dotted connector tells two arrows apart
                // when a gutter runs out of colours.
                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);
}

/// Repaint the glyphs of every traced name in its own colour, splitting the highlighter's
/// spans where it has to. Both kinds are recoloured — the decoration drawn afterwards is
/// what tells a parameter from a local.
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();
        // Position of the span's start within the line, so a spot found on the whole line
        // can be mapped back into the span that contains it.
        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,
        &[],
    )
}

/// `create_figure`, plus the names to mark through the code, each in its own colour.
#[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"];

    // Syntax-highlight every line.
    // Detect language from file_name extension, default to Rust.
    let ext = file_name.rsplit('.').next().unwrap_or("rs");
    let syntax = match ext {
        // Solidity: use JavaScript syntax (best color match with Dracula)
        "sol" => ps
            .find_syntax_by_extension("js")
            .or_else(|| ps.find_syntax_by_extension("rs"))
            .expect("Syntax not found in syntect"),
        // For any other extension, try it directly first, fall back to Rust
        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);


    // Configure background + padding (no shadow).
    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);

    // Build the image formatter.
    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();
}

/// No longer needed — silicon is now a library dependency.
/// Kept for backwards compatibility; always returns true.
pub fn check_silicon_installed() -> bool {
    true
}

#[cfg(test)]
mod line_geometry_test {
    use super::*;

    /// Renders two figures with a known difference in line count and checks that
    /// the measured PNG geometry matches [`line_geometry`].
    ///
    /// silicon's height is `n_lines * line_height + 2 * CODE_PAD + 2 * PAD`
    /// (`get_image_size` uses `get_line_y(max_lineno + 1) + code_pad`, and
    /// `max_lineno` is `n_lines - 1`), so the height delta between an `n` and an
    /// `n + k` line render is exactly `k * line_height`.
    #[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"));

            // 20 extra lines must add exactly 20 line heights.
            assert_eq!(
                height_30 - height_10,
                20 * geometry.line_height,
                "line_height mismatch at font size {font_size}"
            );

            // And the absolute height must match the closed form.
            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}"
            );

            // The last line's center must land inside the image, above the bottom pad.
            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}"
            );
        }
    }

    /// Checks [`line_end_x`] against the actual pixels: renders a figure, scans
    /// the rows belonging to a known line, and finds the rightmost pixel that is
    /// not the Dracula background.
    #[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));

        // A long line first so the image is wider than the line we measure.
        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);

            // Scan every pixel row of this line and keep the rightmost non-background one.
            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;
                    }
                }
            }

            // `line_end_x` returns the pen advance after the last character, so it
            // always sits at or slightly past the last inked pixel — the gap is the
            // glyph's right side bearing, strictly less than one character width.
            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 }
    }

    /// A name inside a comment is prose, not a use: "the pair band bounds the whole
    /// concession" talks about `band`, it does not read it.
    #[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");
    }

    /// The three kinds are told apart by their decoration, so two of them may share a
    /// colour without being confusable.
    #[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);
        }
    }

    /// Only the kind with a background can afford green: the theme gives it to function
    /// names, and an underlined name has nothing else to stand on.
    #[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"));
    }

    /// The rectangles must land on the token, and a name used twice on one line must get
    /// two of them — the geometry is the same arithmetic a connector anchor uses.
    #[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);
        // The second occurrence on a line sits to the right of the first.
        assert!(rects[2].x > rects[1].x);
        assert_eq!(rects[1].y, rects[2].y, "same line, same row");
    }

    /// A struct literal's field name is not a use of the variable: `amountIn: amountIn`
    /// names the field on the left and passes the value on the right.
    #[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");
    }

    /// Prose and messages mention a name without reading it, and the three things that
    /// suspend code are handled by one scan rather than a rule each.
    #[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());
        }
    }

    /// A field that shares a parameter's name is not that parameter.
    #[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");
    }

    /// Yul assigns with `:=`, and that line is where a named return gets its value.
    #[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");
    }

    /// A ternary's colon follows a value mid-line, which must stay marked.
    #[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() {
        // `p` must not light up the `p` inside `supply`, and `from` must not light up
        // `p.from`'s field — a screenshot full of false positives is worse than none.
        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);
    }


}