package windtrap

  1. Overview
  2. Docs

Source file windtrap_coverage.ml

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
(*---------------------------------------------------------------------------
   Copyright (c) 2026 Invariant Systems. All rights reserved.
   Portions adapted from Bisect_ppx (MIT license).
   SPDX-License-Identifier: ISC
  ---------------------------------------------------------------------------*)

(* ───── Types ───── *)

type instrumented_file = {
  filename : string;
  points : int array;
  counts : int array;
}

type coverage = (string, instrumented_file) Hashtbl.t

let file_identifier = "WINDTRAP-COVERAGE-1"

(* ───── Serialization ───── *)

let write_int buffer i =
  Buffer.add_char buffer ' ';
  Buffer.add_string buffer (string_of_int i)

let write_string buffer s =
  Buffer.add_char buffer ' ';
  Buffer.add_string buffer (string_of_int (String.length s));
  Buffer.add_char buffer ' ';
  Buffer.add_string buffer s

let write_array write_element buffer a =
  Buffer.add_char buffer ' ';
  Buffer.add_string buffer (string_of_int (Array.length a));
  Array.iter (write_element buffer) a

let write_list write_element buffer l =
  Buffer.add_char buffer ' ';
  Buffer.add_string buffer (string_of_int (List.length l));
  List.iter (write_element buffer) l

let write_instrumented_file buffer { filename; points; counts } =
  write_string buffer filename;
  write_array write_int buffer points;
  write_array write_int buffer counts

let serialize_files files =
  let buffer = Buffer.create 4096 in
  Buffer.add_string buffer file_identifier;
  write_list write_instrumented_file buffer files;
  Buffer.contents buffer

let values tbl = Hashtbl.fold (fun _ file acc -> file :: acc) tbl []
let serialize coverage = serialize_files (values coverage)

(* ───── Deserialization ───── *)

let junk channel = try ignore (input_char channel) with End_of_file -> ()

let read_int buffer channel =
  Buffer.clear buffer;
  let rec loop () =
    match input_char channel with
    | exception End_of_file -> ()
    | ' ' -> ()
    | c ->
        Buffer.add_char buffer c;
        loop ()
  in
  loop ();
  int_of_string (Buffer.contents buffer)

let read_string buffer channel =
  let length = read_int buffer channel in
  let s = really_input_string channel length in
  junk channel;
  s

let read_array read_element buffer channel =
  let length = read_int buffer channel in
  Array.init length (fun _ -> read_element buffer channel)

let read_list read_element buffer channel =
  read_array read_element buffer channel |> Array.to_list

let read_instrumented_file buffer channel =
  let filename = read_string buffer channel in
  let points = read_array read_int buffer channel in
  let counts = read_array read_int buffer channel in
  if Array.length points <> Array.length counts then
    failwith "points/counts length mismatch";
  { filename; points; counts }

let read_file path =
  let channel = try Some (open_in_bin path) with Sys_error _ -> None in
  match channel with
  | None -> []
  | Some channel -> (
      try
        let id_len = String.length file_identifier in
        let magic = really_input_string channel id_len in
        if magic <> file_identifier then begin
          close_in_noerr channel;
          []
        end
        else begin
          junk channel;
          let buffer = Buffer.create 4096 in
          let result = read_list read_instrumented_file buffer channel in
          close_in_noerr channel;
          result
        end
      with
      | End_of_file | Failure _ ->
          close_in_noerr channel;
          []
      | exn ->
          close_in_noerr channel;
          raise exn)

(* ───── Accumulated Visit Counts ───── *)

let coverage : coverage Lazy.t = lazy (Hashtbl.create 17)
let data () = Lazy.force coverage

let reset_counters () =
  Hashtbl.iter
    (fun _ { counts; _ } ->
      let n = Array.length counts in
      if n > 0 then Array.fill counts 0 n 0)
    (data ())

(* ───── File Output ───── *)

let prng = Random.State.make_self_init ()

let random_filename ~prefix =
  prefix
  ^ string_of_int (abs (Random.State.int prng 1_000_000_000))
  ^ ".coverage"

let default_coverage_file = ref "windtrap"
let default_log_file = ref "windtrap-coverage.log"
let set_output_prefix path = default_coverage_file := path

let full_path fname =
  if Filename.is_implicit fname then
    Filename.concat Filename.current_dir_name fname
  else fname

let rec mkdir_p d =
  if d = "" || d = "." then ()
  else if Sys.file_exists d then ()
  else begin
    mkdir_p (Filename.dirname d);
    try Unix.mkdir d 0o770 with Unix.Unix_error (Unix.EEXIST, _, _) -> ()
  end

let log_error =
  lazy
    (match Sys.getenv_opt "WINDTRAP_COVERAGE_LOG" with
    | Some s when String.uppercase_ascii s = "SILENT" -> fun _ -> ()
    | Some s when String.uppercase_ascii s = "STDERR" ->
        fun msg -> prerr_endline (" *** " ^ msg)
    | opt ->
        let path = match opt with Some p -> p | None -> !default_log_file in
        let oc =
          lazy
            (let oc = open_out_bin (full_path path) in
             at_exit (fun () -> close_out_noerr oc);
             oc)
        in
        fun msg -> Printf.fprintf (Lazy.force oc) " *** %s\n" msg)

let log_error msg = (Lazy.force log_error) msg

let file_channel () =
  let prefix =
    full_path
      (match Sys.getenv_opt "WINDTRAP_COVERAGE_FILE" with
      | Some p -> p
      | None -> !default_coverage_file)
  in
  let dir = Filename.dirname prefix in
  mkdir_p dir;
  let rec create_file () =
    let filename = random_filename ~prefix in
    try
      let fd = Unix.(openfile filename [ O_WRONLY; O_CREAT; O_EXCL ] 0o644) in
      let channel = Unix.out_channel_of_descr fd in
      Some channel
    with
    | Unix.Unix_error (Unix.EEXIST, _, _) -> create_file ()
    | Unix.Unix_error (code, _, _) ->
        log_error
          (Printf.sprintf "Unable to create coverage file: %s: %s"
             (Unix.error_message code) filename);
        None
  in
  create_file ()

let dump () =
  let cov = data () in
  match values cov with
  | [] -> ()
  | files -> (
      match file_channel () with
      | None -> ()
      | Some channel ->
          (try
             output_string channel (serialize_files files);
             flush channel
           with _ -> log_error "Unable to write coverage file");
          close_out_noerr channel)

let coverage_written = ref false

let dump_at_exit () =
  if not !coverage_written then begin
    coverage_written := true;
    dump ()
  end

let register_dump : unit Lazy.t = lazy (at_exit dump_at_exit)

(* ───── Merging ───── *)

let saturating_add x y = if x > max_int - y then max_int else x + y

let elementwise_saturating_add xs ys =
  let longer, shorter =
    if Array.length xs >= Array.length ys then (xs, ys) else (ys, xs)
  in
  let result = Array.copy longer in
  shorter |> Array.iteri (fun i v -> result.(i) <- saturating_add v result.(i));
  result

let merge tbl files =
  List.iter
    (fun (file : instrumented_file) ->
      match Hashtbl.find_opt tbl file.filename with
      | None -> Hashtbl.replace tbl file.filename file
      | Some prev ->
          let counts = elementwise_saturating_add file.counts prev.counts in
          Hashtbl.replace tbl file.filename { prev with counts })
    files

(* ───── Reporting ───── *)

type summary = { visited : int; total : int }

type file_report = {
  filename : string;
  summary : summary;
  uncovered_offsets : int list;
  uncovered_lines : int list;
  source_available : bool;
}

let file_summary { counts; _ } =
  let total = Array.length counts in
  let visited =
    Array.fold_left (fun acc c -> if c > 0 then acc + 1 else acc) 0 counts
  in
  { visited; total }

let summarize cov =
  Hashtbl.fold
    (fun _ file acc ->
      let s = file_summary file in
      { visited = acc.visited + s.visited; total = acc.total + s.total })
    cov { visited = 0; total = 0 }

let summarize_per_file cov =
  Hashtbl.fold
    (fun _ (file : instrumented_file) acc ->
      (file.filename, file_summary file) :: acc)
    cov []
  |> List.sort (fun (a, _) (b, _) -> String.compare a b)

let percentage { visited; total } =
  if total > 0 then float_of_int visited *. 100. /. float_of_int total else 100.

let coverage_style s =
  let pct = percentage s in
  if pct >= 80. then `Green else if pct >= 60. then `Yellow else `Red

let uncovered_offsets { points; counts; _ } =
  let n = min (Array.length points) (Array.length counts) in
  let rec loop i acc =
    if i = n then List.rev acc
    else
      let acc = if counts.(i) = 0 then points.(i) :: acc else acc in
      loop (i + 1) acc
  in
  loop 0 []

let normalize_source_paths = function
  | [] -> [ Filename.current_dir_name ]
  | xs -> xs

let dedup_preserving_order paths =
  let rec loop seen acc = function
    | [] -> List.rev acc
    | path :: rest when List.mem path seen -> loop seen acc rest
    | path :: rest -> loop (path :: seen) (path :: acc) rest
  in
  loop [] [] paths

let source_candidates ~source_paths filename =
  filename
  :: List.map
       (fun source_path -> Filename.concat source_path filename)
       source_paths
  |> dedup_preserving_order

let with_open_in_bin path f =
  let channel = open_in_bin path in
  Fun.protect ~finally:(fun () -> close_in_noerr channel) (fun () -> f channel)

let read_file_contents path =
  try
    Some
      (with_open_in_bin path (fun channel ->
           really_input_string channel (in_channel_length channel)))
  with Sys_error _ -> None

let find_readable_source ~source_paths filename =
  source_candidates ~source_paths filename
  |> List.find_map (fun path ->
      if Sys.file_exists path then
        match read_file_contents path with
        | Some source -> Some source
        | None -> None
      else None)

let line_numbers_of_offsets source offsets =
  let offsets =
    offsets
    |> List.filter (fun offset -> offset >= 0)
    |> List.sort_uniq Int.compare
  in
  let length = String.length source in
  let rec advance pos line target =
    if pos >= target then (pos, line)
    else
      let line = if source.[pos] = '\n' then line + 1 else line in
      advance (pos + 1) line target
  in
  let rec loop pos line offsets acc =
    match offsets with
    | [] -> List.rev acc
    | offset :: rest ->
        let target = if offset <= length then offset else length in
        let pos, line = advance pos line target in
        loop pos line rest (line :: acc)
  in
  loop 0 1 offsets [] |> List.sort_uniq Int.compare

let file_report ~source_paths file =
  let summary = file_summary file in
  let uncovered_offsets = uncovered_offsets file in
  match find_readable_source ~source_paths file.filename with
  | None ->
      {
        filename = file.filename;
        summary;
        uncovered_offsets;
        uncovered_lines = [];
        source_available = false;
      }
  | Some source ->
      {
        filename = file.filename;
        summary;
        uncovered_offsets;
        uncovered_lines = line_numbers_of_offsets source uncovered_offsets;
        source_available = true;
      }

let reports ?(source_paths = []) cov =
  let source_paths = normalize_source_paths source_paths in
  Hashtbl.fold (fun _ file acc -> file_report ~source_paths file :: acc) cov []
  |> List.sort (fun a b -> String.compare a.filename b.filename)

let collapse_ranges lines =
  let rec loop acc range_start range_end = function
    | [] -> List.rev ((range_start, range_end) :: acc)
    | line :: rest ->
        if line = range_end + 1 then loop acc range_start line rest
        else loop ((range_start, range_end) :: acc) line line rest
  in
  match lines with [] -> [] | first :: rest -> loop [] first first rest

let format_ranges ranges =
  ranges
  |> List.map (fun (s, e) ->
      if s = e then string_of_int s else Printf.sprintf "%d-%d" s e)
  |> String.concat ", "

(* ───── Terminal Coloring ───── *)

let is_tty = lazy (Unix.isatty Unix.stdout)

let ansi_of_coverage_style = function
  | `Green -> "\027[32m"
  | `Yellow -> "\027[33m"
  | `Red -> "\027[31m"

let colorize s style =
  if Lazy.force is_tty then ansi_of_coverage_style style ^ s ^ "\027[0m" else s

let bold s = if Lazy.force is_tty then "\027[1m" ^ s ^ "\027[0m" else s
let dim s = if Lazy.force is_tty then "\027[2m" ^ s ^ "\027[0m" else s

(* ───── Summary Printing ───── *)

let print_summary ~per_file ~skip_covered ?(source_paths = []) cov =
  let fmt_pct s =
    let pct = percentage s in
    Printf.sprintf "%6.2f%%" pct
  in
  let overall = summarize cov in
  if per_file then begin
    let file_reports =
      let source_paths = normalize_source_paths source_paths in
      reports ~source_paths cov
    in
    let file_reports =
      if skip_covered then
        List.filter (fun r -> r.summary.visited < r.summary.total) file_reports
      else file_reports
    in
    let digits i = String.length (string_of_int i) in
    let vw =
      List.fold_left
        (fun m r -> max m (digits r.summary.visited))
        (digits overall.visited) file_reports
    in
    let tw =
      List.fold_left
        (fun m r -> max m (digits r.summary.total))
        (digits overall.total) file_reports
    in
    let pct_width = 7 in
    let indent = String.make (pct_width + 3 + vw + 3 + tw + 3) ' ' in
    List.iter
      (fun r ->
        let pct_str = colorize (fmt_pct r.summary) (coverage_style r.summary) in
        Printf.printf "%s   %*d / %-*d   %s\n" pct_str vw r.summary.visited tw
          r.summary.total r.filename;
        if r.uncovered_lines <> [] then begin
          let ranges = collapse_ranges r.uncovered_lines in
          Printf.printf "%s%s\n" indent
            (dim (Printf.sprintf "Lines: %s" (format_ranges ranges)))
        end)
      file_reports;
    let rule_width = pct_width + 3 + vw + 3 + tw + 3 + 5 in
    Printf.printf "%s\n" (String.make rule_width '-');
    let pct_str = colorize (fmt_pct overall) (coverage_style overall) in
    Printf.printf "%s   %d / %d   Total\n%!" pct_str overall.visited
      overall.total
  end
  else begin
    let pct_str =
      colorize
        (Printf.sprintf "%.2f%%" (percentage overall))
        (coverage_style overall)
    in
    Printf.printf "Coverage: %d/%d (%s)\n%!" overall.visited overall.total
      pct_str
  end

(* ───── Source Snippet Printing ───── *)

let print_uncovered ?(context = 1) ?(source_paths = []) cov =
  let source_paths = normalize_source_paths source_paths in
  let file_reports = reports ~source_paths cov in
  let file_reports =
    List.filter
      (fun r -> r.uncovered_lines <> [] && r.source_available)
      file_reports
  in
  let is_first = ref true in
  List.iter
    (fun r ->
      match find_readable_source ~source_paths r.filename with
      | None -> ()
      | Some source ->
          let lines = String.split_on_char '\n' source in
          let lines = Array.of_list lines in
          let total_lines = Array.length lines in
          let uncovered_set =
            let tbl = Hashtbl.create (List.length r.uncovered_lines) in
            List.iter (fun l -> Hashtbl.replace tbl l ()) r.uncovered_lines;
            tbl
          in
          let ranges = collapse_ranges r.uncovered_lines in
          if not !is_first then print_newline ();
          is_first := false;
          let pct = percentage r.summary in
          Printf.printf "%s (%s, %d/%d)\n" (bold r.filename)
            (colorize (Printf.sprintf "%.2f%%" pct) (coverage_style r.summary))
            r.summary.visited r.summary.total;
          let line_num_width =
            List.fold_left
              (fun m (_, e) ->
                max m
                  (String.length
                     (string_of_int (min total_lines (e + context)))))
              1 ranges
          in
          let is_first_region = ref true in
          List.iter
            (fun (range_start, range_end) ->
              let ctx_start = max 1 (range_start - context) in
              let ctx_end = min total_lines (range_end + context) in
              if not !is_first_region then
                Printf.printf "%s\n"
                  (dim (String.make (line_num_width + 4) '.'));
              is_first_region := false;
              for line = ctx_start to ctx_end do
                let text =
                  if line <= total_lines then lines.(line - 1) else ""
                in
                if Hashtbl.mem uncovered_set line then
                  Printf.printf "%s %*d | %s\n" (colorize ">" `Red)
                    line_num_width line (colorize text `Red)
                else Printf.printf "  %*d | %s\n" line_num_width line (dim text)
              done)
            ranges)
    file_reports;
  let overall = summarize cov in
  if file_reports <> [] then print_newline ();
  let pct_str =
    colorize
      (Printf.sprintf "%.2f%%" (percentage overall))
      (coverage_style overall)
  in
  Printf.printf "Coverage: %d/%d (%s)\n%!" overall.visited overall.total pct_str

(* ───── Registration ───── *)

let register_file ~filename ~points =
  Lazy.force register_dump;
  let coverage = data () in
  let counts =
    match Hashtbl.find_opt coverage filename with
    | Some existing -> existing.counts
    | None ->
        let counts = Array.make (Array.length points) 0 in
        Hashtbl.add coverage filename { filename; points; counts };
        counts
  in
  `Visit
    (fun index ->
      let current_count = counts.(index) in
      if current_count < max_int then counts.(index) <- current_count + 1)